Update class mechanics, stat rulesets, and CoA reference catalogs

This commit is contained in:
phenom committed 2026-10-04 12:56:30 -04:00
1 parent b16821e92e
commit 39458d5de6
221 files changed
+2584962 -568

No files matched your search

+6
View File
@@ -23,3 +23,9 @@ dist-android/
.web-public/
__pycache__/
*.pyc
# Local CoA reference downloads and immutable generated snapshots.
/data/coa-reference/cache/
/data/coa-reference/snapshots/
/data/coa-reference/current.json
/data/coa-reference/.current-*.tmp
+130
View File
@@ -4,6 +4,43 @@ 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-10-03 Selectable RoM native stats
- `scripts/playtest-stat-rulesets.cjs` passed against the final production build
in Edge with an isolated temporary offline account/server. Checked chunks:
`index-CGkT8hBz.js`, `combatStore-CVXgxk1Z.js`, and
`PartyFrames-BeW5_TCA.js`.
- Creating a RoM character through the UI selected Native RoM. A synthetic
legacy profile with its mode removed migrated to WoW compatibility. Choices
persisted independently across reloads, and switching preserved equipment,
inventory, class build, and a checkpoint created during the dungeon run.
Synthetic engaged-mob and online-activity fixtures blocked the actual menu
switch control; these guard checks do not certify a real multiplayer session.
- Both native and compatibility characters entered Wailing Caverns. The roster
preview and both dungeon captures showed full character bodies and held starter
staffs. Dungeon captures had overlays closed so bodies, equipment silhouettes,
and HUD counters could be inspected together.
- Native Attributes, Offense, and Defense tabs showed their source labels and
the shared-combat caption. HP/MP agreed with runtime pools and accessible
meters. Whole-value HP/MP display prevents fractional native caps from
overflowing HUD counters; accessible maxima and runtime pools retain precision.
- Desktop and 390-pixel menu selectors fit without label overlap or field
overflow. The narrow native Attributes panel remained readable and in bounds;
the existing owner row retains horizontal scrolling.
- No page errors, HTTP 5xx responses, or application-resource response errors
occurred. The only console response error was the optional favicon 404.
- Evidence: `playtest-artifacts/stat-rulesets/results.json` and its ten referenced
screenshots, including `01-roster-native-rom.png`,
`04-native-dungeon-gameplay-overlays-closed.png`,
`04-native-stats-{attributes,offense,defense}-desktop.png`,
`07-native-stats-attributes-mobile.png`, and
`08-wow-compatibility-dungeon-gameplay-overlays-closed.png`.
- Focused validation passed 219 tests across 22 files with four existing skips;
Ready-five passed seven Vitest and twenty Python checks, avatar validation
passed 47 tests, and the full production build passed. This verifies selectable
stat inputs and shared-runtime integration, not complete native healing,
native combat balance, or a RoM readiness promotion.
## 2026-09-30 Dungeon graphics options
- `scripts/playtest-graphics-options.cjs` entered WoW Wailing Caverns and RoM
@@ -4165,3 +4202,96 @@ Vite's existing large-chunk advisory remains informational.
another RoM character also reloaded. The one-use handoffs were consumed,
and the page reported no errors. Results and screenshots are in
`playtest-artifacts/memory-reset/`.
## Hunter pet family selection (2026-10-02)
- `scripts/playtest-hunter-families.cjs` ran against the freshly built `dist`
bundle in headless Edge. It used an ephemeral localhost server, an isolated
Playwright context, and disposable OS-temp account data. The UI created two
Marksmanship Hunters; the QA fixture then advanced their temporary profiles
to level 10 with `shellStore.saveActiveProgression` so level-10 Call Pet and
Revive Pet are eligible. This fixture does not claim normal gameplay earned
those levels.
- At Wailing Caverns entry, the actual engaged entrance pack left the rendered
family selector disabled at Wolf. The pack ended through ordinary
simulation; with no engaged mobs, the UI changed Shellguard to Turtle. The
runtime showed Turtle's sourced Shell Shield spell ID `26064` and no Wolf
Furious Howl. After page reload, Shellguard remained Turtle and the
independently UI-created Wolfguard remained Wolf. Unit/integration tests
cover selection persistence and family mechanics; this is a manual UI
diagnostic, not Hunter balance certification or Ready-party certification.
- The 390×844 HUD reported no horizontal overflow. The family card sat 37 px
below the party stack, remained in the viewport, and did not overlap the
top-right HUD. Desktop and narrow screenshots show the live Wailing Caverns
WebGL scene and are in `playtest-artifacts/hunter-families/`:
`01-wailing-entry-combat-locked-desktop.png`,
`02-wailing-entry-combat-locked-narrow.png`,
`03-turtle-family-desktop.png`, and `04-turtle-family-narrow.png`. The
current `results.json` records the measured HUD bounds. Earlier harness and
layout captures are explicitly named as excluded diagnostics and are not
final visual evidence.
- The browser reported no page errors or HTTP errors. Its only console error
was the local server's missing `/favicon.ico` (404). Call Pet and Revive Pet
were not activated in the UI: Escape reached the focused scene but did not
open the pause menu on this route. No spell activation was injected. The
current renderer has no Turtle-specific model path, so these captures verify
family card/runtime identity and HUD placement, not unique Turtle creature
art.
## Hunter Cat family and pet controls (2026-10-02)
- `scripts/playtest-hunter-cat.cjs` ran against the fresh production bundle in
headless Edge with an ephemeral localhost server, isolated Playwright
context, and disposable OS-temp account data. The UI created Catguard and
Wolfguard as Marksmanship Hunters; the isolated QA fixture advanced both to
level 10 through `shellStore.saveActiveProgression` for eligibility. That is
test setup, not earned progression.
- After the Wailing Caverns intro was shown and detached, the real entrance
group left the Wolf selector disabled with three live enemies. The group
ended naturally; after all enemies cleared, the Catguard selector enabled.
Cat was chosen through the visible selector. Reload preserved Catguard's
Cat family, while the separate Wolfguard profile remained Wolf.
- The player-owned runtime pet used generic ID `__player__:summon-1`. Its
level-10 profile exposed Claw 16828 rank 2, Rake 59881 rank 1, and Prowl
24450 rank 1, without Bite, Furious Howl, or Shell Shield. This confirms the
profile's available ranks, not live Rake or Prowl casts. Call Pet was
reached through Escape → Pause → Spellbook and activated from the rendered
Spellbook; the existing Cat remained active. Revive Pet was not attempted
because no natural pet death occurred.
- The visible Stay/Follow buttons, reaction selector, family selector, and
Growl auto checkbox all received pointer input and updated the runtime; the
tests restored Follow, Defensive, and Growl auto off. Attack was disabled
without a selected target. This pass found and fixed a pointer-layer bug:
the HUD disabled pointer events, and summon cards did not restore them. The
`.summon-frame` now accepts pointer events; the surrounding playfield remains
click-through.
- The loaded Three.js scene and Cat family card were captured at desktop and
390×844. Narrow layout had no horizontal overflow, the selector remained
visible, the card cleared party frames by 37 px, and it did not overlap the
top-right HUD. The renderer has no family-specific Cat model, so images show
the Cat card/profile identity rather than unique Cat creature art. Final
screenshots and measurements are in `playtest-artifacts/hunter-cat/` and
`results.json`.
- The browser reported no page errors or failed HTTP requests. Its only
console error was the local server's missing `/favicon.ico` (404). This is
a focused UI diagnostic, not Hunter balance or Ready-party certification.
## 2026-10-03 — CoA August catalog promotion
- Local browser check at `http://127.0.0.1:5174/` entered the offline roster,
created `Coaaudit` (Human Witch Hunter, Houndmaster), and opened Talents from
the main menu. Existing roster characters were preserved.
- The five populated tabs are Class, Boltslinger, Houndmaster, Black Knight,
and Inquisition. Selecting Houndmaster and the new Houndmaster's Whistle node
displays its icon, full description, rank, and Houndmaster prerequisite.
The level-1 character has no essence; the editor correctly prevents learning
the gated node while allowing inspection.
- The desktop screenshot shows the tree and readable details panel without
overlap: `playtest-artifacts/coa-catalog-promotion/houndmaster-whistle.jpg`.
Captured warning/error logs were empty.
- The optional local API server was not running; development proxy requests for
shared policy/layout endpoints failed and the offline UI used its defaults.
This pass does not validate online roster or server policy synchronization.
- This is a representative catalog/UI smoke check. It does not exercise an
unlocked Shadowhound, the other CoA classes' combat mechanics, or balance.
All CoA specializations remain Needs Testing.
+29
View File
@@ -0,0 +1,29 @@
# CoA offline reference
This directory holds the local-first CoA reference dataset and the evidence for
explicit active-catalog promotion. Historical reference builds and active
promotion are separate commands.
Run `npm run coa:reference:build` from the project root. The command reads the
existing Ascension client, extracted base-client DBCs, and HealerMan's July talent
snapshot. It never downloads anything or writes to those sources.
`current.json` points to a verified immutable directory under `snapshots/` with
`reference.json`, `audit.json`, `audit.md`, and a checksum manifest. Downloaded
archive supplements live under `cache/`. These generated files are local and
ignored by Git. The lossless July baseline under `baselines/`, the active
`promotion-report.json`, pipeline, tests, and usage documentation are tracked.
Use `npm run coa:reference:fetch` explicitly to cache the pinned August talent
archive, then rebuild to include its comparison. The supplement remains separate
from the primary July/local-client reference dataset.
`npm run coa:reference:fetch:spells` selectively caches missing global BASE spell
records and their linked payloads. `npm run coa:reference:promote` validates the
cached August Vol'Jin trees and local/global spell evidence, then updates the
active CoA catalog offline. Follow it with `npm run coa:catalogs:refresh` to
regenerate CoA runtime/UI shards and missing icons. Classic catalog records and
progression identities are protected; CoA readiness remains Needs Testing.
See [the pipeline guide](../../docs/COA_REFERENCE_PIPELINE.md) for source roots,
lookup examples, validation, failure handling, and version limitations.
+1
View File
@@ -0,0 +1 @@
{"candidateSha256":"9208e973c43c31d263fb429f7ab82a2cc6732e2a70bc14f482b6428c9b296091","gaps":{"missingBaseProgressionRankCount":13,"missingBaseProgressionRankIds":[501862,501863,501864,501865,501918,501919,501920,501922,501923,501924,501925,502889,560469],"missingBaseSpellCount":224,"missingBaseSpellIds":[3042,7015,7016,10238,116863,116936,117041,117066,117069,117151,117284,117490,120241,120248,120270,120614,120640,120692,120919,121208,121300,121404,122206,122296,122388,122452,122507,300070,400544,500017,500019,500025,500029,500030,500032,500037,500084,500088,500122,500145,500148,500155,500197,500332,500366,500565,500909,500921,500922,500942,500948,500949,500954,501361,501862,501863,501864,501865,501918,501919,501920,501922,501923,501924,501925,502612,502889,503215,506002,524988,555377,560137,560469,704095,704134,704140,704160,704376,704480,704507,704572,800053,800183,800232,800264,800268,800271,800312,800314,800369,800377,800383,800411,800429,800433,800445,800455,800467,800535,800570,800582,800625,800713,800718,800720,800721,800725,800745,800746,800747,800748,800777,800853,800883,800885,800888,800896,800897,800900,801239,801250,801267,801296,801298,801321,801329,801330,801408,801587,801605,801606,801647,801648,801651,801671,801673,801675,801681,801682,801684,801694,801715,801750,801762,801764,801810,801819,801822,801845,801846,801896,801937,801944,801965,801993,801995,802010,802012,802077,802116,802123,802127,802288,802565,802869,802875,802992,803691,803944,803952,803982,804006,804167,804180,804229,804255,804269,804289,804291,804352,804504,804549,804554,804555,804556,804558,804560,804562,804594,804600,804601,805481,805489,805491,805497,805623,805630,805645,805665,805713,805728,805733,805765,805772,805781,805903,806098,806107,806127,806229,806230,806238,806243,806280,806284,806287,806317,806318,806326,806362,806367,806519,806601,7055070]},"nodeChanges":{"added":[{"aeCost":0,"classId":29,"connectedNodeIds":[1788],"description":"Inject an ally with a powerful magic antidote, dispelling 2 curse effects from them.","entryType":"Talent","flags":0,"group":0,"iconBasename":"inv_ascend_potions_02","id":11788,"isPassive":false,"isStartingNode":false,"maxPoints":1,"name":"Mycelial Healing","nodeType":"SpendSquare","rankDescriptions":[{"description":"Inject an ally with a powerful magic antidote, dispelling 2 curse effects from them.","rank":1,"spellId":520153}],"requiredClassPoints":0,"requiredIds":[],"requiredLevel":0,"requiredSpecPoints":0,"sortOrder":0,"tabId":92,"teCost":1,"x":4,"y":2},{"aeCost":1,"classId":25,"connectedNodeIds":[33969],"description":"Your Devour Magic is now instant cast.","entryType":"Ability","flags":0,"group":301010,"iconBasename":"achievement_boss_triumvirate_voidbrokenbrute","id":12012,"isPassive":false,"isStartingNode":false,"maxPoints":1,"name":"Devourer","nodeType":"SpendCircle","rankDescriptions":[{"description":"Your Devour Magic is now instant cast.","rank":1,"spellId":301010}],"requiredClassPoints":9,"requiredIds":[],"requiredLevel":0,"requiredSpecPoints":0,"sortOrder":0,"tabId":87,"teCost":0,"x":2,"y":6},{"aeCost":0,"classId":25,"connectedNodeIds":[1375],"description":"Increases the duration of Psychic Suppression by 1 sec and Eldritch Devastation by 2 sec.","entryType":"Talent","flags":0,"group":301008,"iconBasename":"5_druideskill20_border","id":13957,"isPassive":false,"isStartingNode":false,"maxPoints":1,"name":"Dark Channeler","nodeType":"SpendCircle","rankDescriptions":[{"description":"Increases the duration of Psychic Suppression by 1 sec and Eldritch Devastation by 2 sec.","rank":1,"spellId":301008}],"requiredClassPoints":0,"requiredIds":[],"requiredLevel":0,"requiredSpecPoints":8,"sortOrder":0,"tabId":65,"teCost":1,"x":4,"y":7},{"aeCost":0,"classId":12,"connectedNodeIds":[6739,12739],"description":"Removes the cast time of Crush, but its damage is reduced by -25% and it no longer stuns the enemy.","entryType":"Talent","flags":0,"group":807183,"iconBasename":"inv_hammer_1h_jiro_b_01","id":14265,"isPassive":false,"isStartingNode":false,"maxPoints":1,"name":"Crusher","nodeType":"SpendCircle","rankDescriptions":[{"description":"Removes the cast time of Crush, but its damage is reduced by -25% and it no longer stuns the enemy.","rank":1,"spellId":807183}],"requiredClassPoints":0,"requiredIds":[],"requiredLevel":0,"requiredSpecPoints":8,"sortOrder":0,"tabId":31,"teCost":1,"x":6,"y":5},{"aeCost":0,"classId":15,"connectedNodeIds":[],"description":"Summons a permanent Shadowhound companion to aid you in combat. Your Shadowhound receives 25% of healing done to you.","entryType":"Talent","flags":1056772,"group":0,"iconBasename":"inv_wolfdraenormountshadow","id":17414,"isPassive":false,"isStartingNode":false,"maxPoints":1,"name":"Houndmaster's Whistle","nodeType":"SpendSquare","rankDescriptions":[{"description":"Summons a permanent Shadowhound companion to aid you in combat. Your Shadowhound receives 25% of healing done to you.","rank":1,"spellId":801343}],"requiredClassPoints":0,"requLine truncated
+31 -24
View File
@@ -1,6 +1,10 @@
# Active class behavior and spell visual audit
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.
Updated 2026-10-03. 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.
## Native stat input contract (2026-10-03)
Explicitly native RoM profiles now derive source attributes and HP/MP/ATK/DEF/MATK/MDEF/DMG/MDMG channels from local RuneWaker records, retain source healing and combat-point channels, and carry selected native gear values. Existing saved profiles keep WoW compatibility; the main-menu selector changes each character independently. This supersedes earlier findings that the native stat/input contract was entirely missing. It does not implement the full HolyHeal spell resolver, native hit/critical/avoidance consumers, matchmaking, or RoM readiness promotion. See [the implementation and compatibility boundary](CHARACTER_STAT_RULESETS_2026-10-03.md) and [the source contract](ROM_NATIVE_STATS_SOURCE_2026-10-03.md). Dated class reports preserve their original evidence and remaining per-spell/version blockers.
## Class-mechanics source precedence
@@ -11,7 +15,7 @@ Updated 2026-10-02. This is a source/runtime audit of the shipped WoW 3.3.5a and
## Reviewed scope
- WoW: the ten playable class catalogs, including ability ranks, talent-driven modifiers, and combat-effect coverage.
- RoM: the generated catalog inventory covers all ten primary class catalogs and both sides of each paired-elite row. The generated catalog currently contains 929 unique rows (587 active rows and 342 passive rows). Counting each paired row in both its primary and secondary class view gives 1,589 class-linked row associations and 587 distinct active source spell IDs. Primary and secondary role associations have been source-reviewed; executable runtime coverage is selective, as listed below.
- RoM: the generated catalog inventory covers all ten primary class catalogs and both sides of each paired-elite row. The regenerated current catalog includes the supplemental learnable Warden 499544 row and contains 932 unique skills (590 active and 342 passive) across 937 rank records; all 932 have resolved icons. The runtime catalog composes 936 ability definitions plus 937 ranked versions (1,873 entries), including four runtime-added definitions. Primary and secondary role associations have been source-reviewed, while executable runtime coverage is selective. The role-association/coverage table below is from an older 929-row snapshot taken before the Druid/Warrior 499956 and Warden 499544 additions; its class totals are historical, not current.
WoW inventory by generated catalog class:
@@ -27,7 +31,7 @@ WoW inventory by generated catalog class:
| Mage | 78 | 319 | 258 / 319 | 258 / 319 |
| Warlock | 66 | 267 | 224 / 267 | 224 / 267 |
| Druid | 74 | 318 | 283 / 318 | 280 / 318 |
- RoM rows and active-effect coverage by class (primary + secondary roles):
- RoM rows and active-effect coverage by class (primary + secondary roles; historical 929-row snapshot before 499956 and 499544 were added; not current):
| Class | Rows | Active / passive | Active rank IDs with manifest entry | Active rank IDs with original effect graph |
|---|---:|---:|---:|---:|
@@ -83,7 +87,7 @@ Manifest coverage means an effect mapping exists; a graph means an original effe
- RoM Druid/Scout Healing Arrows (494021) now selects its Nature-Power-enhanced HoT when the caster has a point, drains that point, and uses the baseline HoT at zero points. Player and companion casts follow the same branch.
- RoM Plasma Arrow (491168) impact tuning is scale `0.45` and opacity `0.32`; Electric Bolt (491169) is scale `0.6` and opacity `0.45`. These impact adjustments leave cast cues unchanged.
- RoM Warlock/Priest Ring of Comfort heals up to six nearby allies instead of damaging the selected hostile target.
- Champion Rune Pulse's initial Rage-aura mapping was later corrected to its landed-hit behavior in the follow-up below.
- Champion Rune Pulse 498529 restores five Rage on a landed hit and grants +20% outgoing physical/magical damage for one second at cast acceptance; the bonus expires before its two-second impact. See [the source follow-up](ROM_CHAMPION_SOURCE_FOLLOWUP_2026-10-02.md).
- 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.
@@ -98,8 +102,8 @@ Manifest coverage means an effect mapping exists; a graph means an original effe
- 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, 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. 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 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 source learnability is verified, but its toggle/runtime semantics and rank-2 proc chance data remain unavailable; see the focused Champion audit.
- WoW Hunter's Intimidation proc is attributed to the pet and consumes on its successful melee hit. The current player Wolf/Turtle profile and generated-companion Wolf baseline select 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. Other family abilities and 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 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).
@@ -146,16 +150,16 @@ 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. 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:** 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/Turtle player profiles implement the shared level-ranked Bite, Focus cost/regeneration, distinct special and auto attacks, Call/Revive, and Mend Pet targeting; Wolf alone adds Furious Howl. Generated companions remain Wolf, revive their 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; broader family abilities/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.
- 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. Player Hunters can now select and persist Wolf or Turtle in the active pet frame; Call/Revive retains that choice and family switching is encounter-locked. Generated companions remain Wolf. Turtle uses the existing generic pet base/combat profile plus its source-backed Shell Shield; no new Hunter-specific actor/stat baseline is implied. The extract has 40 family rows, 32 classified as Hunter families, but only the partial Wolf/Turtle profile slice is playable; full family-ability coverage, stable/feed/happiness, training, and pet critical inheritance remain open.
- 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:** 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. Wandering Plague (`talent-2003`) now resolves for player and generated-companion Death Knights on positive Blood Plague/Frost Fever ticks, using the generated 33/66/100% tick-damage ranks and owner melee-critical chance; the one-second owner cooldown is AzerothCore corroboration. The triggered splash cannot recurse and skips active break-on-damage statuses; see [the bounded runtime note](WOW_DEATH_KNIGHT_WANDERING_PLAGUE.md).
- **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
- **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.
@@ -165,26 +169,26 @@ These are specific, source-backed gaps that still need implementation or a gamep
### 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/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.
- **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. The Warrior/Druid Whirlwind row separately lists a 30% reduction for 15 seconds with a 20-second retrigger lockout, but it does not explicitly link that rider to passive 494377; do not transfer those values. See [the bounded source audit](ROM_WARRIOR_RAGE_OF_CALMNESS_SOURCE_AUDIT.md). 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. RuneWaker pet crit/dodge/damage passives remain unsupported because its non-Hunter summons lack the needed pet-specific stats or event path; this does not describe the separate WoW Hunter pet model. 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. Wind Return (499548), Revival Engraving (499552), and Forest Guidance (499575) are exact-pair learned markers only: their translated child effects no longer appear on the passive owner, while missing trigger rows and unsupported companion targeting remain open. See [the source audit](ROM_SCOUT_REMAINING_PASSIVES_AUDIT.md).
- **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 (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.
- **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. Protection Rune Seal (498613) now resets the exact actor's learned Heart Rune Energy only on a positive periodic Regenerate (490269) heal, for both player and generated companion owners. The local 8% chance remains because current CoA only says “slight chance”; the possible healing-strength rider has no sourced value and remains open. See [the focused audit](ROM_PRIEST_REMAINING_PASSIVES_AUDIT.md). 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 ofLine 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. Protection Rune Seal (498613) now handles player and generated-companion Regenerate (490269) positive periodic heals under the exact learned Priest/Champion passive and actor-owned aura. The local 8% chance remains because CoA says only “slight chance”; each success resets only that actor's learned Heart Rune Energy (498612) cooldown, with companion cooldown state synchronized. The separate possible heal-strength rider remains unsourced; see [the source audit](ROM_PRIEST_REMAINING_PASSIVES_AUDIT.md).
- **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.
- **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. Knight/Rogue Panther's Blessing (493047) now follows the current CoA same-name, same-pair passive: a resolved incoming dodge restores 10% max HP and 1% max MP. Max-pool basis and whole-point rounding are simulator interpretations because CoA does not specify either; the legacy RuneWaker +2 Dodge child is suppressed. It is distinct from God's Vengeance 499934. See [the runtime note](ROM_KNIGHT_PANTHER_DODGE_PROC_2026-10-02.md).
- **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) now uses the source-proven self-heal `maxHP × (10 + 5 × charges)%`, with normal/noncritical FixPer and no weapon, attack-speed, MagicPow, or critical-power scaling. The supplemental learned 499544 row arms its 900-second state; exact-pair player and generated-companion paths add their own 15-second/max-10 charges only after normal post-absorb HP damage that leaves the actor alive, and apply the sourced six-second +20 physical-dodge-rate aura. The separate 620302 HolyHeal and type-200 magical-dodge channel remain unavailable because the simulator lacks native MND/MPow/heal-rate and magical-dodge consumers. Direct `damagePlayer` coverage is an explicit positive post-absorb/normal-hit adapter approximation because that API has no outcome metadata. Current CoA reuses ID 499542 for Rogue/Warden Low Blow, a different skill; it is not mapped onto local Revival. Weak Point Strike (494043) still has sourced base/bonus-hit formulas but no proc chance; current CoA reuses the ID for Blood Hound, so no proc or identity is inferred. See [the state-chain source audit](ROM_WARDEN_REVIVAL_STATE_AUDIT.md) and [the remaining mechanics audit](ROM_WARDEN_REMAINING_MECHANICS_AUDIT.md). Other reactive passives remain incomplete.
- **Mage:** rank-1 Catalysis mana ticks, rank-2 Catalysis cast haste, Phoenix's 12-target cap, Control Flame and rank-1 Elemental Weakness's all-resistance debuff are covered. Flame Follower 494051 remains source-blocked for its Fireball cooldown/critical-rate rider. 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 494051 remains unresolved: its local tooltip lists a one-second Fireball cooldown reduction and increased critical rate, but no linked effect supplies the trigger phase or critical amount; current CoA 490244 is a later active-summon revision, and reused 494051 is Purgatory Fire. See [the raw source/runtime audit](ROM_MAGE_FLAME_FOLLOWER_RUNTIME_AUDIT.md). 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. RuneWaker Druid proc gaps are now scoped: Revival Slash 499496's 25% hidden-child roll is source-resolved and runs once only when the original target survives the first group; the reduced hidden heal still lacks a complete native runtime formula; Quick Light Protection has no bound local hook and its ID is reused in current CoA; Boiling Rock has no linked proc payload; learned Inspiration Display child 499942 has an ordered 60%-gated effect and 30% fallback plus its timeout payload; this does not establish an automatic proc from public Inspiration or Spiritual Gush; local Avalanche remains open. Current CoA 494029 reworks the passive to apply Weakening Seed on CoA Rockslide 494063, distinct from RuneWaker Rockslide 493543; it publishes 18.8% generic defenses only at skill +105, with no lower-rank curve, defense school, or Nature's Power rider. Its first hit is delayed two seconds, while extra-hit timing is unspecified; Extended Recovery's six added seconds are implemented but its Nature's Power chance is open; Spring Flower's learned child 499935 has a bound Rand30 +1 Nature Power effect and a 1/3/5% max-HP expiry-heal chain; periodic healing remains blocked on native formula inputs. See [the source audit](ROM_DRUID_REMAINING_PROC_AUDIT.md). 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 reuses 494200 as Recovery Expert and changes Recover targeting/payload; its current Recover identity is 493528. Those later rules do not supply RuneWaker Extended Recovery's missing Nature's Power chance or event phase.
- **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); Ring of Comfort 498641 (caster +1 Psi per accepted cast) and Mind Shield 498648 (caster +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. Local 499869 is a level-70 Warlock/Warrior learned Mind Transference variant, not current-CoA Psychic Warrior or Enraged; no current-CoA mapping is established. 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. The remaining CoA cost/range limits are Spreading Pain's 10% Mana cost (the RoM Warlock resource model has no Mana pool) and the unverified conversion of range 150 to local 22.5; Soul Brand Sting retains local range for the same reason. Current-CoA Warlock blockers remain Breathing Control 498634's Enraged chance/event phase/extra Focus; Deep Breathing Control 499870's 15% HP trigger/extra Focus; Mind Shield 498648's reset chance and Defense Net amount; Psychic Warrior 499868's hybrid mitigation, added-hit semantics, and unquantified attribute rider; and End of Thought's current-version contract. Local RuneWaker evidence is now distinct: learned Mind Shield guarantees the Ring reset after a landed Urgent Heal; local 499869 is level-70 Warlock/Warrior Mind Transference, not current-CoA Psychic Warrior or Enraged; and local End of Thought is Warlock/Rogue with a pre-hit threshold callback but no located timed-child consumer. Breathing passives still have no local trigger binding. These local contracts do not fill current-CoA gaps. Mind Transference 498642, Divine Assistance 498668, and current-CoA +1 Psi effects remain resolved; WoW Soul Leech (`talent-1678`) is separate. See the [local source follow-up](ROM_WARLOCK_SOURCE_FOLLOWUP_2026-10-02.md) and [current-CoA audit](ROM_WARLOCK_REMAINING_CONFLICTS_AUDIT.md).
- **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 (498562) is source-learnable for Dwarf Champion at level 16 through flag 546211/quest progression, which the level-derived catalog does not model; its toggle/child runtime remains open. Rank-2 Forge and Chain Drive, Rune Pulse's one-second +20% outgoing-damage window, and Dark Energy Strike's 15% post-physical-result reset are source-backed and implemented. Rank-1 companion auto-attacks now emit owner/expiry-qualified events; a fully absorbed swing cannot proc, while a landed swing grants its own stack and +10 Rage before the ordinary +6. Focused Forge, Champion, and party-runtime tests passed 74 tests across three files. The next-Curse transform, complete Shield Form behavior, and Rune Overload target processing remain open. See [the source follow-up](ROM_CHAMPION_SOURCE_FOLLOWUP_2026-10-02.md) and [the source audit](ROM_CHAMPION_REMAINING_MECHANICS_AUDIT.md).
### Source-complete CoA mechanics with remaining runtime gaps
@@ -215,9 +219,12 @@ These are specific, source-backed gaps that still need implementation or a gamep
- RoM visual review remains limited: only a subset of active source effects have browser-renderable graphs. A class-by-class size/brightness verdict cannot be derived from source file names or default `1x` tuning. Most RoM spells still need per-spell visual review.
- Verification is against the local simulator/runtime and extracted catalogs, not an official WoW or RoM server. The focused tests validate selected fixes; they do not prove every class ability or talent works.
## Focused test coverage
## Focused validation (2026-10-02 reconciliation)
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.
Current aggregate verification passed 382 focused tests across 35 files with four existing IndexedDB skips; Ready-five passed 7 Vitest and 20 Python tests. The 8 GB TypeScript check, prebuild asset audits, and full production build passed after the narrow Hunter summon-card CSS correction. All four earlier baseline assertions and current catalog/count assertions now pass. The previous 202/20 test run and 63-path release check are historical, not the latest aggregate. The rank-1 Forge companion event-gate correction is implemented; its focused Forge, Champion, and party-runtime tests passed 74 tests across three files, and Ready-five passed again after the fix. These are overlapping selections and are not summed. The final post-fix 8 GB TypeScript/Vite build passed with prebuild asset audits and bundle verification. Exact-final-dist Cat QA replay also passed. `npm run release:check` passed for 94 changed paths, including untracked files. See [the current follow-up report](MECHANICS_BACKLOG_FOLLOWUP_2026-10-02.md).
The earlier fresh-dist Turtle UI checkpoint passed in a loaded scene at 390×844: its selector card spanned y=306–420, clearing the party row ending at y=269 by 37px without overlapping the top-right HUD. The selector stayed disabled during real Wailing Caverns combat; afterward it selected Turtle, showed Shell Shield 26064 and no Furious Howl, and persisted through reload while a second character remained Wolf. There were no page or network errors apart from a favicon 404 and no horizontal overflow. The UI-created Marksmanship characters were raised to level 10 for this mechanics diagnostic; this was not Ready-party or balance certification. At that checkpoint, Escape/Spellbook Call/Revive had not yet been verified.
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.
Final Cat UI QA on the exact final production build passed for native family/reaction selectors, mouse Stay/Follow, Growl, encounter lock, and Cat persistence after reload while a second character remained Wolf. In the final narrow view, the party row ended at y=269 and the selector card spanned y=306–420 (37px clearance); there was no top-right overlap or horizontal overflow, and the canvas was loaded. Escape opened pause, Spellbook opened, the actual Call Pet entry was clicked, and the overlay closed with Cat still present. This verifies that activation route, not summoning after the pet is absent. Revive was untested because the pet did not die. No live Rake/Prowl cast or Cat model appearance was verified; the only console error was favicon 404, with no page or HTTP errors.
The initial clean-baseline check reproduced four assertions: Divine Punishment produced an extra catalog entry, God's Vengeance lacked its expected proc, the Mage/Druid elite milestone repeated 25 instead of advancing to 30, and Speed Catalysis restored no Energy. All four assertions now pass in the current aggregate, as do the catalog/count assertions. The initial outcomes remain historical; the current status is in [the follow-up report](MECHANICS_BACKLOG_FOLLOWUP_2026-10-02.md). The full `npm test` suite and all-class balance sweeps were not run. Focused tests validate only their named paths and do not certify every ability or talent.
+33 -19
View File
@@ -1,18 +1,24 @@
# Active Class Mechanics Backlog
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.
Updated 2026-10-03. 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.
## Native stat input update (2026-10-03)
RoM characters now retain an independent stat ruleset: existing saved characters stay on WoW compatibility, newly created RoM characters use native RoM, and the main-menu selector can change the choice safely. Native source stat channels and selected source gear inputs are available without treating native combat points as WoW ratings. The shared dungeon combat adapter and remaining native healing/combat boundaries are documented in [the ruleset implementation](CHARACTER_STAT_RULESETS_2026-10-03.md) and [the source contract](ROM_NATIVE_STATS_SOURCE_2026-10-03.md). This does not complete the Druid/Warden HolyHeal mechanics below, implement matchmaking, or promote RoM readiness. The dated October 2 source reports remain historical evidence.
## WoW 3.3.5a
### Hunter
- [ ] 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.
- [ ] Complete the remaining WoW 3.3.5a Hunter pet mechanics for player Hunters and generated Hunter companions. The current player selector supports persisted Wolf, Turtle, and Cat profiles, including Turtle's source-backed Shell Shield; generated companions remain Wolf. Still needed: full family-ability coverage beyond the partial Wolf/Turtle/Cat profiles, stable, feed/happiness persistence, training mechanics, and pet critical-stat inheritance. The source extract contains 40 family rows, 32 of which the local core classifies as Hunter pets; this does not make all rows playable. 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] The shared Wolf/Turtle 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] Extract the local 3.3.5a Hunter-pet family and skill rows: 40 named families, of which the pinned core's `petTalentType` test identifies 32 as Hunter families. Wolf, Turtle, and Cat are selectable player profiles. The player choice is stored in the character profile, legacy/unsupported values default to Wolf, and changing families is locked during an encounter. Player Call/Revive preserves the choice; generated Hunter companions remain Wolf. See [the source/runtime note](WOW_HUNTER_PET_FAMILIES.md).
- [x] Add the selectable Turtle profile: it shares Wolf's local Bite ranks and has Shell Shield 26064 (0 Focus, 12-second duration, 60-second cooldown, 50% less damage taken). Shell Shield autocast while engaged is a pet-AI policy. Wolf alone has Furious Howl. Cat's Claw, Rake, and Prowl are modeled; other extracted families remain unselectable until their distinct abilities and runtime requirements are modeled.
- [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).
@@ -35,6 +41,7 @@ Updated 2026-10-02. This document lists unresolved ability and talent mechanics
- [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] Wandering Plague (`talent-2003`) now runs for player and generated-companion Death Knights on positive Blood Plague/Frost Fever ticks. Generated ranks deal 33/66/100% of the tick as Shadow area damage within 8 yards; proc chance equals the owner's current melee critical chance, with the corroborated one-second owner cooldown. The splash cannot recursively proc, and damage-breaking statuses exclude targets. See [the source/runtime note](WOW_DEATH_KNIGHT_WANDERING_PLAGUE.md).
- [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.
@@ -57,6 +64,7 @@ Updated 2026-10-02. This document lists unresolved ability and talent mechanics
- [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).
- [x] Nature's Guardian (`talent-1699`) now handles the generated 33/66/100% chance when a landed, positive, nonperiodic damaging attack takes the Shaman from at/above 30% to below 30% health. The source-backed 30-second cooldown is actor-owned; the proc arms one guaranteed critical for the next resolved Nature damage or healing spell, consumed once per spell. The self-heal amount/formula is still unknown and remains unwired. Player and generated-companion paths are covered; see [the event-model note](WOW_SHAMAN_NATURES_GUARDIAN.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.
@@ -81,18 +89,15 @@ Updated 2026-10-02. This document lists unresolved ability and talent mechanics
- [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] 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. Local 499869 is a learned level-70 Warlock/Warrior Mind Transference row (key 546844, prerequisite 499868, slot 25), not current-CoA Psychic Warrior or Enraged; its Rage-to-Soul-Point conversion is RuneWaker-only. See [the local source follow-up](ROM_WARLOCK_SOURCE_FOLLOWUP_2026-10-02.md). 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.
- [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 Ring of Comfort (Warlock/Priest 498641): an accepted exact-pair cast grants 1 Psi to the caster, not each healing recipient; player and generated-companion tests cover recipient scope and caps. The CoA row does not name the recipient, so caster-scoping is an explicit interpretation. The separate Mind Shield reset chance stays open.
- [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 current CoA's Ring of Comfort reset chance open because the proc probability is unspecified, and leave the enhanced-defense amount unmodeled because CoA does not quantify it. The separate local RuneWaker Mind Shield check is Rand 100 gated on learning 498648 and its RunPlot resets Ring on HIT/CRITICAL Urgent Heal; do not transfer that guaranteed local result into CoA mode. See [the local source follow-up](ROM_WARLOCK_SOURCE_FOLLOWUP_2026-10-02.md).
- [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
@@ -128,6 +133,7 @@ Updated 2026-10-02. This document lists unresolved ability and talent mechanics
- [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] Keep Scout/Mage Wind Return (499548), Scout/Priest Revival Engraving (499552), and Scout/Warden Forest Guidance (499575) as learned-only markers. This prevents generated child components from being misapplied as owner auras; their active trigger rows or pet-target behavior remain unsupported. The exact-pair overrides do not create triggers or infer pet stats. See [the source audit](ROM_SCOUT_REMAINING_PASSIVES_AUDIT.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.
@@ -146,7 +152,7 @@ Updated 2026-10-02. This document lists unresolved ability and talent mechanics
- [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] Scope Priest/Champion Protection Rune Seal (498613) cooldown reset to positive periodic healing events from Regenerate (490269), the exact learned passive/pair, and the aura owner. The local generated 8% chance remains the numeric fallback because current CoA says only “slight chance.” Player and generated-companion paths reset only their own learned Heart Rune Energy (498612) cooldown; companion cooldown state is synchronized so healing updates cannot restore a stale value. A possible healing-strength rider has no local component/amount and remains open.
- [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.
@@ -159,6 +165,7 @@ Updated 2026-10-02. This document lists unresolved ability and talent mechanics
### Knight
- [x] Knight/Rogue Panther's Blessing (493047) follows the same-name current CoA passive and restores 10% maximum HP and 1% maximum Mana on a resolved incoming dodge. The simulator uses maximum-pool bases and whole-point rounding because CoA does not specify percentage bases or rounding. The legacy RuneWaker +2 Dodge child is suppressed; this is not God's Vengeance, which is passive 499934.
- [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.
@@ -166,13 +173,14 @@ Updated 2026-10-02. This document lists unresolved ability and talent mechanics
- [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.
- [x] Align Divine Punishment (491608) to current CoA: Punishment gains 28.5% damage and applies +2% general damage taken per pre-spend Holy Seal stack for 10 seconds. This replaces RuneWaker's +1% Light-only vulnerability. The catalog assertion now passes; see [the Knight source audit](ROM_KNIGHT_REMAINING_REFERENCES_AUDIT.md).
- [x] Implement Knight/Rogue God's Vengeance 499934 through its separately learned level-50 Punishment child 499909. The child spends its own target's Seals normally; a landed hit at exactly four stacks resets only the caster's learned Shadow Step 490344. General Punishment 490151 remains separate.
- [ ] Identify unresolved periodic or triggered spell references before implementing their effects.
### Warden
- [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)
- [x] Implement the source-backed RuneWaker Rogue/Warden Revival (499542/499544) state chain for players and generated companions. Learned 499544 arms its actor-owned 900-second state; while active, only a normal, noncritical hit with positive post-absorb HP loss that leaves the owner alive adds that actor's 623072 charge (15 seconds, maximum ten) and applies component 623527's six-second +20 physical-dodge-rate aura. The 499542 heal is `maxHP × (10 + 5 × charges)%`, normal/noncritical FixPer; it does not use weapon damage, attack speed, MagicPow, or critical power. The direct `damagePlayer` adapter has no outcome payload, so positive post-absorb loss is treated as a normal hit there; authored mob/party events retain the explicit normal-hit gate. The separate 620302 HolyHeal and type-200 magical-dodge riders remain unwired because the complete native HolyHeal resolver and magical-dodge combat consumer are not modeled. Native MND/MPow/heal-rate input channels are now available for explicitly native characters; that does not implement this rider. Current CoA's reused 499542 is a different Rogue/Warden Low Blow and is not mapped onto this local ability. See [the state-chain source audit](ROM_WARDEN_REVIVAL_STATE_AUDIT.md) and [the broader source audit](ROM_WARDEN_REMAINING_MECHANICS_AUDIT.md).
- [ ] 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).
@@ -182,7 +190,7 @@ Updated 2026-10-02. This document lists unresolved ability and talent mechanics
- [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).
- [ ] 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 identity review](ROM_MAGE_WARDEN_FLAME_FOLLOWER_SOURCE_REVIEW.md) and [the raw source/runtime audit](ROM_MAGE_FLAME_FOLLOWER_RUNTIME_AUDIT.md).
### Druid
@@ -191,19 +199,25 @@ Updated 2026-10-02. This document lists unresolved ability and talent mechanics
- [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.
- [ ] Keep the remaining RuneWaker Druid source and runtime gaps separate from current-CoA reworks. Revival Slash 499496 has a source-backed 25% child roll after original-target survival, but its hidden HolyHeal/HoT amounts still need per-spell rank, timing, flags, and native resolver integration. Learned Spring Flower child 499935 has a bound 30% +1 Nature Power effect and a 1/3/5% maximum-HP expiry-heal chain; its periodic healing formula is still blocked. Learned Inspiration Display child 499942 has ordered 60%-gated/30%-fallback checks and a timeout modifier, but no automatic cast from public Inspiration or Spiritual Gush is established. Quick Light Protection 494924's probability/heal/tie-break, Boiling Rock 494565's probability/MP amount, local Avalanche 494029's proc payload, and Extended Recovery 494200's Nature Power chance remain unresolved. Selectable native stats now provide source-derived effective MND/Wisdom, MDMG, Magic Power/MPow, HealPowerRate, HealPoint, and target-heal modifier channels. FIX row 303001 supplies Dmg_HealArg = 0.5. This closes the base stat/input-contract gap only: per-spell OrgPower, rank/skill evaluation, timing, flags, aura channel coverage, and the complete native healing consumer remain open. Current CoA does not fill these RuneWaker gaps. See [the source follow-up](ROM_DRUID_SOURCE_FOLLOWUP_2026-10-02.md).
- [ ] Implement the current CoA Spirit Guidance faction conversion and Rebirth debt reduction only after combat state can represent caster reputation/NPC faction and death-debt/item-consumption. The seven-rule helper is source documentation, not runtime support; the optional base Rebirth amulet bonus remains unquantified. Current CoA Druid/Rogue Necrotic Wound replaces the stale RuneWaker healing-reduction payload; see [the CoA behavior note](ROM_DRUID_ROGUE_NECROTIC_WOUND.md).
### Warlock
- [ ] 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. Local 499869 is a separate learned level-70 Warlock/Warrior Mind Transference row (key 546844, prerequisite 499868, slot 25), not current-CoA Psychic Warrior or Enraged 499871. Its Rage-to-Soul-Point conversion is RuneWaker-only; see [the local source follow-up](ROM_WARLOCK_SOURCE_FOLLOWUP_2026-10-02.md).
- [ ] 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 current-CoA proc unwired. The local RuneWaker collections/scripts have no bound trigger contract for 498634/499870. See [the local source follow-up](ROM_WARLOCK_SOURCE_FOLLOWUP_2026-10-02.md).
- [ ] 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 bound checks, components, or callbacks. Do not attach the heal to manual 499871 without a source-defined event link; the extra Focus amount also remains unknown.
- [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.
- [ ] Keep current-CoA End of Thought unwired until its version-specific periodic contract is sourced. The local RuneWaker 498699 is a separate Warlock/Rogue skill: its pre-hit <=35%-HP callback applies a four-second buff, whose one-second child lacks a located native timed-magic consumer. The local callback is source-backed, but its periodic Soul Point effect is not executable from the inspected server tree. See [the local source follow-up](ROM_WARLOCK_SOURCE_FOLLOWUP_2026-10-02.md).
### Champion
- [ ] Verify whether Shield Form has a player-learnable source skill in the RuneWaker version; none is present in the current catalog.
- [ ] Find source-backed proc chances and payloads for Forge rank 2, Chain Drive, Dark Energy Strike, and the other incomplete Champion procs.
- [x] Verify Shield Form (498562) learnability: the local Champion-primary level-16 learn row exists behind Dwarf Champion flag 546211 and a quest grant. The exporter omits its flag-keyed row, so absence from the generated player catalog is not absence from source. Its toggle/child payloads and skill-rank acquisition semantics remain unimplemented; see [the source audit](ROM_CHAMPION_REMAINING_MECHANICS_AUDIT.md).
- [x] Integrate source-backed Rank-2 Forge/Chain Drive: its normal-attack event keeps Forge stack and Rage results even when the later effect roll fails; the 80% Shield Form-buff check precedes a separate 30% fallback, with the target resolved once. The eight-second effect resets Rune Pulse through EndUseLua. Rune Pulse grants +20% outgoing physical/magical damage for one second at cast acceptance, expiring before its two-second impact.
- [x] Add the missing rank-1 Forge companion auto-attack event path with owner/expiry-qualified eligibility. A fully absorbed swing does not proc; a landed normal swing applies its own stack and +10 Rage before the ordinary +6. Focused Forge, Champion, and party-runtime coverage passed 74 tests across three files. The final post-fix 8 GB TypeScript/Vite build, asset audits, bundle verification, exact-final-dist Cat QA, and `npm run release:check` passed; release check covered 94 changed paths, including untracked files.
- [x] Integrate Dark Energy Strike 498599's ordered physical then Shadow packets and 15% cast-level Rune Siphon reset. The reset rolls once only after the physical packet produces a surviving positive result; the later Shadow result does not gate it. See [the source/runtime follow-up](ROM_CHAMPION_SOURCE_FOLLOWUP_2026-10-02.md).
- [ ] Keep the next-Curse instant-cast rider and full Shield Form/Rune Overload target behavior open until their transform, child-state, and target-processing contracts are complete.
- [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,46 @@
# Character stat rulesets
Character origin and character stat rules are independent. RoM characters can select Native RoM or WoW compatibility from the main menu. WoW characters retain WoW rules.
## Saved profiles
- Existing saved profiles without a valid `statRuleset` normalize to `"wow"`. This preserves the pre-existing RoM compatibility calculations, including their hybrid base-stat behavior.
- Newly created RoM profiles explicitly select `"rom"`; newly created WoW profiles select `"wow"`.
- The selector updates only the selected character. Inventory, equipment, progression, class pairing, learned skills, bindings, loadouts, and dungeon checkpoints remain attached to that profile.
- Switching is restricted to the main menu and blocked while a character remains active, a live enemy is engaged, or an online group activity is live. The combat check uses the installed dungeon runtime bridge so the main menu does not eagerly import combat catalogs. A failed storage write reports failure and retains the saved choice. A failed migration write still leaves the existing roster readable and retries on a subsequent read.
- Cloud saves retain the profile field because the client and server persist complete profile objects.
`src/game/characterStatRuleset.ts` owns normalization. `src/app/accountRepository.ts` owns profile migration and persistence; `src/app/shellStore.ts` owns the switch action; `src/ui/MainMenuScreen.tsx` owns the selector and controller actions.
## Native calculation and shared combat
Native inputs must retain their source meaning. Wisdom is RoM MND. Physical and magical attack, defense, damage, and combat points are distinct source channels. Native hit/critical/dodge points must never enter WoW rating-to-percent conversions.
Native derived calculations are sourced from the local RuneWaker `RoleAttr.cpp` and player vocation/race/sex/fixed-argument database records. Secondary-class activation at level 10 and treating the secondary level as the main level remain simulator policies.
`src/game/romNativeStats.ts` owns derivation and source-unit modifier re-derivation. `romNativeStatsTypes.ts` keeps native points and multiplicative rates separate. `scripts/runewaker-pipeline/export-rom-native-stat-rules.mjs` owns `src/game/generated/romNativeStatRules.generated.json` and the compact `romNativeGearCurves.generated.json`; runtime code does not import the full reference item catalog. The existing `romStats.ts`/base-growth path remains available for legacy compatibility.
Equipped native items carry `ItemStats.romNative`. Their common WoW projection is not counted again. Foreign items without that payload bridge only direct Strength, Stamina, Intellect, Agility, and flat health/mana under an explicit simulator policy. Spirit does not become Wisdom; WoW armor, attack/spell power, weapons, and ratings do not become native channels. The five existing source-reference heirloom classes supply native gear curves; other classes retain the direct-stat fallback until native gear profiles are sourced. Reference item selection and intermediate-level interpolation remain synthetic simulator policies.
The existing dungeon combat resolver remains a shared simulator. Adapting a verified native stat into a common combat field does not establish that the WoW damage, hit, armor, or critical formulas reproduce RoM combat. Native healing inputs alone also do not certify every RoM healing spell's amount, rank, or coefficient.
The explicit common adapter uses native ATK as attack/ranged attack power, MDMG as spell power, and DEF as armor. Physical mitigation uses the existing shared WoW armor curve. MATK, MDEF, DMG, native recovery values, and hit/critical/dodge/parry points remain source-semantic sidecar/display fields; native point values are not converted into common percentages. Native common hit/critical/avoidance percentages start at zero. Existing primary resource types and regeneration policy remain part of the shared dungeon simulation; native MP derivation also supplies supported secondary mana pools. The Equipment panel identifies the shared combat boundary, labels point fields explicitly, and shows active native aura effects for its selected owner. Shared HUD counters display whole HP/MP values while their accessible maxima and runtime pools retain source precision.
Player FIX record 303001 sets `DmgAtkArg` and `MDmgMAtkArg` to zero, so this source version derives DMG/MDMG from flat gear channels rather than ATK/MATK. Its `Dmg_HealArg` is **0.5**. Wisdom/MND, MDMG, MPow, HealPowerRate, HealPoint, and target heal-modifier inputs now have explicit source channels. Per-spell OrgPower, rank/skill-level evaluation, timing, flags, and the complete native healing consumer remain separate work. These local RuneWaker records do not certify current-CoA formulas or balance.
The character, party member, and combat actor retain their own ruleset. No global party or dungeon setting chooses every participant's stat system. This provides a foundation for future mixed parties; matchmaking and cross-game balance certification are outside this change.
## Compatibility evidence
`src/game/generated/characterStatRulesetCompatibility.json` freezes 18 pre-change stat outputs across WoW characters and legacy RoM builds, including gear, modifiers, race, level, and secondary-class cases. `src/game/characterStatRulesetCompatibility.test.ts` checks both omitted rulesets and explicit WoW compatibility against those outputs. This fixture is a simulator regression baseline, not native RoM source data.
## Validation
- 219 focused tests passed across 22 files, with four existing IndexedDB skips: the final 21-file selection passed 211 tests, and the independent native runtime suite passed eight. The source/gear worker's 30-test selection overlaps these totals.
- The runtime suite covers per-owner mode, primary and secondary Mana mirrors, level-10 secondary activation, Wisdom/Holy Blessing and GM-buff cap removal, periodic/channel source amounts, native party autoattacks, and exclusion of foreign WoW attack/critical/hit/haste ratings.
- Ready-five passed seven Vitest and twenty Python checks. Avatar validation passed 47 tests across ten files.
- The final full 8 GB TypeScript/Vite production build passed, including prebuild asset audits, compression, and release-bundle verification. A subsequent TypeScript project check also passed after the final test edits.
- Exact-final-dist Edge QA passed through `scripts/playtest-stat-rulesets.cjs`, using an isolated temporary offline account. New-profile defaults, synthetic legacy migration, independent saved choices, build/checkpoint preservation, both dungeon modes, switching locks, native panel tabs, and desktop/390-pixel layouts passed. Roster and both dungeon screenshots show character bodies and held starter staffs. There were no page errors or application-resource response errors; the optional favicon returned 404.
- Browser evidence: `playtest-artifacts/stat-rulesets/results.json` and its ten referenced screenshots. The checked chunks were `index-CGkT8hBz.js`, `combatStore-CVXgxk1Z.js`, and `PartyFrames-BeW5_TCA.js`. Synthetic engaged-mob and online-activity fixtures verify the menu guards, not a real multiplayer session.
- Final `npm run release:check` passed for 157 changed paths, including untracked files and concurrent unrelated work. Final documentation and owned vault notes satisfy text-file hygiene.
- No full repository suite, all-class balance sweep, RoM readiness promotion, matchmaking, or deployment was performed.
+99
View File
@@ -0,0 +1,99 @@
# CoA catalog promotion — October 3, 2026
The active catalogs now use the pinned August 12 Vol'Jin talent capture and global
BASE spell evidence. CoA remains **Needs Testing**. This completes the source and
catalog foundation for further class mechanic implementation.
## Source selection and offline reuse
The builder capture comes from `srhinos/coa-datamine` revision
`5078f9ac22541c45c17a866c23aac6c12073de23`. CoA spell/class evidence uses the paired
local client extraction's global `patch-T` Spell, `patch-M` class metadata, and
`patch-S` supporting tables. Classic Ascension data retains its original Area 52
records, including linked spell payloads. Separate accessors prevent shared IDs
from crossing those scopes.
The archive supplements your extraction rather than replacing it. Its supporting
tables match the local copies. Eleven missing August node spell IDs were found
in the archive's global patch-T table. The selective download cached 26 compressed
raw shards (4,650,843 bytes) and selected 48 records including class-only references
and linked payloads. Forty-one records supplement missing local global rows.
It retains all raw columns and inferred types, with pinned metadata and checksums.
No full client archive or repeated Exiles crawl is needed.
`npm run coa:reference:promote` reads verified local caches. Promotion and historical
reference rebuilds produced identical bytes with network access disabled. The
lossless July baseline remains tracked separately for comparison.
## Active catalog changes
- 21 classes, 91 playable tabs, and 3,618 nodes: six added, 436 changed, none removed.
- 701 ability entries and 2,917 talent entries, with 3,933 node-rank references.
- Resolved 174 encoded description fields from the original builder capture.
- Regenerated all 21 CoA runtime and talent UI shards. Non-CoA talent runtime
records and UI shards are unchanged.
- The runtime catalog has 1,276 ability families, 3,395 ranks, 3,348 distinct
rank spell IDs, and 578 hidden triggered payloads. Sourced starter spells
include class-catalog-only references such as Rejuvenating Venom.
- Exported six missing icons from local BLPs, with zero placeholder fallbacks.
- Preserved all 1,822 progression-chain identities, 1,266 classic chains, 7,268
protected classic spell records, and the outputs of all 612 classic abilities.
The six added nodes are Mycelial Healing, Devourer, Dark Channeler, Crusher,
Houndmaster's Whistle, and Skewer. Full before/after evidence and coverage gaps
are in `data/coa-reference/promotion-report.json`.
## Saved builds and remaining work
Saved CoA node keys remain resolvable under their original class. Saved ranks
clamp to the captured maximum. Source changes include Agonizing Burns 1→2, Timed
Attacks 2→1, Cultist node 6274 2→1, and Advisor's Perfidy 2→1.
Numeric IDs are not a guarantee of identical effects across source versions.
August swaps the spell payloads of Agonizing Burns/Timed Attacks. It also swaps
Dark Infusion/Darkward between Cultist nodes 30036 and 6274, including a change
of tree. Existing saves contain no CoA capture-version metadata, so this update
does not attempt an ambiguous semantic migration. Affected builds need review.
All August node ranks have global DBC evidence. Thirteen older progression ranks
are absent from both selected global sources: 501862–501865, 501918–501920,
501922–501925, 502889, and 560469. Their IDs are retained and gaps are reported.
The wider catalog/context audit reports 224 missing BASE spell records and three
source connection warnings. These are not filled from Area 52.
Descriptions and client effects do not establish server-side proc timing,
acquisition rules, or complete class behavior. Bespoke mechanics and targeted
player/companion tests remain the next class-by-class phase.
## Validation and browser evidence
All 44 pipeline/cache/source-policy tests and 62 focused game tests pass. The
focused tests verify source separation, classic preservation, saved-rank
clamping, runtime shard loading, sourced spell semantics, and explicit source
gaps. Ready-five checks pass (7 game tests and 20 Python checks); no all-class
balance sweep was run. All 21 runtime shards match their generated snapshot.
The negative DBC cast-time sentinel on Berserker Axe is retained in source
evidence and clamped to an instant CoA runtime cast. Classic behavior is
unchanged. A Tinker resource test was corrected to copy the matching maximum
pools alongside the current pools; no production stats behavior was changed.
With network requests blocked, fetch reuses the complete cache, promotion repeats
identically, and historical build/audit/lookup succeed. The promoted candidate
SHA-256 is `9208e973c43c31d263fb429f7ab82a2cc6732e2a70bc14f482b6428c9b296091`.
The historical snapshot is
`e86031775dfac7e336361087c50ca23a0a7a86c36a760c85803bee4bcdb89351`.
The production build and release-bundle verification pass. A browser smoke check
created the local offline `Coaaudit` Witch Hunter and opened its five talent tabs.
Houndmaster's Whistle displays the new icon, full description, and prerequisite
gate. No warning/error logs were captured. This checks presentation at level 1;
it does not exercise unlocked combat mechanics.
The optional local API server was absent during this offline preview. Development
proxy policy/layout requests failed and offline defaults were used. Online roster
and server policy synchronization were outside this check.
Screenshot: `playtest-artifacts/coa-catalog-promotion/houndmaster-whistle.jpg`.
See [the pipeline guide](COA_REFERENCE_PIPELINE.md) for repeatable commands.
+164
View File
@@ -0,0 +1,164 @@
# CoA offline reference pipeline
The reference pipeline prepares source evidence for implementing all 21 Conquest
of Azeroth classes. It reads existing client files first, generates a searchable
dataset, and compares source versions. An explicit offline promotion updates the
active CoA catalogs. CoA remains Needs Testing; source coverage does not certify
class mechanics or balance.
## Sources and version boundaries
The default source pair is `H:/resources/client` and `H:/LadiksMPQEditor` when both
exist. Otherwise the pipeline uses the project's sibling `resources/client` and
`LadiksMPQEditor` directories together. In this workspace those are under
`F:/Projects`. To choose another installation, supply both roots explicitly:
```powershell
npm run coa:reference:inventory -- --client-root H:/resources/client --extracted-root H:/LadiksMPQEditor
npm run coa:reference:build -- --client-root H:/resources/client --extracted-root H:/LadiksMPQEditor
```
The frozen July trees come from
`data/coa-reference/baselines/coa-july-2026-07-25.json.gz`. Before the first
promotion, the original active catalog is preserved losslessly in that file.
Historical reference builds keep using it after active catalog updates. Spell evidence
comes from the extracted base-client tables under `client-current`: global
`patch-T` Spell, `patch-M` class and skill metadata, and `patch-S` supporting
tables. Client `Data/Content` contributes advancement and rank evidence. Relevant
sources receive hashes and version/source labels. Original files stay intact.
July class-spell references also seed identities, including starter spells that
are absent from talent trees and progression chains. Their old DBC values are
not imported. Ownership follows global trigger/effect-168 links so the selective
spell fetch and promotion cover linked payloads in the same CoA scope.
The previous importer preferred the Area 52 `patch-D` overlay. The extraction's README
calls those effective CoA sources, while the external datamine identifies them as
Free-Pick's overlay and uses the base-client chain for CoA. The new reference
retains this disagreement as audit evidence. The combined live importer now
selects global Spell/SkillLineAbility tables for CoA and preserves the existing
Area 52 preference for classic classes. Shared spell IDs have separate CoA
wrappers; classic spell resolution and its triggered fallback remain isolated.
The optional external source is `srhinos/coa-datamine` at commit
`5078f9ac22541c45c17a866c23aac6c12073de23`. Its published talent capture is dated
August 12, 2026. It is a distinct source version applied only by the explicit
promotion command. A surviving API or capture labeled `live` describes source
availability/content at capture time, not the current availability of a realm.
The explicit supplement fetch downloads the pinned talent capture, all 21 derived
talent-tree records, and their identity/provenance metadata. It does not download
the full client extraction or game assets. Git blob hashes and the
capture's published SHA-256 verify the files. Valid cached files are reused, and
interrupted downloads resume at file boundaries. No Exiles crawling is performed.
## Offline active-catalog promotion
```powershell
npm run coa:reference:fetch
npm run coa:reference:fetch:spells
npm run coa:reference:promote
npm run coa:catalogs:refresh
```
The spell fetch is an explicit, selective download of pinned global BASE raw
shards needed for spell IDs absent from the local global table. It includes
linked payloads and verifies the stored shard hashes. It never selects the
archive's Area 52 table. Unresolved old progression ranks retain their identities
and are reported as gaps.
Promotion reads only verified caches and local files. It resolves React Flight
text references in the captured builder before normalizing nodes, filters empty
metadata tabs from playable trees, and checks preservation before atomically
replacing `src/game/coaLiveData.generated.json`. The machine-readable result is
`data/coa-reference/promotion-report.json`. A failed check retains the active
catalog. Repeating promotion with the same inputs produces identical bytes.
Existing `coa-entry-*`, `coa-tree-*`, tree IDs, and progression-chain IDs survive.
Saved talent ranks still follow the normal clamping rules when the captured
maximum rank decreases. An existing node ID can carry a revised effect: August
exchanges the spell payloads of Pyromancer's Agonizing Burns/Timed Attacks and
Cultist's Dark Infusion/Darkward. Saves have no capture-version field, so numeric
ID preservation does not preserve the old effect. Review affected builds after
updating. Existing classic spell records, chains, and generated abilities are
preserved and checked separately.
The refresh command regenerates CoA runtime data and its class shards, resolves
triggered spells from global data, and exports missing icons. It preserves the
existing non-CoA talent records and UI shards.
## Build, search, and audit
```powershell
npm run coa:reference:inventory
npm run coa:reference:fetch
npm run coa:reference:build
npm run coa:reference:audit
npm run coa:reference:lookup -- --class barbarian --tree Brutality --limit 5
npm run coa:reference:lookup -- --class barbarian --ability "Ancestral Strike" --limit 5
npm run coa:reference:lookup -- --spell 800380 --limit 5
```
Fetching is optional and explicit. Build, audit, and lookup work offline. A build
without the archive produces a local-only reference and identifies the missing
comparison. Run fetch and then build to add it. `--refresh` is supported only by
fetch, which revalidates the same pinned revision rather than choosing a new one.
`--output-root PATH` chooses a separate reference output directory; `--cache-root
PATH` chooses the supplemental cache. Defaults are `data/coa-reference` and its
`cache` subdirectory. Lookup accepts numeric class IDs or HealerMan class names,
tree names/IDs, spell IDs, and case-insensitive ability-name fragments. Each result
collection is limited to 20 by default; use `--limit` between 1 and 1000.
Class and tree searches include linked spell ranks and triggered spell dependencies;
those dependencies are not presented as learned abilities.
Build prints the path to the readable audit. The corresponding `audit.json`
contains detailed machine-readable findings. `reference.json` preserves nodes,
spell data, rank relationships, class catalogs, source evidence, and unresolved
relationships. Class aliases are tied to numeric class IDs, so legacy names such
as DemonHunter, Monk, and SonOfArugal do not create extra playable classes.
The report inventories each class, compares July and August nodes/trees, compares
local base spell evidence with the previous imported values, and identifies
runtime-catalog mappings. A mapping is not proof that a talent mechanic works.
The backlog prioritizes source conflicts and identity gaps before bespoke combat
behavior, player/companion integration, UI verification, and targeted balance work.
## Integrity and limitations
Snapshots are content-addressed and immutable. Build writes and verifies all
files before replacing `current.json`. Failed builds leave the last valid
snapshot selected. Rebuilding unchanged sources produces the same snapshot ID.
Audit and lookup verify the selected snapshot's manifest before reading it.
If a source file is malformed or a downloaded cache file fails its checksum, the
command stops with the affected source identified. Restore the original source
or rerun the explicit fetch before rebuilding. Missing spell/dependency evidence
is reported as a coverage gap rather than filled with speculative values.
An existing immutable snapshot with damaged files is rejected. To regenerate it
without changing the damaged evidence, build with a fresh `--output-root` and
point `--cache-root` at the existing verified cache.
Client descriptions, effect fields, and identity joins have different certainty.
The dataset preserves that evidence; it does not invent server-side base stats,
proc timing/rates, missing coefficients, or acquisition rules. Name matches alone
do not establish an exact rank identity. CAD catalog presence alone does not prove
that an ability was trainable in the captured game version.
The promoted trees and spell evidence provide the foundation for class mechanic
implementation. Bespoke procs, resource behavior, acquisition rules, and balance
still require targeted class work before promotion from Needs Testing.
## Validation
```powershell
npm run coa:reference:test
npx vitest run src/game/coaLiveCatalog.test.ts src/game/coaAbilityCatalog.test.ts src/game/coaAbilityCatalogLoader.test.ts src/game/coaSourceScope.test.ts src/game/coaPromotionPreservation.test.ts src/game/talentCatalog.test.ts
npm run release:check
```
Focused pipeline tests cover malformed inputs, source pairing/selection, missing
relationships, independent version comparison, cache integrity/resume, immutable
snapshot publication, and offline lookup. No full test suite or all-class balance
sweep is required for this development-reference change.
@@ -0,0 +1,21 @@
# Mechanics backlog follow-up — 2026-10-02
This follow-up corrects and supersedes selected first-pass findings in [the initial run report](MECHANICS_BACKLOG_RUN_2026-10-02.md). It records current source/runtime status after the separate class-mechanics workers completed their bounded changes. It is not an all-class or balance certification.
## Current catalog inventory
The generated RuneWaker catalog contains **932 skills**: 590 active and 342 passive, with 937 rank rows. All 932 generated rows have resolved icon sources. Runtime composes 936 ability definitions (the 932 generated rows plus four separately composed definitions) and 937 ranked versions, for 1,873 definition/rank entries. These counts are asserted by `src/game/romPlayerCatalog.test.ts` and the generated snapshot at `src/game/romPlayerCatalog.generated.json`. The older 929-row class coverage table is historical.
## Resolved implementation updates
- **WoW Hunter pets:** Cat is now a selectable, persisted player-pet profile alongside Wolf and Turtle. The local Cat kit has Claw, Rake, and Prowl; Rake keeps its direct physical packet separate from its armor-ignoring bleed, and Prowl's one-charge damage bonus applies to a ready melee auto before spell autocasts. Generated Hunter companions remain Wolf. Other family abilities, pet care/training, and owner critical-stat inheritance remain open. See [the family note](WOW_HUNTER_PET_FAMILIES.md).
- **RoM Knight:** Divine Punishment 491608 uses current CoA's 28.5% Punishment bonus and 2% general damage-taken increase per pre-spend Holy Seal for 10 seconds. God's Vengeance 499934 uses the separately learned Knight/Rogue child Punishment 499909; at exactly four of its own target's Seals, a landed hit resets only the caster's learned Shadow Step. Panther's Blessing 493047 restores 10% max HP and 1% max MP on a resolved dodge. The percentage pools and whole-point rounding are explicit simulator interpretations because CoA does not define the bases or rounding. The legacy RuneWaker +2 Dodge child remains suppressed. See [the Knight source audit](ROM_KNIGHT_REMAINING_REFERENCES_AUDIT.md) and [Panther runtime note](ROM_KNIGHT_PANTHER_DODGE_PROC_2026-10-02.md).
- **RoM Champion:** Rank-2 Forge 498604 is the level-22 upgrade of Forge 498523 through Chain Drive 498535. Its normal-attack trigger preserves group order: Forge stack and Rage resolve even if the later effect roll fails; group 1 uses an 80% exact Shield Form buff check, then a 30% fallback, with the target resolved once. The eight-second effect resets Rune Pulse through its effect-use callback. Rune Pulse 498529 grants +20% outgoing physical and magical damage for one second at cast acceptance, so it expires before the two-second impact; it does not boost that impact. Dark Energy Strike 498599 resolves physical then Shadow packets; a surviving positive physical result gates one 15% cast-level reset of the owner's Rune Siphon, regardless of the later Shadow packet's result. Rank-1 Forge companion auto-attacks now emit owner/expiry-qualified roll eligibility; fully absorbed swings do not proc, while a landed normal swing grants its own stack and +16 Rage total (+10 Forge, +6 normal attack). Focused Forge, Champion, and party-runtime tests passed 74 tests across three files. The next-Curse transform, Shield Form's complete child behavior, and Rune Overload target selection also remain unresolved. See the [Champion source follow-up](ROM_CHAMPION_SOURCE_FOLLOWUP_2026-10-02.md) and [Dark Energy runtime note](ROM_CHAMPION_DARK_ENERGY_STRIKE_RUNTIME_2026-10-02.md).
- **RoM Druid source-only findings:** Revival Slash 499496's hidden child has a 25% roll after the original target survives the physical group; it runs once per surviving cast. Its reduced hidden HolyHeal/HoT is not a complete runtime formula. Spring Flower Eulogy's learned child 499935 has a bound 30% +1 Nature Power group and source-defined six-second stack/expiry chain, including 1/3/5% max-HP timeout heals. The periodic tick formula still needs final native healing values. Learned Inspiration Display child 499942 has an ordered, 60%-gated first effect and 30% fallback, plus the 10-second timeout modifier; this proves the learned variant's acquisition path, not an automatic trigger from public Inspiration or Spiritual Gush. Quick Light Protection, Boiling Rock, local Avalanche, and Extended Recovery's Nature Power chance remain without usable local numeric triggers. Verified base MND maps to Wisdom, but no final effective MND, MDMG, Magic Power/MPow, HealPowerRate, HealPoint, and target-heal-absorption bindings were established. See the [Druid source follow-up](ROM_DRUID_SOURCE_FOLLOWUP_2026-10-02.md); these source findings alone do not claim complete playable heals.
- **RoM Warlock:** local RuneWaker Mind Shield 498648 guarantees a Ring of Comfort reset after a landed Urgent Heal when learned; current CoA still leaves its separate chance unquantified, so this local rule is not imported. Local learned 499869 is level-70 Warlock/Warrior Mind Transference (key 546844, prerequisite 499868, slot 25), converting Rage to Soul Points and applying its EndUseLua addition/suppression; it is not current-CoA Psychic Warrior or Enraged. Local End of Thought 498699 is Warlock/Rogue and has a pre-hit <=35%-HP callback that applies a four-second buff, but its one-second child has no located native timed-magic consumer. Breathing Control 498634 and Deep Breathing Control 499870 still have no local trigger contract. These source findings do not complete the current-CoA Psychic Warrior damage path, Mind Shield chance/Defense Net amount, End of Thought current-version contract, or Breathing riders. See the [Warlock source follow-up](ROM_WARLOCK_SOURCE_FOLLOWUP_2026-10-02.md) and [current-CoA conflicts audit](ROM_WARLOCK_REMAINING_CONFLICTS_AUDIT.md).
## Verification and current limits
The aggregate focused run passed **382 tests across 35 files**, with four existing IndexedDB skips. Ready-five passed 7 Vitest tests and 20 Python tests. After the narrow Hunter summon-card CSS correction, the fresh 8 GB TypeScript/Vite production build, prebuild asset audits, and release-bundle verification passed. The earlier four baseline assertions and the current catalog/count assertions now pass; the earlier 202-test/20-file run and 63-path release check in the initial report remain historical results, not the latest totals.
Final Cat UI QA passed, including a replay against the exact final production build. Native pet/reaction selectors, mouse Stay/Follow controls, Growl, and the real-combat change lock responded. The Cat selector persisted across reload while a second character remained Wolf. In the final narrow screenshot, the party row ended at y=269 and the selector card occupied y=306–420, leaving a 37-pixel gap; there was no top-right HUD overlap or horizontal overflow, and the loaded canvas was visible. Escape opened pause, Spellbook opened, the actual Call Pet entry was clicked, and the overlay closed with the existing Cat still selected; this verifies that activation route, not summoning after the pet is absent. Revive remains untested because the pet never died. No live Rake/Prowl cast or Cat model appearance has been verified. There were no page or HTTP errors; the only console message was a favicon 404. The rank-1 Forge companion event correction is implemented; focused Forge, Champion, and party-runtime tests passed 74 tests across three files. Ready-five passed 7 Vitest and 20 Python tests after the correction. The 382/35 aggregate with four existing skips is the earlier passing selection, and the 74-test selection overlaps it; do not add the totals. The final post-fix 8 GB TypeScript/Vite production build passed with prebuild asset audits and bundle verification; Vite processed 1,260 modules in 11.58 seconds. `npm run release:check` passed across 94 changed paths, including untracked files. The full repository test suite and all-class balance sweeps were not run.
+43
View File
@@ -0,0 +1,43 @@
# Mechanics backlog reconciliation — 2026-10-02
This run reconciles new source and runtime work into the active class backlog and audit. A generated-catalog omission does not establish source absence: inspect the raw RuneWaker records, linked Lua/engine paths, learn rows, and event consumers where they are available. Local RuneWaker and current Chronicles of Arcadia behavior remain version-qualified, and missing amounts or event semantics stay open. The Ready certification set is unchanged. All subprocess work used GPT-6 Luna at xhigh; no higher-tier subprocess model was used.
## Implemented or source-reconciled slices
| Mechanic | Current status | Scope and limit |
|---|---|---|
| WoW Hunter pet families | Partial player-profile slice implemented. | Extracted 40 family rows; pinned-core classification identifies 32 Hunter families. Player selection/persistence supports Wolf and Turtle; Call/Revive preserves the selection, and encounter changes are locked. Turtle's Shell Shield 26064 is modeled; its engaged autocast is simulator policy. Generated companions remain Wolf. Full family ability coverage, stable/feed/happiness, training, and critical inheritance remain open. See [family runtime audit](WOW_HUNTER_PET_FAMILIES.md). |
| WoW Death Knight Wandering Plague | Implemented for player and generated-companion Death Knights. | Positive Blood Plague/Frost Fever ticks use generated 33/66/100% tick-damage ranks and owner melee-critical chance. One-second owner cooldown is AzerothCore corroboration. Triggered splashes cannot recurse and skip active break-on-damage statuses. See [source/runtime note](WOW_DEATH_KNIGHT_WANDERING_PLAGUE.md). |
| WoW Shaman Nature's Guardian | Implemented for player and generated-companion Shamans, except the unknown self-heal. | Generated 33/66/100% chance on a landed positive nonperiodic hit crossing from at/above 30% to below 30%; actor-owned 30-second cooldown; one guaranteed critical for the next resolved Nature damage or healing spell. Self-heal amount/formula remains unwired. See [event model](WOW_SHAMAN_NATURES_GUARDIAN.md). |
| RoM Scout paired passive markers | Implemented for three exact pairs. | Wind Return 499548, Revival Engraving 499552, and Forest Guidance 499575 stay learned markers; translated child effects no longer appear on the passive owner. Their absent trigger rows and unsupported companion/pet target behavior remain open. See [source audit](ROM_SCOUT_REMAINING_PASSIVES_AUDIT.md). |
| RoM Priest/Champion Protection Rune Seal | Implemented for player and generated-companion owners. | Positive periodic Regenerate 490269 heals may use local 8% chance to reset only that actor's learned Heart Rune Energy 498612 cooldown. CoA gives only “slight chance,” so 8% is a local fallback. A possible healing-strength rider remains unsourced. See [source/runtime audit](ROM_PRIEST_REMAINING_PASSIVES_AUDIT.md). |
| RoM Knight/Rogue Panther's Blessing | Unsupported passive output suppressed. | 493047 remains a learned marker; local +2 Dodge child has no proc chance or shield-state handler. Current CoA reuses the ID for a different skill, so no cross-version mechanic is mapped. See [trigger audit](ROM_KNIGHT_REMAINING_REFERENCES_AUDIT.md). |
| RoM Champion Shield Form | Learnability question resolved; runtime still open. | Local `learnmagic.db` has Champion-primary 498562 at level 16 behind Dwarf flag 546211 and quest progression. The level-derived catalog omits flag-gated acquisition; its toggle and child effects remain unimplemented. Other Champion proc gaps remain open. See [source audit](ROM_CHAMPION_REMAINING_MECHANICS_AUDIT.md). |
| RoM Warrior/Druid Rage of Calmness | Quantified Whirlwind clauses stay implemented; the post-hit general Rage discount stays unwired. | Local 494377 supplies −10 Rage and −10 seconds for local Whirlwind 490133; component 506368 supplies a 20-second post-hit window but no discount amount. Current CoA Whirlwind 494939 separately says 30%/15 seconds/20-second retrigger lockout, without linking that rider to passive 494377. Do not transfer 30%. See [bounded source audit](ROM_WARRIOR_RAGE_OF_CALMNESS_SOURCE_AUDIT.md). |
| RoM Warden Revival | Source-backed 499544→623079→499545 chain and 499542 heal integrated for player and generated-companion Rogue/Wardens. | Learned 499544 arms an actor-owned 900-second state. Only normal noncritical hits with positive post-absorb HP loss and a living owner add that actor's 623072 charge (15 seconds, max ten) and apply component 623527's six-second +20 physical-dodge-rate points. Revival heals `maxHP × (10 + 5 × charges)%` as normal/noncritical FixPer. `damagePlayer` lacks an outcome payload, so that adapter treats positive post-absorb loss as normal. See [state-chain source audit](ROM_WARDEN_REVIVAL_STATE_AUDIT.md). |
## Source and runtime blockers kept open
| Area | Finding |
|---|---|
| RoM Mage/Warden Flame Follower | Local 494051's tooltip states a one-second Fireball cooldown reduction and increased critical rate, but raw records have no linked effect, trigger phase, or critical amount. Current CoA 490244 is a later active-summon revision; reused 494051 is Purgatory Fire. Do not map these to the old RuneWaker passive. See [raw source/runtime audit](ROM_MAGE_FLAME_FOLLOWER_RUNTIME_AUDIT.md). |
| RoM Druid conditional passives | Keep source versions and event gaps distinct. Revival Slash has a linked reduced Recover child, but chance/roll semantics are absent; Quick Light Protection has no bound local hook and current CoA reuses the ID; Boiling Rock has no linked proc; level-70 Inspiration Display's bound child is deterministic +1 Nature's Power and does not implement its separate Inspiration/double-effect chance; local Avalanche remains open. Current CoA Avalanche 494029 publishes 18.8% generic defenses only at skill +105, without a lower-rank curve, defense school, or Nature's Power rider; CoA Rockslide 494063 differs from local 493543, with a two-second first hit and unspecified extra-hit timing. Extended Recovery's local +6 seconds is implemented but its Nature's Power chance remains open; Spring Flower Eulogy's separate Nature's Power chance/expiration heal is not linked in the local raw graph. Faction conversion and Rebirth debt/item behavior remain helper-only until combat models reputation, faction, debt, and item use. See [source audit](ROM_DRUID_REMAINING_PROC_AUDIT.md). |
| RoM Warlock | Completed Mind Transference 498642, Divine Assistance 498668, and caster-scoped +1 Psi on Ring of Comfort 498641 / Mind Shield 498648 stay closed. Remaining distinct gaps are Breathing Control proc chance/event phase/extra Focus, Deep Breathing Control's 15% HP trigger and extra Focus amount, Mind Shield's Ring reset chance/Defense Net increase, Psychic Warrior hybrid mitigation and extra-hit semantics plus unquantified attribute riders, hidden child 499869 identity, and End of Thought's tick interval. WoW Soul Leech 1678 stays only in the WoW section. See [source audit](ROM_WARLOCK_REMAINING_CONFLICTS_AUDIT.md). |
| WoW Paladin Vindication | Rank-3 attack-power gain stays unwired because the DUMMY effects do not identify an AP operation or child. Its rank chance and AP-reduction child are separate implemented behavior. |
| RoM Scout Enhanced Throat Attack | Extra range stays open: local passive `range` is cast distance, not a bonus. Current CoA rework does not fill the RuneWaker value. |
| RoM Rogue ammunition | Player ammo inventory/consumption remains unmodeled. |
| RoM Warden Revival native riders | HolyHeal 620302 and type-200 magical dodge remain unwired because the simulator lacks native MND/MPow/heal-rate inputs and a separate magical-dodge consumer. Do not synthesize neutral-stat healing rates or merge type-200 into physical Dodge. See [state-chain source audit](ROM_WARDEN_REVIVAL_STATE_AUDIT.md). |
## Validation and limits
The Ready-five check passed 7 Vitest and 20 Python tests. The final combined focused run passed 202 tests across 20 files with 4 IndexedDB skips. The separate Warden focused state-chain run passed 77 tests, and the exact 499544 catalog assertion passed in its filtered check; these selections overlap the combined run, so totals must not be added. The 8 GB TypeScript check and final full `npm run build` passed after the Revival state chain and final narrow Hunter placement edit; prebuild asset audits and release-bundle verification passed. The final `npm run release:check` passed on 63 changed paths, including untracked files.
Final fresh-dist Hunter UI QA passed. In the loaded scene at 390×844, the selector card spans y=306–420, clears the party row ending at y=269 by 37px, and does not overlap the top-right HUD. The selector is disabled while real Wailing Caverns enemies are engaged; after the encounter ended naturally, the rendered control selected Turtle, the profile showed Shell Shield 26064 and no Furious Howl, and reload retained Turtle while the other character retained Wolf. No page or network errors occurred except a favicon 404; there was no horizontal overflow. The UI-created Marksmanship characters were raised to level 10 through the progression control for this mechanics diagnostic; this is not Ready-party or balance certification. Escape/Spellbook Call/Revive remain visually unverified. See the scoped evidence in `playtest-artifacts/hunter-families/` and `PLAYTEST_NOTES.md`.
Four unrelated assertions were reproduced on clean baseline `b16821e9`: Knight Divine Punishment expected 2 entries but got 1, Knight God's Vengeance expected its deterministic proc but got none, the Mage/Druid elite milestone sequence expected 15/20/25/30… but got 15/20/25/25…, and Druid/Rogue Speed Catalysis expected 15 Energy but got none. These baseline failures are not attributed to this reconciliation. The full test suite and all-class balance sweeps were not run. Historical class blockers remain open; this reconciliation is not an all-clear.
## Later correction and current status
The counts, 202-test/20-file focused run, 63-path release check, and four reproduced assertions above are the first-pass history, not the latest status. The current generated catalog has 932 skills (590 active, 342 passive) and 937 ranks; runtime composition has 936 definitions plus 937 ranked versions. The four baseline assertions and catalog/count assertions now pass.
The later aggregate passed 382 focused tests across 35 files with four existing IndexedDB skips, and Ready-five passed 7 Vitest plus 20 Python tests. The full 8 GB production build and bundle checks passed after the Cat summon-card CSS correction, and Cat UI QA passed with its remaining visual limits recorded in the current report. The later rank-1 Forge companion event correction now emits owner/expiry-qualified auto-attack eligibility; focused Forge, Champion, and party-runtime regressions passed 74 tests across three files, and Ready-five passed again after the fix. The final post-fix 8 GB TypeScript/Vite build passed with prebuild asset audits and bundle verification; exact-final-dist Cat QA replay also passed. `npm run release:check` passed for 94 changed paths, including untracked files. See [the current follow-up](MECHANICS_BACKLOG_FOLLOWUP_2026-10-02.md) for corrected mechanics, source links, and active limits.
@@ -0,0 +1,11 @@
# Dark Energy Strike runtime note
Dark Energy Strike (498599) is implemented for the exact RuneWaker Champion/Warlock pair. The generated catalog identifies it as a level-30 skill, key item 545955, with prerequisite 497958 and ordered components 621305, 621306, and 621307. Rune Siphon (498595), the reset target, is the same pair at level 20, key item 545953, prerequisite 497958. Runtime requires the matching learned Rune Siphon row before attempting its reset.
The source collection orders a physical packet (621305), a Darkness packet (621306), then a 15% random group containing the Rune Siphon reset handler (621307). The runtime retains the source's group boundary: group 0 must produce surviving, positive effective HP damage for the target to receive group 1 and for the cast-level reset roll to qualify. A failed Darkness packet does not undo that eligibility. A group-0 kill skips the later target packet but still qualifies the cast-level reset roll. The roll is made once per single-target cast and resets only the owner's Rune Siphon cooldown; companion execution updates both party and combat-actor cooldown mirrors.
The 621306 source row declares MagicType 5 (Darkness), but the generic local component translator classifies it as physical when EffectType is 1. The exact 498599 override corrects only this cast's ordered damage schools to physical then shadow while preserving the existing source-derived packet formulas and level scaling.
The source engine treats a nonzero Parry result as surviving group 0. The pure gate helper preserves that rule, and its test covers a positive-damage parry. The live combat result currently represents parry as a zero-damage, not-landed outcome, so player and companion integration cannot currently produce that eligible parry case. Live eligibility uses positive effective HP damage after absorbs; a fully absorbed packet, miss, dodge, block, or zero result does not unlock the Darkness packet or reset roll. No synthetic absorb event was added.
Focused regressions cover ordered packet schools, physical failure and full absorb, physical kill, Darkness failure after a valid physical packet, the 15% threshold, exact class/ability/learned gates, and player/companion cooldown ownership. The catalog's existing learner checks skill level/rank but does not enforce source prerequisites; that is a general teaching-system boundary and was not changed here.
@@ -0,0 +1,57 @@
# RoM Champion Remaining Mechanics Audit
**Audited:** 2026-10-02
**Source:** the local RuneWaker `Resource` snapshot used by the generated player catalog.
This audit separates source-backed facts from fields still missing for runtime work. The local `learnmagic.db`, `magiccollectobject.db`, `magicobject.db`, and `string_enus.db` hashes match the corresponding source hashes recorded in `src/game/romPlayerCatalog.generated.json`. A missing generated-catalog entry therefore does not establish that a skill is absent from the source.
> **Later source/runtime update:** The [Champion source follow-up](ROM_CHAMPION_SOURCE_FOLLOWUP_2026-10-02.md) closes the RuneWaker conditions previously listed as absent for rank-2 Forge/Chain Drive and Dark Energy Strike, and records Rune Pulse's one-second cast-start damage window. Those mechanics are implemented. Rank-1 Forge companion auto-attacks now have owner/expiry-qualified event eligibility; a fully absorbed hit does not proc, and a landed hit grants its own stack and Rage. Focused Forge, Champion, and party-runtime tests passed 74 tests across three files. The Curse transform, full Shield Form payload, and Rune Overload target execution remain open; the older sections below retain their first-pass evidence.
## Shield Form (498562): learnability verified; execution remains open
**Outcome: source learnability is verified.** The local `learnmagic.db` contains Champion primary skill 498562 at level 16 with key item/flag 546211 and no listed prerequisite. The catalog exporter at `scripts/runewaker-pipeline/export-player-catalog.mjs` filters ordinary skill rows to `keyItemId === 0`; the separate elite-pair mapping does not include this flag-gated acquisition. That explains the generated omission. It is not evidence that Shield Form lacks a player-learnable source row.
The acquisition script `F:/Projects/Runewaker/Resource/luascript/02584.lua` offers flag 546211 through NPC organization 120245 when the player's race is Dwarf (`DrawfRace == 2`), vocation is Champion (`job == 10`), quest 425071 is complete, and the flag is not already set; selecting the option sets the flag. `02641.lua` blocks Champion skill training when flag 546211 is absent. The current character catalog in `src/app/characterCatalog.ts` also permits Champion only for Dwarves. The source condition is therefore consistent with current race/class eligibility, but additionally contains quest and learned-flag state that the simulator does not model.
The acquisition system in this simulator is level-derived and has no quest/skill-flag state. If the project later represents the skill as level-unlocked, it must explicitly treat the quest and flag grant as abstracted acquisition; it must still require the existing Dwarf/Champion eligibility and level 16. Do not make it available to other races/classes or claim the exact quest grant is simulated.
**Runtime remains unimplemented.** The local tooltip describes a toggle that increases accuracy and converts Strength to Stamina. `02550.lua` confirms the toggle-on/off path through buff 621218 and updates related conversion buffs. It computes a conversion amount as `floor(base equipment Strength * (0.1 + skill rank * 0.014))`, reads additional rank values at `MagicPowerLv_SP+5` and `+11`, and adds/cancels several persistent buffs. The source links children 498517, 498540, 498714, 498753, and 850048 through buffs 621218, 621288, and 621426, but the child payloads do not fully describe their effects. The simulator also has no separate per-skill rank or acquisition state corresponding to these `MagicPowerLv_SP` values.
Before implementing the toggle, source work must resolve the hidden child payloads and the intended relationship between Champion skill rank and the three `MagicPowerLv_SP` lookups. If implementing within the current level-only acquisition abstraction, document that quest state is abstracted. The old backlog statement that no player-learnable source skill exists should be corrected to say that source learnability is verified while runtime semantics remain open.
## Forge rank 2 (498604): proc payload is partial
**Outcome: do not implement the secondary proc yet.** The learn row identifies Champion primary skill 498604 at level 22, with prerequisite 498535. Its source components install a five-minute normal-attack trigger through buff 621251, which invokes 498590 at `onAttackTrigger rate=100`. The local description says normal-attack hits can trigger the eight-second Forge effect 621252. That effect's chance is not present in the child payload; the parent's `rate=100` describes the 498590 trigger and cannot establish a 100% chance for 621252.
Component 621253 supplies modifier values 20 (modifier types 204 and 205), and the description associates the effect with increased Rune Pulse (498529) damage and making the next Curse instant. The source does not establish the 621252 proc probability, the precise Rune Pulse modifier interpretation, or which Curse spell/event receives the instant-cast change.
**Needed to close:** a source field or handler that specifies 621252's roll chance and qualifying attack event; authoritative interpretation of 621253's modifier types; and the exact Curse spell and instant-cast application/consumption behavior.
## Chain Drive (498535): source row exists, but its payload is empty
**Outcome: source confirms the skill and qualitative behavior only.** The local Champion primary learn row exists at level 22 with key item 0 and no listed prerequisites. Its `MagicCollect` row has no components. The description says that while Forge Effect is active, normal-attack hits have a chance to reset Rune Pulse (498529)'s cooldown and increase its damage; it also says the next Curse becomes instant.
The source does not give the reset chance, precise qualifying hit/event boundary, Rune Pulse damage amount/duration/scope, or the Curse spell and instant-cast payload. Do not reuse Forge rank-2 component 621253 as the Chain Drive payload without a source link establishing that relationship.
**Needed to close:** the missing source components, an authoritative server/script handler, or a corrected source definition that supplies the probability, trigger boundary, Rune Pulse damage parameters, and Curse mapping/consumption behavior.
## Dark Energy Strike (498599): direct hit is described; Rune Siphon proc chance is absent
**Outcome: keep the unsupported cooldown proc suppressed.** The local learn row is a Champion/Warlock elite skill at level 30, keyed by 545955 and requiring 497958. Its direct hostile strike has a 20 Rage cost and source damage components 621305 and 621306. Component 621307 is a MagicFunc 7 action associated with resetting Rune Siphon, but neither it nor the related source fields specify a proc probability or a qualifying event. The tooltip only says there is a chance to reset Rune Siphon.
**Needed to close:** the chance value and whether the roll occurs on cast, landed hit, or another event, plus any target/success conditions for the reset. Do not infer a guaranteed reset from the presence of action 621307.
## Rune Overload (498572): child timing and drain are known; target selection is not
**Outcome: retain the verified RuneWaker identity and do not schedule the autonomous child yet.** In the local source, 498572 is Rune Overload and 498571 is Remodeled Body. This audit does not revisit the completed identity/catalog correction and does not import the conflicting current CoA reused-ID mapping.
Component 621241 links hidden child 498574 as an `onTimeMagicId` with a one-second interval (`dotTime=1`) and a 15-Rage-per-second drain (`dotBase=-15`). Component 621243 supplies the source damage field `damagePower=-0.6`, and the tooltip describes main-hand physical DPS damage to multiple targets in the immediate vicinity. The catalog now resolves the hidden child's 0.6 physical-damage coefficient from that component. The runtime has no autonomous timer consumer for this on-time child.
Neither the parent nor the hidden child's available target/effect fields specify a numeric radius, target-count cap, target-selection center/geometry, facing or line-of-sight rules, or whether each child tick applies once per eligible target. The source description “immediate vicinity” is insufficient to choose these values. Since the target set for each autonomous tick is unknown, the one-second proc path cannot be safely completed by supplying timing alone.
**Needed to close:** RuneWaker-version source that defines the radius and cap (including whether uncapped), target center and geometry, relevant eligibility/line-of-sight rules, and per-tick hit behavior. Do not fill these from the later CoA skill rework or a heuristic.
## Scope and implementation decision
No runtime change is made by this audit. Shield Form's source learnability question is resolved, but its toggle and child effects remain incomplete. Forge rank 2, Chain Drive, and Dark Energy Strike each lack distinct proc semantics. Rune Overload has a verified one-second child link but still lacks the target-selection semantics required for player and companion execution. None of these remaining runtime gaps meets the source-completeness bar for a safe implementation from the currently available RuneWaker fields.
@@ -0,0 +1,52 @@
# RuneWaker Champion source follow-up — 2026-10-02
This follow-up checks the RuneWaker `Resource/data` snapshot against the local `SourceCodeVS2019` engine and Lua scripts. It does not use the later CoA interpretation of reused skill IDs. No runtime or source data was changed.
## Shield Form (498562): rank slots and toggle path are partly resolved
`JobSkillBaseStruct::SaveLvPos` is documented as the skill-level storage position in `SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h:4147`; the player-catalog exporter reads the matching `learnmagic.db` byte at `scripts/runewaker-pipeline/export-player-catalog.mjs:461`. In the RuneWaker Champion learn rows, Shield Form 498562 maps to slot 4, Runecraft - Fortify 498563 to slot 5, and Last Line of Defense 498539 to slot 11. Lua `02550.lua` reads `MagicPowerLv_SP+4`, `+5`, and `+11` for those respective skills.
The self-targeted 498562 collection applies 621218 first. That MagicObject has `CheckUseLua=Lua_Job10_Skill1_Check`; the handler in `Resource/luascript/02550.lua` calculates the state when 621218 is absent. It converts base Strength using `floor(base Strength * (0.1 + Shield Form rank * 0.014))`, splits the result into buffs 621216/621217, applies 621222 using the Fortify rank, and applies 621228 when Last Line of Defense rank is positive. When 621218 is already present, the same handler cancels the conversion buffs and 621218, then returns false. `MagicProcess.cpp:1517` invokes a component's `CheckUseLua`, and `CheckUseLuaProc` removes the target when the handler returns false (`:5496`), giving the skill a source-backed toggle path.
The 621218 row also stores `OnTimeMagic_Magic=498517`, an interval of 3 seconds, and timeout child 498753. MagicCollect 498517 stores `Lua_Job10_Skill1_State` in its `LUAScript` field. The available server tree serializes these fields but has no execution use site for `MagicCollect.LUAScript`, `OnTimeMagic_Magic`, or `OnTimeMagic_Time`; that recurring refresh path is therefore not established here. The remaining Shield Form collection rows also contain unresolved payloads: 498562 groups 621218, 621288, and a self-skill check for skill 498715 leading to 621426; 498540 references 621229; 498714 conditionally references 621431; 498753 references 621289; and 850048 references 621244. The toggle's complete runtime effect is still not source-complete.
## Forge / Chain Drive: attack proc and Rune Pulse bonus are source-backed; Curse is not
MagicObject 621251 carries an on-attack trigger at rate 100 for MagicCollect 498590, lasts 300 seconds, and shares `clearFlags=1024` with rank-one Forge aura 621167. The engine dispatches mid-gear on-attack magic after the normal main- or off-hand hit when remaining `Damage != 0` (`Global_SimpleSchedular.cpp:789, 813-843`); the 100 rate therefore makes the 498590 trigger certain at that point, not the child effect. MagicCollect 498590 is self-targeted and uses Normal group processing. The rank-two 498604 is the level-22 upgrade rank of Forge 498523 with Chain Drive 498535 as prerequisite; it is not a separately learned base ability.
The groups also carry rank-two behavior outside the conditional branch: group 0 applies 621168's Forge stack on each qualifying trigger, and group 2 applies 621169's Rage restoration after group 1. Preserve that order so a failed Chain Drive roll still keeps the stack and Rage effects.
Its group 1 entries are ordered and must remain separate:
- 80% `Self_Buff` check for buff 621218, followed by MagicObject 621252.
- 30% `Rand` check, followed by MagicObject 621252.
The engine maps condition types to `Check_MyBuff` and `Check_Rand` (`MagicProcess.cpp:51-60`); both use `rand() % 100 < chance`, and the buff check first requires the exact buff ID (`:4905, 5143`). It also skips a target already marked `IsSpellOneMagic`; that marker is reset at the start of each group (`:1396`) and set after an effect attempt (`:2932-55`). Thus the second entry is a fallback for targets the first entry did not process, not an independent roll to combine into a derived overall chance. The first branch is specifically conditioned on the source Shield Form buff 621218; no rank or other inferred Shield Form state is involved.
MagicObject 621252 is an eight-second self effect with `EndUseLua=Cl_ArtisanSkill_621252`. The `Magic01_Assist` path first resolves `AssistMagic`, then invokes `EndUseLua` for each result before classifying misses or no-effect results (`MagicProcess.cpp:3876-4123`). It is therefore an effect-use/application callback, not a buff-expiry callback; the callback can still run for a no-effect result. The Lua handler resets Rune Pulse 498529, and also 498726 when the owner's sub-job is 3 (`Resource/luascript/02545.lua:361-369`).
Rune Pulse 498529's pre-use `CheckUseScript` is `cl_ChackBuff_498535` (`magiccollectobject.db` row 498529), and its catalog cast time is two seconds. `MagicProcess::SpellBegin` invokes the script before cast scheduling (`MagicProcess.cpp:187-210`). It checks for 621252 or the separate alternate buff 622185 and, when either is present, adds 621253 for one second; otherwise it still permits the cast. MagicObject 621253 contains modifiers 204=20 and 205=20. The source enum names these `AttackPlayerPowerRate` and `AttackNPCPowerRate` (`ObjectStruct.h:1646-47`); `RoleAttr.cpp:1823` applies percentage rate as `OrgSpeed * (1 + AddSpeed/100)`, and both physical and magical damage paths multiply through these outgoing rates (`MagicProcess.cpp:3614-16, 3840-42`). The source-supported effect is therefore +20% outgoing power against either player or NPC targets while the one-second buff is active. The modifier is role-wide in the engine; Rune Pulse is the cast whose pre-use script grants it, but the DB modifier itself is not restricted to that one spell.
621252 also stores `settingFlags=524387`, whose bit 19 (`524288`) is `MagicSpellFlagStruct::DisableShowMagicInfo` (`ObjectStruct.h:1392`). The source consumers found for that field only suppress client effect display (`rolesprite.cpp:5476`, `NetCli_MagicChild.cpp:651, 805, 955`); they do not change cast timing. Thus the one-second bonus begins at Rune Pulse cast acceptance and expires before its two-second impact. 498726 is reset by the 621252 callback for sub-job 3, but has no matching `CheckUseScript`, so it does not receive the 621253 bonus from that path.
The exact next-Curse spell and instant-cast transform/consumption remain unidentified. The 621252 callback only resets cooldowns, and the Rune Pulse script only applies 621253. The available Champion collection rows and scripts provide no bound Curse MagicObject or cast-time handler, so that tooltip rider must remain unwired.
## Dark Energy Strike (498599): Rune Siphon reset has a sourced 15% post-hit gate
The RuneWaker 498599 collection is single-target enemy, has `MagicSpellGroupType_Vampire1`, and has these ordered groups in `magiccollectobject.db`:
- Group 0: MagicObject 621305, physical calculation, damage power -0.7.
- Group 1: MagicObject 621306, Darkness magic calculation, damage power -70 with skill-level argument 1.
- Group 2: Rand 15, then MagicObject 621307.
For Vampire1, the engine enables erase-on-failure only for group 0 and applies the child in group 2 to the owner only if a group-0 target remains non-deleted (`MagicProcess.cpp:1426-30, 1489-1513, 1545-63`). In group 0, `Magic00_AddHP` deletes the target for Absorb, NoEffect, Miss, ShieldBlock, Dodge, or zero result value (`:2932-55`). A nonzero result such as Normal, Critical, or Parry survives; a parry remains eligible because it carries nonzero damage. Group 1 runs only for that surviving target, but its failure does not erase the target because erase-on-failure is off for later groups. Therefore the proc gate is the physical group-0 packet's surviving nonzero result; the later Darkness packet cannot turn that gate into a hit-or-both-packets requirement.
Group 2 then replaces the surviving target list with the owner and runs the 15% Rand check once for this single-target cast. MagicObject 621307 has `CheckUseLua=cl_ArtisanSkill_498599`; `CheckUseLuaProc` only calls the handler for a HIT target (`MagicProcess.cpp:5496-5512`), and Lua `02545.lua:114-118` resets Rune Siphon 498595. This supports a 15% Rune Siphon reset after a group-0 nonzero, non-blocked/non-absorbed result, not a guaranteed reset and not a roll on a miss, full absorb, block, dodge, or zero-value result.
## Remaining source gaps and implementation boundary
The Forge attack trigger, unconditional stack/Rage behavior, ordered 80%-conditioned/30%-fallback application, eight-second 621252 effect and cooldown callback, and the one-second role-wide 621253 modifier are implemented for player and companion actors. Rank-one and rank-two Forge use a shared exclusivity group, matching their common source clear flag. The simulator now grants 621253 when the cast is accepted and preserves its one-second expiry; the focused runtime test checks +20% while active and no bonus at the two-second impact. Keep the next-Curse rider unwired until a specific RuneWaker skill and cast-time handler are found. Dark Energy Strike's ordered physical/dark packets and post-physical-result 15% reset gate are source-backed as described above; the separate runtime work is owned by the Dark Energy change.
Focused validation passed: `npx vitest run src/game/romChampionForgeRuntime.test.ts src/game/romChampionRuntime.test.ts` (25 tests), including ordered roll, failed-roll stack/Rage, positive/zero/absorbed packet, rank upgrade, scoped cooldown reset, and Rune Pulse timing/damage cases. The default-memory `npx tsc -b --pretty false` check exhausted Node's 4 GB heap before reporting diagnostics; the repository owner is running the final aggregate TypeScript/release validation after concurrent edits freeze.
Shield Form still lacks complete hidden-child and recurring-state execution semantics. Rune Overload still lacks sourced target center, area geometry/radius, cap, eligibility/LOS, and per-tick target behavior as recorded in `ROM_CHAMPION_REMAINING_MECHANICS_AUDIT.md`; no new dispatch evidence closed that gap in this pass.
+40
View File
@@ -0,0 +1,40 @@
# RuneWaker Druid Remaining Proc Audit
Updated 2026-10-02. This audit covers the seven unresolved Druid-pair passives named in the mechanics backlog and the Recovery identity conflict. It does not change runtime behavior or shared audit/backlog documents.
> **Later source update:** The bounded [Druid source follow-up](ROM_DRUID_SOURCE_FOLLOWUP_2026-10-02.md) supersedes the earlier “chance absent” findings for Revival Slash 499496 (Rand 25 after original-target survival), learned Spring Flower child 499935 (Rand 30 +1 Nature Power and a 1/3/5% expiry-heal chain), and learned Inspiration Display child 499942 (ordered 60%-gated effect/30% fallback and timeout payload). The remaining healing formulas still lack verified native runtime inputs; no automatic public-Inspiration or Spiritual-Gush trigger is established. The table below preserves the first-pass audit context.
## Source method
The local baseline is the RuneWaker `Resource/data/magiccollectobject.db`, `magicobject.db`, and `learnmagic.db`, with linked functions in `Resource/luascript`. The player-catalog exporter reads component records but does not serialize `MagicRunPlot.PlotName` (`scripts/runewaker-pipeline/export-player-catalog.mjs`, `componentFor`); raw `magicobject.db` records carry the plot name at `+0x284..+0x2c3`. Therefore a generated row with no exported plot name does not show that a source script is absent. A script is treated as linked only when the raw component graph names it.
The numeric proc chances for the RuneWaker passives below were not present in their passive `MagicCollect` records or in the linked Lua examined. A chance statement in a tooltip confirms a random trigger, but not its probability. Current CoA values are recorded as later-version identities/reworks; they are not substituted into the RuneWaker baseline.
The Avalanche CoA rework is deterministic, but current source leaves its defense school and any caster-side Nature's Power grant unspecified. The current local Rockslide path resolves its zero-cast-time effects synchronously, so it does not already represent the CoA two-second first impact. A current-CoA implementation needs a delayed impact event before applying the debuff at the sourced time; do not schedule invented timestamps for the three extra hits.
## Per-skill findings
| RuneWaker skill and local behavior | Raw local source finding | Current CoA evidence and identity | Result |
| --- | --- | --- | --- |
| **Warrior/Druid Revival Slash — 499496** (level 60): a hit by hidden skill 499497 may give the caster a reduced Recover effect, blocked while Recover is active. | Passive 499496 and child 499497 are present in `learnmagic.db`. Child 499497 component 623008 links to `Cl_JOB1_8_Heal_Decapitate` in `Resource/luascript/02707.lua`; that function self-casts hidden skill 499883 using the secondary-job Magic Power level. Its hidden effect uses components 623339/623340, whose source power/base values are 30/15 versus ordinary Recover 503796/503797 values 40/20. This supplies a concrete reduced healing payload, but no chance roll/value. The script casts 499883, not public skill ID 493528, so preserve that distinction. | Later CoA patch history changes Slash to a 25% Rage-restoration proc and later renames Revival Slash to Battle Lust. Those are later-version behavior and do not quantify this local Recover proc. [Patch 10.2.1](https://chroniclesofarcadia.com/patch/10-2-1-1000); [patch 10.4.0](https://chroniclesofarcadia.com/patch/10-4-0-4000). | The local trigger and reduced hidden heal are source-backed; proc probability and exact trigger-roll semantics remain unresolved. Do not import the 25% Rage proc. |
| **Rogue/Druid Quick Light Protection — 494924** (level 50): reduces Blind Spot energy cost by 10 and may heal the lowest-HP party member when striking a target. | Passive record has no MagicObject components and no linked plot. Local Blind Spot 490311 components 620282/620297 have no plot-name hooks. No raw linked source gives chance, heal amount, recipient tie-break, or whether the hit must land. | Current CoA keeps Quick Light Protection at level 50 but assigns it ID 494923; current ID 494924 is Malicious Intent. The current tooltip retains the −10 energy and chance-to-heal-lowest-HP-party behavior, without a chance or heal value. [Rogue/Druid skill list](https://chroniclesofarcadia.com/wiki/skills/list/3-8?locale=en). | Same-name behavior is recognizable, but the ID reuse is explicit. Keep local 494924 identity for RuneWaker; do not change the proc chance or rename the generated local row from current CoA alone. |
| **Mage/Druid Boiling Rock — 494565** (level 40): increases Magma Blade Fire critical rate and gives its Earth damage a chance to restore MP. | Passive has no components or linked plot. Magma Blade 494564 has local components 620227/620228 but no plot-name hook. The passive does not expose a chance or MP-restoration amount. | Current CoA reuses 494565 but reworks it: Subterraneous Blade inflicts damage twice at maximum Green Guardian stacks, and wand Magical Damage increases by 75%. Current Green Guardian has a 20-stack cap; Subterraneous Blade also describes an extra hit above 15 stacks. [Mage/Druid skill list](https://chroniclesofarcadia.com/en/wiki/skills/list/4-8). | This is a later rework, not evidence for the local proc. At the maximum 20 stacks, the active skill's >15 extra hit overlaps the passive's “damage twice” clause; do not add another hit without resolving total hit count. The wand bonus is explicit but is outside the requested local proc and should be source-qualified if implemented. |
| **Druid/Mage Inspiration Display — 499941** (level 70): hidden child 499942 can also trigger Inspiration, with a chance to double the triggered effect during Spiritual Gush 494370. | Passive 499941 has no components. Child 499942 is in `learnmagic.db`; its component 503999 links to `lua_star_Skill_NatureBase(0, 1)` in `Resource/luascript/01465.lua`, a deterministic one-point Nature's Power addition. The linked function does not implement a random Inspiration trigger or doubling. Other components do not name a proc plot. | Current CoA reworks Inspiration Display to enhance Earth Arrow with additional Fire damage. The current Druid/Mage page shows the newer Earth Arrow behavior; historical patch 10.2.1 supplies an older level-104 extra-damage figure, while patch 12.1.4 later reworks/renames Earth Arrow. [Druid/Mage skill list](https://chroniclesofarcadia.com/en/wiki/skills/list/8-4); [patch 10.2.1](https://chroniclesofarcadia.com/patch/10-2-1-1000); [patch 12.1.4](https://chroniclesofarcadia.com/patch/12-1-4-1500). | Local doubling chance and size remain unknown. Do not borrow the old patch value as a current rank curve. This is the level-70 **Inspiration Display 499941**, distinct from Druid/Mage **Inspiration 494322** already handled elsewhere. |
| **Druid/Warden Avalanche — 494029** (level 20): local Rockslide gains extra Earth damage and may restore one Nature's Power. | Passive record has no components or linked plot. Local Rockslide 493543 has components 503812/503823 and tooltip-linked damage component 503824, but none names a proc script. No local chance value or extra-damage payload link is present. | Current CoA keeps passive ID 494029 but reworks it to make Rockslide apply Weakening Seed. Current CoA Rockslide is ID 494063. Current Weakening Seed 493546 publishes an 18.8% generic defense reduction for 20 seconds at +105; Avalanche names one Rockslide target. Current Rockslide +105 applies its first damage after 2 seconds and also has three extra Earth damage events, whose individual timing is not specified. [Druid/Warden skill list](https://chroniclesofarcadia.com/en/wiki/skills/list/8-7). | The deterministic CoA rework is source-backed, but it is not a fully specified runtime contract. Existing RuneWaker Weakening Seed components 503815/620458 are each 20-second −2% magical-defense effects (`type 170`, translated to `magical-defense`); they do not reduce physical armor. Keep this local effect distinct from CoA's +105 18.8% generic “defenses” value. The +105 value is fixed for a current-CoA profile; the skill is published as max level 105, and the project has no lower-rank curve, so do not interpolate or substitute a +55 value. Current CoA says “defenses” without naming the school, so applying the local magical-only scope to the CoA value would be an explicit assumption. The passive names one target and gives no area behavior. It should apply once at Rockslide impact; CoA lists the first hit at 2 seconds, but not the extra-hit cadence. The current local zero-cast-time 493543 effect resolves synchronously at cast completion; `AbilityEffect` has no impact-delay field, so it does not represent the CoA two-second first-hit delay. Also unresolved: whether passive application includes the active Weakening Seed's one-point Nature's Power restore. Do not add extra damage or a resource grant. A future current-CoA implementation would need pair-qualified edits in `src/game/romDruidOverrides.ts` and `src/game/romAbilityCatalog.ts`, a narrow helper such as `src/game/romDruidWardenAvalancheRuntime.ts`, plus player/companion delayed-impact support in `src/game/combatStore.ts` and `src/game/partyRuntime.ts` if no reusable impact event exists, with focused catalog/player/companion tests. Keep current CoA Rockslide 494063 versus local 493543 explicit; this does not complete the RuneWaker proc. |
| **Druid/Warden Extended Recovery — 494200** (level 25): adds six seconds to Recover and may restore one Nature's Power. | Passive has no components or linked plot. Runtime currently adds three two-second Recover ticks (six seconds) behind the passive tag; the separate Nature's Power proc probability is absent. See `src/game/romDruidOverrides.ts` cases 494200 and 493528. | Current CoA reuses 494200 as **Recovery Expert**: Recover becomes HoT-only, affects raid members around the caster, may restore Nature's Power, and requires Nature's Power to cast. The tooltip gives no proc probability, pulse-level roll, radius number, or recipient rule beyond “raid members around.” Current CoA Recover is 493528. [Druid/Warden skill list](https://chroniclesofarcadia.com/en/wiki/skills/list/8-7). | The six-second RuneWaker behavior is source-backed and implemented; its chance remains open. Current Recovery Expert is a same-ID later rework, not the local Extended Recovery contract. Any mode switch must update the catalog, cast target/effect construction, and both player and companion execution together; do not infer a per-tick chance or radius. |
| **Druid/Scout Spring Flower Eulogy — 494932** (level 50): when casting 499935, target HP below 50% gives three stacks; the passive may restore one Nature's Power and adds healing when the flower ends. | Passive has no components or linked plot. Active 499935 component 505742 links to deterministic `lua_star_Skill_NatureBase(0, 1)`; this establishes a one-point Nature's Power addition on that active child, not the passive's separately worded chance. `Lua_Skill499935_main` in `Resource/luascript/02708.lua` contains an HP threshold branch, but no raw component PlotName points to it, so it is not accepted as a bound RuneWaker hook. | Current CoA retains 494932 and the below-50%/three-stack and Nature's Power chance clauses, while Camellia Flower is now 499954. Current CoA also describes an extra heal on expiration and Warm Spring cooldown reduction; patch 11.1.5.3000 sets the cooldown reduction to 3 seconds. Neither the page nor patch gives the Nature's Power chance. [Druid/Scout skill list](https://chroniclesofarcadia.com/wiki/skills/list/8-2?locale=en); [patch 11.1.5.3000](https://chroniclesofarcadia.com/patch/11-1-5-3000). | The HP threshold and three-stack branch are explicit; chance and the local extra-heal amount are not recovered from the bound RuneWaker graph. The current Warm Spring cooldown rider is a CoA-only rework detail unless current-CoA behavior is selected. |
## Recovery identity decision
There are two Druid/Warden behaviors attached to ID 494200. RuneWaker names it Extended Recovery and says Recover lasts six seconds longer; the active runtime applies exactly three additional two-second ticks. Current CoA calls the same ID Recovery Expert and changes Recover's targeting and payload. Current CoA Recover remains ID 493528, while RuneWaker's baseline Recover is also 493528. Keep the versions separate: the current page does not establish the local proc chance, the aura pulse at which it rolls, or a numeric party radius. This finding is unrelated to Warrior/Druid Recovery of Life/Rage of Calmness.
## Raw hook cross-check
- `export-player-catalog.mjs` includes component IDs and parsed trigger fields, but not the raw `MagicRunPlot.PlotName`; inspect `magicobject.db` records when the generated row lists no components.
- Linked Revival Slash child 499497 uses component 623008 and `Cl_JOB1_8_Heal_Decapitate`; that script casts hidden 499883 on its owner. The child payload components are 623339/623340. No roll percentage is encoded in the function.
- Druid/Mage child 499942 component 503999 and Druid/Scout skill 499935 component 505742 both bind the deterministic NatureBase +1 function. The separate random claims are not implemented by that function.
- The similarly named 499935 HP-threshold function in 02708.lua is not bound by a raw plot-name reference in the inspected source graph; do not treat a same-file function name as proof of execution.
## Sources
Local RuneWaker records: `../Runewaker/Resource/data/{learnmagic.db,magiccollectobject.db,magicobject.db}` and `../Runewaker/Resource/luascript/{01465.lua,02707.lua,02708.lua}`. Current CoA links are placed by mechanic above. CoA data supports documenting current identities and reworks; it does not fill the missing RuneWaker random chances.
+1 -1
View File
@@ -16,7 +16,7 @@ The profile applies only to the `rom-druid` / `rom-rogue` pair and CoA skill IDs
## 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.
- The current CoA tooltip restores MP, Rage, Focus, Energy, Psi, and Nature's Power for party members within radius 150 and lists no activation cost. This replaces the generated RuneWaker 494023 row's 15-Energy Speed Catalysis cost along with its old haste effect. [Patch 12.1.6.2004](https://chroniclesofarcadia.com/patch/12-1-6-2000) lowers the 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.
@@ -0,0 +1,76 @@
---
type: source-audit
project: healer-man
status: source-confirmed-partial
last_verified: 2026-10-02
tags:
- healer-man
- rom
- druid
- scout
- source-audit
---
# Druid/Scout 499935 NaturePower Rider
## Decision
The linked +1 Nature Power rider and its 30% source chance are confirmed for the Druid/Scout level-50 child 499935. This task records the source only. The generated player catalog currently includes passive 494932 but omits 499935, and the child's other bound healing effects cannot be computed from the stats available to the runtime without guessing. No learner, playable ability, or combat hook was added.
This is a child-ability rider, not evidence that passive 494932 has a known Nature Power proc chance. Keep 494932's separate HP-threshold and proc clauses open. The `Lua_Skill499935_main` function in `../Runewaker/Resource/luascript/02708.lua:125` contains a below-50% branch, but no 499935 `CheckUseScript` or linked component plot calls it; it is not a bound mechanic for this skill.
## Source identity and acquisition
The source `learnmagic.db` class-8 primary rows establish:
| Skill | Learn level | Key item | Prerequisite |
|---|---:|---:|---:|
| 494932 Spring Flower Eulogy | 50 | 543156 | 490420 |
| 499935 child | 50 | 543156 | 494932 |
`../Runewaker/Resource/luascript/02248.lua:164-166` maps Druid primary class 8 / Scout secondary class 2 to key item 543156 at the level-50 elite milestone. The existing generated row for 494932 confirms the parent passive is present; the child is absent because the exporter selects one `learnmagic.db` candidate per elite key item. Preserve 499935 as a separate learned row behind 494932 if catalog work resumes. Do not alias the child to another ability ID.
Source snapshot hashes for the relevant RuneWaker inputs:
| Source file | SHA-256 |
|---|---|
| `../Runewaker/Resource/data/learnmagic.db` | `6148199bcd1484ba1a122a6db5e2b3c2f2a161402bfe67fc67eb53f55fc6cfa3` |
| `../Runewaker/Resource/data/magiccollectobject.db` | `4aae19865cb07b30f99945aa7319a0096db244ff5e5b4b5d902fc6889474f501` |
| `../Runewaker/Resource/data/magicobject.db` | `52af13a2d8cfa9a66d4caa2d3c38832edc232f058995a64ef8135a6b82e43691` |
| `../Runewaker/Resource/luascript/02248.lua` | `ee9e337548abd7fc555d85bf2f96813dfd77416c3e65d4cadf74a80838da1486` |
| `../Runewaker/Resource/luascript/02708.lua` | `f9dda4ec4c1d86febab580ac0803b9d058ad6df887b4696b8ec86570244d067a` |
| `../Runewaker/Resource/luascript/01465.lua` | `074bbea5d3de4671423f6769dbd71d3696bc039edd60a4f2880605dce42fbe84` |
## Bound child effects
`MagicCollect` 499935 targets one selected friend (`RangeType=Target`) and uses `MagicSpellGroupType_Normal`. Its group 0 is an ordered target-buff ladder:
| Target already has | Group 0 applies |
|---|---|
| 623115 | 623115 |
| 623111 | 623115 |
| 623109 | 623111 |
| none of these | 623109 |
Each selected buff is a six-second Assist effect with one-second DoT ticks. Its raw base is 15 for 623109, 25 for 623111, and 35 for 623115; each has `DotSkillLVArg=16`. Their `OnTimeMagicID`s are 494952, 494953, and 494954. The three timeout collections apply fixed max-health healing of 1%, 3%, and 5%, respectively. The native timer runs the one-second DoT pulses and then queues the matching timeout effect once at expiration.
The periodic heal amount also depends on native `CalAddDotMagicLv` / `AssistMagic` calculations using MDMG, MND, HealPowerRate, HealPoint, MPow, and target healing absorption. Current `src/game/romStats.ts` models STR, STA, INT, WIS, AGI, HP, and Mana; `src/game/combatHealing.ts` resolves a supplied scalar amount. It does not expose the source inputs or full formula needed to represent these ticks accurately. The expiration percentages are known, but they do not complete the child healing envelope.
## Separate group-1 rider
`MagicCollect` 499935 group 1 slot 0 is `EM_MagicCheck_Rand` with value 30 and MagicObject 505742. Object 505742 is `MagicFunc=RunPlot` and names `lua_star_Skill_NatureBase(0,1)` from `../Runewaker/Resource/luascript/01465.lua:145`. The script adds one 503827 Nature Power stack to `OwnerID()` and normalizes the caster's own stack count. Its source cap is 10, with up to five extra points for Druid/Scout when flag 543146 is present.
The native `MagicProcessClass::Check_Rand` uses `rand() % 100 < RandValue` for each eligible target. In Normal group mode, group 1 runs independently of group 0. The selected-target range means this cast has one target candidate and one group-1 roll. `Magic07_RunPlot` dispatches the plot only for HIT or CRITICAL target results; it does not require a positive healing amount. Since `NatureBase` reads `OwnerID()`, the rider belongs to the caster even though the selected target is a friend.
This bound 30% child rider does not establish the separate 494932 passive's chance, the passive tooltip's below-50% threshold behavior, or any passive-owned expiration clause. Those remain unresolved. The child group-0 timeout heals above are separately visible source effects; they are not represented as a completed passive implementation.
## Future integration boundary
If a later task supplies or implements the missing periodic-heal inputs, keep the work narrowly scoped:
- Add the exact level-50 Druid/Scout 499935 learner in `scripts/runewaker-pipeline/export-player-catalog.mjs`, requiring key item 543156 and learned 494932.
- Suppress only an unconditional translation of component 505742. Preserve the 30% Rand check, Normal group execution, successful target-result phase, and one NaturePower grant to that cast owner, capped by that owner's pool.
- Preserve the group-0 tier ladder and its three expiration heals. Do not infer HP-threshold behavior from the unbound Lua helper or attach 494932's unknown passive chance to the child rider.
- Integrate player and generated-companion casts behind the exact learned child and pair in `src/game/romCombatMechanics.ts`, `src/game/combatStore.ts`, and `src/game/partyRuntime.ts`. Keep companion resource gains on the companion's own pool.
No exporter, catalog, runtime, or test changes were made for this note-only task.
@@ -0,0 +1,33 @@
# RuneWaker Druid Source Follow-up
Updated 2026-10-02. This is a read-only continuation of [the remaining-proc audit](ROM_DRUID_REMAINING_PROC_AUDIT.md). It follows native collection conditions, callback dispatch, and the corresponding LearnMagic rows. It does not repeat the prior PlotName graph audit or substitute later CoA identities. No runtime files were changed and no tests were run.
## Native dispatch findings
`MagicCollectCondition` stores a check function, component ID, and two arguments (`../Runewaker/SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h:4389-4393`). The source enum is Normal=0, FirstGroupHit=1, PrvGroupHit=2, Vampire=3, Vampire1=4 (`ObjectStruct.h:4412-4418`). In `magiccollectobject.db`, the mode int32 is at record `+0x2C8`: 499935 record `0x9C7304` has bytes `00 00 00 00`; 499497 record `0x958D54` has `01 00 00 00`; 499942 record `0x9C8F3C` has `03 00 00 00`. These offsets pin the mode independently of the condition rows. `Check_Rand` selects a surviving target when `rand() % 100 < RandValue` and skips deleted targets or targets already resolved in that group (`../Runewaker/SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp:4905-4918`). The engine resets `IsSpellOneMagic` at each group, runs each condition in order, and dispatches its component after the check; group target transitions are handled in the same loop (`MagicProcess.cpp:1390-1534`). Therefore ordered 60/30 rows are not a combined-roll estimate.
`MagicBase.CheckUseLua` is called after the collection condition and before the component effect. The Lua guard only evaluates HIT targets; a false return removes that target from the pending effect list (`MagicProcess.cpp:5496-5518`). `EndUseLua` runs from effect handlers after the result is known. `MagicCollect.CheckUseScript` is a separate cast-use check (`MagicProcess.cpp:187-212, 935-962`). The targeted rows have no such collection script. The `MagicCollect.LUAScript` member is declared in `ObjectStruct.h:4489-4497`; no consumer for that field was found in the targeted NetGameServer source. The global Lua VM loader loads FDB or configured text scripts and calls `Initialize`; the split loader also loads `C:\Runewaker\Resource\luascript` (`../Runewaker/SourceCodeVS2019/Server/NetGameServer/MainProc/Global.cpp:219-285, 301-325`). `Magic07_RunPlot` dispatches a component's `RunPlot.PlotName` on HIT or CRITICAL (`MagicProcess.cpp:4719-4743`).
The raw event-trigger slots were checked on the linked Revival Slash components and the Inspiration child payloads. `623008` has `OnAttack` tuple `[10,0,0]` (rate with no trigger ID); its on-time, on-hit, timeout, rebound, and death IDs are zero. `623339` and `623340` likewise have no event IDs. The 499942 delay buffs are the exception: both carry `OnBuffTimeOutMagicID=494344`. Spring Flower's stack buffs carry a separate `OnTimeMagicID` expiry event, detailed below. `BuffProc` decrements the timer, applies periodic damage/healing ticks, then queues the timed magic at a matching remaining-time divisor before removing an expired buff (`../Runewaker/SourceCodeVS2019/Libraries/Union/RoleData/RD_ValueCal.cxx:3456-3470, 3607-3622`). Thus the numeric proc chances below come from collection Rand fields, not an inferred callback or plot-name convention.
## Source-backed candidate rows
| RuneWaker mechanic | Exact local evidence | Status for runtime work |
| --- | --- | --- |
| **Warrior/Druid Revival Slash 499496 / hidden child 499497** | `learnmagic.db` has Warrior primary entry 128: 499496, level 60, key 546742, prerequisite 493528; general entry 28 is hidden 499497 with prerequisite 499496. Child 499497 is `FirstGroupHit` mode (raw record `0x958D54`, mode field `+0x2C8` = 1). Group 0 requires the original hostile target's hit to survive; misses/zero-value failures delete that target. Groups 1 and 2 retain the original target, with later hit types forced to HIT (`MagicProcess.cpp:1425-1459, 1544-1558, 2932-2954`). Group 2 is one `Rand 25 -> 623008` check. Its plot calls `Cl_JOB1_8_Heal_Decapitate`, which self-casts 499883 at secondary-job Magic Power level; the separate CheckUseLua only guards against active 623340 or Recover 503797 (`../Runewaker/Resource/luascript/02707.lua:43-58`). | **Chance resolved: 25%, once on the surviving original target per cast.** The hidden heal is not yet a complete runtime formula. Components 623339/623340 use HolyHeal base 30 and periodic base 15 (skill arguments 20/18); normal Recover 503796/503797 uses 40/20. Native HolyHeal also uses MND, MDMG, MPow, HealPowerRate, and HealPoint (`MagicProcess.cpp:2636-2655`). Do not scale ordinary Recover by 0.75. The current runtime has no targeted 499496/499497/499883 handler. |
| **Druid/Scout Spring Flower Eulogy 494932 / active 499935** | `learnmagic.db` Druid entry 71 is 499935, level 50, key 543156, prerequisite 494932; `02248.lua:164-166` maps Druid/Scout level 50 to key 543156. Its Normal group mode is 0 (raw record `0x9C7304`, mode field `+0x2C8`). Group 0 is an ordered target-buff ladder: 623115 if target has 623115, otherwise 623115 if it has 623111, otherwise 623111 if it has 623109, otherwise 623109. Group 1 is `Rand 30 -> 505742`; 505742 binds `lua_star_Skill_NatureBase(0, 1)` in `01465.lua:145`, granting one Nature Power. The similarly named `Lua_Skill499935_main` function in `02708.lua:125-143` is not bound to the inspected component. | **Proc chance and expiration effect resolved:** 30% for +1 Nature Power on learned/cast 499935, plus a bound 1/3/5% maximum-HP end heal. The stack buffs have six-second duration (`EffectTime_SkillLVArg=0`) and one-second Dot cadence, with bases 15/25/35 and DotSkillLVArg 16; native tick scaling depends on MDMG, MND, HealPowerRate, HealPoint, MPow, and target healing absorption (`RD_Normal.cxx:2423-2501`). Each tier's OnTimeMagic is scheduled at interval six: 623109 -> collection 494952 -> component 505706 (FixPer HP 1); 623111 -> 494953 -> 505740 (FixPer HP 3); 623115 -> 494954 -> 505741 (FixPer HP 5). The final DoT tick and end heal occur when the six-second buff reaches zero. The below-50% Lua helper remains unbound. This is a source-backed candidate tied by LearnMagic prerequisite to 494932. The current generated player catalog has no 499935 row, so HealerMan has no invocation or runtime approximation for it yet. |
| **Druid/Mage Inspiration Display 499941 / hidden 499942** | `learnmagic.db` Druid entry 91 is 499942, level 70, key 546840, prerequisite 499941. Its `Vampire` group mode is 3 (raw record `0x9C8F3C`, mode field `+0x2C8`); the later group target redirects to the owner after an earlier target survives. Group 2 is ordered `Rand 60 -> 623123`, then `Rand 30 -> 504890`. A successful first assist marks the target resolved and suppresses the fallback. 623123 is gated by `lua_bico_dru_mag_es_70` checking Spiritual Gush buff 505163 (`02708.lua:154-161`); if that guard rejects the first effect, the second check can still run. Both one-second special buffs time out to collection 494344 (`../Runewaker/SourceCodeVS2019/Libraries/Union/RoleData/RD_ValueCal.cxx:3042-3070`), whose effect is component 504863: 10 seconds, modifier type 51 (`SpellSpeed_AllMagic`) at -1000 (`ObjectStruct.h:1427-1478`). | The hidden row and its learned prerequisite are source-backed. This establishes the child’s ordered 60% gated effect / 30% fallback and timeout payload. It does **not** establish an automatic cast from public Inspiration 494322 or an automatic trigger from Spiritual Gush 494370; 505163 is only the 623123 gate. Model it, if selected, as the 499942 variant with this exact order and payload. The current generated player catalog has no 499942 row. |
## Remaining unresolved mechanics
- **Quick Light Protection 494924:** its passive row has no component conditions; active Blind Spot 490311 has ordered SelfEqType checks but no Rand chance. The proc probability, heal amount, and recipient tie-break remain unknown.
- **Boiling Rock 494565:** its passive has no component conditions; active Magma Blade 494564 has no Rand condition. The MP-restoration chance and amount remain unknown.
- **Avalanche 494029:** its passive has no component conditions; Rockslide 493543 has no Rand condition. The local extra-damage and Nature Power proc payloads remain unbound.
- **Extended Recovery 494200:** neither its passive row nor Recover 493528 exposes a Rand condition. The existing runtime approximation at `src/game/romDruidOverrides.ts:162-163` adds six seconds; the separate Nature Power chance remains unknown.
- **Spring Flower Eulogy 494932:** its passive row has no group condition. The sourced 499935 Rand30 clause above is usable as the learned child mechanic; other passive clauses still have no bound numeric chance in the inspected rows.
These checks cover the named parents, active rows, learned hidden children, and linked effect objects only. RuneWaker remains separate from the later CoA reworks recorded in the original audit.
## Healing-input boundary
The RuneWaker stat enum identifies MND as stat type 5 (`ObjectStruct.h:1427-1434`), and the HealerMan exporter maps source stat type 5 to `wisdom` (`scripts/runewaker-pipeline/export-rom-heirloom-reference.mjs:23-27`); `src/game/romStats.ts:17-37` exposes that base value. This is a verified base-stat alias, but the targeted RoM combat path does not provide the final effective MND value consumed by the heal formulas. The direct HolyHeal formula also needs native MDMG, the cast's Magic Power level, MPow, HealPowerRate, HealPoint, and target HealAbsorbRate/HealAbsorb (`MagicProcess.cpp:2640-2674`). Periodic heal scaling uses the analogous caster values and target heal absorption through `CalAddDotMagicLv` (`../Runewaker/SourceCodeVS2019/Libraries/Union/RoleData/RD_Normal.cxx:2423-2501`). No verified RoM-runtime aliases were established here for those final values. Keep the Revival Slash heal and Spring Flower periodic tick amounts blocked until those mappings exist; the Spring Flower expiry percentages are separately source-defined.
@@ -0,0 +1,27 @@
# RoM Knight/Rogue Panther's Blessing dodge proc
## Source contract
The current CoA [Knight/Rogue skill list](https://chroniclesofarcadia.com/wiki/skills/list/6-3?locale=ru) identifies 493047 as Panther's Blessing, a level-45 passive. Its current tooltip says a dodge restores 10% HP and 1% MP. This identity is separate from level-50 God's Vengeance 499934.
The current skill row publishes no rank curve, and the tooltip does not define the percentage bases or integer rounding. The simulator therefore applies the flat published 10% and 1% values to the actor's own maximum HP and mana pools, rounds each request to a whole point, and caps each restore at the current maximum. These are explicit simulator interpretations, not separately published CoA details.
The local RuneWaker 493183 child describes a different shield-conditioned chance to grant +2 Dodge. Its chance and required shield check are unavailable, so the child stays suppressed while the current CoA proc runs independently.
## Runtime ownership and boundaries
`src/game/romKnightPanthersBlessingRuntime.ts` owns the exact Knight-primary/Rogue-secondary, learned-passive, living-owner, resolved-dodge gate and the pool-basis calculation. `src/game/combatStore.ts` owns both real-player and generated-companion incoming-hit resolution, so it applies the helper there and records the HP and mana outcomes under `rom-knight-rom-rogue-elite-45`.
The trigger is the combat resolver's actual `combatResult.dodged` outcome. Misses, parries, blocks, ordinary hits, other class pairs, and an unlearned level-45 passive do not trigger it. The generic `damagePlayer` adapter has no dodge result and does not synthesize one. The current resolver exposes dodge for melee attack kinds; this runtime does not add another chance roll, cooldown, shield requirement, rank scaling, or event source.
The player scalar pools and player actor snapshot are updated together when present. Companion health and mana are written through the authoritative actor and mirrored to the party member.
## Validation
Focused validation:
```text
npm test -- --run src/game/romKnightPantherDodgeProc.integration.test.ts src/game/romKnightPanthersBlessingRuntime.test.ts src/game/romKnightOverrides.test.ts
```
The focused command passed all 21 tests across the integration, helper, and catalog files. The integration cases cover a capped player restore, a generated-companion restore and mirror, below-level gating for both actor paths, and the no-synthetic-dodge boundaries for direct damage and a spell hit. The catalog test confirms the stale 493183 child remains absent.
@@ -0,0 +1,38 @@
# RoM Knight periodic and triggered reference audit
This audit closes the bounded review of triggered references on Knight-primary RuneWaker rows. It distinguishes source data that exists locally from fields the player exporter omits, and records where current combat behavior still depends on missing state or target semantics.
> **Current runtime correction:** Panther's Blessing 493047 follows the current same-name Knight/Rogue passive and restores 10% max HP plus 1% max MP after a resolved dodge. The max-pool basis and whole-point rounding are simulator interpretations; the legacy RuneWaker +2 Dodge child remains suppressed. God's Vengeance is the separate level-50 passive 499934, linked to learned child Punishment 499909. See the [Panther runtime note](ROM_KNIGHT_PANTHER_DODGE_PROC_2026-10-02.md) and this audit's current table entries; older source rows below document the legacy conflict.
## Source and runtime ownership
The player catalog retains component IDs and trigger spell IDs, but not `MagicRunPlot.PlotName`. The field exists in `F:\Projects\Runewaker\SourceCodeVS2019\Libraries\Union\RoleData\ObjectStruct.h` (`MagicRunPlot::PlotName`); `MagicProcess.cpp`'s `Magic07_RunPlot` reads that field and calls the Lua function for proc targets. The local `F:\Projects\Runewaker\Resource\data\magicobject.db`, `magiccollectobject.db`, and the referenced `Resource\luascript\*.lua` files are present. `scripts/runewaker-pipeline/export-player-catalog.mjs` currently does not export the RunPlot name, so the omission is in the generated player record, not evidence that the local scripts are missing. This audit does not expand the exporter into a Lua-runtime implementation.
Generated player source IDs and component payloads are in `src/game/romPlayerCatalog.generated.json`. Translation and the unsupported generic radius fallback are in `src/game/romAbilityCatalog.ts`; Knight-specific passive shaping is in `src/game/romKnightOverrides.ts`; pair-specific Knight and Scout behavior is in `src/game/romPriestKnightOverrides.ts`. Incoming-hit, periodic, and companion event wiring lives in shared combat/runtime files and needs a separately scoped change.
## Knight-primary trigger references
| Parent → child | Local source/script result | Current implementation and disposition |
| --- | --- | --- |
| 490192 → 490469 | Component 500149 triggers on magic-attack rebound during its 30-second shield. Child payload 500213 is Light damage. | Covered by the custom Shield of Discipline path in `romAbilityCatalog.ts`; complete for this audit. |
| 492935 → 493094 | Component 503192 triggers every 3 seconds for 15 seconds. Child RunPlot is `lua_star_ParryState` in `01129.lua:120`: it checks `EM_RoleValue_ParryState == 1`, checks buff 503192, then adds buff 503200 (Lua argument 20); if 503192 is absent it cancels 503200. Component 503200 carries +5000 Parry and a 15-second lifetime in the local object data. | Current runtime applies +5000 Parry for 15 seconds and consumes it after two blocks; the hidden periodic aura is also kept for 15 seconds. The `EM_RoleValue_ParryState` set/reset condition and the distinct conditional 503200 application are not represented. Prerequisite: map that role-state value and its event lifecycle before wiring the child; do not turn the Lua argument into a duration by assumption. |
| 492939 → 493246 | Component 503207 triggers on timeout after 15 seconds. Child RunPlot calls `lua_Dy_DELAnother(503207, 506236)` in `01129.lua:518`; the helper cancels 506236 only when 503207 is absent. Both local effects are 15 seconds. | No distinct numeric combat payload is added by the child. Natural expiry aligns; early removal of 503207 and the resulting cleanup of 506236 are not represented. Prerequisite: source-backed buff-removal/timeout event semantics if early cancellation is intended to be observable. |
| 493273 → 491614 | Component 502056 triggers on hit. `lua_star_ProtectAlly` in `01129.lua:313` reads last damage, the buff owner, and `BuffInfoType_Power` (`PLV`), removes 502056, then computes `p = 0.40 + 0.008 × PLV`; it casts child 491612 on the original buff owner with `floor(abs(lastDamage × p / (1 − p))) × 2`. | `romPriestKnightOverrides.ts` currently uses a fixed 40% one-hit redirect and +20 Parry. This is an active approximation: it does not preserve the buff's `PLV` or apply the rank-dependent split formula. Prerequisite for a correct shared player/companion implementation: define the source value stored as `BuffInfoType_Power` for 502056, then carry it through the application and incoming-hit paths in `combatStore.ts`/`partyRuntime.ts`; cover it in `romPriestKnightPairValidation.test.ts`. |
| 499923 → 499910 | Component 623167 triggers every second for 30 seconds. `lua_bico_kni_sco_es_70_blood` in `02708.lua:257` cancels 623167 whenever owner buff 500871 is absent. | Knight/Scout passive marker and Blood Arrow's 50% healing-received/30-second effect are represented. The source's early-cancel check when 500871 is removed is not. Prerequisite: observe source-marker removal and cancel the associated aura on player and companion paths; ordinary matching 30-second expiry is already compatible. |
| 495402 → 497924, 497926 | These are timeout children of the old Leopard Instinct active effects. The local `un_495402_cancelbuff` RunPlot in `02383.lua:182` cancels old buffs 620144/620141/620142/620143. | Superseded by current CoA's passive stack behavior. Existing pair handling suppresses the old active effects; do not restore these timeout children. |
| 493047 → 493183 | The local RuneWaker 493183 child is a MagicFunc1 on-hit self-reference. Its tooltip conditions a chance-based +2 Dodge aura on being attacked while Shield of Valor or Shield of Discipline is active, but the numeric chance and required-shield check are absent. | Current CoA's [Knight/Rogue row](https://chroniclesofarcadia.com/wiki/skills/list/6-3?locale=ru) identifies 493047 as Panther's Blessing, a level-45 passive that restores 10% HP and 1% MP on dodge. The exact learned Knight/Rogue passive now triggers from the actual resolved incoming dodge result for the player and generated companions. Because CoA does not publish the percentage bases or a rounding rule, the simulator uses the owner's maximum HP and mana pools and rounds to whole points; each restore is capped to its current pool. The published current row has no rank curve, so these percentages are flat and no scaling is inferred. The legacy 493183 shield-conditioned +2 Dodge child stays suppressed. God's Vengeance is the separate passive 499934. See `docs/ROM_KNIGHT_PANTHER_DODGE_PROC_2026-10-02.md`. |
| 499934 → 499909 | Current CoA's [Knight/Rogue list](https://chroniclesofarcadia.com/wiki/skills/list/6-3?locale=ru) identifies God's Vengeance as passive 499934, level 50: when Punishment hits a target with exactly four Holy Seal stacks, reset Shadow Step. The local LearnMagic row independently makes child 499909 learnable by Knight/Rogue at level 50 with key item 542123 and prerequisite 499934. Its MagicCollect components match general Punishment 490151's payload; 499909 remains a separate child row rather than an alias of general 490151. | The exporter emits this exact linked child as a second level-50 row. It keeps Punishment's direct damage and ordinary Seal consumption, scales the one hit by the pre-spend Seal count, and removes the retired Seal DoT. Player and generated-companion casts reset only the casting actor's learned Shadow Step (490344) when the exact child lands against its own target with four of that target's Seals. A landed combat event is the runtime's “hits” boundary, including full absorbs; miss/dodge, wrong pair, missing passive/Shadow Step, general 490151, and three/five-stack cases do not reset. Companion AI selects 499909 when it is ready and usable, otherwise retaining its general Punishment fallback. |
| 491606 → 497471 | Parent component 502049 triggers every 2 seconds for 6 seconds. Child RunPlot `Dy_KM15_01` exists in `01129.lua:571`; it reads `MagicPowerLv_SP + 42` and casts 491607 on the owner. MagicCollectObject 491607 is present and names components 502050 and 502051. Component 502050 has Light damage fields `damagePower=-20`, `fixedValue=-0.2`; 502051 is the tooltip's damage-reduction aura. | The periodic damage and reduction component are retained by the catalog override, but the child's actual affected-unit geometry/radius is not established. See the separate radius finding below. |
| 490078 → 490079 | Parent component 502103 triggers every 3 seconds for 18 seconds. Child RunPlot `YOYO_JOB6_4_ExpeditionBlessing` exists in `02842.lua:236`; it advances owner buffs 500140 → 500146 → 500168 → 500169. | This is the old RuneWaker child behavior, not the current War Prayer result. Current CoA/patch behavior applies a personal Holy Seal stack every 3 seconds; the existing six-pulse runtime follows that current behavior. Do not import the stale four-buff sequence as Holy Seal. |
## Holy Light Domain's 10-unit fallback
`areaRadius()` in `romAbilityCatalog.ts` matches the tooltip phrase “surrounding targets within range.” Holy Light Domain is self-targeted with catalog range 0.1, so the generic fallback assigns radius 10. The focused `romKnightPeriodicRuntime.test.ts` currently asserts that radius and verifies the compatibility behavior: targets at x=5 and x=9 receive the effect, while x=12 does not. This is test coverage for the existing approximation, not source evidence for a 10-unit radius.
The child source record exposes `effectRange=50` and `rangeType=3`, but the player export does not preserve a verified conversion of those fields to the runtime's target geometry. The RunPlot confirms the child cast is made on the owner; it does not define the server's affected-unit selection rule. Until a verified interpretation of the child target/range fields is available, leave the radius explicitly unsupported: do not treat 50 as a runtime radius, do not infer a 5-unit radius, and do not map child 497471 to another effect.
## Validation
- Focused validation passes: `romCombatMechanics.test.ts`, `romKnightPeriodicRuntime.test.ts`, `romKnightOverrides.test.ts`, `romPlayerCatalog.test.ts`, `partyAbilityAi.test.ts`, and `romKnightGodsVengeance.integration.test.ts` (77 tests total). The touched Knight Holy Seal semantics case also passes when run alone from `romAbilityCatalogSemantics.test.ts`.
- The previously reported baseline failures are resolved by matching behavior to current sources: Divine Punishment emits one direct hit plus the seal-scaled vulnerability rather than two damage outputs; current CoA God's Vengeance resets Shadow Step on the linked 499909 Punishment at four Seals, rather than expecting the retired Holy Seal DoT.
- Official source rows reviewed: the current CoA Knight/Rogue list gives 493047 Panther's Blessing (passive, level 45) and 499934 God's Vengeance (passive, level 50); the official [10.6.1 patch notes](https://chroniclesofarcadia.com/en/patch/10-6-1-1000) document Holy Seal's damage/vulnerability behavior and removal of its periodic damage. Shadow Step is local Rogue general 490344, level 28 ([official Rogue/Mage list](https://chroniclesofarcadia.com/wiki/skills/list/3-2)). Panther's Blessing event ownership and the explicit max-pool simulator interpretation are covered in `docs/ROM_KNIGHT_PANTHER_DODGE_PROC_2026-10-02.md`.
@@ -0,0 +1,25 @@
# RoM Mage/Warden Flame Follower 494051: raw source/runtime audit
Status: unresolved; no runtime mechanic was added.
## Finding
The available local RuneWaker data does not provide a numeric Fireball critical-rate bonus or a trigger phase for the old Mage/Warden passive. Keep `494051` unresolved. Current CoA Flame Follower `490244` is a later active-summon revision, and the current reuse of `494051` for Purgatory Fire does not establish the old passive's behavior; see [the identity/source review](ROM_MAGE_WARDEN_FLAME_FOLLOWER_SOURCE_REVIEW.md).
## Raw RuneWaker record trace
The local `Resource/data/magiccollectobject.db` and `magicobject.db` hashes match the RuneWaker source hashes recorded in `src/game/romPlayerCatalog.generated.json`. The player-catalog exporter records component IDs and selected fields, but omits `MagicCollect.LUAScript`, `MagicCollect.CheckUseScript`, `MagicBase.CheckUseLua`, and `MagicRunPlot.PlotName`; the latter is the 64-byte `MagicRunPlot` field at raw MagicObject record offset `0x284..0x2c3`.
- Raw MagicCollect `494051` is at file offset `4,180,772` in the 140-byte-header, 1,032-byte-record database. It has source effect type `2` and target type `0`. Searching every aligned word across that record finds no other MagicCollect or MagicObject ID, and the record contains no printable script-name string. The source skill tooltip remains qualitative: it says Fireball cooldown is reduced by one second and Fireball critical rate is increased.
- Raw Fireball MagicCollect `490204` references MagicObject components `620199`, `500379`, and `623146`, and passive MagicCollect records `490221` (Eruption), `491589` (Outburst), and `499588` (Meditation Path). It does not reference `494051`.
- Fireball damage components `620199` and `500379` have `MagicFunc=0`, `Attack.CriticalRate=0`, and `MagicRunPlot.PlotName="none"`. The status component `623146` has `MagicFunc=1`, `Attack.CriticalRate=0`, and `PlotName="none"`; its raw `CheckUseLua` field names `un_623522_CD`.
- `Resource/luascript/02709.lua` defines `un_623522_CD` as an owner-marker gate: it returns false when the owner already has buff `623522`; otherwise it adds that buff and returns true. It does not read `494051`, modify critical rate, or alter Fireball cooldown. In the server, `CheckUseLuaProc` invokes this kind of component check for per-target `HIT` results only, before applying the component. That documents the phase of this separate Fireball child check, not the Flame Follower passive.
- No RuneWaker Lua file contains `494051` or `Flame Follower`. The engine's `Magic07_RunPlot` path is component-scoped: it calls a named plot for each target with `HIT` or `CRITICAL` outcomes. The `494051` row has no linked MagicObject, and Fireball's three referenced MagicObjects do not use `MagicFunc=7` or name a plot, so this engine rule does not identify a Flame Follower trigger.
The relevant definitions are in `F:\Projects\Runewaker\SourceCodeVS2019\Libraries\Union\RoleData\ObjectStruct.h` (`MagicRunPlot`, `CheckUseLua`, and `LUAScript`), and the dispatch is in `F:\Projects\Runewaker\SourceCodeVS2019\Server\NetGameServer\MainProc\MagicProc\MagicProcess.cpp` (`Magic07_RunPlot` and `CheckUseLuaProc`). The generated extractor's selected-field behavior is visible in `scripts/runewaker-pipeline/export-player-catalog.mjs`.
## Missing evidence
The raw old passive row has no identified critical-rate amount, child effect, script name, or link to a Fireball component. The linked Fireball graph has no critical-rate payload for this passive and no handler that checks whether `494051` is learned. The general engine callbacks establish what an attached check or plot would do, but the available source does not attach either callback to Flame Follower.
Closing the item requires a matching-version source that connects the learned `494051` passive to a Fireball event and specifies the critical-rate amount. Without that source, the amount, whether the passive responds to a cast, a landed hit, or a critical hit, and any associated conditions remain unknown. No helper or integration test should encode those values until the missing source is recovered.
@@ -0,0 +1,55 @@
# RoM native stats source contract (2026-10-03)
This adapter is a source-derived RoM stat model for a selectable simulator ruleset. It keeps the existing shared `CharacterStats` contract available to combat code, but its `romNative` sidecar retains Runewaker semantics and raw point channels. It does not convert RoM hit, dodge, critical, or parry points to WoW ratings or percentages.
## Source records and identity
The stat exporter reads `racetable.db`, `sextable.db`, `voctable.db`, and `fixtable.db` from `F:\Projects\Runewaker\Resource\data`. Records begin at byte 140 and are 408 bytes. Vocation records contain two 408-byte records per class. The exporter validates the expected record IDs before interpreting floats and fails on non-finite values.
The player vocation argument rows are IDs 301003, 301005, 301007, 301009, 301011, 301013, 301015, 301017, 301019, and 301021 for Warrior through Champion. Playable race rows are 300000–300002 (Human, Elf, Dwarf); gender rows are 302000–302001 (male, female); the player formula/fix row is 303001. The generated output includes all ten classes, all three races, and both genders. The race and gender growth contributions are zero in these records, but their identities remain explicit inputs.
Formula definitions were checked against:
- `F:\Projects\Runewaker\SourceCodeVS2019\Libraries\Union\RoleData\RoleAttr.cpp`
- `F:\Projects\Runewaker\SourceCodeVS2019\Libraries\Union\RoleData\ObjectStruct.h`
- `F:\Projects\Runewaker\SourceCodeVS2019\Server\NetGameServer\MainProc\MagicProc\MagicProcess.cpp`
`romNativeStatRules.generated.json` records the source file hashes, row IDs, per-class/race/gender growth and coefficients, player FIX values, and formula notes. The generator also checks the Human/male base curves against `romBaseStatGrowth.generated.json`.
## Verified native formulas
At each level above 1, each base stat follows the table-defined progression `value = value * (1 + combinedPercentGrowth) + combinedFlatGrowth`. For a secondary vocation, Runewaker adds the secondary base at `Level_Sub` divided by ten. The helper accepts that secondary level; its primary-level fallback when the value is absent is simulator policy. The level-10 secondary-vocation unlock gate is also simulator policy, applied by the runtime caller rather than the source equation.
The source's MND attribute is exposed as Wisdom in the application. The player-derived channels implemented here are:
- Max HP: `(base HP + ExAddHP + STR*HpStrArg + STA*HpStaArg + flat MaxHP) * MaxHP_Rate`, minimum 1. The table adapter includes the base, attributes, flat gear, and rate; `ExAddHP` from external abilities/passives is outside this base helper.
- Max MP: `(base MP + INT*MPIntArg + MND*MpMndArg + flat MaxMP) * MaxMP_Rate`, minimum 0, when the primary or secondary class has the source MP resource type. The player `JobSpMpType` mapping gives MP to source classes 4–10 (Mage through Champion); classes 1–3 use SP. This does not replace current simulator Focus/Rage/Psi pools or change ability costs.
- ATK: `(STR*AtkStrArg + INT*AtkIntArg + AGI*AtkAgiArg + flat ATK) * ATK_Per`, minimum 1.
- DEF: `(STA*DefArg + flat DEF + armor-category base DEF contributions) * DEF_Per`, minimum 1. The helper calculates the attribute and flat-gear terms; its reference equipment curves do not contain source armor-category `BaseEQ.Def` values.
- MDEF: `(MND*MDefArg + flat MDEF) * MDEF_Rate`, minimum 1.
- MATK: `(INT*MAtkArg + flat MATK) * MATK_Rate`, minimum 1.
- DMG: `(ATK/DmgAtkArg when nonzero + flat DMG) * class/race/gender DmgRate * DMG_Per`, minimum 0. Player FIX row 303001 has `DmgAtkArg = 0`, so player gear DMG is flat.
- MDMG: `(MATK/MDmgMAtkArg when nonzero + flat MDMG) * MDMG_Per`, minimum 0. Player FIX row 303001 has `MDmgMAtkArg = 0`, so player gear MDMG is flat.
- MPow: the all-magic `BodyRate`, neutral 1. It is not MATK or MDMG.
The source's raw hit and avoidance channels are preserved as point values: physical hit adds `PhyHit_AgiArg * AGI`; physical dodge adds `PhyAvoid_AgiArg * AGI`; magical hit adds `MagHit_MndArg * MND`; magical dodge adds `MagAvoid_MndArg * MND + MagAvoid_AgiArg * AGI`. Physical/magic critical and parry are also source point channels. The sidecar does not label those values as percentages or ratings.
The verified Holy Heal inputs are source-backed, but the per-spell equation remains incomplete in this adapter. `MagicProcess.cpp` uses skill `OrgPower`, the capped `MND * 0.3 * SpellTime` contribution (cap branch gives `OrgPower * 6`), optional `MDMG * Dmg_HealArg`, then multiplies by `MPow * HealPowerRate` and adds `HealPoint * SpellTime`. Player FIX row 303001 supplies `Dmg_HealArg = 0.5`. The target-side modifier is `Power * (1 + HealAbsorbRate/100) + HealAbsorb` unless the effect is ignored. `romNative.healing` carries these coefficients and raw caster/target inputs; skill rank, `OrgPower`, timing, flags, random variance, undead behavior, and the full spell resolver remain outside this stat adapter.
## Equipment inputs and compatibility bridge
Native gear is an optional `ItemStats.romNative` payload. Its additive fields are explicitly source-semantic points; `*RateFactor` fields are multiplicative factors, with 1 neutral. `equipmentStats` combines flat values by addition and rate factors by multiplication. A source-native payload on an item takes precedence over the foreign-item bridge for that item, so mixed sets do not count its common direct values twice.
The native heirloom reader imports only `romNativeGearCurves.generated.json`, a compact five-class/slot/level table, and does not load the larger audit catalog at runtime. Those curves are source-selected Good-quality item references for Knight, Priest, Scout, Rogue, and Mage. The reference builder selects class/slot medians and selected package pieces, then applies its documented monotone level interpolation. This is simulator gear-profile policy, not a Runewaker per-level item formula. The remaining five classes and missing slots have no source-native curve.
The canonical converter keeps legacy reference aliases distinct: legacy `magicalDamage` is source type 148 MdmgPoint; legacy `magicalWeaponDamage` is type 191 MDMG; `weaponDamage` is type 25 DMG; `healingPower` is type 150 HealPoint; and `dodge` is type 17 physical dodge. The compact table therefore does not use a generic name normalizer that could map 148 into 191.
An audit of the 999 unique item IDs selected by the current five-class curves against the matching raw `armorobject.db`, `weaponobject.db`, and fixed `addpowerobject.db` records found no selected-item type 44 MPow, type 149 HealPowerRate, type 144 HealAbsorbRate, or type 147 HealAbsorb effects. Type 150 HealPoint occurs on two selected raw items and remains exported. The profile's neutral MPow/HealPowerRate factors therefore reflect absent effects on these selected references, not an assumed RoM conversion. The helper still accepts native payloads for those exact channels if a later source-specific item loader supplies them.
For items without a native payload, `romNativeGearFromSharedDirectStats` is an explicit compatibility simulator policy: it bridges only flat Strength, Stamina, Intellect, Agility, HP, and MP. WotLK Spirit is not Wisdom. WotLK ratings, Attack Power, Spell Power, armor, weapon DPS, and resistances are not interpreted as native RoM stats. Existing common item fields and their WotLK projections are preserved for compatibility.
At the shared combat boundary, runtime may use numeric native ATK as common attack power, MDMG as common spell power, and DEF as common armor under an explicit `combatStatModel` simulator label. That 1:1 adapter is for shared combat consumers; it does not assert equivalence to WoW stat definitions. MDEF and native point channels remain in the sidecar. A complete armor-defense calculation and verified native-point combat resolution are still open.
## Focused verification
`romNativeStats.test.ts` checks every class/race/gender primary curve, player-derived level-1 channels, MP gating, source healing coefficients, the 148/191 conversion boundary, a real level-70 Mage reference curve, rate-preserving aura recomputation, and gear aggregation. `equipment.test.ts` checks per-item payload precedence, foreign direct-stat bridging, mixed-loadout behavior, and native equip/unequip impact. `itemStats.test.ts` checks that native payload persistence does not change the existing WotLK numeric fields.
@@ -0,0 +1,19 @@
# RoM Priest remaining conditional passive audit
## Protection Rune Seal (Priest/Champion 498613)
The current [Chronicles of Arcadia Priest/Champion skill list](https://chroniclesofarcadia.com/en/wiki/skills/list/5-10) describes Protection Rune Seal as giving Regenerate a slight chance to reset Heart Rune Energy whenever it heals. The local generated RuneWaker catalog keeps the exact Priest/Champion pair and 498613 identity and specifies an 8% chance. The runtime retains that local 8% choice because the current CoA entry gives no percentage; it does not treat “slight chance” as a numeric source. The same catalog row has no components or numeric healing-strength modifier, so no healing increase is modeled.
Regenerate's actor-owned Protection Rune Seal aura was already attached to both player and companion casts. The uncovered gap was in the triggered-action handler: it accepted only the player aura owner, so a companion's periodic healing tick could never reset that companion's cooldown. The handler now resolves the exact aura owner, requires the Priest/Champion pair and learned 498613, and accepts only a positive periodic heal attributed to Regenerate 490269. It resets only that actor's learned Heart Rune Energy 498612 cooldown. Player behavior remains on the existing path. For companions, both the party member and authoritative actor cooldown snapshots are updated so the subsequent healing sync cannot restore the stale cooldown.
Coverage checks the exact pair, learned passive, trigger type, source skill, positive tick amount, and the local 8% roll boundary. The player reset regression remains covered; a companion integration now verifies aura ownership, no reset before the first tick, the reset on its successful first tick, and unchanged player cooldown. The tick follows the local catalog's 2-second, 20-second Regenerate HoT; each positive periodic healing event is an independent proc opportunity.
## Still blocked by missing source data
- Protection Rune Seal's possible healing-strength rider remains unresolved. Current CoA 498613 describes only the cooldown-reset chance; RuneWaker 498613 has no healing modifier data. Do not infer a heal bonus from the passive name or from the cooldown chance.
- Priest/Scout Saint's Blessing's Heal rider remains unresolved at playable ranks. Current CoA names Heal 493048 and gives the rider at skill +105, but no lower-rank curve is published and the local catalog lacks that 493048 Heal row. Local Priest Heal 490261 is a different ability. See [the Saint's Blessing source review](ROM_PRIEST_SCOUT_SAINTS_BLESSING.md); do not map the +105 values to the local Heal or infer a lower-rank value.
- The backlog's Warlock/Priest Mind Shield (498648) Ring of Comfort cooldown-reset chance remains unmodeled because its probability is unspecified. See [the active mechanics backlog](ACTIVE_CLASS_MECHANICS_BACKLOG.md#warlock).
## Validation
The focused Priest Protection Rune Seal and Shaman Nature's Guardian suites passed: 5 files, 13 tests. A standalone app TypeScript check could not complete because Node exhausted its default heap during type checking; the root task owns the final build check.
@@ -0,0 +1,71 @@
# RuneWaker Scout Paired-Passive Coverage Audit
**Reviewed:** 2026-10-02
**Scope:** Scout-primary paired passive rows in `src/game/romPlayerCatalog.generated.json`; local runtime and tests only. This audit maps the 39 generated rows across the seven playable Scout secondary classes. It does not add later Chronicles of Arcadia payloads or derive missing proc, range, pet-stat, critical, or dodge values.
## Result
Three source rows translated timed target/pet effects as self auras when a learned passive was initialized. `overrideRomScoutRogueEffects` now leaves a learned marker for those exact Scout-primary pairs, which prevents the child payload from being granted to the Scout before its trigger. The absent trigger rows and unsupported target/pet behavior remain unwired:
| Pair and passive | Generated source evidence | Runtime correction |
|---|---|---|
| Scout/Mage Wind Return (`499548`) | Components `623070` and `623083` describe six-second attack bonuses and a two-second heal tick after `499549` expires. The generated player catalog has no `499549` row. | Retain a marker only; do not grant the post-expiry buff or HoT to the passive owner. |
| Scout/Priest Revival Engraving (`499552`) | Component `623087` describes companion healing received and damage-taken modifiers after a hit by `490443`. The generated player catalog has no `490443` row, and modeled summons expose no pet-specific stats. | Retain a marker only; do not apply companion modifiers to the Scout or invent a pet effect. |
| Scout/Warden Forest Guidance (`499575`) | Component `623117` describes +2% damage taken for 10 seconds on the target hit by `499576`. The generated player catalog has no `499576` row. | Retain a marker only; do not apply the target debuff to the passive owner or add an unsupported target proc. |
The overrides are gated on both exact source skill ID and exact secondary class. The focused regression also verifies that the trigger remains absent, the owner receives no aura carrying the source modifiers, the marker has no proc or modifier, and a different Scout pair continues to use the generic translated effects.
## Scout-primary row map
The status distinguishes earlier supported work from clauses still awaiting source data or simulator support. “No sourced parameter” means the generated row or its referenced source components do not supply the quantity or trigger details needed for a faithful implementation.
| Secondary | Source ID | Passive | Audit disposition |
|---|---:|---|---|
| Warrior | 491488 | Master of Survival | Source components `501889` (+50 HP) and `506369` (+1% maximum HP) are static, indefinite modifiers handled by the generic source-aura path. |
| Warrior | 491489 | Enhanced Throat Attack | The four-second Throat Attack silence is already implemented. The extra-range clause remains blocked: the matched local row supplies no additive range value or linked range modifier; passive field `range: 5` is cast-distance metadata. |
| Warrior | 492915 | Gale | Open: Combo Shot damage amount and interruption-prevention behavior have no quantified source payload. |
| Warrior | 493006 | Accurate Targeting | Already implemented: landed Shot grants the source +50 physical attack for 15 seconds with the 30-second cooldown. |
| Warrior | 493252 | Recover Vitality | Open: Snipe/Piercing Arrow resource restoration and Snipe beneficial-effect cancellation are described only as additional chances; chance and amount are absent. |
| Warrior | 499511 | Vital Reflection | Open: bonus damage for referenced skill `499512` below 30% target HP has no sourced modifier amount. |
| Warrior | 499513 | Breathing Obstruction | Open: component durations `623057`/`623060` do not provide a modeled trigger payload; the described effect depends on enemy skill-use/cooldown state after `490442`, which is not represented by the combat runtime. |
| Rogue | 492342 | Lasso Master | Existing tagged helper scopes the passive to Lasso behavior. No change in this audit. |
| Rogue | 492916 | Exploiting Shot | Existing helper applies the source-backed +2% physical damage taken for 12 seconds after a critical Shot, with the 20-second lockout. The source says the stack total is capped but does not state the cap; the current five-stack cap is marked as a hybrid estimate and remains unresolved. |
| Rogue | 493007 | Deadly Counterattack | Existing helper is explicitly hybrid-estimated: source component `503299` supports a 20-second lockout, but the critical amount and buff duration are absent. Keep those estimates separate from source-confirmed coverage. |
| Rogue | 493253 | Poisonous Spit | Already implemented for landed Reflected Shot/Combo Shot with the carried Poison Powder model and source-backed poison payload. |
| Rogue | 499534 | Master Reflected Shot | Existing custom helper scopes the reflected-shot behavior to this passive. No new source-backed clause was found in this review. |
| Rogue | 499536 | Meridian Attack | Existing custom helper scopes the Meridian Attack rider to its referenced skill. No new source-backed clause was found in this review. |
| Mage | 492918 | Prairie Fire | Open: Ignite damage increase and root chance have no sourced amount or chance. |
| Mage | 493013 | Wilderness Campaign | Open: component `503292` gives a 30-second lockout, but the party-stat amounts shown by the description placeholders are absent from the generated components. |
| Mage | 499539 | Cross-Domain Discipline | Open: component `623062` records the staff restriction; wind-damage child `623064` has no proc chance, and referenced player skill `499540` is absent from the generated catalog. |
| Mage | 499548 | Wind Return | Corrected in this audit; see the source evidence and runtime correction above. |
| Priest | 492344 | Sniper | Already implemented: reduces Scout Snipe cast time by one second only for Scout/Priest. |
| Priest | 492622 | Vampiric Transformation | Open: converts Vampire Arrows' blood effect into group healing, but the heal amount and conversion rule have no sourced payload. |
| Priest | 492917 | Target Neck | Open: extends Neck Strike range, but the additive range amount is absent. |
| Priest | 493010 | Drums of War | Already implemented from current CoA behavior: landed Shot grants the party critical-damage buff and lockout. |
| Priest | 499552 | Revival Engraving | Corrected in this audit; see the source evidence and runtime correction above. |
| Priest | 499555 | Screen Attack | Already implemented from current CoA behavior as a 17.4% damage buff with the documented duration, radius, and cooldown. |
| Knight | 492346 | Enhanced Armor Perfection | Already implemented: source component `501823` sets Enhanced Armor duration to 600 seconds. |
| Knight | 492624 | Mana Recovery | Open: Healing Shot has an unspecified additional chance to restore mana; chance and amount are absent. |
| Knight | 490431 | Armor-Piercing Arrow | Open: extra Disarmament range is unquantified; ranged-weapon DPS damage clause supplies no coefficient. |
| Knight | 493256 | Spirit Meld | Open: component `503563` supplies a 30-second lockout, but party-stat values in the description are absent from the component. |
| Knight | 499561 | Blessed Quiver | Partial text only: the four-second cooldown reduction is explicit; critical chance amount is absent, and the critical-triggered Mana Recovery rider inherits that passive's unspecified chance/amount. |
| Knight | 499564 | Transformation Torrent | Open: party Companion Energy/Rage/Focus restoration and radius 90 are described, but resource amounts are absent. |
| Warden | 494326 | Charged Power | Partial text only: the passive states a 15-second cooldown and Knockdown, but supplies neither remote activation range nor Knockdown duration. |
| Warden | 494378 | Natural Care | Source components `504905` and `505169` support permanent +1% healing received and +1% ranged damage; the generic source-aura path handles these values. |
| Warden | 494628 | Wind Attack | Open: component `507327` supplies the 30-second lockout, but the critical-hit reduction to target critical stats has no sourced magnitude. |
| Warden | 499571 | Deep Roots | Open: the passive names a 180-range reflected shot and a five-second root, but the referenced player row `499573` is absent and the root chance is not quantified. The player-target duration reduction is also unquantified. |
| Warden | 499575 | Forest Guidance | Corrected in this audit; see the source evidence and runtime correction above. |
| Druid | 494380 | Devour Blood | Open: component `506370` supports a 30-second lockout, but Vampire Arrows' party MP-restoration amount is absent. |
| Druid | 494919 | Archer Blessing | Open: component `505718` supports a 30-second lockout, but next-hit damage reduction magnitude is absent; the pet recipient path is unsupported. |
| Druid | 499584 | Ancestor's Blessing | The permanent +1% physical/magical damage-taken reduction from `505684` is a generic source aura. The separate `503954` threat rider has a 30-second lockout but no reduction amount for the next action of the second-highest-threat party member. |
| Druid | 499587 | Efficiency Guidance | Open: referenced trigger `499592` is absent from the generated player catalog; the companion resource-restoration amount has no supported mapping from `623151`. |
| Druid | 499594 | Soul Blessing | Open: enhancement to Archer Blessing's party coverage/effect has no component payload or quantified enhanced value; its companion behavior is unsupported. |
## Further candidates kept separate
No additional Scout passive was changed. The existing Exploiting Shot five-stack cap and Deadly Counterattack critical amount/duration are known hybrid estimates rather than source-confirmed values; they need better source evidence before being treated as closed. Other open clauses above remain blocked by missing values, missing generated trigger rows, or unsupported enemy/pet event state. In particular, no Enhanced Throat Attack range, pet critical/dodge/stat inheritance, or unquantified proc value was added.
## Validation
- Focused: `npx vitest run src/game/romScoutMechanics.test.ts`
- Repository handoff hygiene: `npm run release:check`
@@ -0,0 +1,35 @@
# RoM Warden remaining mechanics audit
## Revival (local 499542): source formula found
The RuneWaker source snapshot resolves the local Revival chain despite CoA reusing skill ID 499542 for a different Rogue/Warden skill. Local MagicCollect 499542 references MagicObject 623078; its raw `magicobject.db` `PlotName` bytes at record offset `+0x284..+0x2c3` decode to `un_499542` (the catalog exporter does not retain `PlotName`). In `F:\Projects\Runewaker\Resource\luascript\02709.lua:49-53`, that function counts component 623072, passes the count as MagicLv when self-casting child 499543, then clears the stack buff. The local skill tooltip caps the stack at ten. The Lua function has no positive-stack guard: with MagicLv zero, the source formula still begins at 10% unless an upstream cast condition prevents this function from running.
The child MagicCollect 499543 references MagicObject 623071. Its raw source fields are `MagicFunc=AddHP`, `AtkType=HP`, `AtkCalType=8` (`FixPer`), `DmgPower=10`, `DmgPower_SkillLVArg=50`, `FixValue=0`, and random range zero. `FixDmg_SkillLVArg=5` is not used by the FixPer branch. The enum value 8 means FixPer (`F:\Projects\Runewaker\SourceCodeVS2019\Libraries\Union\RoleData\ObjectStruct.h:3965-3977`). The RuneWaker server first calculates `OrgPower = DmgPower + DmgPower × DmgPower_SkillLVArg × MagicLv / 100` (`F:\Projects\Runewaker\SourceCodeVS2019\Server\NetGameServer\MainProc\MagicProc\MagicProcess.cpp:2302`). Its FixPer branch keeps that as `PurePower` and converts it to `Target.MaxHP × PurePower / 100` (`MagicProcess.cpp:2751-2754, 2777-2795`). Because the Lua passes stack count as MagicLv, the normal requested heal is:
```text
targetMaxHP × (10 + 5 × stackCount) / 100
```
At zero, one, and ten stacks, that is 10%, 15%, and 60% of max HP. This FixPer branch does not use weapon damage, attack speed, MagicPow, or critical power. The target is self, and positive `CalMagicDMG` input is returned unchanged (`F:\Projects\Runewaker\SourceCodeVS2019\Libraries\Union\RoleData\RoleAttr.cpp:951-955`). The helper in [`romWardenRevivalRuntime.ts`](../src/game/romWardenRevivalRuntime.ts) captures the normal amount before overheal.
The player and generated-companion casts now use the source formula and only their own unexpired 623072 charges. The generated catalog retains the supplemental level-60 499544 learn row for the exact Rogue-primary/Warden-secondary build, sourced from key item 546772 and prerequisite 499542. Learned 499544 arms actor-owned state 623079 for 900 seconds; its 499545 chain grants a 15-second, max-ten 623072 charge and applies component 623527's six-second +20 physical-dodge-rate aura only after a normal noncritical hit causes positive post-absorb HP loss and leaves that actor alive. Authored player and party incoming-hit paths carry this event boundary. The direct `damagePlayer` adapter lacks an outcome payload, so that path treats positive post-absorb HP loss as a normal hit; this is an explicit simulator approximation, not a source claim about environmental or other callers.
The separate 620302 HolyHeal rider remains unwired because the simulator does not expose validated native MND/MPow/heal-rate inputs. The source's type-200 magical-dodge contribution also remains unwired because there is no separate magical-dodge resolver. Do not synthesize neutral-stat healing rates or merge magical dodge into physical Dodge. Exact state ownership, hit gates, and focused regression detail are recorded in the [Revival state-chain audit](ROM_WARDEN_REVIVAL_STATE_AUDIT.md).
The local 499542/623078 to `un_499542` to 499543/623071 identity chain is sourced from the local RuneWaker snapshot. Do not import CoA's unrelated Rogue/Warden Low Blow behavior from the reused ID.
## Weak Point Strike (local 494043): proc chance still absent
The RuneWaker row for local 494043 points to damage components 620294 and 620295. They hold the main hit and bonus-hit formulas; neither component carries an on-attack-trigger ID, and the local 494043 component data exposes no proc rate. A search of the local `Resource/luascript` files found no additional reference defining the chance or trigger. Keep the bonus hit disabled until source supplies its chance and the event that checks it.
Current CoA reuses 494043 for Rogue/Warden Blood Hound. That different name and conditional behavior does not source the local RuneWaker Weak Point Strike chance. Do not bridge the proc by ID or borrow the CoA condition/value.
## Tough Oak (Warden/Mage): defense inputs and application order still absent
Current CoA describes +60% pet Physical Defense, while local RuneWaker component 504600 describes +5% physical-defense-percent. The simulator's generic summons have no Physical Defense stat or corresponding mitigation formula. The CoA page does not provide a pet baseline/scaling rule or defense-to-damage conversion, so the +60% cannot safely be translated into armor or damage reduction. The existing [Tough Oak source and runtime review](ROM_WARDEN_MAGE_TOUGH_OAK.md) records the pet-scope, Damage Transfer/absorb ordering, and local-versus-CoA pet-skill ID gaps.
Before implementing the rider, source or define each of these separately: (1) eligible pet/summon scope; (2) base pet Physical Defense and its scaling; (3) the Physical Defense-to-damage formula; and (4) whether transferred damage passes through pet defense before pet absorbs. Keep the local +5% component separate from the current CoA +60% behavior.
## Focused validation
The focused Warden state-chain run passed 77 tests across the Revival and Warden suites, and the supplemental 499544 catalog prerequisite assertion passed its filtered check. The source/runtime regressions cover the exact 0/1/10-stack heal amounts, actor-owned charge consumption, state duration/cap, player and companion hit gates, normal versus critical hits, shield absorption, death, and physical-dodge scope. The full player-catalog tests still include two unrelated failures reproduced on clean baseline `b16821e9`: the Mage/Druid elite unlock sequence duplicates level 25 where the source expects level 30, and Druid/Rogue Speed Catalysis 494023 expects 15 Energy while the existing CoA override provides none. See the [state-chain audit](ROM_WARDEN_REVIVAL_STATE_AUDIT.md) for the exact event approximation and native-input limits.
+27
View File
@@ -0,0 +1,27 @@
# RuneWaker Rogue/Warden Revival state and hit chain
## Source chain
The local RuneWaker `learnmagic.db` record for Rogue (class 3), record 590003, has a level-60 499544 entry at index 133 (record offset `0xD2C`). It uses key item 546772 and requires 499542. `Resource/luascript/02248.lua` maps that key item to Rogue-primary/Warden-secondary (3/7) at level 60. The exporter previously retained only the first candidate at this elite milestone, which dropped 499544 beside 499542. It now emits this second row only for that exact pair, milestone, key item, parent, and prerequisite; other candidate selection is unchanged.
MagicCollect 499544 is a self-targeted, zero-cooldown cast that references component 623079. That state lasts 900 seconds and owns on-hit trigger 499545. Its trigger power follows the effective cast `MagicLv`: source `AssistMagic` initializes `Buff.Power` from the cast MagicLv, and the child reads that stored value. `AbilitySkillLevelArg=5` scales authored modifiers on the state; 623079 has no such modifiers. The player catalog contains one learned rank of 499544 at level 60, so its current rank approximation supplies effective MagicLv 1. No owner-level-80 substitution or added rank curve is used.
Trigger 499545 references 623072 (the 15-second Revival charge, maximum ten stacks), 620302 (HolyHeal), and 623527 (six-second dodge modifier). The runtime applies one actor-owned charge and refreshes the sourced dodge aura after a qualifying incoming hit. Source type 198 is physical dodge-rate `+20`; the existing RuneWaker semantic adapter maps it to the simulator's flat `dodge` channel, which is consumed as `dodgePercent` by its physical hit resolver. The source's separate type-200 magical dodge channel has no separate consumer in the current simulator and is left unwired.
## Event gate and runtime boundary
RuneWaker `RD_ValueCal.cxx` accepts `SpecialAttackBuffProc` only for `EM_ATTACK_DMGTYPE_NORMAL` and negative post-absorb `addHP`. `MagicProcess.cpp` resolves magic-shield absorption before assigning `Temp.Value` and calling `OnAttack`; critical hits use the distinct `DOUBLE` damage type, and a fully absorbed hit has zero value with the absorb result. The runtime therefore excludes misses/dodges, criticals, fully absorbed hits, zero health loss, and owners dead after damage. Authored player-vs-mob and party-member-vs-mob event paths receive outcome and resolved health damage, so they gate on landed, noncritical hits after absorption.
The existing direct `damagePlayer` API has no attack-result payload. For that adapter only, positive post-absorb health loss is treated as a normal incoming-hit event; its caller may also represent environmental or other damage. This is a documented event approximation, rather than a source claim about those external events.
The player and companion paths both require the exact Rogue-primary/Warden-secondary build, the learned 499544 entry at the actor's current level, and the actor's own active 623079 state. Charges and dodge auras are owned by that actor. Companion upkeep self-casts the learned 499544 state when absent; this AI maintenance choice is simulator policy, not a sourced priority rule. An injured companion's existing 499542 Revival heal retains priority. The 499542 cast consumes only the caster's own unexpired 623072 aura, so expired or foreign charges do not raise its stack-based heal.
## Remaining native-input gaps
The 499545 HolyHeal rider 620302 is not applied. Its source row supplies DmgPower 30, a MagicLv argument of 18%, and random range 2 (±2%), but RuneWaker's HolyHeal calculation also reads MND, spell time, a cap branch, MPow multiplied by HealPowerRate, and HealPoint. The simulator does not currently expose these native inputs and mappings as a validated shared healing adapter. Substituting the source `OrgPower` or a generic spell-power coefficient would be unsupported. This is separate from the sourced 499542 self-heal formula (`10 + 5 × own active stacks` percent of max HP), which remains implemented.
The magical type-200 dodge contribution is also unmodeled because the simulator has no separate magic-dodge resolver. Neither channel is approximated with a new stat rate.
## Validation
Focused Revival helper, player/party state-chain, player 499542, and companion `partyRuntime` tests cover the source gates, durations, stacking cap, exact build/level access, normal versus critical hits, fully absorbed damage, own active charge ownership, and cast maintenance priority. `romWardenRuntime.test.ts` retains a check that the unresolved HolyHeal child is not exposed as a generic triggered ability. The full player-catalog test also has two failures reproduced against the clean baseline: Mage/Druid unlock levels expected `[15, 20, 25, 30, 35, 40, 45, 50, 60, 70]` but receive level 25 instead of 30, and Druid/Rogue Speed Catalysis (494023) expects source cost 15 Energy while the existing runtime override omits it. The new Rogue/Warden 499544 catalog assertion passes; those baseline failures are unchanged.
@@ -0,0 +1,31 @@
# RoM Warlock Remaining CoA and RuneWaker Conflicts
Source review verified 2026-10-02 against the current [CoA Warlock/Warrior list](https://chroniclesofarcadia.com/en/wiki/skills/list/9-1), [CoA Warlock/Priest list](https://chroniclesofarcadia.com/en/wiki/skills/list/9-5), and the patch history linked below. Scope is limited to unresolved Warlock items and duplicate blockers across the WoW and RuneWaker backlog sections. Each item below is one distinct mechanic; completed current-pair fixes are recorded once so the aggregate rows do not reopen them.
> **Local RuneWaker follow-up:** [The source follow-up](ROM_WARLOCK_SOURCE_FOLLOWUP_2026-10-02.md) identifies a guaranteed local Mind Shield 498648 → Ring 498641 reset on landed Urgent Heal, local learned 499869 as Warlock/Warrior Mind Transference, and local End of Thought 498699 as Warlock/Rogue with a pre-hit <=35% callback but no located timed-child consumer. These do not fill the current-CoA reset probability, Psychic Warrior combat, End of Thought, or Breathing Control contracts. The findings below remain the current-CoA blocker reference.
## Current behavior already resolved
- **Mind Transference 498642 (Warlock/Warrior):** current CoA behavior is 75 Rage for 6 Psi with a one-second cooldown. Patch [10.2.1](https://chroniclesofarcadia.com/patch/10-2-1-1000) establishes the 6-Psi conversion and [11.1.2](https://chroniclesofarcadia.com/patch/11-1-2-1000) establishes 75 Rage and the one-second cooldown. Player and companion behavior is already tested. The local 621320 +40% physical-attack child rider is excluded; it is not in the current CoA description.
- **Divine Assistance 498668 (Warlock/Priest):** the stale RuneWaker Inspiration/Defense Net reset text is replaced with current CoA's passive +16% Magical Damage and Healing. The current [pair page](https://chroniclesofarcadia.com/en/wiki/skills/list/9-5) and patches [10.2.1](https://chroniclesofarcadia.com/patch/10-2-1-1000) and [12.1.0](https://chroniclesofarcadia.com/patch/12-1-0-5000) support the rework. Player and companion coverage exists.
- **Ring of Comfort 498641 (Warlock/Priest):** current CoA says the cast also provides 1 Psi. Runtime grants one Psi to the caster per accepted exact-pair cast. Treating the resource recipient as the caster is an explicit recipient interpretation; the CoA row does not name that recipient. Caster scoping prevents area-heal fanout and prevents the selected ally from receiving the Psi. Player and generated-companion tests cover accepted casts, area healing, cap behavior, and wrong-pair exclusion. This is a source interpretation, not a separately published recipient rule.
- **Mind Shield 498648 (Warlock/Priest), sourced Urgent Heal rider:** current CoA says Urgent Heal provides 1 Psi. Runtime now grants that one Psi to the caster using the same explicit caster-recipient interpretation as Ring of Comfort. Player and generated-companion tests verify that the selected ally receives none and that the caster's Psi cap is respected. Defense Net's existing one-second duration reduction remains active.
- **Breathing Control 498634 tooltip:** the catalog now lists all eight current CoA trigger skills—Psychic Arrows, Warp Charge, Weakening Weave Curse, Slash, Soul Trauma, Puzzlement, Heart Collection Strike, and Surge of Malice—and describes automatic Enraged triggering. It no longer advertises the obsolete cooldown reset. This is descriptive only; no proc was added.
## Distinct source blockers that remain
- **Breathing Control 498634 automatic Enraged proc:** the [current CoA row](https://chroniclesofarcadia.com/en/wiki/skills/list/9-1) and patches [10.7.1](https://chroniclesofarcadia.com/en/patch/10-7-1-2000) and [11.1.2](https://chroniclesofarcadia.com/patch/11-1-2-1000) establish the skill list and automatic trigger. They do not establish the proc probability or exact event phase/timing, nor the amount of extra Focus recovered by Enraged. Keep the proc and Focus rider unwired until those values and event links are sourced.
- **Deep Breathing Control 499870:** current CoA and [patch 11.1.2.3000](https://chroniclesofarcadia.com/patch/11-1-2-3000) establish an extra 15% HP and increased Breathing Control Focus recovery. They do not say whether the HP recovery occurs on manually cast Enraged 499871 or only on an automatic Breathing Control proc, and they omit the extra Focus amount. Do not attach the heal to manual Enraged or guess the Focus amount without an explicit trigger link and value.
- **Mind Shield 498648 reset and defense clauses:** the [current CoA pair row](https://chroniclesofarcadia.com/en/wiki/skills/list/9-5) says Urgent Heal has a chance to reset Ring of Comfort and says Defense Net's Physical Defense is enhanced while its effect time is shortened. The reset probability is not given and the Physical Defense enhancement amount is unquantified. Keep the chance-based reset and defense-value modifier unwired; the separately sourced +1 Psi on Urgent Heal is already implemented above.
- **Psychic Warrior 499868 integration:** current CoA and patch history establish 187.5% main-hand DPS for area targets and 1,050% for single targets, with the damage described as Physical Dark. The isolated helper retains the two source tags and current ratios. Combat integration remains blocked until the simulator has a sourced/decided Physical-Dark mitigation rule and the extra hit's cadence and roll inheritance (cast, target, packet, and channel/periodic timing; hit/crit/avoidance/block behavior) are resolved. The conditional Slash/Consistent Heart Strike main-hand rider also has no published amount. See [the bounded Psychic Warrior note](ROM_WARLOCK_WARRIOR_PSYCHIC_WARRIOR.md).
- **Current-CoA identity of local 499869:** the [local source follow-up](ROM_WARLOCK_SOURCE_FOLLOWUP_2026-10-02.md) identifies 499869 as a learned level-70 Warlock/Warrior Mind Transference row (key 546844, prerequisite 499868, slot 25), not an unidentified child. No current-CoA source establishes that local conversion as current Psychic Warrior or Enraged behavior; keep those contracts separate.
- **End of Thought 498699:** the local periodic component has no tick interval (`dotTime` is zero), and the reviewed current CoA source notes do not provide one. Do not choose a cadence until a matching-version source gives it.
- **WoW Soul Leech (talent-1678):** this is the single WoW Warlock blocker, separate from RuneWaker Warlock mechanics. Custom patch-D helper rows 830297–830299 are unlinked from any talent rank; they do not establish the physical-hit trigger, rank gate, or one-charge/cast-finish consumption. Keep the critical-chance rider unwired until the talent-to-helper link and trigger semantics are sourced.
## Backlog deduplication map
In `ACTIVE_CLASS_MECHANICS_BACKLOG.md`, Mind Transference (Warlock/Warrior 498642) and Divine Assistance (Warlock/Priest 498668) are completed and should stay closed. Psychic Warrior 499868, Breathing Control 498634, Deep Breathing Control 499870, Mind Shield's Ring reset/defense clauses, the current-CoA relationship of local 499869, and End of Thought 498699 remain separate RoM items with the prerequisites above. Local 499869 itself is identified as a learned Warlock/Warrior Mind Transference variant, not an unidentified child. Mind Shield's +1 Psi on Urgent Heal is completed; it should not be repeated as an open mechanic. The aggregate RuneWaker lines about "remaining Priest-pair passives" and reconciling Mind Transference/Divine Assistance should be reduced to the unresolved mechanic names only. Keep the WoW Soul Leech 1678 source blocker as one standalone WoW item; do not duplicate it under RuneWaker Warlock.
## Validation
`npx vitest run src/game/romWarlockRuntime.test.ts` passes all 21 tests. New focused coverage checks the current Breathing Control description has no obsolete reset effect; Ring of Comfort grants exactly one caster Psi on accepted exact-pair casts, never fans it out to healing recipients, respects the cap, rejects wrong-pair effects, and grants nothing on a rejected cast; and Mind Shield's Urgent Heal Psi goes to the caster for both the player and generated companion. The shared changes are limited to the `scope: "caster"` resource recipient path in `abilityCatalog.ts`, `combatStore.ts`, and `partyRuntime.ts`.
@@ -0,0 +1,47 @@
# RoM Warlock source follow-up — 2026-10-02
This bounded review checks unresolved Warlock links against the local RuneWaker data snapshot, Lua callbacks, and available server consumers. It does not change runtime code, shared audits, or the backlog. Current Chronicles of Arcadia behavior remains as recorded in [the current-CoA audit](ROM_WARLOCK_REMAINING_CONFLICTS_AUDIT.md); local RuneWaker values below are version-qualified and are not substituted into current-CoA mode.
## Version anchor and scope
The generated catalog identifies its source as the RuneWaker snapshot. Its `learnMagic`, `magicCollect`, and `magicObject` SHA-256 values match `F:/Projects/Runewaker/Resource/data/learnmagic.db`, `magiccollectobject.db`, and `magicobject.db` respectively:
- `learnMagic`: `6148199bcd1484ba1a122a6db5e2b3c2f2a161402bfe67fc67eb53f55fc6cfa3`
- `magicCollect`: `4aae19865cb07b30f99945aa7319a0096db244ff5e5b4b5d902fc6889474f501`
- `magicObject`: `52af13a2d8cfa9a66d4caa2d3c38832edc232f058995a64ef8135a6b82e43691`
The review followed learned-skill rows, ordered `Rand`/`Self_Buff`/`Self_Skill` checks, linked component records, `CheckUseLua`/`EndUseLua`/`PlotName` values, and the corresponding Lua functions. Native consumers were checked in `F:/Projects/Runewaker/SourceCodeVS2019` for check gating, hit phases, callback order, and timed-magic fields. Relevant Lua script is `F:/Projects/Runewaker/Resource/luascript/02551.lua`.
## Findings
### Breathing Control 498634 and Deep Breathing Control 499870 — no new local trigger contract
Both are learned Warlock/Warrior (class pair 9-1) passives in this snapshot: 498634 is level 30, key 545883, prerequisite 490053; 499870 is level 60, key 546843, prerequisite 490053. Their passive collections contain no ordered check groups, linked effect components, or callback binding for the described automatic Enraged proc or heal.
I checked the local collections for Psychic Arrows 497957, Warp Charge 497958, Weakening Weave Curse 497959, Soul Trauma 498550, Puzzlement 498553, Heart Collection Strike 498545, Surge of Malice 497966, and Enraged 490492. No bound check or component references 498634, 499870, or a related heal/reset child. The `Self_Skill` 100 check in 497957 instead references 498694. Linked MagicObject attack/hit fields have no active IDs for these passives; the default `OnAttackMagicRate=10` with ID 0 is not an enabled event or a 10% roll. No source-complete RuneWaker trigger candidate was found. This does not resolve the probability, event phase, extra Focus amount, or current-CoA heal trigger already listed in the current-CoA audit.
### Mind Shield 498648 / Ring of Comfort 498641 — local reset is guaranteed after a landed Urgent Heal
The learned pair is Warlock/Priest (9-5). Local Urgent Heal 491147 has an ordered `Self_Skill` check with `RandValue=100` for learned Mind Shield 498648, followed by component 621391. Native `Check_MySkill` first searches the learned-skill slots for the exact ID, then applies `rand() % 100 >= RandValue`; with 100 the roll always passes once 498648 is learned (`MagicProcess.cpp:5365-5392`).
Component 621391 is a RunPlot for `un_skill_498648` in `02551.lua:242`. The function directly calls `Lua_CDtime_set(OwnerID(), 498641, 0)` and contains no random call. Native `Magic07_RunPlot` invokes `PlotName` only for HIT or CRITICAL results, then invokes `EndUseLua` for each result (`MagicProcess.cpp:4719-4785`). Therefore this local RuneWaker binding resets Ring of Comfort cooldown on each qualifying landed Urgent Heal result while Mind Shield is learned. The adjacent Lua comment mentions 18%, but executable code has no such probability. This is a complete **local-only** reset contract, not evidence for the current-CoA “chance” clause: current CoA still omits the probability, so do not carry the guaranteed local reset into current-CoA mode. The local Mind Shield passive does not quantify the Defense Net defense increase. The local Ring of Comfort heal component 621319 has no linked Psi addition; current CoA's +1 Psi remains version-specific.
### Learned local 499869 is Mind Transference, not current-CoA Psychic Warrior or Enraged
The local `learnmagic.db` contains a learned 499869 row: Warlock/Warrior (9-1), level 70, key 546844, prerequisite 499868, save slot 25. Its omission from the generated catalog does not mean it is absent or unlearnable. Local 499869 is named **Mind Transference**; local passive 499868 is **Enhanced Mind Transference**. Keep this acquired local active separate from current CoA Psychic Warrior 499868 and Enraged 499871.
Local 499869 links component 623411, whose RunPlot is `Un_skill_499869` and whose `EndUseLua` is `Add_SoulP_1`. On a HIT or CRITICAL result, the Lua function computes `floor(current Rage / 20)`, subtracts 20 Rage per conversion, and adds that many Soul Points; the enhanced passive's local tooltip changes the base 35-Rage rate to 20 Rage. Native RunPlot separately invokes `EndUseLua` for each returned result, where `Add_SoulP_1` attempts one additional Soul Point, subject to its existing suppression when buff 501571 or 501572 is present (`02551.lua:19-27, 611-623`; `MagicProcess.cpp:4719-4785`). This is a source-complete **RuneWaker-only candidate** if that older local class mode is explicitly selected. The reused number and behavior are incompatible with the current-CoA identity and must not be used to fill a current-CoA gap.
### End of Thought 498699 — pre-hit threshold callback is live; periodic child consumer is not located
The local learned row is Warlock/Rogue (9-3), level 45, key 545894, prerequisite 490306—not the current CoA Warlock/Priest pairing. Local End of Thought is instant, has a 15-second cooldown and source range 200 (catalog scale 20), and costs 3 Soul Points (`MagicSpellCostType` value 15 is `EM_MagicSpellCostType_SoulPoint` in `ObjectStruct.h:1286-1304`).
Damage component 621435 binds `EndUseLua=un_skill_498699_check`. The callback reads the target's current HP percentage and applies caster buff 621436 for 4 seconds when HP is at or below 35% (`02551.lua:340-350`). Native `Magic00_AddHP` invokes `EndUseLua` before classifying miss/absorb/no-effect results and before `Global::AddHP` applies the pending HP change (`MagicProcess.cpp:2926-2930, 2940-2965, 3000-3007`). This proves the callback checks **pre-hit** HP; it ignores the result argument, so the threshold buff can be applied even when that component later resolves as a miss, absorb, or no-effect.
Buff 621436 encodes `OnTimeMagic_Magic=498784` and `OnTimeMagic_Time=1`. Hidden child collection 498784 has component 621437, whose RunPlot calls `un_skill_498699` to add one Soul Point. The one-second child cadence is encoded in the data, but the available C++ tree contains no runtime use of `OnTimeMagic_Magic`/`OnTimeMagic_Time`; these fields appear in `ObjectStruct.h` and the database reader `ObjectStructEx.cpp` only. Thus the callback and threshold are source-proven, while periodic execution is not. Do not treat the intended interval or repeated Soul Point gain as a live local contract, and do not transfer this pair-mismatched local timing into current-CoA mode.
## Handoff
The source-complete local candidates are Mind Shield's Ring reset and the learned 499869 conversion; both belong only to the RuneWaker snapshot and supply no current-CoA candidate. End of Thought has a live pre-hit threshold hook but its timed child lacks a located native consumer. Breathing Control and Deep Breathing Control still lack a linked local trigger contract. No runtime edits are proposed by this source-only follow-up.
The Champion CheckData cross-check produced no new chance contract beyond the already documented Forge Effect 498590 and Dark Energy Strike 498599 records in `ROM_CHAMPION_SOURCE_FOLLOWUP_2026-10-02.md`. Druid/Scout 499935 is not duplicated here; its two existing reports already identify native healing inputs as the remaining blocker.
@@ -0,0 +1,26 @@
# Warrior/Druid Rage of Calmness: post-hit Rage reduction source audit
**Checked:** 2026-10-02
**Scope:** Only the unresolved general Rage-cost reduction after a successful Whirlwind hit for Rage of Calmness (494377). Existing Rage of Calmness Whirlwind cost/cooldown clauses are outside this audit and remain unchanged.
## Result
The reduction amount remains unsourced for the Rage of Calmness passive, so it stays unwired. RuneWaker component 506368 provides a 20-second effect window but no reduction amount. The current Chronicles of Arcadia Warrior/Druid page separately gives its listed general Whirlwind (494939) an inherent hit rider of 30% for 15 seconds with a 20-second retrigger lockout. The page does not explicitly say that this Whirlwind rider is supplied by passive 494377; the passive entry itself gives no number, duration, or lockout. Do not transfer the 30% value to 494377 without an explicit source link.
## Current CoA page comparison
The [Warrior/Druid page](https://chroniclesofarcadia.com/en/wiki/skills/list/1-8) lists general Whirlwind 494939 as costing 20 Rage with a 5-second cooldown; its own successful-hit text reduces Rage consumption by 30% for 15 seconds and prevents another trigger for 20 seconds after the effect ends. Separately, Rage of Calmness 494377 says Whirlwind has lower cooldown and Rage cost and that hitting a target reduces general Rage consumption, but provides no amount or timing. This same-page proximity does not explicitly attribute Whirlwind's 30% row to passive 494377.
The comparator [Warrior/Rogue page](https://chroniclesofarcadia.com/en/wiki/skills/list/1-3) lists general Whirlwind 490125 (30 Rage, 20-second cooldown) without the 30% rider. The [Warrior/Warlock page](https://chroniclesofarcadia.com/en/wiki/skills/list/1-9) lists general Whirlwind 490133 (50 Rage, 30-second cooldown), also without that rider. Its Warrior/Rogue passive Exhaustion 493248 has a different, explicit Whirlwind cost/cooldown clause. These pages show the 30%/15s/20s text on the Warrior/Druid page's Whirlwind row; they do not establish that it is the missing numeric value of the passive's post-hit clause.
## RuneWaker component 506368
The local generated catalog maps passive 494377 to component 506368. The raw `F:\Projects\Runewaker\Resource\data\magicobject.db` record is present and matches the catalog's recorded `magicObject` source hash. At record offset 6,317,196, component 506368 has `EffectTime=20`, an Assist magic function, zero damage/effect values, zero ability-modifier entries, and no named run-plot (`PlotName` is `none`). The parsed timed/on-hit/rebound/death magic references and all six `BuffSkill` entries are zero. No hidden child is referenced by this component.
The raw `Ability.OnAttackMagicRate` field is 10, but its paired `OnAttackMagicID` is 0. The RuneWaker `RoleDataEx::AddAbility_Buff` path installs this on-attack trigger only when the ID is nonzero; the scheduler then uses the rate to cast that linked magic. This unbound 10 is therefore not evidence of a Rage-cost reduction, and must not be interpreted as 10% or 10 Rage. Local Lua search finds no reference to 506368 or 494377; the lone 490133 occurrence is an unrelated NPC rotation in `Resource\luascript\01506.lua`.
The available engine snapshot shows generic cast finalization calling `DelMagicCost`, but does not include that method's definition or a component-506368-specific handler. It cannot establish a hidden numeric modifier. Thus the raw data and checked engine/script paths do not fill the amount missing from the skill text.
## Implementation decision
Keep the post-hit general Rage-cost reduction out of runtime and generated numeric claims. Retain the already modeled local 10-Rage Whirlwind cost reduction and 10-second cooldown reduction, along with the known 20-second window only as descriptive source data; this audit does not change those clauses. Do not add a numeric helper or tests for the unquantified rider. A future integration needs a source that explicitly ties an amount and activation semantics to Rage of Calmness 494377 (including the applicable Whirlwind identity and retrigger/window rules).
+21
View File
@@ -0,0 +1,21 @@
# WoW Death Knight Wandering Plague
Wandering Plague (`talent-2003`) makes Blood Plague or Frost Fever damage have a chance, equal to the owner's melee critical-strike chance, to repeat a rank-scaled portion of that tick as area damage.
## Source interpretation
The local generated talent entry supplies the three rank values: 33%, 66%, and 100% additional damage. Its tooltip names Blood Plague and Frost Fever, an 8-yard target radius, and a proc chance equal to melee critical-strike chance. The local client snapshot is authoritative for the rank values and tooltip scope.
The [AzerothCore 3.3.5 handler](https://github.com/azerothcore/azerothcore-wotlk/blob/master/src/server/scripts/Spells/spell_dk.cpp#L3080-L3132) corroborates the dynamic base-attack crit roll, a 1-second internal cooldown, and scaling the triggered spell from the disease hit. Its [target filter](https://github.com/azerothcore/azerothcore-wotlk/blob/master/src/server/scripts/Spells/spell_dk.cpp#L2427-L2513) skips damage-breaking control effects. This is a fork-specific server reference, not a claim about pristine TrinityCore. The [WotLK simulator handler](https://github.com/wowsims/wotlk/blob/master/sim/deathknight/talents_unholy.go#L824-L866) corroborates the rank coefficients and disease-tick damage source.
## Runtime ownership
- `src/game/wow335DeathKnightWanderingPlagueRuntime.ts` reads the generated rank effect and owns the proc profile.
- The shared timed-effect runtime in `src/game/combatStore.ts` owns disease tick resolution for player and generated-companion sources. The proc requires a source-owned Death Knight disease tick that landed for positive damage. Its deterministic melee-crit roll and one-second cooldown are scoped to the owner; the triggered shadow splash cannot recursively proc Wandering Plague. Splash targets are living mobs in the generated eight-yard radius, excluding active statuses marked as break-on-damage.
- Focused helper and player/companion integration coverage belongs in `src/game/wow335DeathKnightWanderingPlagueRuntime.test.ts` and `src/game/wow335DeathKnightWanderingPlague.integration.test.ts`.
## Simulator choices and limits
The one-second owner cooldown is taken from the AzerothCore handler; the local generated rank entries have no cooldown value. AzerothCore's target filter checks damage-breaking crowd control classes (confuse, fear, stun, root, transform); this simulator represents the stated tooltip gate with its active `breakOnDamage` status flag. The tick-time splash query covers registered mobs with runtime positions in the generated eight-yard radius. It uses the common Shadow damage resolver per living eligible target, including modeled target immunity/resistance, and rounds the rank-scaled tick packet to the simulator's integer damage amount. It does not apply a Wandering Plague-specific area damage cap because the local generated talent data does not specify one.
The proc chance uses the owner's current modeled melee critical chance plus generated global Death Knight bonuses from Dark Conviction (`talent-1943`) and Ebon Plaguebringer (`talent-2043`). These two bonuses are added explicitly because the companion damage path omits generic talent critical-chance modifiers. Scoped strike talents such as Subversion and Vicious Strikes do not change this base-attack chance. Deterministic rolls are seeded from the disease tick and target.
+37
View File
@@ -0,0 +1,37 @@
# WoW Hunter Pet Family Source Data
## Source and extraction
`src/game/generated/wow335PetFamilyData.json` is generated from the local 3.3.5a client archive `../wow335a/Data/enUS/patch-enUS.MPQ`. The generator reads `CreatureFamily.dbc`, `SkillLineAbility.dbc`, `Spell.dbc`, and `SpellDuration.dbc`:
- `scripts/generate-wow335-pet-families.mjs` owns the extraction and writes the generated JSON.
- `src/game/wow335HunterPetFamilyData.ts` exposes the family rows, their unique and common pet skill lines, and rank/effect fields—including `AttributesEx1`, `procFlags`, `procChance`, and `procCharges`—without marking source rows playable.
- `src/game/wow335HunterPetFamilyData.test.ts` checks family classification and representative Wolf, Turtle, and Cat rank/effect rows.
The DBC has 40 named families. The local core classifies a family as a Hunter pet when `petTalentType` is non-negative (`Player.cpp`, pet talent validation); 32 rows satisfy that condition. Each family row includes its source ID/name, pet talent type, food mask, scaling fields, family skill line, and common pet skill line. Skill-line associations are corroborated by `Pet.cpp`: `LearnPetPassives()` looks up the creature family's skill lines, and `InitLevelupSpellsForLevel()` loads a family's level-up spells.
Regenerate with `node scripts/generate-wow335-pet-families.mjs`. The default archive is the local client path above; optional command-line arguments can supply another MPQ path and output path.
## Runtime boundary
The extracted family rows are source data, not a claim that every family is playable. The current selectable profiles are Wolf, Turtle, and Cat, defined in `src/game/wow335HunterPetFamilySelection.ts` and `src/game/wow335HunterPetRuntime.ts`. The player profile saves the choice as `CharacterProfile.hunterPetFamily`; legacy, invalid, and unsupported values normalize to Wolf. Selection is player-only, available from the active pet frame, and locked while an encounter is active. The player call and revive paths use that saved choice. Generated Hunter companions continue to use Wolf.
Turtle uses the same 11 Bite spell IDs as Wolf (17253, 17255-17261, 27050, 52473-52474), so the existing Bite rank damage and source-listed coefficients apply to either family. Turtle family skill line 251 also supplies Shell Shield spell 26064 at required pet level 1: 0 Focus, 60-second cooldown, 12-second duration, and AuraType 87 with a -50% damage-taken amount. The simulator autocasts Shell Shield while the Turtle has a combat target; that trigger is the simulator's pet-AI policy, while the timing and reduction values come from the local source row. Wolf alone receives Furious Howl.
Cat family skill line 209 supplies Claw (25 Focus, no recovery cooldown, 1.5-second GCD), Rake, and Prowl. The level-80 Rake rank (59886) costs 20 Focus, has a 10-second cooldown and 1.5-second GCD, and applies three ticks at three-second intervals over nine seconds. Cat Claw ranks use the local AzerothCore `spell_bonus_data` coefficients (.07 AP and .119658 spell damage); Rake's direct and periodic components each use .0175 AP and .0299 spell damage. The DBC damage ranges are resolved to their midpoint by the simulator.
Rake has separate armor rules for its components. The local Spell.dbc row has no spell-wide mechanic; effect 0 is direct school damage with mechanic 0, while effect 1 is periodic damage with mechanic 15 (bleed). Core `Unit::IsDamageReducedByArmor` applies physical armor to direct attacks and skips it for the bleed tick. The simulator applies armor to Cat's physical auto attacks and direct Claw/Rake specials, then carries Rake's `ignoreArmor` source mechanic on its periodic event. Target damage modifiers and absorbs still run once through the normal target-damage path for each component. Rake ticks remain owned by the pet and continue on their application target after pet death, dismissal, or family swap, consistent with the target-owned aura and null-caster periodic handler in the local core.
Prowl rank 1/2/3 are learned at pet levels 1/30/50. Their DBC rows cost no Focus, have no GCD, use a 10-second category cooldown, and grant stealth with -50%/-45%/-40% movement speed plus +20%/+35%/+50% damage for school mask 65 (`0x41`, physical and arcane). The runtime keeps the aura pet-owned, applies the rank movement reduction to modeled follow/chase movement, and applies the sourced modifier to the next ready auto swing. Prowl's source rows have `procFlags=332116` (`0x51154`, including `PROC_FLAG_DONE_MELEE_AUTO_ATTACK` bit `0x4`), one proc charge, and proc chance 100/101. The spell's `auraInterruptFlags` are 140291 (`0x22403`) for ranks 1/3 and 139267 (`0x22003`) for rank 2; none includes `MELEE_ATTACK` (`0x1000`). The local core therefore consumes Prowl through its successful-auto-attack proc after damage calculation, rather than through the melee interrupt mask. The runtime models that post-auto consumption for a recorded hit, including a fully absorbed hit. The pet-special damage path has no miss/dodge combat-result channel today, so the simulator treats a ready swing against a valid target as a hit and cannot model source miss exclusion. Claw/Rake have no `ALLOW_WHILE_STEALTHED` exception, and PetAI casts them as non-triggered spells; `Spell::prepare` removes their `SPELL_ATTACK` interrupt aura before effect resolution, so Prowl does not increase their direct or periodic damage. PetAI handles a ready melee swing before spell autocasting; the Cat runtime follows that order when both are ready. Autocasting Prowl without a target and incoming-damage break are simulator policies. Stealth visibility/detection remains unsupported. Kill Command is consumed once per executed Claw or Rake cast, and its matching `SPELLMOD_DAMAGE` and `SPELLMOD_DOT` modifiers are both snapshotted by Rake's direct and periodic components before that cast consumes one charge; subsequent ticks do not consume more charges.
Hunter pets currently have no dynamic model renderer in `src/scene/CombatSummonPopulation.tsx`; the summon frame shows the selected family name. Turtle's family/profile and name change with the saved selection, while `creatureId` remains legacy summon metadata and does not select Wolf-only mechanics.
No other extracted Hunter family is selectable yet. The extracted identities do not imply that all 32 Hunter families are playable.
## Remaining pet mechanics
- **Stable:** the simulator has no persistent pet roster or stable slots. The local core reference stores pets by `character_pet` rows and stable-slot modes (`PetDefines.h`, `Pet.cpp`); choosing among families alone does not model those saved pets.
- **Feed and happiness:** the generated family source now retains each `PetFoodMask`, but the simulator has no pet-owned food inventory or persistent happiness value. `wow335HunterPetHappinessRuntime.ts` only maps a supplied happiness value to a state and auto-attack multiplier.
- **Training:** the DBC source exposes family skill lines, rank requirements, and spell effects, but the simulator has no pet learned-spell book, training-point budget, or trainer purchase flow. Selecting a highest rank from owner level remains a runtime rule for the supported family profiles.
- **Stealth detection:** Prowl's source stealth level is retained, but the world has no stealth-visibility or detection-resolution model; the current runtime marks the pet as stealthed and applies its movement, auto-swing damage, and break behavior.
- **Pet critical inheritance:** unresolved. The local critical audit found no stock owner-crit inheritance formula; do not add the unmerged issue's owner-melee-crit proposal. See `docs/ACTIVE_CLASS_AUDIT.md` for the source boundary.
@@ -0,0 +1,20 @@
# WoW Source-Blocker Follow-up
**Date:** 2026-10-02
## Scope
Reviewed the current Vindication, Soul Leech, Nature's Guardian, and Static Shock blocker notes in `docs/ACTIVE_CLASS_MECHANICS_BACKLOG.md`, `docs/WOW_SHAMAN_REMAINING_TALENT_AUDIT.md`, `docs/WOW_SHAMAN_NATURES_GUARDIAN.md`, and `docs/ROM_WARLOCK_REMAINING_CONFLICTS_AUDIT.md`. The existing Area-52 patch-D and current patch-T DBC review and the exhausted website searches were not repeated.
The new local-source sweep covered source, addon, data, and documentation trees in `F:\Projects\WoWee`; the backed-up client add-ons in `F:\Projects\WoW-Pvt-Server-Backups\wow335a`; and spell scripts plus bounded SQL update directories in the AzerothCore snapshots under `F:\Projects\WoW-Pvt-Server-Backups` and `F:\Projects\wow-pvt-server-backups-2`.
## Findings
- **Paladin Vindication (talent 1633, rank 3 spell 209452):** No sibling source connected either self-targeted DUMMY effect to an AP operation or a child spell. The backup [MultiBotTalent.lua](../../WoW-Pvt-Server-Backups/wow335a/Interface/AddOns/MultiBot-Chatless-main/UI/MultiBotTalent.lua) contains only stock Vindication ranks 9452 and 26016. The separate generated SLS row 209451 naming 200067 remains unlinked from 209452. Needed evidence: an Area-52 server/client binding from 209452 to the AP-changing effect and its exact amount or formula, including its rank and proc gate.
- **Warlock Soul Leech (talent 1678):** No sibling source connected custom rows 830297–830299 to rank spells 30293, 30295, or 30296. The AzerothCore [spell_warlock.cpp](../../wow-pvt-server-backups-2/azerothcore-wotlk/src/server/scripts/Spells/spell_warlock.cpp) implementation is the stock 3.3.5a Soul Leech path and does not bind these Area-52 helper IDs. Needed evidence: the custom rank-to-helper link and the event/consumption rule that applies the +30% critical chance after a physical hit during an eligible cast.
- **Shaman Nature's Guardian (talent 1699):** No sibling source supplies the Area-52 capstone heal formula. The generated rank text still describes only a “small amount” increased by healing power. The AzerothCore [spell_shaman.cpp](../../wow-pvt-server-backups-2/azerothcore-wotlk/src/server/scripts/Spells/spell_shaman.cpp) code handles stock spell 30881 and its stock health/threat behavior; it does not establish the custom tooltip's heal amount. Needed evidence: the custom heal's base amount and healing-power scaling formula, with its rank gate.
- **Shaman Static Shock (talent 2055):** No sibling source resolves the generated condition `$?s973827[1 second][1.5 seconds]` for rank spells 51525–51527. The AzerothCore [spell_shaman.cpp](../../wow-pvt-server-backups-2/azerothcore-wotlk/src/server/scripts/Spells/spell_shaman.cpp) handler is for stock 51525 and only dispatches the Lightning Shield hit and consumes a charge; it does not establish an Area-52 internal-cooldown rank mapping. Needed evidence: the custom condition-to-rank mapping specifying which ranks use each cooldown.
## Source-role boundary
`WoWee` search hits were unrelated item-set references. The backed-up client add-on data describes stock 3.3.5a Vindication IDs. The server code is stock AzerothCore, not an Area-52 patch-D/current patch-T source. These findings are useful for excluding mismatched references, not for supplying Area-52 behavior. No mechanic in this follow-up has a new authoritative binding complete enough to implement.
+9
View File
@@ -0,0 +1,9 @@
# Nature's Guardian event model
Nature's Guardian (`talent-1699`) has generated rank spells 30881, 30883, and 30884. The local generated rank rows expose proc chances of 33%, 66%, and 100%. Their rank descriptions share the capstone text: a damaging non-periodic attack that brings the Shaman below 30% health grants a small heal and makes the next Nature spell guaranteed to critically strike, with a 30-second cooldown. See [the generated talent row](../src/game/wow335Talents.generated.ts#L74316).
The runtime treats “brings you below 30%” as a strict threshold crossing: pre-hit health must be at least 30%, post-hit health must be below 30%, and the attack must land and deal positive health damage. A character already below 30% does not retrigger it. Rank chance and cooldown are read from the generated rank effect and description rather than copied as independent constants. The tooltip does not specify the self-heal amount or its healing-power formula; that rider remains unimplemented.
The tooltip calls the charge the next “Nature spell” without limiting it to damage. The class catalog classifies Shaman healing spells such as Healing Wave as Nature spells, and the combat model supports critical heals, so an accepted Nature damage or healing spell may spend the charge. It is held through cast time and consumed once when the eligible spell resolves. A rejected cast or a cast interrupted before resolution does not consume it. A resolved spell spends one charge even if its damage misses or a heal is fully overhealed; multi-target impacts belong to that one spell and do not spend extra charges. Nature periodic ticks are not separate spell casts. Whether a Nature DoT-only cast spends the charge is not explicit in the generated text and remains outside this implementation. The generated text does not describe separate charge expiration, so the charge has no inferred timeout.
Coverage applies to player and generated companion Shamans. This behavior does not grant generic companions the Shaman talent. Nature-healing eligibility follows the unqualified generated “Nature spell” wording; the unspecified self-heal stays blocked independently.
+9
View File
@@ -30,6 +30,15 @@
"coa:sync": "node scripts/sync-ascension-coa.mjs",
"coa:sync:live": "node scripts/sync-ascension-coa.mjs --tooltips",
"coa:icons": "node scripts/export-spell-icons.mjs",
"coa:reference:inventory": "node scripts/coa-reference/cli.mjs inventory",
"coa:reference:fetch": "node scripts/coa-reference/cli.mjs fetch",
"coa:reference:fetch:spells": "node scripts/coa-reference/cli.mjs fetch-spells",
"coa:reference:build": "node scripts/coa-reference/cli.mjs build",
"coa:reference:audit": "node scripts/coa-reference/cli.mjs audit",
"coa:reference:lookup": "node scripts/coa-reference/cli.mjs lookup",
"coa:reference:test": "node --test scripts/coa-reference/*.test.mjs",
"coa:reference:promote": "node scripts/coa-reference/cli.mjs promote",
"coa:catalogs:refresh": "vite-node scripts/generate-combat-runtime-catalogs.ts --coa-only && node scripts/export-coa-triggered-spells.mjs && vite-node scripts/generate-combat-runtime-catalogs.ts --coa-only && vite-node scripts/generate-catalog-shards.ts --coa-only && node scripts/export-spell-icons.mjs --missing-only",
"ascension:talents": "node scripts/generate-wow335-talents.mjs ../wow335a/Interface/AddOns/PlayerBotManager/PBM/PBM_TalentData.lua ../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc src/game/wow335Talents.generated.ts ../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Talent.dbc",
"catalogs:shard": "vite-node scripts/generate-catalog-shards.ts",
"talents:generate": "vite-node scripts/generate-party-runtime-talents.ts",
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+41
View File
@@ -0,0 +1,41 @@
/** Reconstruct the exact scalar/string semantics of positional archive rows. */
export function archiveRowsToDbc(records, fieldTypes) {
if (!Array.isArray(fieldTypes) || fieldTypes.length !== 234) throw new Error("Archived Spell requires all 234 column types");
const strings = [Buffer.from([0])];
let stringSize = 1;
const stringOffsets = new Map([["", 0]]);
const rows = records.map((record) => {
const row = Buffer.alloc(234 * 4);
for (let index = 0; index < 234; index += 1) {
const key = `f${index}`;
if (!Object.hasOwn(record, key)) throw new Error(`Archived spell ${record.f0} is missing ${key}`);
const value = record[key];
const localized = (index >= 136 && index <= 151) || (index >= 153 && index <= 168)
|| (index >= 170 && index <= 185) || (index >= 187 && index <= 202);
const text = typeof value === "string" ? value : localized && typeof record[`${key}s`] === "string" ? record[`${key}s`] : null;
if (text !== null) {
if (!stringOffsets.has(text)) {
const bytes = Buffer.from(`${text}\0`, "utf8");
stringOffsets.set(text, stringSize);
strings.push(bytes);
stringSize += bytes.length;
}
row.writeUInt32LE(stringOffsets.get(text), index * 4);
} else if (fieldTypes[index] === "float") {
if (!Number.isFinite(value)) throw new Error(`Archived spell ${record.f0} has invalid ${key}`);
row.writeFloatLE(value, index * 4);
} else {
if (!Number.isInteger(value) || value < -2147483648 || value > 4294967295) throw new Error(`Archived spell ${record.f0} has invalid integer ${key}`);
row.writeUInt32LE(value >>> 0, index * 4);
}
}
return row;
});
const header = Buffer.alloc(20);
header.write("WDBC", 0);
header.writeUInt32LE(rows.length, 4);
header.writeUInt32LE(234, 8);
header.writeUInt32LE(234 * 4, 12);
header.writeUInt32LE(stringSize, 16);
return Buffer.concat([header, ...rows, ...strings]);
}
@@ -0,0 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";
import { archiveRowsToDbc } from "./archive-dbc.mjs";
test("archive positional reconstruction preserves signed bits, inferred floats and localized text", () => {
const types = Array(234).fill("int");
types[229] = "float";
const row = Object.fromEntries(types.map((_, index) => [`f${index}`, 0]));
Object.assign(row, { f0: 100, f4: -2147483648, f41: -2, f47: 1101004800, f136: "New spell", f136i: 54321, f170: 43210, f170s: "Alternate decoded description", f229: 0.375 });
const buffer = archiveRowsToDbc([row], types);
assert.equal(buffer.readUInt32LE(4), 1);
assert.equal(buffer.readUInt32LE(8), 234);
assert.equal(buffer.readUInt32LE(20 + 4 * 4), 2147483648);
assert.equal(buffer.readInt32LE(20 + 41 * 4), -2);
assert.equal(buffer.readFloatLE(20 + 47 * 4), 20);
assert.equal(buffer.readFloatLE(20 + 229 * 4), 0.375);
const stringStart = 20 + 234 * 4;
for (const [field, text] of [[136, "New spell"], [170, "Alternate decoded description"]]) {
const start = stringStart + buffer.readUInt32LE(20 + field * 4);
assert.equal(buffer.toString("utf8", start, buffer.indexOf(0, start)), text);
}
const malformed = { ...row };
delete malformed.f71;
assert.throws(() => archiveRowsToDbc([malformed], types), /missing f71/);
});
+566
View File
@@ -0,0 +1,566 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { gunzipSync } from "node:zlib";
export const ARCHIVE_SPELL_SOURCE = Object.freeze({
id: "august-global-spell-base",
repository: "srhinos/coa-datamine",
revision: "5078f9ac22541c45c17a866c23aac6c12073de23",
shortRevision: "5078f9ac2254",
capturedAt: "2026-08-12T21:02:51+00:00",
variantPath: "raw/tables/Spell/variants/data-patch-t-mpq",
tableIndexPath: "raw/tables/Spell/variants/data-patch-t-mpq/index.json",
tableIndexSha256: "3f3bcf86285607f73bc1cab248c57e12abb1d858c015cbd06cfc15d8ec0aea84",
tableIndexGitBlobSha: "fadd592b46f7d6d413713d79dff78018c14595bb",
columnInfoPath: "raw/tables/Spell/variants/data-patch-t-mpq/Spell.colinfo.json",
columnInfoSha256: "ba7ecede4d45c93ac66dd5917e5f9c34c81ac7da818e4a3c33b5cc80d5e1cf20",
columnInfoGitBlobSha: "ad3acfe6b3894a7990b81050972d707a6c0e7b28",
sourceDbcSha256: "a1e4285cdbc8a269a1b8a3f4b304ee43eb4d1bfa5a98511639868c4ff6097623",
rows: 209206,
columns: 234,
});
const cacheSchemaVersion = 1;
const manifestName = "manifest.json";
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function gitBlobSha(bytes) {
return createHash("sha1")
.update(Buffer.from("blob " + bytes.byteLength + "\0", "utf8"))
.update(bytes)
.digest("hex");
}
function parseJson(bytes, label) {
try {
return JSON.parse(bytes.toString("utf8"));
} catch (error) {
throw new Error("Archive Spell JSON is invalid at " + label + ": " + error.message, { cause: error });
}
}
function assertSafeRelativePath(value, label) {
if (typeof value !== "string" || value.length === 0) throw new TypeError(label + " must be a non-empty path");
const normalized = value.replaceAll("\\", "/");
if (normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)
|| normalized.split("/").some((part) => part === "" || part === "." || part === "..")) {
throw new Error(label + " is not a safe relative path: " + value);
}
return normalized;
}
function validateSource(source) {
const required = [
"id", "repository", "revision", "shortRevision", "capturedAt", "variantPath",
"tableIndexPath", "tableIndexSha256", "tableIndexGitBlobSha", "columnInfoPath",
"columnInfoSha256", "columnInfoGitBlobSha", "sourceDbcSha256",
];
for (const key of required) {
if (typeof source[key] !== "string" || source[key].length === 0) {
throw new TypeError("source." + key + " must be a non-empty string");
}
}
if (!/^[0-9a-f]{40}$/i.test(source.revision) || !/^[0-9a-f]{7,40}$/i.test(source.shortRevision)) {
throw new TypeError("source revision values must be hexadecimal Git revisions");
}
for (const key of ["tableIndexSha256", "columnInfoSha256", "sourceDbcSha256"]) {
if (!/^[0-9a-f]{64}$/i.test(source[key])) throw new TypeError("source." + key + " must be a SHA-256 digest");
}
for (const key of ["tableIndexGitBlobSha", "columnInfoGitBlobSha"]) {
if (!/^[0-9a-f]{40}$/i.test(source[key])) throw new TypeError("source." + key + " must be a Git blob SHA");
}
if (!Number.isSafeInteger(source.rows) || source.rows < 1) throw new TypeError("source.rows must be a positive integer");
if (!Number.isSafeInteger(source.columns) || source.columns < 1) throw new TypeError("source.columns must be a positive integer");
source.variantPath = assertSafeRelativePath(source.variantPath, "source.variantPath");
source.tableIndexPath = assertSafeRelativePath(source.tableIndexPath, "source.tableIndexPath");
source.columnInfoPath = assertSafeRelativePath(source.columnInfoPath, "source.columnInfoPath");
if (source.tableIndexPath !== source.variantPath + "/index.json"
|| source.columnInfoPath !== source.variantPath + "/Spell.colinfo.json") {
throw new Error("Archive Spell metadata paths must belong to the pinned BASE variant");
}
return Object.freeze({ ...source });
}
function sourceRecord(source) {
return {
id: source.id,
repository: "https://github.com/" + source.repository,
revision: source.revision,
capturedAt: source.capturedAt,
variantPath: source.variantPath,
tableIndexPath: source.tableIndexPath,
tableIndexSha256: source.tableIndexSha256,
tableIndexGitBlobSha: source.tableIndexGitBlobSha,
columnInfoPath: source.columnInfoPath,
columnInfoSha256: source.columnInfoSha256,
columnInfoGitBlobSha: source.columnInfoGitBlobSha,
sourceDbcSha256: source.sourceDbcSha256,
rows: source.rows,
columns: source.columns,
};
}
function resultSource(source, files) {
const storedBytes = files.reduce((total, file) => total + file.size, 0);
return {
id: source.id,
path: source.variantPath,
sha256: source.tableIndexSha256,
bytes: storedBytes,
storedBytes,
sourceDbcSha256: source.sourceDbcSha256,
originalRecordCount: source.rows,
pin: source.revision,
capturedAt: source.capturedAt,
tableIndexPath: source.tableIndexPath,
tableIndexGitBlobSha: source.tableIndexGitBlobSha,
columnInfoPath: source.columnInfoPath,
columnInfoSha256: source.columnInfoSha256,
columnInfoGitBlobSha: source.columnInfoGitBlobSha,
};
}
function assertPinnedBytes(bytes, sha256Pin, gitBlobPin, label) {
const actualSha = sha256(bytes);
const actualBlob = gitBlobSha(bytes);
if (actualSha !== sha256Pin.toLowerCase()) {
throw new Error(label + " SHA-256 mismatch: expected " + sha256Pin + ", found " + actualSha);
}
if (actualBlob !== gitBlobPin.toLowerCase()) {
throw new Error(label + " Git blob mismatch: expected " + gitBlobPin + ", found " + actualBlob);
}
}
function validateIndex(index, source) {
if (!index || typeof index !== "object" || Array.isArray(index)) throw new Error("Archive Spell index is not an object");
if (index.table !== "Spell" || index.file !== "Spell.dbc") throw new Error("Archive Spell index is not the Spell table");
if (index.sourceSha256 !== source.sourceDbcSha256) {
throw new Error("Archive Spell index is not the pinned BASE patch-T Spell.dbc source");
}
if (index.rows !== source.rows || index.columns !== source.columns) {
throw new Error("Archive Spell index row or column count does not match the pinned source");
}
if (index.format !== "gzip" || index.shardKey?.column !== "f0"
|| index.shardKey?.mode !== "column" || index.shardKey?.unsigned !== true) {
throw new Error("Archive Spell index does not use the pinned unsigned f0 shard layout");
}
if (!Array.isArray(index.shards) || index.shards.length === 0) throw new Error("Archive Spell index has no shard list");
const names = new Set();
const ranges = [];
for (const shard of index.shards) {
const name = assertSafeRelativePath(shard?.file, "Archive Spell shard path");
const match = /^([0-9]+)-([0-9]+)\.jsonl\.gz$/.exec(name);
if (!match || name.includes("/")) throw new Error("Archive Spell index has an invalid shard filename: " + name);
if (names.has(name)) throw new Error("Archive Spell index repeats shard " + name);
names.add(name);
const lo = Number(match[1]);
const hi = Number(match[2]);
if (lo !== shard.lo || hi !== shard.hi || !Number.isSafeInteger(lo) || !Number.isSafeInteger(hi) || hi < lo) {
throw new Error("Archive Spell shard range disagrees with filename: " + name);
}
if (!Number.isSafeInteger(shard.rows) || shard.rows < 0
|| !/^[0-9a-f]{64}$/i.test(shard.sha256 ?? "") || shard.format !== "gzip") {
throw new Error("Archive Spell index has invalid shard metadata for " + name);
}
ranges.push({ lo, hi, name });
}
ranges.sort((a, b) => a.lo - b.lo || a.hi - b.hi);
for (let index = 1; index < ranges.length; index += 1) {
if (ranges[index].lo <= ranges[index - 1].hi) throw new Error("Archive Spell index has overlapping shard ranges");
}
return index;
}
function fieldTypesFromColumnInfo(columnInfo, source) {
const columns = columnInfo?.columns;
if (!columnInfo || typeof columnInfo !== "object" || !Array.isArray(columns)) {
throw new Error("Pinned BASE Spell.colinfo.json has no columns array");
}
if (columnInfo.table !== "Spell" || columnInfo.columnCount !== source.columns || columns.length !== source.columns
|| columnInfo.source?.winner !== "Data/patch-T.MPQ"
|| columnInfo.source?.sha256 !== source.sourceDbcSha256
|| columnInfo.header?.records !== source.rows) {
throw new Error("Pinned BASE Spell.colinfo.json does not describe all Spell fields");
}
const fieldTypes = Array(source.columns);
for (const column of columns) {
const index = Number(column?.index);
const type = column?.inferred;
if (!Number.isSafeInteger(index) || index < 0 || index >= source.columns || fieldTypes[index] !== undefined) {
throw new Error("Pinned BASE Spell.colinfo.json has an invalid or duplicate field index");
}
if (!["int", "float", "string", "unknown"].includes(type)) {
throw new Error("Pinned BASE Spell.colinfo.json has an unsupported inferred type at f" + index);
}
fieldTypes[index] = type;
}
if (fieldTypes.some((type) => type === undefined)) throw new Error("Pinned BASE Spell.colinfo.json is missing a field type");
return fieldTypes;
}
function parseShard(bytes, shard, source) {
if (sha256(bytes) !== shard.sha256.toLowerCase()) {
throw new Error("Cached Archive Spell shard SHA-256 mismatch: " + shard.file);
}
let text;
try {
text = gunzipSync(bytes).toString("utf8");
} catch (error) {
throw new Error("Archive Spell shard is not valid gzip data: " + shard.file, { cause: error });
}
const rows = [];
const ids = new Set();
for (const [lineIndex, line] of text.split("\n").entries()) {
if (line.length === 0) continue;
const row = parseJson(Buffer.from(line, "utf8"), shard.file + ":" + (lineIndex + 1));
if (!row || typeof row !== "object" || Array.isArray(row)) throw new Error("Archive Spell row is not an object in " + shard.file);
const id = Number(row.f0);
if (!Number.isSafeInteger(id) || id < shard.lo || id > shard.hi || ids.has(id)) {
throw new Error("Archive Spell row has an invalid or duplicate f0 in " + shard.file);
}
for (let field = 0; field < source.columns; field += 1) {
if (!Object.hasOwn(row, "f" + field)) throw new Error("Archive Spell row is missing f" + field + " in " + shard.file);
}
ids.add(id);
rows.push(row);
}
if (rows.length !== shard.rows) throw new Error("Archive Spell shard row count mismatch: " + shard.file);
rows.sort((a, b) => Number(a.f0) - Number(b.f0));
return rows;
}
function positiveSpellIds(input) {
if (!Array.isArray(input)) throw new TypeError("spellIds must be an array");
const ids = input.map((value) => Number(value));
if (ids.some((id) => !Number.isSafeInteger(id) || id <= 0)) {
throw new TypeError("spellIds must contain positive safe integers");
}
return [...new Set(ids)].sort((a, b) => a - b);
}
function childrenFromRow(row) {
const children = [];
for (let effect = 0; effect < 3; effect += 1) {
const triggerId = Number(row["f" + (116 + effect)]);
if (Number.isSafeInteger(triggerId) && triggerId > 0) children.push(triggerId);
if (Number(row["f" + (71 + effect)]) === 168) {
const itemSpellId = Number(row["f" + (110 + effect)]);
if (Number.isSafeInteger(itemSpellId) && itemSpellId > 0) children.push(itemSpellId);
}
}
return children;
}
function descriptorForId(index, id) {
return index.shards.find((shard) => id >= shard.lo && id <= shard.hi) ?? null;
}
function validateManifest(manifest, source, index) {
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) throw new Error("Archive Spell cache manifest is not an object");
if (manifest.schemaVersion !== cacheSchemaVersion) throw new Error("Unsupported Archive Spell cache schema");
if (JSON.stringify(manifest.source) !== JSON.stringify(sourceRecord(source))) {
throw new Error("Archive Spell cache manifest points at an unexpected source or patch variant");
}
if (!Array.isArray(manifest.requestedIds) || !Array.isArray(manifest.files)) {
throw new Error("Archive Spell cache manifest is missing requestedIds or files");
}
const requestedIds = positiveSpellIds(manifest.requestedIds);
if (requestedIds.length !== manifest.requestedIds.length
|| requestedIds.some((id, position) => id !== manifest.requestedIds[position])) {
throw new Error("Archive Spell cache manifest requested ids must be sorted and unique");
}
const byFile = new Map(index.shards.map((shard) => [shard.file, shard]));
const names = new Set();
for (const file of manifest.files) {
const name = assertSafeRelativePath(file?.path, "Archive Spell manifest shard path");
const shard = byFile.get(name);
if (!shard || names.has(name) || file.sha256 !== shard.sha256
|| file.archivePath !== source.variantPath + "/" + name
|| file.lo !== shard.lo || file.hi !== shard.hi || file.rows !== shard.rows
|| !/^[0-9a-f]{40}$/i.test(file.gitBlobSha ?? "")
|| !Number.isSafeInteger(file.size) || file.size < 0) {
throw new Error("Archive Spell cache manifest contains an unknown or inconsistent shard: " + name);
}
names.add(name);
}
return { requestedIds, files: manifest.files };
}
export function createArchiveSpellCacheApi(sourceInput = ARCHIVE_SPELL_SOURCE) {
const source = validateSource({ ...sourceInput });
function archiveDirectory(cacheRoot) {
if (typeof cacheRoot !== "string" || cacheRoot.length === 0) throw new TypeError("cacheRoot must be a non-empty directory path");
return path.join(path.resolve(cacheRoot), "archive-" + source.shortRevision, "spell-base");
}
function rawUrl(filePath) {
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
return "https://raw.githubusercontent.com/" + source.repository + "/" + source.revision + "/" + encodedPath;
}
async function responseBytes(fetchImpl, url) {
let response;
try {
response = await fetchImpl(url, { headers: { Accept: "application/json, application/gzip" } });
} catch (error) {
throw new Error("Archive Spell source unavailable at " + url + ": " + error.message, { cause: error });
}
if (!response || response.ok !== true) {
const status = response?.status === undefined ? "no response" : "HTTP " + response.status;
throw new Error("Archive Spell source unavailable at " + url + " (" + status + ")");
}
if (typeof response.arrayBuffer === "function") return Buffer.from(await response.arrayBuffer());
if (typeof response.text === "function") return Buffer.from(await response.text(), "utf8");
throw new Error("Archive Spell source response has no readable body at " + url);
}
async function writeAtomic(filePath, bytes) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const tempPath = filePath + ".tmp-" + process.pid;
await fs.writeFile(tempPath, bytes);
await fs.rename(tempPath, filePath);
}
async function cachedPinnedBytes(filePath, shaPin, blobPin) {
try {
const bytes = await fs.readFile(filePath);
assertPinnedBytes(bytes, shaPin, blobPin, path.basename(filePath));
return bytes;
} catch (error) {
if (error?.code === "ENOENT") return null;
if (error?.code && error.code !== "ENOENT") throw error;
return null;
}
}
async function ensureMetadata(cacheRoot, fetchImpl) {
const root = archiveDirectory(cacheRoot);
const indexCachePath = path.join(root, "Spell.index.json");
const colinfoCachePath = path.join(root, "Spell.colinfo.json");
let [indexBytes, colinfoBytes] = await Promise.all([
cachedPinnedBytes(indexCachePath, source.tableIndexSha256, source.tableIndexGitBlobSha),
cachedPinnedBytes(colinfoCachePath, source.columnInfoSha256, source.columnInfoGitBlobSha),
]);
if (!indexBytes) {
indexBytes = await cachedPinnedBytes(
path.join(path.dirname(root), "remote-audit", "raw_tables_Spell_variants_data-patch-t-mpq_index.json"),
source.tableIndexSha256,
source.tableIndexGitBlobSha,
);
if (indexBytes) await writeAtomic(indexCachePath, indexBytes);
}
if (!colinfoBytes) {
colinfoBytes = await cachedPinnedBytes(
path.join(path.dirname(root), "remote-audit", "base_Spell.colinfo.json"),
source.columnInfoSha256,
source.columnInfoGitBlobSha,
);
if (colinfoBytes) await writeAtomic(colinfoCachePath, colinfoBytes);
}
if (!indexBytes) {
indexBytes = await responseBytes(fetchImpl, rawUrl(source.tableIndexPath));
assertPinnedBytes(indexBytes, source.tableIndexSha256, source.tableIndexGitBlobSha, "BASE Spell index");
await writeAtomic(indexCachePath, indexBytes);
}
if (!colinfoBytes) {
colinfoBytes = await responseBytes(fetchImpl, rawUrl(source.columnInfoPath));
assertPinnedBytes(colinfoBytes, source.columnInfoSha256, source.columnInfoGitBlobSha, "BASE Spell.colinfo.json");
await writeAtomic(colinfoCachePath, colinfoBytes);
}
const index = validateIndex(parseJson(indexBytes, source.tableIndexPath), source);
const columnInfo = parseJson(colinfoBytes, source.columnInfoPath);
const fieldTypes = fieldTypesFromColumnInfo(columnInfo, source);
return { root, index, fieldTypes };
}
async function readShard(root, shard, sourceInputForRows) {
const shardPath = path.join(root, "shards", shard.file);
const bytes = await fs.readFile(shardPath);
return { bytes, rows: parseShard(bytes, shard, sourceInputForRows) };
}
async function loadManifest(root, index) {
const manifestPath = path.join(root, manifestName);
let bytes;
try {
bytes = await fs.readFile(manifestPath);
} catch (error) {
if (error?.code === "ENOENT") return null;
throw error;
}
return validateManifest(parseJson(bytes, manifestPath), source, index);
}
async function closeSpellIds({ index, requestedIds, root, fetchImpl, allowFetch }) {
const records = new Map();
const missing = new Set();
const selectedShards = new Map();
const loadedRows = new Map();
const pending = [...requestedIds];
const visited = new Set();
for (let cursor = 0; cursor < pending.length; cursor += 1) {
const id = pending[cursor];
if (visited.has(id)) continue;
visited.add(id);
const descriptor = descriptorForId(index, id);
if (!descriptor) {
missing.add(id);
continue;
}
selectedShards.set(descriptor.file, descriptor);
let rows = loadedRows.get(descriptor.file);
if (!rows) {
const localPath = path.join(root, "shards", descriptor.file);
let bytes = null;
let localBytesFound = false;
const candidatePaths = allowFetch
? [localPath, path.join(path.dirname(root), "remote-audit", "base-spell-shards", descriptor.file)]
: [localPath];
for (const candidatePath of candidatePaths) {
try {
const candidate = await fs.readFile(candidatePath);
if (candidatePath === localPath) localBytesFound = true;
if (sha256(candidate) !== descriptor.sha256.toLowerCase()) {
if (!allowFetch && candidatePath === localPath) {
throw new Error("Cached Archive Spell shard SHA-256 mismatch: " + descriptor.file);
}
continue;
}
bytes = candidate;
if (candidatePath !== localPath) await writeAtomic(localPath, bytes);
break;
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
if (!bytes && allowFetch) {
bytes = await responseBytes(fetchImpl, rawUrl(source.variantPath + "/" + descriptor.file));
if (sha256(bytes) !== descriptor.sha256.toLowerCase()) {
throw new Error("BASE Archive Spell shard SHA-256 mismatch: " + descriptor.file);
}
await writeAtomic(localPath, bytes);
}
if (!bytes) {
const detail = localBytesFound ? " has invalid cached bytes" : " is missing";
throw new Error("Archive Spell cache shard " + descriptor.file + detail + "; run downloadArchiveSpells first");
}
rows = parseShard(bytes, descriptor, source);
loadedRows.set(descriptor.file, rows);
}
const row = rows.find((candidate) => Number(candidate.f0) === id);
if (!row) {
missing.add(id);
continue;
}
records.set(id, row);
for (const child of childrenFromRow(row)) if (!visited.has(child)) pending.push(child);
}
return {
records: [...records.values()].sort((a, b) => Number(a.f0) - Number(b.f0)),
missingIds: [...missing].sort((a, b) => a - b),
selectedShards,
};
}
function manifestFile(shard, bytes, source) {
return {
path: shard.file,
archivePath: source.variantPath + "/" + shard.file,
lo: shard.lo,
hi: shard.hi,
rows: shard.rows,
sha256: shard.sha256,
gitBlobSha: gitBlobSha(bytes),
size: bytes.byteLength,
};
}
async function readArchiveSpells({ cacheRoot }) {
const root = archiveDirectory(cacheRoot);
let indexBytes;
let colinfoBytes;
try {
[indexBytes, colinfoBytes] = await Promise.all([
fs.readFile(path.join(root, "Spell.index.json")),
fs.readFile(path.join(root, "Spell.colinfo.json")),
]);
} catch (error) {
if (error?.code === "ENOENT") throw new Error("Archive Spell cache metadata is missing; run downloadArchiveSpells first");
throw error;
}
assertPinnedBytes(indexBytes, source.tableIndexSha256, source.tableIndexGitBlobSha, "Cached BASE Spell index");
assertPinnedBytes(colinfoBytes, source.columnInfoSha256, source.columnInfoGitBlobSha, "Cached BASE Spell.colinfo.json");
const index = validateIndex(parseJson(indexBytes, source.tableIndexPath), source);
const fieldTypes = fieldTypesFromColumnInfo(parseJson(colinfoBytes, source.columnInfoPath), source);
const manifest = await loadManifest(root, index);
if (!manifest) throw new Error("Archive Spell cache manifest is missing; run downloadArchiveSpells first");
const closure = await closeSpellIds({ index, requestedIds: manifest.requestedIds, root, allowFetch: false });
const declared = new Map(manifest.files.map((file) => [file.path, file]));
if (declared.size !== closure.selectedShards.size
|| [...closure.selectedShards.keys()].some((name) => !declared.has(name))) {
throw new Error("Archive Spell cache manifest does not cover the complete selected spell closure");
}
const files = [];
for (const shard of [...closure.selectedShards.values()].sort((a, b) => a.lo - b.lo)) {
const { bytes } = await readShard(root, shard, source);
const file = manifestFile(shard, bytes, source);
const expected = declared.get(shard.file);
if (file.gitBlobSha !== expected.gitBlobSha || file.size !== expected.size) {
throw new Error("Archive Spell cached shard does not match its manifest: " + shard.file);
}
files.push(file);
}
return {
source: resultSource(source, files),
fieldTypes,
records: closure.records,
files,
missingIds: closure.missingIds,
};
}
async function downloadArchiveSpells({ cacheRoot, spellIds, fetchImpl = globalThis.fetch }) {
if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl must be a function");
const newIds = positiveSpellIds(spellIds);
const { root, index, fieldTypes } = await ensureMetadata(cacheRoot, fetchImpl);
const previous = await loadManifest(root, index);
const requestedIds = positiveSpellIds([...(previous?.requestedIds ?? []), ...newIds]);
const closure = await closeSpellIds({ index, requestedIds, root, fetchImpl, allowFetch: true });
const files = [];
for (const shard of [...closure.selectedShards.values()].sort((a, b) => a.lo - b.lo)) {
const { bytes } = await readShard(root, shard, source);
files.push(manifestFile(shard, bytes, source));
}
const manifest = {
schemaVersion: cacheSchemaVersion,
source: sourceRecord(source),
requestedIds,
files,
};
await writeAtomic(path.join(root, manifestName), Buffer.from(JSON.stringify(manifest, null, 2) + "\n", "utf8"));
return {
source: resultSource(source, files),
fieldTypes,
records: closure.records,
files,
missingIds: closure.missingIds,
};
}
return Object.freeze({ downloadArchiveSpells, readArchiveSpells });
}
const defaultArchiveSpellApi = createArchiveSpellCacheApi();
export function downloadArchiveSpells({ cacheRoot, spellIds, fetchImpl = globalThis.fetch }) {
return defaultArchiveSpellApi.downloadArchiveSpells({ cacheRoot, spellIds, fetchImpl });
}
export function readArchiveSpells({ cacheRoot }) {
return defaultArchiveSpellApi.readArchiveSpells({ cacheRoot });
}
@@ -0,0 +1,252 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { gzipSync } from "node:zlib";
import { ARCHIVE_SPELL_SOURCE, createArchiveSpellCacheApi } from "./archive-spells.mjs";
const fixtureSourceSha = "b".repeat(64);
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function gitBlobSha(bytes) {
return createHash("sha1")
.update(Buffer.from("blob " + bytes.byteLength + "\0", "utf8"))
.update(bytes)
.digest("hex");
}
function spellRow(id, options = {}) {
const row = {};
for (let field = 0; field < 234; field += 1) row["f" + field] = 0;
row.f0 = id;
row.f41 = 1;
row.f47 = 1.5;
row.f71 = options.effectType ?? 0;
row.f110 = options.miscValue ?? 0;
row.f116 = options.triggerSpellId ?? 0;
row.f136 = options.name ?? "Test spell " + id;
row.f136i = id * 10;
row.f153 = "Passive";
row.f153i = id * 10 + 1;
row.f170 = "Description for " + id;
row.f170i = id * 10 + 2;
row.f187 = "Alternate " + id;
row.f187i = id * 10 + 3;
row.f229 = 0.25;
return row;
}
function makeFixture({ sourceDbcSha256 = fixtureSourceSha } = {}) {
const shardRows = [
{ lo: 1, hi: 99, rows: [spellRow(1, { triggerSpellId: 101 })] },
{ lo: 100, hi: 199, rows: [
spellRow(101, { effectType: 168, miscValue: 202 }),
spellRow(155),
] },
{ lo: 200, hi: 299, rows: [spellRow(202)] },
{ lo: 300, hi: 399, rows: [spellRow(350)] },
];
const shards = [];
const archiveFiles = new Map();
for (const group of shardRows) {
const name = String(group.lo).padStart(8, "0") + "-" + String(group.hi).padStart(8, "0") + ".jsonl.gz";
const bytes = gzipSync(Buffer.from(group.rows.map((row) => JSON.stringify(row)).join("\n") + "\n", "utf8"), { level: 9, mtime: 0 });
archiveFiles.set(name, bytes);
shards.push({
file: name,
format: "gzip",
lo: group.lo,
hi: group.hi,
plainBytes: 1,
rows: group.rows.length,
sha256: sha256(bytes),
storedBytes: bytes.byteLength,
});
}
const index = {
file: "Spell.dbc",
format: "gzip",
rows: 5,
columns: 234,
table: "Spell",
sourceSha256: sourceDbcSha256,
shardKey: { column: "f0", maxRows: 5000, mode: "column", unsigned: true },
shards,
};
const columnInfo = {
table: "Spell",
columnCount: 234,
header: { records: 5 },
source: { winner: "Data/patch-T.MPQ", sha256: sourceDbcSha256 },
columns: Array.from({ length: 234 }, (_, index) => ({
index,
inferred: index === 47 || index === 229 ? "float" : [136, 153, 170, 187].includes(index) ? "string" : "int",
})),
};
const indexBytes = Buffer.from(JSON.stringify(index), "utf8");
const colinfoBytes = Buffer.from(JSON.stringify(columnInfo), "utf8");
const variantPath = "raw/tables/Spell/variants/data-patch-t-mpq";
const source = {
id: "fixture-global-spell-base",
repository: "example/coa-datamine",
revision: "a".repeat(40),
shortRevision: "aaaaaaaaaaaa",
capturedAt: "2026-08-12T21:02:51+00:00",
variantPath,
tableIndexPath: variantPath + "/index.json",
tableIndexSha256: sha256(indexBytes),
tableIndexGitBlobSha: gitBlobSha(indexBytes),
columnInfoPath: variantPath + "/Spell.colinfo.json",
columnInfoSha256: sha256(colinfoBytes),
columnInfoGitBlobSha: gitBlobSha(colinfoBytes),
sourceDbcSha256,
rows: 5,
columns: 234,
};
const prefix = "https://raw.githubusercontent.com/" + source.repository + "/" + source.revision + "/";
const metadataFiles = new Map([
[prefix + source.tableIndexPath, indexBytes],
[prefix + source.columnInfoPath, colinfoBytes],
]);
for (const [name, bytes] of archiveFiles) metadataFiles.set(prefix + variantPath + "/" + name, bytes);
return { source, archiveFiles, files: metadataFiles, prefix, variantPath, indexBytes, colinfoBytes };
}
function fakeFetch(fileMap, calls) {
return async (url) => {
calls.push(url);
const bytes = fileMap.get(url);
if (!bytes) return { ok: false, status: 404 };
return {
ok: true,
arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
};
};
}
async function withTempCache(run) {
const cacheRoot = await fs.mkdtemp(path.join(os.tmpdir(), "coa-archive-spells-"));
try {
await run(cacheRoot);
} finally {
await fs.rm(cacheRoot, { recursive: true, force: true });
}
}
test("pins and reads the global patch-T source and closes trigger and effect-168 references", async () => {
assert.equal(ARCHIVE_SPELL_SOURCE.variantPath, "raw/tables/Spell/variants/data-patch-t-mpq");
assert.equal(ARCHIVE_SPELL_SOURCE.sourceDbcSha256, "a1e4285cdbc8a269a1b8a3f4b304ee43eb4d1bfa5a98511639868c4ff6097623");
await withTempCache(async (cacheRoot) => {
const fixture = makeFixture();
const api = createArchiveSpellCacheApi(fixture.source);
const calls = [];
const fetchImpl = fakeFetch(fixture.files, calls);
const downloaded = await api.downloadArchiveSpells({ cacheRoot, spellIds: [1], fetchImpl });
assert.deepEqual(downloaded.records.map((row) => row.f0), [1, 101, 202]);
assert.deepEqual(downloaded.missingIds, []);
assert.deepEqual(downloaded.fieldTypes.slice(0, 3), ["int", "int", "int"]);
assert.equal(downloaded.fieldTypes[47], "float");
assert.equal(downloaded.fieldTypes[136], "string");
assert.equal(downloaded.source.path, fixture.variantPath);
assert.equal(downloaded.source.sourceDbcSha256, fixture.source.sourceDbcSha256);
assert.equal(downloaded.source.originalRecordCount, 5);
assert.equal(downloaded.source.bytes, downloaded.files.reduce((sum, file) => sum + file.size, 0));
assert.equal(downloaded.files.length, 3);
assert.ok(downloaded.files.every((file) => file.archivePath.startsWith(fixture.variantPath + "/")));
assert.equal(calls.filter((url) => url.includes("/variants/") && url.endsWith(".jsonl.gz")).length, 3);
assert.ok(!calls.some((url) => url.includes("/variants/") && url.includes("00000300-")));
const offline = await api.readArchiveSpells({ cacheRoot });
assert.deepEqual(offline.records, downloaded.records);
assert.deepEqual(offline.fieldTypes, downloaded.fieldTypes);
assert.deepEqual(offline.missingIds, []);
});
});
test("resumes selected shards, repairs tampered cached bytes, and reports absent ids", async () => {
await withTempCache(async (cacheRoot) => {
const fixture = makeFixture();
const api = createArchiveSpellCacheApi(fixture.source);
const calls = [];
const fetchImpl = fakeFetch(fixture.files, calls);
await api.downloadArchiveSpells({ cacheRoot, spellIds: [1], fetchImpl });
const firstCallCount = calls.length;
const resumed = await api.downloadArchiveSpells({ cacheRoot, spellIds: [350, 999], fetchImpl });
assert.deepEqual(resumed.records.map((row) => row.f0), [1, 101, 202, 350]);
assert.deepEqual(resumed.missingIds, [999]);
assert.equal(calls.length, firstCallCount + 1);
const cachedShard = path.join(cacheRoot, "archive-" + fixture.source.shortRevision, "spell-base", "shards", "00000001-00000099.jsonl.gz");
await fs.writeFile(cachedShard, Buffer.from("tampered"));
await assert.rejects(api.readArchiveSpells({ cacheRoot }), /SHA-256 mismatch/);
const beforeRepair = calls.length;
const repaired = await api.downloadArchiveSpells({ cacheRoot, spellIds: [350], fetchImpl });
assert.deepEqual(repaired.records.map((row) => row.f0), [1, 101, 202, 350]);
assert.equal(calls.length, beforeRepair + 1);
const raw = await fs.readFile(cachedShard);
assert.equal(sha256(raw), fixture.archiveFiles.get("00000001-00000099.jsonl.gz") && sha256(fixture.archiveFiles.get("00000001-00000099.jsonl.gz")));
});
});
test("reuses pin-matching remote-audit BASE metadata and shards without a network fetch", async () => {
await withTempCache(async (cacheRoot) => {
const fixture = makeFixture();
const archiveRoot = path.join(cacheRoot, "archive-" + fixture.source.shortRevision);
const remoteAudit = path.join(archiveRoot, "remote-audit");
const shardDirectory = path.join(remoteAudit, "base-spell-shards");
await fs.mkdir(shardDirectory, { recursive: true });
await fs.writeFile(path.join(remoteAudit, "raw_tables_Spell_variants_data-patch-t-mpq_index.json"), fixture.indexBytes);
await fs.writeFile(path.join(remoteAudit, "base_Spell.colinfo.json"), fixture.colinfoBytes);
for (const name of [
"00000001-00000099.jsonl.gz",
"00000100-00000199.jsonl.gz",
"00000200-00000299.jsonl.gz",
]) {
await fs.writeFile(path.join(shardDirectory, name), fixture.archiveFiles.get(name));
}
const calls = [];
const result = await createArchiveSpellCacheApi(fixture.source).downloadArchiveSpells({
cacheRoot,
spellIds: [1],
fetchImpl: async (url) => {
calls.push(url);
throw new Error("unexpected network request");
},
});
assert.equal(calls.length, 0);
assert.deepEqual(result.records.map((row) => row.f0), [1, 101, 202]);
assert.deepEqual(result.files.map((file) => file.path), [
"00000001-00000099.jsonl.gz",
"00000100-00000199.jsonl.gz",
"00000200-00000299.jsonl.gz",
]);
});
});
test("rejects an overlay index even when its metadata hashes match the supplied bytes", async () => {
await withTempCache(async (cacheRoot) => {
const fixture = makeFixture();
const overlayFixture = makeFixture({ sourceDbcSha256: "c".repeat(64) });
const wrongIndexUrl = fixture.prefix + fixture.source.tableIndexPath;
fixture.files.set(wrongIndexUrl, overlayFixture.files.get(wrongIndexUrl));
const overlayBytes = fixture.files.get(wrongIndexUrl);
fixture.source.tableIndexSha256 = sha256(overlayBytes);
fixture.source.tableIndexGitBlobSha = gitBlobSha(overlayBytes);
const api = createArchiveSpellCacheApi(fixture.source);
await assert.rejects(
api.downloadArchiveSpells({ cacheRoot, spellIds: [1], fetchImpl: fakeFetch(fixture.files, []) }),
/pinned BASE patch-T Spell\.dbc source/,
);
});
});
File diff suppressed because it is too large. Load diff
+207
View File
@@ -0,0 +1,207 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { auditReference, renderAuditMarkdown, validateReference } from "./audit.mjs";
const gameIds = [
"barbarian", "witch-doctor", "felsworn", "witch-hunter", "stormbringer", "knight-of-xoroth",
"guardian", "templar", "bloodmage", "ranger", "chronomancer", "necromancer", "pyromancer",
"cultist", "starcaller", "sun-cleric", "tinker", "venomancer", "reaper", "primalist", "runemaster",
];
function sourceRow(id = "base-spell") {
return { id, path: "src/spells.dbc", sha256: "a".repeat(64), bytes: 100, kind: "dbc" };
}
function spell(id, triggerSpellId = 0, name = "Spell " + id) {
const dbc = {
id, name, rankText: "Rank 1", description: "A test spell.", castTimeMs: 0, procChance: 100,
effects: [{ index: 0, effectType: triggerSpellId ? 64 : 2, auraType: 0, basePoints: 1, triggerSpellId }],
};
return { id, name, rank: "Rank 1", description: dbc.description, dbc, source: "base", classIds: [12] };
}
function node(id, overrides = {}) {
return {
id, classId: 12, tabId: 1, x: 0, y: 0, name: "Node " + id,
nodeType: "SpendSquare", entryType: "Talent", isPassive: false, maxPoints: 1,
aeCost: 0, teCost: 1, requiredClassPoints: 0, requiredSpecPoints: 0,
requiredIds: [], connectedNodeIds: [], requiredLevel: 0, isStartingNode: false,
iconBasename: "test", flags: 0, group: 7, sortOrder: id, description: "A test mechanic.",
rankDescriptions: [{ rank: 1, spellId: id === 100 ? 200 : 201, description: "A test mechanic." }],
...overrides,
};
}
function referenceFixture() {
return {
schemaVersion: 1,
baseline: { kind: "local-july", capturedAt: "2026-07-01T00:00:00Z" },
classes: gameIds.map((gameId, index) => ({
classId: index + 12, gameId, className: gameId, tabs: index === 0
? [{ tabId: 1, tabName: "First", sortOrder: 1 }]
: [],
})),
nodes: [node(100), node(101, { group: 7 })],
spells: [spell(200, 201), spell(201)],
progressionChains: [{
id: "chain-a", classId: 12, name: "Test chain", iconBasename: "test",
talented: false, advancementType: "Ability", trainer: false,
ranks: [{ rank: 1, spellId: 200, level: 1 }],
}],
sources: [sourceRow()],
diagnostics: [],
abilities: [{
key: "alias-map", classId: "DemonHunter", name: "Alias fixture",
spellIds: [200], nodeIds: [], rankSpellIds: [201], live: null, evidence: "DBC class alias maps to CoA ID 14.",
}],
};
}
function writeJson(root, relative, value) {
const full = path.join(root, relative);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, JSON.stringify(value) + "\n");
}
function makeProjectRoot() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-reference-audit-"));
writeJson(root, "src/game/coaLiveData.generated.json", {
spells: [
{ id: 200, dbc: { ...spell(200).dbc, castTimeMs: 1500 } },
{ id: 201, dbc: spell(201).dbc },
{ id: 203, dbc: spell(203).dbc },
],
});
writeJson(root, "src/game/generated/coaAbilityRuntimeCatalog.json", {
schemaVersion: 1,
abilitiesByClass: {
barbarian: [{ id: "coa-tree-100", classId: "barbarian", talentEntryId: 100, dbcSpellId: 200 }],
},
resourcesByClass: { barbarian: { primary: "energy" } },
triggeredAbilitiesBySpellId: { "201": { id: "coa-trigger-201", classId: "barbarian", dbcSpellId: 201 } },
});
writeJson(root, "src/game/generated/talentRuntimeCatalog.json", {
schemaVersion: 1,
trees: [{ id: "coa-barbarian-1", classId: "barbarian" }],
nodes: [{ id: "coa-entry-100", index: 100, treeId: "coa-barbarian-1", system: "coa" }],
});
writeJson(root, "src/game/generated/coaTriggeredSpellCatalog.json", {
schemaVersion: 1,
spells: [spell(201).dbc],
});
return root;
}
test("validation resolves DBC class aliases and flags unresolved references as incomplete warnings", () => {
const reference = referenceFixture();
reference.nodes[0].requiredIds = [999];
reference.nodes[0].rankDescriptions[0].spellId = 999;
reference.spells[1].dbc.effects[0].triggerSpellId = 998;
const result = validateReference(reference);
assert.equal(result.valid, true);
assert.equal(result.complete, false);
assert.equal(result.counts.missingRankSpells, 1);
assert.equal(result.counts.missingPrerequisites, 1);
assert.equal(result.counts.missingTriggeredSpells, 1);
assert(result.warnings.some((item) => item.code === "missing-rank-spell" && item.spellId === 999));
assert(result.warnings.some((item) => item.code === "missing-prerequisite" && item.targetId === 999));
assert(result.warnings.some((item) => item.code === "unknown-triggered-spell" && item.spellId === 998));
assert(!result.errors.some((item) => item.code === "invalid-ability-class-id"));
});
test("validation reports malformed and duplicate keys, cross-class edges, and invalid choice groups as errors", () => {
const reference = referenceFixture();
reference.classes[1].tabs = [{ tabId: 2, tabName: "Second", sortOrder: 2 }];
reference.nodes[0].requiredIds = [300];
reference.nodes.push(node(300, { classId: 13, tabId: 2, group: 7 }));
reference.nodes.push(node(100));
reference.nodes[1].tabId = 2;
reference.nodes[1].requiredIds = [0];
reference.sources.push(sourceRow());
const result = validateReference(reference);
assert.equal(result.valid, false);
assert(result.errors.some((item) => item.code === "duplicate-node-key"));
assert(result.errors.some((item) => item.code === "cross-class-node-reference" && item.targetId === 300));
assert(result.errors.some((item) => item.code === "choice-group-cross-tab"));
assert(result.errors.some((item) => item.code === "invalid-node-reference-id"));
assert(result.errors.some((item) => item.code === "duplicate-source-key"));
});
test("audit compares July and August without merging, checks DBC fields, hashes inputs, and avoids implementation claims", () => {
const projectRoot = makeProjectRoot();
try {
const reference = referenceFixture();
reference.spells.push({ id: 202, name: "Placeholder", rank: "", description: "", source: "skill-line", classIds: [12] });
reference.sources[0].path = "src/game/coaLiveData.generated.json";
const supplements = {
source: { revision: "5078f9ac22541c45c17a866c23aac6c12073de23", capturedAt: "2026-08-12T21:02:51+00:00" },
classes: [{
classId: 12,
tabs: [{
tabId: 1, tabName: "First", sortOrder: 1, maxReqTabAE: 4, maxReqTabTE: 5,
nodes: [
{ ...node(100), name: "Changed name", reqTabAE: 0, reqTabTE: 1, isPassive: 0, isStartingNode: 0, spellId: 200, spellIds: [200], iconPath: "Interface/Icons/TEST.blp", rankDescriptions: [{ rank: 1, spellId: 200, description: "<span>A test mechanic.</span>" }] },
{ ...node(102), spellId: 202, spellIds: [202] },
],
}],
}],
manifest: { files: [{ path: "src/game/coaLiveData.generated.json", sha256: "b".repeat(64) }] },
metadata: { provenance: { source: "archive" }, clientSnapshot: { capturedAt: "2026-08-01T00:00:00Z" }, spells: { revision: "spell-set-1" } },
};
const report = auditReference({ reference, supplements, projectRoot });
const julyAugust = report.comparisons.julyToAugust;
assert.deepEqual(julyAugust.counts, {
treesAdded: 0, treesRemoved: 0, treesChanged: 0, emptyMetadataTreesAdded: 0,
nodesAdded: 1, nodesRemoved: 1, nodesChanged: 1,
});
assert.deepEqual(julyAugust.changes.nodes[0].changedFields, ["name"]);
assert.equal(report.counts.nodes, 2);
assert.equal(report.classes.find((item) => item.classId === 12).counts.nodes, 2);
assert.equal(report.classes.find((item) => item.classId === 12).counts.spells, 3);
assert.equal(report.runtimeEvidence.classes.find((item) => item.classId === 12).runtimeAbilityMappings, 1);
assert.equal(report.runtimeEvidence.classes.find((item) => item.classId === 12).talentRuntimeNodes, 1);
assert.equal(report.comparisons.baseDbcToArea52.counts.baseRecords, 2);
assert.equal(report.comparisons.baseDbcToArea52.counts.baseMissingDbcRecords, 1);
assert.equal(report.comparisons.baseDbcToArea52.counts.area52Records, 3);
assert.equal(report.comparisons.baseDbcToArea52.counts.area52Only, 1);
assert.equal(report.comparisons.baseDbcToArea52.counts.changedRecords, 1);
assert.deepEqual(report.comparisons.baseDbcToArea52.changedFields, { castTimeMs: 1, effects: 1 });
assert.deepEqual(report.rankIdentityCoverage.nodeRanks, { missingSpellRecords: 0, missingBaseDbcRows: 0 });
assert.equal(report.supplementalFreshness.sourceHashes.changed, 1);
assert.equal(report.runtimeEvidence.behavioralMechanics.status, "not-assessed");
assert.deepEqual(report.runtimeEvidence.behavioralMechanics.provenImplementedClaims, []);
assert(report.classes.every((item) => item.backlog.some((backlog) => backlog.priority === "player-companion-mechanics")));
const markdown = renderAuditMarkdown(report);
assert(markdown.includes("1 reference spell records do not include a base DBC row"));
assert(markdown.includes("player or companion"));
assert(markdown.includes("No August supplemental cache") === false);
assert(markdown.indexOf("Changed spell samples:\n\n-") >= 0);
assert(markdown.indexOf("July/August tree and node samples:\n\n-") >= 0);
assert(!markdown.includes("implemented: yes"));
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
});
test("missing supplements are explicitly reported and preserve baseline data", () => {
const projectRoot = makeProjectRoot();
try {
const reference = referenceFixture();
const report = auditReference({ reference, supplements: null, projectRoot });
assert.equal(report.comparisons.julyToAugust.status, "absent");
assert.equal(report.supplementalCache.status, "absent");
assert.deepEqual(report.comparisons.julyToAugust.counts, {
treesAdded: 0, treesRemoved: 0, treesChanged: 0, emptyMetadataTreesAdded: 0, nodesAdded: 0, nodesRemoved: 0, nodesChanged: 0,
});
assert.equal(renderAuditMarkdown(report).includes("No supplemental cache was supplied"), true);
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
});
+28
View File
@@ -0,0 +1,28 @@
import fs from "node:fs";
import path from "node:path";
import { gzipSync, gunzipSync } from "node:zlib";
export const julyBaselinePath = "data/coa-reference/baselines/coa-july-2026-07-25.json.gz";
export const activeCatalogPath = "src/game/coaLiveData.generated.json";
export function resolveBaselinePath(projectRoot) {
return fs.existsSync(path.join(projectRoot, julyBaselinePath)) ? julyBaselinePath : activeCatalogPath;
}
export function decodeJsonBytes(bytes, filePath) {
return filePath.endsWith(".gz") ? gunzipSync(bytes) : bytes;
}
/** Preserve the original capture once; never replace it with a promoted catalog. */
export function freezeJulyBaseline(projectRoot) {
const destination = path.join(projectRoot, julyBaselinePath);
if (fs.existsSync(destination)) return destination;
const bytes = fs.readFileSync(path.join(projectRoot, activeCatalogPath));
const capture = JSON.parse(bytes.toString("utf8"));
if (capture.source?.synchronizedAt !== "2026-07-25T05:24:40.596Z") {
throw new Error("Cannot freeze July baseline: active catalog is not the original July capture");
}
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.writeFileSync(destination, gzipSync(bytes, { level: 9 }), { flag: "wx" });
return destination;
}
+41
View File
@@ -0,0 +1,41 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { activeCatalogPath, decodeJsonBytes, freezeJulyBaseline, julyBaselinePath, resolveBaselinePath } from "./baseline.mjs";
test("frozen July capture remains unchanged after active promotion", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-baseline-"));
try {
const active = path.join(root, activeCatalogPath);
fs.mkdirSync(path.dirname(active), { recursive: true });
const original = Buffer.from(JSON.stringify({ source: { synchronizedAt: "2026-07-25T05:24:40.596Z" }, entries: [{ id: 1 }] }));
fs.writeFileSync(active, original);
assert.equal(resolveBaselinePath(root), activeCatalogPath);
const frozen = freezeJulyBaseline(root);
const bytes = fs.readFileSync(frozen);
assert.deepEqual(decodeJsonBytes(bytes, frozen), original);
fs.writeFileSync(active, '{"source":{"synchronizedAt":"2026-08-12"}}');
assert.equal(resolveBaselinePath(root), julyBaselinePath);
assert.equal(freezeJulyBaseline(root), frozen);
assert.deepEqual(fs.readFileSync(frozen), bytes);
} finally {
assert.ok(root.startsWith(`${path.resolve(os.tmpdir())}${path.sep}`));
fs.rmSync(root, { recursive: true });
}
});
test("freezing rejects a later capture rather than mislabelling it July", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-baseline-"));
try {
const active = path.join(root, activeCatalogPath);
fs.mkdirSync(path.dirname(active), { recursive: true });
fs.writeFileSync(active, '{"source":{"synchronizedAt":"2026-08-12"}}');
assert.throws(() => freezeJulyBaseline(root), /not the original July/);
assert.equal(fs.existsSync(path.join(root, julyBaselinePath)), false);
} finally {
assert.ok(root.startsWith(`${path.resolve(os.tmpdir())}${path.sep}`));
fs.rmSync(root, { recursive: true });
}
});
+150
View File
@@ -0,0 +1,150 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { buildLocalReference, resolveSourceRoots } from "./local-source.mjs";
import { auditReference, renderAuditMarkdown, validateReference } from "./audit.mjs";
import { downloadSupplements, readSupplements } from "./supplements.mjs";
import { lookupReference, publishSnapshot, readSnapshot } from "./snapshot.mjs";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const usage = `CoA offline reference
build Build and atomically publish a local-first reference and audit
fetch Explicitly cache the pinned August archive talent supplement
fetch-spells Cache missing global spell records and their linked payloads
audit Verify the current snapshot and repeat offline coverage checks
lookup Search the current snapshot by --class, --tree, --spell, --ability
inventory Inspect the resolved local source pair without changing files
promote Promote the verified August Vol'Jin cache into the active CoA catalog
Options:
--client-root PATH Existing Ascension client directory
--extracted-root PATH Existing LadiksMPQEditor directory (supply both roots)
--output-root PATH Default: data/coa-reference
--cache-root PATH Default: data/coa-reference/cache
--limit NUMBER Lookup results per collection, default 20
--refresh Fetch command: revalidate the pinned supplement
--help Show this help
Build, audit, lookup, inventory and promote never make network requests.
Only promote writes the active gameplay catalog. Original client files stay intact.
`;
export function parseArguments(args) {
const hasCommand = Boolean(args[0] && !args[0].startsWith("--"));
const result = { command: hasCommand ? args[0] : "help" };
const valueFlags = new Set(["client-root", "extracted-root", "output-root", "cache-root", "class", "tree", "spell", "ability", "limit"]);
for (let index = hasCommand ? 1 : 0; index < args.length; index += 1) {
const flag = args[index];
if (flag === "--help") result.command = "help";
else if (flag === "--refresh") result.refresh = true;
else if (flag.startsWith("--") && valueFlags.has(flag.slice(2))) {
if (!args[index + 1] || args[index + 1].startsWith("--")) throw new Error(`Missing value for ${flag}`);
result[flag.slice(2)] = args[++index];
} else throw new Error(`Unknown argument: ${flag}`);
}
if (!["help", "build", "fetch", "fetch-spells", "audit", "lookup", "inventory", "promote"].includes(result.command)) {
throw new Error(`Unknown command: ${result.command}`);
}
if (Boolean(result["client-root"]) !== Boolean(result["extracted-root"])) {
throw new Error("Supply both --client-root and --extracted-root to avoid mixing source installations");
}
if (result.refresh && result.command !== "fetch") throw new Error("--refresh is only supported by fetch");
return result;
}
/** Return unresolved spell roots owned by CoA plus explicit August rank references. */
export function unresolvedCoaArchiveSpellIds(reference, extraNodes = []) {
const spellById = new Map((reference?.spells ?? []).map((spell) => [Number(spell.id), spell]));
const required = new Set();
const add = (value) => {
const id = Number(value);
if (Number.isSafeInteger(id) && id > 0) required.add(id);
};
for (const node of extraNodes) {
for (const rank of node?.rankDescriptions ?? []) add(rank?.spellId);
}
for (const chain of reference?.progressionChains ?? []) {
for (const rank of chain?.ranks ?? []) add(rank?.spellId);
}
for (const spell of reference?.spells ?? []) {
if ((spell?.classIds ?? []).some((classId) => Number(classId) > 11)) add(spell.id);
}
return [...required]
.filter((id) => !spellById.get(id)?.dbc)
.sort((a, b) => a - b);
}
export async function main(args = process.argv.slice(2)) {
const options = parseArguments(args);
if (options.command === "help") {
process.stdout.write(usage);
return;
}
const outputRoot = path.resolve(options["output-root"] ?? path.join(projectRoot, "data/coa-reference"));
const cacheRoot = path.resolve(options["cache-root"] ?? path.join(outputRoot, "cache"));
const sourceOptions = {
projectRoot, clientRoot: options["client-root"], extractedRoot: options["extracted-root"],
};
if (options.command === "inventory") {
console.log(JSON.stringify(resolveSourceRoots(sourceOptions), null, 2));
return;
}
if (options.command === "fetch") {
console.log(JSON.stringify(await downloadSupplements({ cacheRoot, refresh: options.refresh ?? false }), null, 2));
return;
}
if (options.command === "promote") {
const { promoteCachedCoa } = await import("./promotion-command.mjs");
console.log(JSON.stringify(await promoteCachedCoa({ ...sourceOptions, cacheRoot }), null, 2));
return;
}
if (options.command === "fetch-spells") {
const { downloadArchiveSpells } = await import("./archive-spells.mjs");
const { normalizeArchiveNode } = await import("./promote.mjs");
const supplements = await readSupplements({ cacheRoot });
if (!supplements) throw new Error("Verified talent cache is required; run coa:reference:fetch first");
const extraNodes = supplements.classes.flatMap((definition) => definition.nodes.map(normalizeArchiveNode));
const reference = buildLocalReference({ ...sourceOptions, extraNodes });
const result = await downloadArchiveSpells({ cacheRoot, spellIds: unresolvedCoaArchiveSpellIds(reference, extraNodes) });
console.log(JSON.stringify({ source: result.source, records: result.records.length, files: result.files.length, missingIds: result.missingIds }, null, 2));
return;
}
if (options.command === "build") {
const supplements = await readSupplements({ cacheRoot });
const reference = await buildLocalReference(sourceOptions);
reference.supplementSource = supplements?.source ?? null;
const report = auditReference({ reference, supplements, projectRoot });
const validation = report.validation ?? validateReference(reference);
if (validation.errors?.length) {
throw new Error(`Reference validation failed; previous snapshot retained:\n${validation.errors.map((item) => item.message).join("\n")}`);
}
const result = publishSnapshot({ outputRoot, reference, report, markdown: renderAuditMarkdown(report) });
console.log(JSON.stringify({
snapshotId: result.snapshotId, report: path.join(result.directory, "audit.md"),
classes: reference.classes.length, nodes: reference.nodes.length, spells: reference.spells.length,
sourceFiles: reference.sources.length, supplement: reference.supplementSource,
validationErrors: validation.errors?.length ?? 0, validationWarnings: validation.warnings?.length ?? 0,
}, null, 2));
return;
}
const snapshot = readSnapshot(outputRoot);
if (options.command === "audit") {
const validation = validateReference(snapshot.reference);
console.log(JSON.stringify({
snapshotId: snapshot.manifest.snapshotId, report: path.join(snapshot.directory, "audit.md"),
sourceFiles: snapshot.reference.sources.length,
errors: validation.errors ?? [], warningCount: validation.warnings?.length ?? 0,
warningSample: (validation.warnings ?? []).slice(0, 10),
}, null, 2));
if (validation.errors?.length) throw new Error("Reference coverage validation failed");
return;
}
console.log(JSON.stringify(lookupReference(snapshot.reference, options), null, 2));
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main().catch((error) => {
console.error(`CoA reference: ${error.message}`);
process.exitCode = 1;
});
}
+58
View File
@@ -0,0 +1,58 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { spawnSync } from "node:child_process";
import { parseArguments, unresolvedCoaArchiveSpellIds } from "./cli.mjs";
import { publishSnapshot } from "./snapshot.mjs";
test("CLI rejects incomplete source pairs, implicit refresh and unknown options", () => {
assert.throws(() => parseArguments(["build", "--client-root", "client"]), /Supply both/);
assert.throws(() => parseArguments(["build", "--refresh"]), /only supported by fetch/);
assert.throws(() => parseArguments(["build", "--typo"]), /Unknown argument/);
assert.throws(() => parseArguments(["lookup", "--class"]), /Missing value/);
assert.equal(parseArguments(["fetch", "--refresh"]).refresh, true);
assert.equal(parseArguments(["help"]).command, "help");
assert.equal(parseArguments(["--help"]).command, "help");
});
test("fetch-spells selects unresolved CoA-owned API roots and August dependencies", () => {
const reference = {
progressionChains: [{ classId: 27, ranks: [{ spellId: 300, rank: 1 }, { spellId: 301, rank: 2 }] }],
spells: [
{ id: 630868, classIds: [27], name: "July API root without global DBC" },
{ id: 630869, classIds: [27], name: "Owned trigger payload without global DBC" },
{ id: 100, classIds: [1], name: "Classic-only unresolved spell" },
{ id: 200, classIds: [27], dbc: { id: 200 }, name: "Already resolved CoA spell" },
{ id: 300, classIds: [27], dbc: { id: 300 }, name: "Resolved August rank" },
],
};
const extraNodes = [{ rankDescriptions: [{ spellId: 400, rank: 1 }, { spellId: 401, rank: 2 }] }];
assert.deepEqual(unresolvedCoaArchiveSpellIds(reference, extraNodes), [301, 400, 401, 630868, 630869]);
});
test("CLI lookup uses only the verified local snapshot when fetch is disabled", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-reference-cli-"));
try {
const reference = {
baseline: { kind: "local-july" }, sources: [],
classes: [{ classId: 12, gameId: "barbarian", className: "Barbarian", tabs: [{ tabId: 31, tabName: "Brutality" }] }],
nodes: [{ id: 1, classId: 12, tabId: 31, name: "Strike", rankDescriptions: [{ spellId: 100, rank: 1 }] }],
spells: [{ id: 100, name: "Strike" }], abilities: [],
};
publishSnapshot({ outputRoot: root, reference, report: {}, markdown: "# Audit" });
const blocker = path.join(root, "disable-network.mjs");
fs.writeFileSync(blocker, 'globalThis.fetch = () => { throw new Error("Network request during offline command"); };\n');
const result = spawnSync(process.execPath, ["--import", pathToFileURL(blocker).href, path.join(import.meta.dirname, "cli.mjs"),
"lookup", "--output-root", root, "--class", "barbarian", "--spell", "100"], { encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
assert.equal(JSON.parse(result.stdout).spells[0].id, 100);
assert.equal(JSON.parse(result.stdout).baseline.kind, "local-july");
} finally {
assert.ok(root.startsWith(`${path.resolve(os.tmpdir())}${path.sep}`));
fs.rmSync(root, { recursive: true });
}
});
+142
View File
@@ -0,0 +1,142 @@
const FLIGHT_PUSH_MARKER = "self.__next_f.push";
function fail(message, offset) {
const suffix = Number.isInteger(offset) ? ` at offset ${offset}` : "";
throw new Error(`Could not decode pinned builder Flight text${suffix}: ${message}`);
}
function parsePushArgument(html, openParen) {
let start = openParen + 1;
while (/\s/.test(html[start] ?? "")) start += 1;
if (html[start] !== "[") fail("push argument is not a JSON array", openParen);
let depth = 0;
let quoted = false;
let escaped = false;
for (let index = start; index < html.length; index += 1) {
const character = html[index];
if (quoted) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === '"') quoted = false;
continue;
}
if (character === '"') quoted = true;
else if (character === "[") depth += 1;
else if (character === "]") {
depth -= 1;
if (depth === 0) {
const json = html.slice(start, index + 1);
let value;
try {
value = JSON.parse(json);
} catch (error) {
fail(`push argument is invalid JSON (${error.message})`, openParen);
}
return { value, end: index + 1 };
}
}
}
fail("push argument array is unterminated", openParen);
}
function collectTextChunks(builderHtml) {
if (typeof builderHtml !== "string") throw new TypeError("builderHtml must be a string");
const chunks = [];
let cursor = 0;
while (true) {
const markerAt = builderHtml.indexOf(FLIGHT_PUSH_MARKER, cursor);
if (markerAt < 0) break;
const openParen = builderHtml.indexOf("(", markerAt + FLIGHT_PUSH_MARKER.length);
if (openParen < 0) fail("push call has no opening parenthesis", markerAt);
const parsed = parsePushArgument(builderHtml, openParen);
if (!Array.isArray(parsed.value)) fail("push argument did not decode to an array", markerAt);
if (Number(parsed.value[0]) === 1 && typeof parsed.value[1] === "string") chunks.push(parsed.value[1]);
cursor = parsed.end;
}
if (chunks.length === 0) fail("no type-1 text chunks were found");
return chunks;
}
function collectTextRecords(builderHtml) {
const text = collectTextChunks(builderHtml).join("");
const bytes = Buffer.from(text, "utf8");
const records = new Map();
let offset = 0;
while (offset < bytes.length) {
while (bytes[offset] === 0x0a || bytes[offset] === 0x0d) offset += 1;
if (offset >= bytes.length) break;
const comma = bytes.indexOf(0x2c, offset);
const newline = bytes.indexOf(0x0a, offset);
if (comma >= 0 && (newline < 0 || comma < newline)) {
const header = bytes.toString("ascii", offset, comma);
const match = header.match(/^([0-9a-f]+):T([0-9a-f]+)$/i);
if (match) {
const id = match[1].toLowerCase();
const byteLength = Number.parseInt(match[2], 16);
const contentStart = comma + 1;
const contentEnd = contentStart + byteLength;
if (!Number.isSafeInteger(byteLength) || contentEnd > bytes.length) {
fail(`text row ${id} declares ${match[2]} bytes but is truncated`, offset);
}
const value = bytes.toString("utf8", contentStart, contentEnd);
if (records.has(id) && records.get(id) !== value) fail(`text row ${id} is defined more than once with different contents`, offset);
records.set(id, value);
offset = contentEnd;
if (bytes[offset] === 0x0d) offset += 1;
if (bytes[offset] === 0x0a) offset += 1;
continue;
}
}
const lineEnd = bytes.indexOf(0x0a, offset);
if (lineEnd < 0) break;
offset = lineEnd + 1;
}
if (records.size === 0) fail("no length-prefixed text rows were found");
return records;
}
function resolveDescription(value, records, location) {
if (typeof value !== "string" || value.startsWith("$$")) return value;
const match = value.match(/^\$([0-9a-f]+)$/i);
if (!match) return value;
const record = records.get(match[1].toLowerCase());
if (record === undefined) throw new Error(`Pinned builder description reference ${value} at ${location} has no Flight text row`);
return record;
}
function resolveNode(node, records, location) {
if (!node || typeof node !== "object" || Array.isArray(node)) return node;
if (Object.hasOwn(node, "description")) node.description = resolveDescription(node.description, records, `${location}.description`);
if (Array.isArray(node.rankDescriptions)) {
node.rankDescriptions.forEach((rank, index) => {
if (rank && typeof rank === "object" && !Array.isArray(rank) && Object.hasOwn(rank, "description")) {
rank.description = resolveDescription(rank.description, records, `${location}.rankDescriptions[${index}].description`);
}
});
}
return node;
}
function resolveNodeCollections(owner, records, location) {
if (!owner || typeof owner !== "object" || Array.isArray(owner)) return;
for (const key of ["nodes", "entries", "talents"]) {
if (Array.isArray(owner[key])) {
owner[key].forEach((node, index) => resolveNode(node, records, `${location}.${key}[${index}]`));
}
}
if (Array.isArray(owner.tabs)) {
owner.tabs.forEach((tab, index) => resolveNodeCollections(tab, records, `${location}.tabs[${index}]`));
}
}
/** Resolve exact React Flight string references in archived CoA node descriptions. */
export function resolveArchiveDescriptions(classes, builderHtml) {
if (!Array.isArray(classes)) throw new TypeError("classes must be an array");
const records = collectTextRecords(builderHtml);
const resolved = structuredClone(classes);
resolved.forEach((classRecord, index) => resolveNodeCollections(classRecord, records, `classes[${index}]`));
return resolved;
}
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveArchiveDescriptions } from "./flight-text.mjs";
function textRow(id, text) {
return `${id}:T${Buffer.byteLength(text, "utf8").toString(16)},${text}`;
}
function flightPush(value) {
return `<script>self.__next_f.push(${JSON.stringify(value)});</script>`;
}
function builderHtml(...chunks) {
return [flightPush([0]), ...chunks.map((chunk) => flightPush([1, chunk]))].join("");
}
test("resolves exact Flight text references across split chunks using UTF-8 byte lengths", () => {
const firstText = "Mycelial ring: café 🌱\nsecond line";
const secondText = "Spirit stable — restored";
const stream = `${textRow("17f", firstText)}${textRow("180", secondText)}`;
const splitA = stream.slice(0, 8);
const splitB = stream.slice(8, 36);
const splitC = stream.slice(36);
const classes = [{
classId: 12,
untouched: { note: "unchanged" },
nodes: [
{
id: 7221,
description: "$17F",
rankDescriptions: [{ rank: 1, spellId: 1, description: "$180" }],
spellId: 1,
},
{
id: 7222,
description: "$$180",
rankDescriptions: [{ rank: 1, spellId: 2, description: "Literal $d placeholder" }],
},
],
}];
const resolved = resolveArchiveDescriptions(classes, builderHtml(splitA, splitB, splitC));
assert.notEqual(resolved, classes);
assert.notEqual(resolved[0], classes[0]);
assert.equal(resolved[0].nodes[0].description, firstText);
assert.equal(resolved[0].nodes[0].rankDescriptions[0].description, secondText);
assert.equal(resolved[0].nodes[0].spellId, 1);
assert.deepEqual(resolved[0].untouched, classes[0].untouched);
assert.equal(resolved[0].nodes[1].description, "$$180");
assert.equal(resolved[0].nodes[1].rankDescriptions[0].description, "Literal $d placeholder");
assert.equal(classes[0].nodes[0].description, "$17F");
assert.equal(classes[0].nodes[0].rankDescriptions[0].description, "$180");
});
test("resolves node descriptions inside nested tab collections and preserves escaped dollar text", () => {
const stream = textRow("abc", "\u003cstrong\u003eArchive text\u003c/strong\u003e");
const classes = [{
classId: 13,
tabs: [{ tabId: 1, nodes: [{ id: 1, description: "$abc", rankDescriptions: [] }] }],
}];
const resolved = resolveArchiveDescriptions(classes, builderHtml(stream));
assert.equal(resolved[0].tabs[0].nodes[0].description, "<strong>Archive text</strong>");
});
test("rejects missing exact references with their source field", () => {
const classes = [{ classId: 12, nodes: [{ id: 7221, description: "$dead", rankDescriptions: [] }] }];
assert.throws(
() => resolveArchiveDescriptions(classes, builderHtml(textRow("1", "one"))),
/description reference \$dead at classes\[0\]\.nodes\[0\]\.description has no Flight text row/,
);
});
test("rejects truncated UTF-8 text records and malformed Flight push JSON", () => {
const classes = [{ classId: 12, nodes: [] }];
assert.throws(() => resolveArchiveDescriptions(classes, builderHtml("1:T20,short")), /text row 1 declares 20 bytes but is truncated/);
assert.throws(() => resolveArchiveDescriptions(classes, '<script>self.__next_f.push([1,"unterminated]);</script>'), /push argument array is unterminated/);
});
File diff suppressed because it is too large. Load diff
+353
View File
@@ -0,0 +1,353 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { buildLocalReference, resolveSourceRoots } from "./local-source.mjs";
import { freezeJulyBaseline } from "./baseline.mjs";
function makeDbc(records, fieldCount) {
const stringParts = [Buffer.from([0])];
let stringSize = 1;
const rows = records.map((record) => {
const fields = Array.from({ length: fieldCount }, (_, index) => Number(record.fields?.[index] ?? 0));
for (const [fieldIndex, value] of Object.entries(record.strings ?? {})) {
const encoded = Buffer.from(`${value}\0`, "utf8");
fields[Number(fieldIndex)] = stringSize;
stringParts.push(encoded);
stringSize += encoded.length;
}
const row = Buffer.alloc(fieldCount * 4);
for (let index = 0; index < fieldCount; index += 1) {
const value = fields[index];
if (record.floatFields?.includes(index)) row.writeFloatLE(value, index * 4);
else row.writeUInt32LE(value >>> 0, index * 4);
}
return row;
});
const recordsBuffer = Buffer.concat(rows);
const stringsBuffer = Buffer.concat(stringParts);
const header = Buffer.alloc(20);
header.write("WDBC", 0, "ascii");
header.writeUInt32LE(records.length, 4);
header.writeUInt32LE(fieldCount, 8);
header.writeUInt32LE(fieldCount * 4, 12);
header.writeUInt32LE(stringsBuffer.length, 16);
return Buffer.concat([header, recordsBuffer, stringsBuffer]);
}
function spell(id, name, { rank = "Rank 1", description = `${name} description`, trigger = 0, castTimeIndex = 0, durationIndex = 0 } = {}) {
const fields = Array(234).fill(0);
fields[0] = id;
fields[28] = castTimeIndex;
fields[40] = durationIndex;
fields[71] = trigger ? 64 : 0;
fields[116] = trigger;
fields[205] = 133;
fields[206] = 1500;
fields[136] = 0;
fields[153] = 0;
fields[170] = 0;
return { fields, strings: { 136: name, 153: rank, 170: description } };
}
function archiveSpellRow(id, name, { triggerSpellId = 0, effectType = 0, basePoints = 0 } = {}) {
const row = {};
for (let index = 0; index < 234; index += 1) row[`f${index}`] = 0;
row.f0 = id;
row.f71 = triggerSpellId ? 64 : effectType;
row.f80 = basePoints;
row.f86 = effectType ? 6 : 0;
row.f116 = triggerSpellId;
row.f136 = name;
row.f136i = 1;
row.f153 = "Rank 1";
row.f153i = 2;
row.f170 = `August global description for ${id}`;
row.f170i = 3;
row.f187 = "";
row.f187i = 0;
return row;
}
function writeFile(root, relativePath, value) {
const target = path.join(root, ...relativePath.split("/"));
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, value);
return target;
}
function createFixture({ includeCastTimes = true } = {}) {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "coa-local-source-"));
const projectRoot = path.join(tempRoot, "HealerMan");
const clientRoot = path.join(tempRoot, "resources", "client");
const extractedRoot = path.join(tempRoot, "LadiksMPQEditor");
const dbcRoot = "client-current";
const classDefinitions = [
{ classId: 12, className: "Barbarian", tabs: [] },
{ classId: 14, className: "Felsworn", tabs: [] },
{ classId: 19, className: "Templar", tabs: [] },
{ classId: 20, className: "Bloodmage", tabs: [] },
];
const snapshot = {
source: { synchronizedAt: "2026-07-25T05:24:40.596Z" },
classes: classDefinitions,
entries: [{
id: 500,
classId: 14,
tabId: 1,
x: 0,
y: 0,
name: "Alias Ability",
rankDescriptions: [{ rank: 1, spellId: 1002, description: "Baseline rank description" }],
}],
progressionChains: [{
id: "coa-14-alias-1002",
classId: 14,
name: "Alias Ability",
ranks: [{ spellId: 1002, rank: 1, level: 10 }, { spellId: 1003, rank: 2, level: 20 }],
}],
};
writeFile(projectRoot, "src/game/coaLiveData.generated.json", `${JSON.stringify(snapshot)}\n`);
const advancementRows = [
{ ID: 500, Class: "DemonHunter", Name: "Alias Ability", Spells: [1002], Realms: "123" },
{ ID: 501, Class: "Monk", Name: "Templar Client Ability", Spells: [3001], Realms: "456" },
{ ID: 502, Class: "SonOfArugal", Name: "Bloodmage Client Ability", Spells: [3002], Realms: "789" },
{ ID: 503, Class: "Felsworn", Name: "Overlay Only", Spells: [4000], Realms: "101" },
];
writeFile(clientRoot, "Data/Content/CharacterAdvancementData.json", `${JSON.stringify(advancementRows)}\n`);
const spellRankRows = [
{ firstSpellId: 1002, spellId: 1002, rank: 1, level: 10 },
{ firstSpellId: 1002, spellId: 1003, rank: 2, level: 20 },
{ firstSpellId: 1002, spellId: 1004, rank: 2, level: 20 },
{ firstSpellId: 1002, spellId: 1005, rank: 2, level: 20 },
{ firstSpellId: 3001, spellId: 3001, rank: 1, level: 10 },
{ firstSpellId: 3002, spellId: 3002, rank: 1, level: 10 },
];
writeFile(clientRoot, "Data/Content/SpellRankData.json", `${JSON.stringify(spellRankRows)}\n`);
const clientClasses = [
[12, "Barbarian"], [14, "Felsworn"], [19, "Templar"], [20, "Bloodmage"],
].map(([id, name]) => ({ fields: [id], strings: { 4: name } }));
writeFile(extractedRoot, `${dbcRoot}/patch-M/DBFilesClient/ChrClasses.dbc`, makeDbc(clientClasses, 60));
const specs = clientClasses.map((row, index) => ({
fields: [index + 1],
strings: { 26: row.strings[4] },
}));
writeFile(extractedRoot, `${dbcRoot}/patch-M/DBFilesClient/ChrSpecs.dbc`, makeDbc(specs, 65));
const skillRows = [
[1002, 14], [1003, 14], [1004, 13], [1005, 14], [3001, 19], [3002, 20],
].map(([spellId, classId], index) => {
const fields = Array(14).fill(0);
fields[0] = index + 1;
fields[1] = 100 + classId;
fields[2] = spellId;
fields[5] = 1 << (classId - 1);
return { fields };
});
writeFile(extractedRoot, `${dbcRoot}/patch-M/DBFilesClient/SkillLineAbility.dbc`, makeDbc(skillRows, 14));
const baseSpells = [
spell(1002, "BASE Alias", { trigger: 2002, castTimeIndex: 1, durationIndex: 1 }),
spell(1003, "BASE Alias", { rank: "Rank 2", castTimeIndex: 2 }),
spell(1004, "Foreign Class Rank", { rank: "Rank 2" }),
spell(1005, "Second Same-Class Rank", { rank: "Rank 2" }),
spell(2002, "BASE Trigger", { rank: "" }),
spell(3001, "Templar Spell"),
spell(3002, "Bloodmage Spell"),
];
const baseSpellPath = writeFile(extractedRoot, `${dbcRoot}/patch-T/DBFilesClient/Spell.dbc`, makeDbc(baseSpells, 234));
const overlaySpells = [spell(1002, "OVERLAY Alias"), spell(4000, "OVERLAY Only")];
const overlaySpellPath = writeFile(extractedRoot, `${dbcRoot}/area-52/patch-D/DBFilesClient/Spell.dbc`, makeDbc(overlaySpells, 234));
if (includeCastTimes) {
writeFile(extractedRoot, `${dbcRoot}/patch-S/DBFilesClient/SpellCastTimes.dbc`, makeDbc([{ fields: [1, 125] }, { fields: [2, -1] }], 2));
}
writeFile(extractedRoot, `${dbcRoot}/patch-S/DBFilesClient/SpellDuration.dbc`, makeDbc([{ fields: [1, 0, 0, -1] }], 4));
writeFile(extractedRoot, `${dbcRoot}/patch-S/DBFilesClient/SpellRadius.dbc`, makeDbc([{ fields: [1, 0, 0, 3.5], floatFields: [3] }], 4));
writeFile(extractedRoot, `${dbcRoot}/patch-S/DBFilesClient/SpellRange.dbc`, makeDbc([{ fields: [1, 0, 0, 0, 40], floatFields: [1, 2, 3, 4] }], 5));
writeFile(clientRoot, "Data/area-52/listarchive", "patch-D.MPQ\n");
writeFile(extractedRoot, `${dbcRoot}/README.md`, "area-52/patch-D/DBFilesClient provides the realm override. Global patch-T Spell.dbc is retained as a fallback only.\n");
return {
tempRoot,
projectRoot,
clientRoot,
extractedRoot,
baseSpellPath,
overlaySpellPath,
options: { projectRoot, clientRoot, extractedRoot },
};
}
function cleanup(fixture) {
fs.rmSync(fixture.tempRoot, { recursive: true, force: true });
}
test("resolveSourceRoots keeps the client and extraction roots paired", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "coa-roots-"));
try {
const projectRoot = path.join(tempRoot, "HealerMan");
const siblingClient = path.join(tempRoot, "resources", "client");
const siblingExtracts = path.join(tempRoot, "LadiksMPQEditor");
fs.mkdirSync(projectRoot, { recursive: true });
fs.mkdirSync(siblingClient, { recursive: true });
fs.mkdirSync(siblingExtracts, { recursive: true });
const resolved = resolveSourceRoots({ projectRoot });
assert.equal(resolved.rootSet, "F");
assert.equal(resolved.clientRoot, siblingClient);
assert.equal(resolved.extractedRoot, siblingExtracts);
const supplied = resolveSourceRoots({ projectRoot, clientRoot: siblingClient, extractedRoot: siblingExtracts });
assert.equal(supplied.rootSet, "supplied");
assert.equal(supplied.clientRoot, siblingClient);
assert.equal(supplied.extractedRoot, siblingExtracts);
assert.throws(() => resolveSourceRoots({ projectRoot, clientRoot: siblingClient }), /together/);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
test("buildLocalReference keeps patch-T as BASE and closes node, CAD, and trigger spell references", () => {
const fixture = createFixture();
try {
const result = buildLocalReference(fixture.options);
const bySpellId = new Map(result.spells.map((entry) => [entry.id, entry]));
assert.equal(result.schemaVersion, 1);
assert.equal(result.baseline.capturedAt, "2026-07-25T05:24:40.596Z");
assert.equal(result.classes.find((definition) => definition.classId === 14).gameId, "felsworn");
assert.deepEqual(result.classes.find((definition) => definition.classId === 14).aliases, ["DemonHunter"]);
assert.equal(result.classes.find((definition) => definition.classId === 14).clientClassId, 14);
assert.equal(bySpellId.get(1002).name, "BASE Alias");
assert.equal(bySpellId.get(1002).dbc.castTimeMs, 125);
assert.equal(bySpellId.get(1002).dbc.durationMs, -1);
assert.equal(bySpellId.get(1003).dbc.castTimeMs, -1);
assert.equal(bySpellId.get(1002).dbc.startRecoveryTimeMs, 1500);
assert.equal(bySpellId.get(2002).name, "BASE Trigger");
assert.ok(bySpellId.get(2002).sourceEvidence.some((evidence) => evidence.kind === "dbc-effect-trigger" && evidence.sourceSpellId === 1002));
assert.equal(bySpellId.get(4000).dbc, undefined);
assert.ok(result.diagnostics.some((item) => item.code === "missing-spell-reference" && item.spellId === 4000 && item.overlayOnly));
assert.deepEqual(result.inventory.spellTables.base.recordCount, 7);
assert.equal(result.inventory.spellTables.base.sha256, crypto.createHash("sha256").update(fs.readFileSync(fixture.baseSpellPath)).digest("hex"));
assert.equal(result.inventory.spellTables.overlay.recordCount, 2);
assert.ok(result.diagnostics.some((item) => item.code === "source-order-conflict"));
assert.ok(result.sources.every((source) => !path.isAbsolute(source.path)));
const ability = result.abilities.find((entry) => entry.name === "Alias Ability");
assert.equal(ability.classId, 14);
assert.equal(ability.live, null);
assert.deepEqual(ability.nodeIds, [500]);
assert.deepEqual(ability.rankSpellIds, [1002, 1003, 1005]);
assert.ok(result.diagnostics.some((item) => item.code === "ambiguous-rank-join" && item.abilityKey === ability.key && item.rank === 2));
assert.ok(result.classCatalogs.find((catalog) => catalog.classId === 19).advancementRows.some((row) => row.Class === "Monk"));
assert.ok(result.classCatalogs.find((catalog) => catalog.classId === 20).advancementRows.some((row) => row.Class === "SonOfArugal"));
assert.notEqual(fixture.overlaySpellPath, fixture.baseSpellPath);
} finally {
cleanup(fixture);
}
});
test("malformed BASE Spell.dbc is rejected instead of falling back to the overlay", () => {
const fixture = createFixture();
try {
fs.writeFileSync(fixture.baseSpellPath, Buffer.from("not a dbc"));
assert.throws(() => buildLocalReference(fixture.options), /WDBC|truncated/);
} finally {
cleanup(fixture);
}
});
test("missing optional cast-time support leaves a nonzero cast index unresolved", () => {
const fixture = createFixture({ includeCastTimes: false });
try {
const result = buildLocalReference(fixture.options);
const spell = result.spells.find((entry) => entry.id === 1002);
assert.equal(spell.dbc.castTimeIndex, 1);
assert.equal(spell.dbc.castTimeMs, null);
assert.ok(result.diagnostics.some((item) => item.code === "missing-optional-support-dbc" && item.sourceId === "spell-cast-times"));
} finally {
cleanup(fixture);
}
});
test("August seeds resolve new ranks and effect-168 dependencies without replacing July nodes", () => {
const fixture = createFixture();
try {
freezeJulyBaseline(fixture.projectRoot);
writeFile(fixture.projectRoot, "src/game/coaLiveData.generated.json", '{"source":{"synchronizedAt":"2026-08-12"}}');
const newSpell = spell(9002, "August Spell");
newSpell.fields[71] = 168;
newSpell.fields[110] = 9003;
fs.writeFileSync(fixture.baseSpellPath, makeDbc([
...[1002, 1003, 1004, 1005, 2002, 3001, 3002].map((id) => spell(id, `Spell ${id}`)),
newSpell, spell(9003, "August Payload"),
], 234));
const result = buildLocalReference({ ...fixture.options, extraNodes: [
{ id: 901, classId: 14, rankDescriptions: [{ rank: 1, spellId: 9002 }] },
] });
assert.equal(result.baseline.capturedAt, "2026-07-25T05:24:40.596Z");
assert.deepEqual(result.nodes.map((node) => node.id), [500]);
assert.equal(result.spells.find((row) => row.id === 9002).dbc.name, "August Spell");
assert.equal(result.spells.find((row) => row.id === 9003).dbc.name, "August Payload");
assert.ok(result.spells.find((row) => row.id === 9002).sourceEvidence.some((row) => row.kind === "august-node-rank"));
} finally {
cleanup(fixture);
}
});
test("July CoA spell identity seeds archived BASE rows and transfers ownership to missing trigger payloads", () => {
const fixture = createFixture();
try {
const activePath = path.join(fixture.projectRoot, "src/game/coaLiveData.generated.json");
const july = JSON.parse(fs.readFileSync(activePath, "utf8"));
july.spells = [{
id: 630868,
classIds: [27],
name: "Old Rejuvenating Venom",
rank: "Rank 1",
description: "Old Area52 description",
dbc: { id: 630868, name: "Area52 Rejuvenating Venom", effects: [] },
}];
fs.writeFileSync(activePath, `${JSON.stringify(july)}\n`);
freezeJulyBaseline(fixture.projectRoot);
fs.writeFileSync(fixture.overlaySpellPath, makeDbc([spell(630868, "Area52 Rejuvenating Venom")], 234));
const withoutArchive = buildLocalReference(fixture.options);
const unresolved = withoutArchive.spells.find((row) => row.id === 630868);
assert.deepEqual(unresolved.classIds, [27]);
assert.equal(unresolved.dbc, undefined);
assert.notEqual(unresolved.name, "Old Rejuvenating Venom");
assert.ok(unresolved.sourceEvidence.some((item) => item.kind === "july-spell-identity" && item.classId === 27));
assert.ok(unresolved.sourceEvidence.some((item) => item.kind === "comparative-overlay-presence-only"));
const archiveSource = {
id: "august-global-spell-base",
path: "raw/tables/Spell/variants/data-patch-t-mpq",
sha256: "archive-index-fixture",
bytes: 123,
};
const archivedSpells = {
source: archiveSource,
fieldTypes: Array.from({ length: 234 }, (_, index) => (
[47, 77, 78, 79, 102, 103, 119, 120, 121, 216, 217, 218, 229, 230, 231].includes(index)
? "float"
: [136, 153, 170, 187].includes(index) ? "string" : "int"
)),
records: [
archiveSpellRow(630868, "August Rejuvenating Venom", { triggerSpellId: 630869 }),
archiveSpellRow(630869, "August Venom Payload", { effectType: 2, basePoints: 49 }),
],
files: [{ path: "00600000-00699999.jsonl.gz" }],
};
const withArchive = buildLocalReference({ ...fixture.options, archivedSpells });
const root = withArchive.spells.find((row) => row.id === 630868);
const payload = withArchive.spells.find((row) => row.id === 630869);
assert.equal(root.dbc.name, "August Rejuvenating Venom");
assert.deepEqual(root.classIds, [27]);
assert.ok(root.sourceEvidence.some((item) => item.kind === "base-spell-dbc-row" && item.sourceId === archiveSource.id));
assert.equal(payload.dbc.name, "August Venom Payload");
assert.deepEqual(payload.classIds, [27]);
assert.ok(payload.sourceEvidence.some((item) => item.kind === "trigger-payload-owner" && item.classId === 27));
assert.ok(!JSON.stringify(withArchive.spells).includes("Area52 Rejuvenating Venom"));
} finally {
cleanup(fixture);
}
});
+770
View File
@@ -0,0 +1,770 @@
import { isDeepStrictEqual } from "node:util";
export const COA_PROMOTION_ARCHIVE = Object.freeze({
repository: "https://github.com/srhinos/coa-datamine",
revision: "5078f9ac22541c45c17a866c23aac6c12073de23",
capturedAt: "2026-08-12T21:02:51+00:00",
realmSlug: "voljin",
realmName: "Vol'Jin",
coaBaseSpellPath: "client-current/patch-T/DBFilesClient/Spell.dbc",
classicSpellPath: "client-current/area-52/patch-D/DBFilesClient/Spell.dbc",
});
const CLASSIC_CLASS_MAX = 11;
const EXPECTED_COA_CLASS_IDS = Array.from({ length: 21 }, (_, index) => index + 12);
function assert(condition, message) {
if (!condition) throw new TypeError(message);
}
function numberOrZero(value) {
return Number(value) || 0;
}
function cleanHtml(value = "") {
return String(value)
.replace(/<br\s*\/?>/gi, " ")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#0*39;|&apos;/gi, "'")
.replace(/\s+/g, " ")
.trim();
}
function cleanName(value = "") {
return String(value).replace(/^@/, "").replace(/^6(?=[A-Z])/, "").trim();
}
function iconBasename(value = "") {
return String(value)
.replace(/^.*[\\/]/, "")
.replace(/\.(?:blp|tga)$/i, "")
.toLowerCase();
}
function numberList(value) {
return (Array.isArray(value) ? value : []).map(Number).filter(Boolean);
}
function compareEntries(left, right) {
return left.classId - right.classId
|| left.tabId - right.tabId
|| left.y - right.y
|| left.x - right.x
|| left.id - right.id;
}
function compareNumbers(left, right) {
return left - right;
}
function stable(value) {
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function changedFields(before, after) {
const keys = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])].sort();
return keys.filter((key) => stable(before?.[key]) !== stable(after?.[key]));
}
function changedValues(before, after, fields = changedFields(before, after)) {
return Object.fromEntries(fields.map((field) => {
const beforeValue = before?.[field];
const afterValue = after?.[field];
if (field === "dbc" && beforeValue && afterValue && typeof beforeValue === "object" && typeof afterValue === "object") {
const dbcFields = changedFields(beforeValue, afterValue);
return [field, Object.fromEntries(dbcFields.map((dbcField) => [dbcField, {
...(beforeValue[dbcField] === undefined ? {} : { before: clone(beforeValue[dbcField]) }),
...(afterValue[dbcField] === undefined ? {} : { after: clone(afterValue[dbcField]) }),
}]))];
}
return [field, {
...(beforeValue === undefined ? {} : { before: clone(beforeValue) }),
...(afterValue === undefined ? {} : { after: clone(afterValue) }),
}];
}));
}
function spellSummary(spell) {
return {
id: Number(spell.id),
classIds: clone(spell.classIds ?? []),
name: spell.name ?? "",
rank: spell.rank ?? "",
description: spell.description ?? "",
hasBaseDbc: Boolean(spell.dbc),
};
}
function clone(value) {
return structuredClone(value);
}
function classNameById(classes, label) {
const result = new Map();
for (const definition of classes) {
const classId = Number(definition?.classId);
assert(Number.isInteger(classId), `${label} contains a class without an integer classId`);
assert(!result.has(classId), `${label} contains duplicate classId ${classId}`);
result.set(classId, String(definition.className ?? definition.builderClassName ?? definition.class ?? ""));
}
return result;
}
function assertUniqueById(records, label) {
const seen = new Set();
for (const record of records) {
const id = Number(record?.id);
assert(Number.isSafeInteger(id) && id > 0, `${label} contains invalid id ${String(record?.id)}`);
assert(!seen.has(id), `${label} contains duplicate id ${id}`);
seen.add(id);
}
return seen;
}
function previousSpellWrapper(spell) {
const {
id, classIds = [], name = "", iconBasename: icon = "", level = 0, rank = "", description = "",
liveTooltip, sourceIds = [], advancementType = "", advancementTabId = 0, talented = false,
excluded = false, dbc,
} = spell;
return {
id: Number(id),
classIds: clone(classIds),
name,
iconBasename: icon,
level,
rank,
description,
...(liveTooltip ? { liveTooltip } : {}),
sourceIds: clone(sourceIds),
advancementType,
advancementTabId,
talented,
excluded,
...(dbc ? { dbc: clone(dbc) } : {}),
};
}
function referenceSpellWrapper({ id, referenceSpell, previousSpell, owners, nodeOwner, descriptionOverride, dbcSourceOnly = true }) {
const dbc = referenceSpell?.dbc;
const node = nodeOwner?.node;
const description = descriptionOverride
?? nodeOwner?.rankDescription?.description
?? node?.description
?? referenceSpell?.description
?? (dbcSourceOnly ? "" : previousSpell?.description)
?? dbc?.description
?? "";
const ownerClassIds = [...new Set([...(owners ?? [])])].filter((classId) => classId > CLASSIC_CLASS_MAX).sort(compareNumbers);
const icon = node?.iconBasename || previousSpell?.iconBasename || "";
const rankDescriptionRank = nodeOwner?.rankDescription?.rank;
return {
id,
classIds: ownerClassIds.length
? ownerClassIds
: clone(referenceSpell?.classIds ?? previousSpell?.classIds ?? []),
name: referenceSpell?.name || node?.name || previousSpell?.name || dbc?.name || "",
iconBasename: icon,
level: referenceSpell?.level ?? previousSpell?.level ?? dbc?.spellLevel ?? 0,
rank: rankDescriptionRank !== undefined
? `Rank ${rankDescriptionRank}`
: referenceSpell?.rank || previousSpell?.rank || dbc?.rankText || "",
description,
sourceIds: clone(previousSpell?.sourceIds ?? []),
advancementType: previousSpell?.advancementType ?? "",
advancementTabId: previousSpell?.advancementTabId ?? 0,
talented: previousSpell?.talented ?? false,
excluded: previousSpell?.excluded ?? false,
...(dbc ? { dbc: clone(dbc) } : {}),
};
}
function rankOwnerMap(entries) {
const owners = new Map();
for (const node of entries) {
for (const rankDescription of node.rankDescriptions) {
const spellId = rankDescription.spellId;
const existing = owners.get(spellId) ?? [];
existing.push({ classId: node.classId, node, rankDescription });
owners.set(spellId, existing);
}
}
for (const ownerList of owners.values()) {
ownerList.sort((left, right) => compareEntries(left.node, right.node)
|| left.rankDescription.rank - right.rankDescription.rank);
}
return owners;
}
function firstNodeOwner(ownerList) {
return ownerList?.[0] ?? null;
}
function mapOfSpells(spells, label) {
assert(Array.isArray(spells), `${label} must be an array`);
assertUniqueById(spells, label);
return new Map(spells.map((spell) => [Number(spell.id), spell]));
}
function chainSpellIds(chain) {
return (Array.isArray(chain?.ranks) ? chain.ranks : []).map((rank) => Number(rank.spellId));
}
function spellIdsForClassicChains(chains) {
const ids = new Set();
for (const chain of chains) {
if (Number(chain.classId) > CLASSIC_CLASS_MAX) continue;
for (const spellId of chainSpellIds(chain)) if (Number.isSafeInteger(spellId) && spellId > 0) ids.add(spellId);
}
return ids;
}
function classicOwnedSpellIds(spells, classicChainSpellIds) {
const ids = new Set(classicChainSpellIds);
for (const spell of spells) {
if ((spell.classIds ?? []).some((classId) => Number(classId) > 0 && Number(classId) <= CLASSIC_CLASS_MAX)) {
ids.add(Number(spell.id));
}
}
return ids;
}
function classicProtectionClosure({ previousSpells, classicChains, classicTriggeredSpells = [] }) {
const mainById = new Map(previousSpells.map((spell) => [Number(spell.id), spell]));
const triggeredById = new Map();
for (const spell of classicTriggeredSpells) {
const id = Number(spell?.id);
if (Number.isSafeInteger(id) && id > 0 && !triggeredById.has(id)) triggeredById.set(id, spell);
}
const seeds = classicOwnedSpellIds(previousSpells, spellIdsForClassicChains(classicChains));
const referencedIds = new Set(seeds);
const protectedIds = new Set([...seeds].filter((id) => mainById.has(id)));
const queue = [...seeds].sort(compareNumbers);
const visited = new Set();
for (let cursor = 0; cursor < queue.length; cursor += 1) {
const spellId = queue[cursor];
if (visited.has(spellId)) continue;
visited.add(spellId);
const rows = [mainById.get(spellId), triggeredById.get(spellId)].filter(Boolean);
const childIds = new Set();
for (const row of rows) {
const effects = row.dbc?.effects ?? row.effects ?? [];
for (const effect of effects) {
const triggerSpellId = Number(effect.triggerSpellId);
if (Number.isSafeInteger(triggerSpellId) && triggerSpellId > 0) childIds.add(triggerSpellId);
const miscValue = Number(effect.miscValue);
if (Number(effect.effectType) === 168 && Number.isSafeInteger(miscValue) && miscValue > 0) childIds.add(miscValue);
}
}
for (const childId of [...childIds].sort(compareNumbers)) {
referencedIds.add(childId);
if (mainById.has(childId)) protectedIds.add(childId);
if (!visited.has(childId)) queue.push(childId);
}
}
return { protectedIds, referencedIds };
}
function closureForCoaSpells({ entries, chains, referenceSpells }) {
const ownersBySpellId = new Map();
const addOwner = (spellId, classIds) => {
const id = Number(spellId);
if (!Number.isSafeInteger(id) || id <= 0) return false;
const owners = ownersBySpellId.get(id) ?? new Set();
const before = owners.size;
for (const classId of classIds) {
const numericClassId = Number(classId);
if (numericClassId > CLASSIC_CLASS_MAX) owners.add(numericClassId);
}
ownersBySpellId.set(id, owners);
return owners.size !== before;
};
for (const node of entries) {
for (const rank of node.rankDescriptions) addOwner(rank.spellId, [node.classId]);
}
for (const chain of chains) {
if (Number(chain.classId) <= CLASSIC_CLASS_MAX) continue;
for (const rank of chain.ranks ?? []) addOwner(rank.spellId, [chain.classId]);
}
for (const spell of referenceSpells) {
const classes = (spell.classIds ?? []).filter((classId) => Number(classId) > CLASSIC_CLASS_MAX);
if (classes.length) addOwner(spell.id, classes);
}
const byId = new Map(referenceSpells.map((spell) => [Number(spell.id), spell]));
const processedOwners = new Map();
const queue = [...ownersBySpellId.keys()].sort(compareNumbers);
for (let cursor = 0; cursor < queue.length; cursor += 1) {
const spellId = queue[cursor];
const owners = ownersBySpellId.get(spellId) ?? new Set();
const processed = processedOwners.get(spellId) ?? new Set();
const newOwners = [...owners].filter((classId) => !processed.has(classId));
if (newOwners.length === 0) continue;
for (const classId of newOwners) processed.add(classId);
processedOwners.set(spellId, processed);
const dbc = byId.get(spellId)?.dbc;
for (const effect of dbc?.effects ?? []) {
const childIds = [];
const triggerSpellId = Number(effect.triggerSpellId);
if (Number.isSafeInteger(triggerSpellId) && triggerSpellId > 0) childIds.push(triggerSpellId);
const miscValueId = Number(effect.miscValue);
if (Number(effect.effectType) === 168 && Number.isSafeInteger(miscValueId) && miscValueId > 0) childIds.push(miscValueId);
for (const childId of childIds) {
if (addOwner(childId, newOwners)) queue.push(childId);
}
}
}
return ownersBySpellId;
}
function preserveChecks({ previous, candidate, oldEntriesById, oldChainById, oldSpellById, protectedSpellIds, classicReachableSpellIds, coaOwnersBySpellId }) {
const classicChains = previous.progressionChains.filter((chain) => Number(chain.classId) <= CLASSIC_CLASS_MAX);
const candidateClassicChains = candidate.progressionChains.filter((chain) => Number(chain.classId) <= CLASSIC_CLASS_MAX);
const classicProtected = [...protectedSpellIds].sort(compareNumbers);
const classicSpellRecords = classicProtected.flatMap((id) => {
const before = oldSpellById.get(id);
if (!before) return [];
const after = candidate.spells.find((spell) => Number(spell.id) === id);
return [{ id, before, after: after ?? null }];
});
const missingOldNodeIds = [];
const crossClassRewires = [];
for (const [id, oldNode] of oldEntriesById) {
const nextNode = candidate.entries.find((node) => Number(node.id) === id);
if (!nextNode) missingOldNodeIds.push(id);
else if (Number(nextNode.classId) !== Number(oldNode.classId)) {
crossClassRewires.push({ id, beforeClassId: oldNode.classId, afterClassId: nextNode.classId });
}
}
const missingOldChainIds = [];
const changedCoaChainRanks = [];
const candidateChainMap = new Map(candidate.progressionChains.map((chain) => [chain.id, chain]));
for (const [id, oldChain] of oldChainById) {
const nextChain = candidateChainMap.get(id);
if (!nextChain) missingOldChainIds.push(id);
else if (!isDeepStrictEqual(chainSpellIds(oldChain), chainSpellIds(nextChain))) {
changedCoaChainRanks.push({ id, before: chainSpellIds(oldChain), after: chainSpellIds(nextChain) });
}
}
const classicRecordsUnchanged = classicSpellRecords.every(({ before, after }) => after && isDeepStrictEqual(before, after));
return {
classicProgressionChains: {
passed: isDeepStrictEqual(classicChains, candidateClassicChains),
count: classicChains.length,
},
classicProtectedSpellRecords: {
passed: classicRecordsUnchanged,
recordCount: classicSpellRecords.length,
protectedMainIdCount: classicProtected.length,
classicReachableIdCount: classicReachableSpellIds.size,
},
previousCoaNodeIds: {
passed: missingOldNodeIds.length === 0 && crossClassRewires.length === 0,
previousCount: oldEntriesById.size,
missingIds: missingOldNodeIds.sort(compareNumbers),
crossClassRewires: crossClassRewires.sort((left, right) => left.id - right.id),
},
previousProgressionChainIdentities: {
passed: missingOldChainIds.length === 0 && changedCoaChainRanks.length === 0,
previousCount: oldChainById.size,
missingIds: missingOldChainIds.sort(),
changedCoaRanks: changedCoaChainRanks.sort((left, right) => String(left.id).localeCompare(String(right.id))),
},
coaSpellOverrideCoverage: {
passed: candidate.coaSpellOverrides.every((spell) => (coaOwnersBySpellId.get(spell.id)?.size ?? 0) > 0),
overrideCount: candidate.coaSpellOverrides.length,
},
};
}
function validationError(message, report) {
const error = new Error(message);
error.name = "CoaPromotionValidationError";
error.report = report;
return error;
}
function makeSource(previous, reference, supplements) {
const prior = previous.source ?? {};
const baseSpellSource = reference.sources?.find((source) => source.id === "global-spell-base") ?? null;
return {
...clone(prior),
realmSlug: COA_PROMOTION_ARCHIVE.realmSlug,
realmName: COA_PROMOTION_ARCHIVE.realmName,
synchronizedAt: supplements.source.capturedAt,
classSpellRowsCapturedAt: prior.synchronizedAt,
spellDbcPath: COA_PROMOTION_ARCHIVE.coaBaseSpellPath,
supportDbcDirectory: "client-current/patch-S/DBFilesClient",
coaSpellDbcPath: COA_PROMOTION_ARCHIVE.coaBaseSpellPath,
classicSpellDbcPath: COA_PROMOTION_ARCHIVE.classicSpellPath,
coaSkillLineAbilityDbcPath: "client-current/patch-M/DBFilesClient/SkillLineAbility.dbc",
...(reference.archivedSpellSupplement ? { archivedSpellSupplement: clone(reference.archivedSpellSupplement) } : {}),
spellDbcSources: {
coaBase: {
patch: "T",
path: baseSpellSource?.path ?? `extracts/${COA_PROMOTION_ARCHIVE.coaBaseSpellPath}`,
sourceId: baseSpellSource?.id ?? "global-spell-base",
},
classic: {
patch: "area-52",
path: prior.spellDbcPath?.includes("area-52")
? prior.spellDbcPath
: COA_PROMOTION_ARCHIVE.classicSpellPath,
sourceId: "classic-area52-preserved",
},
},
promotion: {
archiveRepository: supplements.source.repository,
archiveRevision: supplements.source.revision,
archiveCapturedAt: supplements.source.capturedAt,
builderSnapshotPath: supplements.source.builderSnapshotPath,
builderSnapshotSha256: supplements.source.builderSnapshotSha256,
realmSlug: COA_PROMOTION_ARCHIVE.realmSlug,
coaSpellSource: "client-current/patch-T/DBFilesClient/Spell.dbc",
classicSpellSource: "client-current/area-52/patch-D/DBFilesClient/Spell.dbc",
},
};
}
function makeCounts(candidate, previous) {
const classSpells = candidate.spells.flatMap((spell) => spell.classIds ?? []);
return {
classes: candidate.classes.length,
tabs: candidate.classes.reduce((total, definition) => total + definition.tabs.length, 0),
entries: candidate.entries.length,
abilityEntries: candidate.entries.filter((entry) => entry.entryType === "Ability").length,
talentEntries: candidate.entries.filter((entry) => entry.entryType === "Talent").length,
rankedNodeSpells: candidate.entries.reduce((total, entry) => total + entry.rankDescriptions.length, 0),
classSpellRows: previous.counts?.classSpellRows ?? classSpells.length,
classicClassSpellRows: previous.counts?.classicClassSpellRows ?? classSpells.filter((classId) => classId <= CLASSIC_CLASS_MAX).length,
coaClassSpellRows: previous.counts?.coaClassSpellRows ?? classSpells.filter((classId) => classId > CLASSIC_CLASS_MAX).length,
spellClassMemberships: classSpells.length,
uniqueSpells: candidate.spells.length,
dbcMatchedSpells: candidate.spells.filter((spell) => spell.dbc).length,
progressionChains: candidate.progressionChains.length,
classicProgressionChains: candidate.progressionChains.filter((chain) => Number(chain.classId) <= CLASSIC_CLASS_MAX).length,
classicBaseProgressionChains: candidate.progressionChains.filter((chain) => Number(chain.classId) <= CLASSIC_CLASS_MAX && !chain.talented).length,
coaProgressionChains: candidate.progressionChains.filter((chain) => Number(chain.classId) > CLASSIC_CLASS_MAX).length,
progressionRanks: candidate.progressionChains.reduce((total, chain) => total + (chain.ranks?.length ?? 0), 0),
liveTooltips: candidate.spells.filter((spell) => spell.liveTooltip).length,
coaSpellOverrides: candidate.coaSpellOverrides.length,
};
}
/** Convert one pinned archive tree node to the active CoA entry shape used by the old sync. */
export function normalizeArchiveNode(raw) {
assert(raw && typeof raw === "object" && !Array.isArray(raw), "Archive node must be an object");
const id = Number(raw.id);
const classId = Number(raw.classId);
const tabId = Number(raw.tabId);
assert(Number.isSafeInteger(id) && id > 0, `Archive node has invalid id ${String(raw.id)}`);
assert(Number.isInteger(classId) && EXPECTED_COA_CLASS_IDS.includes(classId), `Archive node ${id} has noncanonical classId ${String(raw.classId)}`);
assert(Number.isInteger(tabId) && tabId > 0, `Archive node ${id} has invalid tabId ${String(raw.tabId)}`);
const x = Number(raw.x ?? raw.column);
const y = Number(raw.y ?? raw.row);
assert(Number.isFinite(x) && Number.isFinite(y), `Archive node ${id} has invalid coordinates`);
const rankDescriptions = (Array.isArray(raw.rankDescriptions) ? raw.rankDescriptions : []).map((rank, index) => {
const rankNumber = Number(rank?.rank);
const spellId = Number(rank?.spellId);
assert(Number.isInteger(rankNumber) && rankNumber > 0, `Archive node ${id} has invalid rank number at index ${index}`);
assert(Number.isSafeInteger(spellId) && spellId > 0, `Archive node ${id} has invalid rank spellId at index ${index}`);
return {
rank: rankNumber,
spellId,
description: cleanHtml(rank?.description),
};
});
const spellIdCount = Array.isArray(raw.spellIds) ? raw.spellIds.length : rankDescriptions.length;
return {
id,
classId,
tabId,
x,
y,
name: cleanName(raw.name),
iconBasename: iconBasename(raw.iconPath ?? raw.icon),
nodeType: String(raw.nodeType ?? ""),
entryType: String(raw.entryType ?? ""),
isPassive: Boolean(raw.isPassive),
maxPoints: numberOrZero(raw.maxPoints ?? raw.maxRank) || Math.max(1, spellIdCount),
aeCost: numberOrZero(raw.aeCost ?? raw.abilityEssenceCost),
teCost: numberOrZero(raw.teCost ?? raw.talentEssenceCost),
requiredClassPoints: numberOrZero(raw.reqTabAE ?? raw.requiredClassPoints),
requiredSpecPoints: numberOrZero(raw.reqTabTE ?? raw.requiredSpecPoints),
requiredIds: numberList(raw.requiredIds ?? raw.prerequisites),
connectedNodeIds: numberList(raw.connectedNodeIds ?? raw.connections),
requiredLevel: numberOrZero(raw.requiredLevel),
isStartingNode: Boolean(raw.isStartingNode),
flags: numberOrZero(raw.flags),
group: numberOrZero(raw.group),
sortOrder: numberOrZero(raw.sortOrder),
description: cleanHtml(raw.description),
rankDescriptions,
};
}
/** Build an in-memory, validated CoA promotion candidate and an exact machine-readable diff. */
export function buildCoaPromotion({ previous, reference, supplements }) {
assert(previous && typeof previous === "object", "previous CoA data is required");
assert(reference && typeof reference === "object", "local CoA reference is required");
assert(supplements && typeof supplements === "object", "pinned CoA supplements are required");
assert(Array.isArray(previous.classes), "previous CoA data has no classes array");
assert(Array.isArray(previous.entries), "previous CoA data has no entries array");
assert(Array.isArray(previous.progressionChains), "previous CoA data has no progressionChains array");
assert(Array.isArray(previous.spells), "previous CoA data has no spells array");
assert(Array.isArray(reference.classes), "local reference has no classes array");
assert(Array.isArray(reference.nodes), "local reference has no nodes array");
assert(Array.isArray(reference.progressionChains), "local reference has no progressionChains array");
assert(Array.isArray(reference.spells), "local reference has no spells array");
assert(Array.isArray(reference.sources), "local reference has no sources array");
assert(Array.isArray(supplements.classes), "pinned supplements have no classes array");
assert(supplements.source?.revision === COA_PROMOTION_ARCHIVE.revision, "pinned supplement revision does not match the approved August archive");
assert(supplements.source?.capturedAt === COA_PROMOTION_ARCHIVE.capturedAt, "pinned supplement capture time does not match the approved August archive");
const canonicalNames = classNameById(previous.classes, "previous classes");
const previousClassIds = [...canonicalNames.keys()].sort(compareNumbers);
assert(isDeepStrictEqual(previousClassIds, EXPECTED_COA_CLASS_IDS), "previous CoA classes must contain canonical IDs 12 through 32");
const referenceNames = classNameById(reference.classes, "reference classes");
const archiveNames = classNameById(supplements.classes, "supplement classes");
for (const classId of EXPECTED_COA_CLASS_IDS) {
const canonicalName = canonicalNames.get(classId);
assert(canonicalName, `previous CoA class ${classId} has no canonical className`);
assert(referenceNames.get(classId) === canonicalName, `reference class ${classId} does not match canonical name ${canonicalName}`);
const archiveClass = supplements.classes.find((definition) => Number(definition.classId) === classId);
assert(archiveClass, `pinned archive is missing CoA class ${classId}`);
assert(archiveNames.get(classId) === canonicalName, `pinned archive class ${classId} does not match canonical name ${canonicalName}`);
assert((archiveClass.builderClassName ?? archiveClass.class) === canonicalName, `pinned builder class name for ${classId} does not match ${canonicalName}`);
}
assert(supplements.classes.length === EXPECTED_COA_CLASS_IDS.length, "pinned archive must contain exactly 21 CoA classes");
const entries = supplements.classes.flatMap((definition) => definition.nodes.map(normalizeArchiveNode)).sort(compareEntries);
assert(entries.length > 0, "pinned archive has no CoA nodes");
assertUniqueById(entries, "pinned archive nodes");
const entryIds = new Set(entries.map((entry) => entry.id));
const priorEntryIds = assertUniqueById(previous.entries, "previous CoA entries");
const oldEntriesById = new Map(previous.entries.map((entry) => [Number(entry.id), entry]));
const oldIdsMissing = [...priorEntryIds].filter((id) => !entryIds.has(id)).sort(compareNumbers);
const crossClassRewires = previous.entries.flatMap((oldNode) => {
const newNode = entries.find((node) => node.id === Number(oldNode.id));
return newNode && newNode.classId !== Number(oldNode.classId)
? [{ id: Number(oldNode.id), beforeClassId: Number(oldNode.classId), afterClassId: newNode.classId }]
: [];
});
assert(oldIdsMissing.length === 0, `August archive removes July node IDs: ${oldIdsMissing.join(", ")}`);
assert(crossClassRewires.length === 0, `August archive rewires node IDs across classes: ${crossClassRewires.map((item) => item.id).join(", ")}`);
const classes = previous.classes.map((definition) => {
const classId = Number(definition.classId);
const archiveClass = supplements.classes.find((item) => Number(item.classId) === classId);
const classNodes = entries.filter((node) => node.classId === classId);
const nodeTabs = new Set(classNodes.map((node) => node.tabId));
const tabs = (archiveClass.tabs ?? [])
.filter((tab) => tab?.isEmpty !== true && Number(tab?.entryCount ?? 1) > 0 && nodeTabs.has(Number(tab.tabId)))
.map((tab) => ({
tabId: Number(tab.tabId),
tabName: String(tab.tabName ?? tab.name ?? ""),
sortOrder: numberOrZero(tab.sortOrder),
}))
.sort((left, right) => left.sortOrder - right.sortOrder || left.tabId - right.tabId);
assert(tabs.length > 0, `pinned archive class ${classId} has no nonempty tabs`);
const tabIds = new Set(tabs.map((tab) => tab.tabId));
assert(classNodes.every((node) => tabIds.has(node.tabId)), `pinned archive class ${classId} contains a node outside a nonempty tab`);
return { ...clone(definition), tabs };
});
const priorChainIds = new Set(previous.progressionChains.map((chain) => String(chain.id)));
assert(priorChainIds.size === previous.progressionChains.length, "previous progression chains contain duplicate IDs");
const oldChainById = new Map(previous.progressionChains.map((chain) => [String(chain.id), chain]));
const progressionChains = clone(previous.progressionChains);
const referenceSpellById = mapOfSpells(reference.spells, "reference spells");
const oldSpellById = mapOfSpells(previous.spells, "previous spells");
const classicProtection = classicProtectionClosure({
previousSpells: previous.spells,
classicChains: previous.progressionChains,
classicTriggeredSpells: previous.classicTriggeredSpells ?? [],
});
const protectedSpellIds = classicProtection.protectedIds;
const rankOwners = rankOwnerMap(entries);
const coaOwnersBySpellId = closureForCoaSpells({
entries,
chains: progressionChains,
referenceSpells: reference.spells,
});
const allSpellIds = new Set([
...oldSpellById.keys(),
...referenceSpellById.keys(),
...rankOwners.keys(),
...[...coaOwnersBySpellId.keys()],
...progressionChains.flatMap((chain) => chainSpellIds(chain)),
]);
const sortedSpellIds = [...allSpellIds].sort(compareNumbers);
const spells = [];
const coaSpellOverrides = [];
for (const id of sortedSpellIds) {
const previousSpell = oldSpellById.get(id);
const referenceSpell = referenceSpellById.get(id);
const ownerList = rankOwners.get(id) ?? [];
const nodeOwner = firstNodeOwner(ownerList);
const coaOwners = [...(coaOwnersBySpellId.get(id) ?? [])].sort(compareNumbers);
if (protectedSpellIds.has(id) && previousSpell) {
spells.push(clone(previousSpell));
} else if (referenceSpell || previousSpell || nodeOwner) {
spells.push(referenceSpellWrapper({ id, referenceSpell, previousSpell, owners: coaOwners, nodeOwner }));
}
if (protectedSpellIds.has(id) && coaOwners.length > 0) {
coaSpellOverrides.push(referenceSpellWrapper({
id,
referenceSpell,
previousSpell,
owners: coaOwners,
nodeOwner,
}));
}
}
coaSpellOverrides.sort((left, right) => left.id - right.id);
const candidate = {
source: makeSource(previous, reference, supplements),
counts: {},
classes,
entries,
progressionChains,
spells,
...(coaSpellOverrides.length ? { coaSpellOverrides } : {}),
};
candidate.coaSpellOverrides ??= [];
candidate.counts = makeCounts(candidate, previous);
const coaChainSpellIds = new Set(progressionChains
.filter((chain) => Number(chain.classId) > CLASSIC_CLASS_MAX)
.flatMap((chain) => chainSpellIds(chain)));
const coaRequiredIds = new Set([...coaOwnersBySpellId.keys(), ...coaChainSpellIds]);
const missingBaseSpellIds = [...coaRequiredIds]
.filter((id) => !referenceSpellById.get(id)?.dbc)
.sort(compareNumbers);
const missingBaseProgressionRankIds = [...coaChainSpellIds]
.filter((id) => !referenceSpellById.get(id)?.dbc)
.sort(compareNumbers);
const previousSpellIds = new Set(oldSpellById.keys());
const candidateSpellIds = new Set(spells.map((spell) => Number(spell.id)));
const addedSpellIds = [...candidateSpellIds].filter((id) => !previousSpellIds.has(id)).sort(compareNumbers);
const removedSpellIds = [...previousSpellIds].filter((id) => !candidateSpellIds.has(id)).sort(compareNumbers);
const changedSpells = [];
for (const spell of spells) {
const before = oldSpellById.get(Number(spell.id));
if (before && !isDeepStrictEqual(before, spell)) {
const fields = changedFields(before, spell);
changedSpells.push({ id: Number(spell.id), changedFields: fields, changes: changedValues(before, spell, fields) });
}
}
const oldEntryById = oldEntriesById;
const newEntryById = new Map(entries.map((entry) => [entry.id, entry]));
const addedNodeIds = [...newEntryById.keys()].filter((id) => !oldEntryById.has(id)).sort(compareNumbers);
const removedNodeIds = [...oldEntryById.keys()].filter((id) => !newEntryById.has(id)).sort(compareNumbers);
const changedNodes = [];
for (const node of entries) {
const before = oldEntryById.get(node.id);
if (before && !isDeepStrictEqual(before, node)) {
changedNodes.push({ id: node.id, classId: node.classId, changedFields: changedFields(before, node), before: clone(before), after: clone(node) });
}
}
const overrideChanges = coaSpellOverrides.map((after) => {
const before = oldSpellById.get(after.id) ?? null;
const fields = before ? changedFields(before, after) : Object.keys(after).sort();
return {
id: after.id,
protectedClassicRecord: Boolean(before),
classIds: clone(after.classIds),
description: after.description,
hasBaseDbc: Boolean(after.dbc),
changedFields: fields,
changes: before ? changedValues(before, after, fields) : undefined,
};
});
const report = {
schemaVersion: 1,
status: "validated",
sources: {
previous: clone(previous.source ?? null),
reference: {
baseSpellDbc: clone(reference.sources.find((source) => source.id === "global-spell-base") ?? null),
classSources: clone(reference.sources.filter((source) => /class|skill-line/i.test(`${source.id} ${source.role}`))),
},
archive: clone(supplements.source),
},
preserveChecks: {},
nodeChanges: {
previousCount: previous.entries.length,
promotedCount: entries.length,
pinnedArchiveExpectedCount: 3618,
pinnedArchiveCountMatches: entries.length === 3618,
added: addedNodeIds.map((id) => clone(newEntryById.get(id))),
removed: removedNodeIds.map((id) => clone(oldEntryById.get(id))),
changed: changedNodes,
unexpectedRemovals: removedNodeIds,
crossClassRewires,
},
spellChanges: {
previousCount: previous.spells.length,
promotedCount: spells.length,
added: addedSpellIds.map((id) => spellSummary(spells.find((spell) => spell.id === id))),
removed: removedSpellIds.map((id) => spellSummary(oldSpellById.get(id))),
changed: changedSpells,
coaOverrides: overrideChanges,
protectedClassicIdCount: protectedSpellIds.size,
coaContextSpellCount: coaOwnersBySpellId.size,
sharedProtectedIds: overrideChanges.map((item) => item.id),
},
gaps: {
missingBaseSpellIds,
missingBaseSpellCount: missingBaseSpellIds.length,
missingBaseProgressionRankIds,
missingBaseProgressionRankCount: missingBaseProgressionRankIds.length,
},
provenance: {
realmSlug: COA_PROMOTION_ARCHIVE.realmSlug,
archiveRevision: supplements.source.revision,
archiveCapturedAt: supplements.source.capturedAt,
coaBaseSpellPath: COA_PROMOTION_ARCHIVE.coaBaseSpellPath,
classicSpellPath: candidate.source.spellDbcSources.classic.path,
coaSkillLineAbilityDbcPath: candidate.source.coaSkillLineAbilityDbcPath,
fixedCaptureTime: true,
generationTimestampAdded: false,
},
};
report.preserveChecks = preserveChecks({
previous,
candidate,
oldEntriesById,
oldChainById,
oldSpellById,
protectedSpellIds,
classicReachableSpellIds: classicProtection.referencedIds,
coaOwnersBySpellId,
});
const failedChecks = Object.entries(report.preserveChecks).filter(([, result]) => !result.passed).map(([name]) => name);
if (failedChecks.length) {
report.status = "blocked";
throw validationError(`CoA promotion failed preservation checks: ${failedChecks.join(", ")}`, report);
}
const invalidDbc = candidate.spells.filter((spell) => spell.dbc && Number(spell.dbc.id) !== Number(spell.id));
const invalidOverrideDbc = candidate.coaSpellOverrides.filter((spell) => spell.dbc && Number(spell.dbc.id) !== Number(spell.id));
if (invalidDbc.length || invalidOverrideDbc.length) {
report.status = "blocked";
throw validationError(`CoA promotion contains mismatched BASE DBC rows for IDs: ${[...invalidDbc, ...invalidOverrideDbc].map((spell) => spell.id).join(", ")}`, report);
}
return { candidate, report };
}
+292
View File
@@ -0,0 +1,292 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildCoaPromotion, COA_PROMOTION_ARCHIVE, normalizeArchiveNode } from "./promote.mjs";
const classNames = [
"Barbarian", "Witch Doctor", "Felsworn", "Witch Hunter", "Stormbringer", "Knight of Xoroth", "Guardian",
"Templar", "Bloodmage", "Ranger", "Chronomancer", "Necromancer", "Pyromancer", "Cultist", "Starcaller",
"Sun Cleric", "Tinker", "Venomancer", "Reaper", "Primalist", "Runemaster",
];
function archiveNode({ classId, id, spellId, description = "Archive description", tabId = 30 + classId }) {
return {
id,
classId,
tabId,
x: 1,
y: 2,
name: `Class ${classId} node`,
iconPath: "Interface\\Icons\\TEST_ICON.blp",
nodeType: "SpendSquare",
entryType: "Talent",
isPassive: 0,
maxPoints: 1,
aeCost: 0,
teCost: 1,
reqTabAE: 0,
reqTabTE: 0,
requiredIds: [],
connectedNodeIds: [],
requiredLevel: 0,
isStartingNode: 0,
flags: 0,
group: 0,
sortOrder: 0,
description,
rankDescriptions: [{ rank: 1, spellId, description }],
};
}
function dbc(id, { description = `Base description ${id}`, effects = [] } = {}) {
return { id, name: `Base spell ${id}`, rankText: "Rank 1", description, effects };
}
function spell(id, classIds, { description = `Classic description ${id}`, effects = [], liveTooltip } = {}) {
return {
id,
classIds,
name: `Spell ${id}`,
iconBasename: "classic_icon",
level: 1,
rank: "Rank 1",
description,
...(liveTooltip ? { liveTooltip } : {}),
sourceIds: [],
advancementType: "",
advancementTabId: 0,
talented: false,
excluded: false,
dbc: { ...dbc(id), marker: "Area52 overlay row" },
};
}
function fixtures({ missingCoaRanks = [] } = {}) {
const previousClasses = classNames.map((className, index) => ({
classId: index + 12,
className,
maxTalentEssence: 25,
maxAbilityEssence: 26,
tabs: [{ tabId: 30 + index + 12, tabName: "July tab", sortOrder: 1 }],
}));
const archiveClasses = previousClasses.map((definition, index) => {
const classId = definition.classId;
const spellId = classId === 12 ? 100 : 200 + classId;
const node = archiveNode({
classId,
id: 12000 + classId,
spellId,
description: classId === 12 ? "<b>August &amp; improved</b><br>rank" : `August ${classId}`,
});
return {
classId,
class: definition.className,
builderClassName: definition.className,
essence: { maxTalentEssence: 99, maxAbilityEssence: 98 },
tabs: [
{ tabId: node.tabId, tabName: "August tab", sortOrder: 1, entryCount: 1, isEmpty: false },
{ tabId: 900 + classId, tabName: "Empty tab", sortOrder: 2, entryCount: 0, isEmpty: true },
],
nodes: [node],
};
});
const archiveEntries = archiveClasses.flatMap((definition) => definition.nodes.map(normalizeArchiveNode));
const julyEntries = structuredClone(archiveEntries);
julyEntries.find((node) => node.classId === 12).description = "July-only description";
julyEntries.find((node) => node.classId === 12).rankDescriptions[0].description = "July rank description";
const classicChain = {
id: "classic-preserved-chain",
classId: 1,
name: "Classic chain",
iconBasename: "classic",
talented: false,
advancementType: "",
trainer: true,
ranks: [{ spellId: 100, rank: 1, level: 1 }],
};
const coaChain = {
id: "coa-stable-chain",
classId: 12,
name: "Stable CoA chain",
iconBasename: "coa",
talented: false,
advancementType: "",
trainer: false,
ranks: [
{ spellId: 91300, rank: 1, level: 1 },
...missingCoaRanks.map((spellId, index) => ({ spellId, rank: index + 2, level: index + 2 })),
],
};
const sharedSpell = spell(100, [1, 12], { liveTooltip: "stale Area52 tooltip" });
const classicTriggered = spell(101, [5], { liveTooltip: "classic tooltip" });
const classicPayload = spell(102, [5]);
const classicClosureSeed = spell(500, [2]);
const classicEmptyOwnerChild = spell(501, []);
const classicEmptyOwnerPayload = spell(502, []);
const staleCoa = spell(999, [12], { liveTooltip: "stale CoA tooltip" });
const previous = {
source: { realmSlug: "voljin", spellDbcPath: "C:/client/area-52/patch-D/DBFilesClient/Spell.dbc" },
counts: {},
classes: previousClasses,
entries: julyEntries,
progressionChains: [classicChain, coaChain],
spells: [sharedSpell, classicTriggered, classicPayload, classicClosureSeed, classicEmptyOwnerChild, classicEmptyOwnerPayload, staleCoa],
classicTriggeredSpells: [
{ id: 500, effects: [{ effectType: 64, triggerSpellId: 501, miscValue: 0 }] },
{ id: 501, effects: [{ effectType: 168, triggerSpellId: 0, miscValue: 502 }] },
],
};
const allArchiveRanks = archiveClasses.flatMap((definition) => definition.nodes.flatMap((node) => node.rankDescriptions));
const referenceSpellMap = new Map();
for (const rank of allArchiveRanks) {
if (missingCoaRanks.includes(rank.spellId)) continue;
let effects = [];
if (rank.spellId === 100) {
effects = [
{ effectType: 64, triggerSpellId: 101, miscValue: 0 },
{ effectType: 168, triggerSpellId: 0, miscValue: 102 },
];
}
referenceSpellMap.set(rank.spellId, {
id: rank.spellId,
classIds: rank.spellId === 100 ? [1, 12] : [12],
name: `Base spell ${rank.spellId}`,
rank: "Rank 1",
level: 1,
description: `July reference description ${rank.spellId}`,
dbc: dbc(rank.spellId, { effects }),
});
}
for (const classicSpell of [sharedSpell, classicTriggered, classicPayload, classicClosureSeed, classicEmptyOwnerChild, classicEmptyOwnerPayload]) {
referenceSpellMap.set(classicSpell.id, {
id: classicSpell.id,
classIds: classicSpell.classIds,
name: `BASE ${classicSpell.id}`,
rank: "Rank 1",
level: 1,
description: `BASE ${classicSpell.id} description`,
dbc: dbc(classicSpell.id, {
effects: classicSpell.id === 100
? [
{ effectType: 64, triggerSpellId: 101, miscValue: 0 },
{ effectType: 168, triggerSpellId: 0, miscValue: 102 },
]
: [],
}),
});
}
const supplements = {
source: {
...COA_PROMOTION_ARCHIVE,
capturedAt: COA_PROMOTION_ARCHIVE.capturedAt,
},
classes: archiveClasses,
};
const reference = {
classes: previousClasses,
nodes: [],
progressionChains: [classicChain, coaChain],
spells: [...referenceSpellMap.values()].sort((left, right) => left.id - right.id),
sources: [{ id: "global-spell-base", path: "extracts/client-current/patch-T/DBFilesClient/Spell.dbc", role: "base" }],
};
return { previous, reference, supplements, archiveEntries, classicChain, coaChain };
}
test("normalizeArchiveNode matches the old sync entry shape and cleans archive text", () => {
const normalized = normalizeArchiveNode({
...archiveNode({ classId: 12, id: 1, spellId: 2, description: "<span>One</span><br><br>Two &amp; three" }),
name: "@6New Name",
iconPath: "Interface\\Icons\\TEST_ICON.TGA",
isPassive: 127,
isStartingNode: 0,
reqTabAE: undefined,
reqTabTE: undefined,
requiredClassPoints: 5,
requiredSpecPoints: 6,
});
assert.deepEqual(normalized, {
id: 1,
classId: 12,
tabId: 42,
x: 1,
y: 2,
name: "New Name",
iconBasename: "test_icon",
nodeType: "SpendSquare",
entryType: "Talent",
isPassive: true,
maxPoints: 1,
aeCost: 0,
teCost: 1,
requiredClassPoints: 5,
requiredSpecPoints: 6,
requiredIds: [],
connectedNodeIds: [],
requiredLevel: 0,
isStartingNode: false,
flags: 0,
group: 0,
sortOrder: 0,
description: "One Two & three",
rankDescriptions: [{ rank: 1, spellId: 2, description: "One Two & three" }],
});
});
test("promotion retains classic data and old node and chain identities while using August nodes and BASE spells", () => {
const { previous, reference, supplements, archiveEntries, classicChain, coaChain } = fixtures();
const { candidate, report } = buildCoaPromotion({ previous, reference, supplements });
const oldClassicSpells = previous.spells.filter((item) => item.classIds.some((classId) => classId <= 11));
assert.deepEqual(candidate.progressionChains[0], classicChain);
assert.deepEqual(candidate.spells.filter((item) => oldClassicSpells.some((old) => old.id === item.id)), oldClassicSpells);
for (const id of [500, 501, 502]) {
assert.deepEqual(candidate.spells.find((item) => item.id === id), previous.spells.find((item) => item.id === id));
}
assert.deepEqual(candidate.entries, archiveEntries);
assert.deepEqual(candidate.progressionChains[1], coaChain);
assert.equal(candidate.classes[0].tabs.length, 1);
assert.equal(candidate.classes[0].maxTalentEssence, 25);
assert.equal(candidate.entries[0].description, "August & improved rank");
assert.equal(candidate.spells.find((item) => item.id === 100).description, "Classic description 100");
assert.equal(candidate.spells.find((item) => item.id === 100).dbc.marker, "Area52 overlay row");
const overrideById = new Map(candidate.coaSpellOverrides.map((item) => [item.id, item]));
for (const id of [100, 101, 102]) {
assert.equal(overrideById.has(id), true, `expected CoA-context override for linked spell ${id}`);
assert.equal(overrideById.get(id).dbc.id, id);
assert.equal(overrideById.get(id).dbc.marker, undefined);
assert.deepEqual(overrideById.get(id).classIds, [12]);
assert.equal("liveTooltip" in overrideById.get(id), false);
}
assert.equal(overrideById.get(100).description, "August & improved rank");
assert.equal(candidate.source.realmSlug, "voljin");
assert.equal(candidate.source.spellDbcSources.coaBase.patch, "T");
assert.match(candidate.source.promotion.classicSpellSource, /area-52\/patch-D/);
assert.equal(candidate.source.promotion.archiveCapturedAt, "2026-08-12T21:02:51+00:00");
assert.equal(report.preserveChecks.classicProgressionChains.passed, true);
assert.equal(report.preserveChecks.classicProtectedSpellRecords.passed, true);
assert.equal(report.nodeChanges.unexpectedRemovals.length, 0);
assert.equal(report.gaps.missingBaseProgressionRankCount, 1);
assert.equal(report.gaps.missingBaseProgressionRankIds.includes(91300), true);
assert.equal(candidate.spells.find((item) => item.id === 999).dbc, undefined);
assert.equal("liveTooltip" in candidate.spells.find((item) => item.id === 999), false);
});
test("promotion reports all missing BASE progression ranks without replacing chain IDs", () => {
const missingCoaRanks = Array.from({ length: 13 }, (_, index) => 91400 + index);
const { previous, reference, supplements, coaChain } = fixtures({ missingCoaRanks });
const { candidate, report } = buildCoaPromotion({ previous, reference, supplements });
assert.deepEqual(candidate.progressionChains[1], coaChain);
assert.equal(report.gaps.missingBaseProgressionRankCount, 14);
assert.deepEqual(report.gaps.missingBaseProgressionRankIds, [91300, ...missingCoaRanks]);
assert.equal(candidate.spells.some((item) => missingCoaRanks.includes(item.id)), false);
});
test("promotion blocks July node removal and cross-class rewiring", () => {
const missing = fixtures();
missing.supplements.classes.find((item) => item.classId === 12).nodes = [];
assert.throws(() => buildCoaPromotion(missing), /August archive removes July node IDs/);
const rewired = fixtures();
const moved = rewired.supplements.classes.find((item) => item.classId === 12).nodes[0];
rewired.supplements.classes.find((item) => item.classId === 12).nodes = [];
rewired.supplements.classes.find((item) => item.classId === 13).nodes.push({ ...moved, classId: 13, tabId: 43 });
assert.throws(() => buildCoaPromotion(rewired), /August archive rewires node IDs across classes/);
});
@@ -0,0 +1,54 @@
import fs from "node:fs";
import path from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { activeCatalogPath, decodeJsonBytes, freezeJulyBaseline } from "./baseline.mjs";
import { buildLocalReference } from "./local-source.mjs";
import { buildCoaPromotion, normalizeArchiveNode } from "./promote.mjs";
import { readSupplements } from "./supplements.mjs";
import { validateReference } from "./audit.mjs";
import { stableJson } from "./snapshot.mjs";
import { readArchiveSpells } from "./archive-spells.mjs";
/** Explicit offline promotion, distinct from the historical reference build. */
export async function promoteCachedCoa({ projectRoot, cacheRoot, ...sourceOptions }) {
const supplements = await readSupplements({ cacheRoot });
if (!supplements) throw new Error("Verified August cache is required; run coa:reference:fetch once");
const baselinePath = freezeJulyBaseline(projectRoot);
const previous = JSON.parse(decodeJsonBytes(fs.readFileSync(baselinePath), baselinePath).toString("utf8"));
const triggered = JSON.parse(fs.readFileSync(path.join(projectRoot, "src/game/generated/coaTriggeredSpellCatalog.json"), "utf8"));
previous.classicTriggeredSpells = triggered.classicSpells ?? triggered.spells;
const extraNodes = supplements.classes.flatMap((definition) => definition.nodes.map(normalizeArchiveNode));
const archivedSpells = await readArchiveSpells({ cacheRoot });
const reference = buildLocalReference({ projectRoot, ...sourceOptions, extraNodes, archivedSpells });
const { candidate, report } = buildCoaPromotion({ previous, reference, supplements });
const spellById = new Map([...candidate.spells, ...(candidate.coaSpellOverrides ?? [])].map((spell) => [spell.id, spell]));
const validation = validateReference({
...reference,
classes: reference.classes.map((definition) => ({ ...definition, tabs: candidate.classes.find((item) => item.classId === definition.classId).tabs })),
nodes: candidate.entries,
spells: [...spellById.values()],
});
if (validation.errors.length) throw new Error(`Promotion validation failed; active catalog retained: ${validation.errors.map((item) => item.message).join("; ")}`);
for (const entry of candidate.entries) {
for (const rank of entry.rankDescriptions) {
if (!spellById.get(rank.spellId)?.dbc) throw new Error(`August node ${entry.id} rank ${rank.rank} lacks global Spell.dbc evidence; active catalog retained`);
}
}
const candidateBytes = `${JSON.stringify(candidate, null, 2)}\n`;
report.validation = validation;
report.candidateSha256 = createHash("sha256").update(candidateBytes).digest("hex");
const reportPath = path.join(projectRoot, "data/coa-reference/promotion-report.json");
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, stableJson(report));
const activePath = path.join(projectRoot, activeCatalogPath);
const temporaryPath = `${activePath}.${randomUUID()}.tmp`;
try {
fs.writeFileSync(temporaryPath, candidateBytes, { flag: "wx" });
if (createHash("sha256").update(fs.readFileSync(temporaryPath)).digest("hex") !== report.candidateSha256) throw new Error("Staged promotion checksum mismatch");
fs.renameSync(temporaryPath, activePath);
} finally {
if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);
}
return { reportPath, candidateSha256: report.candidateSha256, counts: candidate.counts, validationWarnings: validation.warnings.length,
gaps: { missingBaseSpellCount: report.gaps.missingBaseSpellCount, missingBaseProgressionRankIds: report.gaps.missingBaseProgressionRankIds } };
}
+156
View File
@@ -0,0 +1,156 @@
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
export const digest = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
export function stableJson(value) {
const sort = (item) => {
if (Array.isArray(item)) return item.map(sort);
if (item && typeof item === "object") {
return Object.fromEntries(Object.keys(item).sort().map((key) => [key, sort(item[key])]));
}
return item;
};
return `${JSON.stringify(sort(value))}\n`;
}
function safeChild(root, relative) {
const resolvedRoot = path.resolve(root);
const resolved = path.resolve(root, relative);
if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) throw new Error(`Unsafe reference path: ${relative}`);
return resolved;
}
function verifyFiles(directory, manifest) {
if (manifest.schemaVersion !== 1 || !/^[a-f0-9]{64}$/.test(manifest.snapshotId)) {
throw new Error("Invalid CoA snapshot manifest");
}
for (const name of ["reference.json", "audit.json", "audit.md"]) {
const file = safeChild(directory, name);
const expected = manifest.files[name];
if (!expected || !fs.existsSync(file)) throw new Error(`Missing reference file: ${name}`);
const bytes = fs.readFileSync(file);
if (bytes.length !== expected.bytes || digest(bytes) !== expected.sha256) {
throw new Error(`Corrupt reference file: ${name}; regenerate from verified sources using a fresh --output-root`);
}
}
const expectedId = digest(stableJson(manifest.files));
if (expectedId !== manifest.snapshotId) throw new Error("Snapshot manifest identity mismatch");
}
export function readSnapshot(outputRoot) {
const pointerPath = path.join(outputRoot, "current.json");
if (!fs.existsSync(pointerPath)) throw new Error("No CoA reference snapshot; run coa:reference:build first");
const pointer = JSON.parse(fs.readFileSync(pointerPath, "utf8"));
if (!/^[a-f0-9]{64}$/.test(pointer.snapshotId)) throw new Error("Invalid reference snapshot pointer");
const directory = safeChild(outputRoot, `snapshots/${pointer.snapshotId}`);
const manifest = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf8"));
if (pointer.snapshotId !== manifest.snapshotId) throw new Error("Reference pointer and manifest disagree");
verifyFiles(directory, manifest);
return {
directory,
manifest,
reference: JSON.parse(fs.readFileSync(path.join(directory, "reference.json"), "utf8")),
report: JSON.parse(fs.readFileSync(path.join(directory, "audit.json"), "utf8")),
};
}
/** Publish immutable files before changing the current pointer; failures keep the old snapshot usable. */
export function publishSnapshot({ outputRoot, reference, report, markdown }) {
const files = {
"reference.json": stableJson(reference),
"audit.json": stableJson(report),
"audit.md": `${markdown.trimEnd()}\n`,
};
const fileManifest = Object.fromEntries(Object.entries(files).map(([name, bytes]) => [name, {
sha256: digest(bytes), bytes: Buffer.byteLength(bytes),
}]));
const snapshotId = digest(stableJson(fileManifest));
const manifest = { schemaVersion: 1, snapshotId, files: fileManifest };
const snapshotsRoot = path.join(outputRoot, "snapshots");
fs.mkdirSync(snapshotsRoot, { recursive: true });
const directory = safeChild(snapshotsRoot, snapshotId);
if (fs.existsSync(directory)) {
const previous = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf8"));
verifyFiles(directory, previous);
if (stableJson(previous) !== stableJson(manifest)) throw new Error("Existing snapshot identity collision");
} else {
const staging = fs.mkdtempSync(path.join(snapshotsRoot, ".staging-"));
try {
for (const [name, bytes] of Object.entries(files)) fs.writeFileSync(path.join(staging, name), bytes);
fs.writeFileSync(path.join(staging, "manifest.json"), stableJson(manifest));
verifyFiles(staging, manifest);
fs.renameSync(staging, directory);
} finally {
if (fs.existsSync(staging)) {
safeChild(snapshotsRoot, path.relative(snapshotsRoot, staging));
fs.rmSync(staging, { recursive: true });
}
}
}
const pointer = path.join(outputRoot, "current.json");
const temporary = path.join(outputRoot, `.current-${crypto.randomUUID()}.tmp`);
try {
fs.writeFileSync(temporary, stableJson({ schemaVersion: 1, snapshotId }));
fs.renameSync(temporary, pointer);
} finally {
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
}
return { snapshotId, directory, manifest };
}
export function lookupReference(reference, options = {}) {
const needle = (value) => String(value ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
const requestedClass = options.class;
const classes = reference.classes.filter((item) => !requestedClass || [
item.classId, item.gameId, item.className, ...(item.aliases ?? []),
].some((value) => needle(value) === needle(requestedClass)));
if (requestedClass && classes.length === 0) throw new Error(`Unknown CoA class: ${requestedClass}`);
const classIds = new Set(classes.map((item) => item.classId));
let nodes = reference.nodes.filter((node) => classIds.has(node.classId));
if (options.tree) {
const tabs = new Set(classes.flatMap((item) => item.tabs).filter((tab) => [tab.tabId, tab.tabName]
.some((value) => needle(value) === needle(options.tree))).map((tab) => tab.tabId));
if (!tabs.size) throw new Error(`Unknown tree: ${options.tree}`);
nodes = nodes.filter((node) => tabs.has(node.tabId));
}
if (options.ability) nodes = nodes.filter((node) => needle(node.name).includes(needle(options.ability)));
const limit = Number(options.limit ?? 20);
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw new Error("Lookup limit must be 1..1000");
const rankIds = new Set(nodes.flatMap((node) => node.rankDescriptions.map((rank) => rank.spellId)));
let abilities = (reference.abilities ?? []).filter((item) => classIds.has(item.classId)
&& (!options.ability || needle(item.name).includes(needle(options.ability)))
&& (!options.tree || item.nodeIds.some((id) => nodes.some((node) => node.id === id))));
for (const ability of abilities) {
for (const id of [...(ability.spellIds ?? []), ...(ability.rankSpellIds ?? [])]) rankIds.add(id);
}
const bySpellId = new Map(reference.spells.map((spell) => [spell.id, spell]));
const pending = [...rankIds];
for (let index = 0; index < pending.length; index += 1) {
for (const effect of bySpellId.get(pending[index])?.dbc?.effects ?? []) {
const id = effect.triggerSpellId;
if (id > 0 && !rankIds.has(id)) {
rankIds.add(id);
pending.push(id);
}
}
}
let spells = reference.spells.filter((spell) => options.spell
? spell.id === Number(options.spell)
: rankIds.has(spell.id) || (options.ability && needle(spell.name).includes(needle(options.ability)) && !requestedClass));
if (options.spell) {
if (!spells.length) throw new Error(`Spell ${options.spell} is not in this reference snapshot`);
const spellId = Number(options.spell);
nodes = nodes.filter((node) => node.rankDescriptions.some((rank) => rank.spellId === spellId));
abilities = abilities.filter((ability) => [...(ability.spellIds ?? []), ...(ability.rankSpellIds ?? [])].includes(spellId)
|| ability.nodeIds.some((id) => nodes.some((node) => node.id === id)));
if (requestedClass && !rankIds.has(spellId)) throw new Error(`Spell ${spellId} has no evidence for class ${requestedClass}`);
}
return {
baseline: reference.baseline,
classes: classes.map(({ classId, gameId, className, aliases, tabs }) => ({ classId, gameId, className, aliases, tabs })),
totals: { nodes: nodes.length, abilities: abilities.length, spells: spells.length },
nodes: nodes.slice(0, limit), abilities: abilities.slice(0, limit), spells: spells.slice(0, limit),
};
}
+67
View File
@@ -0,0 +1,67 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { lookupReference, publishSnapshot, readSnapshot, stableJson } from "./snapshot.mjs";
function fixture() {
return {
baseline: { kind: "local-july", capturedAt: "2026-07-25" },
classes: [{ classId: 12, gameId: "barbarian", className: "Barbarian", tabs: [{ tabId: 31, tabName: "Brutality" }] }],
nodes: [{ id: 1, classId: 12, tabId: 31, name: "Ancestral Strike", rankDescriptions: [{ rank: 1, spellId: 100 }] }],
spells: [{ id: 100, name: "Ancestral Strike" }, { id: 101, name: "Ancestral Strike", rank: "Rank 2" }],
abilities: [{ key: "c12:ancestralstrike", classId: 12, name: "Ancestral Strike", spellIds: [100], rankSpellIds: [100, 101], nodeIds: [1] }],
};
}
test("stable serialization ignores object insertion order", () => {
assert.equal(stableJson({ b: 2, a: { y: 1, x: 2 } }), stableJson({ a: { x: 2, y: 1 }, b: 2 }));
});
test("snapshot rebuild is deterministic and corruption does not advance the pointer", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-reference-"));
try {
const first = publishSnapshot({ outputRoot: root, reference: fixture(), report: { warnings: [] }, markdown: "# Audit" });
const second = publishSnapshot({ outputRoot: root, reference: fixture(), report: { warnings: [] }, markdown: "# Audit" });
assert.equal(first.snapshotId, second.snapshotId);
assert.deepEqual(readSnapshot(root).reference, fixture());
const pointer = fs.readFileSync(path.join(root, "current.json"), "utf8");
fs.writeFileSync(path.join(first.directory, "reference.json"), "{}\n");
assert.throws(() => readSnapshot(root), /Corrupt reference/);
assert.throws(() => publishSnapshot({ outputRoot: root, reference: fixture(), report: { warnings: [] }, markdown: "# Audit" }), /Corrupt reference/);
assert.equal(fs.readFileSync(path.join(root, "current.json"), "utf8"), pointer);
} finally {
assert.ok(root.startsWith(`${path.resolve(os.tmpdir())}${path.sep}`));
fs.rmSync(root, { recursive: true });
}
});
test("invalid pointer cannot escape snapshot directory", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-reference-"));
try {
fs.writeFileSync(path.join(root, "current.json"), '{"snapshotId":"../outside"}\n');
assert.throws(() => readSnapshot(root), /Invalid reference snapshot pointer/);
} finally {
assert.ok(root.startsWith(`${path.resolve(os.tmpdir())}${path.sep}`));
fs.rmSync(root, { recursive: true });
}
});
test("lookup supports class names, trees, rank spell IDs and ability names", () => {
assert.equal(lookupReference(fixture(), { class: "Barbarian", tree: "Brutality", ability: "ancestral" }).spells.length, 2);
assert.equal(lookupReference(fixture(), { class: "12", spell: "101" }).spells[0].id, 101);
assert.throws(() => lookupReference(fixture(), { class: "unknown" }), /Unknown CoA class/);
assert.throws(() => lookupReference(fixture(), { tree: "unknown" }), /Unknown tree/);
assert.throws(() => lookupReference(fixture(), { spell: "999" }), /not in this reference/);
});
test("class lookup follows triggered spell dependencies without treating them as learned abilities", () => {
const reference = fixture();
reference.spells[0].dbc = { effects: [{ triggerSpellId: 102 }] };
reference.spells.push({ id: 102, name: "Hidden strike", dbc: { effects: [{ triggerSpellId: 100 }] } });
const result = lookupReference(reference, { class: "barbarian", spell: "102" });
assert.equal(result.spells[0].id, 102);
assert.equal(result.abilities.length, 0);
assert.equal(result.nodes.length, 0);
});
+557
View File
@@ -0,0 +1,557 @@
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveArchiveDescriptions } from "./flight-text.mjs";
import { fileURLToPath } from "node:url";
export const COA_ARCHIVE = Object.freeze({
repository: "srhinos/coa-datamine",
revision: "5078f9ac22541c45c17a866c23aac6c12073de23",
shortRevision: "5078f9ac2254",
capturedAt: "2026-08-12T21:02:51+00:00",
fetchManifestPath: "raw/talents/_fetch.json",
builderSnapshotPath: "raw/talents/coa-builder-voljin.html",
builderSnapshotSha256: "41a6ccd393c0648ae2b1fdc34fe94955d4fd13c0cd59040ad3860e64a8ff6c7a",
});
const defaultProjectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const expectedClassCount = 21;
const cacheSchemaVersion = 1;
const manifestName = "manifest.json";
function assertSafeRelativePath(value, context = "archive path") {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`${context} must be a non-empty string`);
}
const normalized = value.replaceAll("\\", "/");
if (normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)) {
throw new Error(`${context} must be relative: ${value}`);
}
if (normalized.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
throw new Error(`${context} contains an unsafe path segment: ${value}`);
}
return normalized;
}
function classFileFromDescriptor(descriptor) {
let candidate = null;
if (typeof descriptor === "string") {
candidate = descriptor;
} else if (descriptor && typeof descriptor === "object") {
candidate = descriptor.path ?? descriptor.file ?? descriptor.filename ?? descriptor.href ?? null;
if (candidate === null) candidate = descriptor.slug ?? descriptor.key ?? descriptor.id ?? null;
}
if (candidate === null || candidate === undefined) {
throw new Error("CoA talent class index contains an entry without a file path or class key");
}
const relative = assertSafeRelativePath(String(candidate), "CoA talent class file reference");
let filePath;
if (relative.startsWith("data/talents/coa/classes/")) {
filePath = relative;
} else if (relative.startsWith("classes/")) {
filePath = `data/talents/coa/${relative}`;
} else if (relative.startsWith("data/talents/coa/") && !relative.slice("data/talents/coa/".length).includes("/")) {
filePath = relative;
} else if (relative.includes("/")) {
throw new Error(`CoA talent class file is outside the indexed CoA talent data: ${candidate}`);
} else {
filePath = `data/talents/coa/${relative.endsWith(".json") ? relative : `${relative}.json`}`;
}
if (!filePath.startsWith("data/talents/coa/") || !filePath.endsWith(".json")
|| filePath === "data/talents/coa/index.json"
|| filePath === "data/talents/coa/_meta.json") {
throw new Error(`CoA talent class file is outside the indexed CoA talent data: ${candidate}`);
}
return filePath;
}
function isClassArchivePath(filePath) {
return filePath.startsWith("data/talents/coa/")
&& filePath.endsWith(".json")
&& filePath !== "data/talents/coa/index.json"
&& filePath !== "data/talents/coa/_meta.json";
}
function descriptorsFromIndex(index) {
const entries = index?.classes;
if (Array.isArray(entries)) return entries;
if (entries && typeof entries === "object") {
return Object.entries(entries).map(([key, value]) => {
if (typeof value === "string") return value;
if (value && typeof value === "object") return { key, ...value };
return { key };
});
}
throw new Error("CoA talent index has no classes array or object");
}
function classPathsFromIndex(index) {
const descriptors = descriptorsFromIndex(index);
if (descriptors.length !== expectedClassCount) {
throw new Error(`CoA talent index expected ${expectedClassCount} class files, found ${descriptors.length}`);
}
const paths = descriptors.map(classFileFromDescriptor);
if (new Set(paths).size !== expectedClassCount) {
throw new Error("CoA talent index contains duplicate class file references");
}
return paths;
}
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function gitBlobSha(bytes) {
return createHash("sha1")
.update(Buffer.from(`blob ${bytes.byteLength}\0`, "utf8"))
.update(bytes)
.digest("hex");
}
function parseJson(bytes, filePath) {
try {
return JSON.parse(bytes.toString("utf8"));
} catch (error) {
throw new Error(`CoA archive JSON is invalid at ${filePath}: ${error.message}`, { cause: error });
}
}
function isRegularBlob(entry) {
return entry?.type === "blob" && entry?.mode === "100644" && /^[0-9a-f]{40}$/i.test(entry.sha ?? "");
}
export function createSupplementCacheApi(archiveInput = COA_ARCHIVE) {
const archive = Object.freeze({ ...archiveInput });
for (const key of ["repository", "revision", "shortRevision", "capturedAt", "fetchManifestPath", "builderSnapshotPath", "builderSnapshotSha256"]) {
if (typeof archive[key] !== "string" || archive[key].length === 0) {
throw new TypeError(`archive.${key} must be a non-empty string`);
}
}
if (!/^[0-9a-f]{40}$/i.test(archive.revision) || !/^[0-9a-f]{7,40}$/i.test(archive.shortRevision)) {
throw new TypeError("archive revision values must be hexadecimal Git revisions");
}
if (!/^[0-9a-f]{64}$/i.test(archive.builderSnapshotSha256)) {
throw new TypeError("archive.builderSnapshotSha256 must be a SHA-256 hex digest");
}
const requiredPaths = [
archive.fetchManifestPath,
archive.builderSnapshotPath,
"data/talents/coa/index.json",
"data/talents/coa/_meta.json",
"raw/provenance.json",
"raw/_snapshot.json",
"data/spells/_meta.json",
];
function archiveDirectory(cacheRoot) {
if (typeof cacheRoot !== "string" || cacheRoot.length === 0) {
throw new TypeError("cacheRoot must be a non-empty directory path");
}
return path.join(path.resolve(cacheRoot), `archive-${archive.shortRevision}`);
}
function archiveUrls(filePath) {
const apiUrl = `https://api.github.com/repos/${archive.repository}/git/trees/${archive.revision}?recursive=1`;
const rawPath = filePath.split("/").map(encodeURIComponent).join("/");
const rawUrl = `https://raw.githubusercontent.com/${archive.repository}/${archive.revision}/${rawPath}`;
return { apiUrl, rawUrl };
}
function parseCaptureManifest(bytes) {
const capture = parseJson(bytes, archive.fetchManifestPath);
if (capture?.capturedUtc !== archive.capturedAt) {
throw new Error(`CoA capture manifest has an unexpected capture time: ${String(capture?.capturedUtc)}`);
}
if (capture?.savedAs !== path.posix.basename(archive.builderSnapshotPath)) {
throw new Error(`CoA capture manifest has an unexpected savedAs value: ${String(capture?.savedAs)}`);
}
if (capture?.sha256 !== archive.builderSnapshotSha256) {
throw new Error(`CoA capture manifest does not pin builder SHA-256 ${archive.builderSnapshotSha256}`);
}
return capture;
}
function sourceRecord() {
return {
repository: `https://github.com/${archive.repository}`,
revision: archive.revision,
capturedAt: archive.capturedAt,
captureManifestPath: archive.fetchManifestPath,
builderSnapshotPath: archive.builderSnapshotPath,
builderSnapshotSha256: archive.builderSnapshotSha256,
};
}
function validateManifestShape(manifest) {
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
throw new Error("CoA supplement manifest is not an object");
}
if (manifest.schemaVersion !== cacheSchemaVersion) {
throw new Error(`Unsupported CoA supplement cache schema: ${String(manifest.schemaVersion)}`);
}
if (manifest.source?.revision !== archive.revision) {
throw new Error("CoA supplement manifest points at an unexpected archive revision");
}
if (typeof manifest.generation !== "string" || !/^generation-[0-9a-f-]+$/.test(manifest.generation)) {
throw new Error("CoA supplement manifest has an invalid generation name");
}
if (!Array.isArray(manifest.files)) throw new Error("CoA supplement manifest has no file list");
const source = manifest.source;
if (source.captureManifestPath !== archive.fetchManifestPath
|| source.builderSnapshotPath !== archive.builderSnapshotPath
|| source.builderSnapshotSha256 !== archive.builderSnapshotSha256
|| source.capturedAt !== archive.capturedAt) {
throw new Error("CoA supplement manifest has unexpected capture metadata");
}
}
function expectedFilesFromManifest(manifest) {
validateManifestShape(manifest);
const expected = new Map();
for (const file of manifest.files) {
const filePath = assertSafeRelativePath(file?.path, "CoA supplement manifest file path");
if (expected.has(filePath)) throw new Error(`CoA supplement manifest repeats ${filePath}`);
if (!/^[0-9a-f]{40}$/i.test(file.gitBlobSha ?? "")) {
throw new Error(`CoA supplement manifest has an invalid Git blob SHA for ${filePath}`);
}
if (!/^[0-9a-f]{64}$/i.test(file.sha256 ?? "")) {
throw new Error(`CoA supplement manifest has an invalid SHA-256 for ${filePath}`);
}
if (!Number.isSafeInteger(file.size) || file.size < 0) {
throw new Error(`CoA supplement manifest has an invalid size for ${filePath}`);
}
expected.set(filePath, file);
}
for (const filePath of requiredPaths) {
if (!expected.has(filePath)) throw new Error(`CoA supplement manifest is missing ${filePath}`);
}
const classPaths = [...expected.keys()].filter(isClassArchivePath);
if (classPaths.length !== expectedClassCount || expected.size !== requiredPaths.length + expectedClassCount) {
throw new Error(`CoA supplement manifest must contain ${expectedClassCount} class files and ${requiredPaths.length} required files`);
}
return expected;
}
async function responseBytes(fetchImpl, url, accept) {
let response;
try {
response = await fetchImpl(url, { headers: { Accept: accept } });
} catch (error) {
throw new Error(`CoA supplemental source unavailable at ${url}: ${error.message}`, { cause: error });
}
if (!response || response.ok !== true) {
const status = response?.status === undefined ? "no response" : `HTTP ${response.status}`;
throw new Error(`CoA supplemental source unavailable at ${url} (${status})`);
}
try {
if (typeof response.arrayBuffer === "function") return Buffer.from(await response.arrayBuffer());
if (typeof response.text === "function") return Buffer.from(await response.text(), "utf8");
} catch (error) {
throw new Error(`CoA supplemental source read failed at ${url}: ${error.message}`, { cause: error });
}
throw new Error(`CoA supplemental source response has no readable body at ${url}`);
}
async function fetchTree(fetchImpl) {
const { apiUrl } = archiveUrls(archive.fetchManifestPath);
const bytes = await responseBytes(fetchImpl, apiUrl, "application/vnd.github+json");
const treeDocument = parseJson(bytes, "GitHub archive tree");
if (treeDocument.truncated === true) {
throw new Error("CoA archive tree was truncated; refusing an incomplete supplemental cache");
}
if (!Array.isArray(treeDocument.tree)) throw new Error("CoA archive tree response does not contain a tree array");
return new Map(treeDocument.tree.map((entry) => [entry.path, entry]));
}
function expectedFile(tree, filePath) {
const safePath = assertSafeRelativePath(filePath);
const entry = tree.get(safePath);
if (!isRegularBlob(entry)) throw new Error(`Pinned CoA archive tree is missing a regular file at ${safePath}`);
if (!Number.isSafeInteger(entry.size) || entry.size < 0) {
throw new Error(`Pinned CoA archive tree has no valid file size for ${safePath}`);
}
return { path: safePath, gitBlobSha: entry.sha.toLowerCase(), size: entry.size };
}
async function readManifest(archiveRoot) {
try {
return parseJson(await fs.readFile(path.join(archiveRoot, manifestName)), manifestName);
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
}
async function readFileUnder(root, relativePath) {
const safePath = assertSafeRelativePath(relativePath);
const absoluteRoot = path.resolve(root);
const absolutePath = path.resolve(absoluteRoot, ...safePath.split("/"));
if (!absolutePath.startsWith(`${absoluteRoot}${path.sep}`)) {
throw new Error(`CoA archive path escapes its cache directory: ${relativePath}`);
}
return fs.readFile(absolutePath);
}
async function verifyFile(root, fileRecord) {
const bytes = await readFileUnder(root, fileRecord.path);
if (bytes.byteLength !== fileRecord.size) throw new Error(`CoA supplement size mismatch for ${fileRecord.path}`);
if (gitBlobSha(bytes) !== fileRecord.gitBlobSha.toLowerCase()) {
throw new Error(`CoA supplement Git blob checksum mismatch for ${fileRecord.path}`);
}
const actualSha256 = sha256(bytes);
if (actualSha256 !== fileRecord.sha256.toLowerCase()) {
throw new Error(`CoA supplement SHA-256 mismatch for ${fileRecord.path}`);
}
if (fileRecord.path === archive.fetchManifestPath) parseCaptureManifest(bytes);
if (fileRecord.path === archive.builderSnapshotPath && actualSha256 !== archive.builderSnapshotSha256) {
throw new Error(`CoA builder snapshot does not match pinned SHA-256 ${archive.builderSnapshotSha256}`);
}
return bytes;
}
async function loadPublishedCache(archiveRoot) {
const manifest = await readManifest(archiveRoot);
if (manifest === null) return null;
const expected = expectedFilesFromManifest(manifest);
const generationRoot = path.join(archiveRoot, manifest.generation);
const contents = new Map();
for (const [filePath, record] of expected) contents.set(filePath, await verifyFile(generationRoot, record));
parseCaptureManifest(contents.get(archive.fetchManifestPath));
const indexPath = "data/talents/coa/index.json";
const index = parseJson(contents.get(indexPath), indexPath);
const classPaths = classPathsFromIndex(index);
const recordedClassPaths = [...expected.keys()]
.filter(isClassArchivePath)
.sort();
if (classPaths.slice().sort().join("\n") !== recordedClassPaths.join("\n")) {
throw new Error("CoA supplement manifest class files do not match data/talents/coa/index.json");
}
const metadata = {
talents: parseJson(contents.get("data/talents/coa/_meta.json"), "data/talents/coa/_meta.json"),
provenance: parseJson(contents.get("raw/provenance.json"), "raw/provenance.json"),
clientSnapshot: parseJson(contents.get("raw/_snapshot.json"), "raw/_snapshot.json"),
spells: parseJson(contents.get("data/spells/_meta.json"), "data/spells/_meta.json"),
};
const descriptors = descriptorsFromIndex(index);
const rawClasses = classPaths.map((filePath) => parseJson(contents.get(filePath), filePath));
const hasTextReferences = rawClasses.some((definition) => definition.nodes?.some((node) => (
/^\$[0-9a-f]+$/i.test(node.description ?? "")
|| node.rankDescriptions?.some((rank) => /^\$[0-9a-f]+$/i.test(rank.description ?? ""))
)));
const classes = hasTextReferences
? resolveArchiveDescriptions(rawClasses, contents.get(archive.builderSnapshotPath).toString("utf8"))
: rawClasses;
const classFiles = classPaths.map((filePath, position) => ({ path: filePath, indexEntry: descriptors[position], data: classes[position] }));
return {
source: structuredClone(manifest.source),
classes,
classFiles,
metadata,
index,
manifest: structuredClone(manifest),
};
}
async function atomicWrite(filePath, bytes) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.tmp-${randomUUID()}`;
try {
await fs.writeFile(temporaryPath, bytes, { flag: "wx" });
await fs.rename(temporaryPath, filePath);
} catch (error) {
await fs.rm(temporaryPath, { force: true }).catch(() => {});
throw error;
}
}
async function discardInvalidFile(filePath) {
try {
await fs.rm(filePath, { force: true });
} catch (error) {
throw new Error(`Could not discard invalid cached CoA supplement ${filePath}: ${error.message}`, { cause: error });
}
}
async function readAndVerifyStaged(stagingRoot, expected) {
let bytes;
try {
bytes = await readFileUnder(stagingRoot, expected.path);
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
const stagedPath = path.join(stagingRoot, ...expected.path.split("/"));
if (bytes.byteLength !== expected.size || gitBlobSha(bytes) !== expected.gitBlobSha) {
await discardInvalidFile(stagedPath);
return null;
}
const contentSha256 = sha256(bytes);
if (expected.path === archive.fetchManifestPath) {
try {
parseCaptureManifest(bytes);
} catch {
await discardInvalidFile(stagedPath);
return null;
}
}
if (expected.path === archive.builderSnapshotPath && contentSha256 !== archive.builderSnapshotSha256) {
await discardInvalidFile(stagedPath);
return null;
}
return { path: expected.path, gitBlobSha: expected.gitBlobSha, sha256: contentSha256, size: bytes.byteLength };
}
async function fetchAndPromoteFile(fetchImpl, stagingRoot, expected) {
const { rawUrl } = archiveUrls(expected.path);
const bytes = await responseBytes(fetchImpl, rawUrl, "application/octet-stream");
if (gitBlobSha(bytes) !== expected.gitBlobSha) {
throw new Error(`Downloaded CoA supplement Git blob checksum mismatch for ${expected.path}`);
}
const contentSha256 = sha256(bytes);
if (expected.path === archive.fetchManifestPath) parseCaptureManifest(bytes);
if (expected.path === archive.builderSnapshotPath && contentSha256 !== archive.builderSnapshotSha256) {
throw new Error(`Downloaded CoA builder snapshot SHA-256 mismatch for ${expected.path}`);
}
const record = { path: expected.path, gitBlobSha: expected.gitBlobSha, sha256: contentSha256, size: bytes.byteLength };
const stagedPath = path.join(stagingRoot, ...expected.path.split("/"));
await atomicWrite(stagedPath, bytes);
await verifyFile(stagingRoot, record);
return record;
}
async function stageFile(fetchImpl, stagingRoot, expected) {
const cached = await readAndVerifyStaged(stagingRoot, expected);
if (cached) return { record: cached, downloaded: false };
return { record: await fetchAndPromoteFile(fetchImpl, stagingRoot, expected), downloaded: true };
}
async function validateGeneration(generationRoot, manifest) {
const expected = expectedFilesFromManifest(manifest);
const contents = new Map();
for (const [filePath, record] of expected) contents.set(filePath, await verifyFile(generationRoot, record));
const index = parseJson(contents.get("data/talents/coa/index.json"), "data/talents/coa/index.json");
const indexedClassPaths = classPathsFromIndex(index).sort();
const recordedClassPaths = [...expected.keys()]
.filter(isClassArchivePath)
.sort();
if (indexedClassPaths.join("\n") !== recordedClassPaths.join("\n")) {
throw new Error("CoA supplement generation class files do not match the pinned index");
}
parseCaptureManifest(contents.get(archive.fetchManifestPath));
for (const filePath of [
"data/talents/coa/_meta.json",
"raw/provenance.json",
"raw/_snapshot.json",
"data/spells/_meta.json",
...indexedClassPaths,
]) {
parseJson(contents.get(filePath), filePath);
}
}
async function writeManifest(archiveRoot, manifest) {
await atomicWrite(
path.join(archiveRoot, manifestName),
Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"),
);
}
function cacheSummary(manifest, cacheDirectory, reused, downloadedFiles = []) {
return {
source: structuredClone(manifest.source),
cacheDirectory,
revision: archive.revision,
capturedAt: archive.capturedAt,
fileCount: manifest.files.length,
classCount: expectedClassCount,
reused,
downloadedFiles,
};
}
async function readSupplements({ cacheRoot } = {}) {
const archiveRoot = archiveDirectory(cacheRoot);
try {
await fs.access(archiveRoot);
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
try {
const contents = await loadPublishedCache(archiveRoot);
if (contents === null) throw new Error("published manifest is missing");
return contents;
} catch (error) {
throw new Error(`CoA supplement cache at ${archiveRoot} is corrupt or incomplete: ${error.message}`, { cause: error });
}
}
async function downloadSupplements({ cacheRoot, fetchImpl = globalThis.fetch, refresh = false } = {}) {
if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl must be a fetch-compatible function");
const archiveRoot = archiveDirectory(cacheRoot);
if (!refresh) {
try {
const cached = await loadPublishedCache(archiveRoot);
if (cached) return cacheSummary(cached.manifest, archiveRoot, true);
} catch {
// Keep the previous manifest and generation until a complete replacement is ready.
}
}
await fs.mkdir(archiveRoot, { recursive: true });
const tree = await fetchTree(fetchImpl);
const expected = new Map(requiredPaths.map((filePath) => [filePath, expectedFile(tree, filePath)]));
const stagingRoot = path.join(archiveRoot, "staging");
const stagedRecords = new Map();
const downloadedFiles = [];
for (const filePath of requiredPaths) {
const staged = await stageFile(fetchImpl, stagingRoot, expected.get(filePath));
stagedRecords.set(filePath, staged.record);
if (staged.downloaded) downloadedFiles.push(filePath);
}
const indexPath = "data/talents/coa/index.json";
const index = parseJson(await readFileUnder(stagingRoot, indexPath), indexPath);
const classPaths = classPathsFromIndex(index);
for (const filePath of classPaths) expected.set(filePath, expectedFile(tree, filePath));
for (const filePath of classPaths) {
const staged = await stageFile(fetchImpl, stagingRoot, expected.get(filePath));
stagedRecords.set(filePath, staged.record);
if (staged.downloaded) downloadedFiles.push(filePath);
}
const files = [...stagedRecords.values()].sort((left, right) => left.path.localeCompare(right.path));
if (files.length !== requiredPaths.length + expectedClassCount) {
throw new Error(`CoA supplemental cache expected ${requiredPaths.length + expectedClassCount} files but staged ${files.length}`);
}
parseCaptureManifest(await readFileUnder(stagingRoot, archive.fetchManifestPath));
const manifest = {
schemaVersion: cacheSchemaVersion,
source: sourceRecord(),
generation: `generation-${randomUUID()}`,
files,
};
// Validate every staged byte before moving the resumable cache into an immutable generation.
await validateGeneration(stagingRoot, manifest);
const generationRoot = path.join(archiveRoot, manifest.generation);
await fs.rename(stagingRoot, generationRoot);
await writeManifest(archiveRoot, manifest);
return cacheSummary(manifest, archiveRoot, false, downloadedFiles);
}
return Object.freeze({ readSupplements, downloadSupplements });
}
const defaultApi = createSupplementCacheApi();
export const readSupplements = defaultApi.readSupplements;
export const downloadSupplements = defaultApi.downloadSupplements;
export function getDefaultSupplementCacheRoot(projectRoot = defaultProjectRoot) {
return path.join(path.resolve(projectRoot), "data", "coa-reference", "cache");
}
+238
View File
@@ -0,0 +1,238 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test, { afterEach, beforeEach } from "node:test";
import { createSupplementCacheApi } from "./supplements.mjs";
const testArchiveBase = {
repository: "example/coa-archive-fixture",
revision: "a".repeat(40),
shortRevision: "a".repeat(12),
capturedAt: "2026-08-12T21:02:51+00:00",
fetchManifestPath: "raw/talents/_fetch.json",
builderSnapshotPath: "raw/talents/coa-builder-voljin.html",
};
let tempRoot;
let archive;
let fileBytes;
let treeUrl;
let api;
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function gitBlobSha(bytes) {
return createHash("sha1")
.update(Buffer.from(`blob ${bytes.byteLength}\0`, "utf8"))
.update(bytes)
.digest("hex");
}
function pathIsWithin(parent, candidate) {
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
}
function makeFixture({ traversal = false } = {}) {
const html = Buffer.from("fixture builder snapshot payload\n", "utf8");
archive = {
...testArchiveBase,
builderSnapshotSha256: sha256(html),
};
const descriptors = Array.from({ length: 21 }, (_, index) => ({
name: `Class ${index + 1}`,
file: traversal && index === 0
? "../outside.json"
: index === 0
? "Barbarian.json"
: `Class-${String(index + 1).padStart(2, "0")}.json`,
}));
const captureManifest = Buffer.from(`${JSON.stringify({
capturedUtc: archive.capturedAt,
sha256: archive.builderSnapshotSha256,
savedAs: path.posix.basename(archive.builderSnapshotPath),
})}\n`, "utf8");
const entries = new Map([
[archive.fetchManifestPath, captureManifest],
[archive.builderSnapshotPath, html],
["data/talents/coa/index.json", Buffer.from(`${JSON.stringify({ classes: descriptors })}\n`, "utf8")],
["data/talents/coa/_meta.json", Buffer.from('{"capture":"talents"}\n', "utf8")],
["raw/provenance.json", Buffer.from('{"sources":[]}\n', "utf8")],
["raw/_snapshot.json", Buffer.from('{"commit":"fixture"}\n', "utf8")],
["data/spells/_meta.json", Buffer.from('{"capture":"spells"}\n', "utf8")],
]);
for (let index = 0; index < 21; index += 1) {
const fileName = index === 0 ? "Barbarian.json" : `Class-${String(index + 1).padStart(2, "0")}.json`;
const filePath = `data/talents/coa/${fileName}`;
entries.set(filePath, Buffer.from(`${JSON.stringify({ classNumber: index + 1, trees: [{ id: index + 1 }] })}\n`, "utf8"));
}
// These must remain untouched: only metadata and the pinned capture are selected.
entries.set("data/spells/123456.json", Buffer.from("large spell payload"));
entries.set("raw/Spell.dbc", Buffer.from("client dbc"));
fileBytes = entries;
const tree = [...entries].map(([filePath, bytes]) => ({
path: filePath,
mode: "100644",
type: "blob",
sha: gitBlobSha(bytes),
size: bytes.byteLength,
}));
treeUrl = `https://api.github.com/repos/${archive.repository}/git/trees/${archive.revision}?recursive=1`;
api = createSupplementCacheApi(archive);
return { tree };
}
function createFetch({ tree, failPath = null, failOnce = false, calls = [] } = {}) {
let didFail = false;
return {
calls,
fetch: async (url) => {
calls.push(url);
if (url === treeUrl) {
return response(Buffer.from(JSON.stringify({ truncated: false, tree }), "utf8"));
}
const rawPrefix = `https://raw.githubusercontent.com/${archive.repository}/${archive.revision}/`;
assert.ok(url.startsWith(rawPrefix), `unexpected URL ${url}`);
const filePath = url.slice(rawPrefix.length).split("/").map(decodeURIComponent).join("/");
if (filePath === failPath && (!failOnce || !didFail)) {
didFail = true;
return { ok: false, status: 503 };
}
const bytes = fileBytes.get(filePath);
if (!bytes) return { ok: false, status: 404 };
return response(bytes);
},
};
}
function response(bytes) {
return { ok: true, arrayBuffer: async () => Uint8Array.from(bytes).buffer };
}
async function freshRoot() {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "coa-supplements-test-"));
return path.join(tempRoot, "cache");
}
beforeEach(() => {
tempRoot = null;
});
afterEach(async () => {
if (tempRoot) {
assert.ok(pathIsWithin(os.tmpdir(), tempRoot), "cleanup path must stay inside the OS temporary directory");
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
test("downloads the selected archive files and reuses a valid cache offline", async () => {
const { tree } = makeFixture();
const cacheRoot = await freshRoot();
const firstFetch = createFetch({ tree });
const first = await api.downloadSupplements({ cacheRoot, fetchImpl: firstFetch.fetch });
assert.equal(first.reused, false);
assert.equal(first.fileCount, 28);
assert.equal(first.classCount, 21);
assert.equal(firstFetch.calls.length, 29);
assert.equal(firstFetch.calls.includes(`https://raw.githubusercontent.com/${archive.repository}/${archive.revision}/data/spells/123456.json`), false);
assert.equal(firstFetch.calls.includes(`https://raw.githubusercontent.com/${archive.repository}/${archive.revision}/raw/Spell.dbc`), false);
const second = await api.downloadSupplements({
cacheRoot,
fetchImpl: async () => {
throw new Error("valid cache should avoid the network");
},
});
const supplements = await api.readSupplements({ cacheRoot });
assert.equal(second.reused, true);
assert.equal(supplements.classes.length, 21);
assert.deepEqual(supplements.metadata, {
talents: { capture: "talents" },
provenance: { sources: [] },
clientSnapshot: { commit: "fixture" },
spells: { capture: "spells" },
});
assert.equal(supplements.classFiles[0].path, "data/talents/coa/Barbarian.json");
assert.deepEqual(supplements.classFiles[0].data, { classNumber: 1, trees: [{ id: 1 }] });
});
test("resumes verified files after an interrupted download", async () => {
const { tree } = makeFixture();
const cacheRoot = await freshRoot();
const interrupted = createFetch({ tree, failPath: "data/talents/coa/Class-03.json" });
await assert.rejects(
api.downloadSupplements({ cacheRoot, fetchImpl: interrupted.fetch }),
/HTTP 503/,
);
await assert.rejects(api.readSupplements({ cacheRoot }), /incomplete.*published manifest is missing/);
const resumedFetch = createFetch({ tree });
const summary = await api.downloadSupplements({ cacheRoot, fetchImpl: resumedFetch.fetch });
assert.equal(summary.reused, false);
assert.equal(summary.downloadedFiles.length, 19);
const supplements = await api.readSupplements({ cacheRoot });
assert.equal(supplements.classes.length, 21);
});
test("offline reads refuse corrupted files, and an online run repairs them", async () => {
const { tree } = makeFixture();
const cacheRoot = await freshRoot();
await api.downloadSupplements({ cacheRoot, fetchImpl: createFetch({ tree }).fetch });
const archiveRoot = path.join(cacheRoot, `archive-${archive.shortRevision}`);
const manifest = JSON.parse(await fs.readFile(path.join(archiveRoot, "manifest.json"), "utf8"));
const generationFile = path.join(archiveRoot, manifest.generation, "data", "talents", "coa", "Barbarian.json");
await fs.writeFile(generationFile, "corrupted\n", "utf8");
await assert.rejects(api.readSupplements({ cacheRoot }), /size mismatch|checksum mismatch/);
await api.downloadSupplements({ cacheRoot, fetchImpl: createFetch({ tree }).fetch });
const repaired = await api.readSupplements({ cacheRoot });
assert.deepEqual(repaired.classes[0], { classNumber: 1, trees: [{ id: 1 }] });
});
test("reports an unavailable source without publishing a partial manifest", async () => {
makeFixture();
const cacheRoot = await freshRoot();
await assert.rejects(
api.downloadSupplements({ cacheRoot, fetchImpl: async () => { throw new Error("offline"); } }),
/CoA supplemental source unavailable/,
);
await assert.rejects(api.readSupplements({ cacheRoot }), /incomplete.*published manifest is missing/);
});
test("keeps the last valid manifest and generation when a refresh fails", async () => {
const { tree } = makeFixture();
const cacheRoot = await freshRoot();
await api.downloadSupplements({ cacheRoot, fetchImpl: createFetch({ tree }).fetch });
const archiveRoot = path.join(cacheRoot, `archive-${archive.shortRevision}`);
const manifestPath = path.join(archiveRoot, "manifest.json");
const previousManifest = await fs.readFile(manifestPath);
const previous = JSON.parse(previousManifest.toString("utf8"));
const previousClassPath = path.join(archiveRoot, previous.generation, "data", "talents", "coa", "Barbarian.json");
const previousClass = await fs.readFile(previousClassPath);
const interruptedRefresh = createFetch({ tree, failPath: "data/talents/coa/Class-04.json" });
await assert.rejects(
api.downloadSupplements({ cacheRoot, fetchImpl: interruptedRefresh.fetch, refresh: true }),
/HTTP 503/,
);
assert.deepEqual(await fs.readFile(manifestPath), previousManifest);
assert.deepEqual(await fs.readFile(previousClassPath), previousClass);
assert.equal((await api.readSupplements({ cacheRoot })).classes.length, 21);
});
test("rejects index path traversal before requesting a class file", async () => {
const { tree } = makeFixture({ traversal: true });
const cacheRoot = await freshRoot();
const source = createFetch({ tree });
await assert.rejects(
api.downloadSupplements({ cacheRoot, fetchImpl: source.fetch }),
/unsafe path segment/,
);
assert.equal(source.calls.some((url) => url.includes("outside.json")), false);
assert.equal(await fs.stat(path.join(tempRoot, "outside.json")).then(() => true, () => false), false);
});
+99
View File
@@ -0,0 +1,99 @@
import fs from "node:fs";
import path from "node:path";
const dbcDirectory = (root, patch, fileName) => path.join(root, patch, "DBFilesClient", fileName);
function firstExisting(candidates, exists) {
return candidates.find((candidate) => exists(candidate)) ?? candidates.at(-1);
}
/** Resolve independent source paths for classic and CoA data in the combined importer. */
export function resolveCoaSourcePaths({
extractedClientRoot,
legacyExtractedRoot,
classicSpellOverride,
exists = fs.existsSync,
}) {
const area52Spell = dbcDirectory(path.join(extractedClientRoot, "area-52"), "patch-D", "Spell.dbc");
const globalSpell = dbcDirectory(extractedClientRoot, "patch-T", "Spell.dbc");
const legacySpell = dbcDirectory(legacyExtractedRoot, "patch-T", "Spell.dbc");
const area52SkillLineAbility = dbcDirectory(
path.join(extractedClientRoot, "area-52"),
"patch-D",
"SkillLineAbility.dbc",
);
const globalSkillLineAbility = dbcDirectory(extractedClientRoot, "patch-M", "SkillLineAbility.dbc");
const legacySkillLineAbility = dbcDirectory(legacyExtractedRoot, "patch-M", "SkillLineAbility.dbc");
const classicSpellDbcPath = classicSpellOverride
? path.resolve(classicSpellOverride)
: firstExisting([area52Spell, globalSpell, legacySpell], exists);
const classicSiblingSkillLineAbility = path.join(path.dirname(classicSpellDbcPath), "SkillLineAbility.dbc");
return {
classicSpellDbcPath,
classicSkillLineAbilityDbcPath: exists(classicSiblingSkillLineAbility)
? classicSiblingSkillLineAbility
: firstExisting([area52SkillLineAbility, globalSkillLineAbility, legacySkillLineAbility], exists),
coaSpellDbcPath: firstExisting([globalSpell, legacySpell], exists),
coaSkillLineAbilityDbcPath: firstExisting([globalSkillLineAbility, legacySkillLineAbility], exists),
};
}
/** Collect DBC IDs whose classic metadata must remain available beside CoA metadata. */
export function collectRealmSpellIds({
classicClassIds,
coaClassIds,
classSpellRows,
classicRankCandidates,
coaRankCandidates,
coaEntrySpellIds,
}) {
const classicIds = new Set();
const coaIds = new Set(coaEntrySpellIds ?? []);
const classicClasses = new Set(classicClassIds);
const coaClasses = new Set(coaClassIds);
for (const row of classSpellRows) {
if (classicClasses.has(row.classId)) classicIds.add(row.id);
if (coaClasses.has(row.classId)) coaIds.add(row.id);
}
for (const candidate of classicRankCandidates) classicIds.add(candidate.id);
for (const candidate of coaRankCandidates) coaIds.add(candidate.id);
return { classicIds, coaIds };
}
/** Return IDs that need a separate CoA wrapper to protect classic metadata. */
export function sharedRealmSpellIds(classicIds, coaIds) {
return [...coaIds].filter((spellId) => classicIds.has(spellId)).sort((left, right) => left - right);
}
/** Include linked base-client payloads, including effect 168's miscValue spell. */
export function expandCoaSpellIds(buffer, initialIds) {
if (buffer.length < 20 || buffer.toString("ascii", 0, 4) !== "WDBC" || buffer.readUInt32LE(8) < 234) throw new Error("CoA Spell.dbc does not expose the 234-field WDBC schema");
const recordCount = buffer.readUInt32LE(4);
const recordSize = buffer.readUInt32LE(12);
if (recordSize < 234 * 4 || 20 + recordCount * recordSize > buffer.length) throw new Error("CoA Spell.dbc has invalid record lengths");
const rows = new Map();
for (let index = 0; index < recordCount; index += 1) {
const base = 20 + index * recordSize;
const id = buffer.readUInt32LE(base);
if (!rows.has(id)) rows.set(id, base);
}
const ids = new Set(initialIds);
const queue = [...ids];
for (let cursor = 0; cursor < queue.length; cursor += 1) {
const base = rows.get(queue[cursor]);
if (base === undefined) continue;
for (let index = 0; index < 3; index += 1) {
const childId = buffer.readUInt32LE(base + (116 + index) * 4)
|| (buffer.readUInt32LE(base + (71 + index) * 4) === 168 ? buffer.readUInt32LE(base + (110 + index) * 4) : 0);
if (childId > 0 && !ids.has(childId)) {
ids.add(childId);
queue.push(childId);
}
}
}
return ids;
}
+114
View File
@@ -0,0 +1,114 @@
import assert from "node:assert/strict";
import path from "node:path";
import test from "node:test";
import {
collectRealmSpellIds,
expandCoaSpellIds,
resolveCoaSourcePaths,
sharedRealmSpellIds,
} from "./coa-source-policy.mjs";
test("CoA payload closure includes effect-168 links, cycles and absent IDs without overlay fallback", () => {
const buffer = Buffer.alloc(20 + 3 * 234 * 4);
buffer.write("WDBC");
buffer.writeUInt32LE(3, 4);
buffer.writeUInt32LE(234, 8);
buffer.writeUInt32LE(234 * 4, 12);
const write = (row, field, value) => buffer.writeUInt32LE(value, 20 + (row * 234 + field) * 4);
write(0, 0, 100);
write(0, 71, 168);
write(0, 110, 200);
write(1, 0, 200);
write(1, 116, 300);
write(2, 0, 300);
write(2, 116, 100);
write(2, 117, 400);
assert.deepEqual([...expandCoaSpellIds(buffer, [100])], [100, 200, 300, 400]);
assert.throws(() => expandCoaSpellIds(Buffer.from("invalid"), [100]), /234-field/);
});
test("CoA source resolution selects global patch-T and patch-M while classic keeps Area52 priority", () => {
const root = path.resolve("fixture/client-current");
const legacy = path.resolve("fixture/legacy");
const exists = new Set([
path.join(root, "area-52", "patch-D", "DBFilesClient", "Spell.dbc"),
path.join(root, "area-52", "patch-D", "DBFilesClient", "SkillLineAbility.dbc"),
path.join(root, "patch-T", "DBFilesClient", "Spell.dbc"),
path.join(root, "patch-M", "DBFilesClient", "SkillLineAbility.dbc"),
path.join(legacy, "patch-T", "DBFilesClient", "Spell.dbc"),
path.join(legacy, "patch-M", "DBFilesClient", "SkillLineAbility.dbc"),
]);
const sources = resolveCoaSourcePaths({
extractedClientRoot: root,
legacyExtractedRoot: legacy,
exists: (candidate) => exists.has(candidate),
});
assert.equal(sources.classicSpellDbcPath, path.join(root, "area-52", "patch-D", "DBFilesClient", "Spell.dbc"));
assert.equal(sources.classicSkillLineAbilityDbcPath, path.join(root, "area-52", "patch-D", "DBFilesClient", "SkillLineAbility.dbc"));
assert.equal(sources.coaSpellDbcPath, path.join(root, "patch-T", "DBFilesClient", "Spell.dbc"));
assert.equal(sources.coaSkillLineAbilityDbcPath, path.join(root, "patch-M", "DBFilesClient", "SkillLineAbility.dbc"));
});
test("CoA sources fall back only to global legacy files, never Area52", () => {
const root = path.resolve("fixture/client-current");
const legacy = path.resolve("fixture/legacy");
const exists = new Set([
path.join(root, "area-52", "patch-D", "DBFilesClient", "Spell.dbc"),
path.join(root, "area-52", "patch-D", "DBFilesClient", "SkillLineAbility.dbc"),
path.join(legacy, "patch-T", "DBFilesClient", "Spell.dbc"),
path.join(legacy, "patch-M", "DBFilesClient", "SkillLineAbility.dbc"),
]);
const sources = resolveCoaSourcePaths({
extractedClientRoot: root,
legacyExtractedRoot: legacy,
exists: (candidate) => exists.has(candidate),
});
assert.equal(sources.coaSpellDbcPath, path.join(legacy, "patch-T", "DBFilesClient", "Spell.dbc"));
assert.equal(sources.coaSkillLineAbilityDbcPath, path.join(legacy, "patch-M", "DBFilesClient", "SkillLineAbility.dbc"));
});
test("classic SkillLineAbility follows the selected Spell.dbc when that sibling exists", () => {
const root = path.resolve("fixture/client-current");
const legacy = path.resolve("fixture/legacy");
const exists = new Set([
path.join(root, "area-52", "patch-D", "DBFilesClient", "SkillLineAbility.dbc"),
path.join(root, "patch-T", "DBFilesClient", "Spell.dbc"),
path.join(root, "patch-T", "DBFilesClient", "SkillLineAbility.dbc"),
path.join(root, "patch-M", "DBFilesClient", "SkillLineAbility.dbc"),
path.join(legacy, "patch-T", "DBFilesClient", "Spell.dbc"),
path.join(legacy, "patch-M", "DBFilesClient", "SkillLineAbility.dbc"),
]);
const sources = resolveCoaSourcePaths({
extractedClientRoot: root,
legacyExtractedRoot: legacy,
exists: (candidate) => exists.has(candidate),
});
assert.equal(sources.classicSpellDbcPath, path.join(root, "patch-T", "DBFilesClient", "Spell.dbc"));
assert.equal(sources.classicSkillLineAbilityDbcPath, path.join(root, "patch-T", "DBFilesClient", "SkillLineAbility.dbc"));
});
test("realm spell ownership protects classic references and includes CoA tree ranks", () => {
const { classicIds, coaIds } = collectRealmSpellIds({
classicClassIds: [11],
coaClassIds: [27],
classSpellRows: [
{ id: 100, classId: 11 },
{ id: 200, classId: 27 },
{ id: 300, classId: 27 },
{ id: 400, classId: 11 },
{ id: 400, classId: 27 },
],
classicRankCandidates: [{ id: 101 }],
coaRankCandidates: [{ id: 201 }],
coaEntrySpellIds: [300, 301],
});
assert.deepEqual([...classicIds].sort(), [100, 101, 400]);
assert.deepEqual([...coaIds].sort(), [200, 201, 300, 301, 400]);
assert.ok(classicIds.has(100) && coaIds.has(300) && classicIds.has(400) && coaIds.has(400));
assert.deepEqual(sharedRealmSpellIds(classicIds, coaIds), [400]);
assert.deepEqual(sharedRealmSpellIds(new Set([401]), new Set([401])), [401]);
});
+19 -5
View File
@@ -1,9 +1,12 @@
import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
import { resolveSourceRoots } from "./coa-reference/local-source.mjs";
const projectRoot = path.resolve(import.meta.dirname, "..");
const clientRoot = path.resolve(projectRoot, "..", "LadiksMPQEditor", "client-current");
const spellPath = path.join(clientRoot, "area-52", "patch-D", "DBFilesClient", "Spell.dbc");
const { extractedRoot } = resolveSourceRoots({ projectRoot });
const clientRoot = path.join(extractedRoot, "client-current");
const spellPath = path.join(clientRoot, "patch-T", "DBFilesClient", "Spell.dbc");
const supportRoot = path.join(clientRoot, "patch-S", "DBFilesClient");
const runtimePath = path.join(projectRoot, "src", "game", "generated", "coaAbilityRuntimeCatalog.json");
const outputPath = path.join(projectRoot, "src", "game", "generated", "coaTriggeredSpellCatalog.json");
@@ -176,19 +179,30 @@ const castTimes = dbcNumberTable(path.join(supportRoot, "SpellCastTimes.dbc"), 1
const durations = dbcNumberTable(path.join(supportRoot, "SpellDuration.dbc"), 3);
const radii = dbcNumberTable(path.join(supportRoot, "SpellRadius.dbc"), 3, true);
const ranges = dbcRangeTable(path.join(supportRoot, "SpellRange.dbc"));
const parseSpell = spellParser(fs.readFileSync(spellPath), castTimes, durations, radii, ranges);
const spellBytes = fs.readFileSync(spellPath);
const parseSpell = spellParser(spellBytes, castTimes, durations, radii, ranges);
const pending = [...referencedSpellIds(runtime)];
const spells = new Map();
const visited = new Set();
while (pending.length) {
const id = pending.shift();
if (spells.has(id)) continue;
if (visited.has(id)) continue;
visited.add(id);
const spell = parseSpell(id);
if (!spell) continue;
spells.set(id, spell);
for (const effect of spell.effects) {
if (effect.triggerSpellId > 0 && !spells.has(effect.triggerSpellId)) pending.push(effect.triggerSpellId);
if (effect.effectType === 168 && effect.miscValue > 0) pending.push(effect.miscValue);
}
}
fs.writeFileSync(outputPath, `${JSON.stringify({ schemaVersion: 1, spells: [...spells.values()] })}\n`, "utf8");
const previous = JSON.parse(fs.readFileSync(outputPath, "utf8"));
const classicSpells = previous.classicSpells ?? previous.spells;
fs.writeFileSync(outputPath, `${JSON.stringify({
schemaVersion: 1,
source: { spellDbcPath: "client-current/patch-T/DBFilesClient/Spell.dbc", sha256: createHash("sha256").update(spellBytes).digest("hex") },
spells: [...spells.values()].sort((left, right) => left.id - right.id),
classicSpells,
})}\n`, "utf8");
console.log(`Wrote ${outputPath} with ${spells.size} triggered spell records.`);
+6 -2
View File
@@ -13,7 +13,8 @@ const wow335GeneratedPath = path.join(projectRoot, "src", "game", "wow335Ability
const manastormAffixesPath = path.join(projectRoot, "src", "game", "generated", "manastormAffixes.json");
const manastormRuntimePath = path.join(projectRoot, "src", "game", "generated", "manastormRuntimeCatalog.json");
const defaultSource = path.resolve(projectRoot, "..", "LadiksMPQEditor", "patch-I", "Interface", "Icons");
const sourceDirectory = path.resolve(process.argv[2] ?? defaultSource);
const sourceDirectory = path.resolve(process.argv.slice(2).find((argument) => !argument.startsWith("--")) ?? defaultSource);
const missingOnly = process.argv.includes("--missing-only");
const outputDirectory = path.join(projectRoot, "public", "assets", "ui", "spells");
const catalogSources = catalogPaths.map((catalogPath) => fs.readFileSync(catalogPath, "utf8"));
@@ -278,8 +279,10 @@ function encodePng({ width, height, pixels }) {
fs.mkdirSync(outputDirectory, { recursive: true });
const fallbackSourcePath = path.join(sourceDirectory, "inv_misc_questionmark.blp");
let fallbacks = 0;
let exported = 0;
const fallbackDetails = [];
for (const basename of iconBasenames) {
if (missingOnly && fs.existsSync(path.join(outputDirectory, `${basename}.png`))) continue;
const sourcePath = path.join(sourceDirectory, `${basename}.blp`);
const missingSource = !fs.existsSync(sourcePath);
const resolvedSourcePath = missingSource ? fallbackSourcePath : sourcePath;
@@ -297,10 +300,11 @@ for (const basename of iconBasenames) {
png = encodePng(decodeBlp2(fs.readFileSync(fallbackSourcePath), fallbackSourcePath));
}
fs.writeFileSync(path.join(outputDirectory, `${basename}.png`), png);
exported += 1;
}
console.log(JSON.stringify({
exported: iconBasenames.length,
exported,
fallbacks,
fallbackDetails,
sourceDirectory,
+5 -2
View File
@@ -17,6 +17,7 @@ import { TALENT_NODES, TALENT_TREES } from "../src/game/talentCatalog";
const projectRoot = resolve(import.meta.dirname, "..");
const talentOutputDirectory = resolve(projectRoot, "src/game/generated/talentUi");
const coaAbilityOutputDirectory = resolve(projectRoot, "src/game/generated/coaAbilities");
const coaOnly = process.argv.includes("--coa-only");
function writeJson(file: string, value: unknown): void {
writeFileSync(file, `${JSON.stringify(value)}\n`, "utf8");
@@ -33,9 +34,10 @@ function removeStaleJson(directory: string, expectedNames: ReadonlySet<string>):
mkdirSync(talentOutputDirectory, { recursive: true });
const talentClassIds = new Set<ClassId>(TALENT_TREES.map((tree) => tree.classId));
const talentFileNames = new Set([...talentClassIds].map((classId) => `${classId}.json`));
removeStaleJson(talentOutputDirectory, talentFileNames);
if (!coaOnly) removeStaleJson(talentOutputDirectory, talentFileNames);
for (const classId of talentClassIds) {
if (coaOnly && !COA_CLASS_IDS.includes(classId as CoaClassId)) continue;
const trees = TALENT_TREES.filter((tree) => tree.classId === classId).map((tree) => ({
id: tree.id,
classId: tree.classId,
@@ -103,4 +105,5 @@ for (const classId of COA_CLASS_IDS) {
});
}
console.log(`Generated ${talentFileNames.size} talent UI shards and ${coaAbilityFileNames.size} Conquest ability shards from ${basename(coaAbilityInput)}.`);
const writtenTalentCount = coaOnly ? [...talentClassIds].filter((classId) => COA_CLASS_IDS.includes(classId as CoaClassId)).length : talentFileNames.size;
console.log(`Generated ${writtenTalentCount} talent UI shards and ${coaAbilityFileNames.size} Conquest ability shards from ${basename(coaAbilityInput)}.`);
+26 -3
View File
@@ -1,4 +1,4 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { CLASSES, type CoaClassId } from "../src/app/characterCatalog";
import {
@@ -40,7 +40,7 @@ const coaBudgetsByClass = Object.fromEntries(CLASSES
}];
}));
writeJson("src/game/generated/talentRuntimeCatalog.json", {
const talentSnapshot = {
schemaVersion: 1,
maximumCoaLevel: COA_LIVE_SOURCE.maximumLevel,
coaBudgetsByClass,
@@ -62,4 +62,27 @@ writeJson("src/game/generated/talentRuntimeCatalog.json", {
requiredLevel: node.requiredLevel,
isPassive: node.isPassive,
})),
});
};
if (process.argv.includes("--coa-only")) {
const previous = JSON.parse(readFileSync("src/game/generated/talentRuntimeCatalog.json", "utf8"));
const coaClasses = new Set(CLASSES.filter((definition) => definition.mode === "conquest").map((definition) => definition.id));
const coaTrees = new Set(talentSnapshot.trees.filter((tree) => coaClasses.has(tree.classId)).map((tree) => tree.id));
function replaceCoa<T>(oldRecords: T[], newRecords: T[], isCoa: (record: T) => boolean): T[] {
const result: T[] = [];
let inserted = false;
for (const record of oldRecords) {
if (!isCoa(record)) result.push(record);
else if (!inserted) {
result.push(...newRecords.filter(isCoa));
inserted = true;
}
}
if (!inserted) result.push(...newRecords.filter(isCoa));
return result;
}
talentSnapshot.trees = replaceCoa(previous.trees, talentSnapshot.trees, (tree) => coaClasses.has(tree.classId));
talentSnapshot.nodes = replaceCoa(previous.nodes, talentSnapshot.nodes, (node) => coaTrees.has(node.treeId));
}
writeJson("src/game/generated/talentRuntimeCatalog.json", talentSnapshot);
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { MpqArchive } = require("stormlib-js");
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultArchive = path.resolve(projectRoot, "..", "wow335a", "Data", "enUS", "patch-enUS.MPQ");
const defaultOutput = path.join(projectRoot, "src", "game", "generated", "wow335PetFamilyData.json");
const archivePath = path.resolve(process.argv[2] ?? defaultArchive);
const outputPath = path.resolve(process.argv[3] ?? defaultOutput);
function assertDbc(buffer, name, minimumFields) {
if (buffer.toString("ascii", 0, 4) !== "WDBC") throw new Error(`${name} is not WDBC data.`);
const fieldCount = buffer.readUInt32LE(8);
const recordSize = buffer.readUInt32LE(12);
if (fieldCount < minimumFields || recordSize < fieldCount * 4) {
throw new Error(`${name} has an unsupported schema.`);
}
}
function rowCount(buffer) {
return buffer.readUInt32LE(4);
}
function recordSize(buffer) {
return buffer.readUInt32LE(12);
}
function recordBase(index, size) {
return 20 + index * size;
}
function textAt(buffer, base, firstField, shift = 0) {
const count = rowCount(buffer);
const stringsOffset = 20 + count * recordSize(buffer);
const stringSize = buffer.readUInt32LE(16);
for (let locale = 0; locale < 16; locale += 1) {
const offset = buffer.readUInt32LE(base + (firstField + shift + locale) * 4);
if (!offset || offset >= stringSize) continue;
const start = stringsOffset + offset;
const end = buffer.indexOf(0, start);
const value = buffer.toString("utf8", start, end < 0 ? buffer.length : end);
if (value) return value;
}
return "";
}
function spellRows(buffer, durations) {
assertDbc(buffer, "Spell.dbc", 234);
const count = rowCount(buffer);
const size = recordSize(buffer);
const fields = buffer.readUInt32LE(8);
const shift = fields >= 239 ? 6 : 0;
const shifted = (field) => field >= 77 ? field + shift : field;
const rows = new Map();
for (let index = 0; index < count; index += 1) {
const base = recordBase(index, size);
const unsigned = (field) => buffer.readUInt32LE(base + field * 4);
const signed = (field) => buffer.readInt32LE(base + field * 4);
const float = (field) => buffer.readFloatLE(base + field * 4);
const effects = Array.from({ length: 3 }, (_, effectIndex) => {
const effectType = unsigned(71 + effectIndex);
const auraType = unsigned(shifted(95 + effectIndex));
const basePoints = signed(shifted(80 + effectIndex)) + 1;
const triggerSpellId = unsigned(shifted(116 + effectIndex));
return {
index: effectIndex,
effectType,
auraType,
mechanic: unsigned(shifted(83 + effectIndex)),
basePoints,
dieSides: signed(74 + effectIndex),
pointsPerLevel: float(shifted(77 + effectIndex)),
periodMs: unsigned(shifted(98 + effectIndex)),
miscValue: signed(shifted(110 + effectIndex)),
triggerSpellId,
coefficient: float(shifted(229 + effectIndex)),
};
}).filter((effect) => effect.effectType || effect.auraType || effect.basePoints !== 1 || effect.triggerSpellId);
const attributes0 = unsigned(4);
rows.set(unsigned(0), {
spellId: unsigned(0),
name: textAt(buffer, base, 136, shift),
rankText: textAt(buffer, base, 153, shift),
mechanic: unsigned(3),
requiredLevel: unsigned(39),
passive: Boolean(attributes0 & 0x40),
attributesEx1: unsigned(5),
powerType: signed(41),
powerCost: unsigned(42),
cooldownMs: unsigned(29),
categoryCooldownMs: unsigned(30),
globalCooldownMs: unsigned(shifted(206)),
auraInterruptFlags: unsigned(32),
schoolMask: unsigned(shifted(225)),
procFlags: unsigned(34),
procChance: unsigned(35),
procCharges: unsigned(36),
durationMs: durations.get(unsigned(40)) ?? 0,
effects,
});
}
return rows;
}
function durationTable(buffer) {
assertDbc(buffer, "SpellDuration.dbc", 4);
const durations = new Map();
const size = recordSize(buffer);
for (let index = 0; index < rowCount(buffer); index += 1) {
const base = recordBase(index, size);
durations.set(buffer.readUInt32LE(base), buffer.readInt32LE(base + 3 * 4));
}
return durations;
}
function familyRows(buffer) {
assertDbc(buffer, "CreatureFamily.dbc", 28);
const families = [];
const size = recordSize(buffer);
for (let index = 0; index < rowCount(buffer); index += 1) {
const base = recordBase(index, size);
const minScale = buffer.readFloatLE(base + 1 * 4);
const maxScale = buffer.readFloatLE(base + 3 * 4);
const petTalentType = buffer.readInt32LE(base + 8 * 4);
const name = textAt(buffer, base, 10);
if (!name) continue;
families.push({
id: buffer.readUInt32LE(base),
name,
minScale,
minScaleLevel: buffer.readUInt32LE(base + 2 * 4),
maxScale,
maxScaleLevel: buffer.readUInt32LE(base + 4 * 4),
petFoodMask: buffer.readUInt32LE(base + 7 * 4),
petTalentType,
familySkillLineId: buffer.readUInt32LE(base + 5 * 4),
commonPetSkillLineId: buffer.readUInt32LE(base + 6 * 4),
categoryEnumId: buffer.readUInt32LE(base + 9 * 4),
});
}
return families;
}
function skillRows(buffer, lineIds, spells) {
assertDbc(buffer, "SkillLineAbility.dbc", 14);
const result = new Map([...lineIds].map((id) => [id, []]));
const size = recordSize(buffer);
for (let index = 0; index < rowCount(buffer); index += 1) {
const base = recordBase(index, size);
const lineId = buffer.readUInt32LE(base + 1 * 4);
if (!result.has(lineId)) continue;
const spellId = buffer.readUInt32LE(base + 2 * 4);
const spell = spells.get(spellId);
if (!spell) continue;
result.get(lineId).push({
...spell,
acquireMethod: buffer.readUInt32LE(base + 9 * 4),
raceMask: buffer.readUInt32LE(base + 3 * 4),
classMask: buffer.readUInt32LE(base + 4 * 4),
minSkill: buffer.readUInt32LE(base + 7 * 4),
supersededBySpellId: buffer.readUInt32LE(base + 8 * 4),
});
}
for (const rows of result.values()) {
rows.sort((left, right) => left.requiredLevel - right.requiredLevel || left.spellId - right.spellId);
}
return result;
}
const archive = MpqArchive.open(archivePath);
let output;
try {
const extract = (name) => archive.extractFile(`DBFilesClient\\${name}.dbc`);
const familyBuffer = extract("CreatureFamily");
const skillLineBuffer = extract("SkillLineAbility");
const spellBuffer = extract("Spell");
const durations = durationTable(extract("SpellDuration"));
const spells = spellRows(spellBuffer, durations);
const families = familyRows(familyBuffer);
const lineIds = new Set(families.flatMap((family) => [family.familySkillLineId, family.commonPetSkillLineId]).filter(Boolean));
const skills = skillRows(skillLineBuffer, lineIds, spells);
output = {
source: {
product: "World of Warcraft",
build: "3.3.5a.12340",
archive: path.relative(projectRoot, archivePath).replaceAll("\\", "/"),
familyRows: rowCount(familyBuffer),
skillLineAbilityRows: rowCount(skillLineBuffer),
spellRows: rowCount(spellBuffer),
},
families: families.map((family) => ({
...family,
hunterPet: family.petTalentType >= 0,
familySkills: skills.get(family.familySkillLineId) ?? [],
commonPetSkills: skills.get(family.commonPetSkillLineId) ?? [],
})),
};
} finally {
archive.close();
}
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, `${JSON.stringify(output)}\n`);
console.log(`Generated ${output.families.filter((family) => family.hunterPet).length} Hunter pet family records at ${outputPath}.`);
+621
View File
@@ -0,0 +1,621 @@
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 directory = path.resolve(process.env.HUNTER_CAT_PLAYTEST_DIR || 'playtest-artifacts/hunter-cat');
const staticDir = path.resolve('dist');
const contentDir = path.resolve('content');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-hunter-cat-'));
function cleanupDataDir() {
const resolvedDataDir = path.resolve(dataDir);
const resolvedTempDir = path.resolve(os.tmpdir());
if (path.dirname(resolvedDataDir) !== resolvedTempDir
|| !path.basename(resolvedDataDir).startsWith('healer-man-hunter-cat-')) {
throw new Error(`Refusing recursive cleanup outside the dedicated Hunter QA temp directory: ${resolvedDataDir}`);
}
fs.rmSync(resolvedDataDir, { recursive: true, force: true });
}
const assetDirectory = path.join(staticDir, 'assets');
if (!fs.existsSync(path.join(staticDir, 'index.html'))) {
cleanupDataDir();
throw new Error('A fresh production build is required before this playtest. Build dist, then rerun.');
}
const assets = fs.readdirSync(assetDirectory);
const mainModule = `/assets/${assets.find((file) => /^index-.*\.js$/.test(file))}`;
const combatModule = `/assets/${assets.find((file) => /^combatStore-.*\.js$/.test(file))}`;
if (mainModule.endsWith('undefined') || combatModule.endsWith('undefined')) {
cleanupDataDir();
throw new Error('Could not locate the production app and combat store modules in dist/assets.');
}
fs.mkdirSync(directory, { recursive: true });
async function storeSnapshot(page) {
return page.evaluate(async () => {
const { shell, combat, game } = await window.hunterFamilyStores();
const roster = shell.getState();
const runtime = combat.getState();
const activePet = runtime.summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__');
const activeProfile = roster.characters.find((character) => character.id === roster.selectedCharacterId);
return {
phase: roster.phase,
selectedCharacter: activeProfile ? {
name: activeProfile.name,
classId: activeProfile.classId,
level: activeProfile.level,
specializationId: activeProfile.specializationId,
hunterPetFamily: activeProfile.hunterPetFamily ?? null,
} : null,
combat: {
classId: runtime.classId,
level: runtime.level,
family: runtime.hunterPetFamily,
activePet: activePet ? {
id: activePet.id,
ownerActorId: activePet.ownerActorId,
name: activePet.name,
family: activePet.hunterPet.family,
alive: activePet.health > 0,
health: activePet.health,
primary: {
name: activePet.hunterPet.abilityName,
spellId: activePet.hunterPet.spellId,
rank: activePet.hunterPet.rank,
},
rake: activePet.hunterPet.rake ? {
spellId: activePet.hunterPet.rake.spellId,
rank: activePet.hunterPet.rake.rank,
requiredLevel: activePet.hunterPet.rake.requiredLevel,
} : null,
prowl: activePet.hunterPet.prowl ? {
spellId: activePet.hunterPet.prowl.spellId,
rank: activePet.hunterPet.prowl.rank,
requiredLevel: activePet.hunterPet.prowl.requiredLevel,
} : null,
shellShieldSpellId: activePet.hunterPet.shellShield?.spellId ?? null,
furiousHowlSpellId: activePet.hunterPet.furiousHowl?.spellId ?? null,
command: activePet.hunterPetCommand ?? 'follow',
reactMode: activePet.hunterPetReactMode ?? 'defensive',
growlAutocast: Boolean(activePet.hunterPetGrowlAutocastEnabled),
} : null,
},
assetStatus: game.getState().assetStatus,
};
});
}
async function waitForGameReady(page) {
await page.locator('.game-shell').waitFor({ timeout: 120_000 });
const isRenderedAndReady = async () => page.evaluate(async () => {
const { game } = await window.hunterFamilyStores();
const overlay = document.querySelector('.loading-screen');
const style = overlay ? getComputedStyle(overlay) : null;
const overlayVisible = Boolean(overlay && style?.display !== 'none' && style?.visibility !== 'hidden'
&& overlay.getBoundingClientRect().width > 0 && overlay.getBoundingClientRect().height > 0);
const canvas = document.querySelector('canvas');
return ['ready', 'proxy'].includes(game.getState().assetStatus)
&& !overlayVisible
&& Boolean(canvas && canvas.width > 0 && canvas.height > 0 && canvas.dataset.engine);
});
await page.waitForFunction(async () => {
const { game } = await window.hunterFamilyStores();
const overlay = document.querySelector('.loading-screen');
const style = overlay ? getComputedStyle(overlay) : null;
const overlayVisible = Boolean(overlay && style?.display !== 'none' && style?.visibility !== 'hidden'
&& overlay.getBoundingClientRect().width > 0 && overlay.getBoundingClientRect().height > 0);
const canvas = document.querySelector('canvas');
return ['ready', 'proxy'].includes(game.getState().assetStatus)
&& !overlayVisible
&& Boolean(canvas && canvas.width > 0 && canvas.height > 0 && canvas.dataset.engine);
}, null, { timeout: 180_000 });
await page.waitForTimeout(1_500);
if (!await isRenderedAndReady()) {
await page.waitForFunction(async () => {
const { game } = await window.hunterFamilyStores();
const overlay = document.querySelector('.loading-screen');
const style = overlay ? getComputedStyle(overlay) : null;
const overlayVisible = Boolean(overlay && style?.display !== 'none' && style?.visibility !== 'hidden'
&& overlay.getBoundingClientRect().width > 0 && overlay.getBoundingClientRect().height > 0);
const canvas = document.querySelector('canvas');
return ['ready', 'proxy'].includes(game.getState().assetStatus)
&& !overlayVisible
&& Boolean(canvas && canvas.width > 0 && canvas.height > 0 && canvas.dataset.engine);
}, null, { timeout: 180_000 });
}
await page.locator('canvas').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 180_000 }).catch(() => undefined);
}
async function renderedReadiness(page) {
return page.evaluate(async () => {
const { game } = await window.hunterFamilyStores();
const overlay = document.querySelector('.loading-screen');
const style = overlay ? getComputedStyle(overlay) : null;
const overlayVisible = Boolean(overlay && style?.display !== 'none' && style?.visibility !== 'hidden'
&& overlay.getBoundingClientRect().width > 0 && overlay.getBoundingClientRect().height > 0);
const canvas = document.querySelector('canvas');
return {
assetStatus: game.getState().assetStatus,
dungeonIntroActive: game.getState().dungeonIntroActive,
loadingScreenVisible: overlayVisible,
canvasEngine: canvas?.dataset.engine ?? null,
canvasSize: canvas ? { width: canvas.width, height: canvas.height } : null,
};
});
}
async function createHunter(page, name) {
await page.locator('.character-select-screen').waitFor();
await page.locator('.roster-list').getByRole('button', { name: /Create character/ }).click();
await page.locator('.character-create-screen').waitFor();
await page.locator('.class-grid').getByRole('button', { name: /Hunter/ }).click();
await page.locator('.creator-specializations').getByRole('button', { name: /Marksmanship/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name', { exact: true }).fill(name);
await page.locator('.character-create-screen').getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: new RegExp(`Continue as ${name}`) }).waitFor();
await page.getByRole('button', { name: new RegExp(`Continue as ${name}`) }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
}
async function enterWailingCaverns(page) {
await page.getByRole('button', { name: /Dungeons/ }).click();
await page.locator('.dungeon-select-screen').waitFor();
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
await page.locator('.dungeon-select-row').filter({ hasText: 'Wailing Caverns' }).click();
await page.locator('.dungeon-select-copy h2').getByText(/^Wailing Caverns$/, { exact: true }).waitFor();
await page.getByRole('button', { name: /Choose role/ }).click();
await page.locator('.dungeon-role-option').filter({ hasText: /DPS/ }).first().click();
await waitForGameReady(page);
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
await page.locator('.party-frame').nth(4).waitFor({ timeout: 180_000 });
}
async function capture(page, name, fullPage = false) {
await page.screenshot({ path: path.join(directory, `${name}.png`), fullPage });
console.log(`Captured ${name}`);
}
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir, staticDir, contentDir, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${runtime.server.address().port}`;
let browser;
let page;
const pageErrors = [];
const consoleErrors = [];
const httpErrors = [];
const recentRequests = [];
const keyEvents = [];
try {
browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, locale: 'en-US' });
page = await context.newPage();
page.setDefaultTimeout(90_000);
page.on('pageerror', (error) => pageErrors.push(error.message));
page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push({ text: message.text(), location: message.location() }); });
page.on('request', (request) => {
recentRequests.push({ method: request.method(), url: request.url() });
if (recentRequests.length > 100) recentRequests.shift();
});
page.on('response', (response) => { if (response.status() >= 400) httpErrors.push({ status: response.status(), url: response.url() }); });
page.on('requestfailed', (request) => httpErrors.push({ status: 'failed', url: request.url(), failure: request.failure()?.errorText }));
await page.addInitScript(({ mainModule, combatModule }) => {
window.__hunterKeyEvents = [];
window.addEventListener('keydown', (event) => {
window.__hunterKeyEvents.push({ key: event.key, code: event.code, target: event.target?.nodeName ?? null });
}, true);
window.hunterFamilyStores = async () => {
const exports = [...Object.values(await import(mainModule)), ...Object.values(await import(combatModule))];
const stateOf = (value) => { try { return value?.getState?.(); } catch { return undefined; } };
return {
shell: exports.find((value) => stateOf(value) && 'characters' in stateOf(value) && 'selectedCharacterId' in stateOf(value)),
combat: exports.find((value) => stateOf(value) && 'castAbility' in stateOf(value) && 'summons' in stateOf(value)),
game: exports.find((value) => stateOf(value) && 'assetStatus' in stateOf(value) && 'openOverlay' in stateOf(value)),
simulation: exports.find((value) => stateOf(value) && 'sessionMode' in stateOf(value) && 'runState' in stateOf(value)),
biteSizedLayout: exports.find((value) => stateOf(value) && 'panelFocused' in stateOf(value) && 'editorMode' in stateOf(value)),
};
};
}, { mainModule, combatModule });
await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.getByRole('button', { name: /Play offline/ }).click();
await page.locator('.character-select-screen').waitFor();
await createHunter(page, 'Catguard');
await page.getByRole('button', { name: 'Change character', exact: true }).click();
await createHunter(page, 'Wolfguard');
// QA-only eligibility seed: after normal UI creation, advance both temporary
// local profiles to level 10 through the repository-backed progression API.
// Family selection and persistence are exercised only through the rendered UI.
const seededProfiles = await page.evaluate(async () => {
const { shell } = await window.hunterFamilyStores();
const names = ['Catguard', 'Wolfguard'];
const seeded = [];
for (const name of names) {
const profile = shell.getState().characters.find((character) => character.name === name);
if (!profile) throw new Error(`Missing UI-created test character ${name}.`);
shell.setState({ activeCharacter: profile });
shell.getState().saveActiveProgression(10, 0, profile.talentRanks, profile.glyphIds);
shell.setState({ activeCharacter: null });
const saved = shell.getState().characters.find((character) => character.id === profile.id);
if (saved?.level !== 10) throw new Error(`${name} did not reach the level-10 QA fixture.`);
seeded.push({ name, level: saved.level, family: saved.hunterPetFamily ?? 'wolf' });
}
return seeded;
});
assert.deepEqual(seededProfiles, [
{ name: 'Catguard', level: 10, family: 'wolf' },
{ name: 'Wolfguard', level: 10, family: 'wolf' },
]);
await page.getByRole('button', { name: 'Change character', exact: true }).click();
await page.locator('.character-select-screen').waitFor();
await page.locator('.roster-character').filter({ hasText: 'Catguard' }).click();
await page.getByRole('button', { name: /Continue as Catguard/ }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
await enterWailingCaverns(page);
let selector = page.locator('.hunter-pet-controls select[aria-label="Pet family"]');
await selector.waitFor();
const bossIntro = page.locator('.dungeon-boss-intro');
const bossIntroObserved = await bossIntro.waitFor({ state: 'visible', timeout: 12_000 }).then(() => true, () => false);
if (bossIntroObserved) await bossIntro.waitFor({ state: 'detached', timeout: 30_000 });
await waitForGameReady(page);
await page.waitForTimeout(300);
const entranceLock = {
disabled: await selector.isDisabled(),
selected: await selector.inputValue(),
engagedMobs: await page.evaluate(async () => {
const { combat } = await window.hunterFamilyStores();
return Object.values(combat.getState().mobs)
.filter((mob) => mob.engaged && !mob.dead && !mob.despawned)
.map((mob) => ({ id: mob.id, name: mob.name }));
}),
};
assert.equal(entranceLock.selected, 'wolf', 'A new eligible Hunter enters with its default Wolf.');
if (entranceLock.disabled) {
await capture(page, '01-wailing-entry-combat-locked-desktop');
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(350);
await capture(page, '02-wailing-entry-combat-locked-narrow');
await page.setViewportSize({ width: 1440, height: 1000 });
}
// Entrance mobs must die through the ordinary party simulation before the
// OOC family selector is used. This bounded wait never changes encounter state.
await page.waitForFunction(() => {
const select = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
return Boolean(select && !select.disabled);
}, null, { timeout: 60_000 });
assert.equal(await selector.isEnabled(), true, 'Family selection unlocks naturally when the entrance encounter ends.');
const outOfCombatMobs = await page.evaluate(async () => {
const { combat } = await window.hunterFamilyStores();
return Object.values(combat.getState().mobs)
.filter((mob) => mob.engaged && !mob.dead && !mob.despawned)
.map((mob) => ({ id: mob.id, name: mob.name }));
});
assert.deepEqual(outOfCombatMobs, [], 'Cat is selected only after the actual encounter has ended.');
const familyPointerTarget = await selector.evaluate((element) => {
const bounds = element.getBoundingClientRect();
const hit = document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2);
return { hitTag: hit?.tagName ?? null, hitClassName: hit?.className?.toString?.() ?? null,
hitControls: Boolean(hit?.closest('.hunter-pet-controls') === element.closest('.hunter-pet-controls')) };
});
assert.equal(familyPointerTarget.hitControls, true, 'The visible family selector receives normal pointer hit testing.');
await selector.click();
await page.keyboard.press('End');
await page.keyboard.press('Enter');
await page.waitForFunction(async () => {
const { shell, combat } = await window.hunterFamilyStores();
const profile = shell.getState().characters.find((character) => character.name === 'Catguard');
const pet = combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__');
return profile?.hunterPetFamily === 'cat'
&& combat.getState().hunterPetFamily === 'cat'
&& pet?.name === 'Cat'
&& pet.hunterPet.family === 'cat'
&& pet.hunterPet.abilityName === 'Claw'
&& pet.hunterPet.rake?.spellId === 59881
&& pet.hunterPet.prowl?.spellId === 24450
&& !pet.hunterPet.furiousHowl
&& !pet.hunterPet.shellShield;
}, null, { timeout: 10_000 });
let current = await storeSnapshot(page);
assert.equal(current.selectedCharacter?.hunterPetFamily, 'cat');
assert.equal(current.combat.activePet?.id, '__player__:summon-1');
assert.equal(current.combat.activePet?.ownerActorId, '__player__');
assert.deepEqual(current.combat.activePet?.primary, { name: 'Claw', spellId: 16_828, rank: 2 });
assert.deepEqual(current.combat.activePet?.rake, { spellId: 59_881, rank: 1, requiredLevel: 1 });
assert.deepEqual(current.combat.activePet?.prowl, { spellId: 24_450, rank: 1, requiredLevel: 1 });
assert.equal(current.combat.activePet?.furiousHowlSpellId, null);
assert.equal(current.combat.activePet?.shellShieldSpellId, null);
await waitForGameReady(page);
const renderReadyProof = await renderedReadiness(page);
assert.ok(['ready', 'proxy'].includes(renderReadyProof.assetStatus), 'Dungeon assets are ready before visual capture.');
assert.equal(renderReadyProof.loadingScreenVisible, false, 'The actual loading screen is hidden before visual capture.');
assert.ok(renderReadyProof.canvasEngine, 'The rendered 3D canvas is present before visual capture.');
const canvasSize = renderReadyProof.canvasSize;
assert.ok(canvasSize.width > 0 && canvasSize.height > 0, 'The rendered dungeon has an active WebGL canvas.');
await page.setViewportSize({ width: 1440, height: 1000 });
await capture(page, '03-cat-family-desktop');
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(350);
const narrowLayout = await page.evaluate(() => {
const selector = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
const petFrame = selector?.closest('.summon-frame');
const partyFrames = document.querySelector('.party-frames');
const topRightHud = document.querySelector('.hud-top-right');
const rect = (element) => {
if (!element) return null;
const { top, right, bottom, left, width, height } = element.getBoundingClientRect();
return { top, right, bottom, left, width, height };
};
const overlaps = (first, second) => Boolean(first && second
&& first.left < second.right && first.right > second.left
&& first.top < second.bottom && first.bottom > second.top);
const controlRect = rect(selector);
const petRect = rect(petFrame);
const partyRect = rect(partyFrames);
const topRightRect = rect(topRightHud);
return {
width: innerWidth,
documentWidth: document.documentElement.scrollWidth,
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
selectorVisibleInViewport: Boolean(controlRect && controlRect.width > 0 && controlRect.height > 0
&& controlRect.left >= 0 && controlRect.right <= innerWidth
&& controlRect.top >= 0 && controlRect.bottom <= innerHeight),
partyFrameRect: partyRect,
petFrameRect: petRect,
selectorRect: controlRect,
partyClearance: partyRect && petRect ? petRect.top - partyRect.bottom : null,
overlapsPartyFrames: overlaps(petRect, partyRect),
overlapsTopRightHud: overlaps(petRect, topRightRect),
};
});
assert.equal(narrowLayout.horizontalOverflow, false, 'The narrow HUD has no horizontal overflow.');
assert.equal(narrowLayout.selectorVisibleInViewport, true, 'The narrow family selector remains visible.');
assert.ok(narrowLayout.partyClearance >= 8, 'The player pet card sits below the party stack with clearance.');
assert.equal(narrowLayout.overlapsTopRightHud, false, 'The player pet card does not overlap buffs/debuffs or minimap HUD.');
await capture(page, '04-cat-family-narrow');
const petControls = page.locator('.summon-frame--summon .hunter-pet-controls');
const petCommands = petControls.getByRole('group', { name: 'Pet command' });
const stayButton = petCommands.getByRole('button', { name: 'stay', exact: true });
const followButton = petCommands.getByRole('button', { name: 'follow', exact: true });
const attackButton = petCommands.getByRole('button', { name: 'attack', exact: true });
assert.equal(await petControls.count(), 1, 'The active player-owned Cat exposes its rendered controls.');
const commandPointerTarget = await petControls.evaluate((element) => {
const button = element.querySelector('button[aria-pressed="false"]');
if (!button) return null;
const bounds = button.getBoundingClientRect();
const hit = document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2);
return { button: button.textContent?.trim(), hitTag: hit?.tagName ?? null,
hitClassName: hit?.className?.toString?.() ?? null,
hitControls: Boolean(hit?.closest('.hunter-pet-controls') === element) };
});
assert.equal(commandPointerTarget.hitControls, true, 'Visible command buttons receive normal pointer hit testing.');
const attackDisabledWithoutTarget = await attackButton.isDisabled();
assert.equal(attackDisabledWithoutTarget, true, 'Attack is visibly unavailable without a selected hostile target.');
await stayButton.click();
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetCommand === 'stay';
}, null, { timeout: 5_000 });
await followButton.click();
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetCommand === 'follow';
}, null, { timeout: 5_000 });
const reactSelector = petControls.getByLabel('Pet reaction');
await reactSelector.click();
await page.keyboard.press('End');
await page.keyboard.press('Enter');
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetReactMode === 'passive';
}, null, { timeout: 5_000 });
await reactSelector.click();
await page.keyboard.press('ArrowUp');
await page.keyboard.press('Enter');
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetReactMode === 'aggressive';
}, null, { timeout: 5_000 });
await reactSelector.click();
await page.keyboard.press('Home');
await page.keyboard.press('Enter');
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetReactMode === 'defensive';
}, null, { timeout: 5_000 });
const growlToggle = petControls.locator('.hunter-pet-controls__growl input[type="checkbox"]');
await growlToggle.click();
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetGrowlAutocastEnabled === true;
}, null, { timeout: 5_000 });
await growlToggle.click();
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetGrowlAutocastEnabled === false;
}, null, { timeout: 5_000 });
const commandReactProof = {
rendered: await petControls.count() === 1,
pointerActivation: 'Stay, follow, reaction changes, and Growl autocast were exercised on the rendered controls after hit-testing them against the controls card.',
pointerHitTest: commandPointerTarget,
staySelected: await stayButton.getAttribute('aria-pressed'),
followSelectedAfterRestore: await followButton.getAttribute('aria-pressed'),
attackDisabledWithoutTarget,
reactionValuesTested: ['passive', 'aggressive', 'defensive'],
growlAutocastToggledAndRestored: true,
restoredReaction: await reactSelector.inputValue(),
};
// Try the ordinary Escape -> pause menu -> Spellbook route once. If it does
// not open in this input context, stop instead of calling store actions.
const spellRoute = { escapePressed: false, pauseOpened: false, spellbookOpened: false, callPet: 'not attempted' };
await page.setViewportSize({ width: 1440, height: 1000 });
await page.locator('.scene-shell').focus().catch(() => undefined);
await page.keyboard.press('Escape').catch(() => undefined);
spellRoute.escapePressed = true;
spellRoute.pauseOpened = await page.locator('.pause-menu').isVisible().catch(() => false);
if (spellRoute.pauseOpened) {
await page.getByRole('button', { name: 'Spellbook', exact: true }).click();
const spellbook = page.locator('.spellbook-panel');
await spellbook.waitFor({ timeout: 5_000 });
spellRoute.spellbookOpened = true;
const callPetAbility = spellbook.locator('.spellbook-ability').filter({ hasText: /^Call Pet/ }).first();
if (await callPetAbility.count()) {
const label = await callPetAbility.getAttribute('aria-label');
if (label?.startsWith('Call Pet, unlocked')) {
await callPetAbility.click();
await page.waitForTimeout(600);
const afterCall = await storeSnapshot(page);
spellRoute.callPet = {
attemptedThroughRenderedSpellbook: true,
abilityLabel: label,
petId: afterCall.combat.activePet?.id ?? null,
family: afterCall.combat.activePet?.family ?? null,
overlayAfterClick: await page.locator('.spellbook-panel').isVisible().catch(() => false) ? 'spellbook' : 'closed',
};
} else spellRoute.callPet = { attemptedThroughRenderedSpellbook: false, abilityLabel: label ?? null };
} else spellRoute.callPet = { attemptedThroughRenderedSpellbook: false, reason: 'No Call Pet entry in the rendered spellbook.' };
} else {
spellRoute.callPet = { attemptedThroughRenderedSpellbook: false, reason: 'Escape did not expose the pause menu in this gameplay route.' };
}
spellRoute.revivePet = 'Not attempted: the live pet remained alive; no ordinary UI route to a naturally dead pet occurred.';
await page.setViewportSize({ width: 1440, height: 1000 });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.locator('.character-select-screen').waitFor();
let afterReload = await page.evaluate(async () => {
const { shell } = await window.hunterFamilyStores();
return shell.getState().characters.map((character) => ({
name: character.name,
family: character.hunterPetFamily ?? null,
level: character.level,
}));
});
assert.equal(afterReload.find((profile) => profile.name === 'Catguard')?.family, 'cat');
assert.equal(afterReload.find((profile) => profile.name === 'Wolfguard')?.family, 'wolf');
assert.deepEqual(pageErrors, [], 'No new browser page errors occurred.');
const unexpectedConsoleErrors = consoleErrors.filter((entry) => !entry.location.url.endsWith('/favicon.ico'));
assert.deepEqual(unexpectedConsoleErrors, [], 'No browser console errors beyond the local server’s missing favicon occurred.');
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({
passed: true,
server: 'ephemeral localhost server with disposable OS-temp data directory and isolated Playwright context',
characterSeed: 'Two Marksmanship Hunters created through the normal UI; both advanced to level 10 through shellStore.saveActiveProgression in the isolated QA fixture so the level-10 family controls and Cat skill ranks could be inspected. This is eligibility setup, not earned progression.',
scope: 'Manual Hunter Cat family UI diagnostic only in disposable Wailing Caverns. Not a Hunter balance certification or Ready-party certification.',
familySelectionProof: 'Cat was selected through the rendered Pet family select while the live runtime reported no engaged mobs; the selected character’s saved value was re-read after page reload, and a separate UI-created Hunter remained Wolf.',
combatLockProof: entranceLock.disabled
? 'At entry, live Wailing Caverns mobs were engaged and the rendered family selector was disabled; later it enabled only after the real encounter ended.'
: 'The Cat run’s first observation came after the entrance pack had ended; this run did not capture an in-combat Cat-family lock state. Earlier Hunter family QA covered the lock with Wolf/Turtle.',
callAndRevive: spellRoute,
commandReactProof,
catAbilityProof: 'The active player-owned pet runtime profile reports Claw 16828 rank 2, Rake 59881 rank 1, and Prowl 24450 rank 1 at level 10. It has neither Bite, Furious Howl, nor Shell Shield. This profile/readiness evidence does not prove live Rake or Prowl casts.',
visual: { renderReadyProof, bossIntroObserved, screenshots: [
...(entranceLock.disabled ? [
'01-wailing-entry-combat-locked-desktop.png',
'02-wailing-entry-combat-locked-narrow.png',
] : []),
'03-cat-family-desktop.png',
'04-cat-family-narrow.png',
] },
entranceLock,
outOfCombatMobs,
narrowLayout,
afterReload: afterReload,
finalState: current,
secondaryCharacter: afterReload.find((profile) => profile.name === 'Wolfguard'),
pageErrors,
consoleErrors,
httpErrors,
}, null, 2)}\n`);
console.log(`Hunter Cat browser playtest passed. Evidence: ${directory}`);
} catch (error) {
if (page) {
await page.screenshot({ path: path.join(directory, 'failure.png'), fullPage: true }).catch(() => undefined);
console.log((await page.locator('body').innerText().catch(() => '')).slice(-2_000));
}
console.log('Browser page errors:', pageErrors);
console.log('Browser console errors:', consoleErrors);
console.log('HTTP errors:', httpErrors);
console.log('Recent requests:', recentRequests.slice(-30));
const diagnostic = await page?.evaluate(async () => {
const { shell, combat, game, simulation, biteSizedLayout } = await window.hunterFamilyStores();
const runtime = combat.getState();
const familySelector = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
return {
keyEvents: window.__hunterKeyEvents ?? [],
focusedElement: { tag: document.activeElement?.tagName, className: document.activeElement?.className },
game: { overlay: game.getState().overlay, dungeonIntroActive: game.getState().dungeonIntroActive, assetStatus: game.getState().assetStatus },
inputContext: {
sessionMode: simulation?.getState().sessionMode ?? null,
runState: simulation?.getState().runState ?? null,
biteSizedPanelFocused: biteSizedLayout?.getState().panelFocused ?? null,
biteSizedEditorMode: biteSizedLayout?.getState().editorMode ?? null,
},
sessionMode: window.localStorage.getItem('healer-man-dungeon-simulation'),
familySelector: familySelector ? {
value: familySelector.value,
disabled: familySelector.disabled,
visible: Boolean(familySelector.getClientRects().length),
} : null,
engagedMobs: Object.values(runtime.mobs).filter((mob) => mob.engaged && !mob.dead && !mob.despawned).map((mob) => ({ id: mob.id, name: mob.name })),
family: runtime.hunterPetFamily,
pets: runtime.summons.filter((summon) => summon.hunterPet && summon.ownerActorId === '__player__').map((summon) => ({ id: summon.id, kind: summon.kind, name: summon.name, family: summon.hunterPet?.family, alive: summon.health > 0 })),
profiles: shell.getState().characters.map((character) => ({ name: character.name, family: character.hunterPetFamily ?? null, level: character.level })),
};
}).catch((diagnosticError) => ({ error: diagnosticError.message }));
let narrowEvidence = null;
if (page) {
await page.screenshot({ path: path.join(directory, '01-wailing-entry-desktop-diagnostic.png'), fullPage: false }).catch(() => undefined);
await page.setViewportSize({ width: 390, height: 844 }).catch(() => undefined);
await page.waitForTimeout(350).catch(() => undefined);
narrowEvidence = await page.evaluate(() => {
const rect = (element) => {
if (!element) return null;
const { top, right, bottom, left, width, height } = element.getBoundingClientRect();
return { top, right, bottom, left, width, height };
};
const selector = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
const petFrame = selector?.closest('.summon-frame');
const partyFrames = document.querySelector('.party-frames');
const selectorRect = rect(selector);
return {
width: innerWidth,
documentWidth: document.documentElement.scrollWidth,
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
selectorRect,
selectorVisibleInViewport: Boolean(selectorRect && selectorRect.width > 0 && selectorRect.height > 0
&& selectorRect.left >= 0 && selectorRect.right <= innerWidth
&& selectorRect.top >= 0 && selectorRect.bottom <= innerHeight),
partyFrameRect: rect(partyFrames),
petFrameRect: rect(petFrame),
partyClearance: partyFrames && petFrame ? petFrame.getBoundingClientRect().top - partyFrames.getBoundingClientRect().bottom : null,
};
}).catch(() => null);
await page.screenshot({ path: path.join(directory, '02-wailing-entry-narrow-diagnostic.png'), fullPage: false }).catch(() => undefined);
}
const failure = {
passed: false,
status: error.message.includes('party stack') ? 'failed_narrow_hud_layout_assertion' : 'failed_assertion',
error: { name: error.name, message: error.message },
diagnostic,
narrowEvidence,
screenshots: ['failure.png', '01-wailing-entry-desktop-diagnostic.png', '02-wailing-entry-narrow-diagnostic.png'],
consoleErrors,
httpErrors,
recentRequests: recentRequests.slice(-30),
};
fs.writeFileSync(path.join(directory, 'failure.json'), `${JSON.stringify(failure, null, 2)}\n`);
throw error;
} finally {
await browser?.close();
await new Promise((resolve) => runtime.server.close(resolve));
cleanupDataDir();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+449
View File
@@ -0,0 +1,449 @@
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 directory = path.resolve(process.env.HUNTER_FAMILY_PLAYTEST_DIR || 'playtest-artifacts/hunter-families');
const staticDir = path.resolve('dist');
const contentDir = path.resolve('content');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-hunter-families-'));
function cleanupDataDir() {
const resolvedDataDir = path.resolve(dataDir);
const resolvedTempDir = path.resolve(os.tmpdir());
if (path.dirname(resolvedDataDir) !== resolvedTempDir
|| !path.basename(resolvedDataDir).startsWith('healer-man-hunter-families-')) {
throw new Error(`Refusing recursive cleanup outside the dedicated Hunter QA temp directory: ${resolvedDataDir}`);
}
fs.rmSync(resolvedDataDir, { recursive: true, force: true });
}
const assetDirectory = path.join(staticDir, 'assets');
if (!fs.existsSync(path.join(staticDir, 'index.html'))) {
cleanupDataDir();
throw new Error('A fresh production build is required before this playtest. Build dist, then rerun.');
}
const assets = fs.readdirSync(assetDirectory);
const mainModule = `/assets/${assets.find((file) => /^index-.*\.js$/.test(file))}`;
const combatModule = `/assets/${assets.find((file) => /^combatStore-.*\.js$/.test(file))}`;
if (mainModule.endsWith('undefined') || combatModule.endsWith('undefined')) {
cleanupDataDir();
throw new Error('Could not locate the production app and combat store modules in dist/assets.');
}
fs.mkdirSync(directory, { recursive: true });
async function storeSnapshot(page) {
return page.evaluate(async () => {
const { shell, combat, game } = await window.hunterFamilyStores();
const roster = shell.getState();
const runtime = combat.getState();
const activePet = runtime.summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__');
const activeProfile = roster.characters.find((character) => character.id === roster.selectedCharacterId);
return {
phase: roster.phase,
selectedCharacter: activeProfile ? {
name: activeProfile.name,
classId: activeProfile.classId,
level: activeProfile.level,
specializationId: activeProfile.specializationId,
hunterPetFamily: activeProfile.hunterPetFamily ?? null,
} : null,
combat: {
classId: runtime.classId,
level: runtime.level,
family: runtime.hunterPetFamily,
activePet: activePet ? {
name: activePet.name,
family: activePet.hunterPet.family,
alive: activePet.health > 0,
health: activePet.health,
shellShieldSpellId: activePet.hunterPet.shellShield?.spellId ?? null,
furiousHowlSpellId: activePet.hunterPet.furiousHowl?.spellId ?? null,
} : null,
},
assetStatus: game.getState().assetStatus,
};
});
}
async function waitForGameReady(page) {
await page.locator('.game-shell').waitFor({ timeout: 120_000 });
const overlay = page.locator('.loading-screen');
let showedOverlay = await overlay.isVisible().catch(() => false);
if (!showedOverlay) {
showedOverlay = await overlay.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true, () => false);
}
if (showedOverlay) await overlay.waitFor({ state: 'hidden', timeout: 180_000 });
await page.waitForFunction(async () => {
const { game } = await window.hunterFamilyStores();
return game.getState().assetStatus === 'ready';
}, null, { timeout: 180_000 });
await page.locator('canvas').waitFor();
await page.waitForTimeout(800);
}
async function createHunter(page, name) {
await page.locator('.character-select-screen').waitFor();
await page.locator('.roster-list').getByRole('button', { name: /Create character/ }).click();
await page.locator('.character-create-screen').waitFor();
await page.locator('.class-grid').getByRole('button', { name: /Hunter/ }).click();
await page.locator('.creator-specializations').getByRole('button', { name: /Marksmanship/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name', { exact: true }).fill(name);
await page.locator('.character-create-screen').getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: new RegExp(`Continue as ${name}`) }).waitFor();
await page.getByRole('button', { name: new RegExp(`Continue as ${name}`) }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
}
async function enterWailingCaverns(page) {
await page.getByRole('button', { name: /Dungeons/ }).click();
await page.locator('.dungeon-select-screen').waitFor();
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
await page.locator('.dungeon-select-row').filter({ hasText: 'Wailing Caverns' }).click();
await page.locator('.dungeon-select-copy h2').getByText(/^Wailing Caverns$/, { exact: true }).waitFor();
await page.getByRole('button', { name: /Choose role/ }).click();
await page.locator('.dungeon-role-option').filter({ hasText: /DPS/ }).first().click();
await waitForGameReady(page);
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
await page.locator('.party-frame').nth(4).waitFor({ timeout: 180_000 });
}
async function activateSpellFromSpellbook(page, spellName) {
await page.locator('.scene-shell').focus();
await page.keyboard.press('Escape');
await page.locator('.pause-menu').waitFor({ timeout: 15_000 });
await page.getByRole('button', { name: 'Spellbook', exact: true }).click();
const spellbook = page.locator('.spellbook-panel');
await spellbook.waitFor();
const spell = spellbook.locator('.spellbook-ability').filter({ hasText: spellName }).first();
await spell.waitFor();
assert.match(await spell.getAttribute('aria-label'), new RegExp(`${spellName}, unlocked`), `${spellName} is unlocked at the seeded level`);
await spell.click();
await spellbook.waitFor({ state: 'detached' });
}
async function callPet(page, expectedFamily) {
await activateSpellFromSpellbook(page, 'Call Pet');
const selector = page.locator('.hunter-pet-controls select[aria-label="Pet family"]');
assert.equal(await selector.count(), 1, 'The player-controlled pet has one family selector.');
await selector.waitFor();
assert.equal(await selector.inputValue(), expectedFamily);
const current = await storeSnapshot(page);
assert.equal(current.combat.classId, 'hunter');
assert.ok(current.combat.level >= 10, 'Call Pet is unlocked by the documented level-10 test seed.');
assert.deepEqual(current.combat.activePet && {
name: current.combat.activePet.name,
family: current.combat.activePet.family,
}, { name: expectedFamily === 'turtle' ? 'Turtle' : 'Wolf', family: expectedFamily });
return selector;
}
async function capture(page, name, fullPage = false) {
await page.screenshot({ path: path.join(directory, `${name}.png`), fullPage });
console.log(`Captured ${name}`);
}
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir, staticDir, contentDir, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${runtime.server.address().port}`;
let browser;
let page;
const pageErrors = [];
const consoleErrors = [];
const httpErrors = [];
const recentRequests = [];
const keyEvents = [];
try {
browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, locale: 'en-US' });
page = await context.newPage();
page.setDefaultTimeout(90_000);
page.on('pageerror', (error) => pageErrors.push(error.message));
page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push({ text: message.text(), location: message.location() }); });
page.on('request', (request) => {
recentRequests.push({ method: request.method(), url: request.url() });
if (recentRequests.length > 100) recentRequests.shift();
});
page.on('response', (response) => { if (response.status() >= 400) httpErrors.push({ status: response.status(), url: response.url() }); });
page.on('requestfailed', (request) => httpErrors.push({ status: 'failed', url: request.url(), failure: request.failure()?.errorText }));
await page.addInitScript(({ mainModule, combatModule }) => {
window.__hunterKeyEvents = [];
window.addEventListener('keydown', (event) => {
window.__hunterKeyEvents.push({ key: event.key, code: event.code, target: event.target?.nodeName ?? null });
}, true);
window.hunterFamilyStores = async () => {
const exports = [...Object.values(await import(mainModule)), ...Object.values(await import(combatModule))];
const stateOf = (value) => { try { return value?.getState?.(); } catch { return undefined; } };
return {
shell: exports.find((value) => stateOf(value) && 'characters' in stateOf(value) && 'selectedCharacterId' in stateOf(value)),
combat: exports.find((value) => stateOf(value) && 'castAbility' in stateOf(value) && 'summons' in stateOf(value)),
game: exports.find((value) => stateOf(value) && 'assetStatus' in stateOf(value) && 'openOverlay' in stateOf(value)),
simulation: exports.find((value) => stateOf(value) && 'sessionMode' in stateOf(value) && 'runState' in stateOf(value)),
biteSizedLayout: exports.find((value) => stateOf(value) && 'panelFocused' in stateOf(value) && 'editorMode' in stateOf(value)),
};
};
}, { mainModule, combatModule });
await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.getByRole('button', { name: /Play offline/ }).click();
await page.locator('.character-select-screen').waitFor();
await createHunter(page, 'Shellguard');
await page.getByRole('button', { name: 'Change character', exact: true }).click();
await createHunter(page, 'Wolfguard');
// QA-only eligibility seed: after normal UI creation, advance both temporary
// local profiles to level 10 through the repository-backed progression API.
// Family selection and persistence are exercised only through the rendered UI.
const seededProfiles = await page.evaluate(async () => {
const { shell } = await window.hunterFamilyStores();
const names = ['Shellguard', 'Wolfguard'];
const seeded = [];
for (const name of names) {
const profile = shell.getState().characters.find((character) => character.name === name);
if (!profile) throw new Error(`Missing UI-created test character ${name}.`);
shell.setState({ activeCharacter: profile });
shell.getState().saveActiveProgression(10, 0, profile.talentRanks, profile.glyphIds);
shell.setState({ activeCharacter: null });
const saved = shell.getState().characters.find((character) => character.id === profile.id);
if (saved?.level !== 10) throw new Error(`${name} did not reach the level-10 QA fixture.`);
seeded.push({ name, level: saved.level, family: saved.hunterPetFamily ?? 'wolf' });
}
return seeded;
});
assert.deepEqual(seededProfiles, [
{ name: 'Shellguard', level: 10, family: 'wolf' },
{ name: 'Wolfguard', level: 10, family: 'wolf' },
]);
await page.getByRole('button', { name: 'Change character', exact: true }).click();
await page.locator('.character-select-screen').waitFor();
await page.locator('.roster-character').filter({ hasText: 'Shellguard' }).click();
await page.getByRole('button', { name: /Continue as Shellguard/ }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
await enterWailingCaverns(page);
let selector = page.locator('.hunter-pet-controls select[aria-label="Pet family"]');
await selector.waitFor();
await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached', timeout: 30_000 });
await page.waitForTimeout(300);
const entranceLock = {
disabled: await selector.isDisabled(),
selected: await selector.inputValue(),
engagedMobs: await page.evaluate(async () => {
const { combat } = await window.hunterFamilyStores();
return Object.values(combat.getState().mobs)
.filter((mob) => mob.engaged && !mob.dead && !mob.despawned)
.map((mob) => ({ id: mob.id, name: mob.name }));
}),
};
assert.equal(entranceLock.selected, 'wolf', 'A new eligible Hunter enters with its default Wolf.');
if (entranceLock.disabled) {
await capture(page, '01-wailing-entry-combat-locked-desktop');
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(350);
await capture(page, '02-wailing-entry-combat-locked-narrow');
await page.setViewportSize({ width: 1440, height: 1000 });
}
// Entrance mobs must die through the ordinary party simulation before the
// OOC family selector is used. This bounded wait never changes encounter state.
await page.waitForFunction(() => {
const select = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
return Boolean(select && !select.disabled);
}, null, { timeout: 60_000 });
assert.equal(await selector.isEnabled(), true, 'Family selection unlocks naturally when the entrance encounter ends.');
const outOfCombatMobs = await page.evaluate(async () => {
const { combat } = await window.hunterFamilyStores();
return Object.values(combat.getState().mobs)
.filter((mob) => mob.engaged && !mob.dead && !mob.despawned)
.map((mob) => ({ id: mob.id, name: mob.name }));
});
assert.deepEqual(outOfCombatMobs, [], 'Turtle is selected only after the actual encounter has ended.');
await selector.selectOption('turtle');
await page.waitForFunction(async () => {
const { shell, combat } = await window.hunterFamilyStores();
const profile = shell.getState().characters.find((character) => character.name === 'Shellguard');
const pet = combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__');
return profile?.hunterPetFamily === 'turtle'
&& combat.getState().hunterPetFamily === 'turtle'
&& pet?.name === 'Turtle'
&& pet.hunterPet.family === 'turtle'
&& !pet.hunterPet.furiousHowl;
}, null, { timeout: 10_000 });
let current = await storeSnapshot(page);
assert.equal(current.selectedCharacter?.hunterPetFamily, 'turtle');
assert.equal(current.combat.activePet?.shellShieldSpellId, 26_064);
assert.equal(current.combat.activePet?.furiousHowlSpellId, null);
const canvasSize = await page.locator('canvas').first().evaluate((element) => ({ width: element.width, height: element.height }));
assert.ok(canvasSize.width > 0 && canvasSize.height > 0, 'The rendered dungeon has an active WebGL canvas.');
await page.setViewportSize({ width: 1440, height: 1000 });
await capture(page, '03-turtle-family-desktop');
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(350);
const narrowLayout = await page.evaluate(() => {
const selector = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
const petFrame = selector?.closest('.summon-frame');
const partyFrames = document.querySelector('.party-frames');
const topRightHud = document.querySelector('.hud-top-right');
const rect = (element) => {
if (!element) return null;
const { top, right, bottom, left, width, height } = element.getBoundingClientRect();
return { top, right, bottom, left, width, height };
};
const overlaps = (first, second) => Boolean(first && second
&& first.left < second.right && first.right > second.left
&& first.top < second.bottom && first.bottom > second.top);
const controlRect = rect(selector);
const petRect = rect(petFrame);
const partyRect = rect(partyFrames);
const topRightRect = rect(topRightHud);
return {
width: innerWidth,
documentWidth: document.documentElement.scrollWidth,
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
selectorVisibleInViewport: Boolean(controlRect && controlRect.width > 0 && controlRect.height > 0
&& controlRect.left >= 0 && controlRect.right <= innerWidth
&& controlRect.top >= 0 && controlRect.bottom <= innerHeight),
partyFrameRect: partyRect,
petFrameRect: petRect,
selectorRect: controlRect,
partyClearance: partyRect && petRect ? petRect.top - partyRect.bottom : null,
overlapsPartyFrames: overlaps(petRect, partyRect),
overlapsTopRightHud: overlaps(petRect, topRightRect),
};
});
assert.equal(narrowLayout.horizontalOverflow, false, 'The narrow HUD has no horizontal overflow.');
assert.equal(narrowLayout.selectorVisibleInViewport, true, 'The narrow family selector remains visible.');
assert.ok(narrowLayout.partyClearance >= 8, 'The player pet card sits below the party stack with clearance.');
assert.equal(narrowLayout.overlapsTopRightHud, false, 'The player pet card does not overlap buffs/debuffs or minimap HUD.');
await capture(page, '04-turtle-family-narrow');
await page.setViewportSize({ width: 1440, height: 1000 });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.locator('.character-select-screen').waitFor();
let afterReload = await page.evaluate(async () => {
const { shell } = await window.hunterFamilyStores();
return shell.getState().characters.map((character) => ({
name: character.name,
family: character.hunterPetFamily ?? null,
level: character.level,
}));
});
assert.equal(afterReload.find((profile) => profile.name === 'Shellguard')?.family, 'turtle');
assert.equal(afterReload.find((profile) => profile.name === 'Wolfguard')?.family, 'wolf');
assert.deepEqual(pageErrors, [], 'No new browser page errors occurred.');
const unexpectedConsoleErrors = consoleErrors.filter((entry) => !entry.location.url.endsWith('/favicon.ico'));
assert.deepEqual(unexpectedConsoleErrors, [], 'No browser console errors beyond the local server’s missing favicon occurred.');
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({
passed: true,
server: 'ephemeral localhost server with disposable OS-temp data directory and isolated Playwright context',
characterSeed: 'Two Marksmanship Hunters created through the normal UI; both advanced to level 10 through shellStore.saveActiveProgression in the isolated QA fixture so sourced Call Pet (level 10) and Revive Pet (level 10) are available.',
scope: 'Manual Hunter family UI mechanics diagnostic only in disposable Wailing Caverns. Not a Hunter balance certification or Ready-party certification.',
familySelectionProof: 'Turtle was selected through the rendered Pet family select while the live runtime reported no engaged mobs; the selected character’s saved value was re-read after page reload, and a separate UI-created Hunter remained Wolf.',
combatLockProof: entranceLock.disabled
? 'At entry, live Wailing Caverns mobs were engaged and the rendered family selector was disabled; later it enabled only after the real encounter ended.'
: 'The rendered family selector was enabled at the first post-intro observation with no engaged mobs. A separate earlier diagnostic screenshot and failure.json record the selector disabled while the entrance mobs were engaged; there is no narrow combat-lock screenshot.',
callAndRevive: 'Spellbook Call Pet / Revive Pet activation was not covered; the Escape shortcut reached the focused scene but did not open the pause menu in this route. Neither spell was activated by state injection.',
visual: { canvasSize, screenshots: [
'01-wailing-entry-combat-locked-desktop.png',
'02-wailing-entry-combat-locked-narrow.png',
'03-turtle-family-desktop.png',
'04-turtle-family-narrow.png',
] },
entranceLock,
outOfCombatMobs,
narrowLayout,
afterReload: afterReload,
finalState: current,
pageErrors,
consoleErrors,
httpErrors,
}, null, 2)}\n`);
console.log(`Hunter family browser playtest passed. Evidence: ${directory}`);
} catch (error) {
if (page) {
await page.screenshot({ path: path.join(directory, 'failure.png'), fullPage: true }).catch(() => undefined);
console.log((await page.locator('body').innerText().catch(() => '')).slice(-2_000));
}
console.log('Browser page errors:', pageErrors);
console.log('Browser console errors:', consoleErrors);
console.log('HTTP errors:', httpErrors);
console.log('Recent requests:', recentRequests.slice(-30));
const diagnostic = await page?.evaluate(async () => {
const { shell, combat, game, simulation, biteSizedLayout } = await window.hunterFamilyStores();
const runtime = combat.getState();
const familySelector = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
return {
keyEvents: window.__hunterKeyEvents ?? [],
focusedElement: { tag: document.activeElement?.tagName, className: document.activeElement?.className },
game: { overlay: game.getState().overlay, dungeonIntroActive: game.getState().dungeonIntroActive, assetStatus: game.getState().assetStatus },
inputContext: {
sessionMode: simulation?.getState().sessionMode ?? null,
runState: simulation?.getState().runState ?? null,
biteSizedPanelFocused: biteSizedLayout?.getState().panelFocused ?? null,
biteSizedEditorMode: biteSizedLayout?.getState().editorMode ?? null,
},
sessionMode: window.localStorage.getItem('healer-man-dungeon-simulation'),
familySelector: familySelector ? {
value: familySelector.value,
disabled: familySelector.disabled,
visible: Boolean(familySelector.getClientRects().length),
} : null,
engagedMobs: Object.values(runtime.mobs).filter((mob) => mob.engaged && !mob.dead && !mob.despawned).map((mob) => ({ id: mob.id, name: mob.name })),
family: runtime.hunterPetFamily,
pets: runtime.summons.filter((summon) => summon.hunterPet && summon.ownerActorId === '__player__').map((summon) => ({ id: summon.id, kind: summon.kind, name: summon.name, family: summon.hunterPet?.family, alive: summon.health > 0 })),
profiles: shell.getState().characters.map((character) => ({ name: character.name, family: character.hunterPetFamily ?? null, level: character.level })),
};
}).catch((diagnosticError) => ({ error: diagnosticError.message }));
let narrowEvidence = null;
if (page) {
await page.screenshot({ path: path.join(directory, '01-wailing-entry-desktop-diagnostic.png'), fullPage: false }).catch(() => undefined);
await page.setViewportSize({ width: 390, height: 844 }).catch(() => undefined);
await page.waitForTimeout(350).catch(() => undefined);
narrowEvidence = await page.evaluate(() => {
const rect = (element) => {
if (!element) return null;
const { top, right, bottom, left, width, height } = element.getBoundingClientRect();
return { top, right, bottom, left, width, height };
};
const selector = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
const petFrame = selector?.closest('.summon-frame');
const partyFrames = document.querySelector('.party-frames');
const selectorRect = rect(selector);
return {
width: innerWidth,
documentWidth: document.documentElement.scrollWidth,
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
selectorRect,
selectorVisibleInViewport: Boolean(selectorRect && selectorRect.width > 0 && selectorRect.height > 0
&& selectorRect.left >= 0 && selectorRect.right <= innerWidth
&& selectorRect.top >= 0 && selectorRect.bottom <= innerHeight),
partyFrameRect: rect(partyFrames),
petFrameRect: rect(petFrame),
partyClearance: partyFrames && petFrame ? petFrame.getBoundingClientRect().top - partyFrames.getBoundingClientRect().bottom : null,
};
}).catch(() => null);
await page.screenshot({ path: path.join(directory, '02-wailing-entry-narrow-diagnostic.png'), fullPage: false }).catch(() => undefined);
}
const failure = {
passed: false,
status: error.message.includes('party stack') ? 'failed_narrow_hud_layout_assertion' : 'failed_assertion',
error: { name: error.name, message: error.message },
diagnostic,
narrowEvidence,
screenshots: ['failure.png', '01-wailing-entry-desktop-diagnostic.png', '02-wailing-entry-narrow-diagnostic.png'],
consoleErrors,
httpErrors,
recentRequests: recentRequests.slice(-30),
};
fs.writeFileSync(path.join(directory, 'failure.json'), `${JSON.stringify(failure, null, 2)}\n`);
throw error;
} finally {
await browser?.close();
await new Promise((resolve) => runtime.server.close(resolve));
cleanupDataDir();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+710
View File
@@ -0,0 +1,710 @@
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');
// Run only after a fresh production build. All account and server data is isolated
// under this script's temporary directory; screenshots/results are the only output.
const directory = path.resolve('playtest-artifacts/stat-rulesets');
const staticDir = path.resolve('dist');
const contentDir = path.resolve('content');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-stat-rulesets-'));
const databaseKey = 'healer-man.profile-database.v1';
const requiredNativeRows = Object.freeze({
attributes: [
'Strength (STR)', 'Stamina (STA)', 'Intellect (INT)', 'Wisdom (WIS)', 'Agility (AGI)', 'Health', 'Mana (MP)',
],
offense: [
'Physical attack (ATK)', 'Physical damage (DMG)', 'Magical attack (MATK)', 'Magical damage (MDMG)',
'Magical damage points', 'Magic power rate', 'Physical hit (points)', 'Magical hit (points)',
'Physical critical (points)', 'Magical critical (points)', 'Healing power rate', 'Healing points',
],
defense: [
'Physical defense (DEF)', 'Magical defense (MDEF)', 'Physical dodge (points)', 'Magical dodge (points)', 'Parry (points)',
],
});
function cleanupDataDir() {
const resolvedDataDir = path.resolve(dataDir);
const resolvedTempDir = path.resolve(os.tmpdir());
if (path.dirname(resolvedDataDir) !== resolvedTempDir
|| !path.basename(resolvedDataDir).startsWith('healer-man-stat-rulesets-')) {
throw new Error(`Refusing cleanup outside dedicated QA temp directory: ${resolvedDataDir}`);
}
fs.rmSync(resolvedDataDir, { recursive: true, force: true });
}
if (!fs.existsSync(path.join(staticDir, 'index.html'))) {
cleanupDataDir();
throw new Error('A fresh production build is required before this playtest.');
}
const assetsDirectory = path.join(staticDir, 'assets');
const assets = fs.readdirSync(assetsDirectory);
const mainFile = assets.find((file) => /^index-.*\.js$/.test(file));
const combatFile = assets.find((file) => /^combatStore-.*\.js$/.test(file));
if (!mainFile || !combatFile) {
cleanupDataDir();
throw new Error('Could not locate the production app and combat-store modules in dist/assets.');
}
const moduleUrls = assets
.filter((file) => /^(?:index|combatStore|partyStore)-.*\.js$/.test(file))
.map((file) => `/assets/${file}`);
fs.mkdirSync(directory, { recursive: true });
async function capture(page, name, fullPage = false) {
const file = path.join(directory, `${name}.png`);
await page.screenshot({ path: file, fullPage });
return file;
}
async function waitForScreen(page, selector) {
await page.locator(selector).waitFor({ timeout: 180_000 });
}
async function waitForGameReady(page) {
await waitForScreen(page, '.party-frames');
await page.waitForFunction(async () => {
const stores = await window.statRulesetStores();
const overlay = document.querySelector('.loading-screen');
const visible = overlay && (() => {
const style = getComputedStyle(overlay);
const bounds = overlay.getBoundingClientRect();
return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) > 0
&& bounds.width > 0 && bounds.height > 0;
})();
const ready = stores.game?.getState().assetStatus === 'ready' && !visible;
if (!ready) {
window.__statRulesetVisualReadySince = 0;
return false;
}
if (!window.__statRulesetVisualReadySince) window.__statRulesetVisualReadySince = performance.now();
return performance.now() - window.__statRulesetVisualReadySince >= 900;
}, null, { timeout: 180_000, polling: 100 });
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 15_000 });
await page.locator('.scene-shell canvas').waitFor({ state: 'visible', timeout: 30_000 });
await page.locator('.party-frame--player[aria-label^="Target "]').waitFor();
await page.locator('.party-frame--player .party-frame__health[aria-label="Player health"]').waitFor();
await page.waitForTimeout(1_200);
}
async function createRomPriest(page, name) {
await page.locator('.roster-new').click();
await waitForScreen(page, '.character-create-screen');
await page.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click();
await page.locator('.class-option').filter({ hasText: 'Priest' }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name', { exact: true }).fill(name);
await page.locator('.character-create-screen').getByRole('button', { name: /Create character/ }).click();
await waitForScreen(page, '.character-select-screen');
const created = await page.evaluate(async (characterName) => {
const { shell } = await window.statRulesetStores();
const normalizedName = characterName.replace(/[^A-Za-z]/g, '').slice(0, 12).toLowerCase();
const character = shell.getState().characters.find((entry) => entry.name.toLowerCase() === normalizedName);
return character ? { id: character.id, name: character.name, classId: character.classId, statRuleset: character.statRuleset } : null;
}, name);
assert.deepEqual(created && { classId: created.classId, statRuleset: created.statRuleset }, {
classId: 'rom-priest',
statRuleset: 'rom',
}, 'New RoM characters are created with Native RoM stats.');
return created;
}
async function chooseRosterCharacter(page, name) {
await waitForScreen(page, '.character-select-screen');
await page.locator('.roster-character').filter({ hasText: name }).click();
await page.getByRole('button', { name: new RegExp(`Continue as ${name}`) }).click({ position: { x: 24, y: 30 } });
await waitForScreen(page, '.main-menu-screen');
await page.locator('.main-menu-character').waitFor();
}
async function seedProfilesAndRemoveLegacyRule(page, ids) {
return page.evaluate(({ nativeId, legacyId, databaseKey }) => {
const database = JSON.parse(localStorage.getItem(databaseKey) || 'null');
if (!database?.rosters) throw new Error('The offline profile database was not found.');
const owners = Object.entries(database.rosters);
const nativeRecord = owners.flatMap(([, roster]) => roster).find((entry) => entry.id === nativeId);
const legacyRecord = owners.flatMap(([, roster]) => roster).find((entry) => entry.id === legacyId);
if (!nativeRecord || !legacyRecord) throw new Error('Created test profiles were missing from local storage.');
const inventory = (suffix) => [{
instanceId: `qa-robes-${suffix}`,
itemId: 10001,
itemKey: `qa-robes-${suffix}`,
name: `Field-Test Robe ${suffix}`,
quality: 'rare',
inventorySlot: 'chest',
armorType: 'cloth',
weaponType: null,
itemLevel: 30,
sourceItemLevel: 30,
stats: {
health: 0, mana: 0, strength: 0, agility: 0, stamina: 7, intellect: 3, spirit: 0,
armor: 18, attackPower: 0, rangedAttackPower: 0, spellPower: 0, criticalStrikeRating: 0,
hitRating: 0, hasteRating: 0, expertiseRating: 0, armorPenetrationRating: 0,
defenseRating: 0, dodgeRating: 0, parryRating: 0, blockRating: 0, blockValue: 0,
resilienceRating: 0, spellPenetration: 0, mp5: 0, healthPer5: 0, arcaneResistance: 0,
fireResistance: 0, frostResistance: 0, natureResistance: 0, shadowResistance: 0,
meleeWeapon: null, rangedWeapon: null,
},
quantity: 1,
sourceBossKey: 'qa-stat-ruleset-fixture',
sourceBossName: 'Stat Ruleset QA Fixture',
acquiredAt: Date.now(),
}];
const prepare = (profile, suffix, ruleset) => {
const item = inventory(suffix)[0];
Object.assign(profile, {
level: 30,
experience: 0,
secondaryClassId: 'rom-mage',
talentRanks: { 'rom-priest-qa-fixture': 1 },
glyphIds: ['qa-preserved-glyph'],
actionBindings: { 'primary-1': 'rom-priest-primary-1' },
actionLoadouts: { 'rom-priest:rom-mage': { 'primary-1': 'rom-priest-primary-1' } },
inventory: [item],
equipment: { player: { chest: item.instanceId } },
});
if (ruleset) profile.statRuleset = ruleset;
else delete profile.statRuleset;
};
prepare(nativeRecord, 'native', 'rom');
prepare(legacyRecord, 'legacy', null);
localStorage.setItem(databaseKey, JSON.stringify(database));
return {
nativeId,
legacyId,
legacyRuleRemoved: !Object.hasOwn(legacyRecord, 'statRuleset'),
fixtureItemIds: ['qa-robes-native', 'qa-robes-legacy'],
};
}, { ...ids, databaseKey });
}
async function profileSnapshot(page, id) {
return page.evaluate(async (characterId) => {
const { shell } = await window.statRulesetStores();
const profile = shell.getState().characters.find((entry) => entry.id === characterId);
if (!profile) throw new Error(`Profile ${characterId} was not found.`);
return {
id: profile.id,
name: profile.name,
classId: profile.classId,
statRuleset: profile.statRuleset,
level: profile.level,
secondaryClassId: profile.secondaryClassId,
talentRanks: profile.talentRanks,
glyphIds: profile.glyphIds,
actionBindings: profile.actionBindings,
actionLoadouts: profile.actionLoadouts,
inventory: profile.inventory,
equipment: profile.equipment,
settings: profile.settings,
dungeonRunCheckpoint: profile.dungeonRunCheckpoint,
};
}, id);
}
function buildSnapshot(profile) {
return {
level: profile.level,
secondaryClassId: profile.secondaryClassId,
talentRanks: profile.talentRanks,
glyphIds: profile.glyphIds,
actionBindings: profile.actionBindings,
actionLoadouts: profile.actionLoadouts,
inventory: profile.inventory,
equipment: profile.equipment,
settings: profile.settings,
dungeonRunCheckpoint: profile.dungeonRunCheckpoint,
};
}
async function chooseMode(page, ruleset) {
const label = ruleset === 'rom' ? 'Native RoM' : 'WoW compatibility';
const button = page.locator('.main-menu-character').getByRole('button', { name: label, exact: true });
await button.waitFor();
await button.click();
await page.waitForFunction(async (expected) => {
const { shell } = await window.statRulesetStores();
const state = shell.getState();
return state.characters.find((entry) => entry.id === state.selectedCharacterId)?.statRuleset === expected;
}, ruleset);
assert.equal(await button.getAttribute('aria-pressed'), 'true');
}
async function selectWailingCaverns(page) {
await page.getByRole('button', { name: /Dungeons/ }).click();
await waitForScreen(page, '.dungeon-select-screen');
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
await page.locator('.dungeon-select-row').filter({ hasText: 'Wailing Caverns' }).click();
await page.getByRole('button', { name: /Choose role/ }).click();
const damageRole = page.locator('.dungeon-role-option--damage');
if (await damageRole.count()) await damageRole.first().click();
else await page.locator('.dungeon-role-option').first().click();
await waitForGameReady(page);
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden', timeout: 120_000 });
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 15_000 });
await page.waitForTimeout(750);
}
async function openStatsPanel(page) {
const scene = page.locator('.scene-shell');
await scene.focus();
for (let attempt = 0; attempt < 2; attempt += 1) {
const overlay = await page.evaluate(async () => (await window.statRulesetStores()).game.getState().overlay);
if (overlay === 'pause') break;
await scene.press('Escape');
await page.waitForTimeout(100);
}
const overlay = await page.evaluate(async () => (await window.statRulesetStores()).game.getState().overlay);
assert.equal(overlay, 'pause', 'Escape on the focused dungeon view opens the visible pause menu.');
await page.locator('.pause-menu').waitFor();
await page.getByRole('button', { name: 'Inventory & equipment', exact: true }).click();
await page.locator('.equipment-panel').waitFor();
await page.locator('.equipment-panel .equipment-character-stats').waitFor();
}
async function readStatsPanelPage(page) {
const panel = page.locator('.equipment-panel .equipment-character-stats');
const stats = await panel.evaluate((list) => ({
label: list.getAttribute('aria-label'),
rows: [...list.querySelectorAll('div')].map((row) => ({
label: row.querySelector('dt')?.textContent?.trim() ?? '',
value: row.querySelector('dd')?.textContent?.trim() ?? '',
})),
vitals: [...document.querySelectorAll('.equipment-character-vitals span')].map((element) => element.innerText.trim()),
}));
return {
...stats,
caption: await page.locator('.equipment-character-stats__note').innerText().catch(() => ''),
};
}
(async () => {
let runtime;
let browser;
let page;
const pageErrors = [];
const consoleErrors = [];
const httpErrors = [];
const responseErrors = [];
const results = {
outputDirectory: directory,
buildChunks: { main: mainFile, combat: combatFile, runtimeModules: moduleUrls },
fixtures: { legacyProfileIsSynthetic: true, liveCombatGuardIsSynthetic: true, onlineActivityLock: 'attempted only if its store is exposed by production entry chunks' },
screenshots: {},
observations: {},
};
try {
const { createGameServer } = await import('../server/server.mjs');
runtime = createGameServer({ dataDir, staticDir, contentDir, enableShelvedModes: false });
await new Promise((resolve, reject) => {
runtime.server.once('error', reject);
runtime.server.listen(0, '127.0.0.1', resolve);
});
const origin = `http://127.0.0.1:${runtime.server.address().port}`;
results.origin = origin;
browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, locale: 'en-US' });
page = await context.newPage();
page.setDefaultTimeout(90_000);
page.on('pageerror', (error) => pageErrors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push({ text: message.text(), location: message.location() });
});
page.on('response', (response) => {
if (response.status() >= 400) {
const error = { status: response.status(), url: response.url() };
responseErrors.push(error);
if (response.status() >= 500) httpErrors.push(error);
}
});
await page.addInitScript((urls) => {
window.statRulesetStores = async () => {
const exports = (await Promise.all(urls.map((url) => import(url)))).flatMap(Object.values);
const stores = exports.filter((value) => {
try { return typeof value?.getState === 'function'; } catch { return false; }
});
const stateOf = (value) => { try { return value?.getState?.(); } catch { return undefined; } };
return {
shell: stores.find((value) => typeof stateOf(value)?.setSelectedCharacterStatRuleset === 'function'),
combat: stores.find((value) => typeof stateOf(value)?.castAbility === 'function' && 'mobs' in stateOf(value)),
game: stores.find((value) => typeof stateOf(value)?.openOverlay === 'function' && 'assetStatus' in stateOf(value)),
party: stores.find((value) => Array.isArray(stateOf(value)?.members) && typeof stateOf(value)?.setCommand === 'function'),
online: stores.find((value) => 'snapshot' in (stateOf(value) ?? {}) && typeof stateOf(value)?.setFillWithAi === 'function'),
storeCount: stores.length,
};
};
}, moduleUrls);
await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.getByRole('button', { name: /Play offline/ }).click();
await waitForScreen(page, '.character-select-screen');
const nativeCreated = await createRomPriest(page, 'Nativestats');
const legacyCreated = await createRomPriest(page, 'Legacystats');
const seeded = await seedProfilesAndRemoveLegacyRule(page, {
nativeId: nativeCreated.id,
legacyId: legacyCreated.id,
});
assert.equal(seeded.legacyRuleRemoved, true);
results.observations.createdNativeProfile = nativeCreated;
results.observations.legacyFixture = seeded;
// A reload forces the real repository read/migration path for the old RoM save.
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForScreen(page, '.character-select-screen');
await page.locator('.character-preview canvas').waitFor({ timeout: 180_000 });
await page.locator('.roster-character').filter({ hasText: nativeCreated.name }).click();
await page.waitForTimeout(3_000);
const rosterPreview = await page.locator('.character-preview').evaluate((preview) => {
const canvas = preview.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
return {
caption: preview.querySelector('figcaption')?.innerText.trim() ?? '',
canvas: bounds ? { width: bounds.width, height: bounds.height } : null,
};
});
assert.ok(rosterPreview.canvas?.width > 100 && rosterPreview.canvas?.height > 100, 'The saved RoM character body preview has a rendered canvas.');
results.observations.rosterPreview = rosterPreview;
const migratedNative = await profileSnapshot(page, nativeCreated.id);
const migratedLegacy = await profileSnapshot(page, legacyCreated.id);
assert.equal(migratedNative.statRuleset, 'rom');
assert.equal(migratedLegacy.statRuleset, 'wow', 'A legacy saved RoM profile without the field retains WoW-compatible behavior.');
assert.equal(migratedLegacy.secondaryClassId, 'rom-mage');
assert.equal(migratedLegacy.inventory[0]?.instanceId, 'qa-robes-legacy');
assert.deepEqual(migratedLegacy.equipment.player, { chest: 'qa-robes-legacy' });
results.observations.afterLegacyMigration = {
native: { statRuleset: migratedNative.statRuleset, classId: migratedNative.classId },
legacy: { statRuleset: migratedLegacy.statRuleset, classId: migratedLegacy.classId, level: migratedLegacy.level },
savedRuleAfterMigration: JSON.parse(await page.evaluate((key) => localStorage.getItem(key), databaseKey))
.rosters['offline-roster'].find((entry) => entry.id === legacyCreated.id).statRuleset,
};
results.screenshots.rosterNative = await capture(page, '01-roster-native-rom');
await chooseRosterCharacter(page, nativeCreated.name);
await page.locator('.main-menu-character').getByRole('group', { name: 'RoM stat profile' }).waitFor();
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom');
const nativeBuild = buildSnapshot(await profileSnapshot(page, nativeCreated.id));
// Exercise the actual menu controls and browser persistence for one profile.
await chooseMode(page, 'wow');
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'wow');
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForScreen(page, '.character-select-screen');
await chooseRosterCharacter(page, nativeCreated.name);
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'wow', 'The visible WoW-compatible selection survives reload.');
results.screenshots.menuWowCompatibility = await capture(page, '02-menu-wow-compatibility');
await chooseMode(page, 'rom');
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom');
// The second RoM profile must retain its legacy WoW setting independently.
await page.getByRole('button', { name: 'Change character', exact: true }).click();
await waitForScreen(page, '.character-select-screen');
await chooseRosterCharacter(page, legacyCreated.name);
assert.equal((await profileSnapshot(page, legacyCreated.id)).statRuleset, 'wow');
await chooseMode(page, 'rom');
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom');
await chooseMode(page, 'wow');
assert.equal((await profileSnapshot(page, legacyCreated.id)).statRuleset, 'wow');
await page.getByRole('button', { name: 'Change character', exact: true }).click();
await waitForScreen(page, '.character-select-screen');
await chooseRosterCharacter(page, nativeCreated.name);
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom', 'Changing the second profile never changes the first profile.');
// Measure the selector itself at desktop and narrow phone widths.
const desktopSelectorLayout = await page.locator('.main-menu-character fieldset').evaluate((fieldset) => {
const bounds = fieldset.getBoundingClientRect();
const parent = fieldset.closest('.main-menu-sidebar').getBoundingClientRect();
const overlapsSummary = [...fieldset.parentElement.children]
.filter((element) => element !== fieldset)
.some((element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && rect.left < bounds.right && rect.right > bounds.left
&& rect.top < bounds.bottom && rect.bottom > bounds.top;
});
const controls = [...fieldset.querySelectorAll('button')].map((button) => {
const rect = button.getBoundingClientRect();
return { label: button.innerText, left: rect.left, right: rect.right, width: rect.width, height: rect.height };
});
return {
clientWidth: fieldset.clientWidth,
scrollWidth: fieldset.scrollWidth,
fieldset: { left: bounds.left, right: bounds.right, width: bounds.width },
sidebar: { left: parent.left, right: parent.right },
overlapsSummary,
controls,
};
});
assert.ok(desktopSelectorLayout.scrollWidth <= desktopSelectorLayout.clientWidth + 1, 'Desktop selector content fits its fieldset.');
assert.equal(desktopSelectorLayout.overlapsSummary, false, 'Desktop selector does not overlap its character label.');
results.observations.desktopSelectorLayout = desktopSelectorLayout;
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(350);
const narrowSelectorLayout = await page.locator('.main-menu-character fieldset').evaluate((fieldset) => {
const bounds = fieldset.getBoundingClientRect();
const parent = fieldset.closest('.main-menu-sidebar').getBoundingClientRect();
const overlapsSummary = [...fieldset.parentElement.children]
.filter((element) => element !== fieldset)
.some((element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && rect.left < bounds.right && rect.right > bounds.left
&& rect.top < bounds.bottom && rect.bottom > bounds.top;
});
return {
viewportWidth: window.innerWidth,
documentWidth: document.documentElement.scrollWidth,
clientWidth: fieldset.clientWidth,
scrollWidth: fieldset.scrollWidth,
fieldset: { left: bounds.left, right: bounds.right, width: bounds.width },
sidebar: { left: parent.left, right: parent.right },
overlapsSummary,
controls: [...fieldset.querySelectorAll('button')].map((button) => {
const rect = button.getBoundingClientRect();
return { label: button.innerText, left: rect.left, right: rect.right, width: rect.width, height: rect.height };
}),
};
});
assert.ok(narrowSelectorLayout.fieldset.left >= narrowSelectorLayout.sidebar.left - 1);
assert.ok(narrowSelectorLayout.fieldset.right <= narrowSelectorLayout.sidebar.right + 1);
assert.ok(narrowSelectorLayout.scrollWidth <= narrowSelectorLayout.clientWidth + 1, 'Phone selector does not overflow its fieldset.');
assert.equal(narrowSelectorLayout.overlapsSummary, false, 'Phone selector does not overlap its character label.');
assert.ok(narrowSelectorLayout.controls.every((control) => control.left >= narrowSelectorLayout.fieldset.left - 1
&& control.right <= narrowSelectorLayout.fieldset.right + 1), 'Phone selector buttons stay inside the fieldset.');
results.observations.narrowSelectorLayout = narrowSelectorLayout;
results.screenshots.menuSelectorMobile = await capture(page, '03-menu-selector-mobile', true);
await page.setViewportSize({ width: 1440, height: 1000 });
await selectWailingCaverns(page);
results.screenshots.nativeDungeonGameplay = await capture(page, '04-native-dungeon-gameplay-overlays-closed');
const nativeRuntime = await page.evaluate(async () => {
const { shell, combat, party } = await window.statRulesetStores();
const profile = shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId);
const state = combat.getState();
return {
phase: shell.getState().phase,
profile: profile && { name: profile.name, statRuleset: profile.statRuleset },
classId: state.classId,
statRuleset: state.statRuleset,
health: state.health,
maxHealth: state.maxHealth,
resource: state.resource,
maxResource: state.maxResource,
resourceName: state.resourceName,
resourcePools: state.resourcePools,
inventory: state.inventory.map((item) => ({ instanceId: item.instanceId, name: item.name })),
playerEquipment: state.equipment.player,
party: party?.getState().members.map((member) => ({
name: member.name,
classId: member.classId,
statRuleset: member.statRuleset ?? 'wow',
})) ?? [],
};
});
assert.equal(nativeRuntime.phase, 'game');
assert.equal(nativeRuntime.profile?.statRuleset, 'rom');
assert.equal(nativeRuntime.classId, 'rom-priest');
assert.equal(nativeRuntime.statRuleset, 'rom', 'The selected profile reaches the actual combat runtime.');
assert.ok(nativeRuntime.maxHealth > 0 && nativeRuntime.maxResource > 0);
assert.ok(nativeRuntime.inventory.some((item) => item.instanceId === 'qa-robes-native'));
assert.equal(nativeRuntime.playerEquipment.chest, 'qa-robes-native', 'The equipped test item reaches the actual dungeon runtime.');
for (const member of nativeRuntime.party) {
if (member.classId.startsWith('rom-')) assert.equal(member.statRuleset, 'rom', `${member.name} keeps its own RoM ruleset.`);
}
results.observations.nativeDungeonRuntime = nativeRuntime;
await openStatsPanel(page);
const nativePanelPages = {};
for (const [index, pageName] of ['attributes', 'offense', 'defense'].entries()) {
if (pageName !== 'attributes') {
const tab = page.locator('.equipment-stat-tabs').getByRole('tab', { name: pageName, exact: true });
await tab.click();
await page.waitForFunction((name) => {
const selected = document.querySelector('.equipment-stat-tabs [role="tab"][aria-selected="true"]');
return selected?.textContent?.trim() === name;
}, pageName);
}
await page.locator('.equipment-panel .equipment-character-stats').scrollIntoViewIfNeeded();
const panelPage = await readStatsPanelPage(page);
const labels = new Set(panelPage.rows.map((row) => row.label));
const missing = requiredNativeRows[pageName].filter((label) => !labels.has(label));
assert.deepEqual(missing, [], `Native RoM ${pageName} page is missing fields: ${missing.join(', ')}`);
assert.equal(panelPage.caption, 'Native RoM stats · shared dungeon combat rules');
nativePanelPages[pageName] = panelPage;
results.screenshots[`nativeStats${pageName[0].toUpperCase()}${pageName.slice(1)}Desktop`] = await capture(
page,
`04-native-stats-${pageName}-desktop`,
);
}
const renderedMeters = await page.locator('.party-frame--player').evaluate((frame) => {
const health = frame.querySelector('.party-frame__health[aria-label="Player health"]');
const resource = frame.querySelector('.party-frame__resource');
return {
health: {
current: Number(health?.getAttribute('aria-valuenow')),
maximum: Number(health?.getAttribute('aria-valuemax')),
text: health?.querySelector('b')?.textContent?.trim() ?? '',
},
resourceTitle: resource?.getAttribute('title') ?? '',
};
});
assert.ok(Math.abs(renderedMeters.health.maximum - nativeRuntime.maxHealth) <= 1, 'Rendered health maximum agrees with combat state.');
assert.ok(Math.abs(renderedMeters.health.current - nativeRuntime.health) <= 2, 'Rendered health current agrees with combat state.');
const renderedResource = renderedMeters.resourceTitle.match(/^(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s+(.+)$/);
assert.ok(renderedResource, 'Rendered resource meter exposes current, maximum, and resource type.');
assert.ok(Math.abs(Number(renderedResource[2]) - nativeRuntime.maxResource) <= 1, 'Rendered resource maximum agrees with combat state.');
assert.ok(Math.abs(Number(renderedResource[1]) - Math.floor(nativeRuntime.resource)) <= 1, 'Rendered resource current agrees with combat state.');
assert.equal(renderedResource[3], nativeRuntime.resourceName, 'Rendered resource type agrees with combat state.');
const attributeValues = new Map(nativePanelPages.attributes.rows.map((row) => [row.label, Number(row.value.replaceAll(',', ''))]));
assert.ok(Math.abs(attributeValues.get('Health') - nativeRuntime.maxHealth) <= 1, 'Native panel Health agrees with combat max health.');
assert.ok(Math.abs(attributeValues.get('Mana (MP)') - nativeRuntime.maxResource) <= 1, 'Native panel Mana agrees with combat max resource.');
assert.ok(nativePanelPages.attributes.vitals.some((row) => row.includes(`Combat health ${Math.round(nativeRuntime.maxHealth).toLocaleString()}`)));
results.observations.nativePanelPages = { ...nativePanelPages, renderedMeters };
await page.locator('.equipment-stat-tabs').getByRole('tab', { name: 'attributes', exact: true }).click();
await page.locator('.equipment-panel .equipment-character-stats').scrollIntoViewIfNeeded();
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(350);
results.screenshots.nativeDungeonStatsMobile = await capture(page, '07-native-stats-attributes-mobile', true);
await page.setViewportSize({ width: 1440, height: 1000 });
await page.getByRole('button', { name: 'Return to pause menu' }).click();
await page.locator('.pause-menu').waitFor();
// Synthetic shell-route fixture over an installed live combat runtime. It
// tests the runtime's engaged-mob guard without leaving or saving a real run.
const blockedBefore = await profileSnapshot(page, nativeCreated.id);
const syntheticLock = await page.evaluate(async () => {
const { shell, combat } = await window.statRulesetStores();
shell.setState({ phase: 'main-menu', activeCharacter: null });
combat.setState({ mobs: { '__qa-synthetic-live-mob__': { engaged: true, dead: false } } });
return { phase: shell.getState().phase, statRuleset: shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId)?.statRuleset };
});
assert.equal(syntheticLock.phase, 'main-menu');
await waitForScreen(page, '.main-menu-screen');
await page.locator('.main-menu-character').getByRole('button', { name: 'WoW compatibility', exact: true }).click();
const blockedAttempt = await page.evaluate(async () => {
const { shell } = await window.statRulesetStores();
const profile = shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId);
return { phase: shell.getState().phase, statRuleset: profile?.statRuleset, notice: shell.getState().notice };
});
assert.equal(blockedAttempt.statRuleset, blockedBefore.statRuleset);
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom');
results.observations.syntheticLiveCombatLock = {
beforeClick: syntheticLock,
afterClick: blockedAttempt,
fixture: 'Synthetic main-menu route and engaged mob injected over the local live runtime; no run was left or saved.',
};
await page.evaluate(async () => {
const { shell, combat } = await window.statRulesetStores();
combat.setState({ mobs: {} });
shell.setState({ phase: 'game', activeCharacter: null });
});
await page.locator('.pause-menu').waitFor({ timeout: 30_000 });
await page.getByRole('button', { name: 'Main menu', exact: true }).click();
await waitForScreen(page, '.main-menu-screen');
// Optionally set a synthetic online activity if its store is exposed by the
// production entry chunks; absence is recorded rather than faked via UI state.
const onlineFixture = await page.evaluate(async () => {
const { online } = await window.statRulesetStores();
if (!online) return { available: false };
online.setState({ snapshot: {
group: { id: 'qa-synthetic-online-group', members: [], activity: { id: 'qa-synthetic-live-activity' } },
invitations: [],
} });
return { available: true };
});
if (onlineFixture.available) {
await page.waitForFunction(() => [...document.querySelectorAll('.main-menu-character button')].every((button) => button.disabled));
const blocked = await page.evaluate(async () => {
const { shell } = await window.statRulesetStores();
const accepted = shell.getState().setSelectedCharacterStatRuleset('wow');
const profile = shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId);
return { accepted, statRuleset: profile?.statRuleset };
});
assert.equal(blocked.accepted, false);
assert.equal(blocked.statRuleset, 'rom');
const profileButtonDisabled = await page.locator('.main-menu-character [aria-label="RoM stat profile"] button').evaluateAll((buttons) => buttons.every((button) => button.disabled));
assert.equal(profileButtonDisabled, true, 'A synthetic online activity disables the profile selector.');
results.observations.syntheticOnlineActivityLock = { ...onlineFixture, ...blocked, profileButtonDisabled, fixture: 'Synthetic online group activity injected into the local store.' };
await page.evaluate(async () => (await window.statRulesetStores()).online.getState().reset());
} else {
results.observations.syntheticOnlineActivityLock = { available: false, note: 'The online-group store was not exported by production entry chunks.' };
}
// Visible WoW switch after leaving the run must preserve all saved build data.
const beforeWowSwitch = await profileSnapshot(page, nativeCreated.id);
const beforeWowBuild = buildSnapshot(beforeWowSwitch);
assert.deepEqual(
{ ...beforeWowBuild, dungeonRunCheckpoint: null },
{ ...nativeBuild, dungeonRunCheckpoint: null },
'The first dungeon run leaves the character build and inventory intact.',
);
await chooseMode(page, 'wow');
const afterWowSwitch = await profileSnapshot(page, nativeCreated.id);
assert.equal(afterWowSwitch.statRuleset, 'wow');
assert.deepEqual(buildSnapshot(afterWowSwitch), beforeWowBuild, 'Switching rulesets preserves class pair, talents, bars, inventory, gear, and checkpoint.');
assert.equal((await profileSnapshot(page, legacyCreated.id)).statRuleset, 'wow');
results.observations.persistedBuildAfterWowSwitch = buildSnapshot(afterWowSwitch);
results.screenshots.menuWowAfterDungeon = await capture(page, '06-menu-wow-after-dungeon');
// A second actual run verifies the saved compatibility selection reaches runtime.
await selectWailingCaverns(page);
results.screenshots.wowDungeonGameplay = await capture(page, '08-wow-compatibility-dungeon-gameplay-overlays-closed');
const wowRuntime = await page.evaluate(async () => {
const { shell, combat } = await window.statRulesetStores();
const profile = shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId);
const state = combat.getState();
return { statRuleset: state.statRuleset, profileRuleset: profile?.statRuleset, classId: state.classId };
});
assert.deepEqual(wowRuntime, { statRuleset: 'wow', profileRuleset: 'wow', classId: 'rom-priest' });
results.observations.wowCompatibilityDungeonRuntime = wowRuntime;
await capture(page, '07-wow-compatibility-dungeon');
await page.keyboard.press('Escape');
await page.locator('.pause-menu').waitFor();
await page.getByRole('button', { name: 'Main menu', exact: true }).click();
await waitForScreen(page, '.main-menu-screen');
const profileBeforeFinalReload = await profileSnapshot(page, nativeCreated.id);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForScreen(page, '.character-select-screen');
await chooseRosterCharacter(page, nativeCreated.name);
const finalNative = await profileSnapshot(page, nativeCreated.id);
const finalLegacy = await profileSnapshot(page, legacyCreated.id);
assert.equal(finalNative.statRuleset, 'wow');
assert.equal(finalLegacy.statRuleset, 'wow');
assert.deepEqual(finalNative.dungeonRunCheckpoint, profileBeforeFinalReload.dungeonRunCheckpoint,
'The saved checkpoint survives a reload alongside the selected ruleset.');
assert.deepEqual(
{ ...buildSnapshot(finalNative), dungeonRunCheckpoint: null },
{ ...nativeBuild, dungeonRunCheckpoint: null },
'Native to WoW switching and the dungeon run preserve the character build and inventory.',
);
results.observations.finalProfiles = {
native: { name: finalNative.name, classId: finalNative.classId, statRuleset: finalNative.statRuleset },
legacy: { name: finalLegacy.name, classId: finalLegacy.classId, statRuleset: finalLegacy.statRuleset },
checkpointRetainedAfterReload: Boolean(finalNative.dungeonRunCheckpoint),
};
assert.deepEqual(pageErrors, [], 'No uncaught browser errors occurred.');
assert.deepEqual(httpErrors, [], 'No production asset or server failures occurred.');
const unexpectedResponseErrors = responseErrors.filter((error) => !(error.status === 404 && new URL(error.url).pathname === '/favicon.ico'));
assert.deepEqual(unexpectedResponseErrors, [], 'No app assets or API resources failed to load (the browser may request a missing optional favicon).');
results.diagnostics = { pageErrors, consoleErrors, httpErrors, responseErrors };
results.status = 'passed';
fs.writeFileSync(path.join(directory, 'results.json'), JSON.stringify(results, null, 2));
console.log(`Stat ruleset browser playtest passed: ${directory}`);
} catch (error) {
results.status = 'failed';
results.error = error?.stack ?? String(error);
results.diagnostics = { pageErrors, consoleErrors, httpErrors, responseErrors };
if (page) {
results.screenshots.failure = await capture(page, 'failure', true).catch(() => null);
}
fs.writeFileSync(path.join(directory, 'results.json'), JSON.stringify(results, null, 2));
throw error;
} finally {
await browser?.close().catch(() => {});
if (runtime?.server.listening) await new Promise((resolve) => runtime.server.close(resolve));
cleanupDataDir();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -42,6 +42,22 @@ const DRUID_WARRIOR_BRIAR_CHILD = {
passiveSourceSkillId: 499955,
childSourceSkillId: 499956,
};
const ROGUE_WARDEN_REVIVAL_STATE = {
mainClass: 3,
secondaryClass: 7,
level: 60,
keyItemId: 546772,
parentSourceSkillId: 499542,
childSourceSkillId: 499544,
};
const KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT = {
mainClass: 6,
secondaryClass: 3,
level: 50,
keyItemId: 542123,
parentSourceSkillId: 499934,
childSourceSkillId: 499909,
};
const RACE_MATRIX = {
"rom-human": [1, 2, 3, 4, 5, 6],
"rom-elf": [1, 2, 3, 4, 7, 8],
@@ -597,6 +613,75 @@ for (const key of [...eligiblePairs].sort()) {
iconResolution: "native-interface-fdb",
});
}
// RuneWaker's Rogue/Warden 499544 state has its own level-60 row behind
// 499542, sharing the exact elite key item. Preserve that second learned
// candidate instead of dropping it at the one-row milestone selector.
if (mapping.mainClass === ROGUE_WARDEN_REVIVAL_STATE.mainClass
&& mapping.secondaryClass === ROGUE_WARDEN_REVIVAL_STATE.secondaryClass
&& milestone.level === ROGUE_WARDEN_REVIVAL_STATE.level
&& milestone.keyItemId === ROGUE_WARDEN_REVIVAL_STATE.keyItemId
&& candidate.sourceSkillId === ROGUE_WARDEN_REVIVAL_STATE.parentSourceSkillId) {
const child = candidates.find((entry) => entry.classIndex === ROGUE_WARDEN_REVIVAL_STATE.mainClass
&& entry.sourceSkillId === ROGUE_WARDEN_REVIVAL_STATE.childSourceSkillId
&& entry.learnLevel === milestone.level
&& entry.keyItemId === milestone.keyItemId
&& entry.prerequisites.includes(ROGUE_WARDEN_REVIVAL_STATE.parentSourceSkillId));
if (!child) throw new Error("Rogue/Warden Revival state 499544 lost its source unlock row.");
const childSpell = sourceSpell(child.sourceSkillId, mapping.mainClass);
if (!childSpell) throw new Error("Rogue/Warden Revival state 499544 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",
});
}
// Knight/Rogue God's Vengeance explicitly unlocks its linked 499909
// Punishment as a second learned row on the same level-50 key item.
if (mapping.mainClass === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.mainClass
&& mapping.secondaryClass === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.secondaryClass
&& milestone.level === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.level
&& milestone.keyItemId === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.keyItemId
&& candidate.sourceSkillId === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.parentSourceSkillId) {
const child = candidates.find((entry) => entry.classIndex === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.mainClass
&& entry.sourceSkillId === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.childSourceSkillId
&& entry.learnLevel === milestone.level
&& entry.keyItemId === milestone.keyItemId
&& entry.prerequisites.includes(KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.parentSourceSkillId));
if (!child) throw new Error("Knight/Rogue God's Vengeance Punishment 499909 lost its learned source row.");
const childSpell = sourceSpell(child.sourceSkillId, mapping.mainClass);
if (!childSpell) throw new Error("Knight/Rogue God's Vengeance Punishment 499909 has no MagicCollect row.");
skills.push({
id: `${CLASS_IDS[mapping.mainClass - 1]}-${CLASS_IDS[mapping.secondaryClass - 1]}-elite-${milestone.level}-${childSpell.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",
});
}
}
}
@@ -639,9 +724,10 @@ const audit = {
},
adaptations,
};
// 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
// In addition to the 660 ordinary milestones, the source emits the Druid/Warrior
// Cross of Thorns Attacks row, Rogue/Warden Revival state, and linked Knight/Rogue
// Punishment row behind their learned prerequisites.
if (eliteMilestoneCount !== 660 || supplementalEliteCount !== 3 || unresolvedVisible.length !== 0
|| audit.coverage.iconResolved !== skills.length || iconCoverage.requested !== iconCoverage.resolved) {
throw new Error(`Coverage failed: ${JSON.stringify(audit.coverage)}`);
}
@@ -0,0 +1,204 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../..");
const sourceRoot = path.resolve(process.env.RUNEWAKER_ROOT ?? path.join(projectRoot, "..", "Runewaker"));
const dataRoot = path.join(sourceRoot, "Resource", "data");
const ruleOutput = path.join(projectRoot, "src", "game", "generated", "romNativeStatRules.generated.json");
const gearOutput = path.join(projectRoot, "src", "game", "generated", "romNativeGearCurves.generated.json");
const referencePath = path.join(projectRoot, "src", "game", "generated", "romHeirloomReference.json");
const baselinePath = path.join(projectRoot, "src", "game", "romBaseStatGrowth.generated.json");
const DATA_START = 140;
const RECORD_SIZE = 408;
const CLASS_ROWS = [
["rom-warrior", 1], ["rom-scout", 2], ["rom-rogue", 3], ["rom-mage", 4], ["rom-priest", 5],
["rom-knight", 6], ["rom-warden", 7], ["rom-druid", 8], ["rom-warlock", 9], ["rom-champion", 10],
];
const RACE_ROWS = [["rom-human", 0], ["rom-elf", 1], ["rom-dwarf", 2]];
const GENDER_ROWS = [["male", 0], ["female", 1]];
const STAT_KEYS = ["strength", "stamina", "intellect", "wisdom", "agility", "health", "mana"];
const ARG_FIELDS = [
"baseStrengthLevelOne", "baseStaminaLevelOne", "baseIntellectLevelOne", "baseWisdomLevelOne", "baseAgilityLevelOne", "baseHealthLevelOne", "baseManaLevelOne",
"baseStrengthGrowth", "baseStaminaGrowth", "baseIntellectGrowth", "baseWisdomGrowth", "baseAgilityGrowth", "baseHealthGrowth", "baseManaGrowth",
"strengthGrowthRate", "staminaGrowthRate", "intellectGrowthRate", "wisdomGrowthRate", "agilityGrowthRate", "healthGrowthRate", "manaGrowthRate",
"hpStrength", "hpStamina", "mpIntellect", "mpWisdom", "attackStrength", "attackIntellect", "attackAgility", "defenseStamina",
"baseHealthRecovery", "baseManaRecovery", "healthRecoveryStamina", "manaRecoveryWisdom", "magicalAttackIntellect", "magicalDefenseWisdom",
"baseMoveSpeed", "recoverySpMove", "recoverySpStop", "recoverySpAttack", "recoverySpUnderAttack", "recoverySpAttackTimeMove", "recoverySpAttackTimeStop",
"maxSP", "damageRate",
];
const FIX_FIELDS = [
"damageAttackCoefficient", "magicalDamageAttackCoefficient", "criticalLevelCoefficient", "damageLevelCoefficient", "missLevelCoefficient",
"secondHandMissCoefficient", "secondHandDamageCoefficient", "basePhysicalCritical", "basePhysicalCriticalPower", "baseMagicalCritical", "baseMagicalCriticalPower",
"magicDamageCoefficient", "magicMagicalDamageCoefficient", "dotMagicalDamageCoefficient", "healingMagicalDamageCoefficient", "healingDotMagicalDamageCoefficient",
"baseRateCoefficient", "baseResistCoefficient", "levelRateCoefficient", "levelResistCoefficient", "physicalHitBase", "physicalHitRange",
"physicalHitAgility", "physicalDodgeAgility", "magicalHitBase", "magicalHitRange", "magicalHitWisdom", "magicalDodgeAgility", "magicalDodgeWisdom",
"phantomRankCoefficient", "phantomLevelCoefficient",
];
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
function cleanFloat(value) {
if (!Number.isFinite(value)) throw new Error("Native source table contains a non-finite float.");
return Number(value.toFixed(5));
}
function database(buffer, name, recordSize) {
const count = buffer.readInt32LE(132);
if (count <= 0 || DATA_START + count * recordSize > buffer.length) throw new Error(`${name} fixed-record layout changed.`);
return count;
}
function row(buffer, index, expectedId, label) {
const offset = DATA_START + index * RECORD_SIZE;
if (offset + RECORD_SIZE > buffer.length
|| buffer.readInt32LE(offset) !== expectedId
|| buffer.readInt32LE(offset + 4) !== expectedId) {
throw new Error(`${label} row sentinel drifted (expected ${expectedId}).`);
}
return Array.from({ length: 100 }, (_, field) => cleanFloat(buffer.readFloatLE(offset + 8 + field * 4)));
}
function named(values, names) {
return Object.fromEntries(names.map((name, index) => [name, values[index]]));
}
function validateGrowthMirror(variants, baseline) {
for (const [classId] of CLASS_ROWS) for (const [raceId] of RACE_ROWS) for (const [gender] of GENDER_ROWS) {
const row = variants[classId][raceId][gender];
for (const [statIndex, stat] of STAT_KEYS.entries()) {
if (raceId !== "rom-human" || gender !== "male") continue;
const expected = baseline[classId][stat];
const actual = row.growth[stat];
for (const key of ["levelOne", "flatGrowth", "percentGrowth"]) {
if (Math.abs(actual[key] - expected[key]) > 0.00001) {
throw new Error(`${classId}.${stat}.${key} no longer matches romBaseStatGrowth.generated.json.`);
}
}
if (statIndex < 0) throw new Error("Unreachable stat index.");
}
}
}
const filenames = ["racetable.db", "sextable.db", "voctable.db", "fixtable.db"];
const buffers = Object.fromEntries(await Promise.all(filenames.map(async (name) => [
name, await readFile(path.join(dataRoot, name)),
])));
database(buffers["racetable.db"], "racetable.db", RECORD_SIZE);
database(buffers["sextable.db"], "sextable.db", RECORD_SIZE);
database(buffers["voctable.db"], "voctable.db", RECORD_SIZE * 2);
database(buffers["fixtable.db"], "fixtable.db", RECORD_SIZE);
const classRows = Object.fromEntries(CLASS_ROWS.map(([classId, sourceClassId]) => {
const args = row(buffers["voctable.db"], sourceClassId * 2 + 1, 301000 + sourceClassId * 2 + 1, `vocation ${classId}`);
return [classId, { sourceClassId, args: named(args.slice(0, ARG_FIELDS.length), ARG_FIELDS) }];
}));
const raceRows = Object.fromEntries(RACE_ROWS.map(([raceId, sourceRaceId]) => [
raceId, { sourceRaceId, args: named(row(buffers["racetable.db"], sourceRaceId, 300000 + sourceRaceId, `race ${raceId}`).slice(0, ARG_FIELDS.length), ARG_FIELDS) },
]));
const genderRows = Object.fromEntries(GENDER_ROWS.map(([gender, sourceGenderId]) => [
gender, { sourceGenderId, args: named(row(buffers["sextable.db"], sourceGenderId, 302000 + sourceGenderId, `gender ${gender}`).slice(0, ARG_FIELDS.length), ARG_FIELDS) },
]));
const fixedRow = row(buffers["fixtable.db"], 1, 303001, "player fixed formula");
const playerFix = named(fixedRow.slice(0, FIX_FIELDS.length), FIX_FIELDS);
const variants = {};
for (const [classId] of CLASS_ROWS) {
variants[classId] = {};
for (const [raceId] of RACE_ROWS) {
variants[classId][raceId] = {};
for (const [gender] of GENDER_ROWS) {
const classArgs = classRows[classId].args;
const raceArgs = raceRows[raceId].args;
const genderArgs = genderRows[gender].args;
const growth = Object.fromEntries(STAT_KEYS.map((stat, index) => [stat, {
levelOne: cleanFloat(classArgs[ARG_FIELDS[index]] + raceArgs[ARG_FIELDS[index]] + genderArgs[ARG_FIELDS[index]]),
flatGrowth: cleanFloat(classArgs[ARG_FIELDS[index + 7]] + raceArgs[ARG_FIELDS[index + 7]] + genderArgs[ARG_FIELDS[index + 7]]),
percentGrowth: cleanFloat(classArgs[ARG_FIELDS[index + 14]] + raceArgs[ARG_FIELDS[index + 14]] + genderArgs[ARG_FIELDS[index + 14]]),
}]));
const coefficients = Object.fromEntries(ARG_FIELDS.slice(21, 42).map((field) => [
field,
cleanFloat(classArgs[field] + raceArgs[field] + genderArgs[field]),
]));
coefficients.damageRate = cleanFloat(classArgs.damageRate * raceArgs.damageRate * genderArgs.damageRate);
variants[classId][raceId][gender] = { growth, coefficients };
}
}
}
validateGrowthMirror(variants, JSON.parse(await readFile(baselinePath, "utf8")));
if (Math.abs(playerFix.damageAttackCoefficient) > 1e-8
|| Math.abs(playerFix.magicalDamageAttackCoefficient) > 1e-8
|| Math.abs(playerFix.healingMagicalDamageCoefficient - 0.5) > 1e-8) {
throw new Error("Player formula row changed: DMG/MDMG/heal MDMG mapping needs a source review.");
}
const ruleData = {
schemaVersion: 1,
source: {
project: "Runewaker client/server source data",
formulaFiles: [
"SourceCodeVS2019/Libraries/Union/RoleData/RoleAttr.cpp",
"SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h",
"SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp",
],
recordFiles: Object.fromEntries(filenames.map((name) => [name, sha256(buffers[name])])),
recordLayout: { dataStart: DATA_START, recordSize: RECORD_SIZE, vocationSubrecordSize: RECORD_SIZE },
playerFixRecordId: 303001,
raceRecordIds: Object.fromEntries(RACE_ROWS.map(([key, id]) => [key, 300000 + id])),
genderRecordIds: Object.fromEntries(GENDER_ROWS.map(([key, id]) => [key, 302000 + id])),
vocationRecordIds: Object.fromEntries(CLASS_ROWS.map(([key, id]) => [key, 301000 + id * 2 + 1])),
fieldOrder: "ObjectStruct.h FightArgumentStruct fields 0-43; player FIX fields 0-30",
},
classRows,
raceRows,
genderRows,
playerFix,
variants,
manaClassIds: ["rom-mage", "rom-priest", "rom-knight", "rom-warden", "rom-druid", "rom-warlock", "rom-champion"],
formulaNotes: {
secondaryBase: "Main base at Level + secondary base at Level_Sub divided by 10; runtime supplies the source secondary level.",
baseProgression: "For each level after 1: value = value * (1 + combined percent growth) + combined flat growth.",
maxHealth: "(base HP + STR*HpStrArg + STA*HpStaArg + flat MaxHP) * MaxHP rate; minimum 1.",
maxMana: "MP classes only: (base MP + INT*MPIntArg + WIS*MpMndArg + flat MaxMP) * MaxMP rate; minimum 0.",
physicalAttack: "(STR*AtkStrArg + INT*AtkIntArg + AGI*AtkAgiArg + flat ATK) * ATK rate; minimum 1.",
physicalDefense: "(STA*DefArg + flat DEF) * DEF rate; source also adds armor-category base-defense contributions not represented by this curve adapter.",
magicalDefense: "(WIS*MDefArg + flat MDEF) * MDEF rate; minimum 1.",
magicalAttack: "(INT*MAtkArg + flat MATK) * MATK rate; minimum 1.",
physicalDamage: "(ATK / DmgAtkArg when nonzero + flat DMG) * merged class/race/sex DmgRate * DMG rate; player FIX DmgAtkArg is zero.",
magicalDamage: "(MATK / MDmgMAtkArg when nonzero + flat MDMG) * MDMG rate; player FIX MDmgMAtkArg is zero.",
magicPower: "MPow is the native all-magic BodyRate, neutral 1; it is not MATK or MDMG.",
healer: "HolyHeal uses skill OrgPower, capped MND contribution, optional MDMG*Dmg_HealArg (player FIX Dmg_HealArg is 0.5), MPow*HealPowerRate, and HealPoint*SpellTime. Target HealAbsorbRate and HealAbsorb apply after the caster amount. Skill OrgPower/timing and other skill flags are outside this stat helper.",
secondaryGate: "The source formula assumes a present secondary vocation and uses Level_Sub; the level-10 unlock gate and same-level fallback are simulator policy.",
},
};
const reference = JSON.parse(await readFile(referencePath, "utf8"));
const legacyToNative = (stats) => {
const native = {};
const direct = [
"strength", "stamina", "intellect", "wisdom", "agility", "health", "mana", "healthRecovery", "manaRecovery",
"physicalAttack", "physicalDefense", "magicalDefense", "magicalAttack", "physicalHit", "magicalHit",
"physicalCritical", "magicalCritical", "parry",
];
for (const key of direct) if (stats[key] > 0) native[key] = stats[key];
if (stats.dodge > 0) native.physicalDodge = stats.dodge;
if (stats.weaponDamage > 0) native.physicalDamage = stats.weaponDamage;
if (stats.magicalDamage > 0) native.magicalDamagePoint = stats.magicalDamage;
if (stats.magicalWeaponDamage > 0) native.magicalDamage = stats.magicalWeaponDamage;
if (stats.healingPower > 0) native.healPoint = stats.healingPower;
return native;
};
const compactProfiles = Object.fromEntries(Object.entries(reference.profiles).map(([classId, slots]) => [
classId,
Object.fromEntries(Object.entries(slots).map(([slot, rows]) => [slot, rows.map((entry) => legacyToNative(entry.stats))])),
]));
const gearData = {
schemaVersion: 1,
status: "source-item stat references with synthetic class/slot selection and level interpolation",
sourceRecordSha256: reference.sourceFiles,
statTypeMap: {
"12": "physicalAttack", "13": "physicalDefense", "14": "magicalDefense", "15": "magicalAttack",
"16": "physicalHit", "17": "physicalDodge", "18": "physicalCritical", "20": "magicalCritical",
"22": "parry", "25": "physicalDamage", "148": "magicalDamagePoint", "150": "healPoint", "191": "magicalDamage", "195": "magicalHit",
},
profiles: compactProfiles,
};
await writeFile(ruleOutput, `${JSON.stringify(ruleData, null, 2)}\n`);
await writeFile(gearOutput, `${JSON.stringify(gearData)}\n`);
console.log(`Exported native formula rules for ${CLASS_ROWS.length} classes, ${RACE_ROWS.length} races, and ${GENDER_ROWS.length} genders; wrote compact gear curves for ${Object.keys(compactProfiles).length} reference classes.`);
@@ -25,10 +25,10 @@
"eliteMilestones": 660,
"ordinaryFamilies": 269,
"sourceRankRows": 274,
"iconResolved": 930,
"iconResolved": 932,
"uniqueNativeIcons": 918,
"interfaceFdbEntries": 34954,
"executableOrNonCombat": 930,
"executableOrNonCombat": 932,
"unresolvedVisible": 0
},
"adaptations": [
+131 -81
View File
@@ -1,5 +1,11 @@
import fs from "node:fs";
import path from "node:path";
import {
collectRealmSpellIds,
expandCoaSpellIds,
resolveCoaSourcePaths,
sharedRealmSpellIds,
} from "./coa-source-policy.mjs";
const projectRoot = path.resolve(import.meta.dirname, "..");
const outputPath = path.join(projectRoot, "src", "game", "coaLiveData.generated.json");
@@ -10,63 +16,18 @@ const extractedClientRoot = path.resolve(
"LadiksMPQEditor",
"client-current",
);
const realmSpellDbc = path.join(
extractedClientRoot,
"area-52",
"patch-D",
"DBFilesClient",
"Spell.dbc",
);
const globalSpellDbc = path.join(
extractedClientRoot,
"patch-T",
"DBFilesClient",
"Spell.dbc",
);
const legacySpellDbc = path.resolve(
projectRoot,
"..",
"LadiksMPQEditor",
"patch-T",
"DBFilesClient",
"Spell.dbc",
);
const realmSkillLineAbilityDbc = path.join(
extractedClientRoot,
"area-52",
"patch-D",
"DBFilesClient",
"SkillLineAbility.dbc",
);
const globalSkillLineAbilityDbc = path.join(
extractedClientRoot,
"patch-M",
"DBFilesClient",
"SkillLineAbility.dbc",
);
const legacySkillLineAbilityDbc = path.resolve(
projectRoot,
"..",
"LadiksMPQEditor",
"patch-M",
"DBFilesClient",
"SkillLineAbility.dbc",
);
const defaultSpellDbc = fs.existsSync(realmSpellDbc)
? realmSpellDbc
: fs.existsSync(globalSpellDbc)
? globalSpellDbc
: legacySpellDbc;
const defaultSkillLineAbilityDbc = fs.existsSync(realmSkillLineAbilityDbc)
? realmSkillLineAbilityDbc
: fs.existsSync(globalSkillLineAbilityDbc)
? globalSkillLineAbilityDbc
: legacySkillLineAbilityDbc;
const cliArguments = process.argv.slice(2);
const fetchLiveTooltips = process.env.ASCENSION_COA_FETCH_TOOLTIPS === "1"
|| cliArguments.includes("--tooltips");
const spellDbcArgument = cliArguments.find((argument) => argument !== "--tooltips");
const spellDbcPath = path.resolve(spellDbcArgument ?? defaultSpellDbc);
const legacyExtractedRoot = path.resolve(projectRoot, "..", "LadiksMPQEditor");
const sourcePaths = resolveCoaSourcePaths({
extractedClientRoot,
legacyExtractedRoot,
classicSpellOverride: spellDbcArgument,
});
const classicSpellDbcPath = sourcePaths.classicSpellDbcPath;
const coaSpellDbcPath = sourcePaths.coaSpellDbcPath;
const legacySupportDbcDirectory = path.resolve(
projectRoot,
"..",
@@ -314,6 +275,26 @@ function dbcRankCandidates(buffer, names, spellSkillLines) {
return candidates;
}
function rankCandidatesForRows(classSpellRows, spellDbcBuffer, skillLineAbilityDbcPath) {
const progressionRows = classSpellRows.filter((spell) => (
!spell.excluded
&& !spell.advancementType
&& /^Rank \d+$/i.test(spell.rank)
&& !isDeprecated(`${spell.name} ${spell.rank}`)
));
const progressionNames = new Set(progressionRows.map((spell) => spell.name));
const progressionSkillLines = new Map();
for (const spell of progressionRows) {
const skillLines = progressionSkillLines.get(spell.name) ?? new Set();
for (const skillLineId of spell.skillLineIds) skillLines.add(skillLineId);
progressionSkillLines.set(spell.name, skillLines);
}
const spellSkillLines = dbcSkillLines(skillLineAbilityDbcPath);
const candidates = dbcRankCandidates(spellDbcBuffer, progressionNames, spellSkillLines)
.filter((candidate) => candidate.skillLineIds.some((skillLineId) => progressionSkillLines.get(candidate.name)?.has(skillLineId)));
return { progressionNames, candidates };
}
function dbcSpells(buffer, wantedIds, castTimes, durations, radii, ranges) {
assert(buffer.toString("ascii", 0, 4) === "WDBC", "Spell.dbc is not a WDBC file");
const recordCount = buffer.readUInt32LE(4);
@@ -501,38 +482,55 @@ const entries = Object.values(realm.talents.entriesByTab)
.sort((left, right) => left.classId - right.classId || left.tabId - right.tabId || left.y - right.y || left.x - right.x || left.id - right.id);
assert(entries.length >= 3_500, `Only received ${entries.length} CoA tree entries`);
const progressionNames = new Set(classSpellRows
.filter((spell) => (
!spell.excluded
&& !spell.advancementType
&& /^Rank \d+$/i.test(spell.rank)
&& !isDeprecated(`${spell.name} ${spell.rank}`)
))
.map((spell) => spell.name));
assert(fs.existsSync(spellDbcPath), `Spell.dbc was not found at ${spellDbcPath}`);
const spellDbcBuffer = fs.readFileSync(spellDbcPath);
const siblingSkillLineAbilityDbc = path.join(path.dirname(spellDbcPath), "SkillLineAbility.dbc");
const spellSkillLines = dbcSkillLines(
fs.existsSync(siblingSkillLineAbilityDbc) ? siblingSkillLineAbilityDbc : defaultSkillLineAbilityDbc,
assert(fs.existsSync(classicSpellDbcPath), `Classic Spell.dbc was not found at ${classicSpellDbcPath}`);
assert(fs.existsSync(coaSpellDbcPath), `CoA base Spell.dbc was not found at ${coaSpellDbcPath}`);
assert(fs.existsSync(sourcePaths.classicSkillLineAbilityDbcPath), `Classic SkillLineAbility.dbc was not found at ${sourcePaths.classicSkillLineAbilityDbcPath}`);
assert(fs.existsSync(sourcePaths.coaSkillLineAbilityDbcPath), `CoA SkillLineAbility.dbc was not found at ${sourcePaths.coaSkillLineAbilityDbcPath}`);
const classicSpellDbcBuffer = fs.readFileSync(classicSpellDbcPath);
const coaSpellDbcBuffer = fs.readFileSync(coaSpellDbcPath);
const classicProgression = rankCandidatesForRows(
classSpellRows.filter((spell) => classicClassIds.includes(spell.classId)),
classicSpellDbcBuffer,
sourcePaths.classicSkillLineAbilityDbcPath,
);
const progressionSkillLines = new Map();
for (const spell of classSpellRows.filter((candidate) => progressionNames.has(candidate.name))) {
const skillLines = progressionSkillLines.get(spell.name) ?? new Set();
for (const skillLineId of spell.skillLineIds) skillLines.add(skillLineId);
progressionSkillLines.set(spell.name, skillLines);
}
const rankCandidates = dbcRankCandidates(spellDbcBuffer, progressionNames, spellSkillLines)
.filter((candidate) => candidate.skillLineIds.some((skillLineId) => progressionSkillLines.get(candidate.name)?.has(skillLineId)));
const coaProgression = rankCandidatesForRows(
classSpellRows.filter((spell) => coaClassIds.includes(spell.classId)),
coaSpellDbcBuffer,
sourcePaths.coaSkillLineAbilityDbcPath,
);
const progressionNames = new Set([...classicProgression.progressionNames, ...coaProgression.progressionNames]);
const classicRankCandidates = classicProgression.candidates;
const coaRankCandidates = coaProgression.candidates;
const rankCandidates = [...classicRankCandidates, ...coaRankCandidates];
const coaEntrySpellIds = entries.flatMap((entry) => entry.rankDescriptions.map((rank) => rank.spellId));
const { classicIds: classicSpellIds, coaIds: coaRootSpellIds } = collectRealmSpellIds({
classicClassIds,
coaClassIds,
classSpellRows,
classicRankCandidates,
coaRankCandidates,
coaEntrySpellIds,
});
const coaSpellIds = expandCoaSpellIds(coaSpellDbcBuffer, coaRootSpellIds);
const wantedSpellIds = new Set([
...classSpellRows.map((spell) => spell.id),
...entries.flatMap((entry) => entry.rankDescriptions.map((rank) => rank.spellId)),
...coaEntrySpellIds,
...rankCandidates.map((candidate) => candidate.id),
...coaSpellIds,
]);
const castTimes = dbcNumberTable(path.join(supportDbcDirectory, "SpellCastTimes.dbc"), 1);
const durations = dbcNumberTable(path.join(supportDbcDirectory, "SpellDuration.dbc"), 3);
const radii = dbcNumberTable(path.join(supportDbcDirectory, "SpellRadius.dbc"), 3, true);
const ranges = dbcRangeTable(path.join(supportDbcDirectory, "SpellRange.dbc"));
const dbcById = dbcSpells(spellDbcBuffer, wantedSpellIds, castTimes, durations, radii, ranges);
const classicDbcById = dbcSpells(classicSpellDbcBuffer, classicSpellIds, castTimes, durations, radii, ranges);
const coaDbcById = dbcSpells(coaSpellDbcBuffer, coaSpellIds, castTimes, durations, radii, ranges);
const dbcById = new Map();
for (const spellId of wantedSpellIds) {
const dbc = classicSpellIds.has(spellId)
? classicDbcById.get(spellId)
: coaDbcById.get(spellId);
if (dbc) dbcById.set(spellId, dbc);
}
const entryRankBySpellId = new Map(entries.flatMap((entry) => (
entry.rankDescriptions.map((rank) => [rank.spellId, { ...rank, entryId: entry.id }])
)));
@@ -554,8 +552,8 @@ const progressionTooltipIds = [...new Set([
...classSpellRows
.filter((spell) => progressionNames.has(spell.name) && /^Rank \d+$/i.test(spell.rank))
.map((spell) => spell.id),
...rankCandidates.map((candidate) => candidate.id),
])];
...classicRankCandidates.map((candidate) => candidate.id),
])].filter((id) => classicSpellIds.has(id));
if (fetchLiveTooltips) {
const uncached = progressionTooltipIds.filter((spellId) => !previousTooltips.has(spellId));
console.log(`Fetching ${uncached.length} live spell tooltips (${previousTooltips.size} cached)`);
@@ -582,12 +580,33 @@ if (fetchLiveTooltips) {
}
const spellClassIds = new Map();
const coaSpellClassIds = new Map();
for (const spell of classSpellRows) {
const existing = spellClassIds.get(spell.id) ?? new Set();
existing.add(spell.classId);
spellClassIds.set(spell.id, existing);
if (coaClassIds.includes(spell.classId)) {
const coaExisting = coaSpellClassIds.get(spell.id) ?? new Set();
coaExisting.add(spell.classId);
coaSpellClassIds.set(spell.id, coaExisting);
}
}
const classRowBySpellId = new Map(classSpellRows.map((spell) => [spell.id, spell]));
const coaClassSpellRows = classSpellRows.filter((spell) => coaClassIds.includes(spell.classId));
const coaClassRowBySpellId = new Map(coaClassSpellRows.map((spell) => [spell.id, spell]));
const coaEntryBySpellId = new Map();
for (const entry of entries) {
for (const rank of entry.rankDescriptions) {
coaSpellClassIds.set(rank.spellId, (coaSpellClassIds.get(rank.spellId) ?? new Set()).add(entry.classId));
if (!coaEntryBySpellId.has(rank.spellId)) coaEntryBySpellId.set(rank.spellId, entry);
}
}
for (const candidate of coaRankCandidates) {
for (const spell of coaClassSpellRows) {
if (spell.name !== candidate.name || !spell.skillLineIds.some((id) => candidate.skillLineIds.includes(id))) continue;
coaSpellClassIds.set(candidate.id, (coaSpellClassIds.get(candidate.id) ?? new Set()).add(spell.classId));
}
}
const spells = [...wantedSpellIds]
.sort((left, right) => left - right)
.map((id) => {
@@ -602,7 +621,29 @@ const spells = [...wantedSpellIds]
level: classRow?.level ?? dbc?.spellLevel ?? 0,
rank: classRow?.rank || dbc?.rankText || (entryRank ? `Rank ${entryRank.rank}` : ""),
description: entryRank?.description || dbc?.description || "",
...(previousTooltips.has(id) ? { liveTooltip: previousTooltips.get(id) } : {}),
...(classicSpellIds.has(id) && previousTooltips.has(id) ? { liveTooltip: previousTooltips.get(id) } : {}),
sourceIds: classRow?.sourceIds ?? [],
advancementType: classRow?.advancementType || "",
advancementTabId: classRow?.advancementTabId || 0,
talented: classRow?.talented ?? false,
excluded: classRow?.excluded ?? false,
...(dbc ? { dbc } : {}),
};
});
const coaSpellOverrides = sharedRealmSpellIds(classicSpellIds, coaSpellIds)
.map((id) => {
const classRow = coaClassRowBySpellId.get(id);
const entry = coaEntryBySpellId.get(id);
const entryRank = entryRankBySpellId.get(id);
const dbc = coaDbcById.get(id);
return {
id,
classIds: [...(coaSpellClassIds.get(id) ?? [])].sort((left, right) => left - right),
name: classRow?.name || entry?.name || dbc?.name || "",
iconBasename: classRow?.iconBasename || entry?.iconBasename || "",
level: classRow?.level ?? dbc?.spellLevel ?? 0,
rank: classRow?.rank || dbc?.rankText || (entryRank ? `Rank ${entryRank.rank}` : ""),
description: entryRank?.description || dbc?.description || "",
sourceIds: classRow?.sourceIds ?? [],
advancementType: classRow?.advancementType || "",
advancementTabId: classRow?.advancementTabId || 0,
@@ -683,6 +724,9 @@ for (const [classId, rows] of classPages) {
});
}
}
const classRankCandidates = classicClassIds.includes(classId)
? classicRankCandidates
: coaRankCandidates;
for (const chain of bySignature.values()) {
const {
name,
@@ -693,7 +737,7 @@ for (const [classId, rows] of classPages) {
advancementType,
ranks,
} = chain;
for (const candidate of rankCandidates.filter((rank) => (
for (const candidate of classRankCandidates.filter((rank) => (
rank.name === name
&& rank.skillLineIds.some((skillLineId) => chainSkillLines.has(skillLineId))
))) {
@@ -748,7 +792,11 @@ const output = {
maximumLevel: Number(realm.max_level),
schemaVersion: realm.schema_version,
synchronizedAt: new Date().toISOString(),
spellDbcPath: spellDbcPath.replaceAll("\\", "/"),
spellDbcPath: classicSpellDbcPath.replaceAll("\\", "/"),
classicSpellDbcPath: classicSpellDbcPath.replaceAll("\\", "/"),
classicSkillLineAbilityDbcPath: sourcePaths.classicSkillLineAbilityDbcPath.replaceAll("\\", "/"),
coaSpellDbcPath: coaSpellDbcPath.replaceAll("\\", "/"),
coaSkillLineAbilityDbcPath: sourcePaths.coaSkillLineAbilityDbcPath.replaceAll("\\", "/"),
supportDbcDirectory: supportDbcDirectory.replaceAll("\\", "/"),
},
counts: {
@@ -762,6 +810,7 @@ const output = {
classicClassSpellRows: classSpellRows.filter((spell) => classicClassIds.includes(spell.classId)).length,
coaClassSpellRows: classSpellRows.filter((spell) => coaClassIds.includes(spell.classId)).length,
uniqueSpells: spells.length,
coaSpellOverrides: coaSpellOverrides.length,
dbcMatchedSpells: spells.filter((spell) => spell.dbc).length,
progressionChains: progressionChains.length,
classicProgressionChains: progressionChains.filter((chain) => classicClassIds.includes(chain.classId)).length,
@@ -776,6 +825,7 @@ const output = {
entries,
progressionChains,
spells,
coaSpellOverrides,
};
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
+202
View File
@@ -9,6 +9,7 @@ import {
loadAccountBossCards,
saveAccountBossCards,
replaceCharactersForOwner,
setRosterChangeListener,
deleteCharacter,
loadCombatPresentationSettings,
loginLocalAccount,
@@ -16,10 +17,12 @@ import {
saveSession,
updateCharacterActionBindings,
updateCharacterEquipment,
updateCharacterHunterPetFamily,
updateCharacterDungeonRunCheckpoint,
updateCharacterInventory,
updateCharacterManastormProgress,
updateCharacterProgression,
updateCharacterStatRuleset,
updateCharacterSettings,
updateCharacterSpecialization,
updateCombatPresentationSettings,
@@ -48,7 +51,206 @@ class MemoryStorage implements StorageLike {
removeItem(key: string) { this.values.delete(key); }
}
class FailingWriteStorage extends MemoryStorage {
failWrites = false;
setItem(key: string, value: string) {
if (this.failWrites) throw new Error("Storage quota exceeded.");
super.setItem(key, value);
}
}
describe("local profile repository", () => {
it("normalizes legacy Hunter family data to Wolf and persists implemented family choices", () => {
const storage = new MemoryStorage();
const hunter = createCharacter({
ownerId: "offline-roster",
name: "Shellguard",
categoryId: "wow",
raceId: "human",
classId: "hunter",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
expect(hunter.hunterPetFamily).toBe("wolf");
const cat = updateCharacterHunterPetFamily(hunter.ownerId, hunter.id, "cat", storage)!;
expect(cat.hunterPetFamily).toBe("cat");
expect(listCharacters(hunter.ownerId, storage)[0]?.hunterPetFamily).toBe("cat");
expect(updateCharacterHunterPetFamily(hunter.ownerId, hunter.id, "bear", storage)).toBeNull();
expect(listCharacters(hunter.ownerId, storage)[0]?.hunterPetFamily).toBe("cat");
const invalidStoredFamily = { ...cat, hunterPetFamily: "bear" } as unknown as typeof cat;
const [normalizedLegacy] = replaceCharactersForOwner(hunter.ownerId, [invalidStoredFamily], storage);
expect(normalizedLegacy?.hunterPetFamily).toBe("wolf");
});
it("defaults legacy saved characters to WoW stat rules and new RoM characters to native RoM", () => {
const storage = new MemoryStorage();
const legacyRom = {
id: "legacy-rom",
ownerId: "offline-roster",
name: "Old Healer",
categoryId: "rom",
raceId: "rom-human",
classId: "rom-priest",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
level: 4,
location: "Dungeon Entrance",
createdAt: 1,
lastPlayedAt: 1,
};
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
version: 7,
accounts: [],
rosters: { "offline-roster": [legacyRom] },
bossCardsByOwner: {},
presentationSettingsByOwner: {},
}));
expect(listCharacters("offline-roster", storage)[0]?.statRuleset).toBe("wow");
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).rosters["offline-roster"][0].statRuleset)
.toBe("wow");
const created = createCharacter({
ownerId: "offline-roster",
name: "New Healer",
categoryId: "rom",
raceId: "rom-human",
classId: "rom-priest",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
expect(created.statRuleset).toBe("rom");
});
it("keeps a legacy roster readable when its ruleset migration cannot be written", () => {
const storage = new FailingWriteStorage();
const character = createCharacter({
ownerId: "offline-roster", name: "Legacy Healer", classId: "rom-priest",
raceId: "rom-human", gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
const raw = JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!);
delete raw.rosters[character.ownerId][0].statRuleset;
const legacySave = JSON.stringify(raw);
storage.setItem(PROFILE_STORAGE_KEYS.database, legacySave);
storage.failWrites = true;
expect(listCharacters(character.ownerId, storage)[0]).toMatchObject({
id: character.id, statRuleset: "wow",
});
expect(updateCharacterStatRuleset(character.ownerId, character.id, "rom", storage)).toBeNull();
expect(storage.getItem(PROFILE_STORAGE_KEYS.database)).toBe(legacySave);
expect(listCharacters(character.ownerId, storage)[0]?.statRuleset).toBe("wow");
storage.failWrites = false;
expect(listCharacters(character.ownerId, storage)[0]?.statRuleset).toBe("wow");
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).rosters[character.ownerId][0].statRuleset)
.toBe("wow");
});
it("switches one RoM profile while preserving its build and other characters", () => {
const storage = new MemoryStorage();
const first = createCharacter({
ownerId: "offline-roster",
name: "First",
categoryId: "rom",
raceId: "rom-human",
classId: "rom-priest",
secondaryClassId: "rom-mage",
actionLoadouts: { "rom-priest:rom-mage": { "primary:face-top": "rom-priest-heal" } },
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
const item = {
instanceId: "kept-item",
itemId: 10412,
itemKey: "belt-of-the-fang",
name: "Belt of the Fang",
quality: "rare" as const,
inventorySlot: "waist",
armorType: "leather",
weaponType: null,
itemLevel: 17,
sourceItemLevel: 21,
stats: { ...EMPTY_ITEM_STATS, agility: 4, stamina: 4, armor: 15 },
quantity: 1,
sourceBossKey: "lady-anacondra",
sourceBossName: "Lady Anacondra",
acquiredAt: 5_000,
};
updateCharacterActionBindings(first.ownerId, first.id, { "primary:face-top": "rom-priest-heal" }, storage);
updateCharacterInventory(first.ownerId, first.id, [item], storage);
const second = createCharacter({
ownerId: "offline-roster",
name: "Second",
categoryId: "rom",
raceId: "rom-human",
classId: "rom-mage",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
const wowCharacter = createCharacter({
ownerId: "offline-roster",
name: "WoW Character",
categoryId: "wow",
raceId: "human",
classId: "priest",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
let cloudSnapshot: ReadonlyArray<ReturnType<typeof listCharacters>[number]> = [];
setRosterChangeListener((ownerId, characters) => {
if (ownerId === first.ownerId) cloudSnapshot = characters;
});
const updated = updateCharacterStatRuleset(first.ownerId, first.id, "wow", storage)!;
setRosterChangeListener(null);
expect(updated).toMatchObject({
statRuleset: "wow",
secondaryClassId: "rom-mage",
actionBindings: { "primary:face-top": "rom-priest-heal" },
actionLoadouts: { "rom-priest:rom-mage": { "primary:face-top": "rom-priest-heal" } },
inventory: [item],
});
expect(listCharacters(first.ownerId, storage).find((entry) => entry.id === first.id)?.statRuleset).toBe("wow");
expect(listCharacters(second.ownerId, storage).find((entry) => entry.id === second.id)?.statRuleset).toBe("rom");
expect(updateCharacterStatRuleset(first.ownerId, first.id, "rom", storage)?.statRuleset).toBe("rom");
expect(cloudSnapshot.find((entry) => entry.id === first.id)?.statRuleset).toBe("wow");
expect(updateCharacterDungeonRunCheckpoint(first.ownerId, first.id, {
version: DUNGEON_RUN_CHECKPOINT_VERSION,
dungeonId: "wailing-caverns",
difficultyId: "normal",
checkpointId: "entrance",
spawn: { footPosition: [-158, -82, 123], yaw: 0 },
encounters: [],
savedAt: 42,
}, storage)?.statRuleset).toBe("rom");
expect(updateCharacterStatRuleset(first.ownerId, second.id, "invalid", storage)).toBeNull();
expect(updateCharacterStatRuleset(first.ownerId, wowCharacter.id, "rom", storage)).toBeNull();
expect(updateCharacterStatRuleset(first.ownerId, "missing", "wow", storage)).toBeNull();
});
it("leaves the saved stat ruleset unchanged when storage rejects a switch", () => {
const storage = new FailingWriteStorage();
const character = createCharacter({
ownerId: "offline-roster",
name: "Steady",
categoryId: "rom",
raceId: "rom-human",
classId: "rom-priest",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
storage.failWrites = true;
expect(updateCharacterStatRuleset(character.ownerId, character.id, "wow", storage)).toBeNull();
storage.failWrites = false;
expect(listCharacters(character.ownerId, storage).find((entry) => entry.id === character.id)?.statRuleset)
.toBe("rom");
});
it("defaults new glyph loadouts to empty and persists explicit glyph choices", () => {
const storage = new MemoryStorage();
const character = createCharacter({
+66 -1
View File
@@ -33,6 +33,14 @@ import {
normalizeDungeonRunCheckpoint,
type DungeonRunCheckpoint,
} from "../game/dungeonRunCheckpoint";
import {
isWow335HunterPetFamily,
normalizeWow335HunterPetFamily,
} from "../game/wow335HunterPetFamilySelection";
import {
isCharacterStatRuleset,
normalizeCharacterStatRuleset,
} from "../game/characterStatRuleset";
const DATABASE_KEY = "healer-man.profile-database.v1";
const SESSION_KEY = "healer-man.session.v1";
@@ -123,6 +131,12 @@ function isCharacterProfile(value: unknown): value is CharacterProfile {
function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
const profile = { ...value };
profile.statRuleset = normalizeCharacterStatRuleset(value.statRuleset, value.classId);
if (value.classId === "hunter") {
profile.hunterPetFamily = normalizeWow335HunterPetFamily(value.hunterPetFamily);
} else {
delete profile.hunterPetFamily;
}
// Cards now belong to the owner; legacy fields are consumed before normalization.
delete profile.bossCards;
const legacy = value as CharacterProfile & {
@@ -235,8 +249,13 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
? parsed.bossCardsByOwner : {};
const bossCardsByOwner = Object.fromEntries(Object.entries(rawCards).map(([owner, cards]) => [owner, normalizeBossCardCollection(cards)]));
let cardsMigrated = false;
let statRulesetsMigrated = false;
const rosters = Object.fromEntries(Object.entries(parsed.rosters as Record<string, unknown>).map(([ownerId, roster]) => {
const characters = Array.isArray(roster) ? roster.filter(isCharacterProfile).slice(0, MAX_CHARACTERS) : [];
if (characters.some((character) => (
!isCharacterStatRuleset(character.statRuleset)
|| normalizeCharacterStatRuleset(character.statRuleset, character.classId) !== character.statRuleset
))) statRulesetsMigrated = true;
if (characters.some((character) => character.bossCards !== undefined)) {
const legacyCards = mergeBossCardCollections(characters.map((character) => character.bossCards), "sum");
bossCardsByOwner[ownerId] = mergeBossCardCollections([bossCardsByOwner[ownerId], legacyCards]);
@@ -278,7 +297,11 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {};
const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)]));
const database: ProfileDatabase = { version: 7, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner };
if (parsed.version !== 7 || presentationSettingsMigrated || cardsMigrated) writeDatabase(database, storage);
if (parsed.version !== 7 || presentationSettingsMigrated || cardsMigrated || statRulesetsMigrated) {
// A full or read-only device must still expose its existing roster.
// Retry migration on the next read when persistence is available.
try { writeDatabase(database, storage); } catch { /* Keep the normalized in-memory profiles. */ }
}
return database;
} catch {
return emptyDatabase();
@@ -535,6 +558,7 @@ export function createCharacter(
const now = Date.now();
const character = normalizeCharacterProfile({
...profile,
statRuleset: profile.statRuleset ?? (contentCategoryForClass(profile.classId) === "rom" ? "rom" : "wow"),
id: makeId("character"),
level: 1,
experience: 0,
@@ -553,6 +577,29 @@ export function createCharacter(
return { ok: true, character };
}
export function updateCharacterStatRuleset(
ownerId: string,
characterId: string,
value: unknown,
storage: StorageLike | null = browserLocalStorage(),
): CharacterProfile | null {
if (!storage || !isCharacterStatRuleset(value)) return null;
const database = readDatabase(storage);
let updated: CharacterProfile | null = null;
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
if (character.id !== characterId || contentCategoryForClass(character.classId) !== "rom") return character;
updated = normalizeCharacterProfile({ ...character, statRuleset: value });
return updated;
});
if (!updated) return null;
try {
writeDatabase(database, storage);
} catch {
return null;
}
return updated;
}
export function touchCharacter(
ownerId: string,
characterId: string,
@@ -788,6 +835,24 @@ export function updateCharacterEquipment(
return updated;
}
export function updateCharacterHunterPetFamily(
ownerId: string,
characterId: string,
family: unknown,
storage: StorageLike | null = browserLocalStorage(),
): CharacterProfile | null {
if (!storage || !isWow335HunterPetFamily(family)) return null;
const database = readDatabase(storage);
let updated: CharacterProfile | null = null;
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
if (character.id !== characterId || character.classId !== "hunter") return character;
updated = normalizeCharacterProfile({ ...character, hunterPetFamily: family });
return updated;
});
if (updated) writeDatabase(database, storage);
return updated;
}
export function loadAccountBossCards(
ownerId: string,
storage: StorageLike | null = browserLocalStorage(),
+6 -1
View File
@@ -1,6 +1,7 @@
import { dungeonById } from "../game/dungeonCatalog";
import { activateDungeonSession } from "../game/dungeonSession";
import { useGameStore } from "../game/store";
import { useCombatStore } from "../game/combatStore";
import {
classCanFillPartyRole,
defaultPartyRoleForClass,
@@ -15,7 +16,11 @@ import { dungeonContentIsUsable } from "../content/contentManager";
import { preparedBiteSizedRunSelection } from "./biteSizedLayoutContent";
export function installDungeonShellRuntime(): void {
registerDungeonShellRuntime({ enterDungeon });
registerDungeonShellRuntime({
enterDungeon,
canChangeCharacterStatRuleset: () => !Object.values(useCombatStore.getState().mobs)
.some((mob) => mob.engaged && !mob.dead),
});
}
function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: DungeonEntrySettings): boolean {
+1
View File
@@ -2,6 +2,7 @@ import type { PartyRole } from "../game/partyRoles";
export interface DungeonShellRuntime {
enterDungeon: (dungeonId: string, playerRole?: PartyRole, settings?: DungeonEntrySettings) => boolean;
canChangeCharacterStatRuleset?: () => boolean;
}
export interface DungeonEntrySettings {
+104
View File
@@ -6,6 +6,7 @@ import { installManastormShellRuntime } from "./manastormShellRuntime";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
import { DUNGEON_MANIFEST } from "../game/dungeonManifest";
import { useGameStore } from "../game/store";
import { useCombatStore } from "../game/combatStore";
import { useManastormStore } from "../game/manastormStore";
import { createEmptyManastormProgress, recordManastormCheckpoint } from "../game/manastormProgress";
import {
@@ -21,6 +22,8 @@ import {
resolveBossAdminEntry,
} from "../game/manastormAdminConfig";
import { useManastormAdminStore } from "../game/manastormAdminStore";
import { createCharacter, listCharacters, type StorageLike } from "./accountRepository";
import { useOnlineGroupStore } from "./onlineGroupStore";
installDungeonShellRuntime();
installManastormShellRuntime();
@@ -62,6 +65,20 @@ function readyBossForMode(modeId: number) {
const readyLevelingBoss = readyBossForMode(0);
const readyEndgameBoss = readyBossForMode(2);
if (!readyLevelingBoss || !readyEndgameBoss) throw new Error("Shell tests require runnable Manastorm bosses for both modes.");
class MemoryStorage implements StorageLike {
private values = new Map<string, string>();
getItem(key: string) { return this.values.get(key) ?? null; }
setItem(key: string, value: string) { this.values.set(key, value); }
removeItem(key: string) { this.values.delete(key); }
}
class FailingWriteStorage extends MemoryStorage {
failWrites = false;
setItem(key: string, value: string) {
if (this.failWrites) throw new Error("Storage quota exceeded.");
super.setItem(key, value);
}
}
const readyTestConfig = {
...DEFAULT_MANASTORM_ADMIN_CONFIG,
bosses: {
@@ -84,9 +101,11 @@ function prepareSelectedCharacter(): void {
describe("shell store", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
beforeEach(() => {
useOnlineGroupStore.getState().reset();
useShellStore.setState({
phase: "login",
session: null,
@@ -108,6 +127,91 @@ describe("shell store", () => {
expect(useShellStore.getState().notice).toMatch(/select or create/i);
});
it("saves a Hunter's selected family to the active character and local roster", () => {
const storage = new MemoryStorage();
vi.stubGlobal("window", { localStorage: storage });
const hunter = createCharacter({
ownerId: "offline-roster",
name: "Turtlekeeper",
categoryId: "wow",
raceId: "human",
classId: "hunter",
specializationId: "hunter:marksmanship",
gender: "female",
appearance: createDefaultAppearance(),
}, storage).character!;
useShellStore.setState({
session: { kind: "offline", ownerId: hunter.ownerId, displayName: "Offline" },
characters: [hunter],
activeCharacter: hunter,
});
expect(useShellStore.getState().saveActiveHunterPetFamily("turtle")).toBe(true);
expect(useShellStore.getState().activeCharacter?.hunterPetFamily).toBe("turtle");
expect(listCharacters(hunter.ownerId, storage)[0]?.hunterPetFamily).toBe("turtle");
expect(useShellStore.getState().saveActiveHunterPetFamily("cat")).toBe(true);
expect(useShellStore.getState().activeCharacter?.hunterPetFamily).toBe("cat");
expect(listCharacters(hunter.ownerId, storage)[0]?.hunterPetFamily).toBe("cat");
});
it("switches the selected RoM stat profile only from the main menu and reports storage failure", () => {
const storage = new FailingWriteStorage();
vi.stubGlobal("window", { localStorage: storage });
const romCharacter = createCharacter({
ownerId: "offline-roster",
name: "Runekeeper",
categoryId: "rom",
raceId: "rom-human",
classId: "rom-priest",
gender: "female",
appearance: createDefaultAppearance(),
}, storage).character!;
useShellStore.setState({
phase: "main-menu",
session: { kind: "offline", ownerId: romCharacter.ownerId, displayName: "Offline" },
characters: [romCharacter],
selectedCharacterId: romCharacter.id,
activeCharacter: null,
});
expect(useShellStore.getState().setSelectedCharacterStatRuleset("wow")).toBe(true);
expect(useShellStore.getState().characters[0]?.statRuleset).toBe("wow");
expect(listCharacters(romCharacter.ownerId, storage)[0]?.statRuleset).toBe("wow");
storage.failWrites = true;
expect(useShellStore.getState().setSelectedCharacterStatRuleset("rom")).toBe(false);
expect(useShellStore.getState().characters[0]?.statRuleset).toBe("wow");
expect(useShellStore.getState().notice).toMatch(/could not be saved/i);
storage.failWrites = false;
const previousMobs = useCombatStore.getState().mobs;
try {
useCombatStore.setState({ mobs: { "live-fight": { engaged: true, dead: false } as never } });
expect(useShellStore.getState().setSelectedCharacterStatRuleset("rom")).toBe(false);
expect(useShellStore.getState().notice).toMatch(/combat is active/i);
expect(listCharacters(romCharacter.ownerId, storage)[0]?.statRuleset).toBe("wow");
} finally {
useCombatStore.setState({ mobs: previousMobs });
}
useShellStore.setState({ activeCharacter: romCharacter });
expect(useShellStore.getState().setSelectedCharacterStatRuleset("rom")).toBe(false);
useShellStore.setState({ activeCharacter: null });
useOnlineGroupStore.setState({ snapshot: {
group: { activity: { id: "live-run" } } as never,
invitations: [],
} });
useShellStore.setState({ phase: "main-menu", notice: "" });
expect(useShellStore.getState().setSelectedCharacterStatRuleset("rom")).toBe(false);
expect(useShellStore.getState().characters[0]?.statRuleset).toBe("wow");
expect(useShellStore.getState().notice).toMatch(/live online group activity/i);
useOnlineGroupStore.getState().reset();
useShellStore.setState({ phase: "game", notice: "" });
expect(useShellStore.getState().setSelectedCharacterStatRuleset("rom")).toBe(false);
expect(useShellStore.getState().characters[0]?.statRuleset).toBe("wow");
expect(listCharacters(romCharacter.ownerId, storage)[0]?.statRuleset).toBe("wow");
});
it("routes a legacy WoW character through the mandatory specialization picker", () => {
prepareSelectedCharacter();
useShellStore.setState({ characters: [{ ...character, specializationId: null }] });
+47
View File
@@ -12,6 +12,8 @@ import {
updateCharacterActionBindings,
updateCharacterEquipment,
updateCharacterInventory,
updateCharacterHunterPetFamily,
updateCharacterStatRuleset,
saveAccountBossCards as persistAccountBossCards,
updateCharacterManastormProgress,
updateCharacterDungeonRunCheckpoint,
@@ -119,6 +121,8 @@ export interface ShellState {
saveActiveInventory: (inventory: readonly InventoryItem[]) => void;
saveAccountBossCards: (bossCards: BossCardCollection) => void;
saveActiveEquipment: (equipment: EquipmentAssignments) => void;
saveActiveHunterPetFamily: (family: unknown) => boolean;
setSelectedCharacterStatRuleset: (ruleset: unknown) => boolean;
saveActiveSettings: (settings: GameplaySettings) => void;
updateCombatPresentationSettings: (patch: Partial<CombatPresentationSettings>) => void;
resetCombatPresentationSettings: () => void;
@@ -286,6 +290,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
categoryId: draft.categoryId,
raceId: draft.raceId,
classId: draft.classId,
statRuleset: draft.categoryId === "rom" ? "rom" : "wow",
specializationId: draft.categoryId === "rom" ? null : draft.specializationId,
gender: draft.gender,
appearance: clampAppearance(draft.raceId, draft.gender, draft.appearance),
@@ -595,6 +600,48 @@ export const useShellStore = create<ShellState>((set, get) => ({
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
},
saveActiveHunterPetFamily: (family) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter || activeCharacter.classId !== "hunter") return false;
const updated = updateCharacterHunterPetFamily(
session.ownerId,
activeCharacter.id,
family,
);
if (!updated) return false;
set((state) => ({
activeCharacter: updated,
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
return true;
},
setSelectedCharacterStatRuleset: (ruleset) => {
const { phase, session, selectedCharacterId, activeCharacter } = get();
if (phase !== "main-menu" || !session || !selectedCharacterId) return false;
if (activeCharacter || dungeonShellRuntime()?.canChangeCharacterStatRuleset?.() === false) {
set({ notice: "Stat profiles cannot change while combat is active. Finish leaving the expedition first." });
return false;
}
if (useOnlineGroupStore.getState().snapshot.group?.activity) {
set({ notice: "Stat profiles cannot change during a live online group activity." });
return false;
}
const updated = updateCharacterStatRuleset(
session.ownerId,
selectedCharacterId,
ruleset,
);
if (!updated) {
set({ notice: "Stat profile could not be saved. Check device storage and try again." });
return false;
}
set((state) => ({
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
activeCharacter: state.activeCharacter?.id === updated.id ? updated : state.activeCharacter,
notice: "Stat profile saved.",
}));
return true;
},
saveActiveSettings: (settings) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter) return;
+6
View File
@@ -6,6 +6,8 @@ import type { GameplaySettings } from "../game/combatStore";
import type { ManastormProgress } from "../game/manastormProgress";
import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint";
import type { BossCardCollection } from "../game/bossCards";
import type { Wow335HunterPetFamily } from "../game/wow335HunterPetFamilySelection";
import type { CharacterStatRuleset } from "../game/characterStatRuleset";
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "arenas" | "arena-game" | "gm-admin" | "bite-sized-editor" | "game";
export type SessionKind = "account" | "offline";
@@ -32,6 +34,10 @@ export interface CharacterProfile {
categoryId?: ContentCategoryId;
/** RoM's inactive class. The active primary remains `classId`. */
secondaryClassId?: RomClassId | null;
/** Optional for legacy profile inputs; repository-normalized profiles always include a value. */
statRuleset?: CharacterStatRuleset;
/** Persisted choice for a Hunter's current pet family; legacy profiles default to Wolf. */
hunterPetFamily?: Wow335HunterPetFamily;
gender: GenderId;
appearance: CharacterAppearance;
level: number;
+1 -1
View File
@@ -197,7 +197,7 @@ export type AbilityEffect =
readonly resource?: ResourceType;
readonly percentage?: boolean;
/** Party-scoped resource effects use the ability's radius and targeting shape. */
readonly scope?: "target" | "party";
readonly scope?: "target" | "party" | "caster";
}
| { readonly kind: "combo"; readonly amount: number }
| { readonly kind: "taunt"; readonly durationMs: number; readonly maxTargets?: number }
+10 -1
View File
@@ -21,7 +21,16 @@ export function arenaAbilityIsLearned(ability: AbilityDefinition, character: Cha
}
export function createArenaCharacterKit(character: CharacterProfile, level = character.level): ArenaCharacterKit {
const stats = deriveCharacterStats(character.classId, level, activeEquipmentStatsForClass(character.equipment, character.inventory, PLAYER_EQUIPMENT_OWNER_ID, character.classId), character.raceId, {}, character.secondaryClassId);
const stats = deriveCharacterStats(
character.classId,
level,
activeEquipmentStatsForClass(character.equipment, character.inventory, PLAYER_EQUIPMENT_OWNER_ID, character.classId),
character.raceId,
{},
character.secondaryClassId,
character.gender,
character.statRuleset ?? "wow",
);
const profile = resourceProfileForClass(character.classId);
const resource = profile.type === "mana" ? { ...profile, maximum: stats.mana, initial: stats.mana, regenerationPerSecond: stats.mana * 0.004 } : profile;
const abilities = arenaCharacterAbilities(character, level).filter((ability) => arenaAbilityIsLearned(ability, character, level));
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import {
isCharacterStatRuleset,
normalizeCharacterStatRuleset,
} from "./characterStatRuleset";
describe("character stat ruleset", () => {
it("validates supported profile values", () => {
expect(isCharacterStatRuleset("wow")).toBe(true);
expect(isCharacterStatRuleset("rom")).toBe(true);
expect(isCharacterStatRuleset("RoM")).toBe(false);
expect(isCharacterStatRuleset(undefined)).toBe(false);
});
it("keeps missing and invalid saved values on WoW behavior", () => {
expect(normalizeCharacterStatRuleset(undefined, "rom-priest")).toBe("wow");
expect(normalizeCharacterStatRuleset("invalid", "rom-priest")).toBe("wow");
});
it("accepts native RoM only for a RoM class", () => {
expect(normalizeCharacterStatRuleset("rom", "rom-priest")).toBe("rom");
expect(normalizeCharacterStatRuleset("rom", "priest")).toBe("wow");
expect(normalizeCharacterStatRuleset("wow", "rom-priest")).toBe("wow");
});
});
+15
View File
@@ -0,0 +1,15 @@
import { isRomClassId, type ClassId } from "../app/characterCatalog";
export type CharacterStatRuleset = "wow" | "rom";
export function isCharacterStatRuleset(value: unknown): value is CharacterStatRuleset {
return value === "wow" || value === "rom";
}
/** Missing or invalid saved/runtime values keep the historical WoW behavior. */
export function normalizeCharacterStatRuleset(
value: unknown,
classId: ClassId,
): CharacterStatRuleset {
return value === "rom" && isRomClassId(classId) ? "rom" : "wow";
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import type { ClassId, RaceId } from "../app/characterCatalog";
import fixture from "./generated/characterStatRulesetCompatibility.json";
import type { ItemStats } from "./itemStats";
import { deriveCharacterStats } from "./wotlkStats";
describe("saved-character stat compatibility", () => {
for (const row of fixture.rows) {
it(`preserves frozen ${row.classId} level ${row.level} stats with and without an explicit WoW profile`, () => {
const args = [
row.classId as ClassId,
row.level,
fixture.gear as ItemStats,
row.raceId as RaceId,
fixture.modifiers,
row.secondaryClassId as ClassId | null,
] as const;
for (const stats of [
deriveCharacterStats(...args),
deriveCharacterStats(...args, undefined, "wow"),
]) {
// Compare the pre-existing outputs; extra ruleset metadata may be added.
expect(Object.fromEntries(Object.keys(row.expected).map((key) => [
key,
stats[key as keyof typeof stats],
]))).toEqual(row.expected);
}
});
}
});
@@ -0,0 +1,617 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
PLAYER_AURA_ENTITY_ID,
auraAdjustedCharacterStats,
useCombatStore,
equipmentAdjustedCombatAmount,
} from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
updatePartyRuntimePosition,
updatePartyRuntimeTargetVisibility,
} from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { deriveCharacterStats } from "./wotlkStats";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { usePartyBuffPolicyStore } from "./partyBuffPolicyStore";
import {
ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG,
ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID,
romMageDruidGreenGuardianMovementSpeedMultiplier,
} from "./romMageDruidGreenGuardianRuntime";
import { applyRomNativeStatModifiers, type RomNativeGearStats } from "./romNativeStats";
import { romPriestHolyBlessingWisdom } from "./romPriestHolyBlessingRuntime";
const manaAuraAbility = ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-priest-general-2")!;
const chainOfLight = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491_164
&& ability.classId === "rom-priest" && !ability.secondaryClassId);
function nativeTrinket(
instanceId: string,
romNative: RomNativeGearStats,
otherStats: Partial<typeof EMPTY_ITEM_STATS> = {},
) {
return {
instanceId,
itemId: 990_001,
itemKey: instanceId,
name: "Native stat test trinket",
quality: "rare" as const,
inventorySlot: "trinket",
armorType: null,
weaponType: null,
itemLevel: 30,
sourceItemLevel: 30,
stats: { ...EMPTY_ITEM_STATS, ...otherStats, romNative },
quantity: 1,
sourceBossKey: "integration-test",
sourceBossName: "Integration Test",
acquiredAt: 0,
};
}
function nativeCompanionSwingDamage(physicalAttack: number, foreignWowRatings = false): number {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 30 });
const targetId = "native-physical-attack-target";
const member = createConfiguredPartyMember({
name: "Native Attack Warrior",
classId: "rom-warrior",
raceId: "rom-human",
specialization: "Primary",
role: "damage",
statRuleset: "rom",
}, 30, 0, "native-physical-attack-warrior");
usePartyStore.getState().initializeParty(
`native-physical-attack-${physicalAttack}-${foreignWowRatings}`, 30,
useCombatStore.getState().health, "damage", 1, "rom",
);
usePartyStore.setState({
members: [{
...member,
gearStats: {
...EMPTY_ITEM_STATS,
...(foreignWowRatings ? {
attackPower: 2_000_000,
criticalStrikeRating: 2_000_000,
hitRating: 2_000_000,
} : {}),
romNative: { physicalAttack },
},
}],
command: "attack",
activeMobId: targetId,
});
registerPartyRuntimePosition(member.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().registerMob(targetId, {
name: "Native physical attack target", level: 30, maxHealth: 1_000_000, xpReward: 0,
});
setMobPosition(targetId, [2, 0, 0]);
useCombatStore.getState().damageMob(targetId, 1, 100);
updatePartyRuntimeTargetVisibility(member.id, targetId, true);
advanceDungeonPartyCombat(1_000);
const swing = useCombatStore.getState().combatEvents.find((event) => (
event.kind === "damage" && event.sourceActorId === member.id
&& event.targetActorId === targetId && event.abilityId === "party-auto-attack"
));
if (!swing) throw new Error("Native warrior companion did not produce its expected autoattack");
return swing.rawAmount;
}
function playerChannelTiming(ruleset: "wow" | "rom", hasteRating: number): {
castMs: number;
gcdMs: number;
} {
if (!chainOfLight) throw new Error("Missing generated RoM Priest Chain of Light channel");
const item = nativeTrinket(`channel-timing-${ruleset}-${hasteRating}`, {}, { hasteRating });
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
raceId: "rom-human",
level: 30,
statRuleset: ruleset,
inventory: [item],
equipment: { player: { trinket: item.instanceId } },
});
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().registerMob("channel-timing-target", {
name: "Channel timing target", level: 30, maxHealth: 10_000_000, xpReward: 0,
});
setMobPosition("channel-timing-target", [3, 0, 0]);
useCombatStore.getState().selectMob("channel-timing-target");
const now = 20_000;
expect(useCombatStore.getState().castAbility(chainOfLight.id, undefined, now)).toMatchObject({ ok: true });
const state = useCombatStore.getState();
const activeCast = state.activeCast;
if (!activeCast) throw new Error("Chain of Light did not start a timed cast");
return {
castMs: activeCast.completesAt - activeCast.startedAt,
gcdMs: state.globalCooldownEndsAt - activeCast.startedAt,
};
}
function temporaryManaAura(percent = 0.5) {
return {
kind: "apply-aura" as const,
recipient: "caster" as const,
aura: {
id: "character-stat-ruleset:test-mana-cap",
name: "Mana Pool Test Aura",
disposition: "buff" as const,
durationMs: 2_000,
maxStacks: 1,
stackBehavior: "refresh" as const,
modifiers: [{
kind: "resource" as const,
resource: "mana",
property: "maximum" as const,
operation: "percent" as const,
value: percent,
perStack: false,
}],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 0,
};
}
function castPlayerChannel(ruleset: "wow" | "rom", gear: RomNativeGearStats): {
actorRuleset: string | undefined;
amounts: number[];
} {
if (!chainOfLight) throw new Error("Missing generated RoM Priest Chain of Light channel");
const item = nativeTrinket(`channel-${ruleset}-native-trinket`, gear);
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
raceId: "rom-human",
level: 30,
statRuleset: ruleset,
inventory: [item],
equipment: { player: { trinket: item.instanceId } },
});
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().registerMob("native-channel-target", {
name: "Native channel target", level: 30, maxHealth: 10_000_000, xpReward: 0,
});
setMobPosition("native-channel-target", [3, 0, 0]);
useCombatStore.getState().selectMob("native-channel-target");
const actorRuleset = useCombatStore.getState().actors[PLAYER_AURA_ENTITY_ID]?.statRuleset;
expect(useCombatStore.getState().castAbility(chainOfLight.id, undefined, 10_000)).toMatchObject({ ok: true });
for (const now of [11_000, 12_000, 13_000, 14_000]) useCombatStore.getState().tick(1, now);
const amounts = useCombatStore.getState().combatEvents
.filter((event) => event.kind === "damage" && event.abilityId === chainOfLight.id)
.map((event) => event.rawAmount);
return { actorRuleset, amounts };
}
describe("selectable native RoM character stat ruleset runtime integration", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 30 });
usePartyStore.getState().initializeParty(
"native-stat-ruleset-integration", 30, useCombatStore.getState().health, "damage", 5, "rom",
);
});
it("threads the explicit native ruleset through the player and generated companion actor snapshots while omitted seeds stay legacy", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
secondaryClassId: "rom-knight",
raceId: "rom-human",
level: 30,
statRuleset: "rom",
});
usePartyStore.getState().initializeParty(
"native-stat-ruleset-actors", 30, useCombatStore.getState().health, "damage", 5, "rom",
);
useCombatStore.getState().synchronizeActors();
const state = useCombatStore.getState();
const playerActor = state.actors[PLAYER_AURA_ENTITY_ID];
const companions = usePartyStore.getState().members;
expect(state.statRuleset).toBe("rom");
expect(playerActor?.statRuleset).toBe("rom");
expect(playerActor?.resources.mana?.maximum).toBe(state.maxResourcePools.mana);
expect(companions.length).toBeGreaterThan(0);
for (const companion of companions) {
expect(companion.statRuleset).toBe("rom");
expect(state.actors[companion.id]?.statRuleset).toBe("rom");
expect(state.actors[companion.id]?.resources.mana?.maximum).toBe(companion.resourceMaximums?.mana);
}
const rageCaster = createConfiguredPartyMember({
name: "Native Warrior Priest",
classId: "rom-warrior",
secondaryClassId: "rom-priest",
raceId: "rom-human",
specialization: "Warrior/Priest",
role: "damage",
statRuleset: "rom",
}, 10, 0, "native-rage-caster-companion");
usePartyStore.setState((party) => ({ members: [...party.members, rageCaster] }));
useCombatStore.getState().synchronizeActors();
const rageCasterActor = useCombatStore.getState().actors[rageCaster.id];
const rageCasterNative = deriveCharacterStats(
"rom-warrior", 10, rageCaster.gearStats, rageCaster.raceId, {}, "rom-priest", rageCaster.gender, "rom",
).romNative;
expect(rageCasterNative?.usesMana).toBe(true);
expect(rageCaster.resourceMaximums?.mana).toBeCloseTo(rageCasterNative!.mana.max);
expect(rageCaster.resourcePools.mana).toBeCloseTo(rageCaster.resourceMaximums!.mana!, 0);
expect(rageCasterActor?.statRuleset).toBe("rom");
expect(rageCasterActor?.resources.mana?.maximum).toBeCloseTo(rageCasterNative!.mana.max);
expect(rageCasterActor?.resources.mana?.current).toBeCloseTo(rageCaster.resourcePools.mana!, 0);
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 30 });
useCombatStore.getState().synchronizeActors();
expect(useCombatStore.getState().statRuleset).toBe("wow");
expect(useCombatStore.getState().actors[PLAYER_AURA_ENTITY_ID]?.statRuleset).toBe("wow");
});
it("activates the canonical secondary at level 10 and mirrors the native Mana cap and current value", () => {
usePartyStore.getState().initializeParty(
"native-level-gate", 9, useCombatStore.getState().health, "damage", 5, "rom",
);
const before = createConfiguredPartyMember({
name: "Serenna",
classId: "rom-priest",
secondaryClassId: "rom-knight",
raceId: "rom-human",
specialization: "Primary",
role: "healer",
statRuleset: "rom",
}, 9, 0, "native-level-gate-canonical-member");
usePartyStore.setState({ members: [before] });
expect(before).toMatchObject({ classId: "rom-priest", level: 9, secondaryClassId: null, statRuleset: "rom" });
useCombatStore.getState().synchronizeActors();
usePartyStore.getState().syncLevel(10);
useCombatStore.getState().synchronizeActors();
const after = usePartyStore.getState().members.find((member) => member.name === "Serenna");
const actor = useCombatStore.getState().actors[after!.id];
const native = deriveCharacterStats(
"rom-priest", 10, after!.gearStats, after!.raceId, {}, "rom-knight", after!.gender, "rom",
).romNative;
expect(after).toMatchObject({ level: 10, secondaryClassId: "rom-knight", statRuleset: "rom" });
expect(native?.usesMana).toBe(true);
expect(after!.resourceMaximums?.mana).toBeCloseTo(native!.mana.max);
expect(after!.resourcePools.mana).toBeCloseTo(after!.resourceMaximums!.mana!, 0);
expect(actor?.statRuleset).toBe("rom");
expect(actor?.resources.mana?.maximum).toBeCloseTo(native!.mana.max);
expect(actor?.resources.mana?.current).toBeCloseTo(after!.resourcePools.mana!, 0);
});
it("keeps player and companion Mana current values and actor mirrors in step across a native maximum-Mana aura expiry", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 30, statRuleset: "rom",
});
const companion = createConfiguredPartyMember({
name: "Native Mana Priest",
classId: "rom-priest",
secondaryClassId: "rom-knight",
raceId: "rom-human",
specialization: "Primary",
role: "healer",
statRuleset: "rom",
}, 30, 0, "native-mana-aura-companion");
usePartyStore.setState({ members: [companion] });
useCombatStore.getState().synchronizeActors();
const basePlayerMaximum = useCombatStore.getState().maxResourcePools.mana!;
const baseCompanionMaximum = useCombatStore.getState().actors[companion.id]!.resources.mana!.maximum;
const playerCurrentBefore = useCombatStore.getState().resourcePools.mana!;
const companionCurrentBefore = useCombatStore.getState().actors[companion.id]!.resources.mana!.current;
const effect = temporaryManaAura();
expect(useCombatStore.getState().applyActorAura(PLAYER_AURA_ENTITY_ID, PLAYER_AURA_ENTITY_ID, manaAuraAbility, effect, 1_000)).toBe(true);
expect(useCombatStore.getState().applyActorAura(PLAYER_AURA_ENTITY_ID, companion.id, manaAuraAbility, effect, 1_000)).toBe(true);
const increasedPlayerMaximum = useCombatStore.getState().maxResourcePools.mana!;
const increasedCompanionMaximum = useCombatStore.getState().actors[companion.id]!.resources.mana!.maximum;
expect(increasedPlayerMaximum).toBeCloseTo(basePlayerMaximum * 1.5);
expect(increasedCompanionMaximum).toBeCloseTo(baseCompanionMaximum * 1.5);
expect(useCombatStore.getState().resourcePools.mana).toBe(playerCurrentBefore);
expect(useCombatStore.getState().actors[PLAYER_AURA_ENTITY_ID]?.resources.mana?.current).toBe(playerCurrentBefore);
expect(useCombatStore.getState().actors[companion.id]?.resources.mana?.current).toBe(companionCurrentBefore);
expect(usePartyStore.getState().members[0]?.resourcePools.mana).toBe(companionCurrentBefore);
expect(usePartyStore.getState().members[0]?.resourceMaximums?.mana).toBeCloseTo(increasedCompanionMaximum);
useCombatStore.getState().restoreActorResource(PLAYER_AURA_ENTITY_ID, "mana", increasedPlayerMaximum);
useCombatStore.getState().restoreActorResource(companion.id, "mana", increasedCompanionMaximum);
expect(useCombatStore.getState().resourcePools.mana).toBeCloseTo(increasedPlayerMaximum);
expect(usePartyStore.getState().members[0]?.resourcePools.mana).toBeCloseTo(increasedCompanionMaximum);
const resourceOnlyPlayerMaximum = useCombatStore.getState().maxResourcePools.mana!;
const resourceOnlyCompanionMaximum = useCombatStore.getState().actors[companion.id]!.resources.mana!.maximum;
const gmWisdomEffect = {
kind: "apply-aura" as const,
recipient: "caster" as const,
aura: {
id: "character-stat-ruleset:gm-wisdom",
name: "GM Wisdom Test Buff",
disposition: "buff" as const,
durationMs: null,
exclusiveGroup: "gm-party-buff:test-wisdom",
modifiers: [{ kind: "stat" as const, stat: "wisdom", operation: "flat" as const, value: 30 }],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 0,
};
expect(useCombatStore.getState().applyActorAura(
PLAYER_AURA_ENTITY_ID, PLAYER_AURA_ENTITY_ID, manaAuraAbility, gmWisdomEffect, 1_500,
)).toBe(true);
expect(useCombatStore.getState().applyActorAura(
PLAYER_AURA_ENTITY_ID, companion.id, manaAuraAbility, gmWisdomEffect, 1_500,
)).toBe(true);
const gmPlayerMaximum = useCombatStore.getState().maxResourcePools.mana!;
const gmCompanionMaximum = useCombatStore.getState().actors[companion.id]!.resources.mana!.maximum;
expect(gmPlayerMaximum).toBeGreaterThan(resourceOnlyPlayerMaximum);
expect(gmCompanionMaximum).toBeGreaterThan(resourceOnlyCompanionMaximum);
useCombatStore.getState().restoreActorResource(PLAYER_AURA_ENTITY_ID, "mana", gmPlayerMaximum);
useCombatStore.getState().restoreActorResource(companion.id, "mana", gmCompanionMaximum);
usePartyBuffPolicyStore.setState({
config: { schemaVersion: 1, rules: [] },
draft: { schemaVersion: 1, rules: [] },
});
useCombatStore.getState().applyDefaultPartyBuffs(2_000);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === gmWisdomEffect.aura.id)).toBe(false);
expect(useCombatStore.getState().maxResourcePools.mana).toBeCloseTo(resourceOnlyPlayerMaximum);
expect(useCombatStore.getState().resourcePools.mana).toBeCloseTo(resourceOnlyPlayerMaximum);
expect(useCombatStore.getState().actors[PLAYER_AURA_ENTITY_ID]?.resources.mana).toMatchObject({
current: resourceOnlyPlayerMaximum,
maximum: resourceOnlyPlayerMaximum,
});
expect(useCombatStore.getState().actors[companion.id]?.resources.mana).toMatchObject({
current: resourceOnlyCompanionMaximum,
maximum: resourceOnlyCompanionMaximum,
});
expect(usePartyStore.getState().members[0]?.resourcePools.mana).toBeCloseTo(resourceOnlyCompanionMaximum);
expect(usePartyStore.getState().members[0]?.resourceMaximums?.mana).toBeCloseTo(resourceOnlyCompanionMaximum);
useCombatStore.getState().tick(0, 3_000);
expect(useCombatStore.getState().maxResourcePools.mana).toBeCloseTo(basePlayerMaximum);
expect(useCombatStore.getState().resourcePools.mana).toBeCloseTo(basePlayerMaximum);
expect(useCombatStore.getState().actors[PLAYER_AURA_ENTITY_ID]?.resources.mana).toMatchObject({
current: basePlayerMaximum,
maximum: basePlayerMaximum,
});
expect(useCombatStore.getState().actors[companion.id]?.resources.mana).toMatchObject({
current: baseCompanionMaximum,
maximum: baseCompanionMaximum,
});
expect(usePartyStore.getState().members[0]?.resourcePools.mana).toBeCloseTo(baseCompanionMaximum);
expect(usePartyStore.getState().members[0]?.resourceMaximums?.mana).toBeCloseTo(baseCompanionMaximum);
});
it("applies native Holy Blessing after a native Wisdom BodyRate and ordinary Wisdom aura without changing MDMG", () => {
const gear = { wisdom: 35, wisdomRateFactor: 1.45, magicalDamage: 180, magicalDamageRateFactor: 1.2 };
const item = nativeTrinket("native-wisdom-test-trinket", gear);
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
raceId: "rom-human",
level: 30,
statRuleset: "rom",
inventory: [item],
equipment: { player: { trinket: item.instanceId } },
});
useCombatStore.getState().synchronizeActors();
const before = useCombatStore.getState();
const blessingActive = before.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.name === "Holy Blessing");
expect(blessingActive).toBe(true);
const base = deriveCharacterStats(
"rom-priest", 30, before.gearStats, before.raceId, {}, null, before.gender, "rom",
).romNative!;
const ordinaryWisdom = applyRomNativeStatModifiers(base, {
attributes: { wisdom: 30 },
rates: { wisdom: 1.2 },
});
const wisdomAfterBlessing = romPriestHolyBlessingWisdom(ordinaryWisdom.attributes.wisdom);
const blessingRate = ordinaryWisdom.rateFactors.wisdom * ordinaryWisdom.rateFactors.allAttributes;
const expected = applyRomNativeStatModifiers(ordinaryWisdom, {
attributes: { wisdom: (wisdomAfterBlessing - ordinaryWisdom.attributes.wisdom) / blessingRate },
});
expect(expected.magicalDamage).toBeCloseTo(base.magicalDamage);
const wisdomAura = {
kind: "apply-aura" as const,
recipient: "caster" as const,
aura: {
id: "character-stat-ruleset:test-wisdom",
name: "Ordinary Wisdom Test Aura",
disposition: "buff" as const,
durationMs: 2_000,
modifiers: [
{ kind: "stat" as const, stat: "wisdom", operation: "flat" as const, value: 30 },
{ kind: "stat" as const, stat: "wisdom", operation: "percent" as const, value: 0.2 },
],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 0,
};
const manaBeforeWisdomAura = before.maxResourcePools.mana!;
expect(useCombatStore.getState().applyActorAura(
PLAYER_AURA_ENTITY_ID, PLAYER_AURA_ENTITY_ID, manaAuraAbility, wisdomAura, 1_000,
)).toBe(true);
const withWisdomAura = useCombatStore.getState();
const effective = auraAdjustedCharacterStats(
deriveCharacterStats("rom-priest", 30, withWisdomAura.gearStats, withWisdomAura.raceId,
{}, null, withWisdomAura.gender, "rom"),
withWisdomAura.auras,
PLAYER_AURA_ENTITY_ID,
1_000,
);
expect(effective.romNative?.attributes.wisdom).toBeCloseTo(
(ordinaryWisdom.attributes.wisdom + 115) * 1.105,
);
expect(effective.romNative?.attributes.wisdom).toBeCloseTo(wisdomAfterBlessing);
expect(effective.romNative?.magicalDamage).toBeCloseTo(base.magicalDamage);
expect(effective.romNative?.magicalDefense).toBeCloseTo(expected.magicalDefense);
expect(effective.mana).toBeCloseTo(expected.mana.max);
expect(withWisdomAura.maxResourcePools.mana).toBeCloseTo(expected.mana.max);
expect(withWisdomAura.maxResourcePools.mana).toBeGreaterThan(manaBeforeWisdomAura);
useCombatStore.getState().tick(0, 3_000);
expect(useCombatStore.getState().maxResourcePools.mana).toBeCloseTo(manaBeforeWisdomAura);
expect(useCombatStore.getState().actors[PLAYER_AURA_ENTITY_ID]?.resources.mana?.maximum)
.toBeCloseTo(manaBeforeWisdomAura);
if (!chainOfLight) throw new Error("Missing generated RoM Priest Chain of Light channel");
useCombatStore.getState().registerMob("native-wisdom-channel-target", {
name: "Native Wisdom Channel Target", level: 30, maxHealth: 10_000_000, xpReward: 0,
});
setMobPosition("native-wisdom-channel-target", [3, 0, 0]);
useCombatStore.getState().selectMob("native-wisdom-channel-target");
expect(useCombatStore.getState().castAbility(chainOfLight.id, undefined, 10_000)).toMatchObject({ ok: true });
for (const now of [11_000, 12_000, 13_000, 14_000]) useCombatStore.getState().tick(1, now);
const amounts = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === chainOfLight.id
)).map((event) => event.rawAmount);
expect(amounts).toHaveLength(4);
expect(amounts.every((amount) => amount > 0)).toBe(true);
for (const amount of amounts) expect(amount).toBeCloseTo(amounts[0]!, 5);
});
it("uses the configured companion ruleset for a real native periodic damage source", () => {
const nativeGear: RomNativeGearStats = { magicalDamage: 160, magicalDamageRateFactor: 1.35 };
const native = createConfiguredPartyMember({
name: "Native Periodic Priest",
classId: "rom-priest",
raceId: "rom-human",
specialization: "Primary",
role: "damage",
statRuleset: "rom",
}, 30, 0, "native-periodic-companion");
const legacy = createConfiguredPartyMember({
name: "Legacy Periodic Priest",
classId: "rom-priest",
raceId: "rom-human",
specialization: "Primary",
role: "damage",
}, 30, 1, "legacy-periodic-companion");
const members = [native, legacy].map((member) => ({
...member,
gearStats: { ...member.gearStats, romNative: nativeGear },
}));
usePartyStore.setState({ members });
useCombatStore.getState().synchronizeActors();
for (const id of ["native-periodic-target", "legacy-periodic-target"]) {
useCombatStore.getState().registerMob(id, {
name: id, level: 30, maxHealth: 10_000_000, xpReward: 0,
});
}
const boneChill = ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-priest-primary-4");
const dot = boneChill?.effects.find((effect) => effect.kind === "dot");
if (!boneChill || !dot || dot.kind !== "dot") throw new Error("Missing generated Priest Bone Chill periodic damage");
const nativeExpected = equipmentAdjustedCombatAmount(
dot, native.level, members[0]!.gearStats, native.classId, boneChill, 0, undefined,
native.raceId, native.gender, "rom",
);
const legacyExpected = equipmentAdjustedCombatAmount(
dot, legacy.level, members[1]!.gearStats, legacy.classId, boneChill, 0, undefined,
legacy.raceId, legacy.gender, "wow",
);
expect(useCombatStore.getState().applyTimedActorEffect(native.id, "native-periodic-target", boneChill, dot, 1_000)).toBe(true);
expect(useCombatStore.getState().applyTimedActorEffect(legacy.id, "legacy-periodic-target", boneChill, dot, 1_000)).toBe(true);
const appliedEffects = useCombatStore.getState().effects.filter((effect) => effect.sourceAbilityId === boneChill.id);
expect(appliedEffects.map((effect) => effect.kind === "dot" ? effect.fixedAmount : null)).toEqual([
nativeExpected, legacyExpected,
]);
expect(useCombatStore.getState().actors[native.id]?.statRuleset).toBe("rom");
expect(useCombatStore.getState().actors[legacy.id]?.statRuleset).toBe("wow");
useCombatStore.getState().tick(0, 3_000);
const ticks = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === boneChill.id
));
expect(ticks.map((event) => [event.sourceActorId, event.targetActorId, event.rawAmount])).toEqual([
[native.id, "native-periodic-target", nativeExpected],
[legacy.id, "legacy-periodic-target", legacyExpected],
]);
});
it("advances Green Guardian stacks for a native Mage/Druid companion", () => {
const skill = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID
&& ability.classId === "rom-mage" && ability.secondaryClassId === "rom-druid");
if (!skill) throw new Error("Missing generated Mage/Druid Green Guardian ability");
const companion = createConfiguredPartyMember({
name: "Native Green Guardian Mage",
classId: "rom-mage",
secondaryClassId: "rom-druid",
raceId: "rom-elf",
specialization: "Primary",
role: "damage",
statRuleset: "rom",
}, 45, 0, "native-green-guardian-companion");
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().actors[companion.id]?.statRuleset).toBe("rom");
expect(useCombatStore.getState().applyActorAura(companion.id, companion.id, skill, effect, 1_000)).toBe(true);
useCombatStore.getState().tick(0.5, 1_500);
useCombatStore.getState().tick(0.5, 2_000);
useCombatStore.getState().tick(0.5, 2_500);
const guardian = useCombatStore.getState().auras.find((aura) => aura.targetId === companion.id
&& aura.definition.utilityTags?.includes(ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG));
expect(guardian?.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);
const movedGuardian = useCombatStore.getState().auras.find((aura) => aura.targetId === companion.id
&& aura.definition.utilityTags?.includes(ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG));
expect(movedGuardian?.stacks).toBe(1);
expect(romMageDruidGreenGuardianMovementSpeedMultiplier(
useCombatStore.getState().auras, companion.id, 2_750,
)).toBe(1.01);
useCombatStore.getState().tick(1, 3_750);
const stationaryGuardian = useCombatStore.getState().auras.find((aura) => aura.targetId === companion.id
&& aura.definition.utilityTags?.includes(ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG));
expect(stationaryGuardian?.stacks).toBe(2);
});
it("uses native and legacy source stats for player Chain of Light channel pulses", () => {
const gear: RomNativeGearStats = { magicalDamage: 140, magicalDamageRateFactor: 1.25 };
const legacy = castPlayerChannel("wow", gear);
clearMobRuntimeRegistry();
const native = castPlayerChannel("rom", gear);
expect(legacy.actorRuleset).toBe("wow");
expect(native.actorRuleset).toBe("rom");
expect(legacy.amounts).toHaveLength(4);
expect(native.amounts).toHaveLength(4);
expect(native.amounts[0]).toBeGreaterThan(legacy.amounts[0]!);
for (const amount of legacy.amounts) expect(amount).toBeCloseTo(legacy.amounts[0]!, 5);
for (const amount of native.amounts) expect(amount).toBeCloseTo(native.amounts[0]!, 5);
});
it("keeps native companion autoattacks on RoM ATK and ignores foreign WoW attack and haste ratings", () => {
const noNativeAttack = nativeCompanionSwingDamage(0);
const nativeAttack = nativeCompanionSwingDamage(2_000);
const nativeAttackWithForeignRatings = nativeCompanionSwingDamage(2_000, true);
expect(nativeAttack).toBeGreaterThan(noNativeAttack);
expect(nativeAttackWithForeignRatings).toBe(nativeAttack);
const nativeBaseTiming = playerChannelTiming("rom", 0);
const nativeForeignHasteTiming = playerChannelTiming("rom", 1_000_000);
const legacyForeignHasteTiming = playerChannelTiming("wow", 1_000_000);
expect(nativeForeignHasteTiming).toEqual(nativeBaseTiming);
expect(legacyForeignHasteTiming.castMs).toBeLessThan(nativeBaseTiming.castMs);
expect(legacyForeignHasteTiming.gcdMs).toBeLessThan(nativeBaseTiming.gcdMs);
});
});
+43 -7
View File
@@ -9,10 +9,18 @@ import {
import {
COA_PROGRESSION_CHAINS,
COA_TREE_ENTRIES,
ascensionSpellById,
coaClassIdFromDbcId,
coaSpellById,
} from "./coaLiveCatalog";
const UNSOURCED_BASE_RANK_SPELL_IDS = new Set([
501862, 501863, 501864, 501865,
501918, 501919, 501920,
501922, 501923, 501924, 501925,
502889,
560469,
]);
const abilities = Object.values(COA_ABILITIES_BY_CLASS).flat();
it("keeps Drakkari Barrier friendly and its retaliation in the damage-taken proc", () => {
@@ -63,12 +71,17 @@ describe("Ascension Conquest ability catalog", () => {
expect(payload.effects.find(effect => effect.kind === "hot")).toMatchObject({ scope: "party", radius: 30, maximumTargets: 1 });
});
it("normalizes negative DBC cast-time sentinels for runtime ranks", () => {
const berserkerAxe = ranks.find(({ rank }) => rank.spellId === 804138);
expect(berserkerAxe?.rank).toMatchObject({ castMode: "instant", castTimeMs: 0 });
});
it("covers every class, source family, and playable rank", () => {
expect(Object.keys(COA_ABILITIES_BY_CLASS).sort()).toEqual([...COA_CLASS_IDS].sort());
expect(Object.keys(COA_CLASS_RESOURCES).sort()).toEqual([...COA_CLASS_IDS].sort());
expect(abilities).toHaveLength(1_275);
expect(ranks).toHaveLength(3_394);
expect(new Set(ranks.map(({ rank }) => rank.spellId)).size).toBe(3_347);
expect(abilities).toHaveLength(1_276);
expect(ranks).toHaveLength(3_395);
expect(new Set(ranks.map(({ rank }) => rank.spellId)).size).toBe(3_348);
const runtimeSpellIds = new Set(ranks.map(({ rank }) => rank.spellId));
for (const chain of COA_PROGRESSION_CHAINS) {
@@ -87,7 +100,7 @@ describe("Ascension Conquest ability catalog", () => {
}
});
it("gives every family and rank finite, executable runtime data", () => {
it("gives every rank finite data and every sourced rank an executable effect", () => {
expect(new Set(abilities.map((ability) => ability.id)).size).toBe(abilities.length);
for (const ability of abilities) {
expect(ability.classId, ability.id).toBeTruthy();
@@ -109,13 +122,36 @@ describe("Ascension Conquest ability catalog", () => {
expect(rank.cost.amount, label).toBeGreaterThanOrEqual(0);
expect(rank.effects.length, label).toBeGreaterThan(0);
expect(rank.effects.some((effect) => effect.kind === "scripted" || effect.kind === "unsupported"), label).toBe(false);
if (!ability.passive) {
if (!ability.passive && !UNSOURCED_BASE_RANK_SPELL_IDS.has(rank.spellId)) {
expect(rank.effects.some((effect) => effect.kind !== "utility" || effect.action !== "class-script"), label).toBe(true);
}
}
}
});
it("keeps known BASE DBC gaps explicit without borrowing another realm's payload", () => {
const emptySourcePlaceholder = [{
kind: "utility",
action: "class-script",
effectType: 0,
auraType: 0,
miscValue: 0,
triggerSpellId: 0,
durationMs: 0,
}];
for (const spellId of UNSOURCED_BASE_RANK_SPELL_IDS) {
const label = `spell ${spellId}`;
expect(coaSpellById(spellId), label).not.toBeNull();
expect(coaSpellById(spellId)?.dbc, `${label} CoA BASE row`).toBeUndefined();
expect(ascensionSpellById(spellId)?.dbc, `${label} classic source row`).toBeUndefined();
const sourceRank = ranks.find(({ rank }) => rank.spellId === spellId);
expect(sourceRank, label).toBeDefined();
if (!sourceRank) continue;
expect(sourceRank.rank.description, label).toBe("No client tooltip is available.");
expect(sourceRank.rank.effects, label).toEqual(emptySourcePlaceholder);
}
});
it("translates every standard DBC combat effect into its executable semantic", () => {
const applyAuraTypes = new Set([0, 3, 6, 27, 35, 65, 119, 128, 129, 143, 190]);
const triggerTypes = new Set([32, 64, 140, 141, 142, 148, 151, 160, 165, 169, 175, 178, 183]);
@@ -199,7 +235,7 @@ describe("Ascension Conquest ability catalog", () => {
it("ships executable hidden payloads for proc-triggered DBC spell IDs", () => {
// Geode Barrage now uses its ranked parent payload instead of hidden placeholder 803138.
expect(Object.keys(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)).toHaveLength(584);
expect(Object.keys(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)).toHaveLength(578);
expect(COA_TRIGGERED_ABILITIES_BY_SPELL_ID[803138]).toBeUndefined();
expect(COA_TRIGGERED_ABILITIES_BY_SPELL_ID[800951]).toMatchObject({
name: "Battle Rhythm",
@@ -207,7 +243,7 @@ describe("Ascension Conquest ability catalog", () => {
});
expect(Object.values(COA_TRIGGERED_ABILITIES_BY_SPELL_ID).filter((ability) => (
ability.effects.some((effect) => effect.kind !== "utility")
))).toHaveLength(545);
))).toHaveLength(536);
for (const [spellId, ability] of Object.entries(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)) {
expect(ability.dbcSpellId, spellId).toBe(Number(spellId));
expect(ability.effects.length, spellId).toBeGreaterThan(0);
+30 -14
View File
@@ -26,6 +26,8 @@ import {
coaEntryRank,
coaIconUrl,
coaSpellById,
ascensionSpellById,
isCoaClassId,
type CoaDbcEffect,
type CoaDbcSpell,
type CoaProgressionChain,
@@ -44,9 +46,13 @@ interface CoaSourceSpell {
const COA_TRIGGERED_DBC_BY_ID = new Map(
(triggeredSpellCatalog.spells as unknown as readonly CoaDbcSpell[]).map((spell) => [spell.id, spell]),
);
const CLASSIC_TRIGGERED_DBC_BY_ID = new Map(
((triggeredSpellCatalog as unknown as { classicSpells?: readonly CoaDbcSpell[] }).classicSpells
?? triggeredSpellCatalog.spells as unknown as readonly CoaDbcSpell[]).map((spell) => [spell.id, spell]),
);
function coaSourceSpellById(spellId: number): CoaSourceSpell | null {
const live = coaSpellById(spellId);
function coaSourceSpellById(spellId: number, classic = false): CoaSourceSpell | null {
const live = (classic ? ascensionSpellById : coaSpellById)(spellId);
if (live?.dbc) return {
name: live.name,
rank: live.rank,
@@ -54,7 +60,9 @@ function coaSourceSpellById(spellId: number): CoaSourceSpell | null {
...(live.liveTooltip ? { liveTooltip: live.liveTooltip } : {}),
dbc: live.dbc,
};
const dbc = COA_TRIGGERED_DBC_BY_ID.get(spellId);
// An unresolved live record must not inherit a different realm's fallback.
if (live) return null;
const dbc = (classic ? CLASSIC_TRIGGERED_DBC_BY_ID : COA_TRIGGERED_DBC_BY_ID).get(spellId);
return dbc ? {
name: dbc.name || `Triggered Spell ${spellId}`,
rank: dbc.rankText,
@@ -750,6 +758,7 @@ function effectsForSpell(
abilityName: string,
depth = 0,
visitedSpellIds: ReadonlySet<number> = new Set(),
classic = false,
): readonly AbilityEffect[] {
if (!spell) {
if (/\b(?:heal|restore|mend|rejuvenat)\w*\b/i.test(description)) return [{ kind: "heal", coefficient: 1 }];
@@ -856,7 +865,7 @@ function effectsForSpell(
if (effect.effectType === 30) {
// Primalist's imported energizes use DBC power units. Other CoA class
// script payloads retain their existing interpretation in this pass.
if (!coaSpellById(spell.id)?.classIds.includes(31)) {
if (!(classic ? ascensionSpellById : coaSpellById)(spell.id)?.classIds.includes(31)) {
effects.push({ kind: "resource", amount: Math.abs(effect.basePoints) });
continue;
}
@@ -865,7 +874,7 @@ function effectsForSpell(
continue;
}
if (effect.effectType === 137) {
effects.push({ kind: "resource", ...(coaSpellById(spell.id)?.classIds.includes(31) ? { resource: resourceForPowerType(effect.miscValue) } : {}),
effects.push({ kind: "resource", ...((classic ? ascensionSpellById : coaSpellById)(spell.id)?.classIds.includes(31) ? { resource: resourceForPowerType(effect.miscValue) } : {}),
amount: Math.abs(effect.basePoints), percentage: true });
continue;
}
@@ -941,18 +950,18 @@ function effectsForSpell(
}
const triggeredSpellId = effect.triggerSpellId > 0
? effect.triggerSpellId
: effect.effectType === 168 && coaSourceSpellById(effect.miscValue) ? effect.miscValue : 0;
: effect.effectType === 168 && coaSourceSpellById(effect.miscValue, classic) ? effect.miscValue : 0;
if ((TRIGGER_SPELL_EFFECTS.has(effect.effectType) || effect.effectType === 168) && triggeredSpellId > 0) {
effects.push({
kind: "trigger-spell",
spellId: triggeredSpellId,
// Expanded payloads below execute in the parent; retain this marker
// for source lookup without executing the same payload a second time.
expanded: depth < 2 && !visitedSpellIds.has(triggeredSpellId) && Boolean(coaSourceSpellById(triggeredSpellId)),
expanded: depth < 2 && !visitedSpellIds.has(triggeredSpellId) && Boolean(coaSourceSpellById(triggeredSpellId, classic)),
...([141, 142, 183].includes(effect.effectType) ? { withValue: effect.basePoints } : {}),
});
if (depth < 2 && !visitedSpellIds.has(triggeredSpellId)) {
const triggered = coaSourceSpellById(triggeredSpellId);
const triggered = coaSourceSpellById(triggeredSpellId, classic);
if (triggered) {
const expandedVisited = new Set(visitedSpellIds);
expandedVisited.add(spell.id);
@@ -962,6 +971,7 @@ function effectsForSpell(
triggered.name,
depth + 1,
expandedVisited,
classic,
).filter((nested) => nested.kind !== "utility"));
} else if (effect.effectType === 142) {
const describesDamagePayload = /\bdamage\b/i.test(description);
@@ -1015,7 +1025,7 @@ function effectsForSpell(
continue;
}
if (effect.effectType === 192 || effect.effectType === 195) {
const affected = coaSourceSpellById(effect.miscValue);
const affected = coaSourceSpellById(effect.miscValue, classic);
const describedNames = description.match(/cooldown of\s+(.+?)(?:\s+spells?\b|\.)/i)?.[1]
?.split(/,|\band\b/i)
.map((name) => name.trim())
@@ -1225,14 +1235,19 @@ function rankDefinition(
spellId: number,
rank: number,
level: number,
classic = false,
): AbilityRankDefinition {
const liveSpell = coaSourceSpellById(spellId);
const liveSpell = coaSourceSpellById(spellId, classic);
const dbc = liveSpell?.dbc;
const liveTooltip = liveSpell?.liveTooltip ?? "";
const mechanicsTooltip = liveTooltipMechanics(liveSpell, liveTooltip);
const description = liveTooltip || liveSpell?.description || dbc?.description || "No client tooltip is available.";
const effects = effectsForSpell(dbc, description, liveSpell?.name ?? dbc?.name ?? `Spell ${spellId}`);
const castTimeMs = liveTooltipCastTime(mechanicsTooltip, dbc?.castTimeMs ?? 0);
const effects = effectsForSpell(dbc, description, liveSpell?.name ?? dbc?.name ?? `Spell ${spellId}`, 0, new Set(), classic);
const sourcedCastTimeMs = liveTooltipCastTime(mechanicsTooltip, dbc?.castTimeMs ?? 0);
// DBC cast-time lookups can yield negative sentinels (for example -1000000
// at cast-time index 18); these are not executable durations. Keep classic
// runtime behavior untouched while normalizing CoA rank values.
const castTimeMs = classic ? sourcedCastTimeMs : Math.max(0, sourcedCastTimeMs);
const channeling = Boolean(dbc?.channelInterruptFlags && dbc.durationMs > 0);
const channelTickMs = channeling ? dbc?.effects
.map((effect) => effect.periodMs)
@@ -1268,9 +1283,10 @@ export function ascensionProgressionAbility(
id = chain.id,
): AbilityDefinition | null {
if (!chain.ranks.length) return null;
const ranks = chain.ranks.map((rank) => rankDefinition(resource, rank.spellId, rank.rank, rank.level));
const classic = !isCoaClassId(classId);
const ranks = chain.ranks.map((rank) => rankDefinition(resource, rank.spellId, rank.rank, rank.level, classic));
const first = ranks[0];
const firstSpell = coaSourceSpellById(first.spellId);
const firstSpell = coaSourceSpellById(first.spellId, classic);
const dbc = firstSpell?.dbc;
const target = targetForSpell(dbc, first.effects, first.description);
const radius = Math.max(0, ...((dbc?.effects ?? []).map((effect) => effect.radius)));
+2 -1
View File
@@ -30,7 +30,8 @@ describe("CoA healer resources and recipients", () => {
const state = useCombatStore.getState();
usePartyStore.getState().initializeParty("tinker-mana", 80, state.health);
const member = { ...usePartyStore.getState().members[0], classId: "tinker" as const, raceId: state.raceId, level: 80,
resourceType: "energy" as const, maxResource: state.maxResource, resourcePools: state.resourcePools, gearStats: state.gearStats };
resourceType: "energy" as const, maxResource: state.maxResource, resourcePools: state.resourcePools,
resourceMaximums: state.maxResourcePools, gearStats: state.gearStats };
const actor = companionActorFromMember(member);
expect(state.maxResourcePools.mana).toBeGreaterThan(1_000);
expect(actor.resources.mana.current).toBe(state.resourcePools.mana);
+10 -9
View File
@@ -14,7 +14,7 @@ import {
} from "./coaLiveCatalog";
describe("live Ascension Conquest catalog", () => {
it("contains the complete validated Vol'Jin payload", () => {
it("contains the pinned August Vol'Jin trees with separately preserved classic data", () => {
expect(COA_LIVE_SOURCE).toMatchObject({
realmId: 40,
realmSlug: "voljin",
@@ -24,24 +24,24 @@ describe("live Ascension Conquest catalog", () => {
expect(COA_LIVE_COUNTS).toMatchObject({
classes: 21,
tabs: 91,
entries: 3612,
abilityEntries: 700,
talentEntries: 2912,
rankedNodeSpells: 3929,
entries: 3618,
abilityEntries: 701,
talentEntries: 2917,
rankedNodeSpells: 3933,
classSpellRows: 12374,
classicClassSpellRows: 6930,
coaClassSpellRows: 5444,
uniqueSpells: 15439,
dbcMatchedSpells: 15439,
progressionChains: 1822,
classicProgressionChains: 1266,
classicBaseProgressionChains: 612,
coaProgressionChains: 556,
progressionRanks: 8405,
});
expect(COA_LIVE_SOURCE.spellDbcPath).toContain("client-current/area-52/patch-D");
expect(COA_LIVE_SOURCE.coaSpellDbcPath).toContain("client-current/patch-T");
expect(COA_LIVE_SOURCE.classicSpellDbcPath).toContain("client-current/area-52/patch-D");
expect(COA_LIVE_SOURCE.supportDbcDirectory).toContain("client-current/patch-S");
expect(COA_LIVE_COUNTS.dbcMatchedSpells).toBe(COA_LIVE_COUNTS.uniqueSpells);
expect(COA_LIVE_SOURCE.synchronizedAt).toBe("2026-08-12T21:02:51+00:00");
expect(COA_LIVE_COUNTS.dbcMatchedSpells).toBeLessThan(COA_LIVE_COUNTS.uniqueSpells);
expect(COA_LIVE_CLASSES).toHaveLength(COA_CLASS_IDS.length);
expect(COA_TREE_ENTRIES).toHaveLength(COA_LIVE_COUNTS.entries);
});
@@ -89,6 +89,7 @@ describe("live Ascension Conquest catalog", () => {
expect(rank.spellId).toBeGreaterThan(0);
expect(rank.description.length).toBeGreaterThan(0);
expect(coaSpellById(rank.spellId)).not.toBeNull();
expect(coaSpellById(rank.spellId)?.dbc, `node ${entry.id} rank ${rank.rank}`).toBeDefined();
rankCount += 1;
}
}
+14
View File
@@ -21,6 +21,10 @@ export interface CoaLiveSource {
readonly synchronizedAt: string;
readonly spellDbcPath: string;
readonly supportDbcDirectory: string;
readonly coaSpellDbcPath?: string;
readonly classicSpellDbcPath?: string;
readonly coaSkillLineAbilityDbcPath?: string;
readonly classicSkillLineAbilityDbcPath?: string;
}
export interface CoaLiveCounts {
@@ -205,6 +209,7 @@ interface CoaLiveData {
readonly entries: readonly CoaTreeEntry[];
readonly progressionChains: readonly CoaProgressionChain[];
readonly spells: readonly CoaLiveSpell[];
readonly coaSpellOverrides?: readonly CoaLiveSpell[];
}
const data = liveData as unknown as CoaLiveData;
@@ -219,6 +224,10 @@ const CLASSIC_CLASS_ID_BY_DBC_ID = Object.freeze(Object.fromEntries(
.map((definition) => [definition.dbcId, definition.id]),
)) as Readonly<Record<number, BaseClassId>>;
const SPELL_BY_ID = new Map(data.spells.map((spell) => [spell.id, spell]));
const COA_SPELL_BY_ID = new Map([
...data.spells.filter((spell) => spell.classIds.some((classId) => classId > 11)),
...(data.coaSpellOverrides ?? []),
].map((spell) => [spell.id, spell]));
const ENTRY_BY_ID = new Map(data.entries.map((entry) => [entry.id, entry]));
const CLASS_BY_DBC_ID = new Map(data.classes.map((definition) => [definition.classId, definition]));
@@ -266,6 +275,11 @@ export function coaEntriesForTab(classId: CoaClassId, tabId: number): readonly C
}
export function coaSpellById(spellId: number): CoaLiveSpell | null {
return COA_SPELL_BY_ID.get(spellId) ?? null;
}
/** Classic classes retain their realm-specific values when a spell ID is shared. */
export function ascensionSpellById(spellId: number): CoaLiveSpell | null {
return SPELL_BY_ID.get(spellId) ?? null;
}
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { gunzipSync } from "node:zlib";
import { describe, expect, it } from "vitest";
import { ASCENSION_CLASSIC_ABILITIES_BY_CLASS } from "./ascensionClassicAbilityCatalog";
import { ASCENSION_CLASSIC_PROGRESSION_CHAINS, COA_TREE_ENTRIES, ascensionSpellById } from "./coaLiveCatalog";
const july = JSON.parse(gunzipSync(readFileSync("data/coa-reference/baselines/coa-july-2026-07-25.json.gz")).toString("utf8"));
describe("offline CoA promotion preservation", () => {
it("preserves every July node identity and all classic chains and owned spell records", () => {
const entries = new Map(COA_TREE_ENTRIES.map((node) => [node.id, node]));
for (const node of july.entries) expect(entries.get(node.id)?.classId, String(node.id)).toBe(node.classId);
expect(ASCENSION_CLASSIC_PROGRESSION_CHAINS).toEqual(july.progressionChains.filter((chain: { classId: number }) => chain.classId <= 11));
for (const spell of july.spells) {
if (spell.classIds.some((id: number) => id <= 11)) expect(ascensionSpellById(spell.id), `classic spell ${spell.id}`).toEqual(spell);
}
});
it("leaves all 612 classic generated progression abilities identical to the pre-promotion capture", () => {
expect(Object.values(ASCENSION_CLASSIC_ABILITIES_BY_CLASS).flat()).toHaveLength(612);
const digest = createHash("sha256").update(JSON.stringify(ASCENSION_CLASSIC_ABILITIES_BY_CLASS)).digest("hex");
expect(digest).toBe("30d3647c76a8903d081c6cbdc6d2ccaa17b4c86067e762cc4697f7614c1892f0");
});
});
+222
View File
@@ -0,0 +1,222 @@
import { describe, expect, it, vi } from "vitest";
import type { AbilityDefinition } from "./abilityCatalog";
import {
ascensionSpellById,
coaSpellById,
type CoaProgressionChain,
} from "./coaLiveCatalog";
import { ascensionProgressionAbility } from "./coaAbilityCatalog";
const fixture = vi.hoisted(() => {
const makeEffect = (effectType: number, triggerSpellId = 0, basePoints = 1) => ({
index: 0,
effectType,
auraType: 0,
basePoints,
dieSides: 0,
pointsPerLevel: 0,
mechanic: 0,
implicitTargetA: 6,
implicitTargetB: 0,
radiusIndex: 0,
radius: 0,
periodMs: 0,
valueMultiplier: 0,
chainTargets: 0,
itemType: 0,
miscValue: 0,
miscValueB: 0,
triggerSpellId,
pointsPerCombo: 0,
coefficient: 0,
});
const makeDbc = (
id: number,
name: string,
effects: ReturnType<typeof makeEffect>[] = [],
startRecoveryTimeMs = 1_500,
) => ({
id,
name,
rankText: "Rank 1",
description: `${name} fixture description`,
categoryId: 0,
dispelType: 0,
mechanic: 0,
attributes: Array(8).fill(0),
castTimeIndex: 0,
castTimeMs: 0,
recoveryTimeMs: 0,
categoryRecoveryTimeMs: 0,
interruptFlags: 0,
auraInterruptFlags: 0,
channelInterruptFlags: 0,
procFlags: 0,
procChance: 0,
procCharges: 0,
maxLevel: 80,
baseLevel: 1,
spellLevel: 1,
durationIndex: 0,
durationMs: 0,
powerType: 0,
powerCost: 0,
powerCostPerLevel: 0,
powerCostPerSecond: 0,
powerCostPerSecondPerLevel: 0,
rangeIndex: 0,
rangeMin: 0,
rangeMax: 30,
projectileSpeed: 0,
stackAmount: 0,
equippedItemClass: 0,
equippedItemSubclassMask: 0,
equippedItemInventoryMask: 0,
iconId: 0,
powerCostPercentage: 0,
startRecoveryCategory: 0,
startRecoveryTimeMs,
maximumTargets: 0,
damageClass: 0,
preventionType: 0,
schoolMask: 1,
runeCostId: 0,
powerDisplayId: 0,
effects,
});
const makeLiveSpell = (id: number, name: string, dbc?: ReturnType<typeof makeDbc>) => ({
id,
classIds: [11, 27],
name,
iconBasename: "fixture",
level: 1,
rank: "Rank 1",
description: `${name} live description`,
sourceIds: [],
advancementType: "",
advancementTabId: 0,
talented: false,
excluded: false,
...(dbc ? { dbc } : {}),
});
const mainSpells = [
makeLiveSpell(900, "Shared Classic Root", makeDbc(900, "Classic Root", [], 700)),
makeLiveSpell(910, "Shared Trigger Root", makeDbc(910, "Classic Trigger Root", [makeEffect(64, 920)])),
makeLiveSpell(920, "Shared First Payload", makeDbc(920, "Classic First Payload", [makeEffect(64, 930)])),
makeLiveSpell(930, "Shared Nested Payload", makeDbc(930, "Classic Nested Payload", [makeEffect(2)])),
makeLiveSpell(940, "Shared Missing CoA Base", makeDbc(940, "Area52 Overlay Payload", [makeEffect(2)])),
{ ...makeLiveSpell(950, "Classic Only Root", makeDbc(950, "Classic Only Root", [makeEffect(2)])), classIds: [11] },
];
const coaOverrides = [
makeLiveSpell(900, "Shared CoA Root", makeDbc(900, "CoA Root", [], 0)),
makeLiveSpell(910, "Shared CoA Trigger Root", makeDbc(910, "CoA Trigger Root", [makeEffect(64, 920)])),
makeLiveSpell(920, "Shared CoA First Payload", makeDbc(920, "CoA First Payload", [makeEffect(64, 930)])),
makeLiveSpell(930, "Shared CoA Nested Payload", makeDbc(930, "CoA Nested Payload", [makeEffect(67)])),
makeLiveSpell(940, "Shared CoA Missing Base"),
];
const overlayPayload = makeDbc(940, "Area52 Overlay Payload", [makeEffect(2)]);
return {
liveData: {
source: {
builderUrl: "fixture",
databaseClassUrl: "fixture",
realmId: 1,
realmSlug: "fixture",
realmName: "Fixture",
maximumLevel: 80,
schemaVersion: {},
synchronizedAt: "2026-01-01T00:00:00.000Z",
spellDbcPath: "fixture/classic/Spell.dbc",
supportDbcDirectory: "fixture/support",
},
counts: {},
classes: [],
entries: [],
progressionChains: [],
spells: mainSpells,
coaSpellOverrides: coaOverrides,
},
triggeredCatalog: {
spells: [overlayPayload],
classicSpells: [overlayPayload],
},
};
});
vi.mock("./coaLiveData.generated.json", () => ({ default: fixture.liveData }));
vi.mock("./generated/coaTriggeredSpellCatalog.json", () => ({ default: fixture.triggeredCatalog }));
function chain(spellId: number): CoaProgressionChain {
return {
id: `scope-${spellId}`,
classId: 11,
name: `Scope ${spellId}`,
iconBasename: "fixture",
talented: false,
advancementType: "",
trainer: false,
ranks: [{ spellId, rank: 1, level: 1 }],
};
}
function ability(spellId: number, classId: "druid" | "sun-cleric"): AbilityDefinition {
const classic = classId === "druid";
const result = ascensionProgressionAbility(
chain(spellId),
classId,
"mana",
classic ? "ascension-progression" : "coa-progression",
);
if (!result) throw new Error(`Fixture spell ${spellId} did not produce an ability`);
return result;
}
describe("classic and CoA spell source scope", () => {
it("excludes classic-only records from CoA lookup", () => {
expect(ascensionSpellById(950)?.dbc?.name).toBe("Classic Only Root");
expect(coaSpellById(950)).toBeNull();
expect(ability(950, "druid").effects.some((effect) => effect.kind === "damage")).toBe(true);
expect(ability(950, "sun-cleric").effects.some((effect) => effect.kind === "damage")).toBe(false);
});
it("resolves a shared root spell through separate classic and CoA wrappers", () => {
expect(ascensionSpellById(900)?.dbc?.name).toBe("Classic Root");
expect(coaSpellById(900)?.dbc?.name).toBe("CoA Root");
expect(ability(900, "druid").ranks?.[0].gcdMs).toBe(700);
expect(ability(900, "sun-cleric").ranks?.[0].gcdMs).toBe(0);
});
it("keeps nested triggered payloads in the root spell's class scope", () => {
const classicEffects = ability(910, "druid").ranks?.[0].effects ?? [];
const coaEffects = ability(910, "sun-cleric").ranks?.[0].effects ?? [];
expect(classicEffects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "trigger-spell", spellId: 920 }),
expect.objectContaining({ kind: "trigger-spell", spellId: 930 }),
expect.objectContaining({ kind: "damage" }),
]));
expect(classicEffects.some((effect) => effect.kind === "heal")).toBe(false);
expect(coaEffects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "trigger-spell", spellId: 920 }),
expect.objectContaining({ kind: "trigger-spell", spellId: 930 }),
expect.objectContaining({ kind: "heal" }),
]));
expect(coaEffects.some((effect) => effect.kind === "damage")).toBe(false);
});
it("does not fall back to classic overlay DBC when a CoA override has no base row", () => {
expect(ascensionSpellById(940)?.dbc?.name).toBe("Area52 Overlay Payload");
expect(coaSpellById(940)).toMatchObject({ id: 940, name: "Shared CoA Missing Base" });
expect(coaSpellById(940)?.dbc).toBeUndefined();
const classicEffects = ability(940, "druid").ranks?.[0].effects ?? [];
const coaEffects = ability(940, "sun-cleric").ranks?.[0].effects ?? [];
expect(classicEffects.some((effect) => effect.kind === "damage")).toBe(true);
expect(coaEffects.some((effect) => effect.kind === "damage")).toBe(false);
});
});
+47 -14
View File
@@ -4,12 +4,19 @@ import {
type CombatActorState,
} from "./combatActors";
import type { PartyMember } from "./partyStore";
import { baseClassFor, isRomClassId } from "../app/characterCatalog";
import { isRomClassId } from "../app/characterCatalog";
import { deriveCharacterStats } from "./wotlkStats";
export function companionResourceMaximum(member: PartyMember, id: string): number {
return id === member.resourceType ? member.maxResource : id === "mana" && !isRomClassId(member.classId)
? deriveCharacterStats(baseClassFor(member.classId), member.level, member.gearStats, member.raceId).mana
if (id === member.resourceType) return member.maxResource;
const mirroredMaximum = member.resourceMaximums?.[id as ResourceType];
if (mirroredMaximum !== undefined) return mirroredMaximum;
return id === "mana"
&& (member.statRuleset === "rom" || !isRomClassId(member.classId))
? deriveCharacterStats(
member.classId, member.level, member.gearStats, member.raceId, {},
member.secondaryClassId, member.gender, member.statRuleset ?? "wow",
).mana
: id === "nature-power" ? naturePowerMaximumForCharacter(member.classId, member.secondaryClassId, member.level)
: id === "psi" ? 6
: 100;
@@ -25,23 +32,47 @@ export function companionActorFromMember(
existing?: CombatActorState | null,
): CombatActorState {
const profile = resourceProfileForClass(member.classId);
const resources = existing?.kind === "companion"
? existing.resources
: Object.fromEntries(Object.entries(member.resourcePools).map(([id, current]) => [id,
normalizeActorResourcePool(
id,
current ?? 0,
companionResourceMaximum(member, id),
id === member.resourceType ? profile.regenerationPerSecond : 0,
member.lastResourceSpentAt[id as ResourceType] ?? 0,
),
]));
let resources: CombatActorState["resources"];
if (existing?.kind === "companion" && member.statRuleset === "rom" && isRomClassId(member.classId)) {
const previousResources = existing.resources;
const nextResources: Record<string, ReturnType<typeof normalizeActorResourcePool>> = {};
const resourceIds = new Set([...Object.keys(previousResources), ...Object.keys(member.resourcePools)]);
for (const id of resourceIds) {
const previous = previousResources[id];
const nextMaximum = companionResourceMaximum(member, id);
const hasChangedMaximum = previous !== undefined && Math.abs(previous.maximum - nextMaximum) > 1e-6;
const current = hasChangedMaximum
? member.resourcePools[id as ResourceType] ?? previous.current
: previous?.current ?? member.resourcePools[id as ResourceType] ?? 0;
nextResources[id] = normalizeActorResourcePool(
id,
current,
nextMaximum,
id === member.resourceType ? profile.regenerationPerSecond : previous?.regenerationPerSecond ?? 0,
member.lastResourceSpentAt[id as ResourceType] ?? previous?.lastSpentAt ?? 0,
);
}
resources = nextResources;
} else if (existing?.kind === "companion") {
resources = existing.resources;
} else {
resources = Object.fromEntries(Object.entries(member.resourcePools).map(([id, current]) => [id,
normalizeActorResourcePool(
id,
current ?? 0,
companionResourceMaximum(member, id),
id === member.resourceType ? profile.regenerationPerSecond : 0,
member.lastResourceSpentAt[id as ResourceType] ?? 0,
),
]));
}
const actor = existing?.kind === "companion" ? existing : null;
return Object.freeze({
id: member.id,
threatActorId: member.id,
kind: "companion" as const,
classId: member.classId,
statRuleset: member.statRuleset,
level: member.level,
health: actor?.health ?? member.health,
maxHealth: actor?.maxHealth ?? member.maxHealth,
@@ -64,6 +95,7 @@ export function companionActorFromMember(
export function memberFromCompanionActor(member: PartyMember, actor?: CombatActorState | null): PartyMember {
if (!actor || actor.kind !== "companion") return member;
const resourcePools = Object.fromEntries(Object.entries(actor.resources).map(([id, pool]) => [id, pool.current])) as Partial<Record<ResourceType, number>>;
const resourceMaximums = Object.fromEntries(Object.entries(actor.resources).map(([id, pool]) => [id, pool.maximum])) as Partial<Record<ResourceType, number>>;
const lastResourceSpentAt = Object.fromEntries(Object.entries(actor.resources).map(([id, pool]) => [id, pool.lastSpentAt])) as Partial<Record<ResourceType, number>>;
const primary = actor.resources[member.resourceType];
return {
@@ -73,6 +105,7 @@ export function memberFromCompanionActor(member: PartyMember, actor?: CombatActo
resource: primary?.current ?? member.resource,
maxResource: primary?.maximum ?? member.maxResource,
resourcePools,
resourceMaximums,
lastResourceSpentAt,
comboPoints: actor.comboPoints,
comboTargetId: actor.comboTargetId,
+3
View File
@@ -1,5 +1,6 @@
import type { ClassId } from "../app/characterCatalog";
import type { DamageSchool } from "./combatAuras";
import type { CharacterStatRuleset } from "./characterStatRuleset";
export type CombatActorKind = "player" | "companion" | "enemy" | "pet" | "totem" | "summon";
export type CombatResourceId = string;
@@ -59,6 +60,8 @@ export interface CombatActorState {
readonly kind: CombatActorKind;
readonly ownerId?: string;
readonly classId?: ClassId;
/** The owning character's stat model, carried with authoritative snapshots. */
readonly statRuleset?: CharacterStatRuleset;
readonly level: number;
readonly health: number;
readonly maxHealth: number;
+4
View File
@@ -258,6 +258,10 @@ export interface ProcEvent {
readonly amount?: number;
readonly school?: DamageSchool;
readonly attackKind?: AttackKind;
/** True only for a normal weapon swing, never a melee spell hit. */
readonly isAutoAttack?: boolean;
/** Stable originating combat roll seed for ordered source proc branches. */
readonly rollSeed?: string;
}
export interface ProcRollContext {
Loaded 100 of 221 files, more files were not shown because too many files have changed in this diff. Show more