Update Healer Man server

This commit is contained in:
phenom committed 2026-10-04 22:51:21 -04:00
1 parent 6cc1b9217f
commit 6114a2295f
67 files changed
+6208 -314

No files matched your search

+30
View File
@@ -61,6 +61,36 @@ These rules apply to every change under this project root.
- CoA specializations and RoM class/role builds are Needs Testing. They may be inspected in an explicit targeted diagnostic, but must not enter random, all-eligible, regression, bracket, or NPC seat-audit runs.
- A broader balance run requires an explicit user request or a readiness-policy promotion. Record that expanded scope in the task handoff.
## Default test dungeon
- Use **Stormwind Stockade** (`stormwind-stockade`) for new routine gameplay,
mechanics, healing, party, camera, input, performance and browser diagnostics.
This is the project owner's default as of 2026-10-04. Use the full dungeon,
rather than its Bite-Sized or randomized variants, unless that mode is the
subject of the task.
- Shared playtest defaults belong to `scripts/playtest-defaults.cjs`; the current
workflow and evidence requirements are in `docs/TEST_DUNGEON_POLICY.md`.
Install/verify the Stockade content pack through the normal entry flow when
needed. Bundled starter content is not a reason to select Wailing Caverns.
- Do not choose Wailing Caverns for new routine live tests. Retain its existing
source-specific unit/import fixtures and historical reports as evidence, but
they do not override this default. A new Wailing live run requires an explicit
user request for that dungeon.
- Preserve incident-specific historical evidence from other dungeons. When
investigating a report tied to another dungeon, inspect that exact source;
use Stockade for generic validation and follow the user's requested dungeon
for any separately authorized encounter-specific reproduction.
- This fixture choice does not promote Needs Testing builds, broaden balance
certification, change the level-80 cap, or authorize loot wiring.
## Mechanic subprocess preference
- When delegating the owner's mechanics work, use `gpt-6-luna` with `xhigh`
reasoning by default. A higher-tier subprocess model requires the owner's
explicit permission; do not silently upgrade because a task is difficult.
- Divide independent mechanics where practical, coordinate shared runtime files
and the production build, and keep user updates brief.
## Playable characters and equipment
- `src/avatar/CharacterModel.tsx` is the shared player, party, roster, and character-creation rendering path. Never replace it with a screen-specific loader.
+88 -1
View File
@@ -4,6 +4,83 @@ 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.
New routine live tests use the full **Stormwind Stockade** (`stormwind-stockade`)
under [the current test policy](docs/TEST_DUNGEON_POLICY.md) and shared defaults in
`scripts/playtest-defaults.cjs`. Dated Wailing Caverns captures below remain
historical evidence and do not select future tests.
## 2026-10-04 Stockade default and Blood Arrow toggle
- Root contributor rules, shared playtest helpers, routine script defaults and
canonical vault notes now select full Stormwind Stockade. Mode-specific
Bite-Sized spawn checks use its Stockade variant. An independent audit found
no remaining routine Wailing script default; old source fixtures and dated
captures retain their dungeon labels.
- Blood Arrow's verified Knight/Scout and Scout/Rogue base definitions and ranks
now use an indefinite owner-local toggle with no legacy idle drain. Recast or
right-click removal starts a ten-second cooldown; unrelated buff clicks leave
Blood Arrow active. Permanent NPC Blood Arrow icons remain visible without
exposing every long-term buff. Exact costs and rider lifetime have focused
integration coverage; player recurring ranged Auto Shot remains open.
- Corrected-build ordinary Stockade run `20261004-194732821` passed both owners'
cancellation, cooldown rejection/reactivation/reuse and unrelated-buff guards,
plus owner-only NPC cancellation. Scout/Rogue's isolated Shot consumed 46 HP
and no MP. Knight/Scout's cost sample overlaps an incoming hit and regeneration;
it is not exact-cost live proof. Root inspected actual screenshots: bodies and
equipped silhouettes render, with party overlap at the entry/combat sample.
The image named `knight-scout-body-and-gear.png` shows the training-gear panel.
- The primary Knight/Scout roster has all five active identities. The optional
Scout/Rogue damage-role roster instead duplicates Scout/Rogue and omits
Mage/Warlock; companion player-pair exclusion is tracked in the backlog.
- The first full route exposed unsupported NPC utility spells dealing damage.
Two source corrections now omit thirteen verified utility/extra-attack IDs.
The second pass removes 51 more false damage rows and corrects 30 real
attack/control rows for source melee range, interruption and caster-area
targeting. Exact Ascension/Epoch pins preserve profile-specific effects;
real utility, chain fan-out and separate-target shout effects remain gaps.
Regeneration also refreshes existing school/kind metadata. Independent
normalization found no unrelated encounter change or added attack.
- Final source-aware catalog/enemy checks passed 57/57; Ready-five passed 7
Vitest and 20 Python checks, for 340 distinct Vitest checks across this task.
Both catalog forms match. Independent review checked all 22 pinned spell IDs
against both DBC inputs. Production build `index-CM5EW2MK.js` passed its
TypeScript/Vite, asset, compression and release-bundle checks.
- Final ordinary Stockade run `20261004-214255695` verifies the native FULL
DUNGEON CLEAR screen and all five persistent killed-boss IDs in 17:37.7
(difficulty 1 / Easy / Normal). Native-stat Priest/Knight naturally reaches
level 39 from 30; all five survive at the endpoint. Rorik dies once and is
revived through the normal Rez dead members control; no player respawn or
corridor replay occurs. Root inspected actual roster, entrance, route and
results images: loaded bodies and equipped silhouettes render, with overlap
at some party samples. The results image is `stormwind-stockade-boss-arrival.png`.
- Whole-log player healing is 158,465.20 effective across 203 positive events;
34 ordinary Alt+Digit2 Group Heal inputs all start and heal, totaling 66,939.01.
The entrance right-drag changes yaw by -0.378, released yaw remains unchanged
and pointer lock stays null. No page errors occur; only a local favicon 404
appears in console capture. Hamhock dies before his named checkpoint, so
that full clear contains no Vow attempt. Player checkpoint recovery remains
unverified live.
- Separate bounded Stockade diagnostic `20261004-232722030` confirms one player
Vow cancellation on living Hamhock: completed cast, owned silence aura,
disappearance before release and seven read-only samples through release
plus 118 ms without matching Chain Lightning damage. Normal right-drag uses
a hit-tested canvas row, paced deltas and measured hostile facing; pointer
lock stays null. Root checked raw proof and the actual endpoint image showing
Hamhock at 54,974/80,135 HP and loaded equipped bodies, with overlap and a
large wall in view. Targorr and Kam are its only credited bosses. Rorik,
Durnik and Caelan die once and return through normal UI revival; all five
finish alive. There is no player death or full-clear attempt in this capture.
Earlier facing/HUD-blocked diagnostics remain uncredited. No page errors
occur; only the favicon 404 appears in console capture.
- The reviewed campaign harness discovers learned Group Heal on Alt+Digit2,
waits for actual control/school locks and records owned-party targets.
Ordinary death recovery replays the authored corridor and keeps progress
and replay evidence distinct. Production helper discovery retains the
matched function reference because minified export aliases may collide.
- Evidence and remaining source/runtime limits are in
[the live report](docs/ROM_BLOOD_ARROW_STOCKADE_PLAYTEST_2026-10-04.md) and
[the parent continuation report](docs/STOCKADE_MECHANICS_CONTINUATION_2026-10-04.md).
## 2026-10-04 Timed mechanics and campaign bosses
- Shared Killing Spree now schedules five player/companion assaults over two
@@ -16,7 +93,8 @@ camera, input, physics, mob-rendering, or asset-loading code changes.
grants +15% received healing and +5% Holy damage on Blood Arrow. Actual NPC
casts use combat effect schools and consume outgoing aura modifiers once on
their respective native/compatibility paths. The separate current-CoA Blood
Arrow base-toggle/cost migration remains open; no loot or cap change.
Arrow base-toggle/cost migration was still open in that capture; the Stockade
continuation above supersedes it for the two verified pairs. No loot or cap change.
- The player and spectator follow cameras share physical terrain and visible
wall/pillar obstruction queries. Final-build Wailing screenshots near the
formerly clipped pose show loaded character bodies and equipped silhouettes,
@@ -4581,3 +4659,12 @@ The equipment/manual-formula, damage-pressure and production QA evidence above s
- Visually reviewed `playtest-artifacts/party-equipment/WoW-roster.png`, `WoW-party-front.png`, `RoM-roster.png`, `RoM-dungeon.png`, `RoM-party-front.png`, and `RoM-party-open.png`. WoW party bodies have visible robes or armor, shoulder pads, leggings, and capes. The native RoM player and nearby companions wear the source burgundy/orange starter outfit; the mage carries the original textured staff, and the knight carries the original metal mace and wooden shield. Bodies remain visible while equipment loads. The Forsaken Abbey entrance camera crowds the party near a wall, so these captures do not claim every companion is simultaneously unobstructed or certify dungeon positioning.
- Roster previews now receive saved equipped items. Optional equipment completion requests a preview bounds refit after the initial ready-pose blend. Final `RoM-roster.png` shows the full native staff, character body, and boots within the viewport; the earlier immediate refit used bind-pose bounds and cropped the staff tip. The final RoM-only follow-up reported zero browser or asset errors.
- Validation: all 53 avatar tests passed; the production TypeScript/Vite build, asset audits, bootstrap/bundle verification, and release check passed. Refreshed local content as `content-f39a3ad523d546d5`. Original RoM armor is currently a common starter outfit; per-drop modular armor replacement and exact skins for every weapon/armor item remain outside this change. See `docs/CHARACTER_EQUIPMENT_APPEARANCE.md` for regeneration and content-pack requirements.
## 2026-10-04 — RoM weapons on fallback bodies and Caelan's bow grip
- Found an uncovered presentation path: original RoM props were selected by the loaded body's renderer strategy. Without the optional RoM character pack, the basic human body therefore kept generated starter weapons. Weapon selection now follows RoM class identity; the fallback body keeps its existing clothing and shoulders while its hands receive the packaged original RoM GLBs. Each optional prop retains independent loading/error isolation and its temporary procedural fallback.
- The original bow's source axis differs from the basic human bow socket. Its basic-body attachment now aligns that axis before applying the inverse RoM socket adaptation. Native RoM bodies keep the original source grip. A blanket half-turn was rejected after inspecting the actual skinned geometry: it put the string side away from the torso. The shipped adjustment faces the bow toward the archer on both rigs.
- The isolated headless Edge check uses the production build (`index-CsqM7pOM.js`, `SkeletonUtils-BwiwJPus.js`) and the full Stormwind Stockade entry. A test-only canvas observation hook inspects the existing shared renderer; a temporary camera callback captures Caelan closely without changing models, poses, equipment, or party positions. Source mesh checks require original hammer/shield, staff, bow, and dagger meshes on every RoM companion. Bow-facing telemetry compares the source limb tips with the grip and torso.
- Visually reviewed the fresh-profile `playtest-artifacts/party-equipment/basic/RoM-roster-basic.png` and `RoM-Caelan-basic.png`: the roster has the original textured wooden staff and clothing; Caelan holds the original bow upright with the string side toward him. The bow-facing dot product is 0.896 and its vertical-axis alignment is 0.995. The entire fresh-profile route passed with zero page, console, or non-favicon HTTP errors; no optional native character pack was installed. Results are in that directory's `results.json`.
- The completed native-pack WoW/RoM route also reported zero browser or asset errors. Reviewed `playtest-artifacts/party-equipment/WoW-party-front.png`, `RoM-roster.png`, and the native close-up follow-up `RoM-Caelan-native.png`/`RoM-party-front.png`: WoW robes, shoulders, armor and cloaks remain visible; the native RoM roster shows the complete source staff and outfit; Caelan carries the original textured bow in the native lowered idle grip, with the limb tips/string side toward his torso. The final native-facing dot product is 0.465. The close-up uses the normal camera's collision-safe position and a temporary narrower field of view; a previous actor-relative camera position landed inside a wall and was rejected as visual evidence. Nearby native companions show the original shield, hammer and daggers. The group remains crowded on the entry stairs, so the captures do not certify party spacing or every combat animation.
- Focused validation: all 56 avatar tests pass, including missing-pack equipment resolution, source bow alignment, and inverse socket-frame coverage. The final production build, asset audits, compression, and release-bundle verification pass. No original assets needed regeneration, and no deployment was performed.
+8 -1
View File
@@ -6,6 +6,11 @@ WMO, all 3,381 active default-set doodad placements, reconstructed WMO water,
separate collision, authoritative entrance/encounter anchors, and a responsive
exploration HUD modeled after the architecture of `i-want-to-heal-mmo`.
New routine gameplay and mechanics QA uses **Stormwind Stockade**
(`stormwind-stockade`). See [the test-dungeon policy](docs/TEST_DUNGEON_POLICY.md)
and `AGENTS.md`; Wailing's source-specific fixtures and dated evidence remain
historical references.
## Run
```powershell
@@ -298,6 +303,8 @@ use the collision GLB directly.
- `src/ui/`: responsive HUD, encounter map, loading, pause, and error surfaces
- `src/assets/game/dungeons/wailing-caverns/`: shipping GLBs and anchor metadata
Wailing Caverns remains the global-WMO regression fixture. Deadmines is the
Stormwind Stockade is the default live test dungeon. Wailing Caverns retains its
existing global-WMO import/source fixtures, which do not select routine QA.
Deadmines is the
gated ADT/hybrid pilot and cannot be enabled without authoritative server data
and green automated/visual reports.
+8 -1
View File
@@ -2,6 +2,8 @@
Updated 2026-10-04. 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.
New routine mechanic/browser diagnostics use **Stormwind Stockade** (`stormwind-stockade`), including Hamhock for generic Boss Testing. Import `scripts/playtest-defaults.cjs` and follow [the test policy](TEST_DUNGEON_POLICY.md); historical Wailing findings below do not select future tests.
## Targeted follow-up (2026-10-04)
- [x] Correct pinned AzerothCore creature-immunity foreign-key decoding. Lady Anacondra is Fear/Horror immune, not Silence/Interrupt immune; the source-registered Lightning Bolt regression now cancels under Vow of Silence. Actual Silence/Interrupt-immune creatures retain protection. See [the audit](VOW_OF_SILENCE_AUDIT_2026-10-04.md).
@@ -10,7 +12,12 @@ Updated 2026-10-04. This document lists unresolved ability and talent mechanics
- [x] Audit the Killing Spree representative-signature failure. Spell 51690 is a source periodic-dummy selector with a dedicated player `class-script` handler; the test now checks that exact mechanism while preserving the ten-strike runtime regression. Correct the player damage aura from 1% to the linked DBC child 61851's 20% for two seconds. See [the audit](WOW_KILLING_SPREE_AUDIT_2026-10-04.md).
- [x] Implement shared timed player/companion Killing Spree: five assaults at application and 500-ms intervals through two seconds, retained cast-time target pools, owner-local cleanup, linked +20% final-pulse ordering, actual player/NPC relocation, and learned companion AI selection. Seeded target selection, estimated weapon-child damage/teleport coordinates and unverified active-control semantics remain simulator limits. See [the parity report](WOW_KILLING_SPREE_PARITY_2026-10-04.md). This Combat Rogue diagnostic does not expand Ready-five.
- [x] Audit the five active native RoM identities and correct Knight/Scout Blood Punishment to current CoA's +15% healing received and +5% Holy damage on the actual Blood Arrow aura. Correct companion RoM direct-hit school selection and no-roll outgoing damage-aura consumption; native and compatibility NPC casts are covered separately. See [the five-class report](ROM_FIVE_MECHANIC_GAPS_2026-10-04.md).
- [ ] Reconcile Blood Arrow's current CoA base toggle and per-offensive-ranged-skill cost contract with local 490434. The passive rider is current; the base aura still uses the local 30-second/periodic-HP-cost behavior. Current general and Knight/Scout active identities differ, so do not silently replace one with the other. See the five-class report's remaining base-contract section.
- [x] Reconcile Blood Arrow for the verified Scout/Rogue and Knight/Scout owners: indefinite owner-local toggle, recast/right-click cancellation, 10-second cooldown after deactivation, and no legacy idle drain. Knight/Scout offensive ranged-weapon skills charge 2% max HP plus 1% max MP; Scout/Rogue damaging ranged skills and owned NPC ranged auto swings charge 2% max HP without the MP surcharge. Base definitions and resolved ranks are scoped to those pairs; unverified owners retain the RuneWaker behavior. See [the implementation report](ROM_BLOOD_ARROW_TOGGLE_2026-10-04.md).
- [ ] Add the missing recurring player RoM ranged auto-attack/Auto Shot path before claiming full Scout/Rogue Blood Arrow auto-attack parity. Player ordinary auto attacks currently use the melee path; activating Auto Shot is not a recurring shot event. Source the cap-80 ranged-damage curve separately; the retained local 10% aura is not a verified current CoA rank curve.
- [ ] Correct native-five damage-role companion selection so the player's active pairing is excluded when the remaining active identities can fill the seats. The targeted Stockade Scout/Rogue fixture generated another Scout/Rogue and omitted Mage/Warlock; Knight/Scout's tank-role fixture had the exact complement. See [the live evidence](ROM_BLOOD_ARROW_STOCKADE_PLAYTEST_2026-10-04.md). This finding does not promote other RoM builds or expand the companion pool.
- [ ] Propagate Group Heal's sourced area radius to its party-heal effect for both player and NPC recipients. `romAbilityCatalog.ts` derives ability radius 12, but its party heal effect omits radius and `healingRecipientIds` defaults to 30. The final Stockade tank death is a separate timing case: Group Heal resolves 701 ms after the inferred lethal hit. Damage arithmetic implies zero HP when the living-recipient filter would exclude him; the casualty is observed later without a recipient-selection event. This does not prove a range/LOS rejection. Preserve other spells' explicit recipient contracts and add boundary coverage before changing the shared fallback. See [the final continuation evidence](STOCKADE_MECHANICS_CONTINUATION_2026-10-04.md).
- [x] Prevent the thirteen audited utility/extra-attack NPC spells from falling back to direct hits. Exact Ascension/Epoch signatures preserve each source profile; the second pass removes another 51 false attack rows and corrects 30 real attack/control rows, including hostile melee range, pure Kick interruption, caster-centered fear/stun and Chain Lightning range. Real damage and mixed-damage Shield Slam remain. Catalog regeneration also refreshes the existing damage-school/kind metadata. See [the source audit](STOCKADE_UTILITY_ATTACK_AUDIT_2026-10-04.md).
- [ ] Model the audited NPC stance, haste/AP, block, offhand, extra-attack, disarm and flat damage-taken effects before claiming their full source parity. Their false direct-hit fallback is fixed, but their real utility remains unsupported. Chain Lightning fan-out, separate-target shout triggers, sourced Rend ticks and Enrage damage/haste values remain gaps; keep Ascension/Epoch source contracts distinct. See [the source audit](STOCKADE_UTILITY_ATTACK_AUDIT_2026-10-04.md).
## Native stat input update (2026-10-03)
+10 -1
View File
@@ -48,7 +48,15 @@ node scripts/runewaker-pipeline/build-population.mjs --recipe scripts/runewaker-
The generated import manifest records the source ROS path, source SHA-256,
output SHA-256, bounds, and triangle count for each prop. Converted source
props retain the RoM socket frame; the WoW-to-RoM procedural fallback rotation
must not be applied to them.
must not be applied to them on native rigs. The bow retains its native facing
on RoM rigs. On the basic human fallback, its source Y axis is aligned to the
bow socket before applying the inverse socket adaptation, keeping the string
toward the archer. Other props retain their source orientation.
Weapon selection follows RoM class identity even when the optional character
pack is absent. The basic human body retains its WoW clothing and shoulders,
but its hands receive original RoM props with the inverse socket adaptation.
The optional character pack is not required to see authentic RoM weapons.
## Packaging and validation
@@ -63,6 +71,7 @@ fallback. Missing item assets cannot hide the body or other equipment.
- `npm test -- --run src/avatar`
- `npm run build`
- `node scripts/playtest-party-equipment.cjs` after building and refreshing content
- `node scripts/playtest-party-equipment.cjs --rom-only --basic-only` checks the missing-character-pack path
- Review roster and dungeon screenshots in `playtest-artifacts/party-equipment/`
- Record visual findings in `PLAYTEST_NOTES.md`
- `npm run release:check`
+6
View File
@@ -3,6 +3,12 @@
Use this runbook for requests to inspect or fix the current dungeon reports.
It is intentionally repo-relative except for the production endpoint.
Routine mechanic/browser QA defaults to **Stormwind Stockade**
(`stormwind-stockade`), including Hamhock for generic Boss Testing. See
`docs/TEST_DUNGEON_POLICY.md` and `AGENTS.md`. A report tied to a specific dungeon
still requires inspecting that source and the user's authorized reproduction;
existing Wailing reports do not make it the default test dungeon.
## 1. Read the live queue first
The public read endpoint is:
@@ -1,5 +1,7 @@
# Mechanic parity and campaign boss pass - 2026-10-04
> **Future QA default (2026-10-04):** Use Stormwind Stockade for new routine live tests. The Wailing captures and commands in this dated report are historical; follow [the current test policy](TEST_DUNGEON_POLICY.md) and root `AGENTS.md`. A new Wailing reproduction needs an explicit owner request.
## Outcome
Timed player/NPC Killing Spree and source-confirmed active-five RoM fixes are implemented. Vow's live counter and full 8.4-second silence pass after correcting the source Nature Thorns cast gate. Normal Barren fully clears in 13:36.9 with one casualty; strict Wailing reaches and defeats Lady with all five actors alive, while full Wailing completion remains unverified. Sampled camera visibility/right-drag checks pass. The final build, 296 focused assertions, Ready-five checks and repository/vault hygiene pass. Blood Arrow's separate base toggle/cost migration remains open.
+2
View File
@@ -1,5 +1,7 @@
# Performance check — 2026-10-04
> **Future QA default (2026-10-04):** Use Stormwind Stockade for new routine live tests. The Wailing captures and commands in this dated report are historical; follow [the current test policy](TEST_DUNGEON_POLICY.md) and root `AGENTS.md`. A new Wailing reproduction needs an explicit owner request.
## Findings and changes
- **CPU:** The production CPU profile spent about 925 ms of an eight-second sample inside `filesForContentPack`, which repeatedly scanned and sorted the asset catalog. `src/content/contentManifest.ts` now indexes each immutable file map once through a WeakMap. `src/content/contentManager.ts` caches usable/unavailable pack results until the next content publication; install, removal, validation, and catalog changes invalidate those results.
+48
View File
@@ -0,0 +1,48 @@
# Barren Caves tank death investigation (2026-10-04)
This is historical incident analysis only. Barren Caves was not replayed. The current default for live dungeon diagnostics is Stormwind Stockade; use a future Stockade run to verify healer behavior after the active fixes land.
## Evidence reviewed
- `playtest-artifacts/campaign-boss-pass/final-build-20261004-150021873/barren-caves-campaign-boss-route.json` and its screenshots. The captured build assets were `index-DHQInfBp.js` and `combatStore-Cx06rFzG.js`.
- `playtest-artifacts/campaign-boss-pass/final-build-20261004-150021873/results.json`.
- The earlier exploratory run at `playtest-artifacts/campaign-boss-pass/exploration-current-dist-20261004-144223345/failure.json` and `failure.png`.
- Current combat ownership in `src/game/partyRuntime.ts`, `src/game/partyAbilityAi.ts`, and `src/game/generated/romNativePeriodicDamageBindings.generated.json`.
- At the time of the final capture, `scripts/playtest-campaign-boss-pass.cjs` had a 16-entry heal-attempt limit in `stabilizeCampaignPack`. The Stockade migration now removes the arbitrary attempt-count ceiling and retains the 110-second encounter/deadline bounds; this note describes the older capture behavior.
Both captures show a normal campaign full clear: 13:36.9 in the final build and 10:45.8 in the earlier exploration. The character started as a level-30 RoM Priest/Knight and naturally reached level 36. Rorik was the only fallen party member; the player and three damage companions survived. The final artifact records 46,328.47 effective native healing across the route and `partyDeaths: ["Rorik"]`. The earlier results screen also shows Rorik at 0 HP. Its harness failure happened after the results overlay opened, when the harness tried to click a party frame.
The final run reached a full-clear overlay before the harness's separate boss handler ran. Treat the final route pack as a normal-play incident trace, not an isolated boss balance trial.
## Final encounter findings
The final recorded pack began at 15:12:49.086 and ended at 15:14:24.850. It ran through two Kobold targets and Anglo. In the captured harness version, it recorded exactly 16 healing inputs and then stopped entering the triage branch. Thirteen inputs produced positive healing events; three produced no healing event and no resource change. Eleven positive heals targeted Rorik for 6,365 total effective healing; one healed Veyra and one healed Caelan.
Rorik's last positive heal completed at 15:13:55.625, raising him from 2,488.69 to 3,284.69 HP out of 3,488.69. The 16th recorded healing input at 15:13:56.628 healed Caelan. There are no later Rorik healing events. The hard 16-input guard therefore suppressed further triage for the rest of this long combined trash-and-boss pack, even if another ally later crossed the 88% threshold.
The captured casts do not show range as the blocker. The player-to-Rorik distance at healing inputs reached 18.66 m; the bound Urgent Heal range was 25 m, and those casts resolved. Across the 181 samples (which stop before this pack), the maximum player-to-living-party separation was 12.52 m; the pack-end value was 15.56 m. A single line-of-sight rejection occurred earlier in the route at 13.48 m and a later heal resolved. No failed final-pack input records an explicit range or line-of-sight reason, but concurrent combat feedback overwrote the failure message, so those three no-event inputs cannot be classified. Cast waits for resolved Urgent Heal actions were about 1.1–1.3 seconds. The player finished with 3,643/3,747 Mana, so resource exhaustion is not supported by this trace.
## Blood Arrow self-damage
The combat log also shows the NPC tank activating Blood Arrow, ability `rom-scout-general-4` (source skill 490434). The generated RoM skill row describes a 30-second effect that reduces the user's HP by 3% every second to raise ranged physical attack. Its periodic binding uses component 500871, a one-second HP drain with `dotBase: -3`.
Rorik's own runtime actor applied the aura seven times at 15:00:46.945, 15:02:50.840, 15:04:51.240, 15:06:51.332, 15:09:22.430, 15:11:35.425, and 15:14:08.939. The event log records 17,022 total Blood Arrow self-damage across the route. The final application produced eight 105-HP ticks through 15:14:17.022. This is separate from the 9,013.69 effective damage from mobs in the final pack.
From Rorik's last healed state to the last observed combat event, the log contains 2,218.69 effective mob damage and 840 Blood Arrow self-damage. That leaves about 226 HP by arithmetic if the event and health snapshots are complete; the result endpoint instead reports 0 HP. The exact lethal transition is therefore unresolved.
## Limits and next ownership
The route has 181 periodic samples, but the last is at 15:12:47.140, before the final pack started. The trace contains no HP samples for the roughly 96-second final encounter, and the combat event stream ends at 15:14:17.526, 7.3 seconds before the results endpoint. The endpoint proves Rorik was down, but it does not supply the lethal timestamp or a continuous HP curve. Do not treat the event arithmetic as proof of the exact killing blow.
The evidence identifies two concrete confounders before any encounter tuning: the captured route harness stopped healing after 16 recorded inputs, and the Knight/Scout NPC rotation applied a 30-second, HP-draining Blood Arrow effect. The first belongs to `stabilizeCampaignPack` in `scripts/playtest-campaign-boss-pass.cjs`; the current Stockade migration removes its attempt-count ceiling and samples HP throughout pack handling. Existing encounter/deadline bounds still prevent an unbounded run. The second belongs to the companion ability and periodic-effect lifecycle in `src/game/partyRuntime.ts`, `src/game/partyAbilityAi.ts`, and `src/game/romNativePeriodicDamage.ts`. The Blood Arrow lifecycle is already under a focused follow-up. Do not change Anglo or dungeon damage from this evidence.
For the next live diagnostic, use Stockade. Capture party HP, player-to-ally distance, selected heal target, cast result, resource, and Blood Arrow aura/tick events throughout the full boss phase. Then compare incoming mob damage with effective healing after the NPC self-drain behavior and harness cap are isolated.
The subsequent verified-pair implementation now removes legacy idle drain for
Knight/Scout and Scout/Rogue and uses owner-local toggle/deactivation cooldown
and per-ranged-action costs. Focused native/compatibility player/NPC checks and
ordinary Stockade UI evidence are in
[the implementation](ROM_BLOOD_ARROW_TOGGLE_2026-10-04.md) and
[the live report](ROM_BLOOD_ARROW_STOCKADE_PLAYTEST_2026-10-04.md).
This correction does not fill the missing historical lethal sample or establish
an exact cause for Rorik's old death.
@@ -0,0 +1,104 @@
# Stockade Blood Arrow targeted UI proof
Run `20261004-194732821` completed on the corrected final build in the full
Stormwind Stockade (`stormwind-stockade`). It used the normal download and
role-selection flow, with Boss Testing off and `gmTestMode` false. The parent
build passed TypeScript, Vite, asset audits, compression, and release-bundle
verification before capture. The playtest served all five expected frozen
chunks and 141 JavaScript asset responses with HTTP 200:
- `index-CpWq1F33.js`
- `GameRuntime-CSfKe7bD.js`
- `combatStore-D4Bif_EK.js`
- `PartyFrames-BDJ5ssyc.js`
- `romAbilityCatalog-Dh-3_mot.js`
## Fixtures and input
Both profiles were prepared through the character UI as level-30 RoM
characters before entering the dungeon. Telemetry was read-only. Dungeon entry,
ability binding, movement, target selection, casts, and right-click aura actions
used ordinary UI controls. No combat-time profile, gear, weapon, health, mana,
cooldown, or mob-health edits were made.
| Fixture | Profile identity | Roster |
| --- | --- | --- |
| Primary | `Bloodarrowks`, `character-3fc723a3-01f2-46d4-81cb-b48125217eec`, level 30 Knight/Scout, RoM | Exact native-five complement verified: Priest/Knight, Knight/Scout player, Rogue/Warrior, Scout/Rogue, Mage/Warlock. |
| HP-only | `Bloodarrowsr`, `character-7275636c-ebe2-4d7f-99b9-57f12fce34f9`, level 30 Scout/Rogue, RoM | Ordinary generated party: Knight/Scout, Priest/Knight, Rogue/Warrior and another Scout/Rogue. It duplicates the player's pairing and omits Mage/Warlock from the native-five complement; this composition gap remains open. |
Blood Arrow was discovered as `rom-scout-general-4` (DBC source 490434; the
Knight/Scout alias is 499924). The real in-game primary bindings recorded for
the Knight/Scout were face-bottom → Blood Arrow, face-left → Scout `Shot`
(`rom-scout-general-0`, DBC 490423), and face-top → Holy Shield. The Scout/Rogue
fixture had face-bottom → Blood Arrow, face-left → `Shot`, and face-top → Arrow
of Essence. The character/equipment panel showed supplied level-30 ranged gear
before combat.
The on-screen Blood Arrow tooltip labels it permanent and states that each
offensive ranged-weapon skill costs 2.0% HP and 1.0% MP. That verifies the
displayed rule; the live Knight/Scout sample below does not isolate those exact
amounts.
## Results
- **Indefinite toggle and idle:** Blood Arrow was active at entry. The harness
did not press its toggle while already active; it used the normal right-click
path to deactivate it, then activated it again after the cooldown. During the
separate 32.094-second Knight/Scout idle window, the aura remained active and
the sampled combat log contained no Blood Arrow self-damage event. Incoming
enemy damage and party healing were separately present in the sample.
- **Player cancellation and cooldown:** Right-clicking the player's Blood Arrow
buff removed the aura and set an approximately 9.9-second cooldown. Pressing
the bound action while it was recharging produced no Blood Arrow cast event,
left the aura absent, and showed “That ability is still recharging.” After
readiness, the ordinary bound action restored the aura; using the same toggle
again removed it. The final aura counts were 1 after reactivation and 0 after
reuse-deactivation.
- **Different-buff guard:** Right-clicking Holy Shield on Knight/Scout and Arrow
of Essence on Scout/Rogue preserved each owner's Blood Arrow aura. Both
checks passed using the actual buff UI.
- **NPC owner isolation:** The visible Caelan Scout/Rogue party-frame Blood
Arrow icon was right-clicked. Caelan (`party-damage-1-rom-scout`) lost only
its own aura and entered a 9.864-second cooldown. The before/after cooldown
maps show null for the healer, rogue, and mage both before and after; only
Caelan changed from null to a Blood Arrow cooldown. No player AutoShot timer
was added or simulated.
- **Knight/Scout ranged cast:** The ordinary `Shot` action was accepted against
a Stockade Defias inmate; the actual log contains both a `cast` and `damage`
event from `__player__` for `rom-scout-general-0`. The pre/post sample was
3420.955/3420.955 HP and 1315.028/1315.028 mana to 3206.955 HP and 1304.445
mana. This window also records a 146-effective incoming hit from creature
`creature-79046`; the HP delta therefore does not isolate Blood Arrow's cost.
The mana sample also spans natural resource updates. Neither raw delta is
claimed as the exact Blood Arrow cost.
- **Scout/Rogue HP-only cast:** The same accepted `Shot` cast recorded HP
2293.534 → 2247.534 (−46) with resource remaining 100 → 100 and no other
player-directed combat event in the captured cast window. The observed HP
change is consistent with the expected rounded 2% max-HP cost for this
HP-only fixture.
The live sample does not isolate a clean Knight/Scout per-cast HP amount, does
not expose a separately attributable NPC scheduled ranged-auto cost, and does
not test the level-70 rider. The live proof does not quantify the existing
hybrid damage modifier or its cap. These RoM fixtures remain targeted Needs
Testing diagnostics, not balance certification. The combat segment was bounded;
this report does not claim a full Stockade clear.
## Evidence
All screenshots and raw snapshots are in
[`playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821`](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/).
The full telemetry is in
[`results.json`](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/results.json);
the reusable run harness is [`playtest-stockade-blood-arrow.cjs`](../scripts/playtest-stockade-blood-arrow.cjs).
| UI state | Evidence |
| --- | --- |
| Level-30 rosters, Stockade picker/entry and supplied gear | [Knight/Scout roster](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/knight-scout-roster-level-30.png), [entry party](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/knight-scout-stockade-entrance-party.png), [Inventory & Equipment training-gear panel](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/knight-scout-body-and-gear.png), [Scout/Rogue roster](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/scout-rogue-roster-level-30.png) |
| Active aura, tooltip/cost text and different-buff preservation | [Knight/Scout Blood Arrow](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/knight-scout-blood-arrow-active.png), [buff tooltip with permanent state and displayed cost](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/knight-scout-blood-arrow-buff-context.png), [other-buff click result](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/knight-scout-after-other-buff-right-click.png), [Scout/Rogue other-buff click result](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/scout-rogue-after-other-buff-right-click.png) |
| >30-second idle and accepted ranged casts | [idle window](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/knight-scout-blood-arrow-idle-over-30-seconds.png), [Knight/Scout Shot](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/knight-scout-after-ranged-skill.png), [Scout/Rogue Shot](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/scout-rogue-after-ranged-skill.png) |
| Player and NPC cancellation/cooldown | [player cooldown](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/knight-scout-right-click-off-cooldown.png), [activation rejected during cooldown](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/knight-scout-cooldown-activation-rejected.png), [reactivated](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/knight-scout-reactivated-after-cooldown.png), [toggle reused to deactivate](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/knight-scout-reuse-deactivated.png), [NPC owner aura](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/knight-scout-npc-blood-arrow-owner-buff.png), [NPC owner cooldown](../playtest-artifacts/rom-blood-arrow-stockade/20261004-194732821/knight-scout-npc-blood-arrow-owner-off.png) |
The run recorded zero page errors and zero HTTP errors. Chromium logged one
non-blocking 404 for `/favicon.ico`; the expected game chunks were all served
successfully.
+25
View File
@@ -0,0 +1,25 @@
# Blood Arrow toggle and cost contract — 2026-10-04
## Source identity
The Scout/Rogue current skill row identifies Scout General **Blood Arrow** as source skill `490434`. The RuneWaker import binds that row to local ability `rom-scout-general-4`. The matching current class page is [Chronicles of Arcadia — Scout/Rogue skills](https://chroniclesofarcadia.com/en/wiki/skills/list/2-3). Knight/Scout **Blood Arrow** is a distinct current source row, `499924`, on the [Knight/Scout skill page](https://chroniclesofarcadia.com/wiki/skills/list/6-2?locale=en). Its passive `499923` and Joint Blow `490420` are separate skills. The Knight/Scout ranged-skill surcharge is not applied to Scout/Rogue.
The current verified rows describe Blood Arrow as a toggle: recasting it, or right-clicking its buff icon, turns it off and starts a 10-second cooldown for that owner. The local import’s old 30-second expiry and 3% max-HP-per-second drain came from the stale RuneWaker snapshot. The indefinite toggle and removal of the legacy periodic self-damage binding are scoped to the verified Scout/Rogue and Knight/Scout owners; other builds retain the legacy imported effect pending their own source reconciliation. Activation itself has no health, mana, or focus cost for the verified pairs.
## Runtime behavior
- Player and owned NPC buff instances are owner-local for the verified pairs. Recast and right-click deactivate only the owner's Blood Arrow, remove its rider, and set the ability ready time to 10 seconds later. The context menu accepts only the clicked Blood Arrow aura with matching owner source and target; right-clicking another self-owned buff leaves Blood Arrow active. The persistent NPC frame keeps this cancelable indefinite icon visible while continuing to filter other long-term clutter. Player Status already displays persistent auras, so its layout is unchanged. Unverified builds do not receive the new toggle/deactivation cooldown handling. Death, removal, and roster synchronization clear the aura and stale cooldown for its owner.
- The Knight/Scout source contract charges 2% of max HP and 1% of max MP once when an eligible offensive ranged-weapon skill cast commits. Ordinary Scout General skills and the pair’s own offensive elite rows are considered; Scout Primary-only rows, non-damage control abilities, Blood Arrow itself, melee abilities, and the ongoing Auto Shot mode are excluded. Joint Blow source skill `490420` is included by its source identity even though the local tooltip omits the ranged-weapon wording. An owner without enough resources cannot commit the skill.
- Scout/Rogue Blood Arrow charges 2% max HP per ranged attack and no additional mana. The runtime charges eligible damaging Scout Primary/General skill casts and each owned NPC ranged auto swing while its own aura is active. The NPC path skips the swing if its owner cannot afford the health cost. Knight/Scout plain auto attacks do not receive the skill surcharge.
- The aura’s existing ranged-damage effect is retained. The local rank-scaling model remains unverified at cap 80; this change does not invent or import a level-105 damage curve.
- Blood Punishment’s 15% received-healing and 5% Holy-damage riders remain attached to the active Blood Arrow aura and therefore end with it. There is no separate rider timer.
Player ordinary auto attacks and repeated Auto Shot are not currently simulated as ranged events, so there is no player auto-attack hook to charge. This implementation does not add a new player ranged auto-attack system; full Scout/Rogue parity for player auto attacks remains open.
The current source tooltips state percentage HP/MP costs without distinguishing current from maximum pools. The simulator uses maximum HP/MP and rounds each cost to whole points; that percentage basis is a local interpretation. The published +105 damage value is not extrapolated to the project's level-80 cap.
## Verification
npx vitest run src/game/romKnightScoutBloodPunishment.integration.test.ts src/ui/AuraStrip.test.ts src/ui/PlayerStatusPanel.test.ts src/ui/partyResurrection.test.ts passes all 32 tests. Coverage includes ROM and WoW player costs and no-idle-drain checks, owned Knight/Scout activation and affordability in both rulesets, player recast/cooldown, Blood Punishment lifetime, and an owned Scout/Rogue ranged auto swing that charges once, keeps party and actor health synchronized, and receives Blood Arrow's ranged damage modifier. It also checks rank-resolved toggle behavior for verified owners, unchanged legacy behavior for an unverified Scout/Warrior, rejection of another buff's right-click, and rendered visibility of an NPC's tagged indefinite aura while unrelated long-term buffs remain filtered.
No dungeon playtest was run in this slice. Future live QA uses Stormwind Stockade (`stormwind-stockade`) as the default test dungeon; no Wailing Caverns route or test was added.
@@ -1,5 +1,7 @@
# Campaign boss-route playtest — 2026-10-04
> **Future QA default (2026-10-04):** Use Stormwind Stockade for new routine live tests. The Wailing captures and commands in this dated report are historical; follow [the current test policy](TEST_DUNGEON_POLICY.md) and root `AGENTS.md`. A new Wailing reproduction needs an explicit owner request.
## Scope and method
This pass extends ordinary campaign routes in Barren Caves and Wailing Caverns through their normal boss encounters. The scoped Playwright harness creates an isolated Priest through the UI, prepares the permitted level-30/Knight-secondary fixture before entry, enters the normal Dungeons picker as healer, and uses ordinary WASD, Tab, F1/F2, party-frame selection, learned bound abilities, and the visible resurrection control. Source navigation paths are movement guidance only. During the live campaign, game position, health, mana, cooldowns, combat targets, party positions, and dungeon results are read-only telemetry; no loot was taken and no level-cap or readiness setting was changed.
+6 -4
View File
@@ -10,15 +10,17 @@ The owned-party direct-damage adapter also had two gaps that hid this rider in N
## Blood Punishment source and identity
The generated RuneWaker catalog identifies `499923` as the learnable level-70 Knight/Scout passive Blood Punishment, with prerequisite Blood Arrow `490420`; the source general active is Scout Blood Arrow `490434` (the current CoA corresponding active is `499924`). The local `learnmagic.db`, `magiccollectobject.db`, and `magicobject.db` SHA-256 values match the generated catalog's recorded hashes. Local component `623167` links its one-second `onTimeMagicId` to `499910` for 30 seconds. `Resource/luascript/02708.lua`'s `lua_bico_kni_sco_es_70_blood` checks owner buff `500871` and cancels `623167` when that Blood Arrow marker is absent; this is the old child-timer behavior.
The generated RuneWaker catalog identifies `499923` as the learnable level-70 Knight/Scout passive Blood Punishment, with raw prerequisite ID `490420` (Joint Blow, not Blood Arrow); the source general active is Scout Blood Arrow `490434`. Current CoA publishes the Knight/Scout class variant as `499924`. The local `learnmagic.db`, `magiccollectobject.db`, and `magicobject.db` SHA-256 values match the generated catalog's recorded hashes. Local component `623167` links its one-second `onTimeMagicId` to `499910` for 30 seconds. `Resource/luascript/02708.lua`'s `lua_bico_kni_sco_es_70_blood` checks owner buff `500871` and cancels `623167` when that Blood Arrow marker is absent; this is the old child-timer behavior.
The [current CoA Knight/Scout skill list](https://chroniclesofarcadia.com/wiki/skills/list/6-2?locale=ar) still identifies Blood Punishment as Knight/Scout `499923`, Blood Arrow as Scout-general `499924`, and says Blood Arrow increases received healing by 15% and Light damage by 5% while active. The [10.2.1 patch history](https://chroniclesofarcadia.com/ru/patch/10-2-1-1000) records the 15% healing-received reduction for Knight/Scout Blood Punishment. The exact skill IDs and pairing match the local rows, so the current CoA rider replaces the stale local 50%/30-second child. The current page's level-105 examples do not change Healer Man's level-80 cap.
The [current CoA Knight/Scout skill list](https://chroniclesofarcadia.com/wiki/skills/list/6-2?locale=ar) still identifies Blood Punishment as Knight/Scout `499923`, the Knight/Scout variant of Blood Arrow as `499924`, and says Blood Arrow increases received healing by 15% and Light damage by 5% while active. The [10.2.1 patch history](https://chroniclesofarcadia.com/ru/patch/10-2-1-1000) records the 15% healing-received reduction for Knight/Scout Blood Punishment. The passive identity and pairing match the local row, so the current CoA rider replaces the stale local 50%/30-second child. The current page's level-105 examples do not change Healer Man's level-80 cap.
The runtime attaches both modifiers directly to the existing caster aura tagged `rom-self-health-drain` for source skill `490434`. That keeps the rider actor-local and ties its lifetime to the active Blood Arrow aura already modeled by the simulator. The simulator still models the local Blood Arrow 30-second lifetime and 3%-of-maximum-HP periodic cost; this change does not claim full CoA toggle, deactivation cooldown, or per-ranged-skill HP/MP cost parity. The local 623167/499910 timer is intentionally not layered onto the current CoA rider.
### Remaining Blood Arrow base-contract work
### Blood Arrow base-contract work at the time of this pass
This is source-confirmed but intentionally not implemented by the passive-rider fix. The [current CoA Scout general-skill page](https://chroniclesofarcadia.com/en/wiki/skills/list/1-2) documents Blood Arrow as a toggle that can be deactivated from its buff, with a ten-second cooldown on deactivation and an HP cost on physical attacks. The [10.7.0 patch notes](https://chroniclesofarcadia.com/en/patch/10-7-0-1000) also describe Blood Arrow's per-tick Mana cost. Healer Man's local `490434` instead remains a 30-second aura with a 3%-maximum-HP periodic drain and has no player toggle, deactivation cooldown, or per-offensive-attack HP/Mana charge. The general page's Blood Arrow row is `499998`, while the Knight/Scout page identifies the passive's active as `499924`; this audit does not assume those rows have an identical current cost contract. Keep these base-skill differences open and do not describe the pair as fully CoA-parity complete.
**Identity correction from the subsequent continuation:** CoA list `1-2` is Warrior/Scout, and its Blood Arrow `499998` must not be treated as the Scout general skill. Scout/Rogue list `2-3` identifies `490434`; Knight/Scout list `6-2` identifies its variant `499924`. The local prerequisite `490420` is Joint Blow. See `docs/ROM_BLOOD_ARROW_TOGGLE_2026-10-04.md` for the subsequent pair-aware toggle/cost work and its current limits. The paragraph below records the earlier pending base-contract state.
This is source-confirmed but intentionally not implemented by the passive-rider fix. The [current CoA Warrior/Scout skill page](https://chroniclesofarcadia.com/en/wiki/skills/list/1-2) documents Blood Arrow as a toggle that can be deactivated from its buff, with a ten-second cooldown on deactivation and an HP cost on physical attacks. The [10.7.0 patch notes](https://chroniclesofarcadia.com/en/patch/10-7-0-1000) also describe Blood Arrow's per-tick Mana cost. Healer Man's local `490434` instead remains a 30-second aura with a 3%-maximum-HP periodic drain and has no player toggle, deactivation cooldown, or per-offensive-attack HP/Mana charge. The Warrior/Scout page's Blood Arrow row is `499998`, while the Knight/Scout page identifies the passive's active as `499924`; this audit does not assume those rows have an identical current cost contract. Keep these base-skill differences open and do not describe the pair as fully CoA-parity complete.
## Other active identities
+3 -1
View File
@@ -1,5 +1,7 @@
# RoM Native Party Campaign Route Playtest — 2026-10-04
> **Future QA default (2026-10-04):** Use Stormwind Stockade for new routine live tests. The Wailing captures and commands in this dated report are historical; follow [the current test policy](TEST_DUNGEON_POLICY.md) and root `AGENTS.md`. A new Wailing reproduction needs an explicit owner request.
## Scope and build
Tested bounded entrance-to-encounter segments in Barren Caves and Wailing Caverns using the ordinary campaign picker. Each run used a UI-created level-30 RoM Priest / Knight healer and four active RoM NPC companions. Both dungeons entered in healer role with `gameMode=dungeon`, `gmTestMode=false`, and no Boss Testing state. Only initial profile level and secondary class were prepared for the isolated test profile. Dungeon entry, WASD movement, Tab targeting, party commands, ability keys, and ally selection used the game UI and normal inputs. Combat health, mana, damage, positions, cooldowns, enemy health, and targets were not changed by the harness.
@@ -52,4 +54,4 @@ Both route runs reported zero browser page exceptions. Each recorded one console
## Harness
`node scripts/playtest-native-routes.cjs --route=<barren-caves|wailing-caverns> --route-seconds=90` runs an independent UI-created profile and one bounded segment. The default records both routes sequentially and carries natural level progression into later entry. Run and review each route independently to control its starting profile at level 30.
The current `node scripts/playtest-native-routes.cjs --route-seconds=90` default runs an independent UI-created profile and one bounded **Stormwind Stockade** segment. An explicitly requested Barren reproduction uses `--route=barren-caves`. Historical Wailing reproduction requires the owner's explicit request and `--historical-wailing --route=wailing-caverns`; it is no longer the default. The captures above used the older two-route harness. Run and review each authorized route independently to control its starting profile at level 30.
+2
View File
@@ -1,5 +1,7 @@
# RoM Vow of Silence live Boss Testing evidence — 2026-10-04
> **Future QA default (2026-10-04):** Use Stormwind Stockade for new routine live tests. The Wailing captures and commands in this dated report are historical; follow [the current test policy](TEST_DUNGEON_POLICY.md) and root `AGENTS.md`. A new Wailing reproduction needs an explicit owner request.
## Corrected final-build replay
The corrected production build passed the dedicated live Boss Testing replay. A UI-bound RoM Priest/Knight used Vow of Silence on Lady Anacondra's actual source-scheduled Lightning Bolt. The Bolt ended before its scheduled completion, the boss held the 8.4-second silence status for its full duration, and no nonphysical active cast or cast start was observed while that status was active. Lady naturally resumed casting Nature Lightning Bolt 61 ms after the status expired. The final harness recorded 39 active-status samples.
@@ -0,0 +1,199 @@
# Stockade mechanics continuation — 2026-10-04
The owner replaced Wailing Caverns with Stormwind Stockade for new routine QA while authorizing the pending Blood Arrow and Barren tank-death work. Independent GPT-6 Luna extra-high subprocesses cover the mechanic, full Stockade route, separate live toggle proof, historical incident analysis and read-only reviews.
## Scope and documentation
Root `AGENTS.md`, `README.md`, `docs/TEST_DUNGEON_POLICY.md`, report triage, dungeon-pipeline guidance, the mechanics backlog and canonical Obsidian workflows now direct future agents to the full `stormwind-stockade` dungeon. Shared constants/UI download helpers live in `scripts/playtest-defaults.cjs`. Reusable UI, camera, performance, party, healing, native-stat, Hunter and generic Boss Testing harness defaults use Stockade/Hamhock. Dedicated Lady/Vow and old native-combat live reproductions require an explicit historical flag; their old results keep their original labels. No-selector native routes now use Stockade.
Existing Wailing import assets, source unit fixtures, bundled starter content and dated evidence remain valid in their original scope. No new Wailing live run is part of this task. Level cap 80, existing-save WoW compatibility, actor-local stat mode, Needs Testing policy and no-new-loot-wiring remain in force.
## Evidence owners
- `docs/ROM_BLOOD_ARROW_TOGGLE_2026-10-04.md`: source identity, pair-aware toggle/cost implementation and focused coverage.
- `docs/ROM_BARREN_TANK_DEATH_2026-10-04.md`: historical final-pack incident and capture limits.
- `docs/STORMWIND_STOCKADE_PLAYTEST_2026-10-04.md`: actual final-build route, boss objectives, mechanic, camera and screenshot proof.
- `docs/ROM_BLOOD_ARROW_STOCKADE_PLAYTEST_2026-10-04.md`: separate ordinary-UI Knight/Scout and Scout/Rogue toggle/cost proof.
- `docs/STOCKADE_UTILITY_ATTACK_AUDIT_2026-10-04.md`: exact source and live-event evidence for non-damaging stance/shout spells incorrectly compiled as attacks.
## Mechanic and incident findings
Blood Arrow's current contract applies only to the verified Knight/Scout and Scout/Rogue owners, including every rank selected by `abilityAtLevel`. Its aura stays active until removed, has no legacy idle self-drain, and recast or right-click deactivation starts an owner-local ten-second cooldown. Knight/Scout offensive ranged-weapon skills cost 2% max HP plus 1% max MP; Scout/Rogue damaging ranged skills and owned NPC ranged auto swings cost 2% max HP. The Blood Punishment rider ends with its aura. Unverified pairings retain their previous imported behavior.
Player recurring ranged Auto Shot is still absent, and the retained local 10% ranged damage aura is not a sourced current cap-80 curve. These remain explicit backlog items. No new loot system, level-cap change or readiness promotion was introduced.
The separate live run `20261004-194732821` passed both owners' ordinary buff cancellation, cooldown rejection/reactivation/reuse and unrelated-buff protection, plus owner-only NPC cancellation. Scout/Rogue's isolated Shot lost 46 HP with no MP cost; Knight/Scout's HP sample overlapped an enemy hit and its resource sample included natural updates, so that live window does not prove exact cost amounts. Focused integration covers those amounts, scheduled NPC auto costs and the level-70 rider. Root inspected the actual final screenshots and raw roster telemetry. The Knight/Scout fixture has the exact native-five complement; the optional Scout/Rogue fixture duplicates Scout/Rogue and omits Mage/Warlock, now recorded as an open companion-selection gap.
The historical Barren tank death is confounded by the old harness's sixteen-attempt heal ceiling and Blood Arrow's periodic self-cost. The last lethal transition was not sampled, so the report does not assign an exact cause or justify encounter damage tuning. The current Stockade harness removes that arbitrary heal ceiling and samples health throughout encounters.
The first Stockade route failed near Kam. Actual combat events exposed Defensive/Battle Stance and two Demoralizing Shouts dealing damage despite their pinned DBC rows containing only unsupported utility auras. The first correction omitted IDs 7164, 7165, 16244 and 13730 when their audited signatures matched; their actual utility effects remain unmodeled. Seventeen entity attack rows across ten definitions were removed, with supported Shield Slam/Rend and both affected bosses' real mechanics preserved. Regeneration also caught up existing compiler school/kind metadata: 2,392 each of `schoolMask`, `damageOrigin`, `damageClass` and 1,751 `attackKind` fields. Structural normalization found no other catalog drift or encounter scalar changes.
The failed route also had a run-store discovery omission and insufficient cast-readiness/LOS telemetry. The retry asserts progress-store discovery, waits for zero remaining GCD/personal cooldown, records actual cast starts and whole-log healing, and uses ordinary Recall plus one measured retry for an explicit blocked heal. Those capture limits and the source defect do not establish a sole cause of the old death.
The utility-corrected retry `20261004-202642234` reached checkpoint 122 of 481, about 263.1 of 759.31 route metres, before player defeat near Kam. The actual run store records Targorr as the only completed boss (1/5); this is a failed diagnostic, not a full clear. It contains 134 Urgent Heal cast events, 71 effective healing events and 43,738.6 effective healing. All four omitted utility IDs are absent from the combat log. Of 73 attempted inputs without a recorded cast start, 28 had explicit line-of-sight rejection and 33 were blocked by an enemy control effect; the remaining 12 had overwritten feedback. This does not identify the source of every control or a sole cause of defeat.
That retry exposed further import and harness defects. Battle Shout 9128 and Improved Blocking 3419 still produced damage despite utility-only source effects; Backhand 6253 and mixed-damage Shield Slam 8242 had incorrect delivery/target/range. The second source correction removes 51 more verified utility/extra-attack rows across 20 definitions and corrects 30 supported attack/control rows. An independent structural comparison found only those changes and corresponding mechanic-ID removals on 15 bosses, with no additions or unrelated encounter changes. Group Heal was already learned and bound to Alt+Digit2, but the planner scanned only primary bindings. The corrected harness scans every binding layer, captures owned-party selection and enemy control state, and supports ordinary death-checkpoint recovery.
The expanded source audit confirms additional utility-only stance, haste, dual-wield, extra-attack, disarm and debuff spells that must not become direct hits. Real hostile melee damage/control and source-centered fear/stun effects are preserved with verified targets and ranges. The compiler uses separate Ascension and Epoch spell sources by recipe, with exact profile-specific signatures. In particular, the pinned Epoch Kick 11978 has real damage plus a six-second interrupt, while Stockade's Ascension row is a pure four-second interrupt. Epoch's missing duration/radius tables use the compiler's existing patch-S fallback. This is a local source contract, not a claim of pristine Epoch timings. Real utility effects, Chain Lightning fan-out, secondary-target shout triggers, sourced Rend ticks and Enrage values remain explicit gaps.
Independent harness review confirmed that ordinary checkpoint respawn needs ordered corridor replay rather than movement straight to the interrupted waypoint. The corrected driver records replay separately, allows new boss kills during recovery while requiring prior progress to remain, and distinguishes early native completion from completed replay. Production spell-school discovery matches and retains the actual pure helper function; chunk export aliases are minified and can collide, so a bare alias is insufficient. The reviewed campaign cast window already counted Group Heal recipients; explicit recipient sets improve attribution and fix the separate entrance-segment aggregation rather than correcting a proven campaign event-count defect.
## Verification
The final owner-scope regression selection passed 187/187 across five files (including 15 Blood Arrow integration checks). The UI correction selection passed 32/32 across four files: those same 15 integration checks plus 17 aura/status/party rendering checks. Stockade navigation passed 16/16; the current shared workspace avatar checks passed 56/56 across eleven files. Ready-five passed 7 Vitest and 20 Python checks. This totals 283 distinct Vitest and 20 Python checks, without a full-suite or all-class balance run. Concurrent avatar edits are preserved and are not attributed to this task.
The refreshed enemy attack/casting/damage-type/content checks passed another 24 checks, and the final source-aware campaign catalog passed 33/33. These add 57 distinct checks, for **340 Vitest and 20 Python** across the task. Root independently reran this 57-check selection and Ready-five after the second regeneration, verified JSON/compressed catalog equality, and reproduced the exact 51-removal/30-correction structural diff. A stale registry-total assertion (92 versus the existing 98) was corrected with source-snapshot coverage for the six additional RuneWaker definitions; the new SmartAI fixture lookup also received a typed key after the first build caught its JSON-index error.
Nineteen root-owned default/helper harnesses passed Node syntax checks; that does not claim all migrated legacy harnesses were rerun. The separate new Blood Arrow harness and coordinated campaign harness also pass syntax checks. A final independent default audit found one future Wailing slice-playtest prompt; it now explicitly defers that dungeon until an owner request. `npm run build` passed TypeScript, Vite, asset audits, static compression and release-bundle verification. The normal prebuild catalog generation is existing build behavior, not new loot wiring.
Blood Arrow live-test chunks: `index-CpWq1F33.js`, `GameRuntime-CSfKe7bD.js`, `combatStore-D4Bif_EK.js`, `PartyFrames-BDJ5ssyc.js`, `romAbilityCatalog-Dh-3_mot.js`. An intermediate build caught a missing UI import; it was restored before this successful build. Earlier setup captures are labeled pre-correction exploration.
The subsequent utility-corrected build passed TypeScript, Vite, asset audits, compression and release-bundle verification. Frozen route-test chunks are `index-Cnzz9c5G.js`, `GameRuntime-BeKkNayj.js`, `combatStore-CtB1A5Sm.js`, `PartyFrames-CoDzf_mJ.js`, `romAbilityCatalog-BZyh1vZY.js`, and `dungeonSession-Bct86pfr.js`. The build's module report maps the actual progress-store owner to `dungeonWorldInteraction-DRqE4yxE.js`; its relocation between builds requires source-owner discovery from that report. The first retry's guard stopped before route inputs when the old guessed owner was absent. That intermediate build produced only partial exploratory route evidence; the final source-aware result follows below. Aggregate campaign results now distinguish a completed diagnostic from a verified full clear and include actual `fullClearStatus`, blocked-route status and boss progress.
The final source-aware build also passed TypeScript, Vite, asset audits,
compression and release-bundle verification. Current frozen assets are
`index-CM5EW2MK.js`, `GameRuntime-aKrUucEK.js`, `combatStore-o8tawtxY.js`,
`PartyFrames-Bm7f0aVD.js`, `romAbilityCatalog-D-lLBXpt.js`, and
`dungeonSession-BeNxGHr9.js`; the module report identifies the run-store owner
as `dungeonWorldInteraction-coBQV5ap.js`. An independent read-only subprocess
decoded all 22 pinned spell IDs from both DBC inputs and found no concrete
source/implementation issue. Smoke Bomb's raw range is self-only; runtime
range 5 is the existing hostile-target gate adapted to its sourced radius 5,
not a raw SpellRange claim.
## Final ordinary Stockade result
The frozen source-aware build cleared Stormwind Stockade through ordinary
campaign input in `playtest-artifacts/campaign-boss-pass/final-build-20261004-214255695/`.
Root independently checked the raw route JSON and inspected the actual native
results screenshot, `stormwind-stockade-boss-arrival.png`: **FULL DUNGEON CLEAR,
5/5 bosses, 17:37.7**, difficulty 1 / Easy / Normal. The persistent run store
contains Targorr, Kam, Hamhock, Bazil and Dextren's five required IDs. This is a
verified clear, not an inference from route position or the area HUD.
The Priest/Knight player and all four named NPCs use native RoM stats. The
player naturally advances from level 30 to 39. All five survive at the endpoint;
Rorik dies once and returns through the ordinary **Rez dead members** control.
There is no observed player death, checkpoint respawn or corridor replay in
this successful run, so the reviewed player-recovery path is not live-proven by
this capture. The native results screen stops further route inputs at waypoint
462/481, before Dextren's source anchor; natural earlier boss kills count.
Whole-log player healing contains 424 events, 203 with positive healing,
totaling **158,465.20 effective healing** and 423,321.80 overheal. Distinct input
windows separately record 169 attempts, 166 matching cast starts and 160
effective windows. All 34 ordinary Alt+Digit2 Group Heal inputs start and heal,
totaling 66,939.01 effective healing across recipients. Player damage has 53
positive events totaling 99,019. These counters do not use the ordinary
entrance-segment summary schema. The route owner corrected the initially zero
aggregate counters using this saved raw evidence, without rerunning gameplay.
Only `results.json` derived metadata changed; the detailed route JSON and
screenshots remain intact. It now separates the configured entrance-segment
duration from the 1,058,913 ms observed campaign evidence span and native time.
An independent read-only tank audit found a strongly supported final hit from
Defias Inmate `creature-79108`. Rorik's last living sample has 1,524.18 HP;
the following five logged hits total that amount, ending in a 209.18 effective
basic attack. The next sample confirms zero HP. There is no dedicated combat
death event, so this is damage arithmetic with a sample-bounded transition.
His last direct heal restores 868 HP. A subsequent three-second Group Heal
heals Caelan but resolves 701 ms after Rorik's inferred lethal hit. The damage
arithmetic implies he is already at zero, and the living-recipient filter
would exclude him. The casualty observation is 139 ms after heal resolution;
there is no dedicated death or recipient-selection event. Earlier direct heals
succeed at 14.207 m and player control is unblocked. This does not establish
a range or LOS failure. The deeper source check corrected the initial radius
inference: Group Heal's ability radius is 12, but its party-heal effect omits
radius and the shared player/NPC recipient resolver defaults to 30. Adjacent
samples put Rorik within 30 while alive. That separate fidelity gap is now in
the mechanics backlog; the frozen tested build is unchanged.
No Rorik Blood Arrow activation or cost is recorded before the death; unlike
the historical Barren capture, there is no evidence of a legacy self-drain
contribution. Per-recipient alive/range/LOS telemetry would improve future
attribution; this one trace does not justify enemy damage tuning.
Hamhock dies during an earlier pack, before the named boss checkpoint. His log
contains actual Nature Chain Lightning damage, but no Vow attempt; Vow
cancellation remains unverified in this run. Root inspected entry, roster,
25/50/75-percent and result images: loaded bodies and equipped silhouettes are
visible, with party overlap at some samples. The entrance camera probe records
held right-drag yaw -0.378 radians, zero released yaw change and null pointer
lock. There are no page errors; the aggregate console capture contains only
the local favicon 404. No new loot wiring or loot-menu action was introduced.
The scoped live report retains the failed exploratory runs, exact build assets,
capture limitations and ordinary revival evidence. The final release-check
transcript for the saved repository changes is
`playtest-artifacts/campaign-boss-pass/release-check-final-20261004.txt`.
## Bounded interrupt diagnostic
The separate `--stockade-vow-diagnostic` campaign mode watches Hamhock during
ordinary pack triage, selects him through Tab and uses the UI-bound Vow key.
It requires a living target and the same live attack/start/end tuple at input,
then a completed player Vow cast on Hamhock, the actual owned
`rom-priest-vow-of-silence` CombatAura with refreshed `appliedAt`/`expiresAt`,
and disappearance before scheduled release without matching Chain Lightning
damage. A mob-status owner tuple is insufficient: aura control uses an
`aura:` source-ability marker without a player owner. Vow's silence applies
before its interrupt effect, so school-lock advancement is supplemental.
Independent Luna review passed after correcting the aura observation and
deadline guard. The driver checks readiness, range, control, school locks,
player life and overlays, and stops input on proof, target death or its
900-second deadline. Targeted results remain separate from full-clear status.
The existing production build stays frozen; only the driver and evidence
reports change. Actual diagnostic outcome belongs to the live report.
The first targeted capture, `final-build-20261004-224251892`, ends at
Hamhock's natural defeat with three recorded boss IDs and all five actors alive.
It observes five interruptible Chain Lightning windows and five normal
Shift+Digit5 presses, but no completed Vow cast or owned Vow aura. Two windows
retain explicit "You must face that target" feedback; a separate window
records Chain Lightning damage at its scheduled release. Cancellation is
unverified, not evidence of a Vow runtime failure. Root inspected the actual
endpoint image, which shows Hamhock's existing boss-card notification rather
than a full-clear panel. The capture remains intact. A narrow driver retry
uses ordinary right-drag input to face the observed target and records the
actual camera yaw used by the player's combat spatial actor.
The paced retry, `final-build-20261004-230719179`, observes six live cast
windows but issues zero Vow inputs. Its canvas guard rejects every fixed
mid-height drag: the first two path points hit the visible boss notification,
while the remaining points hit the canvas. Hamhock is naturally defeated,
three boss IDs are recorded and all five survive. This capture does not test
Vow. The follow-up correction selects an actually clear canvas row through
read-only hit tests, retaining the paced input, release and cast guards.
The final targeted capture, `final-build-20261004-232722030`, **confirms one
live player Vow cancellation** from four watched Hamhock cast windows and one
ordinary Shift+Digit5 input. Root independently checked the raw route events,
owned aura, facing evidence and actual endpoint screenshot. It uses the same
frozen `index-CM5EW2MK.js` build as the full clear.
Chain Lightning starts at `1791157080692` and would release at
`1791157082192`. A paced normal right-drag changes yaw by 1.600262 radians,
leaving a 1.25-radian heading error inside the hostile arc. The original cast
remains live throughout the turn; player displacement is effectively zero,
mouse look ends on release and pointer lock stays null. The chosen clear
canvas row is 60% of viewport height, verified from live DOM hit tests.
The player Vow completes on Hamhock at `1791157081779`; the owned
`rom-priest-vow-of-silence` aura has that application time and expires at
`1791157090179`. At `1791157081834`, Hamhock remains alive at 55,483 HP and
the watched cast is gone, 358 ms before its scheduled release. Seven
read-only follow-through samples continue through `1791157082310`, 118 ms
past release, with no matching Chain Lightning damage. The last sample still
shows the living boss at 54,974 HP and no active cast. School-lock advancement
is not observed; silence-first cancellation supplies the proof.
This diagnostic stops gameplay input at checkpoint 213, with Targorr and Kam
as its two recorded boss IDs. It is separate from the verified 5/5 full clear.
Rorik, Durnik and Caelan each die once and return through the normal **Rez dead
members** control; all five are alive at the endpoint. The native-stat player
advances from 30 to 35. There is no player death or corridor replay, so that
recovery path still lacks live proof. The inspected endpoint image shows
Hamhock alive and loaded equipped bodies, with party overlap and a wall
occupying much of the view. No page errors occur; the only console error is
the local favicon 404. No runtime, catalog or build changes are made for the
three targeted captures.
@@ -0,0 +1,154 @@
# Stockade utility attack source audit — 2026-10-04
**Finding:** The flagged entries were real NPC damage packets in the failed route artifact, not damage caused by the adjacent player actions. The route’s old generated attacks lacked explicit effects, so the runtime treated them as direct damage. Two source corrections now omit thirteen audited utility/extra-attack IDs and preserve supported damage/control with verified delivery, targets and ranges; no runtime aura values or encounter damage were guessed.
## First correction and failed-route evidence
| Stockade source | Failed-route generated attack | Captured route events |
| --- | --- | --- |
| Kam Deepfury, entry 1666 / actor `creature-79088`; spell 7164 Defensive Stance | `smart-1666-0-defensive-stance`, primary target, projectile, damage multiplier 1.35 | 14 records, 12 landed, 6,079.14 effective damage; 10 records hit Durnik for 4,991.14 total |
| Defias Inmate, entry 1708; spell 7165 Battle Stance | `spell-7165`, primary target, projectile, damage multiplier 1.12 | 11 records, 10 landed, 2,577.78 effective damage |
| Defias Convict, entry 1711; spell 16244 Demoralizing Shout | `spell-16244`, primary target, projectile, damage multiplier 1.12 | 27 records, 24 landed, 5,115 effective damage |
| Defias Insurgent, entry 1715; spell 13730 Demoralizing Shout | `spell-13730`, primary target, projectile, damage multiplier 1.12 | 17 records, 16 landed, 4,620.29 effective damage |
These four IDs account for 69 recorded events, 62 landed, and 18,392.20 effective damage in the failed artifact. They contributed damage during the attempt, but the known LOS and GCD harness problems prevent assigning them as the sole cause of any death or route failure. The refreshed campaign catalog contains none of these four as direct-damage attacks.
## Source contract
The compiler’s default 3.3.5a Ascension source is `../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc` (`ASCENSION_SPELL_DBC` can override it). Its SHA-256 is `2f77f18b91ffbae32a19568e4bc05ec78ee384b6ba8dd2a6e05f0e97abbb2dce`, matching the local pin in `src/game/ascensionCustomEncounterCatalog.ts`. The DBC effect rows show:
- 7164 Defensive Stance and 7165 Battle Stance each contain only effect 6 (`APPLY_AURA`), aura 36 (`MOD_SHAPESHIFT`), caster target A 1, and target B 0. Their misc values are 18 and 17. Neither has a damage opcode.
- 16244 contains only `APPLY_AURA`, aura 79 (`MOD_DAMAGE_PERCENT_DONE`), target A 22 (`TARGET_SRC_CASTER`), target B 15 (`TARGET_UNIT_SRC_AREA_ENEMY`), misc value 1, and no damage opcode.
- 13730 contains only `APPLY_AURA`, aura 166 (`MOD_ATTACK_POWER_PCT`), with the same source-centered enemy-area target pair, misc value 0, and no damage opcode.
The generated `azerothCoreWorldAi.json` identifies the AzerothCore world SQL snapshot as commit `4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3`. Its exact rows agree with the read-only historical SQL reference under `../wow-pvt-server-backups-2/azerothcore-wotlk/data/sql/base/db_world/`:
- Entry 1666, row 0 casts 7164 in combat with `target_type=1` (`SMART_TARGET_SELF`).
- Entry 1708, row 0 casts 7165 out of combat with `target_type=1` (self). The creature spell list also includes 7165, which is why the generic spell-list path had incorrectly emitted an in-combat attack.
- Entry 1715, row 1 casts 13730 in combat with `target_type=1` (self). The DBC specifies the source-centered enemy area.
- Entry 1711’s creature spell list includes 16244; its SmartAI rows do not declare it as a primary-target damage action.
## Owning boundary and correction
The defect belonged to `scripts/dungeon-pipeline/compile-campaign-catalog.mjs`: `spellSemantic()` did not support these aura families, and `spellAttack()` filled the missing semantics with an inferred direct-damage attack. The generic creature-spell path also lost the Inmate’s out-of-combat phase; the SmartAI path could turn a self target into primary when constructing an attack.
The first source pass recognized only the four audited spell IDs and their pinned aura opcode, aura type, source target pair, and misc value. With no supported semantic or direct-damage opcode, each attack was omitted. A signature change failed generation explicitly. Null results were filtered from both creature-spell and SmartAI paths.
The runtime’s generic aura effect does not faithfully model shapeshift form 36 or the outgoing damage / attack-power modifiers here. Omitting unsupported utility spells keeps them from becoming damage while avoiding invented aura magnitudes. No runtime schema change was needed. The first compiler refresh removed these four IDs from 17 generated entity rows across 10 dungeon definitions; no other spell IDs were suppressed in that pass. At the end of that pass, Kam retained Shield Slam 8242 and Improved Blocking 3419, and Houndmaster Grebmar retained 21049 and 15615 without a fallback surge. The second pass below removes 3419 and retargets 8242. Stockade Rend 6547 / 16509 remain, and mixed-damage Shield Slam 8242 remains a control effect with its supported damage multiplier.
The generated catalog had additional pre-existing drift: the committed compiler already emitted `schoolMask`, `damageOrigin`, and `damageClass` (plus `attackKind` where applicable), but the committed generated JSON had none of these fields on 3,762 attacks. The first refresh added 2,392 `schoolMask`, 2,392 `damageOrigin`, 2,392 `damageClass`, and 1,751 `attackKind` fields. Its structural comparison confirmed that, after removing those four already-supported metadata fields and the four first-pass utility attack IDs from the pre-refresh catalog, the result exactly matched the refreshed JSON; only the corresponding two `mechanicIds` entries were also removed. No health, damage multiplier, or encounter scalar changed in that pass.
The earlier completed backlog item about preserving enemy damage class and school
covers damage typing, not non-damaging NPC aura semantics or SmartAI self/phase
handling. No narrower item covered these rows at audit start. The four-spell
damage-fallback correction now has its own completed item in
`docs/ACTIVE_CLASS_MECHANICS_BACKLOG.md`; full utility aura modeling remains open.
Verification: `node --check scripts/dungeon-pipeline/compile-campaign-catalog.mjs` passed; the catalog generator refreshed `src/game/generated/dungeonCampaignCatalog.json` and its compressed TypeScript output; the focused `dungeonCampaignCatalog.test.ts` Stockade utility regression passed. No full suite, production build, browser QA, or route retest was run.
The coordinating parent subsequently passed the corrected campaign catalog at
32/32, enemy attack/casting/damage-type/content checks at 24/24, Ready-five at
7 Vitest plus 20 Python checks, and the utility-corrected production build.
The registry-total regression now verifies the six existing additional
RuneWaker definitions from their committed source snapshots rather than retaining
the stale 92-entry total; the current registry has 98 definitions. The normal
Stockade campaign retry uses this new build and is documented separately in
`docs/STORMWIND_STOCKADE_PLAYTEST_2026-10-04.md`.
## Source-semantic pass two
The corrected route exposed more Stockade contract errors: Backhand 6253 was a
random-party projectile instead of a victim stun at melee range; Shield Slam
8242 was self-targeted despite combining victim stun and damage; and Battle
Shout 9128 plus Improved Blocking 3419 were emitted as damage packets. The
same source review found wrong targeting or range for Kick 11978, Strike 11976,
Backstab 15657, Chain Lightning 421, Intimidating Shout 5246, Frightening
Shout 19134, and Smoke Bomb 7964.
The compiler tags parsed spells with the recipe's DBC profile and validates an
exact ordered signature for every audited ID: range index, school mask, damage
class, duration, effect type/aura/base/mechanic/targets/radius/misc, and period.
An unmatched profile or changed signature fails generation. Unsupported
utilities are omitted only after the selected profile signature matches and
has no supported semantic or direct/mixed damage effect.
| Utility/extra-attack ID | Removed rows | Affected definitions |
| --- | ---: | --- |
| 674 Dual Wield | 1 | Stormwind Stockade |
| 2457 Battle Stance | 4 | Shadowfang Keep, Stormwind Stockade, Uldaman |
| 3391 Thrash | 26 | Blackrock Depths, Blackrock Spire, Blood Furnace, Deadmines, Dire Maul, Maraudon, Razorfen Downs, Scarlet Monastery, Scholomance, Shadow Labyrinth, Shattered Halls, Stormwind Stockade, Stratholme, Sunken Temple, Uldaman, Zul'Farrak |
| 3419 Improved Blocking | 1 | Stormwind Stockade |
| 6713 Disarm | 7 | Hellfire Ramparts, Scarlet Monastery, Shadowfang Keep, Steamvault, Stormwind Stockade, Stratholme, Uldaman |
| 6742 Bloodlust | 4 | Blackrock Spire, Scarlet Monastery, Stormwind Stockade, Uldaman |
| 9128 Battle Shout | 6 | Blackrock Depths, Dire Maul, Razorfen Downs, Razorfen Kraul, Stormwind Stockade |
| 17230 Infected Wound | 1 | Stormwind Stockade |
| 29651 Dual Wield | 1 | Stormwind Stockade |
These nine exact signatures remove 51 false direct-hit rows across 20
definitions. Dual Wield is an offhand capability; Thrash adds two extra
attacks; the remaining IDs are non-damaging stance, buff, block, disarm, or
target-debuff effects. Those unsupported behaviors remain documented gaps.
| Preserved/corrected ID | Updated rows and definitions | Generated contract |
| --- | --- | --- |
| 6253 Backhand | 5: Stockade, Blackrock Spire | Stun remains; primary victim, melee, range 5. |
| 8242 Shield Slam | 3: Stockade, Ragefire Chasm | Mixed stun/damage remains; primary victim, melee, range 5. Existing damage multipliers remain 1.12 for mobs and 1.35 for bosses. |
| 11976 Strike | 6: Stockade, Blackrock Depths, Stratholme, Ragefire Chasm, Underbog | Weapon damage remains; primary melee, range 5. |
| 15657 Backstab | 2: Stockade, Maraudon | Direct damage remains; primary melee, range 5. |
| 11978 Kick | 5: Stockade, Scarlet Monastery, Blackrock Depths, Stratholme, Blood Furnace | Ascension's pure interrupt is primary melee for 4 seconds; the compiler's former synthetic direct hit is removed. The pinned Epoch variant adds source damage and a 6-second interrupt; its damage remains represented with the prior scalar. |
| 421 Chain Lightning | 1: Stockade | Direct damage remains; range is corrected from 28 to the sourced 30 yards. The runtime still resolves one target, so DBC chain fan-out is unsupported. |
| 5246 Intimidating Shout | 3: Stockade, Dire Maul, Slave Pens | Fear remains caster-centered at radius 8; effective source range 8. |
| 19134 Frightening Shout | 3: Stockade, Dire Maul, The Nexus | Fear remains caster-centered at radius 8; source range 10. Duration follows the selected profile. |
| 7964 Smoke Bomb | 2: Stockade, Maraudon | Stun remains caster-centered at radius 5; effective cast range 5. |
The shouts' triggered secondary effects remain unsupported because they require
different target sets from the caster-centered fear. Stockade controls use
`nearby-party`, preserving all eligible party actors in the sourced radius.
Rend 6547/16509 and Enrage 8599 were not changed. Rend's periodic damage and
Enrage's buff values remain existing approximation gaps rather than being
reclassified as direct damage.
The second-pass structural comparison used
`playtest-artifacts/campaign-boss-pass/catalog-before-second-source-pass.json`.
It found exactly the nine utility/extra-attack removals above (51 rows), 30
corrected attack rows, and corresponding mechanic-ID removals on 15 bosses. No
attack was added, no other entity or encounter field changed, and no boss
gained fallback surge mechanics. Damage scalars stayed unchanged except that
the five pure Ascension 11978 interrupt rows no longer carry the former
synthetic direct-hit multiplier.
The Ascension source is
`../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc`
(SHA-256 `2f77f18b91ffbae32a19568e4bc05ec78ee384b6ba8dd2a6e05f0e97abbb2dce`).
The Epoch source used by the catalog is
`../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc`
(SHA-256 `D5CCE1A83550DCFA9EB2F0251DBB11FD24C272534B2B1A9B230924A44D817AB3`).
Epoch has no sibling `SpellDuration.dbc` or `SpellRadius.dbc`, so the compiler
uses its established shared patch-S fallback tables (SHA-256
`EEAE44EF0185924F3D6EC57AB2701C66566DB86E51AB0BAE017A9449126EB3A0` and
`DBA6ED2F165F75C84DDA5F32AE46B1A01FE562EFD88FA51FD19365705C3E6A44`). These
are the exact duration/radius inputs used for Epoch imports, not pristine
Epoch-client support tables. Profile differences are pinned separately:
Epoch's three shouts use aura 99 (`MOD_ATTACK_POWER`); its Battle Stance and
Disarm signatures have fewer aura rows; Shield Slam and Backstab use different
direct-damage bases/effects; Kick includes a small damage effect and lasts 6
seconds; and 421, 5246, and 19134 have different damage or control-duration
values. No Ascension-only signature suppresses an unmatched Epoch spell.
The focused test checks Stockade as an Ascension fixture and The Nexus
Frightening Shout as an Epoch fixture: both receive the same sourced area
target/radius, while the fallback-backed durations remain 4 seconds and 6
seconds respectively. Verification: `node --check
scripts/dungeon-pipeline/compile-campaign-catalog.mjs`; the catalog compiler
completed for 54 definitions; and `npx vitest run
src/game/dungeonCampaignCatalog.test.ts` passed all 33 tests. The generated JSON
and compressed TypeScript were refreshed. No build, browser QA, encounter
tuning, or full test suite was run by this audit.
The parent independently reran 57 catalog/enemy checks and Ready-five (7 Vitest
and 20 Python), verified raw/compressed catalog equality, and reproduced the
exact second-pass structural diff. A separate reviewer decoded all 22 pins
from both live DBC inputs and found no concrete issue. Smoke Bomb's raw range
is self-only: runtime range 5 is a hostile-target gate adapted to its sourced
caster-area radius 5. The final source-aware production build passed under
`index-CM5EW2MK.js`; full-route live evidence belongs to the Stockade report.
@@ -0,0 +1,119 @@
# Stormwind Stockade campaign playtest — 2026-10-04
## Status
The final source-aware build completed a normal five-boss Stormwind Stockade campaign route and displayed the native **FULL DUNGEON CLEAR** result with all five required boss IDs present. The successful route used the ordinary level-30 Priest/Knight test identity and the native five-member party; the player leveled to 39 during the run. A separate bounded Vow of Silence diagnostic is being prepared because Hamhock's Chain Lightning was observed during the full clear, but the boss was killed before the route reached its Vow handler.
Stormwind Stockade (`stormwind-stockade`) is the routine testing fixture for this campaign route. The generated definition has five required bosses, ending at Dextren Ward. The route harness uses the shared exact-title Stockade picker helper when no route selector is supplied. Wailing Caverns is guarded as historical-only and requires both `--route=wailing-caverns` and `--historical-wailing`; this run does not use that path.
## Generated route and geometry
The source is `src/game/generated/dungeonCampaignCatalog.json`: map 34, level range 20–30, five required bosses, 11 creature entries, and 93 static spawns. The guide is emitted by `scripts/campaign-boss-route-probe.ts` to `playtest-artifacts/campaign-boss-pass/navigation-guidance.jsonl`.
The probe reads the generated Stockade definition and the shipping navigation asset at `/assets/game/manastorm/34-stormwindjail/navigation.glb`. It expands normalized quantized vertex attributes with their normalized flag, applies the GLB node world transform, and calls the shipping `preparePartyNavigationGraph` path with the Stockade navigation links and traversal ramps. The waypoints are a route guide for subsequent ordinary WASD input; they do not move the player or skip obstacles.
| Order | Boss | Source id | Guide waypoint | Segment distance | Surface snap |
| --- | --- | --- | ---: | ---: | ---: |
| 1 | Targorr the Dread | `creature-84027` | 86 | 129.40 m | 0.164 m |
| 2 | Kam Deepfury | `creature-79088` | 180 | 142.52 m | 0.042 m |
| 3 | Hamhock | `creature-79098` | 235 | 83.71 m | 0.036 m |
| 4 | Bazil Thredd | `creature-79124` | 255 | 43.97 m | 0.083 m |
| 5 | Dextren Ward | `creature-79116` | 481 | 359.72 m | 0.132 m |
The prepared path contains 482 waypoints and 759.31 m of navigation-graph travel. Entrance snap distance is 0.185 m. These figures validate the geometry-derived guide only; live traversal and line of sight are still verified through normal game input and the engine.
## Normal campaign run
From the repository root, regenerate the guide and start the full-route attempt after the coordinated final build is ready:
```powershell
New-Item -ItemType Directory -Force playtest-artifacts/campaign-boss-pass | Out-Null
node_modules/.bin/vite-node --script scripts/campaign-boss-route-probe.ts > playtest-artifacts/campaign-boss-pass/navigation-guidance.jsonl
node scripts/playtest-campaign-boss-pass.cjs --campaign-boss-route --route=stormwind-stockade --camera-diagnostic --max-campaign-seconds=2400
```
The harness creates a Priest through the normal character-creation UI, applies the allowed isolated level-30/secondary-class fixture before entry, enters through the normal Dungeons picker and healer-role choice, and requires the native active five-player party. It uses normal keyboard movement, target cycling, party Attack/Defend commands, selected party frames, and the player’s currently bound healing and damage abilities. Range and game feedback are recorded; collision, targeting, line of sight, and cast resolution remain game-owned. A dedicated Stockade entrance screenshot records the player’s equipped silhouette alongside the native party, in addition to the roster-creation screenshots.
At each source boss anchor the harness records the encounter checkpoint, normal commands, player casts, heal/damage distances, party health, visible caster state, encounter progress, and the native `killedBossIds` snapshots. Bosses defeated naturally before their route checkpoint count as observed progress. Hamhock’s source AI includes Chain Lightning (spell 421) and Bloodlust (spell 6742); if the test identity normally exposes Vow of Silence (DBC spell 490275), the harness assigns it through Ability Controls and attempts it only against a live observed Chain Lightning cast at the Hamhock checkpoint. In the completed full-route capture, Hamhock was engaged and six Chain Lightning damage events were recorded before the guide reached his authored checkpoint, but the campaign boss handler did not issue a Vow input during the earlier pack triage. The Vow result is therefore still unverified by that full clear; a separate targeted watch is required.
The party may die naturally during a route encounter; each observed casualty and recovery transition is retained separately from final survivors, and visible UI resurrection actions are summarized independently. A pack with no observed engaged mobs is explicitly recorded as `no-engaged-mobs-observed; clear-not-credited`. There is no heal-attempt-count cutoff; each pack is bounded by the route deadline and a 110-second encounter window, with repeated failed inputs and end-of-window outcomes retained. One-second health, party, and live-mob samples continue during packs. Once the dungeon-results overlay opens, the route and encounter handlers stop issuing inputs. Only the native full-dungeon-clear overlay is reported as a full clear; boss progress alone does not imply full completion.
The route artifact records the ordered boss coverage and full-clear status under `playtest-artifacts/campaign-boss-pass/<run>/stormwind-stockade-campaign-boss-route.json`. The results summary derives healing and player-damage counts from the campaign's raw source-attributed combat events and keeps its campaign timing separate from the configured direct-segment duration. Screenshots include the normal campaign picker and entrance, the equipped player silhouette with native party, route progress, the held right-drag/released-camera probe with pointer-lock state, route boss checkpoints, and the result view when reached. The camera probe is performed at the entrance; it verifies that a held right drag changes camera yaw without moving the player, that pointer lock remains null, and that subsequent unheld movement leaves yaw stable.
## Final source-aware five-boss route
The frozen production run is `playtest-artifacts/campaign-boss-pass/final-build-20261004-214255695/`. The selected dungeon ID and title were `stormwind-stockade` / `Stormwind Stockade`; the generated definition and route result contain these ordered IDs: Targorr the Dread (`creature-84027`), Kam Deepfury (`creature-79088`), Hamhock (`creature-79098`), Bazil Thredd (`creature-79124`), and Dextren Ward (`creature-79116`). The live module report identified `dungeonWorldInteraction-coBQV5ap.js` as the `dungeonRunStore` owner, and read-only entry discovery found the store before movement began.
The native result overlay showed **FULL DUNGEON CLEAR**, “Bosses defeated · 5/5,” and all five names as Defeated. The run store's `killedBossIds` contained all five required IDs. The final overlay opened at navigation checkpoint 462/481 and stopped further route input, as required; the route did not fabricate a boss arrival or a 100% waypoint screenshot. The native panel reported 17:37.7 to final boss. Recorded route evidence spans about 17:38.9 from the segment start to the last captured pack state. The full clear remains verified from the overlay and run-store IDs even though the final authored boss waypoint was not reached before the overlay appeared.
The level-30 Priest/Knight player reached level 39. One companion casualty was recorded: Rorik died once and returned through the ordinary “Rez dead members” UI. The player had no death transition; all five party members were alive at the result overlay. Of 25 pack-triage records, 23 ended with observed clear and party consolidation, one reached its 110-second encounter window, and the final triage record ended when the native results overlay opened. Those pack outcomes are reported separately from the authoritative full-clear status.
The read-only fatal-window audit found Rorik at 1,524.1798 HP in the last living sample 3,751 ms before his down transition. The next five logged hits summed to exactly 1,524.1798; the final effective hit was 209.1798 from `creature-79108`, observed 840 ms before the down transition, and the next pack sample showed 0 HP 31 ms later. His last Urgent Heal completed 3,843 ms before the down transition. A Group Heal began at `1791151066769` and resolved at `1791151069828`; Rorik's 209.1798 effective damage hit was at `1791151069127`, 701 ms before the Group Heal resolved. The hit arithmetic implies his HP had reached zero by the Group Heal resolution, and the living-recipient filter would exclude a dead member. The trace has no dedicated death or recipient-selection event, so it does not directly show that exclusion. This timing does not support a range or line-of-sight explanation for his death. A separate source-fidelity audit found that the Group Heal catalog advertises a 12 m radius while the shared runtime recipient helper defaults to 30 m for player and NPC healing; that mismatch is documented independently and was not changed in the frozen build.
The source-attributed healing log contains 424 player-source healing events, 203 with positive effective healing, totaling 158,465.20 effective healing and 423,321.80 overhealing. Those event totals differ from scripted healing-input windows: 172 windows contained 169 attempted inputs, 166 accepted cast starts, 160 windows with an effective heal, three attempted keys without a matching cast start, and three windows skipped without input. Urgent Heal had 135 attempts, 132 accepted cast starts, and 126 effective windows. Group Heal was normally assigned to the tertiary `Alt+Digit2` binding and had 34 attempted and accepted starts; all 34 windows observed a positive healing event on at least one eligible party recipient, for 66,939.01 effective healing within those windows. The action-school helper was uniquely discovered from the frozen `combatStore-o8tawtxY.js` function source and supplied the recorded action schools.
The entrance camera probe recorded a yaw change of −0.378 radians during the held right drag, only 0.00264 m player displacement during the drag, null pointer lock throughout, and zero unheld yaw change after release. The actual screenshots present in the run directory are the two roster views, campaign-picker, entrance, equipped player/native-party appearance, Vow binding UI, three entrance camera frames, route checkpoints at 25%, 50%, and 75%, and `stormwind-stockade-boss-arrival.png` showing the native full-clear panel. There is no route-100 or endpoint screenshot because the overlay ended route input first.
The route JSON recorded no browser page exceptions or campaign console errors. The outer results summary captured one initial `favicon.ico` 404; required game chunks and asset URLs are recorded in `actualAssetUrls` and `frozenBuildAssetUrls`. This static icon miss did not affect the run.
## Exploratory corrected-Blood-Arrow route and heal-input diagnosis
The first full-route attempt on the corrected Blood Arrow build is preserved at `playtest-artifacts/campaign-boss-pass/final-build-20261004-192248412/`. It loaded the corrected Blood Arrow chunks and reached Stockade. The player died while stabilizing the live engaged pack at checkpoint 149/481 near the Kam Deepfury approach. The source mob for Targorr the Dread was observed dead, but the route did not reach the other four boss checkpoints, Hamhock/Vow proof, or the native results overlay. The `stormwind-stockade-boss-route-25.png` view shows the spread-out native party and equipped bodies during the route; entrance and UI screenshots are also retained. Do not treat this as a clear.
That run reported `dungeonRunStoreFoundAtEntry: false` because the harness's read-only module discovery omitted the store's owner chunk; its empty `killedBossIds` snapshots therefore say nothing about whether the game registered Targorr's death. At the time, `dungeonSession-*.js` was only a guessed owner and was not established by that run. The build's module report later identified `dungeonWorldInteraction-*.js` as the actual owner; the current harness discovers it from the report and stops before route execution if the store is unavailable.
The last 62.6-second pack recorded 47 key-5 Urgent Heal input windows, six with an effective-heal event and 41 without one. Those 41 are not the route's total healing: across the full route the combat log contains 118 Urgent Heal cast events and 111 source-`__player__` healing events, totaling 38,741.29 effective healing and 45,021.71 overhealing. The 111 event rows break down as follows; Life Link also accounts for the separate self-recipient rows.
| Source | Ability | Recipient | Healing events | Effective healing | Overheal |
| --- | --- | --- | ---: | ---: | ---: |
| `__player__` | Urgent Heal | Rorik | 37 | 25,152.62 | 2,486.38 |
| `__player__` | Urgent Heal | Caelan | 12 | 6,416.09 | 2,485.91 |
| `__player__` | Urgent Heal | Veyra | 2 | 1,046.99 | 353.01 |
| `__player__` | Urgent Heal | Durnik | 4 | 3,236.00 | 0.00 |
| `__player__` | Urgent Heal | player self | 56 | 2,889.58 | 39,696.42 |
Within the last-pack input windows, eight matching Urgent Heal cast-start events were observed: six completed with positive healing, and two began but did not produce a positive healing event. The other 39 keypresses had no matching cast start in their action window. This distinction comes from key-window correlation; the old harness only saved the latest feedback after its wait, which was often a later mob hit and could not explain a rejected input. All 47 presses reached the page key listener. Their recorded party distances ranged from 12.90 to 21.56 m against Urgent Heal’s 25 m range, but five windows ended with explicit `That target is not in line of sight.` feedback. Mana stayed full through the repeated non-effective attempts. The old capture omitted GCD readiness and selected-friendly-target state, so it cannot identify the rejection cause of each no-start window.
The route harness now captures the bound key, focused UI element, selected friendly actor, active cast, personal/global cooldown timing, feedback before/immediately after/through the input window, player/target health and resource, and cast-start/effective events. Healing keys wait for live readiness. An explicit party-heal LOS rejection can trigger one measured normal F4 Recall and one retry; already learned, UI-bound Group Heal is eligible when at least two injured allies are in its documented radius. No ability or stat is added and no combat state is written by the harness. The refreshed source-attributed event summary is kept separate from its distinct ordinary-key input-window summary.
The first exploratory build emitted physical damage for Kam Deepfury Defensive Stance (7164) and trash Battle Stance (7165) / Demoralizing Shout (16244, 13730), despite the source-only utility entries having no damage opcode. The utility correction was included in the later build: exact `spell-7164`, `spell-7165`, `spell-16244`, and `spell-13730` damage events were absent from that capture. That later run did, however, contain 32 physical damage events attributed to `spell-9128` (15,648 raw, 9,232.59 effective) across all five party targets. The subsequent source audit classified that additional case as unsupported utility and omitted it in the final source-aware build. See `docs/STOCKADE_UTILITY_ATTACK_AUDIT_2026-10-04.md`; this is an import correction rather than an encounter-balance finding, and the harness made no damage or runtime changes.
## Utility-corrected exploratory route and harness repair
The newer utility-corrected-build capture is `playtest-artifacts/campaign-boss-pass/final-build-20261004-202642234/`. The dynamically discovered dungeon-run store owner was `dungeonWorldInteraction-DRqE4yxE.js`, so the store was present and its progress is authoritative. The route observed Targorr the Dread (`creature-84027`) defeated, then stopped in a seven-mob pack containing Kam Deepfury at route checkpoint 122/481 (263.10 m of the 759.31 m guide). The player died after 23.785 seconds in the pack; the native results overlay never appeared. The final party was Veyra alive at 759.90/2,781.90, with Rorik, Durnik, and Caelan down. Rorik had also died and recovered once earlier. The capture is a partial diagnostic, not a clear.
The capture contains 134 source-attributed player healing events: 71 with positive effective healing, totaling 43,738.60 effective and 52,884.40 overheal. These event totals are separate from 147 Urgent Heal keypress windows: 74 had a matching cast start and 67 produced an effective-healing window. The remaining 73 windows had no cast start. Their immediate feedback classified 28 as line-of-sight rejections and 33 as enemy-control rejections; feedback for 12 was overwritten by subsequent combat messages. All 73 had an observed key event, the correct binding, zero GCD and personal cooldown remaining, no active cast, and an in-range living target. The earlier `selectedFriendlyActorId`-only diagnostic was misleading because native party-frame targeting is held by `partyStore.selectedMemberId` / `selfSelected`.
The source-attributed heal event breakdown is:
| Recipient | Healing events | Effective healing | Overheal |
| --- | ---: | ---: | ---: |
| Rorik | 47 | 31,575.41 | 2,037.59 |
| Player self | 67 | 1,569.61 | 47,104.39 |
| Caelan | 16 | 7,661.58 | 3,742.42 |
| Durnik | 3 | 2,188.00 | 0.00 |
| Veyra | 1 | 744.00 | 0.00 |
The recorded Group Heal is already assigned to `tertiary:face-left` (`rom-priest-primary-8`, `Alt+Digit2`) in the ordinary action bindings; it was omitted because the earlier route planner inspected only primary slots. The route harness now scans primary, secondary, and tertiary bindings and records the modifier state on actual key events. Group Heal input windows retain all eligible native-party recipient IDs and report their individual source-attributed healing events. The harness records `partyStore.selectedMemberId` / `selfSelected`, `controlledUntil`, `controlMechanic`, and the derived action-block gate alongside cooldown, active-cast, target, feedback, health, resource, range, and cast-start telemetry. It waits for action-blocking control to expire instead of sending a rejected cast. Because production minifies the helper export name, the harness discovers the pure `abilityDamageSchool` helper from its unique ability-name literals and source/school-rule shape, records the discovery and selected school source per action, and waits for the relevant school lock. If that helper is unavailable and an active school lock exists while the action school is unknown, the input is withheld.
Normal player recovery is available through the in-game Defeated prompt. Its checkpoint list consists of the entrance and completed/dead required-boss positions. Clicking the prompt's respawn button uses an authored checkpoint; if a party fight is still active, it respawns only the player and leaves the encounter running. The harness records the visible checkpoint choices, selects the most recently killed boss checkpoint when available (otherwise the latest authored boss option or entrance), clicks the normal UI button, and verifies restored health. Recovery evidence checks that all previously killed boss IDs remain present, compares dungeon/run identity when those fields are available, and separately records any bosses companions kill during the recovery interval. After respawn, route movement rewinds to the selected checkpoint's source waypoint and replays ordered waypoints through the interruption. Replay visits are recorded separately so they do not replace first-pass verification; this also applies to approach-pack and auxiliary final-boss recovery. A replay counts as complete only after its actual interrupted checkpoint is verified; an intervening native results overlay is recorded as such, and a deadline or movement block leaves replay incomplete. Companion resurrection remains a separate visible UI action and can be unavailable while a live boss is engaged. No player position, health, cast, or encounter state is written by these recovery steps.
## Harness checks
- `node --check scripts/playtest-campaign-boss-pass.cjs` passes. `git diff --check` passes for the harness and this report.
- The navigation probe completed against the generated catalog and shipping geometry; it reproduced the 482-waypoint, 759.31 m guide with all five authored boss anchors.
- The coordinating parent reported `src/game/stockadesNavigation.test.ts` passing 16/16 focused checks, including corridor/resurrection behavior and Stockade wall/stair/line-of-sight cases.
- The stopped pre-correction setup evidence is preserved at `playtest-artifacts/campaign-boss-pass/final-build-20261004-190619118/`. It contains the UI-created desktop and compact Priest/Knight roster screenshots and the exact Stockade picker screenshot; the aura issue was found before dungeon entry.
- The initial capture had a run-store discovery failure and is retained only as setup evidence; the later exploratory capture had a confirmed store and one-of-five boss progress. The source-aware final build has since completed and produced the verified full clear documented above. A separate bounded Vow diagnostic is recorded below; it is not another full-clear attempt.
## Targeted Hamhock Vow cancellation diagnostic
The separate ordinary-campaign diagnostic is preserved at `playtest-artifacts/campaign-boss-pass/final-build-20261004-232722030/`. It used the same frozen source-aware production assets as the full-clear capture and dynamically discovered `dungeonWorldInteraction-coBQV5ap.js` as the dungeon-run-store owner. Vow of Silence was learned and assigned through the normal Ability Controls UI to `Shift+Digit5`. The route observed four live Hamhock Chain Lightning windows and made one Vow input; this single attempt confirmed cancellation. The route stopped gameplay inputs immediately after that proof and did not attempt a full clear.
The watched cast was Hamhock (`creature-79098`) Chain Lightning (`smart-1717-0-chain-lightning`, spell 421), started at `1791157080692` with scheduled release at `1791157082192`. Before the input, the player was 18.996 m from the living target, but camera yaw left a 2.8503-radian heading error. The harness used ordinary held right-mouse camera drag on a hit-tested canvas row: eight paced increments, each at most 100 px with a rendered frame between increments. It verified mouse-look activation, null pointer lock, and that the same cast remained active throughout. Measured yaw change matched the requested turn within 0.000001 radians, leaving a 1.25-radian heading error inside the hostile facing arc with effectively zero player displacement.
At `1791157081779`, the normal `Shift+Digit5` input completed a player-source Vow cast on Hamhock. The owned `rom-priest-vow-of-silence` aura was applied by `__player__` to Hamhock at that time and expired at `1791157090179`. At `1791157081834`, before the original cast’s scheduled release, Hamhock was still alive, the watched cast had disappeared, and no matching Chain Lightning damage event had occurred. The harness then issued no gameplay inputs and sampled through 100 ms after scheduled release; all seven follow-through samples showed no active cast and no matching damage event for that cast. The target’s health changed during this period from other damage, so the evidence is cast-specific rather than a claim that target health stayed constant. The school lock did not advance; Vow’s silence aura provides the cancellation, so a school-lock change is not required proof.
This diagnostic stopped at route checkpoint 213 with two authoritative boss IDs recorded: Targorr (`creature-84027`) and Kam Deepfury (`creature-79088`). The remaining bosses were not credited and the native full-clear overlay was not reached. Rorik, Durnik, and Caelan each had one recorded casualty and returned through the ordinary “Rez dead members” UI; all five party members were alive at the diagnostic endpoint. The artifact also contains the binding UI, campaign picker, entrance, equipped-party view, 25% route view, and boss-arrival screenshot. Its route JSON reports `confirmed-live-chain-lightning-cancellation` and explicitly records `full-clear-not-attempted`.
The earlier failed diagnostics remain unchanged as driver evidence: `final-build-20261004-224251892` recorded two facing rejections, while `final-build-20261004-230719179` made no Vow input because every tested drag path crossed a HUD element. The reviewed harness now scans candidate canvas rows, records hit-test identities, spaces normal drag increments across frames, gates the ability key on the measured facing arc and same live cast, and preserves a read-only follow-through through the watched cast’s scheduled release. No runtime, catalog, game-balance, or build files were changed for these diagnostics.
+83
View File
@@ -0,0 +1,83 @@
# Default test dungeon — Stormwind Stockade
The project owner selected **Stormwind Stockade** on 2026-10-04 as the default
for new routine gameplay, mechanics, healing, party, camera, input, performance
and browser diagnostics. The runtime ID is `stormwind-stockade`; the UI title is
`Stormwind Stockade`. This replaces Wailing Caverns as the routine test dungeon.
Mode-specific Bite-Sized spawn/Race diagnostics use `bite-sized-stormwind-stockade`
as their default Stockade fixture. They remain distinct from the full dungeon
required for ordinary mechanics and campaign-route proof.
## Agent workflow
1. Read `AGENTS.md` and the relevant canonical vault notes.
2. Use the full Stockade dungeon from **Dungeons → WoW → Stormwind Stockade**.
Install and verify its content through the normal entry/download flow when
required. Do not fall back to bundled Wailing content.
3. For reusable harnesses, import `scripts/playtest-defaults.cjs`. Its exports
are `DEFAULT_TEST_DUNGEON_ID`, `DEFAULT_TEST_DUNGEON_TITLE`, and
`DEFAULT_TEST_DUNGEON_BOSS` (Hamhock for generic Boss Testing).
`selectDefaultTestDungeon(page)` selects the exact full-dungeon row;
`openSelectedDungeonRoles(page)` handles both installed content and the normal
download confirmation before waiting for role selection.
4. Use ordinary movement/casting for live mechanic and route proof. Read-only
telemetry may inspect casts, resources, actor positions and run progress.
Label any isolated pre-entry profile preparation or synthetic unit fixture.
5. Record loaded build assets, source boss identity, actual mechanic outcome,
screenshots, runtime errors and limitations in `PLAYTEST_NOTES.md` and the
scoped report. Inspect the screenshots before claiming a visual fix.
## Source and completion
The authored recipe is `dungeon-pipeline/recipes/stormwind-stockade.json`;
`dungeon-pipeline/runtime-overrides/stormwind-stockade.json` owns runtime
overrides. The catalog is generated by the dungeon pipeline; do not hand-edit
its JSON or compressed TypeScript. The existing Stockade navigation regressions
are in `src/game/stockadesNavigation.test.ts`.
The current full-dungeon objective order is Targorr the Dread, Kam Deepfury,
Hamhock, Bazil Thredd and Dextren Ward. Dextren Ward is the final objective.
A normal full-clear claim requires all five objectives and the native results
screen; reaching an anchor or clearing a Bite-Sized variant is separate proof.
Record persistent killed-boss/run progress so natural earlier kills survive
endpoint despawning. A zero-engaged pack attempt does not establish a pack kill.
Hamhock's source list includes Nature Chain Lightning 421 and Bloodlust 6742.
Chain Lightning is a real damaging cast with sourced range 30; its chain fan-out
remains unsupported. Bloodlust is a haste buff, and its unsupported utility
effect is omitted rather than emitted as damage. Use the actual imported
timing/control/immunity data for live interrupt proof; do not carry Lady
Anacondra's IDs or casts into a Stockade test.
For a focused ordinary-campaign Vow check, use
`node scripts/playtest-campaign-boss-pass.cjs --campaign-boss-route --route=stormwind-stockade --stockade-vow-diagnostic`.
This bounded diagnostic is separate from full-clear proof. Tab selection does
not face the target: player combat facing follows `cameraYaw`. Use ordinary
right-mouse drag, verify the actual facing arc and keep pointer lock null.
Pace large turns across frames to respect the input manager's delta limits.
Hit-test a clear canvas path; boss notifications can cover a fixed starting
point even when the game canvas fills the viewport.
Credit cancellation only from the same watched live cast, a completed player
Vow cast, its owned target aura and disappearance before scheduled release
without matching Chain Lightning damage while the target remains alive.
Stop gameplay inputs and retain read-only samples through scheduled release
plus 100 ms to check that interval directly.
## Earlier fixtures and scope
Keep Wailing's existing import/source regressions and dated reports as historical
evidence. They do not select the dungeon for future routine live QA. Dedicated
legacy Wailing/Lady harnesses are historical reproductions, and a new live run
requires the owner's explicit request for that dungeon. Do not relabel their
old screenshots or results as Stockade evidence.
Incident analysis may read Barren or another dungeon's captured evidence and
source. Generic follow-up validation uses Stockade; encounter-specific live
reproduction follows the user's explicitly requested dungeon. This policy
replaces the previously proposed additional Wailing clear with a Stockade
full-route/full-clear attempt.
Ready-five balance scope, RoM Needs Testing status, saved-profile compatibility,
level cap 80 and the no-new-loot-wiring constraint continue to apply. The test
fixture choice does not change the game's bundled content or playable dungeons.
+7 -2
View File
@@ -4,6 +4,11 @@
This is a first-pass design audit of the 51 standard WoW dungeons already imported by Healer Man (Classic through Wrath of the Lich King). The map atlas and campaign catalog are the project source of truth for the layouts and encounter names. Three additional imported definitions are Healer Man / Ascension custom dungeons, so they are not part of this WoW pass.
The owner's current routine test default is the full **Stormwind Stockade**
(`stormwind-stockade`); see [the test policy](TEST_DUNGEON_POLICY.md). The
Wailing-specific slice questions below remain deferred until the owner explicitly
requests that dungeon. They do not authorize a new routine Wailing playtest.
A good slice should feel like a complete short outing: a clear reason to enter, one strong visual space or route, a handful of combat beats, and one satisfying final encounter. The closest existing model is **Stormwind Stockade: Cellblock Rush**: keep the original environment, retain one route, wall off the side wings, and finish on one seeded boss. Some source dungeons have a scripted event that makes an even better slice, but that event also raises implementation cost.
Ratings:
@@ -16,7 +21,7 @@ The three first-pass candidates below are registered as derived Bite-Sized dunge
| Prototype | Implemented slice | Open playtest question |
| --- | --- | --- |
| Wailing Caverns — Verdan’s Lair | Starts inside Verdan’s chamber at a navigation-surface point. Three source cave mobs spawn in the room; defeating all three activates Verdan. Naralex and Mutanus remain only in the full dungeon. | Confirm the room population, Verdan activation, party targeting, and completion in play. |
| Wailing Caverns — Verdan’s Lair | Starts inside Verdan’s chamber at a navigation-surface point. Three source cave mobs spawn in the room; defeating all three activates Verdan. Naralex and Mutanus remain only in the full dungeon. | Deferred until an explicit Wailing request: confirm room population, Verdan activation, party targeting, and completion in play. |
| The Deadmines — Take the Ship | Starts at Mr. Smite’s source floor position, clears the ship population, gates VanCleef behind Captain Greenskin, then ends in the cabin. | Confirm the new start is grounded and the ship route remains walkable. |
| Utgarde Pinnacle — Skadi’s Gauntlet | Starts on the grounded source wave corridor. Defeating three first-wave guards along that corridor activates Skadi at its far end. The long waves and harpoons are omitted from this short run. | Confirm Skadi appears after the guard clear and the fight completes without an interaction. |
@@ -26,7 +31,7 @@ Each derived ID resolves to its source dungeon’s content pack and map asset di
| Rank | Dungeon slice | Why it fits | Main implementation question |
| --- | --- | --- | --- |
| 1 | **Wailing Caverns — Verdan’s Lair:** clear the chamber mobs, then fight Verdan the Everliving. | One navigable cave room and a clear boss reveal keep the run short. The Naralex escort remains a future, more complex candidate. | Verify the room floor, combat spacing, and boss activation in play. [Dungeon overview](https://warcraft.wiki.gg/wiki/Wailing_Caverns) |
| 1 | **Wailing Caverns — Verdan’s Lair:** clear the chamber mobs, then fight Verdan the Everliving. | One navigable cave room and a clear boss reveal keep the run short. The Naralex escort remains a future, more complex candidate. | Deferred until an explicit Wailing request: verify room floor, combat spacing, and boss activation in play. [Dungeon overview](https://warcraft.wiki.gg/wiki/Wailing_Caverns) |
| 2 | **The Deadmines — Take the Ship:** enter at Mr. Smite’s deck position, clear the ship, fight Captain Greenskin, then finish Edwin VanCleef in the cabin. | One strong visual set piece, a straightforward forward route, and a natural boss finale. The optional Cookie detour can be left out. [Classic guide](https://warcraft.wiki.gg/wiki/Deadmines_%28Classic%29) |
| 3 | **Zul’Farrak — The Staircase:** start at the pyramid event, survive Sergeant Bly’s descending waves, and finish at Chief Ukorz’s chamber. | The stair defense is already a compact wave encounter with a clear spatial progression; it could be a bite-sized run by itself or a short prelude into a boss. | Choose whether Bly and his group are protected participants or replace the event with a controlled wave sequence; the event’s outcome opens the Ukorz route. [Instance map and event guide](https://ptgmedia.pearsoncmg.com/imprint_downloads/brady/wow/zulfarrak/zulfarrak_lr.pdf) |
| 4 | **The Blood Furnace — Broggok’s Breakout:** begin at the prison cells, release the waves, then push through the final corridor to Keli’dan. | A tight industrial route with a built-in escalation and a boss payoff. The map is compact and the three encounters already form a clean progression. [Dungeon overview](https://warcraft.wiki.gg/wiki/Blood_Furnace) |
+6 -1
View File
@@ -34,7 +34,12 @@ TypeScript/production bundle checks passed.
## Baseline
- [x] Wailing Caverns (`43`) - native golden fixture; keep as the reference.
These are import baselines, not routine live-test selections. As of 2026-10-04,
new routine gameplay/mechanics QA uses Stormwind Stockade (`stormwind-stockade`)
under `../AGENTS.md` and `../docs/TEST_DUNGEON_POLICY.md`.
- [x] Wailing Caverns (`43`) - preserve the historical native import reference;
use Stormwind Stockade for new routine live QA.
## Classic
+4 -2
View File
@@ -1,8 +1,10 @@
# Dungeon pipeline
This directory turns the original Wailing Caverns one-off into a reviewed,
versioned process. Wailing Caverns is the global-WMO golden fixture and
Deadmines is the first ADT/hybrid recipe.
versioned process. Wailing Caverns retains the historical global-WMO import
fixture and Deadmines is the first ADT/hybrid recipe. New routine live QA uses
**Stormwind Stockade** (`stormwind-stockade`), as required by `../AGENTS.md` and
`../docs/TEST_DUNGEON_POLICY.md`. Import baselines do not override that test choice.
## Inputs
+102 -36
View File
@@ -1,79 +1,145 @@
import { NodeIO } from "@gltf-transform/core";
import { BufferGeometry, Float32BufferAttribute, Matrix4, Vector3 } from "three";
import { requireDungeonDefinition } from "../src/game/dungeonRegistry";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { createRequire } from "node:module";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { MeshoptDecoder } from "meshoptimizer";
import { BufferAttribute, BufferGeometry, Group, Mesh } from "three";
import {
buildPartyNavigationGraph,
findNearestPartyNavigationSurface,
findPartyNavigationPath,
} from "../src/game/partyNavigation";
import { preparePartyNavigationGraph } from "../src/game/partyNavigationAssetCache";
import type { DungeonDefinition } from "../src/game/dungeonTypes";
import { withWowDungeonEnvironment } from "../src/game/wowDungeonEnvironment";
const dungeonIds = process.argv.slice(2).filter((argument) => !argument.startsWith("--"));
if (!dungeonIds.length) throw new Error("Pass at least one dungeon id.");
const require = createRequire(import.meta.url);
const { DEFAULT_TEST_DUNGEON_ID } = require("./playtest-defaults.cjs") as {
DEFAULT_TEST_DUNGEON_ID: string;
};
type Position = readonly [number, number, number];
type ProbeDungeon = {
id: string;
title: string;
entrance: { footPosition: Position };
assets: { navigation?: { fileName: string; url: string } | null };
navigationLinks?: readonly { start: Position; end: Position; bidirectional: boolean }[];
traversalRamps?: DungeonDefinition["traversalRamps"];
bosses: readonly { id: string; name: string; position: Position; eventOnly?: boolean }[];
};
const campaignCatalogPath = resolve(process.cwd(), "src", "game", "generated", "dungeonCampaignCatalog.json");
const campaignCatalog = JSON.parse(readFileSync(campaignCatalogPath, "utf8")) as { definitions: ProbeDungeon[] };
function requireGeneratedDungeonDefinition(dungeonId: string): ProbeDungeon {
const dungeon = campaignCatalog.definitions.find((candidate) => candidate.id === dungeonId);
if (!dungeon) throw new Error(`Generated campaign catalog has no dungeon ${dungeonId}.`);
return dungeon;
}
const navigationIo = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const requestedDungeonIds = process.argv.slice(2).filter((argument) => !argument.startsWith("--"));
const dungeonIds = requestedDungeonIds.length ? requestedDungeonIds : [DEFAULT_TEST_DUNGEON_ID];
async function navigationGraph(dungeonId: string) {
const dungeon = requireDungeonDefinition(dungeonId);
const dungeon = requireGeneratedDungeonDefinition(dungeonId);
const navigation = dungeon.assets.navigation;
if (!navigation) throw new Error(`${dungeonId} has no navigation asset.`);
const file = `src/assets/game/dungeons/${dungeonId}/${navigation.fileName}`;
const document = await new NodeIO().read(file);
const positions: number[] = [];
const vertex = new Vector3();
const publicAssetPath = navigation.url.startsWith("/")
? resolve(process.cwd(), "public", navigation.url.slice(1))
: resolve(process.cwd(), "public", "assets", "game", "dungeons", dungeonId, navigation.fileName);
const sourceAssetPath = resolve(process.cwd(), "src", "assets", "game", "dungeons", dungeonId, navigation.fileName);
const file = [publicAssetPath, sourceAssetPath].find(existsSync);
if (!file) throw new Error(`${dungeonId} navigation asset is missing at ${publicAssetPath} and ${sourceAssetPath}.`);
await MeshoptDecoder.ready;
const document = await navigationIo.read(file);
const scene = new Group();
for (const node of document.getRoot().listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
const world = new Matrix4().fromArray(node.getWorldMatrix());
for (const primitive of mesh.listPrimitives()) {
const source = primitive.getAttribute("POSITION")?.getArray();
if (!source) continue;
const indices = primitive.getIndices()?.getArray()
?? Array.from({ length: source.length / 3 }, (_, index) => index);
for (const index of indices) {
vertex.fromArray(source, Number(index) * 3).applyMatrix4(world);
positions.push(vertex.x, vertex.y, vertex.z);
}
}
}
const accessor = primitive.getAttribute("POSITION");
const source = accessor?.getArray();
if (!source || !accessor) continue;
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
geometry.setAttribute("position", new BufferAttribute(source, 3, accessor.getNormalized()));
const sourceIndices = primitive.getIndices()?.getArray();
if (sourceIndices) geometry.setIndex(Array.from(sourceIndices));
const navigationMesh = new Mesh(geometry);
navigationMesh.matrixAutoUpdate = false;
navigationMesh.matrix.fromArray(node.getWorldMatrix());
scene.add(navigationMesh);
}
}
const definition = withWowDungeonEnvironment(dungeon as unknown as DungeonDefinition);
try {
return buildPartyNavigationGraph(geometry);
return preparePartyNavigationGraph(navigation.url, scene, definition.navigationLinks ?? [], definition.traversalRamps ?? []);
} finally {
geometry.dispose();
scene.traverse((node) => {
if ((node as Mesh).isMesh) (node as Mesh).geometry.dispose();
});
}
}
for (const dungeonId of dungeonIds) {
const dungeon = requireDungeonDefinition(dungeonId);
const boss = dungeon.bosses.find((candidate) => !candidate.eventOnly);
if (!boss) throw new Error(`${dungeonId} has no normal campaign boss.`);
const dungeon = requireGeneratedDungeonDefinition(dungeonId);
const eligibleBosses = dungeon.bosses.filter((candidate) => !candidate.eventOnly);
const bosses = dungeonId === DEFAULT_TEST_DUNGEON_ID ? eligibleBosses : eligibleBosses.slice(0, 1);
if (!bosses.length) throw new Error(`${dungeonId} has no normal campaign bosses.`);
const graph = await navigationGraph(dungeonId);
const start = findNearestPartyNavigationSurface(graph, dungeon.entrance.footPosition, 3);
if (!start) throw new Error(`${dungeonId} entrance does not snap to navigation.`);
const waypoints: number[][] = [[...start.position]];
const objectives: Array<{ id: string; name: string; position: readonly number[]; navigationPosition: readonly number[];
waypointIndex: number; expectedNavigationDistance: number; snapDistance: number }> = [];
let segmentStart = start.position;
let expectedDistance = 0;
for (const boss of bosses) {
const end = findNearestPartyNavigationSurface(graph, boss.position, 6);
if (!start || !end) throw new Error(`${dungeonId} entrance or ${boss.name} does not snap to navigation.`);
const path = findPartyNavigationPath(graph, start.position, end.position, {
if (!end) throw new Error(`${dungeonId} ${boss.name} does not snap to navigation.`);
const path = findPartyNavigationPath(graph, segmentStart, end.position, {
maxStartSnapDistance: 3,
maxEndSnapDistance: 6,
simplifyTolerance: 0.01,
});
if (!path?.length) throw new Error(`${dungeonId} has no navigation path to ${boss.name}.`);
let expectedDistance = 0;
const startIndex = waypoints.length - 1;
let segmentDistance = 0;
for (let index = 1; index < path.length; index += 1) {
expectedDistance += Math.hypot(
const segmentLength = Math.hypot(
path[index][0] - path[index - 1][0],
path[index][1] - path[index - 1][1],
path[index][2] - path[index - 1][2],
);
segmentDistance += segmentLength;
expectedDistance += segmentLength;
waypoints.push([...path[index]]);
}
objectives.push({
id: boss.id,
name: boss.name,
position: boss.position,
navigationPosition: end.position,
waypointIndex: waypoints.length - 1,
expectedNavigationDistance: Number(segmentDistance.toFixed(2)),
snapDistance: end.distance,
});
if (waypoints.length - 1 <= startIndex) {
throw new Error(`${dungeonId} navigation path to ${boss.name} has no traversable segment.`);
}
segmentStart = end.position;
}
process.stdout.write(`${JSON.stringify({
dungeonId,
status: "navigation-guidance-only; live UI traversal required",
objective: boss.name,
objective: bosses.at(-1)!.name,
objectiveId: bosses.at(-1)!.id,
objectives,
entrance: start.position,
objectivePosition: end.position,
objectivePosition: bosses.at(-1)!.position,
startSnapDistance: start.distance,
objectiveSnapDistance: end.distance,
waypointCount: path.length,
objectiveSnapDistance: objectives.at(-1)!.snapDistance,
waypointCount: waypoints.length,
expectedNavigationDistance: Number(expectedDistance.toFixed(2)),
waypoints: path,
waypoints,
})}\n`);
}
@@ -973,7 +973,7 @@ async function spellSupportTables(spellFile, fallbackRoot) {
};
}
function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map()) {
function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map(), sourceProfile) {
if (!buffer || buffer.toString("ascii", 0, 4) !== "WDBC") return new Map();
const recordCount = buffer.readUInt32LE(4);
const fieldCount = buffer.readUInt32LE(8);
@@ -1002,11 +1002,13 @@ function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map()
}
spells.set(id, {
id,
sourceProfile,
name: name.replace(/\|c[0-9a-f]{8}|\|r/gi, "").trim() || `Spell ${id}`,
recoveryTimeMs: unsigned(29),
school: schoolFor(unsigned(225)),
schoolMask: unsigned(225),
damageClass: unsigned(213),
rangeIndex: unsigned(46),
durationMs: durations.get(unsigned(40)) ?? 0,
effects: Array.from({ length: 3 }, (_, effectIndex) => ({
effectType: unsigned(71 + effectIndex),
@@ -1017,6 +1019,7 @@ function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map()
implicitTargetB: unsigned(shifted(89 + effectIndex)),
radius: radii.get(unsigned(shifted(92 + effectIndex))) ?? 0,
miscValue: signed(shifted(110 + effectIndex)),
periodMs: unsigned(98 + effectIndex),
})).filter((effect) => effect.effectType || effect.auraType),
});
}
@@ -1037,6 +1040,191 @@ const CONTROL_AURA_MECHANICS = Object.freeze(new Map([
[193, "slow"],
]));
const DIRECT_DAMAGE_EFFECT_TYPES = Object.freeze([2, 31, 58, 121]);
const pinnedEffect = (effectType, auraType, basePoints, targetA, targetB, options = {}) => ({
effectType,
auraType,
basePoints,
mechanic: options.mechanic ?? 0,
implicitTargetA: targetA,
implicitTargetB: targetB,
radius: options.radius ?? 0,
miscValue: options.miscValue ?? 0,
periodMs: options.periodMs ?? 0,
});
const pinnedSourceSpell = (rangeIndex, schoolMask, damageClass, durationMs, effects, contract = {}) => {
const { epochSignature, ...attackContract } = contract;
const signature = { rangeIndex, schoolMask, damageClass, durationMs, effects };
return {
signatures: {
ascension: signature,
epoch: epochSignature ?? signature,
},
...attackContract,
};
};
const PINNED_SOURCE_SPELLS = Object.freeze(new Map([
[7164, pinnedSourceSpell(1, 1, 0, 180_000, [pinnedEffect(6, 36, 0, 1, 0, { miscValue: 18 })], { unsupportedUtility: true })],
[7165, pinnedSourceSpell(1, 1, 2, -1, [pinnedEffect(6, 36, 0, 1, 0, { miscValue: 17 })], { unsupportedUtility: true })],
[16244, pinnedSourceSpell(1, 1, 1, 15_000, [pinnedEffect(6, 79, -10, 22, 15, { radius: 10, miscValue: 1 })], {
unsupportedUtility: true,
epochSignature: {
rangeIndex: 1, schoolMask: 1, damageClass: 1, durationMs: 30_000,
effects: [pinnedEffect(6, 99, -20, 22, 15, { radius: 10 })],
},
})],
[13730, pinnedSourceSpell(1, 1, 1, 30_000, [pinnedEffect(6, 166, -15, 22, 15, { radius: 10 })], {
unsupportedUtility: true,
epochSignature: {
rangeIndex: 1, schoolMask: 1, damageClass: 1, durationMs: 30_000,
effects: [pinnedEffect(6, 99, -10, 22, 15, { radius: 10 })],
},
})],
[3419, pinnedSourceSpell(1, 1, 0, 6_000, [pinnedEffect(6, 51, 75, 1, 0)], { unsupportedUtility: true })],
[9128, pinnedSourceSpell(1, 1, 1, 120_000, [pinnedEffect(6, 79, 15, 20, 0, { radius: 20, miscValue: 1 })], {
unsupportedUtility: true,
epochSignature: {
rangeIndex: 1, schoolMask: 1, damageClass: 1, durationMs: 120_000,
effects: [pinnedEffect(6, 99, 4, 20, 0, { radius: 20 })],
},
})],
[674, pinnedSourceSpell(1, 1, 1, 0, [pinnedEffect(40, 0, 1, 1, 0)], { unsupportedUtility: true })],
[29651, pinnedSourceSpell(1, 1, 0, 0, [pinnedEffect(40, 0, 1, 1, 0)], { unsupportedUtility: true })],
[2457, pinnedSourceSpell(1, 1, 0, -1, [
pinnedEffect(6, 36, 0, 1, 0, { miscValue: 17 }),
pinnedEffect(6, 280, 10, 1, 0),
pinnedEffect(6, 286, 100, 1, 0),
], {
unsupportedUtility: true,
epochSignature: {
rangeIndex: 1, schoolMask: 1, damageClass: 2, durationMs: -1,
effects: [
pinnedEffect(6, 36, 0, 1, 0, { miscValue: 17 }),
pinnedEffect(6, 280, 10, 1, 0),
],
},
})],
[3391, pinnedSourceSpell(1, 1, 2, 0, [pinnedEffect(19, 0, 2, 1, 0)], { unsupportedUtility: true })],
[6742, pinnedSourceSpell(4, 8, 1, 30_000, [pinnedEffect(6, 138, 30, 21, 0)], { unsupportedUtility: true })],
[6713, pinnedSourceSpell(2, 1, 2, 5_000, [
pinnedEffect(6, 67, 0, 6, 0),
pinnedEffect(6, 278, 1, 6, 0),
], {
unsupportedUtility: true,
epochSignature: {
rangeIndex: 2, schoolMask: 1, damageClass: 2, durationMs: 5_000,
effects: [pinnedEffect(6, 67, 0, 6, 0)],
},
})],
[17230, pinnedSourceSpell(2, 8, 0, 300_000, [pinnedEffect(6, 14, 6, 6, 0, { miscValue: 1 })], { unsupportedUtility: true })],
[6253, pinnedSourceSpell(2, 1, 2, 2_000, [pinnedEffect(6, 12, 1, 6, 0)], {
target: "primary",
delivery: "melee",
animation: "attack",
range: 5,
})],
[8242, pinnedSourceSpell(2, 1, 2, 2_000, [
pinnedEffect(6, 12, 1, 6, 0),
pinnedEffect(2, 0, 53, 6, 0),
], {
target: "primary",
delivery: "melee",
animation: "attack",
range: 5,
epochSignature: {
rangeIndex: 2, schoolMask: 1, damageClass: 2, durationMs: 2_000,
effects: [
pinnedEffect(6, 12, 1, 6, 0),
pinnedEffect(2, 0, 20, 6, 0),
],
},
})],
[11976, pinnedSourceSpell(2, 1, 2, 0, [pinnedEffect(58, 0, 5, 6, 0)], {
target: "primary",
delivery: "melee",
animation: "attack",
range: 5,
})],
[15657, pinnedSourceSpell(2, 1, 2, 0, [pinnedEffect(2, 0, 100, 6, 0)], {
target: "primary",
delivery: "melee",
animation: "attack",
range: 5,
epochSignature: {
rangeIndex: 2, schoolMask: 1, damageClass: 2, durationMs: 0,
effects: [pinnedEffect(58, 0, 20, 6, 0)],
},
})],
[11978, pinnedSourceSpell(2, 1, 2, 4_000, [pinnedEffect(68, 0, 1, 6, 0, { mechanic: 26 })], {
target: "primary",
delivery: "melee",
animation: "attack",
range: 5,
interruptControl: true,
preserveSourceDamage: true,
epochSignature: {
rangeIndex: 2, schoolMask: 1, damageClass: 2, durationMs: 6_000,
effects: [
pinnedEffect(2, 0, 2, 6, 0),
pinnedEffect(68, 0, 1, 6, 0, { mechanic: 26 }),
],
},
})],
[421, pinnedSourceSpell(4, 8, 1, 0, [pinnedEffect(2, 0, 287, 6, 0)], {
range: 30,
epochSignature: {
rangeIndex: 4, schoolMask: 8, damageClass: 1, durationMs: 0,
effects: [pinnedEffect(2, 0, 191, 6, 0)],
},
})],
[5246, pinnedSourceSpell(137, 1, 1, 5_000, [
pinnedEffect(64, 0, 1, 6, 0),
pinnedEffect(6, 7, 1, 22, 15, { radius: 8 }),
pinnedEffect(6, 31, 25, 22, 15, { radius: 8 }),
], {
target: "nearby-party",
delivery: "area",
range: 8,
radius: 8,
epochSignature: {
rangeIndex: 137, schoolMask: 1, damageClass: 1, durationMs: 8_000,
effects: [
pinnedEffect(64, 0, 1, 6, 0),
pinnedEffect(6, 7, 1, 22, 15, { radius: 8 }),
pinnedEffect(6, 31, 25, 22, 15, { radius: 8 }),
],
},
})],
[19134, pinnedSourceSpell(7, 1, 1, 4_000, [
pinnedEffect(64, 0, 1, 6, 0),
pinnedEffect(6, 7, 1, 22, 15, { radius: 8 }),
pinnedEffect(6, 31, 25, 22, 15, { radius: 8 }),
], {
target: "nearby-party",
delivery: "area",
range: 10,
radius: 8,
epochSignature: {
rangeIndex: 7, schoolMask: 1, damageClass: 1, durationMs: 6_000,
effects: [
pinnedEffect(64, 0, 1, 6, 0),
pinnedEffect(6, 7, 1, 22, 15, { radius: 8 }),
pinnedEffect(6, 31, 25, 22, 15, { radius: 8 }),
],
},
})],
[7964, pinnedSourceSpell(1, 1, 1, 4_000, [
pinnedEffect(6, 12, 1, 22, 15, { mechanic: 12, radius: 5 }),
pinnedEffect(3, 0, 1, 18, 0),
], {
target: "nearby-party",
delivery: "area",
range: 5,
radius: 5,
})],
]));
const AUXILIARY_NON_BOSS_CASTS = Object.freeze(new Set([
"26687:47669", // The stasis orb awakens Gortok; Gortok does not cast this spell.
"29307:54888", // The Colossus does not own the elemental's spawn/reset visual.
@@ -1049,7 +1237,7 @@ function spellSemantic(spell, boss, area) {
? 0
: spell.durationMs || (boss ? 8_000 : 5_000);
const damageMultiplier = area ? (boss ? 0.82 : 0.62) : boss ? 1.35 : 1.12;
const hasDamage = spell.effects.some((effect) => [2, 31, 58, 121].includes(effect.effectType));
const hasDamage = spell.effects.some((effect) => DIRECT_DAMAGE_EFFECT_TYPES.includes(effect.effectType));
const controlEffect = spell.effects.find((effect) => CONTROL_AURA_MECHANICS.has(effect.auraType));
const controlMechanic = /\b(?:sleep|slumber)\b/i.test(spell.name)
? "sleep"
@@ -1164,6 +1352,50 @@ function spellSemantic(spell, boss, area) {
return undefined;
}
function pinnedSourceSpellFor(spell) {
const contract = PINNED_SOURCE_SPELLS.get(spell.id);
if (!contract) return undefined;
const signature = contract.signatures[spell.sourceProfile];
if (!signature) {
throw new Error(
`Pinned Spell.dbc contract for spell ${spell.id} has no ${spell.sourceProfile} source profile.`,
);
}
const scalarFieldsMatch = ["rangeIndex", "schoolMask", "damageClass", "durationMs"]
.every((field) => spell[field] === signature[field]);
const effectsMatch = spell.effects.length === signature.effects.length
&& signature.effects.every((expected, index) => {
const actual = spell.effects[index];
return [
"effectType",
"auraType",
"basePoints",
"mechanic",
"implicitTargetA",
"implicitTargetB",
"radius",
"miscValue",
"periodMs",
].every((field) => actual[field] === expected[field]);
});
if (!scalarFieldsMatch || !effectsMatch) {
const actualSignature = {
rangeIndex: spell.rangeIndex,
schoolMask: spell.schoolMask,
damageClass: spell.damageClass,
durationMs: spell.durationMs,
effects: spell.effects,
};
throw new Error(
`Pinned ${spell.sourceProfile} Spell.dbc contract drift for spell ${spell.id}; `
+ `review its source effects before changing the import. `
+ `Expected ${JSON.stringify(signature)}; found ${JSON.stringify(actualSignature)}.`,
);
}
return contract;
}
function basicAttack(cooldownMs = 1_900) {
return {
id: "basic-attack",
@@ -1179,8 +1411,10 @@ function basicAttack(cooldownMs = 1_900) {
}
function spellAttack(spell, hash, boss) {
const pinnedSource = pinnedSourceSpellFor(spell);
const pinnedHostileMelee = pinnedSource?.delivery === "melee";
const area = /nova|roar|stomp|whirl|rain|volley|blizzard|explosion|quake|storm|breath|cleave|fear|horror|terror/i
.test(spell.name) || spell.id === 53030;
.test(spell.name) || spell.id === 53030 || pinnedSource?.delivery === "area";
const melee = spell.school === "physical"
&& /strike|slash|cleave|bash|smash|rend|bite|claw|pummel/i.test(spell.name);
const healing = /\b(?:heal|healing|mend|renew|rejuvenat\w*|restor\w*|holy light|lay on hands)\b/i
@@ -1202,12 +1436,26 @@ function spellAttack(spell, hash, boss) {
: null;
const aura = /\b(?:enrage|frenzy|shield|reflection|reflect|defensive aura|battle aura|berserker aura|haste|hasten)\b/i
.test(spell.name);
const semanticEffect = spellSemantic(spell, boss, area);
const hasDamage = spell.effects.some((entry) => DIRECT_DAMAGE_EFFECT_TYPES.includes(entry.effectType));
const defaultDamageMultiplier = area ? (boss ? 0.82 : 0.62) : boss ? 1.35 : 1.12;
const semanticEffect = pinnedSource?.interruptControl
? {
kind: "control",
mechanic: "interrupt",
durationMs: spell.durationMs,
...(pinnedSource.preserveSourceDamage && hasDamage
? { damageMultiplier: defaultDamageMultiplier }
: {}),
}
: pinnedSource?.effect ?? spellSemantic(spell, boss, area);
if (pinnedSource?.unsupportedUtility && !semanticEffect && !hasDamage) return null;
const semanticEffects = semanticEffect?.kind === "composite"
? semanticEffect.effects
: semanticEffect ? [semanticEffect] : [];
const semanticHas = (kind) => semanticEffects.some((entry) => entry.kind === kind);
const target = healing
const target = pinnedSource?.target
? pinnedSource.target
: healing
? "lowest-health-friendly"
: semanticHas("aura") || aura
? "self"
@@ -1245,7 +1493,7 @@ function spellAttack(spell, hash, boss) {
id: `spell-${spell.id}`,
name: spell.name,
spellId: spell.id,
delivery: target === "self" ? "area" : area ? "area" : melee ? "melee" : "projectile",
delivery: pinnedSource?.delivery ?? (target === "self" ? "area" : area ? "area" : melee ? "melee" : "projectile"),
target,
school: spell.school,
schoolMask: spell.schoolMask,
@@ -1254,11 +1502,11 @@ function spellAttack(spell, hash, boss) {
...(spell.damageClass === 1 ? { attackKind: "spell" }
: spell.damageClass === 2 ? { attackKind: "melee" }
: spell.damageClass === 3 ? { attackKind: "ranged" } : {}),
animation: melee ? "attack" : "cast",
range: target === "self" ? 0 : melee ? 5.5 : area ? 22 : 28,
animation: pinnedSource?.animation ?? (pinnedHostileMelee || melee ? "attack" : "cast"),
range: pinnedSource?.range ?? (target === "self" ? 0 : melee ? 5.5 : area ? 22 : 28),
cooldownMs: Math.max(2_500, Math.min(12_000, spell.recoveryTimeMs || 4_000 + hash % 3_500)),
damageMultiplier: effect ? 0 : area ? (boss ? 0.82 : 0.62) : boss ? 1.35 : 1.12,
...(area ? { radius: dbcRadius || (boss ? 11 : 8) } : {}),
...(area ? { radius: pinnedSource?.radius ?? (dbcRadius || (boss ? 11 : 8)) } : {}),
...(effect ? { effect } : {}),
...(healing
? {
@@ -1405,15 +1653,20 @@ function smartAiMechanicsFor(entity, boss, spells, worldAi) {
if (!spell) continue;
const hash = stableHash(`${entry}:${spellId}:${action.id}`);
const attack = spellAttack(spell, hash, true);
const target = [5, 6].includes(Number(action.target_type))
? "random-party"
: attack.target;
if (!attack) continue;
const targetType = Number(action.target_type);
const pinnedSource = PINNED_SOURCE_SPELLS.get(spellId);
const sourceTarget = pinnedSource?.target;
const target = sourceTarget
?? ([5, 6].includes(targetType) ? "random-party" : attack.target);
mechanics.push({
...attack,
...event,
id: `smart-${entry}-${action.id}-${slugPart(spell.name)}`,
target,
range: target === "self" ? attack.range : Math.max(attack.range, 28),
range: pinnedSource?.range !== undefined || target === "self"
? attack.range
: Math.max(attack.range, 28),
source,
});
} else if (actionType === 12) {
@@ -1526,7 +1779,8 @@ function combatFor(
: null;
})
.filter(Boolean)
.map((spell) => spellAttack(spell, hash, boss));
.map((spell) => spellAttack(spell, hash, boss))
.filter(Boolean);
const importedMechanics = smartAiMechanicsFor(entity, boss, spells, worldAi);
const authored = boss ? bossMechanics?.bosses?.[String(entry)] : undefined;
let mechanics = authored?.mode === "replace"
@@ -1814,6 +2068,7 @@ async function compile() {
wantedSpellIds,
ascensionSpellSupport.durations,
ascensionSpellSupport.radii,
"ascension",
)
: new Map();
const epochSpellSupport = await spellSupportTables(epochSpellDbc, defaultSpellSupportRoot);
@@ -1823,6 +2078,7 @@ async function compile() {
wantedSpellIds,
epochSpellSupport.durations,
epochSpellSupport.radii,
"epoch",
)
: ascensionSpells;
const standardGameObjectDisplays = await exists(standardGameObjectDisplayDbc)
+1 -1
View File
@@ -6,7 +6,7 @@ const path = require('node:path');
const out = path.resolve(process.env.BITE_SIZED_PLAYTEST_DIR || 'playtest-artifacts/bite-sized-spawns');
fs.mkdirSync(out, { recursive: true });
const defaultLocations = [
{ id: 'bite-sized-wailing-caverns', name: 'wailing' },
{ id: 'bite-sized-stormwind-stockade', name: 'stockade' },
{ id: 'bite-sized-utgarde-pinnacle-skadi', name: 'utgarde' },
];
const locations = process.env.BITE_SIZED_PLAYTEST_IDS
+9 -1
View File
@@ -1,9 +1,17 @@
const { DEFAULT_TEST_DUNGEON_BOSS } = require('./playtest-defaults.cjs');
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const selectedBossName = process.env.BOSS_TEST_BOSS || 'Lady Anacondra';
const selectedBossName = process.env.BOSS_TEST_BOSS || DEFAULT_TEST_DUNGEON_BOSS;
const historicalWailingBosses = [
'Lady Anacondra', 'Lord Cobrahn', 'Kresh', 'Lord Pythas', 'Skum',
'Lord Serpentis', 'Verdan the Everliving', 'Mutanus the Devourer',
];
if (historicalWailingBosses.includes(selectedBossName) && !process.argv.includes('--historical-wailing')) {
throw new Error('A Wailing boss override requires an explicit owner request and --historical-wailing. Routine Boss Testing defaults to Hamhock.');
}
const directory = path.resolve(process.env.BOSS_TEST_ARTIFACT_DIR || path.join(
'playtest-artifacts',
selectedBossName === 'Celebras the Cursed' ? 'boss-testing-scaling' : 'boss-testing',
File diff suppressed because it is too large. Load diff
+4 -3
View File
@@ -1,3 +1,4 @@
const { DEFAULT_TEST_DUNGEON_TITLE, selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
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');
@@ -72,11 +73,11 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-readiness-
assert.equal(await beta.isDisabled(), false);
assert.equal(await beta.isChecked(), false);
assert.match(await page.locator('.companion-pool-controls').innerText(), /1 beta spec available/);
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 selectDefaultTestDungeon(page);
await page.locator('.dungeon-select-copy h2').getByText(DEFAULT_TEST_DUNGEON_TITLE, { exact: true }).waitFor();
await capture('03-dungeon-ready-pool');
await page.getByRole('button', { name: /Choose role/ }).click();
await openSelectedDungeonRoles(page);
const roleButton = page.locator('.dungeon-role-option').first();
await roleButton.click();
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
+31
View File
@@ -0,0 +1,31 @@
// Routine test fixture selected by the project owner; see AGENTS.md.
const DEFAULT_TEST_DUNGEON_ID = 'stormwind-stockade';
const DEFAULT_TEST_DUNGEON_TITLE = 'Stormwind Stockade';
const DEFAULT_TEST_DUNGEON_BOSS = 'Hamhock';
async function selectDefaultTestDungeon(page) {
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
await page.locator('.dungeon-select-row').filter({
has: page.getByText(DEFAULT_TEST_DUNGEON_TITLE, { exact: true }),
}).click();
}
async function openSelectedDungeonRoles(page) {
const download = page.getByRole('button', { name: 'Download and continue', exact: true });
const role = page.locator('.dungeon-role-option').first();
// Selecting an uninstalled pack may open its confirmation immediately.
// Installed selections expose Choose Role without that modal.
if (await download.isVisible()) await download.click();
else if (!await role.isVisible()) await page.getByRole('button', { name: /choose role/i }).click();
await role.or(download).first().waitFor({ timeout: 180_000 });
if (await download.isVisible()) await download.click();
await role.waitFor({ timeout: 180_000 });
}
module.exports = Object.freeze({
DEFAULT_TEST_DUNGEON_ID,
DEFAULT_TEST_DUNGEON_TITLE,
DEFAULT_TEST_DUNGEON_BOSS,
selectDefaultTestDungeon,
openSelectedDungeonRoles,
});
+3 -2
View File
@@ -1,3 +1,4 @@
const { DEFAULT_TEST_DUNGEON_ID } = require('./playtest-defaults.cjs');
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
@@ -75,7 +76,7 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
await capture('01-gm-route-launch');
const routeSelects = page.locator('.dungeon-sim-admin__catalog select');
await routeSelects.nth(0).selectOption('wailing-caverns');
await routeSelects.nth(0).selectOption(DEFAULT_TEST_DUNGEON_ID);
await routeSelects.nth(1).selectOption('navigation-only');
await page.locator('.dungeon-sim-admin__catalog input').fill('browser-smoke-fixed-seed');
await page.getByRole('button', { name: 'Launch spectate mode' }).click();
@@ -160,7 +161,7 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
await capture('06-finished-report');
await page.getByRole('button', { name: 'Return to GM Tools' }).click();
await page.getByRole('heading', { name: 'Dungeon route simulations' }).waitFor();
await page.locator('.dungeon-sim-admin__catalog select').nth(0).selectOption('wailing-caverns');
await page.locator('.dungeon-sim-admin__catalog select').nth(0).selectOption(DEFAULT_TEST_DUNGEON_ID);
await page.getByText(/1 runs for this route/).waitFor();
await capture('07-saved-replay');
+3 -4
View File
@@ -1,3 +1,4 @@
const { DEFAULT_TEST_DUNGEON_TITLE, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
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');
@@ -14,9 +15,7 @@ async function enterDungeon(page, category, name) {
await page.getByRole('button', { name: /^Dungeons/ }).click();
if (category) await page.locator('.dungeon-category-tab').filter({ hasText: category }).click();
await page.locator('.dungeon-select-row').filter({ hasText: name }).first().click();
const download = page.getByRole('button', { name: 'Download and continue' });
if (await download.isVisible()) await download.click();
else await page.getByRole('button', { name: 'Choose role' }).click();
await openSelectedDungeonRoles(page);
await page.locator('.dungeon-role-option--healer').click();
await page.locator('.scene-shell').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'visible', timeout: 180_000 });
@@ -49,7 +48,7 @@ async function canvasScale(page) {
await page.getByRole('button', { name: /Continue|Enter world/ }).first().evaluate((button) => button.click());
await page.locator('.main-menu-screen').waitFor();
await enterDungeon(page, 'WoW', 'Wailing Caverns');
await enterDungeon(page, 'WoW', DEFAULT_TEST_DUNGEON_TITLE);
const defaultScale = await canvasScale(page);
await page.screenshot({ path: path.join(output, 'wow-default.png') });
await page.keyboard.press('Escape');
+15 -14
View File
@@ -1,3 +1,4 @@
const { DEFAULT_TEST_DUNGEON_TITLE, selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
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');
@@ -159,13 +160,13 @@ async function createHunter(page, name) {
await page.locator('.main-menu-screen').waitFor();
}
async function enterWailingCaverns(page) {
async function enterDefaultTestDungeon(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 selectDefaultTestDungeon(page);
await page.locator('.dungeon-select-copy h2').getByText(DEFAULT_TEST_DUNGEON_TITLE, { exact: true }).waitFor();
await openSelectedDungeonRoles(page);
await page.locator('.dungeon-role-option').filter({ hasText: /DPS/ }).first().click();
await waitForGameReady(page);
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
@@ -257,7 +258,7 @@ async function capture(page, name, fullPage = false) {
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);
await enterDefaultTestDungeon(page);
let selector = page.locator('.hunter-pet-controls select[aria-label="Pet family"]');
await selector.waitFor();
@@ -278,10 +279,10 @@ async function capture(page, name, fullPage = false) {
};
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 capture(page, '01-stockade-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 capture(page, '02-stockade-entry-combat-locked-narrow');
await page.setViewportSize({ width: 1440, height: 1000 });
}
// Entrance mobs must die through the ordinary party simulation before the
@@ -508,18 +509,18 @@ async function capture(page, name, fullPage = false) {
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.',
scope: 'Manual Hunter Cat family UI diagnostic only in disposable Stormwind Stockade. 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.'
? 'At entry, live Stormwind Stockade 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',
'01-stockade-entry-combat-locked-desktop.png',
'02-stockade-entry-combat-locked-narrow.png',
] : []),
'03-cat-family-desktop.png',
'04-cat-family-narrow.png',
@@ -572,7 +573,7 @@ async function capture(page, name, fullPage = false) {
}).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.screenshot({ path: path.join(directory, '01-stockade-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(() => {
@@ -598,7 +599,7 @@ async function capture(page, name, fullPage = false) {
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);
await page.screenshot({ path: path.join(directory, '02-stockade-entry-narrow-diagnostic.png'), fullPage: false }).catch(() => undefined);
}
const failure = {
passed: false,
@@ -606,7 +607,7 @@ async function capture(page, name, fullPage = false) {
error: { name: error.name, message: error.message },
diagnostic,
narrowEvidence,
screenshots: ['failure.png', '01-wailing-entry-desktop-diagnostic.png', '02-wailing-entry-narrow-diagnostic.png'],
screenshots: ['failure.png', '01-stockade-entry-desktop-diagnostic.png', '02-stockade-entry-narrow-diagnostic.png'],
consoleErrors,
httpErrors,
recentRequests: recentRequests.slice(-30),
+15 -14
View File
@@ -1,3 +1,4 @@
const { DEFAULT_TEST_DUNGEON_TITLE, selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
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');
@@ -95,13 +96,13 @@ async function createHunter(page, name) {
await page.locator('.main-menu-screen').waitFor();
}
async function enterWailingCaverns(page) {
async function enterDefaultTestDungeon(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 selectDefaultTestDungeon(page);
await page.locator('.dungeon-select-copy h2').getByText(DEFAULT_TEST_DUNGEON_TITLE, { exact: true }).waitFor();
await openSelectedDungeonRoles(page);
await page.locator('.dungeon-role-option').filter({ hasText: /DPS/ }).first().click();
await waitForGameReady(page);
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
@@ -223,7 +224,7 @@ async function capture(page, name, fullPage = false) {
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);
await enterDefaultTestDungeon(page);
let selector = page.locator('.hunter-pet-controls select[aria-label="Pet family"]');
await selector.waitFor();
@@ -241,10 +242,10 @@ async function capture(page, name, fullPage = false) {
};
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 capture(page, '01-stockade-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 capture(page, '02-stockade-entry-combat-locked-narrow');
await page.setViewportSize({ width: 1440, height: 1000 });
}
// Entrance mobs must die through the ordinary party simulation before the
@@ -341,15 +342,15 @@ async function capture(page, name, fullPage = false) {
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.',
scope: 'Manual Hunter family UI mechanics diagnostic only in disposable Stormwind Stockade. 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.'
? 'At entry, live Stormwind Stockade 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',
'01-stockade-entry-combat-locked-desktop.png',
'02-stockade-entry-combat-locked-narrow.png',
'03-turtle-family-desktop.png',
'04-turtle-family-narrow.png',
] },
@@ -400,7 +401,7 @@ async function capture(page, name, fullPage = false) {
}).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.screenshot({ path: path.join(directory, '01-stockade-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(() => {
@@ -426,7 +427,7 @@ async function capture(page, name, fullPage = false) {
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);
await page.screenshot({ path: path.join(directory, '02-stockade-entry-narrow-diagnostic.png'), fullPage: false }).catch(() => undefined);
}
const failure = {
passed: false,
@@ -434,7 +435,7 @@ async function capture(page, name, fullPage = false) {
error: { name: error.name, message: error.message },
diagnostic,
narrowEvidence,
screenshots: ['failure.png', '01-wailing-entry-desktop-diagnostic.png', '02-wailing-entry-narrow-diagnostic.png'],
screenshots: ['failure.png', '01-stockade-entry-desktop-diagnostic.png', '02-stockade-entry-narrow-diagnostic.png'],
consoleErrors,
httpErrors,
recentRequests: recentRequests.slice(-30),
+4
View File
@@ -1,3 +1,7 @@
// Historical Barren/Lady evidence harness. Routine live QA uses Stockade.
if (!process.argv.includes('--historical-wailing') && !process.argv.includes('--fixtures-only')) {
throw new Error('Historical Lady live checks require an explicit owner request and --historical-wailing. Use the Stockade campaign/mechanics harness for new routine live QA.');
}
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');
+7 -2
View File
@@ -1,3 +1,4 @@
const { DEFAULT_TEST_DUNGEON_ID, DEFAULT_TEST_DUNGEON_TITLE } = require('./playtest-defaults.cjs');
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');
@@ -20,9 +21,13 @@ const moduleFiles = fs.readdirSync('dist/assets')
const moduleUrls = moduleFiles.map((file) => `/assets/${file}`);
const routes = [
{ id: 'barren-caves', title: 'Barren Caves', category: 'RoM' },
{ id: 'wailing-caverns', title: 'Wailing Caverns', category: 'WoW' },
{ id: DEFAULT_TEST_DUNGEON_ID, title: DEFAULT_TEST_DUNGEON_TITLE, category: 'WoW' },
...(process.argv.includes('--historical-wailing') ? [{ id: 'wailing-caverns', title: 'Wailing Caverns', category: 'WoW' }] : []),
];
const routesToRun = routeSelection ? routes.filter((route) => route.id === routeSelection) : routes;
if (routeSelection === 'wailing-caverns' && !process.argv.includes('--historical-wailing')) {
throw new Error('Historical Wailing reproduction requires an explicit owner request and --historical-wailing.');
}
const routesToRun = routeSelection ? routes.filter((route) => route.id === routeSelection) : routes.filter((route) => route.id === DEFAULT_TEST_DUNGEON_ID);
const actionSlots = [
['primary:face-bottom', '1'], ['primary:face-left', '2'], ['primary:face-top', '3'],
['primary:face-right', '4'], ['primary:r1', '5'], ['primary:r2', '6'],
+134 -10
View File
@@ -1,10 +1,11 @@
const { DEFAULT_TEST_DUNGEON_TITLE, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const output = path.resolve('playtest-artifacts/party-equipment');
const output = path.resolve('playtest-artifacts/party-equipment', process.argv.includes('--basic-only') ? 'basic' : '.');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-equipment-'));
fs.mkdirSync(output, { recursive: true });
@@ -23,8 +24,19 @@ async function capture(page, name) {
for (const category of process.argv.includes('--rom-only') ? ['RoM'] : ['WoW', 'RoM']) {
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } });
const page = await context.newPage();
// Test-only observation of the existing canvas; no alternate character renderer.
await page.route(/\/assets\/SkeletonUtils-.*\.js$/, async (route) => {
const response = await route.fetch();
const source = await response.text();
const instrumented = source.replace(/onCreated:([\w$]+)=>\{(?=\1\.events\.connect)/,
'onCreated:$1=>{(window.__equipmentQaScenes??=[]).push($1);');
assert.notEqual(instrumented, source, 'The built canvas creation hook must be observable.');
await route.fulfill({ response, body: instrumented });
});
page.setDefaultTimeout(60000);
const errors = [];
let inspectedParty = [];
let bowFacing = null;
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error' && !message.text().startsWith('Failed to load resource:')) errors.push(message.text());
@@ -43,9 +55,10 @@ async function capture(page, name) {
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.character-select-screen').waitFor();
await page.waitForTimeout(4000);
await capture(page, `${category}-roster`);
await capture(page, `${category}-roster-basic`);
await page.getByRole('button', { name: /Continue as/ }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
if (!process.argv.includes('--basic-only')) {
await page.getByRole('button', { name: /Downloads/ }).click();
const pack = page.locator('.content-pack-row').filter({ hasText: category === 'WoW'
? 'Extra Wrath Character Models & Skins' : 'RuneWaker Character Models & Skins' });
@@ -58,12 +71,17 @@ async function capture(page, name) {
await capture(page, `${category}-roster`);
await page.getByRole('button', { name: /Continue as/ }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
}
await page.getByRole('button', { name: /^Dungeons/ }).click();
await page.locator('.dungeon-category-tab').filter({ hasText: category }).click();
await page.locator('.dungeon-select-row').filter({ hasText: category === 'WoW' ? 'Wailing Caverns' : 'Forsaken Abbey' }).first().click();
const download = page.getByRole('button', { name: 'Download and continue' });
if (await download.isVisible()) await download.click();
else await page.getByRole('button', { name: 'Choose role' }).click();
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
await page.locator('.dungeon-select-row').filter({ hasText: DEFAULT_TEST_DUNGEON_TITLE }).first().click();
const download = page.getByRole('button', { name: 'Download and continue', exact: true });
if (await download.isVisible()) {
await download.click();
await page.locator('.dungeon-role-option').first().waitFor({ timeout: 180000 });
} else {
await openSelectedDungeonRoles(page);
}
await page.locator('.dungeon-role-option--healer').click();
await page.locator('.scene-shell').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'hidden' });
@@ -71,6 +89,107 @@ async function capture(page, name) {
await page.waitForTimeout(12000);
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden' });
await capture(page, `${category}-dungeon`);
if (category === 'RoM') {
inspectedParty = await page.evaluate(() => {
const canvas = document.querySelector('.scene-shell canvas');
const state = window.__equipmentQaScenes?.find((entry) => entry.gl.domElement === canvas);
if (!state) throw new Error('Could not inspect the live shared scene.');
window.equipmentQaScene = state;
const actors = state.scene.getObjectByName('party-population').children;
return actors.map((actor) => {
const weapons = [];
const sourceMeshes = [];
actor.traverse((node) => {
if (node.name.startsWith('equipped-')) weapons.push(node.name);
if (/^(blade01|hammer01|shield01|staff00|bow03|dagger01)_/.test(node.name)) sourceMeshes.push(node.name);
});
if (!sourceMeshes.length) throw new Error(`${actor.name} must carry original RoM weapon meshes.`);
return { name: actor.name, position: actor.position.toArray(), weapons, sourceMeshes };
});
});
console.log(JSON.stringify({ inspection: inspectedParty }, null, 2));
await page.evaluate(() => {
const scene = window.equipmentQaScene.scene;
const actors = scene.getObjectByName('party-population').children;
const scout = actors.find((actor) => {
let found = false;
actor.traverse((node) => { if (node.name.startsWith('bow03_')) found = true; });
return found;
});
if (!scout) throw new Error('Caelan must carry the original bow03 RoM mesh.');
const state = scout.__r3f.root.getState();
window.equipmentQaScene = state;
let bow;
scout.traverse((node) => { if (node.name.startsWith('bow03_')) bow = node; });
let prop = bow;
while (prop && !prop.name.startsWith('equipped-')) prop = prop.parent;
window.equipmentQaBow = { bow, prop };
const vector = scout.position.clone();
const eye = state.camera.position.clone();
window.equipmentQaOriginalFov = state.camera.fov;
const view = {
ref: { current: () => {
const center = scout.getWorldPosition(vector.clone());
center.y += 0.95;
// Keep the normal camera's collision-safe location in the corridor.
state.camera.position.copy(eye);
state.camera.fov = 40;
state.camera.updateProjectionMatrix();
state.camera.lookAt(center);
} }, priority: 0, store: scout.__r3f.root,
};
state.internal.subscribers.push(view);
state.internal.subscribers.sort((left, right) => left.priority - right.priority);
window.equipmentQaCameraView = view;
});
await page.waitForTimeout(1200);
bowFacing = await page.evaluate(() => {
window.equipmentQaBowFacing = () => {
const { bow, prop } = window.equipmentQaBow;
bow.updateWorldMatrix(true, true);
const center = bow.position.clone().set(0, 0, 0);
const tips = center.clone();
const upper = center.clone();
const lower = center.clone();
let upperCount = 0;
let lowerCount = 0;
let centerCount = 0;
let tipCount = 0;
const positions = bow.geometry.attributes.position;
for (let index = 0; index < positions.count; index++) {
const y = Math.abs(positions.getY(index));
const point = bow.localToWorld(bow.getVertexPosition(index, center.clone().set(0, 0, 0)));
if (y < 0.2) { center.add(point); centerCount++; }
if (y > 0.8) { tips.add(point); tipCount++; }
if (positions.getY(index) > 0.8) { upper.add(point); upperCount++; }
if (positions.getY(index) < -0.8) { lower.add(point); lowerCount++; }
}
center.divideScalar(centerCount);
tips.divideScalar(tipCount);
upper.divideScalar(upperCount);
lower.divideScalar(lowerCount);
const actor = window.equipmentQaScene.scene.getObjectByName('party-population').children.find((node) => {
let found = false;
node.traverse((child) => { if (child === bow) found = true; });
return found;
});
const chest = actor.localToWorld(center.clone().set(0, 1.1, 0));
const facing = tips.sub(center).normalize().dot(chest.sub(prop.getWorldPosition(center.clone())).normalize());
return { stringSideTowardTorso: facing, longAxisUp: Math.abs(upper.sub(lower).normalize().y), grip: prop.getWorldPosition(center.clone()).toArray() };
};
return window.equipmentQaBowFacing();
});
console.log(JSON.stringify({ bowFacing }));
assert.ok(bowFacing.stringSideTowardTorso > 0.1, 'The original bow string side must face Caelan’s torso.');
await capture(page, process.argv.includes('--basic-only') ? 'RoM-Caelan-basic' : 'RoM-Caelan-native');
await page.evaluate(() => {
const subscribers = window.equipmentQaScene.internal.subscribers;
const index = subscribers.indexOf(window.equipmentQaCameraView);
if (index >= 0) subscribers.splice(index, 1);
window.equipmentQaScene.camera.fov = window.equipmentQaOriginalFov;
window.equipmentQaScene.camera.updateProjectionMatrix();
});
}
// Rotate the camera with the normal held-right-mouse gesture to show the party fronts.
const bounds = await page.locator('.scene-shell canvas').boundingBox();
await page.mouse.move(bounds.x + bounds.width * 0.5, bounds.y + bounds.height * 0.55);
@@ -90,9 +209,14 @@ async function capture(page, name) {
}
assert.deepEqual(errors, []);
results[category] = {
installedNativeCharacterPack: true,
screenshots: [`${category}-roster.png`, `${category}-dungeon.png`, `${category}-party-front.png`,
...(category === 'RoM' ? [`${category}-party-open.png`] : [])],
dungeon: DEFAULT_TEST_DUNGEON_TITLE,
installedNativeCharacterPack: !process.argv.includes('--basic-only'),
screenshots: [`${category}-roster-basic.png`,
...(!process.argv.includes('--basic-only') ? [`${category}-roster.png`] : []),
`${category}-dungeon.png`, `${category}-party-front.png`,
...(category === 'RoM' ? [`${category}-party-open.png`, process.argv.includes('--basic-only') ? 'RoM-Caelan-basic.png' : 'RoM-Caelan-native.png'] : [])],
inspectedParty,
bowFacing,
errors,
};
} catch (error) {
+4 -7
View File
@@ -1,3 +1,4 @@
const { selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
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');
@@ -37,11 +38,6 @@ fs.mkdirSync(directory, { recursive: true });
const { shell } = await stores();
shell.getState().openMainMenu();
};
window.enterDungeonForPartyHealPlaytest = async () => {
const { shell } = await stores();
const ok = shell.getState().enterDungeon('wailing-caverns');
return { ok, notice: shell.getState().notice, phase: shell.getState().phase };
};
window.openDungeonsForPartyHealPlaytest = async () => {
const { shell } = await stores();
shell.getState().openDungeons();
@@ -86,8 +82,9 @@ fs.mkdirSync(directory, { recursive: true });
await page.locator('.main-menu-screen').waitFor();
await page.evaluate(() => window.openDungeonsForPartyHealPlaytest());
await page.locator('.dungeon-select-screen').waitFor();
const entry = await page.evaluate(() => window.enterDungeonForPartyHealPlaytest());
assert.equal(entry.ok, true, JSON.stringify(entry));
await selectDefaultTestDungeon(page);
await openSelectedDungeonRoles(page);
await page.locator('.dungeon-role-option--healer').click();
await page.locator('.party-frames').waitFor();
await page.locator('.party-frame').nth(4).waitFor();
await page.locator('.loading-screen').waitFor({ state: 'detached' });
+4 -3
View File
@@ -1,3 +1,4 @@
const { selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs');
const path = require('node:path');
@@ -42,10 +43,10 @@ async function sample(page, cdp, label) {
await page.getByRole('button', { name: /Continue|Enter world/ }).first().evaluate((button) => button.click());
console.log('Entering adventure menu');
await page.locator('.main-menu-screen').waitFor();
console.log('Choosing bundled Wailing Caverns');
console.log('Choosing Stormwind Stockade');
await page.getByRole('button', { name: /^Dungeons/ }).click();
await page.locator('.dungeon-select-row').filter({ hasText: /Wailing Caverns/i }).first().click();
await page.getByRole('button', { name: 'Choose role' }).click();
await selectDefaultTestDungeon(page);
await openSelectedDungeonRoles(page);
await page.locator('.dungeon-role-option--healer').click();
console.log('Waiting for scene');
await page.locator('.scene-shell').waitFor();
+3 -2
View File
@@ -1,3 +1,4 @@
const { selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs');
const path = require('node:path');
@@ -9,8 +10,8 @@ fs.mkdirSync(output, { recursive: true });
async function enterDungeon(page) {
await page.getByRole('button', { name: /^Dungeons/ }).click();
await page.locator('.dungeon-select-row').filter({ hasText: /Wailing Caverns/i }).first().click();
await page.getByRole('button', { name: 'Choose role' }).click();
await selectDefaultTestDungeon(page);
await openSelectedDungeonRoles(page);
await page.locator('.dungeon-role-option--healer').click();
await page.locator('.scene-shell').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 180_000 });
+3 -2
View File
@@ -1,3 +1,4 @@
const { selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
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');
@@ -61,8 +62,8 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-rom-five-u
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.getByRole('button', { name: /Choose role/ }).click();
await selectDefaultTestDungeon(page);
await openSelectedDungeonRoles(page);
await page.locator('.dungeon-role-option--damage').click();
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 180_000 });
+4 -7
View File
@@ -1,3 +1,4 @@
const { selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
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');
@@ -60,13 +61,9 @@ fs.mkdirSync(directory, { recursive: true });
shell.getState().openDungeons();
});
await page.locator('.dungeon-select-screen').waitFor();
await page.locator('.dungeon-select-row').first().waitFor();
const entry = await page.evaluate(async () => {
const { shell } = await window.romStatusStores();
const ok = shell.getState().enterDungeon('wailing-caverns');
return { ok, notice: shell.getState().notice };
});
assert.equal(entry.ok, true, JSON.stringify(entry));
await selectDefaultTestDungeon(page);
await openSelectedDungeonRoles(page);
await page.locator('.dungeon-role-option--healer').click();
await page.locator('.party-frames').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'detached' });
await page.evaluate(async () => {
+5 -4
View File
@@ -1,3 +1,4 @@
const { DEFAULT_TEST_DUNGEON_TITLE, DEFAULT_TEST_DUNGEON_BOSS } = require('./playtest-defaults.cjs');
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');
@@ -8,7 +9,7 @@ const directory = path.resolve('playtest-artifacts', 'rotation-overhaul');
fs.mkdirSync(directory, { recursive: true });
const databaseDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-rotation-playtest-'));
const partySeed = 'rotation-boss-test-0';
const bossName = 'Lady Anacondra';
const bossName = DEFAULT_TEST_DUNGEON_BOSS;
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
@@ -183,7 +184,7 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
}
await sleep(50);
}
assert(engaged, 'the companion party engages Lady Anacondra');
assert(engaged, `the companion party engages ${bossName}`);
await page.screenshot({ path: path.join(directory, 'boss-engaged.png'), fullPage: true });
const canvasImage = await page.locator('canvas').screenshot({ path: path.join(directory, 'boss-engaged-canvas.png') });
assert(canvasImage.length > 10_000, 'boss screenshot contains a rendered WebGL scene');
@@ -199,7 +200,7 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
}
await sleep(50);
}
assert(completed, 'the companion party defeats Lady Anacondra');
assert(completed, `the companion party defeats ${bossName}`);
await page.waitForTimeout(250);
await page.screenshot({ path: path.join(directory, 'boss-defeated.png'), fullPage: true });
@@ -216,7 +217,7 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
assert.deepEqual(errors, [], `No browser page errors: ${errors.join('; ')}`);
const results = {
dungeon: 'Wailing Caverns — Boss Testing',
dungeon: `${DEFAULT_TEST_DUNGEON_TITLE} — Boss Testing`,
boss: bossName,
partySeed,
engagedBoss: engaged.boss,
+6 -5
View File
@@ -1,3 +1,4 @@
const { selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
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');
@@ -232,12 +233,12 @@ async function chooseMode(page, ruleset) {
assert.equal(await button.getAttribute('aria-pressed'), 'true');
}
async function selectWailingCaverns(page) {
async function selectDefaultDungeon(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();
await selectDefaultTestDungeon(page);
await openSelectedDungeonRoles(page);
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();
@@ -475,7 +476,7 @@ async function readStatsPanelPage(page) {
results.screenshots.menuSelectorMobile = await capture(page, '03-menu-selector-mobile', true);
await page.setViewportSize({ width: 1440, height: 1000 });
await selectWailingCaverns(page);
await selectDefaultDungeon(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();
@@ -682,7 +683,7 @@ async function readStatsPanelPage(page) {
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);
await selectDefaultDungeon(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();
File diff suppressed because it is too large. Load diff
+4
View File
@@ -1,3 +1,7 @@
// Historical Lady Anacondra reproduction only. New routine Vow QA uses Stockade.
if (!process.argv.includes('--historical-wailing')) {
throw new Error('This historical Wailing harness requires an explicit owner request and --historical-wailing. Use the Stockade campaign/mechanics harness for routine QA.');
}
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
+9
View File
@@ -72,6 +72,15 @@ const selected: CharacterAppearance = {
};
describe("resolveAvatarAppearance", () => {
it("keeps authentic RoM weapons and clothing when the optional native body falls back to WoW", () => {
const resolved = resolveAvatarAppearance(manifest, entry, selected, "rom-scout");
expect(resolved.equipmentModels.filter((model) => model.attachment === "handLeft")).toEqual([
expect.objectContaining({ sourceFrame: "runewaker", url: expect.stringContaining("starter-bow.glb") }),
]);
expect(resolved.bodyLayers.length).toBeGreaterThan(9);
expect(resolved.equipmentModels.some((model) => model.attachment === "shoulderLeft")).toBe(true);
});
it("composes body layers in WoW replacement-texture order", () => {
const resolved = resolveAvatarAppearance(manifest, entry, selected, "priest");
expect(resolved.bodyLayers.slice(0, 9).map((layer) => layer.url)).toEqual([
+15 -2
View File
@@ -1,4 +1,4 @@
import type { CharacterAppearance, ClassId } from "../app/characterCatalog";
import { isRomClassId, type CharacterAppearance, type ClassId } from "../app/characterCatalog";
import type { InventoryItem } from "../game/lootTypes";
import type {
AvatarManifest,
@@ -60,9 +60,22 @@ export function resolveAvatarAppearance(
const featureTextures = tripletAt(feature?.byColor ?? [], hairColorIndex);
const regions = manifest.textureLayout.regions;
const layers: AvatarTextureLayer[] = [];
const equipped = entry.rendererStrategy?.kind === "runewaker-modular" && classId
const nativeBody = entry.rendererStrategy?.kind === "runewaker-modular";
const bodyEquipment = nativeBody && classId
? resolveRunewakerEquipment(classId, equipment)
: resolveEquipmentAppearance(equipment, entry.gender, classId);
// Class identity survives the optional character pack's basic-human fallback.
const romWeapons = classId && isRomClassId(classId)
? resolveRunewakerEquipment(classId, equipment)
: null;
const equipped = romWeapons && !nativeBody ? {
...bodyEquipment,
cacheKey: `${bodyEquipment.cacheKey}:${romWeapons.cacheKey}`,
models: [
...bodyEquipment.models.filter((model) => model.attachment === "shoulderLeft" || model.attachment === "shoulderRight"),
...romWeapons.models,
],
} : bodyEquipment;
if (skin?.base) addLayer(layers, skin.base, regions.base);
addLayer(layers, skin?.underwear?.[0] ?? "", regions.pelvisUpper);
+1 -1
View File
@@ -36,7 +36,7 @@ export interface EquipmentModelVisual {
readonly attachment: EquipmentAttachmentName;
readonly fallbackKind?: "bow" | "dagger" | "mace" | "shield" | "staff" | "sword";
readonly weaponStance?: CharacterWeaponStance;
/** Native RoM props already use the authored RoM socket frame. */
/** Original RoM prop frame, adapted separately from the body's socket frame. */
readonly sourceFrame?: "runewaker";
}
+32 -3
View File
@@ -28,6 +28,7 @@ import {
avatarWeaponSocketRotation,
findAvatarHandBone,
findAvatarShieldBone,
runewakerWeaponSocketRotation,
StarterWeaponVisual,
type AvatarHand,
type StarterWeaponRotation,
@@ -42,6 +43,34 @@ const EQUIPMENT_ATTACHMENT_NAMES: readonly EquipmentAttachmentName[] = Object.fr
]);
const NO_ROTATION: StarterWeaponRotation = Object.freeze([0, 0, 0]);
/** Native props need the inverse socket adaptation when the optional rig is absent. */
export function equipmentWeaponSocketRotation(
entry: AvatarManifestEntry,
itemModel: EquipmentModelVisual,
): StarterWeaponRotation {
if (!itemModel.fallbackKind || !itemModel.weaponStance) return NO_ROTATION;
if (itemModel.sourceFrame !== "runewaker") {
return avatarWeaponSocketRotation(entry, itemModel.fallbackKind, itemModel.weaponStance);
}
if (entry.rendererStrategy?.kind === "runewaker-modular") return NO_ROTATION;
const nativeRotation = runewakerWeaponSocketRotation(
itemModel.fallbackKind,
itemModel.weaponStance,
);
return [-nativeRotation[0], -nativeRotation[1], -nativeRotation[2]];
}
export function equipmentWeaponModelRotation(
entry: AvatarManifestEntry,
itemModel: EquipmentModelVisual,
): StarterWeaponRotation {
// The original bow is Y-long; the basic human bow socket expects the adapted Z axis.
return itemModel.sourceFrame === "runewaker" && itemModel.fallbackKind === "bow"
&& entry.rendererStrategy?.kind !== "runewaker-modular"
? [Math.PI / 2, 0, 0]
: NO_ROTATION;
}
export type EquipmentAttachmentTransforms = Readonly<
Partial<Record<EquipmentAttachmentName, AvatarAttachmentTransform>>
>;
@@ -144,9 +173,7 @@ function AttachedEquipmentModel({
materials.forEach((material) => material.dispose());
}, [scene]);
const socketRotation = itemModel.sourceFrame !== "runewaker" && itemModel.fallbackKind && itemModel.weaponStance
? avatarWeaponSocketRotation(entry, itemModel.fallbackKind, itemModel.weaponStance)
: NO_ROTATION;
const socketRotation = equipmentWeaponSocketRotation(entry, itemModel);
return createPortal(
<group
@@ -154,7 +181,9 @@ function AttachedEquipmentModel({
rotation={socketRotation}
scale={transform.scale}
>
<group rotation={equipmentWeaponModelRotation(entry, itemModel)}>
<primitive object={scene} />
</group>
</group>,
transform.target,
);
+34
View File
@@ -1,11 +1,45 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { Euler, Quaternion, Vector3 } from "three";
import { CLASSES } from "../app/characterCatalog";
import type { InventoryItem } from "../game/lootTypes";
import { resolveRunewakerEquipment } from "./runewakerEquipment";
import { equipmentWeaponModelRotation, equipmentWeaponSocketRotation } from "./equipmentAttachments";
import { avatarWeaponSocketRotation } from "./starterWeapons";
import type { AvatarManifest } from "./characterManifest";
describe("original RoM equipment", () => {
it("aligns the original bow to the basic body's grip without reversing its native facing", () => {
const native = (JSON.parse(readFileSync("public/assets/characters/runewaker-v1/manifest.json", "utf8")) as AvatarManifest).entries["rom-human:male"];
const basic = { ...native, rendererStrategy: undefined };
const bow = resolveRunewakerEquipment("rom-scout", []).models[0];
expect(equipmentWeaponModelRotation(native, bow)).toEqual([0, 0, 0]);
const axis = new Vector3(0, 1, 0)
.applyEuler(new Euler(...equipmentWeaponModelRotation(basic, bow)))
.applyEuler(new Euler(...equipmentWeaponSocketRotation(basic, bow)));
expect(axis.distanceTo(new Vector3(1, 0, 0))).toBeLessThan(1e-6);
const stringSide = new Vector3(0, 0, -1).applyEuler(new Euler(...equipmentWeaponModelRotation(basic, bow)));
expect(stringSide.y).toBeCloseTo(1);
for (const model of resolveRunewakerEquipment("rom-knight", []).models) {
expect(equipmentWeaponModelRotation(native, model)).toEqual([0, 0, 0]);
expect(equipmentWeaponModelRotation(basic, model)).toEqual([0, 0, 0]);
}
});
it("converts native props back to the basic body's socket frame when its pack is absent", () => {
const native = (JSON.parse(readFileSync("public/assets/characters/runewaker-v1/manifest.json", "utf8")) as AvatarManifest).entries["rom-human:male"];
const basic = { ...native, rendererStrategy: undefined };
for (const classId of ["rom-knight", "rom-mage", "rom-scout", "rom-rogue"] as const) {
for (const model of resolveRunewakerEquipment(classId, []).models) {
expect(equipmentWeaponSocketRotation(native, model)).toEqual([0, 0, 0]);
const nativeRotation = new Quaternion().setFromEuler(new Euler(...avatarWeaponSocketRotation(native, model.fallbackKind!, model.weaponStance!)));
const fallbackRotation = new Quaternion().setFromEuler(new Euler(...equipmentWeaponSocketRotation(basic, model)));
expect(nativeRotation.multiply(fallbackRotation).angleTo(new Quaternion())).toBeLessThan(1e-6);
}
}
});
it("equips every RoM class with source-client props in its native socket frame", () => {
for (const characterClass of CLASSES.filter((entry) => entry.id.startsWith("rom-"))) {
const resolved = resolveRunewakerEquipment(characterClass.id, []);
+8
View File
@@ -329,6 +329,14 @@ export function avatarWeaponSocketRotation(
if (entry?.rendererStrategy?.kind !== "runewaker-modular" || kind === "shield") {
return ZERO_ROTATION;
}
return runewakerWeaponSocketRotation(kind, stance);
}
export function runewakerWeaponSocketRotation(
kind: StarterWeaponKind,
stance: StarterWeaponStance,
): StarterWeaponRotation {
if (kind === "shield") return ZERO_ROTATION;
return stance === "two-handed" || stance === "two-handed-left"
? RUNEWAKER_TWO_HAND_SOCKET_ROTATION
: RUNEWAKER_ONE_HAND_SOCKET_ROTATION;
+12 -1
View File
@@ -13,6 +13,7 @@ import type { AuraDefinition, DamageSchool, DispelCategory } from "./combatAuras
import { applyAbilityBalanceTuning, currentBalanceTuningPolicy } from "./balanceTuning";
import { preloadRuntimeTalentCatalog } from "./talentRuntimeCatalog";
import { applyGeneratedBeastWithinTalent } from "./wow335HunterRuntime";
import { romBloodArrowAbilityForOwner } from "./romBloodArrow";
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
@@ -1101,7 +1102,17 @@ export function abilitiesForCharacter(
ability.romSkillGroup === "general" || (ability.romSkillGroup === "passive" && ability.id.includes("-general-"))
));
const elites = romEliteAbilities.filter((ability) => ability.classId === classId && ability.secondaryClassId === secondaryClassId);
const characterAbilities = [...primary, ...secondaryGeneral, ...elites];
const characterAbilities = [...primary, ...secondaryGeneral, ...elites].map((ability) => {
const bloodArrow = romBloodArrowAbilityForOwner(ability, { classId, secondaryClassId });
return bloodArrow === ability ? ability : {
...bloodArrow,
description: classId === "rom-knight" && secondaryClassId === "rom-scout"
? "Activates Blood Arrow and increases ranged weapon damage. Each offensive ranged weapon skill costs 2.0% HP and 1.0% MP. Right-click the buff icon or use the skill again to deactivate; the skill enters a 10-second cooldown after deactivation."
: classId === "rom-scout" && secondaryClassId === "rom-rogue"
? "Activates Blood Arrow and increases physical ranged weapon damage. Ranged attacks cost 2.0% HP each. Right-click the buff icon or use the skill again to deactivate; the skill enters a 10-second cooldown after deactivation."
: "Activates Blood Arrow and increases physical ranged weapon damage. Right-click the buff icon or use the skill again to deactivate; the skill enters a 10-second cooldown after deactivation.",
};
});
if (classId !== "rom-warlock" || secondaryClassId !== "rom-priest") return characterAbilities;
return characterAbilities.map((ability) => {
if (ability.classId === "rom-priest" && ability.dbcSpellId === 490256) {
+172 -11
View File
@@ -565,6 +565,15 @@ import {
ROM_KNIGHT_PANTHERS_BLESSING_ABILITY_ID,
romKnightPanthersBlessingRestoreOnDodge,
} from "./romKnightPanthersBlessingRuntime";
import {
hasOwnedRomScoutBloodArrow,
isRomBloodArrowOwner,
isRomScoutBloodArrowAbility,
romBloodArrowCastCosts,
ROM_SCOUT_BLOOD_ARROW_ABILITY_ID,
ROM_SCOUT_BLOOD_ARROW_AURA_TAG,
ROM_SCOUT_BLOOD_ARROW_DEACTIVATION_COOLDOWN_MS,
} from "./romBloodArrow";
import {
ROM_COA_KNIGHT_ROGUE_GODS_SWORD_ID,
romCoaKnightRogueGodsSwordBonusLightDamage,
@@ -1562,6 +1571,7 @@ export interface CombatState {
selectFriendlyActor: (id: string | null) => boolean;
setPlayerPosition: (position: CombatPosition) => void;
castAbility: (abilityId: string, requestOrOrigin?: CastRequest | CombatPosition, now?: number, completingCast?: ActiveCast) => CastResult;
deactivateRomScoutBloodArrow: (ownerId: string, now?: number) => boolean;
beginGroundTarget: (abilityId: string) => boolean;
moveGroundTarget: (moveX: number, moveForward: number, yaw: number, deltaSeconds: number) => void;
confirmGroundTarget: (position?: CombatPosition, now?: number) => CastResult;
@@ -10958,13 +10968,26 @@ function damageCompanionActorInWork(work: MutableCastWork, id: string, amount: n
if (!actor || actor.health <= 0 || requested <= 0) return 0;
const applied = Math.min(actor.health, requested);
const health = actor.health - applied;
const next = Object.freeze({ ...actor, health, alive: health > 0, cast: health > 0 ? actor.cast : null });
const next = Object.freeze({
...actor,
health,
alive: health > 0,
cast: health > 0 ? actor.cast : null,
...(health <= 0 ? {
cooldowns: Object.fromEntries(Object.entries(actor.cooldowns)
.filter(([abilityId]) => abilityId !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)),
} : {}),
});
work.actors[id] = next;
mirrorCompanionActor(next);
if (health <= 0) {
work.auras = work.auras.filter((aura) => !(aura.targetId === id
&& aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG)));
usePartyStore.setState((party) => ({
members: party.members.map((member) => member.id === id
? { ...member, status: "down", activeCast: null, activeCastTargetId: null }
? { ...member, status: "down", activeCast: null, activeCastTargetId: null,
cooldowns: Object.fromEntries(Object.entries(member.cooldowns)
.filter(([abilityId]) => abilityId !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)) }
: member),
}));
}
@@ -11985,13 +12008,38 @@ export const useCombatStore = create<CombatState>((set, get) => ({
cancelGroundTarget: () => set({ pendingGroundTarget: null }),
synchronizeActors: () => {
const partyMembers = usePartyStore.getState().members;
const livingCompanionIds = new Set(partyMembers.filter((member) => member.health > 0).map((member) => member.id));
let actors: Readonly<Record<string, CombatActorState>> = {};
set((state) => {
actors = combatActorSnapshots(state);
return { actors };
const activeBloodArrowOwners = new Set(livingCompanionIds);
if (state.health > 0) activeBloodArrowOwners.add(PLAYER_AURA_ENTITY_ID);
actors = Object.freeze(Object.fromEntries(Object.entries(combatActorSnapshots(state)).map(([id, actor]) => (
activeBloodArrowOwners.has(id) ? [id, actor] : [id, {
...actor,
cooldowns: Object.fromEntries(Object.entries(actor.cooldowns)
.filter(([abilityId]) => abilityId !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)),
}]
))));
const auras = state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG)
|| aura.sourceId !== aura.targetId
|| (aura.sourceId === PLAYER_AURA_ENTITY_ID
? state.health > 0
: livingCompanionIds.has(aura.sourceId)));
const cooldowns = state.health > 0
? state.cooldowns
: Object.fromEntries(Object.entries(state.cooldowns).filter(([id]) => id !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID));
return { actors, auras, cooldowns };
});
usePartyStore.setState((party) => ({
members: party.members.map((member) => memberFromCompanionActor(member, actors[member.id])),
members: party.members.map((member) => {
const runtime = memberFromCompanionActor(member, actors[member.id]);
return member.health > 0 ? runtime : {
...runtime,
cooldowns: Object.fromEntries(Object.entries(runtime.cooldowns)
.filter(([id]) => id !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)),
};
}),
}));
},
applyDefaultPartyBuffs: (now = combatNow()) => {
@@ -12226,6 +12274,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
get().synchronizeActors();
},
commitCompanionActors: (members) => {
const livingCompanionIds = new Set(members.filter((member) => member.health > 0).map((member) => member.id));
let actors: Readonly<Record<string, CombatActorState>> = {};
set((state) => {
const next = { ...state.actors };
@@ -12234,15 +12283,40 @@ export const useCombatStore = create<CombatState>((set, get) => ({
member,
entityAbsorbAmount(state.auras, member.id),
);
next[member.id] = companionActorWithMemberRuntime(existing, member);
const runtime = companionActorWithMemberRuntime(existing, member);
next[member.id] = member.health > 0 ? runtime : {
...runtime,
cooldowns: Object.fromEntries(Object.entries(runtime.cooldowns)
.filter(([id]) => id !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)),
};
}
const activeBloodArrowOwners = new Set(livingCompanionIds);
if (state.health > 0) activeBloodArrowOwners.add(PLAYER_AURA_ENTITY_ID);
for (const [id, actor] of Object.entries(next)) {
if (activeBloodArrowOwners.has(id) || actor.cooldowns[ROM_SCOUT_BLOOD_ARROW_ABILITY_ID] === undefined) continue;
next[id] = {
...actor,
cooldowns: Object.fromEntries(Object.entries(actor.cooldowns)
.filter(([abilityId]) => abilityId !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)),
};
}
actors = Object.freeze(next);
return { actors };
const auras = state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG)
|| aura.sourceId !== aura.targetId
|| (aura.sourceId === PLAYER_AURA_ENTITY_ID
? state.health > 0
: livingCompanionIds.has(aura.sourceId)));
return { actors, auras };
});
usePartyStore.setState((party) => ({
members: party.members.map((member) => {
const runtime = members.find((candidate) => candidate.id === member.id) ?? member;
return memberFromCompanionActor(runtime, actors[member.id]);
const projected = memberFromCompanionActor(runtime, actors[member.id]);
return projected.health > 0 ? projected : {
...projected,
cooldowns: Object.fromEntries(Object.entries(projected.cooldowns)
.filter(([id]) => id !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)),
};
}),
}));
},
@@ -12267,6 +12341,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const suddenDoomFreeDeathCoil = request[WOW335_DEATH_KNIGHT_SUDDEN_DOOM_FREE_CAST] === true
&& ability?.id === WOW335_DEATH_KNIGHT_SUDDEN_DOOM_DEATH_COIL_ABILITY_ID;
const activePlayerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID);
if (!completingCast && ability && isRomScoutBloodArrowAbility(ability)
&& isRomBloodArrowOwner({ classId: state.classId, secondaryClassId: state.secondaryClassId })
&& state.abilities.some((entry) => entry.id === ability!.id)
&& hasOwnedRomScoutBloodArrow(state.auras, PLAYER_AURA_ENTITY_ID, now)
&& get().deactivateRomScoutBloodArrow(PLAYER_AURA_ENTITY_ID, now)) {
return {
ok: true,
abilityId,
affectedMobIds: [],
damage: 0,
healing: 0,
levelsGained: 0,
};
}
const druidMasterShapeshifterHealingModifiers = state.classId === "druid"
? wow335DruidMasterShapeshifterModifiersForForm(
state.talentRanks[WOW335_DRUID_MASTER_SHAPESHIFTER_TALENT_ID] ?? 0,
@@ -12691,7 +12779,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: state.classId === "warrior" && ability?.name === "Execute"
? Math.min(3, Math.max(0, Math.trunc(state.talentRanks["talent-1542"] ?? 0))) * 2
: 0;
const costs = ability && !consumesInnerFocus && !consumesDruidClearcasting && !consumesSurgeOfLight
const costs = [
...(ability && !consumesInnerFocus && !consumesDruidClearcasting && !consumesSurgeOfLight
? resolvedAbilityCosts(ability, modifiers, { ...state, now }).map((cost) => {
if (suddenDoomFreeDeathCoil && cost.resource === "runic-power") return { ...cost, amount: 0 };
if (consumesHunterLockAndLoad && cost.resource === "mana") return { ...cost, amount: 0 };
@@ -12700,7 +12789,19 @@ export const useCombatStore = create<CombatState>((set, get) => ({
? { ...cost, amount: Math.max(0, cost.amount - warriorFlatRageCostReduction) }
: cost;
})
: [];
: []),
...(ability ? romBloodArrowCastCosts(
ability,
{ classId: state.classId, secondaryClassId: state.secondaryClassId },
activePlayerAuras,
PLAYER_AURA_ENTITY_ID,
state.maxHealth,
resourceProfileForClass(state.classId).type === "mana"
? state.maxResource
: state.maxResourcePools.mana ?? 0,
now,
) : []),
];
const resourceCost = ability && !suddenDoomFreeDeathCoil
&& !consumesInnerFocus && !consumesDruidClearcasting && !consumesSurgeOfLight
? Math.max(0, talentAdjustedResourceCost(ability, modifiers)
@@ -17103,6 +17204,45 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return result;
},
deactivateRomScoutBloodArrow: (ownerId, now = combatNow()) => {
const state = get();
const playerOwner = ownerId === PLAYER_AURA_ENTITY_ID;
const member = playerOwner
? null
: usePartyStore.getState().members.find((candidate) => candidate.id === ownerId) ?? null;
const ownerBuild = playerOwner ? state : member;
if (!ownerBuild || !isRomBloodArrowOwner(ownerBuild)
|| !hasOwnedRomScoutBloodArrow(state.auras, ownerId, now)) return false;
if ((playerOwner && state.health <= 0) || (!playerOwner && (!member || member.health <= 0))) return false;
const readyAt = now + ROM_SCOUT_BLOOD_ARROW_DEACTIVATION_COOLDOWN_MS;
const auras = state.auras.filter((aura) => !(aura.sourceId === ownerId
&& aura.targetId === ownerId
&& aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG)));
const actors = state.actors[ownerId]
? { ...state.actors, [ownerId]: {
...state.actors[ownerId],
cooldowns: { ...state.actors[ownerId].cooldowns, [ROM_SCOUT_BLOOD_ARROW_ABILITY_ID]: readyAt },
} }
: state.actors;
if (playerOwner) {
set({
auras,
actors,
cooldowns: { ...state.cooldowns, [ROM_SCOUT_BLOOD_ARROW_ABILITY_ID]: readyAt },
});
return true;
}
usePartyStore.setState((party) => ({
members: party.members.map((candidate) => candidate.id === ownerId
? { ...candidate, cooldowns: { ...candidate.cooldowns, [ROM_SCOUT_BLOOD_ARROW_ABILITY_ID]: readyAt } }
: candidate),
}));
set({ auras, actors });
return true;
},
consumeActorAuraTag: (actorId, tag) => {
set((state) => ({
auras: state.auras.filter((aura) => !(aura.targetId === actorId
@@ -18944,7 +19084,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
set({
auras: health <= 0 ? nextAuras.filter((aura) => !(aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-rogue-killing-spree"))) : nextAuras,
&& (aura.definition.utilityTags?.includes("wow335-rogue-killing-spree")
|| aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG)))) : nextAuras,
cooldowns: health <= 0
? Object.fromEntries(Object.entries(state.cooldowns)
.filter(([id]) => id !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID))
: state.cooldowns,
killingSpreeWindows: health <= 0
? state.killingSpreeWindows.filter((window) => window.ownerActorId !== PLAYER_AURA_ENTITY_ID)
: state.killingSpreeWindows,
@@ -21036,6 +21181,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
combatResult.dodged ? "dodge" : combatResult.landed && combatResult.rawDamage > 0 ? "hit" : "other",
now,
)];
if (procWork.health <= 0) {
procWork.auras = procWork.auras.filter((aura) => !(aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG)));
delete procWork.cooldowns[ROM_SCOUT_BLOOD_ARROW_ABILITY_ID];
}
const playerPushbackReduction = healthDamage > 0
? paladinConcentrationAuraPushbackReductionForTarget(
procWork.auras,
@@ -22078,6 +22228,17 @@ export const useCombatStore = create<CombatState>((set, get) => ({
combatResult.dodged ? "dodge" : combatResult.landed && combatResult.rawDamage > 0 ? "hit" : "other",
now,
)];
if (nextActor.health <= 0) {
aurasAfterProcs = aurasAfterProcs.filter((aura) => !(aura.targetId === member.id
&& aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG)));
nextActor = {
...nextActor,
cooldowns: Object.fromEntries(Object.entries(nextActor.cooldowns)
.filter(([abilityId]) => abilityId !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)),
};
actorsAfterHit = { ...actorsAfterHit, [nextActor.id]: nextActor };
mirrorCompanionActor(nextActor);
}
set({
actors: actorsAfterHit,
summons: nextSummons,
+180 -1
View File
@@ -2,6 +2,9 @@ import { describe, expect, it } from "vitest";
import campaign from "../../dungeon-pipeline/dungeon-campaign.json";
import epochInstances from "../../dungeon-pipeline/epoch-five-player-instances.json";
import gameObjectAssetManifest from "../../dungeon-pipeline/WOW_GAMEOBJECT_ASSET_MANIFEST.json";
import stockadeRecipe from "../../dungeon-pipeline/recipes/stormwind-stockade.json";
import theNexusRecipe from "../../dungeon-pipeline/recipes/the-nexus.json";
import worldAi from "./generated/azerothCoreWorldAi.json";
import generated from "./generated/dungeonCampaignCatalog.json";
import manastormPackages from "./generated/manastormAssetPackages.json";
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
@@ -72,7 +75,7 @@ const WDT_PLACEMENT_DUNGEON_IDS = [
describe("generated dungeon campaign catalog", () => {
it("covers only reviewed five-player instances with loadable assets and combat content", () => {
expect(generated.definitions).toHaveLength(54);
expect(DUNGEON_DEFINITIONS).toHaveLength(92);
expect(DUNGEON_DEFINITIONS).toHaveLength(98);
expect(generated.definitions.map((dungeon) => dungeon.id))
.toEqual(campaign.dungeons.map((dungeon) => dungeon.slug));
for (const dungeon of generated.definitions) {
@@ -86,6 +89,25 @@ describe("generated dungeon campaign catalog", () => {
}
});
it("keeps the six newer source-backed RuneWaker dungeons in the registry", () => {
const newerSourceDungeons = [
["kawaks-tomb", "scripts/runewaker-pipeline/snapshots/kawaks-tomb-zone-134.json"],
["grafu-castle", "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json"],
["sardo-castle", "scripts/runewaker-pipeline/snapshots/sardo-castle-zone-139.json"],
["tomb-seven-heroes", "scripts/runewaker-pipeline/snapshots/tomb-seven-heroes-zone-142.json"],
["aeternal-circle", "scripts/runewaker-pipeline/snapshots/aeternal-circle-zone-144.json"],
["kulech-bones-nest", "scripts/runewaker-pipeline/snapshots/kulech-bones-nest-zone-147.json"],
] as const;
for (const [id, sourceSnapshot] of newerSourceDungeons) {
const definition = DUNGEON_DEFINITIONS.find((candidate) => candidate.id === id);
expect(definition, id).toMatchObject({
expansion: "runewaker",
provenance: { sourceSnapshot },
});
}
});
it("never exposes raw database gameobject rows to the live world-object renderer", () => {
for (const dungeon of DUNGEON_DEFINITIONS) {
for (const object of dungeon.serverGameObjects) {
@@ -857,6 +879,163 @@ describe("generated dungeon campaign catalog", () => {
expect(targorr?.position).toEqual([-159.582, -25.6062, 1.2531]);
});
it("omits only the source-confirmed utility-only Stockade casts and preserves real damage", () => {
const stockades = requireDungeonDefinition("stormwind-stockade");
const blackrockDepths = requireDungeonDefinition("blackrock-depths");
const attacksFor = (dungeon: typeof stockades, entry: number) => (
dungeon.entities[`entry-${entry}`]?.combat?.attacks ?? []
);
const hasSpell = (dungeon: typeof stockades, entry: number, spellId: number) => (
attacksFor(dungeon, entry).some((attack) => attack.spellId === spellId)
);
const sourceSmartAction = (
entry: "1663" | "1666" | "1706" | "1708" | "1711" | "1715" | "1716" | "1717" | "1696",
spellId: number,
) => (
worldAi.smartScriptsByEntry[entry]?.find((row) => (
row.action_type === 11 && row.action_param1 === spellId
))
);
expect(sourceSmartAction("1666", 7164)).toMatchObject({ event_type: 0, target_type: 1 });
expect(sourceSmartAction("1708", 7165)).toMatchObject({ event_type: 1, target_type: 1 });
expect(sourceSmartAction("1715", 13730)).toMatchObject({ event_type: 0, target_type: 1 });
expect(worldAi.creatureSpellsByEntry["1711"]?.map((spell) => spell.spellId)).toContain(16244);
expect([1666, 1708, 1711, 1715].flatMap((entry) => (
attacksFor(stockades, entry).filter((attack) => [7164, 7165, 16244, 13730].includes(attack.spellId ?? 0))
))).toEqual([]);
const kamAttacks = attacksFor(stockades, 1666);
const shieldSlam = kamAttacks.find((attack) => attack.spellId === 8242);
expect(shieldSlam?.effect).toMatchObject({ kind: "control", damageMultiplier: 1.35 });
expect(kamAttacks.map((attack) => attack.spellId)).not.toContain(3419);
expect(kamAttacks.some((attack) => attack.id.endsWith("-surge"))).toBe(false);
const inmateAttacks = attacksFor(stockades, 1708);
expect(hasSpell(stockades, 1708, 6547)).toBe(true);
expect(hasSpell(stockades, 1708, 6253)).toBe(true);
expect(hasSpell(stockades, 1711, 16509)).toBe(true);
const grebmarAttacks = attacksFor(blackrockDepths, 9319);
expect(hasSpell(blackrockDepths, 9319, 21049)).toBe(true);
expect(hasSpell(blackrockDepths, 9319, 15615)).toBe(true);
expect(grebmarAttacks.some((attack) => attack.id.endsWith("-surge"))).toBe(false);
});
it("preserves pinned Stockade hostile targeting, utility omissions, and supported damage", () => {
const stockades = requireDungeonDefinition("stormwind-stockade");
const theNexus = requireDungeonDefinition("the-nexus");
const attacksFor = (entry: number) => (
stockades.entities[`entry-${entry}`]?.combat?.attacks ?? []
);
const attackFor = (entry: number, spellId: number) => (
attacksFor(entry).find((attack) => attack.spellId === spellId)
);
const sourceSmartAction = (
entry: "1663" | "1666" | "1706" | "1708" | "1711" | "1715" | "1716" | "1717" | "1696",
spellId: number,
) => (
worldAi.smartScriptsByEntry[entry]?.find((row) => (
row.action_type === 11 && row.action_param1 === spellId
))
);
expect(sourceSmartAction("1663", 11976)).toMatchObject({ target_type: 2 });
expect(sourceSmartAction("1663", 19134)).toMatchObject({ target_type: 2 });
expect(sourceSmartAction("1666", 8242)).toMatchObject({ target_type: 2 });
expect(sourceSmartAction("1716", 7964)).toMatchObject({ target_type: 1 });
expect(stockadeRecipe.clientBuild).toBe("3.3.5a-Ascension");
expect(theNexusRecipe.clientBuild).toBe("3.3.5a-Epoch");
for (const [entry, spellId] of [
[1666, 3419], [1708, 7165], [1711, 16244], [1715, 13730],
[1706, 6713], [1707, 17230], [1663, 2457], [1717, 6742],
[1716, 9128], [1716, 29651], [1696, 674], [1696, 3391],
] as const) {
expect(attackFor(entry, spellId), `entry ${entry}, spell ${spellId}`).toBeUndefined();
}
expect(attackFor(1666, 8242)).toMatchObject({
target: "primary",
delivery: "melee",
range: 5,
effect: { kind: "control", mechanic: "stun", damageMultiplier: 1.35 },
});
expect(attackFor(1711, 8242)).toMatchObject({
target: "primary",
delivery: "melee",
range: 5,
effect: { kind: "control", mechanic: "stun", damageMultiplier: 1.12 },
});
expect(attackFor(1711, 6253)).toMatchObject({
target: "primary",
delivery: "melee",
range: 5,
effect: { kind: "control", mechanic: "stun", durationMs: 2_000 },
});
expect(attackFor(1708, 6547)).toBeDefined();
expect(attackFor(1711, 16509)).toBeDefined();
expect(attackFor(1663, 11976)).toMatchObject({
target: "primary",
delivery: "melee",
range: 5,
});
expect(attackFor(1707, 15657)).toMatchObject({
target: "primary",
delivery: "melee",
range: 5,
attackKind: "melee",
});
expect(attackFor(1706, 11978)).toMatchObject({
target: "primary",
delivery: "melee",
range: 5,
effect: { kind: "control", mechanic: "interrupt", durationMs: 4_000 },
});
expect(attackFor(1717, 421)).toMatchObject({
target: "primary",
delivery: "projectile",
range: 30,
attackKind: "spell",
});
expect(attackFor(1717, 421)?.damageMultiplier).toBeGreaterThan(0);
expect(attackFor(1663, 5246)).toMatchObject({
target: "nearby-party",
range: 8,
radius: 8,
effect: { kind: "control", mechanic: "fear" },
});
expect(attackFor(1663, 19134)).toMatchObject({
target: "nearby-party",
range: 10,
radius: 8,
effect: { kind: "control", mechanic: "fear", durationMs: 4_000 },
});
expect(attackFor(1716, 7964)).toMatchObject({
target: "nearby-party",
range: 5,
radius: 5,
effect: { kind: "control", mechanic: "stun", durationMs: 4_000 },
});
const epochFrighteningShout = theNexus.entities["entry-26796"]?.combat?.attacks
.find((attack) => attack.spellId === 19134);
expect(epochFrighteningShout).toMatchObject({
target: "nearby-party",
delivery: "area",
range: 10,
radius: 8,
effect: { kind: "control", mechanic: "fear", durationMs: 6_000 },
});
const targorrAttacks = attacksFor(1696);
expect(attackFor(1696, 8599)?.effect).toMatchObject({ kind: "aura" });
expect(targorrAttacks.some((attack) => attack.id.endsWith("-surge"))).toBe(false);
});
it("starts Ragefire Chasm in the dungeon instead of the Orgrimmar portal annex", () => {
const ragefire = requireDungeonDefinition("ragefire-chasm");
const firstSpawn = ragefire.staticSpawns.find(
@@ -1,2 +1,2 @@
// Generated by npm run dungeon:catalog. Do not edit.
export default "H4sIAAAAAAACCuy9a5PjNpY2+FcY+e7GzkRIKODgXhP7wa5yt/22q+11edq70eGYgEgok1OUmEMps1zuqP++cQBSgu5USk63s9kfqp2ESNzOec4VB/+4WeR3fub+5ptFWc9vXrPRzaJ+aHJ/8/ofN7mb3bvydv7OzcupXyxvXt8UD/NbX8/H9+W9r8q5f9U96H5L/ntRz29GN37+WDb1fObny+9d/sHd+sXN67/fLJr81a2b+Ve3fu4bt/TFq2n56L+v3CffvI3f+mZ2XzfLRfelPa/M3NwtlnUz+2Kx8Kvvhxeyf1t6fN81n7Kpq6qJyz/8+83POKC8fpgvffPGLV1V3968PvrlHx7my3Lm2x93Y3G/+qZe3r2pG/+mns1KXBJR2IIVYiI1cG+YzWXOLZ2q3DGmNRheTKYT4/nm6+/DKn9b5x/2D2TvT1dLcu+rajWPf9y4Re7ncQNvCHn1rSvKD4t33/8/XxXlsm5e5VXp58tx/tA0fr585RrvxhJe3btlfjd+++rtl38qK794E3716j1+mxSTHDfxvs7v4je/9q7yzTs3H79f1o279a86Ehh/rJsPHR0sXoV3xrHLg5/+/Hl0k9ePHj908/rv/+jo6psCV+POFfXHqZvfjj94f38zupm5e2zhPNnGxc1rjX8uy2WJtMWRdJduWebv793H+eLmNaN2dNPUblbOb5FGFjevQazpu9uNvG78zefR1hiQBj6W82K8WNb5B1f4ZBhicxgyHQbbGYbl26OgPQdReFfMyrlfJH2rI0sAsLMEencJeM/eP7qyKue349w9+maejEFsbYPZGAPdHgNI2B6D7jmExv1aN1M/H39o3EOVjEAfIwTTYxUY9BzCpHL5h6lb3tWzceH9fboOZnMUamMd9M46ULYzir6U8FAVbubm6741PWsFOIUnr8DtvJ75xt+m3dut7sVxTuR6Z+qc9eXEh/kHPx8jqlcJEwbmPjwEvrP6Qm4PQZxLhUX9MeUDBvYYAcgdMrRiZxP6MuMid03ll+NZjfLJN5+SYRh7BI/4Dh1ypLqtYaiew/j1oZq6pnEf1t0DPdY9sB5wwMQ5zNjU+Yfx4r5sEmqA7a1gG6PQOwwhldoBRjh7FIW/X94lFAF8iy8YT4eh9ogosbsZsjdN3NVVPXPzPF2JbWowJ5ABgeqpA1g2CIzVLBWO2zth0/7ljnQQfAca8Au9+p+5xj0U9TwVzUdnv4MKEnZQAWhvWLj107Lx4/zOLWbJGMwxZNrVD9juDvTlhwK7n22Kxh1OgA1O2EElLXc4QfbdgMWdWy5944vxnauqhBGk2GKEDT7gu3qKNU+GxklV18V4+tDMXcoIUsCRIbAdWGTcPnkId76qAik0aAU1y42V2NKWNraD7ayEkU8W1Iuld7NH91At0+7leauwBw76amsP88I3k/o27V0dYYU96qpiT1ZXF5V79ON7P99YfH1k9rCHFXdkE++7+q7J3bJxvya9Szg2e3t66/vy4aReunmZu7Rvfkwq210VmT2Z+mc+v3Nz16S9i7OozognqwTRWBxXbvKpKefLu3QQ8jziM08HgIVf3vkPH3ZgUKpjzK/2qANPtljRczFe1rPJRv/H6D+s8Wb/zD5ZGXAP+Z2fu3KcN5/uNxBQmmOLIE7by1z3XoPbErXjxXjpm2ZTHJijQLhrM4odIFTn6IfjWd24RaobKntkAHvE0ZO1Ar/I3b0fTxs0WR+a+V1dpe4LqY7J5l1o4GZ3Q/oO5WF565rCb/lxpD4GD3s4k8HThVI7gvtyPnd5akFKfQwf+oBU31VY3vnx3P/ykDKFVsdsxx1kAP50iMTu6/yh2uzfHJt8ny3oKxjzhyr4kerpeJ/BIO1RmN51J7En+9MCOsdx1PONIdizKEHrJ2spReM+LO9cU8+3eEJRepa0svbJRrz79b/rajz3zcMk7Z8dw8cdy0nTp9LDaheq8vZuOS/nt+ko4Cxi2EeWfR1Ltw/zYsOToegxx+5u50Y/2Z/3WNbozUFkTvs3R7Zgx3CzT2UDdzf/4O78clxXxfhDOb8t6sR8VeyoP2dXZ+D8yTrDtG5ufWDI+iHVmxSHYyrDDjGKHeXV9MXGpnQVjgBBMr9zs/sy9ScoSY8sxs6WyKduyX25DMuAwJD2fowg+C43KPNk43HFlY2fVj5fbi6DOqrC7Q7kyRC9LOcffLP0+d14cVd/RI/rehgGtjCKpcPYpUx1bBT3TZ374qFx1biLE+7SZ1P/6ufjxrv8LnV+m2ceRt3c1suln48xGpR4frX5rRfk59FN4aflvESCWIRQ3W7EuNwftVuWywo//H7Vkv1lJ55XTW+7sG+B3zcYqv3l3nXhzLxyi0WZb0aU39VFIKRiOb77NGlKBNGinE7L/KFafvqba0o3X8bRhrHN62bmqo0fpU8XuD7v3OJDmE3lH331g5uHyCQzI05/bpcBXStvV1/4ZuPL7tGVlZuUVRm+3f4Z9M37xi/8HHcCZ/SPm6rO2//eszTLOz/DuX137+cYWa2KzP9y74uy5cjFw2zmGuziTyg+s+VdUz/c3mVbXxplhZ96t8zK5SJbUUjW1GgejTI3LzLcpWx55zP0WH3KXFU+enIzuqlqV5Tz23d+sQjR2ZvvG3/vmnJ+u90JIfj7h3l+55qlL75ovPurC6P/z+5ZtjtDJKzbpn6Y4/r9L2poQXF7pzGUnddV3YQGx4DRm9HN3LsmBj6n+B+g6OfRjZtNMKT8La7BxmvOTIRFvaacLzEmjrtBCcDn0c2dn5WL+zvf+NVriw+f3nRvGm1zgy6UOLjVc2rphNrtLyrxeRT8rwEqXbU7kgJybYvN99jopp5OF36JpAUjoKOx/vkzMsTSoyRahC88LOuH5ffBtRp47ua/vmT/B+YwxBZcY3z88+hmUvl5sflLCL8MDckPXYEU9Og3fyvCb7u21c/DgO5xKB/LYnmHTnqKyxenyFFTvceX5rdRIcjr+bJ+aMJH34HIgMrsjVEZYyZjAjIQOgNqMqZZ9p5zkWmWCaoyBupmdPOOmwy4zd4wajNmeMY0ZKBMBoKG195zITKrMm51pvXNz5g84DDxI6zWY7l4cNWa19u/RzfTsvItOcZn5LZCwnhoqpvXN6/iF2LaxSrr4xXn4zWQvdp4L7/z+YfFw+zm9Q3PhYfCems8Jncwh1kenjPPJQNrFJXeGTEpDOcAzObUUc2dY8JM7NRpjXxc/upRU7JGCyVGQRGZ31b+DXLqzWthgTKKuJPXVVVGMOymuHo0ppRuznSj6fwJ73s9mTe101zllHpwzE6nwuSsmIhiYqyfeEuZ5HRSWMM0TLnVEyU8s4Xmjko6LbSiybyZsGpn1kpKrlD67UyTHZ4mu2yabHeaSjGjmXSOu8I4BROrjaSWMacVU1ZKRTU3lBfADUg+ZYxOcsiBTSkUuZXpNBV6W3pPEw5PEy6bJuxOU0smQUylmTCtnGeTiZwUoAqnpS54rmkBWvJJoY02uZWSOjFx0slp7jmVbpJMEyiYM6bJD0+TXzZNvjvNCWdOiSkIkDktcimnmjEQAMA1tVNbFNJ5ZyWdUiWdkNo7k08Z1RM79bnzG7tpzpmmODxNcdk0xe402YSJqbNiAibXYmoo9TgVCsIIM5VOaCM41dRNJ3kxsXYyyb2fTlRuTT4BLjd5E86Ypjw8TXnZNOUeomXWiokzWrrpROcABqaQK+YmUhhOLaPSg3FO2sJMiomHyZQV+USqqXM58zaZprDqnN1Uh6epLpum2p0mTKdsCrmGglNdKMWokhPv9HRKc5AcB88Y80C9sIpNBJtwDlNDNYiJskwk05RGnDNNfXia+rJp6t1pFhPpNKXgcsdz6xwzU6apV9RzO1XAc2GY9LmyXikQXk4FaJ9PbFFwbu3EJNNUgoozpmkOT9NcNk2zR6AUblIozgUtFCgzUcqBds55DZRKOZl6NoHCe+WsZOAF5NSoKUz51ChhTTpNTuU5vGkPT9NeNk27O00jqaEepkznuTKoAzlv8mnBjfN8kvt8oqXkngmrpfJT6iUVxhpnteDaTVOBIiWcITfZYS2IXaYFsT1akKUw4abwIPhkMuXeTX3hhTXFZAp6OqGKs0IwS4sp11NgOaW0EGyKqgGwiV5Pk1tp6C5vasWt3TvNw1oQu0wLYvu0IDA5NxNpRK7ywjjwYMAya6XxRgvm6FQVcjJ13nglhKY8V5j9XFDvvUgFijXsHC2IHdaC2GVaENujBclcWW3RWLYTpXKtqWF+or1WHqYTwRR44ZkS1AOdUk6Bg4dcTBy1QulEoADjfB/SKm3E3mke1oLYZVoQ26MFWZZ7nedWF1qCkhOgBbOKS6UkY4WlxhoJljme525aGC+0VHSa01xxT6c8gSDBmWC7ujsi1/7tPKwGscvUILZHDVKoz04mAhUErorpBLhSKi+cM3xqKDPKMKes4WALmTvwygqu3FTBlBqr3Xo7QfM9zKk0N3LvNA+rQewyNYjtUYPohBuYcLRHFBjnJzQXTuWUOymYcT4Hz7WAiS8KmBjwNJ/YKXeFnrgpnepUDWJWnyFR2GE1iF2mBrE9apDxPqeF9XRKpbVKg4SJ4SK3Sk45qg3ST9iETifKTLzNwYGVUS0U0jK+niZXXO2ZphaW7mfOw2oQu0wNYnvUIGlyZYyX3LCJkzl4gxOlRrpC6dwLA6KwHCCfOimEormSzHsrhIAJExrWRKuM2Ae1xsD+aR5Wg9hlahDbowZpUxijnC2MZRI0zSeCCS8odxPJc+MEA5rbyVRPweYghXJWSfBQKC5zNuWraUpKrdjV9qxmsF9wHlaD2GVqENujBvGiMIzlolCCWxSaE234JM/lpNCWUQuCM8qdzk1RGId+lEkhXTEtIKd6atfeIW4U37ObWlHO900TDqtBcJkaBHvUIDGhrnDSSgpcoHThSjst+dTlspDSGW2cBV9QLq2aGjURBReSFXo6mYKhiYmijRVn6AdwWA2Cy9QLine truncated
export default "H4sIAAAAAAACCuy9a5PbRpY2+FcQ9e7GzkSQqcyTd0/sB1tyt/221fZanvZudDgmkkCyCiOQqAFZJcsd+u8bJxMgk3ewSEmtavQHtQtJIG/nPOeaJ/9xs8jv/Mz9zTeLsp7ffMVGN4v6ocn9zVf/uMnd7N6Vt/PXbl5O/WJ589VN8TC/9fV8fF/e+6qc+xfdg+635L8X9fxmdOPnj2VTz2d+vvzJ5W/drV/cfPX3m0WTv7h1M//i1s9945a+eDEtH/1PlXvvm1fxW9/P7utmuei+tOeVmZu7xbJuZl8vFn71/fBC9m9Lj++75n02dVU1cfnbf7/5DQeU1w/zpW9euqWr6tubr45++eeH+bKc+fbH3VjcH76pl3cv68a/rGezEpdEFLZghZhIDdwbZnOZc0unKneMaQ2GF5PpxHi++fqbsMo/1Pnb/QPZ+9PVktz7qlrN4x83bpH7edzAG0Je/OCK8u3i9U//z7dFuaybF3lV+vlynD80jZ8vX7jGu7GEF/dumd+NX7149c2fysovXoZfvXiD3ybFJMdNvK/zu/jN77yrfPPazcdvlnXjbv2LjgTG7+rmbUcHixfhnXHs8uCnP3wY3eT1o8cP3Xz19390dPV9gatx54r63dTNb8dvvb+/Gd3M3D22cJ5s4+LmK41/LstlibTFkXSXblnmb+7du/ni5itG7eimqd2snN8ijSxuvgKxpu9uN/K68TcfRltjQBp4V86L8WJZ529d4ZNhiM1hyHQYbGcYlm+PgvYcROFdMSvnfpH0rY4sAcDOEujdJeA9e3/nyqqc345z9+ibeTIGsbUNZmMMdHsMIGF7DLrnEBr3R91M/Xz8tnEPVTICfYwQTI9VYNBzCJPK5W+nbnlXz8aF9/fpOpjNUaiNddA760DZzij6UsJDVbiZm6/71vSsFeAUnrwCt/N65ht/m3Zvt7oXxzmR652pc9aXEx/mb/18jKheJUwYmPvwEPjO6gu5PQRxLhUW9buUDxjYYwQgd8jQip1N6MuMi9w1lV+OZzXKJ9+8T4Zh7BE84jt0yJHqtoaheg7jj4dq6prGvV13D/RY98B6wAET5zBjU+dvx4v7skmoAba3gm2MQu8whFRqBxjh7FEU/n55l1AE8C2+YDwdhtojosTuZsjeNHFXV/XMzfN0JbapwZxABgSqpw5g2SAwVrNUOG7vhE37lzvSQfAdaMAv9Op/5hr3UNTzVDQfnf0OKkjYQQWgvWHh1k/Lxo/zO7eYJWMwx5BpVz9guzvQlx8K7H62KRp3OAE2OGEHlbTc4QTZdwMWd2659I0vxneuqhJGkGKLETb4gO/qKdY8GRonVV0X4+lDM3cpI0gBR4bAdmCRcfvkIdz5qgqk0KAV1Cw3VmJLW9rYDrazEkY+WVAvlt7NHt1DtUy7l+etwh446KutPcwL30zq27R3dYQV9qirij1ZXV1U7tGP7/18Y/H1kdnDHlbckU287+q7JnfLxv2R9C7h2Ozt6a3vy4eTeunmZe7SvvkxqWx3VWT2ZOqf+fzOzV2T9i7OojojnqwSRGNxXLnJ+6acL+/SQcjziM88HQAWfnnn377dgUGpjjG/2qMOPNliRc/FeFnPJhv9H6P/sMab/TP7ZGXAPeR3fu7Kcd68v99AQGmOLYI4bS9z3XsNbkvUjhfjpW+aTXFgjgLhrs0odoBQnaMfjmd14xapbqjskQHsEUdP1gr8Inf3fjxt0GR9aOZ3dZW6L6Q6Jpt3oYGb3Q3pO5SH5a1rCr/lx5H6GDzs4UwGTxdK7Qjuy/nc5akFKfUxfOgDUn1XYXnnx3P/+0PKFFodsx13kAH40yESu6/zh2qzf3Ns8n22oK9gzB+q4Eeqp+N9BoO0R2F6153EnuxPC+gcx1HPN4Zgz6IErZ+spRSNe7u8c0093+IJRelZ0sraJxvx7o//rqvx3DcPk7R/dgwfdywnTZ9KD6tdqMrbu+W8nN+mo4CziGEfWfZ1LN0+zIsNT4aixxy7u50b/WR/3mNZozcHkTnt3xzZgh3DzT6VDdzd/K2788txXRXjt+X8tqgT81Wxo/6cXZ2B8yfrDNO6ufWBIeuHVG9SHI6pDDvEKHaUV9MXG5vSVTgCBMn8zs3uy9SfoCQ9shg7WyKfuiX35TIsAwJD2vsxguC73KDMk43HFVc2flr5fLm5DOqoCrc7kCdD9LKcv/XN0ud348Vd/Q49ruthGNjCKJYOY5cy1bFR3Dd17ouHxlXjLk64S59N/Yefjxvv8rvU+W0+8TDq5rZeLv18jNGgxPOrzcdekN9GN4WflvMSCWIRQnW7EeNyf9RuWS4r/PCbVUv2l514XjW97cK+BX7fYKj293vXhTPzyi0WZb4ZUX5dF4GQiuX47v2kKRFEi3I6LfOHavn+b64p3XwZRxvGNq+bmas2fpQ+XeD6vHaLt2E2lX/01c9uHiKTzIw4/a1dBnStvFp94fuNL7tHV1ZuUlZl+Hb7Z9A37xu/8HPcCZzRP26qOm//e8/SLO/8DOf2472fY2S1KjL/+70vypYjFw+zmWuwiz+h+MyWd039cHuXbX1plBV+6t0yK5eLbEUhWVOjeTTK3LzIcJey5Z3P0GP1PnNV+ejJzeimql1Rzm9f+8UiRGdvfmr8vWvK+e12J4Tg7x/m+Z1rlr74uvHury6M/j+7Z9nuDJGwbpv6YY7r97+ooQXF7Z3GUHZeV3UTGhwDRm9GN3Pvmhj4nOJ/gKIfRjduNsGQ8g+4BhuvOTMRFvWacr7EmDjuBiUAH0Y3d35WLu7vfONXry3evn/ZvWm0zQ26UOLgVs+ppRNqt7+oxIdR8L8GqHTV7kgKyLUtNt9jo5t6Ol34JZIWjICOxvq3D8gQS4+SaBG+8LCsH5Y/Bddq4Lmb//qG/R+YwxBbcI3x8W+jm0nl58XmLyH8MjQkP3QFUtCj3/ytCL/t2lY/DwO6x6G8K4vlHTrpKS5fnCJHTfUeX5rfRoUgr+fL+qEJH30NIgMqs5dGZYyZjAnIQOgMqMmYZtkbzkWmWSaoyhiom9HNa24y4DZ7yajNmOEZ05CBMhkIGl57w4XIrMq41ZnWN79h8oDDxI+wWo/l4sFVa15v/x7dTMvKt+QYn5HbCgnjoaluvrp5Eb8Q0y5WWR8vOB+vgezFxnv5nc/fLh5mN1/d8Fx4KKy3xmNyB3OY5eE581wysEZR6Z0Rk8JwDsBsTh3V3DkmzMROndbIx+UfHjUla7RQYhQUkflt5V8ip958JSxQRhF38rqqygiG3RRXj8aU0s2ZbjSdP+F9ryfzpnaaq5xSD47Z6VSYnBUTUUyM9RNvKZOcTgprmIYpt3qihGe20NxRSaeFVjSZNxNW7cxaSckVSr+dabLD02SXTZPtTlMpZjSTznFXGKdgYrWR1DLmtGLKSqmo5obyArgByaeM0UkOObAphSK3Mp2mQm9L72nC4WnCZdOE3WlqySSIqTQTppXzbDKRkwJU4bTUBc81LUBLPim00Sa3UlInJk46Oc09p9JNkmkCBXPGNPnhafLLpsl3pznhzCkxBQEyp0Uu5VQzBgIAuKZ2aotCOu+spFOqpBNSe2fyKaN6Yqc+d35jN8050xSHpykum6bYnSabMDF1VkzA5FpMDaUep0JBGGGm0gltBKeauukkLybWTia599OJyq3JJ8DlJm/CGdOUh6cpL5um3EO0zFoxcUZLN53oHMDAFHLF3EQKw6llVHowzklbmEkx8TCZsiKfSDV1LmfeJtMUVp2zm+rwNNVl01S704TplE0h11BwqgulGFVy4p2eTmkOkuPgGWMeqBdWsYlgE85haqgGMVGWiWSa0ohzpqkPT1NfNk29O81iIp2mFFzueG6dY2bKNPWKem6nCnguDJM+V9YrBcLLqQDt84ktCs6tnZhkmkpQccY0zeFpmsumafYIlMJNCsW5oIUCZSZKOdDOOa+BUiknU88mUHivnJUMvICcGjWFKZ8aJaxJp8mpPIc37eFp2sumaXenaSQ11MOU6TxXBnUg500+Lbhxnk9yn0+0lNwzYbVUfkq9pMJY46wWXLtpKlCkhDPkJjusBbHLtCC2RwuyFCbcFB4En0ym3LupL7ywpphMQU8nVHFWCGZpMeV6CiynlBaCTVE1ADbR62lyKw3d5U2tuLV7p3lYC2KXaUFsnxYEJudmIo3IVV4YBx4MWGatNN5owRydqkJOps4br4TQlOcKs58L6r0XqUCxhp2jBbHDWhC7TAtie7QgmSurLRrLdqJUrjU1zE+018rDdCKYAi88U4J6oFPKKXDwkIuJo1YonQgUYJzvQ1qljdg7zcNaELtMC2J7tCDLcq/z3OpCS1ByArRgVnGplGSssNRYI8Eyx/PcTQvjhZaKTnOaK+7plCcQJDgTbFd3R+Tav52H1SB2mRrE9qhBCvXZyUSggsBVMZ0AV0rlhXOGTw1lRhnmlDUcbCFzB15ZwZWbKphSY7Vbbydovoc5leZG7p3mYTWIXaYGsT1qEJ1wAxOO9ogC4/yE5sKpnHInBTPO5+C5FjDxRQETA57mEzvlrtATN6VTnapBzOozJAo7rAaxy9QgtkcNMt7ntLCeTqm0VmmQMDFc5FbJKUe1QfoJm9DpRJmJtzk4sDKqhUJaxtfT5IqrPdPUwtL9zHlYDWKXqUFsjxokTa6M8ZIbNnEyB29wotRIVyide2FAFJYD5FMnhVA0V5J5b4UQMGFCw5polRH7oNYY2D/Nw2oQu0wNYnvUIG0KY5SzhbFMgqb5RDDhBeVuInlunGBAczuZ6inYHKRQzioJHgrFZc6mfDVNSakVu9qe1Qz2C87DahC7TA1ie9QgXhSGsVwUSnCLQnOiDZ/kuZwU2jJqQXBGudO5KQrj0I8yKaQrpgXkVE/t2jvEjeJ7dlMryvm+acJhNQguU4NgjxokJtQVTlpJgQuULlxppyWfulwWUjqjjbPgC8qlVVOjJqLgQrJCTydTMDQxUbSx4gz9AA6rQXC Line truncated
@@ -1 +1 @@
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
+12 -1
View File
@@ -51,6 +51,7 @@ import { romPriestChampionCooldownOverride } from "./romPriestChampionOverrides"
import { applyTalentModifierValue, talentModifiersForRanks } from "./talentModifiers";
import { romWarlockMageSoulBrandStingHasWillpowerBladeState } from "./romWarlockMageSoulBrandSting";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { romBloodArrowCastCosts } from "./romBloodArrow";
export interface PartyAbilityContext {
readonly now: number;
@@ -281,7 +282,7 @@ export function partyAbilityCosts(member: PartyMember, ability: AbilityDefinitio
const mageClearcasting = member.classId === "mage" && auras.some((aura) => (
aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_CONCENTRATION_TAG)
));
return (adjustedAbility.costs?.length ? adjustedAbility.costs : [adjustedAbility.cost]).map((cost) => {
const resolved = (adjustedAbility.costs?.length ? adjustedAbility.costs : [adjustedAbility.cost]).map((cost) => {
const baseMana = cost.percentageBasis === "base-mana"
? adjustedAbility.source === "runewaker" && member.statRuleset === "rom"
? deriveCharacterStats(
@@ -307,6 +308,16 @@ export function partyAbilityCosts(member: PartyMember, ability: AbilityDefinitio
: cost.resource !== "health" && mageClearcasting ? 0 : Math.max(0, Math.round(adjusted)),
};
});
const bloodArrowCosts = romBloodArrowCastCosts(
ability,
member,
auras,
member.id,
member.maxHealth,
companionResourceMaximum(member, "mana"),
now,
);
return [...resolved, ...bloodArrowCosts];
}
export function partyAbilityIsReady(
+57 -5
View File
@@ -112,6 +112,14 @@ import {
romWarlockMageSoulBrandEffects,
} from "./romWarlockMageSoulBrand";
import { ROM_WARLOCK_PRIEST_SPREADING_PAIN_CRIT_TAG } from "./romWarlockPriestSpreadingPain";
import {
hasOwnedRomScoutBloodArrow,
isRomBloodArrowOwner,
isRomScoutRogueBloodArrowOwner,
isRomScoutBloodArrowAbility,
ROM_SCOUT_BLOOD_ARROW_AURA_TAG,
romScoutRogueBloodArrowRangedAutoAttackCost,
} from "./romBloodArrow";
import {
ROM_ROGUE_POISON_AURA_TAG,
ROM_ROGUE_POISON_ATTACK_SPEED_TAG,
@@ -2319,6 +2327,21 @@ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: nu
}
if (now < (nextWeaponAttackAt.get(member.id) ?? 0)) return member;
nextWeaponAttackAt.set(member.id, now + memberSwingMs(member, now));
const scoutRogueRangedAutoAttack = isRomScoutRogueBloodArrowOwner(member)
&& partyUsesRangedAttacks(member);
const bloodArrowAutoAttackCost = scoutRogueRangedAutoAttack
? romScoutRogueBloodArrowRangedAutoAttackCost(
member,
useCombatStore.getState().auras,
member.id,
member.maxHealth,
now,
)
: 0;
if (bloodArrowAutoAttackCost > 0 && member.health <= bloodArrowAutoAttackCost) return member;
if (bloodArrowAutoAttackCost > 0) {
member = { ...member, health: member.health - bloodArrowAutoAttackCost };
}
const abilityId = "party-auto-attack";
const scentRank = member.classId === "death-knight"
? member.talentRanks[WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID] ?? 0
@@ -2351,7 +2374,20 @@ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: nu
? "rom-rogue-dual-wield-main-hand"
: forgeAutoAttackActive ? "rom-champion-forge-auto-attack" : "scent-of-blood-auto-attack"}`;
useCombatStore.getState().notifyOwnerHostileAction(member.id, targetId);
const ownerAttackDamage = partyAttackDamage(member, now);
const baseOwnerAttackDamage = partyAttackDamage(member, now);
const bloodArrowRangedAuras = scoutRogueRangedAutoAttack
? activeAuras.filter((aura) => aura.sourceId === member.id
&& aura.targetId === member.id
&& (aura.expiresAt === null || aura.expiresAt > now)
&& aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG))
: [];
const ownerAttackDamage = bloodArrowRangedAuras.length > 0
? Math.max(1, Math.round(resolveAuraDamageValue(baseOwnerAttackDamage, bloodArrowRangedAuras, {
direction: "dealt",
school: "physical",
attackKind: "ranged",
})))
: baseOwnerAttackDamage;
const result = useCombatStore.getState().damageMob(targetId, ownerAttackDamage, now,
{ actorId: member.id, role: member.role, abilityId }, "physical", needsAttackRoll ? {
attackKind: goForTheThroatRank > 0 ? "ranged" : "melee",
@@ -5166,8 +5202,12 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
if (member.activeCast) {
const activeCast = member.activeCast;
const catalogAbility = abilityById(member.activeCast.abilityId);
const castingAbility = catalogAbility
? learnedAbilityForBuild(catalogAbility, member.level, member.talentRanks)
const ownerAbility = catalogAbility && isRomScoutBloodArrowAbility(catalogAbility)
? abilitiesForCharacter(member.classId, member.secondaryClassId)
.find((ability) => ability.id === catalogAbility.id) ?? catalogAbility
: catalogAbility;
const castingAbility = ownerAbility
? learnedAbilityForBuild(ownerAbility, member.level, member.talentRanks)
: null;
const castTargetId = member.activeCastTargetId;
if (!castingAbility || !castTargetId) {
@@ -5574,14 +5614,26 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
&& aura.definition.id === effect.aura.id && aura.targetId === abilityTargetId));
},
});
const selectedAbility = selectedDecision?.ability ?? null;
const bloodArrowAbility = isRomBloodArrowOwner(runtimeMember)
? abilitiesForCharacter(runtimeMember.classId, runtimeMember.secondaryClassId)
.find(isRomScoutBloodArrowAbility) ?? null
: null;
const shouldActivateBloodArrow = Boolean(bloodArrowAbility
&& !hasOwnedRomScoutBloodArrow(currentCombat.auras, runtimeMember.id, now)
&& partyAbilityIsReady(runtimeMember, bloodArrowAbility, now)
&& partyAbilityTargetIsValid(runtimeMember, bloodArrowAbility, runtimeMember.id, undefined, now));
const selectedAbility = shouldActivateBloodArrow
? bloodArrowAbility
: selectedDecision?.ability ?? null;
if (!selectedAbility) {
const inAttackRange = targetVisible && targetPosition
&& distanceSquared(memberPosition, targetPosition) <= partyAttackRange(member) ** 2;
nextMembers[index] = { ...runtimeMember, status: inAttackRange ? desiredStatus : "advancing", nextActionAt: now + 250 };
continue;
}
const abilityTargetId = selectedDecision?.targetId
const abilityTargetId = shouldActivateBloodArrow
? member.id
: selectedDecision?.targetId
?? (selectedAbility.target === "self" || selectedAbility.target === "friendly" ? member.id : memberTargetId);
const inRange = (selectedAbility.target === "self" || targetVisible)
&& partyAbilityTargetIsValid(runtimeMember, selectedAbility, abilityTargetId, undefined, now);
+2 -1
View File
@@ -37,6 +37,7 @@ import { romScoutPriestScreenAttackCatalogOverride } from "./romScoutPriestScree
import { romScoutPriestDrumsOfWarCatalogOverride } from "./romScoutPriestOverrides";
import { romPriestScoutCuringShotCatalogOverride } from "./romPriestScoutCuringShot";
import { romWarlockWarriorEnragedCoaAdditionalAbilities } from "./romWarlockWarriorEnragedCoaMechanics";
import { ROM_SCOUT_BLOOD_ARROW_AURA_TAG } from "./romBloodArrow";
import {
ROM_WARLOCK_MAGE_SOUL_BRAND_STING_COST,
ROM_WARLOCK_MAGE_SOUL_BRAND_STING_DESCRIPTION,
@@ -1168,7 +1169,7 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
if (row.sourceSkillId === 490434) {
return translated.map((effect) => effect.kind === "apply-aura" ? {
...effect,
aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), "rom-self-health-drain"] },
aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), ROM_SCOUT_BLOOD_ARROW_AURA_TAG] },
} : effect);
}
if (row.sourceSkillId === 498770) {
+187
View File
@@ -0,0 +1,187 @@
import type { AbilityDefinition, ResourceType } from "./abilityCatalog";
import type { ActiveAura } from "./combatAuras";
export const ROM_SCOUT_BLOOD_ARROW_ABILITY_ID = "rom-scout-general-4";
export const ROM_SCOUT_BLOOD_ARROW_SOURCE_SKILL_ID = 490434;
export const ROM_SCOUT_BLOOD_ARROW_AURA_TAG = "rom-self-health-drain";
export const ROM_SCOUT_BLOOD_ARROW_DEACTIVATION_COOLDOWN_MS = 10_000;
export interface RomBloodArrowOwnerBuild {
readonly classId: string;
readonly secondaryClassId?: string | null;
}
export function isRomScoutBloodArrowAbility(ability: AbilityDefinition): boolean {
return ability.source === "runewaker"
&& ability.id === ROM_SCOUT_BLOOD_ARROW_ABILITY_ID
&& ability.classId === "rom-scout"
&& ability.secondaryClassId === undefined
&& ability.dbcSpellId === ROM_SCOUT_BLOOD_ARROW_SOURCE_SKILL_ID;
}
export function isRomKnightScoutBloodArrowOwner(owner: RomBloodArrowOwnerBuild): boolean {
return owner.classId === "rom-knight" && owner.secondaryClassId === "rom-scout";
}
export function isRomScoutRogueBloodArrowOwner(owner: RomBloodArrowOwnerBuild): boolean {
return owner.classId === "rom-scout" && owner.secondaryClassId === "rom-rogue";
}
export function isRomBloodArrowOwner(owner: RomBloodArrowOwnerBuild): boolean {
return isRomKnightScoutBloodArrowOwner(owner) || isRomScoutRogueBloodArrowOwner(owner);
}
function bloodArrowDescriptionForOwner(owner: RomBloodArrowOwnerBuild): string {
if (isRomKnightScoutBloodArrowOwner(owner)) {
return "Activates Blood Arrow and increases ranged weapon damage. Each offensive ranged weapon skill costs 2.0% HP and 1.0% MP. Right-click the buff icon or use the skill again to deactivate; the skill enters a 10-second cooldown after deactivation.";
}
if (isRomScoutRogueBloodArrowOwner(owner)) {
return "Activates Blood Arrow and increases physical ranged weapon damage. Ranged attacks cost 2.0% HP each. Right-click the buff icon or use the skill again to deactivate; the skill enters a 10-second cooldown after deactivation.";
}
return "Activates Blood Arrow and increases physical ranged weapon damage. Right-click the buff icon or use the skill again to deactivate; the skill enters a 10-second cooldown after deactivation.";
}
function indefiniteBloodArrowEffects(effects: AbilityDefinition["effects"]): AbilityDefinition["effects"] {
const auraEffect = effects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster");
if (auraEffect?.kind !== "apply-aura") return effects;
return [{
...auraEffect,
aura: {
...auraEffect.aura,
durationMs: null,
utilityTags: auraEffect.aura.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG)
? auraEffect.aura.utilityTags
: [...(auraEffect.aura.utilityTags ?? []), ROM_SCOUT_BLOOD_ARROW_AURA_TAG],
},
}];
}
export function romBloodArrowAbilityForOwner(
ability: AbilityDefinition,
owner: RomBloodArrowOwnerBuild,
): AbilityDefinition {
if (!isRomScoutBloodArrowAbility(ability) || !isRomBloodArrowOwner(owner)) return ability;
const description = bloodArrowDescriptionForOwner(owner);
return {
...ability,
description,
cooldownMs: 0,
effects: indefiniteBloodArrowEffects(ability.effects),
ranks: ability.ranks?.map((rank) => ({
...rank,
description,
cooldownMs: 0,
effects: indefiniteBloodArrowEffects(rank.effects),
})),
};
}
export function isRomKnightScoutOffensiveRangedWeaponSkill(
ability: AbilityDefinition,
owner: RomBloodArrowOwnerBuild,
): boolean {
if (!isRomKnightScoutBloodArrowOwner(owner)
|| ability.source !== "runewaker"
|| ability.passive
|| ability.dbcSpellId === ROM_SCOUT_BLOOD_ARROW_SOURCE_SKILL_ID
|| ability.dbcSpellId === 492589 // Autoshot is an ongoing mode, not a cast trigger.
|| ability.sourceAttackKind === "melee"
|| !ability.effects.some((effect) => effect.kind === "damage" || effect.kind === "dot")) return false;
const scoutSkill = ability.classId === "rom-scout"
&& ability.secondaryClassId === undefined
&& ability.romSkillGroup === "general";
const knightScoutElite = ability.classId === "rom-knight" && ability.secondaryClassId === "rom-scout";
return (scoutSkill || knightScoutElite)
&& (/ranged weapon DPS/i.test(ability.description) || ability.dbcSpellId === 490420);
}
export function isRomScoutRogueRangedAttackSkill(
ability: AbilityDefinition,
owner: RomBloodArrowOwnerBuild,
): boolean {
if (!isRomScoutRogueBloodArrowOwner(owner)
|| ability.source !== "runewaker"
|| ability.passive
|| ability.dbcSpellId === ROM_SCOUT_BLOOD_ARROW_SOURCE_SKILL_ID
|| ability.dbcSpellId === 492589 // Autoshot is an ongoing mode, not a cast trigger.
|| ability.sourceAttackKind === "melee"
|| !ability.effects.some((effect) => effect.kind === "damage" || effect.kind === "dot")) return false;
const scoutGeneralSkill = ability.classId === "rom-scout"
&& ability.secondaryClassId === undefined
&& (ability.romSkillGroup === "general" || ability.romSkillGroup === "primary");
return scoutGeneralSkill
&& (/ranged weapon DPS/i.test(ability.description) || ability.dbcSpellId === 490420);
}
export function romBloodArrowCastCosts(
ability: AbilityDefinition,
owner: RomBloodArrowOwnerBuild,
auras: readonly ActiveAura[],
ownerId: string,
maxHealth: number,
maxMana: number,
now?: number,
): readonly { readonly resource: ResourceType; readonly amount: number }[] {
if (!isRomBloodArrowOwner(owner)
|| !auras.some((aura) => aura.sourceId === ownerId
&& aura.targetId === ownerId
&& (now === undefined || aura.expiresAt === null || aura.expiresAt > now)
&& aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG))) return [];
if (isRomKnightScoutBloodArrowOwner(owner)
&& isRomKnightScoutOffensiveRangedWeaponSkill(ability, owner)) {
return [
{ resource: "health", amount: Math.max(0, Math.round(maxHealth * 0.02)) },
{ resource: "mana", amount: Math.max(0, Math.round(maxMana * 0.01)) },
];
}
if (isRomScoutRogueBloodArrowOwner(owner)
&& isRomScoutRogueRangedAttackSkill(ability, owner)) {
return [{ resource: "health", amount: Math.max(0, Math.round(maxHealth * 0.02)) }];
}
return [];
}
export function romScoutRogueBloodArrowRangedAutoAttackCost(
owner: RomBloodArrowOwnerBuild,
auras: readonly ActiveAura[],
ownerId: string,
maxHealth: number,
now: number,
): number {
return isRomScoutRogueBloodArrowOwner(owner)
&& hasOwnedRomScoutBloodArrow(auras, ownerId, now)
? Math.max(0, Math.round(maxHealth * 0.02))
: 0;
}
export function hasOwnedRomScoutBloodArrow(
auras: readonly ActiveAura[],
ownerId: string,
now: number,
): boolean {
return auras.some((aura) => aura.sourceId === ownerId
&& aura.targetId === ownerId
&& (aura.expiresAt === null || aura.expiresAt > now)
&& aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG));
}
export function isOwnedRomScoutBloodArrowAura(aura: ActiveAura, ownerId: string): boolean {
return aura.sourceId === ownerId
&& aura.targetId === ownerId
&& aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG) === true;
}
export function deactivateRomBloodArrowFromAuraContextMenu(
aura: ActiveAura,
ownerId: string,
deactivate: (ownerId: string, now?: number) => boolean,
now?: number,
): boolean {
return isOwnedRomScoutBloodArrowAura(aura, ownerId)
&& deactivate(ownerId, now);
}
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { abilitiesForCharacter, abilityAtLevel } from "./abilityCatalog";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { commitPartyAbilityUse, partyAbilityCosts, partyAbilityIsReady } from "./partyAbilityAi";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
clearPartyRuntimeRegistry,
@@ -9,7 +11,8 @@ import {
} from "./partyRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { abilityDamageSchool, useCombatStore } from "./combatStore";
import { abilityDamageSchool, resolvedAbilityCosts, useCombatStore } from "./combatStore";
import { deactivateRomBloodArrowFromAuraContextMenu } from "./romBloodArrow";
const BLOOD_ARROW_ID = "rom-scout-general-4";
const HOLY_STRIKE_ID = "rom-knight-general-1";
@@ -49,18 +52,26 @@ function runPlayerDamageComparison(passiveActive: boolean) {
function companionWithBuild(
seed: string,
name: string,
classId: "rom-knight" | "rom-mage",
classId: "rom-knight" | "rom-mage" | "rom-scout",
statRuleset: "rom" | "wow" = "rom",
) {
const secondaryClassId = classId === "rom-knight" ? "rom-scout"
: classId === "rom-scout" ? "rom-rogue" : undefined;
const specialization = classId === "rom-knight" ? "Knight/Scout"
: classId === "rom-scout" ? "Scout/Rogue" : "Mage";
const specializationId = classId === "rom-knight" ? "rom-knight:rom-scout"
: classId === "rom-scout" ? "rom-scout:rom-rogue" : "rom-mage";
const talentTreeId = classId === "rom-knight" ? "rom-knight-rom-scout"
: classId === "rom-scout" ? "rom-scout-rom-rogue" : "rom-mage";
return createConfiguredPartyMember({
name,
classId,
statRuleset,
...(classId === "rom-knight" ? { secondaryClassId: "rom-scout" as const } : {}),
...(secondaryClassId ? { secondaryClassId } : {}),
raceId: "rom-human",
specialization: classId === "rom-knight" ? "Knight/Scout" : "Mage",
specializationId: classId === "rom-knight" ? "rom-knight:rom-scout" : "rom-mage",
talentTreeId: classId === "rom-knight" ? "rom-knight-rom-scout" : "rom-mage",
specialization,
specializationId,
talentTreeId,
role: "damage",
}, 79, 0, seed);
}
@@ -74,7 +85,7 @@ function runOwnedHolyStrikeComparison(useBloodPunishment: boolean, statRuleset:
registerPartyRuntimePosition(owner.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
const bloodArrow = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490434)!;
const bloodArrow = abilitiesForCharacter("rom-knight", "rom-scout").find((ability) => ability.dbcSpellId === 490434)!;
const sourceAura = bloodArrow.effects.find((effect) => effect.kind === "apply-aura"
&& effect.recipient === "caster" && effect.aura.utilityTags?.includes(BLOOD_ARROW_TAG));
if (sourceAura?.kind !== "apply-aura") throw new Error("Blood Arrow's source aura is missing.");
@@ -139,6 +150,42 @@ describe("Knight/Scout Blood Punishment current-CoA runtime", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", raceId: "rom-human", level: 79, role: "damage" });
});
it("limits current toggle semantics to verified Scout/Rogue and Knight/Scout owners", () => {
const base = ROM_ALL_ABILITIES.find((ability) => ability.id === BLOOD_ARROW_ID)!;
const verified = abilitiesForCharacter("rom-scout", "rom-rogue").find((ability) => ability.id === BLOOD_ARROW_ID)!;
const unverified = abilitiesForCharacter("rom-scout", "rom-warrior").find((ability) => ability.id === BLOOD_ARROW_ID)!;
const verifiedRank = abilityAtLevel(verified, 79);
const unverifiedRank = abilityAtLevel(unverified, 79);
const verifiedAura = verified.effects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster");
const unverifiedAura = unverified.effects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster");
const verifiedRankAura = verifiedRank.effects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster");
const unverifiedRankAura = unverifiedRank.effects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster");
expect(verified).not.toBe(base);
expect(verified.cooldownMs).toBe(0);
expect(verified.effects.some((effect) => effect.kind === "dot")).toBe(false);
expect(verifiedAura).toMatchObject({ kind: "apply-aura", aura: { durationMs: null } });
expect(verifiedRank.cooldownMs).toBe(0);
expect(verifiedRank.effects.some((effect) => effect.kind === "dot")).toBe(false);
expect(verifiedRank.description).toContain("Ranged attacks cost 2.0% HP each");
expect(verifiedRankAura).toMatchObject({ kind: "apply-aura", aura: { durationMs: null } });
expect(unverified).toBe(base);
expect(unverified.effects.some((effect) => effect.kind === "dot")).toBe(true);
expect(unverifiedAura).toMatchObject({ kind: "apply-aura", aura: { durationMs: 30_000 } });
expect(unverifiedRank.effects.some((effect) => effect.kind === "dot")).toBe(true);
expect(unverifiedRank.cooldownMs).toBeGreaterThan(0);
expect(unverifiedRankAura).toMatchObject({ kind: "apply-aura", aura: { durationMs: 30_000 } });
useCombatStore.getState().initializeCharacter({
classId: "rom-scout", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 79, role: "damage",
});
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 1_000)).toMatchObject({ ok: true });
const legacyReadyAt = useCombatStore.getState().cooldowns[BLOOD_ARROW_ID];
expect(useCombatStore.getState().deactivateRomScoutBloodArrow(PLAYER_AGGRO_ID, 1_000)).toBe(false);
expect(useCombatStore.getState().cooldowns[BLOOD_ARROW_ID]).toBe(legacyReadyAt);
expect(bloodArrowAura(PLAYER_AGGRO_ID)).toBeDefined();
});
it("resolves the player's 15% received healing and Holy-only 5% damage rider", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 79, role: "tank",
@@ -151,7 +198,7 @@ describe("Knight/Scout Blood Punishment current-CoA runtime", () => {
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 1_000)).toMatchObject({ ok: true });
const liveAura = bloodArrowAura(PLAYER_AGGRO_ID);
expect(liveAura?.expiresAt).toBe(31_000);
expect(liveAura?.expiresAt).toBeNull();
const heal = useCombatStore.getState().healActor(PLAYER_AGGRO_ID, 100, {
sourceActorId: "test-healer", abilityId: "blood-punishment-heal-test", school: "holy", now: 2_000,
});
@@ -176,23 +223,211 @@ describe("Knight/Scout Blood Punishment current-CoA runtime", () => {
expect(upgraded.physical.rawAmount).toBe(plain.physical.rawAmount);
});
it("ends the player's rider when the actual Blood Arrow marker is canceled", () => {
it("ends the player's rider on the Blood Arrow toggle and starts its owner-local cooldown", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 79, role: "tank",
});
useCombatStore.setState({ health: useCombatStore.getState().maxHealth - 1_000 });
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 1_000)).toMatchObject({ ok: true });
expect(bloodArrowAura(PLAYER_AGGRO_ID)).toBeDefined();
const activeArrow = bloodArrowAura(PLAYER_AGGRO_ID);
expect(activeArrow).toBeDefined();
const unrelatedBuff = {
...activeArrow!,
instanceId: "unrelated-player-buff",
definition: {
...activeArrow!.definition,
utilityTags: activeArrow!.definition.utilityTags?.filter((tag) => tag !== BLOOD_ARROW_TAG),
},
};
expect(deactivateRomBloodArrowFromAuraContextMenu(
unrelatedBuff,
PLAYER_AGGRO_ID,
useCombatStore.getState().deactivateRomScoutBloodArrow,
2_000,
)).toBe(false);
expect(bloodArrowAura(PLAYER_AGGRO_ID)).toBe(activeArrow);
useCombatStore.getState().consumeActorAuraTag(PLAYER_AGGRO_ID, BLOOD_ARROW_TAG);
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 2_000)).toMatchObject({ ok: true });
expect(bloodArrowAura(PLAYER_AGGRO_ID)).toBeUndefined();
expect(useCombatStore.getState().cooldowns[BLOOD_ARROW_ID]).toBe(12_000);
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 3_000)).toMatchObject({
ok: false,
reason: "cooldown",
});
const afterCancellation = useCombatStore.getState().healActor(PLAYER_AGGRO_ID, 100, {
sourceActorId: "test-healer", abilityId: "blood-punishment-cancel-test", school: "holy", now: 2_000,
});
expect(afterCancellation.rawAmount).toBe(101); // Knight's independent Devotion stays; the +15% rider ends.
});
it.each(["rom", "wow"] as const)("does not bind the verified player's legacy activation or idle health drain in %s compatibility", (statRuleset) => {
useCombatStore.getState().initializeCharacter({
classId: "rom-scout", secondaryClassId: "rom-rogue", statRuleset,
raceId: "rom-human", level: 79, role: "damage",
});
const before = useCombatStore.getState();
const bloodArrow = abilitiesForCharacter("rom-scout", "rom-rogue").find((ability) => ability.id === BLOOD_ARROW_ID)!;
expect(resolvedAbilityCosts(bloodArrow, [], { ...before, now: 1_000 }))
.not.toEqual(expect.arrayContaining([expect.objectContaining({ resource: "health" })]));
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 1_000)).toMatchObject({ ok: true });
const activeHealth = useCombatStore.getState().health;
useCombatStore.getState().tick(31, 32_001);
expect(bloodArrowAura(PLAYER_AGGRO_ID)?.expiresAt).toBeNull();
expect(useCombatStore.getState().health).toBeGreaterThanOrEqual(activeHealth);
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "damage"
&& event.abilityId === BLOOD_ARROW_ID
&& event.sourceActorId === PLAYER_AGGRO_ID
&& event.targetActorId === PLAYER_AGGRO_ID)).toBe(false);
});
it.each(["rom", "wow"] as const)("charges a committed ranged skill cast once in %s player compatibility", (statRuleset) => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", statRuleset,
raceId: "rom-human", level: 79, role: "damage",
});
const initial = useCombatStore.getState();
const manaBefore = initial.resourcePools.mana ?? initial.resource;
useCombatStore.getState().registerMob("blood-arrow-cost-player", {
name: "Blood Arrow Cost Target", level: 1, maxHealth: 1_000_000, xpReward: 0,
});
setMobPosition("blood-arrow-cost-player", [3, 0, 0]);
useCombatStore.getState().selectMob("blood-arrow-cost-player");
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 1_000)).toMatchObject({ ok: true });
const activated = useCombatStore.getState();
expect(activated.health).toBeGreaterThanOrEqual(initial.health);
expect(activated.resourcePools.mana ?? activated.resource).toBe(manaBefore);
const healthBeforeShot = activated.health;
const manaBeforeShot = activated.resourcePools.mana ?? activated.resource;
const arrowCostHp = Math.round(activated.maxHealth * 0.02);
const arrowCostMana = Math.round(activated.maxResource * 0.01);
const shot = ROM_ALL_ABILITIES.find((ability) => ability.id === SCOUT_SHOT_ID)!;
const ordinaryShotCosts = resolvedAbilityCosts(shot, [], { ...activated, now: 3_000 });
const ordinaryHealthCost = ordinaryShotCosts.find((cost) => cost.resource === "health")?.amount ?? 0;
const ordinaryManaCost = ordinaryShotCosts.find((cost) => cost.resource === "mana")?.amount ?? 0;
expect(useCombatStore.getState().castAbility(SCOUT_SHOT_ID, undefined, 3_000)).toMatchObject({ ok: true });
const committed = useCombatStore.getState();
expect(committed.health).toBe(healthBeforeShot - ordinaryHealthCost - arrowCostHp);
expect(committed.resourcePools.mana ?? committed.resource).toBe(manaBeforeShot - ordinaryManaCost - arrowCostMana);
expect(bloodArrowAura(PLAYER_AGGRO_ID)?.expiresAt).toBeNull();
});
it.each(["rom", "wow"] as const)("charges Scout/Rogue ranged skill casts 2%% HP and no added mana in %s compatibility", (statRuleset) => {
useCombatStore.getState().initializeCharacter({
classId: "rom-scout", secondaryClassId: "rom-rogue", statRuleset,
raceId: "rom-human", level: 79, role: "damage",
});
const initial = useCombatStore.getState();
const initialFocus = initial.resourcePools.focus ?? initial.resource;
const initialMana = initial.resourcePools.mana ?? 0;
useCombatStore.getState().registerMob("scout-rogue-blood-arrow-cost-player", {
name: "Scout/Rogue Blood Arrow Cost Target", level: 1, maxHealth: 1_000_000, xpReward: 0,
});
setMobPosition("scout-rogue-blood-arrow-cost-player", [3, 0, 0]);
useCombatStore.getState().selectMob("scout-rogue-blood-arrow-cost-player");
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 1_000)).toMatchObject({ ok: true });
const activated = useCombatStore.getState();
expect(activated.health).toBeGreaterThanOrEqual(initial.health);
expect(activated.resourcePools.focus ?? activated.resource).toBe(initialFocus);
expect(activated.resourcePools.mana ?? 0).toBe(initialMana);
const healthBeforeShot = activated.health;
const focusBeforeShot = activated.resourcePools.focus ?? activated.resource;
const arrowCostHp = Math.round(activated.maxHealth * 0.02);
const shot = ROM_ALL_ABILITIES.find((ability) => ability.id === SCOUT_SHOT_ID)!;
const ordinaryShotCosts = resolvedAbilityCosts(shot, [], { ...activated, now: 3_000 });
const ordinaryHealthCost = ordinaryShotCosts.find((cost) => cost.resource === "health")?.amount ?? 0;
const ordinaryManaCost = ordinaryShotCosts.find((cost) => cost.resource === "mana")?.amount ?? 0;
const ordinaryFocusCost = ordinaryShotCosts.find((cost) => cost.resource === "focus")?.amount ?? 0;
expect(useCombatStore.getState().castAbility(SCOUT_SHOT_ID, undefined, 3_000)).toMatchObject({ ok: true });
const committed = useCombatStore.getState();
expect(committed.health).toBe(healthBeforeShot - ordinaryHealthCost - arrowCostHp);
expect(committed.resourcePools.focus ?? committed.resource).toBe(focusBeforeShot - ordinaryFocusCost);
expect(committed.resourcePools.mana ?? 0).toBe(initialMana - ordinaryManaCost);
});
it("charges an owned Scout/Rogue ranged auto once and applies Blood Arrow's ranged damage modifier", () => {
const seed = "rom-scout-rogue-blood-arrow-auto-cost";
useCombatStore.getState().initializeCharacter({ classId: "mage", raceId: "rom-human", level: 79, role: "damage" });
const owner = companionWithBuild(seed, "Scout/Rogue", "rom-scout", "wow");
const readyOwner = { ...owner, globalCooldownEndsAt: 10_000 };
usePartyStore.getState().initializeParty(seed, 79, useCombatStore.getState().health, "damage", 1, "rom");
usePartyStore.setState({ members: [readyOwner], command: "attack", activeMobId: "blood-arrow-scout-auto" });
registerPartyRuntimePosition(owner.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().registerMob("blood-arrow-scout-auto", {
name: "Scout Blood Arrow Auto Target", level: 1, maxHealth: 1_000_000, xpReward: 0,
});
setMobPosition("blood-arrow-scout-auto", [3, 0, 0]);
updatePartyRuntimeTargetVisibility(owner.id, "blood-arrow-scout-auto", true);
advanceDungeonPartyCombat(1_000);
const inactiveSwing = useCombatStore.getState().combatEvents.find((event) => (
event.kind === "damage" && event.sourceActorId === owner.id && event.abilityId === "party-auto-attack"
));
expect(inactiveSwing).toBeDefined();
const healthBeforeActiveSwing = usePartyStore.getState().members.find((member) => member.id === owner.id)!.health;
const bloodArrow = abilitiesForCharacter("rom-scout", "rom-rogue").find((ability) => ability.id === BLOOD_ARROW_ID)!;
const sourceAura = bloodArrow.effects.find((effect) => effect.kind === "apply-aura"
&& effect.recipient === "caster" && effect.aura.utilityTags?.includes(BLOOD_ARROW_TAG));
if (sourceAura?.kind !== "apply-aura") throw new Error("Blood Arrow's source aura is missing.");
useCombatStore.getState().applyActorAura(owner.id, owner.id, bloodArrow, sourceAura, 2_000);
advanceDungeonPartyCombat(4_000);
const ownerAfterSwing = usePartyStore.getState().members.find((member) => member.id === owner.id)!;
const actorAfterSwing = useCombatStore.getState().actors[owner.id];
const activeSwings = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.sourceActorId === owner.id && event.abilityId === "party-auto-attack"
));
expect(ownerAfterSwing.health).toBe(healthBeforeActiveSwing - Math.round(ownerAfterSwing.maxHealth * 0.02));
expect(actorAfterSwing.health).toBe(ownerAfterSwing.health);
expect(activeSwings).toHaveLength(2);
expect(activeSwings[1].rawAmount).toBe(Math.round(activeSwings[0].rawAmount * 1.1));
});
it.each(["rom", "wow"] as const)("activates and protects the owned Knight/Scout in %s compatibility", (statRuleset) => {
const seed = `rom-knight-scout-blood-arrow-cost:${statRuleset}`;
useCombatStore.getState().initializeCharacter({ classId: "mage", raceId: "rom-human", level: 79, role: "damage" });
const owner = companionWithBuild(seed, "Knight/Scout", "rom-knight", statRuleset);
usePartyStore.getState().initializeParty(seed, 79, useCombatStore.getState().health, "damage", 1, "rom");
usePartyStore.setState({ members: [owner], command: "attack", activeMobId: "blood-arrow-cost-owned" });
registerPartyRuntimePosition(owner.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().registerMob("blood-arrow-cost-owned", {
name: "Owned Blood Arrow Cost Target", level: 1, maxHealth: 1_000_000, xpReward: 0,
});
setMobPosition("blood-arrow-cost-owned", [3, 0, 0]);
updatePartyRuntimeTargetVisibility(owner.id, "blood-arrow-cost-owned", true);
advanceDungeonPartyCombat(1_000);
const activatedMember = usePartyStore.getState().members.find((member) => member.id === owner.id)!;
expect(bloodArrowAura(owner.id)?.expiresAt).toBeNull();
const shot = abilitiesForCharacter("rom-knight", "rom-scout")
.find((ability) => ability.id === SCOUT_SHOT_ID)!;
const costs = partyAbilityCosts(activatedMember, shot, 3_000);
expect(costs).toEqual(expect.arrayContaining([
{ resource: "health", amount: Math.round(activatedMember.maxHealth * 0.02) },
{ resource: "mana", amount: Math.round(activatedMember.maxResource * 0.01) },
]));
expect(partyAbilityIsReady({
...activatedMember,
health: Math.round(activatedMember.maxHealth * 0.02),
}, shot, 3_000)).toBe(false);
const committed = commitPartyAbilityUse(activatedMember, shot, 3_000);
expect(committed?.health).toBe(activatedMember.health - Math.round(activatedMember.maxHealth * 0.02));
expect(committed?.resourcePools.mana).toBe((activatedMember.resourcePools.mana ?? activatedMember.resource)
- Math.round(activatedMember.maxResource * 0.01));
});
it("uses the explicit native school for Earth Chain and the physical Wind Arrows hit", () => {
for (const [sourceSkillId, expectedSchool] of [[494025, "nature"], [491128, "physical"]] as const) {
const ability = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === sourceSkillId);
@@ -233,7 +468,7 @@ describe("Knight/Scout Blood Punishment current-CoA runtime", () => {
const activeAura = bloodArrowAura(knightScout.id);
expect(activeAura?.sourceId).toBe(knightScout.id);
expect(activeAura?.expiresAt).toBe(31_000);
expect(activeAura?.expiresAt).toBeNull();
expect(activeAura?.definition.modifiers).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "healing", direction: "taken", value: 0.15 }),
expect.objectContaining({ kind: "damage", direction: "dealt", school: "holy", value: 0.05 }),
@@ -256,13 +491,15 @@ describe("Knight/Scout Blood Punishment current-CoA runtime", () => {
});
expect(playerHeal.rawAmount).toBe(100);
useCombatStore.getState().tick(30.1, 31_001);
expect(bloodArrowAura(knightScout.id)).toBeUndefined();
useCombatStore.getState().tick(60, 61_001);
expect(bloodArrowAura(knightScout.id)).toBeDefined();
usePartyStore.getState().damageMember(knightScout.id, 100);
const afterExpiryHeal = useCombatStore.getState().healActor(knightScout.id, 100, {
sourceActorId: PLAYER_AGGRO_ID, abilityId: "owned-blood-punishment-expiry-test", school: "holy", now: 31_002,
expect(useCombatStore.getState().deactivateRomScoutBloodArrow(knightScout.id, 61_002)).toBe(true);
expect(bloodArrowAura(knightScout.id)).toBeUndefined();
const afterDeactivationHeal = useCombatStore.getState().healActor(knightScout.id, 100, {
sourceActorId: PLAYER_AGGRO_ID, abilityId: "owned-blood-punishment-deactivation-test", school: "holy", now: 61_003,
});
expect(afterExpiryHeal.rawAmount).toBe(101); // Devotion remains learned; only Blood Arrow's +15% ended.
expect(afterDeactivationHeal.rawAmount).toBe(101); // Devotion remains learned; only Blood Arrow's +15% ended.
});
it("resolves the owned NPC's Holy damage rider through its cast and damage path", () => {
+10
View File
@@ -6,6 +6,7 @@ import {
auraDefinitionDisplaysInStrip,
auraDefinitionIcon,
auraDefinitionIsDispellableDebuff,
auraDefinitionIsFilteredLongTerm,
auraDefinitionIsLongTerm,
auraDescription,
groupAurasForDisplay,
@@ -65,6 +66,15 @@ describe("aura strip visibility", () => {
expect(auraDefinitionIsLongTerm(aura({ durationMs: LONG_TERM_AURA_DURATION_MS - 1 }))).toBe(false);
});
it("keeps only explicitly retained persistent utility auras in clutter-filtered strips", () => {
const bloodArrow = aura({ disposition: "buff", durationMs: null, utilityTags: ["rom-self-health-drain"] });
const otherPersistentBuff = aura({ disposition: "buff", durationMs: null, utilityTags: ["other-persistent"] });
expect(auraDefinitionIsFilteredLongTerm(bloodArrow)).toBe(true);
expect(auraDefinitionIsFilteredLongTerm(bloodArrow, ["rom-self-health-drain"])).toBe(false);
expect(auraDefinitionIsFilteredLongTerm(otherPersistentBuff, ["rom-self-health-drain"])).toBe(true);
});
it("identifies only categorized debuffs as dispellable player effects", () => {
expect(auraDefinitionIsDispellableDebuff(aura({ dispelCategory: "magic" }))).toBe(true);
expect(auraDefinitionIsDispellableDebuff(aura())).toBe(false);
+12 -1
View File
@@ -1,12 +1,14 @@
import { useShallow } from "zustand/react/shallow";
import { usePresentationClock } from "./usePresentationClock";
import type { AuraDisposition } from "../game/combatAuras";
import type { ActiveAura } from "../game/combatAuras";
import { useCombatStore } from "../game/combatStore";
import { formatTimedEffectTimer } from "../game/timedEffectDisplay";
import {
auraDefinitionDisplaysInStrip,
auraDefinitionIcon,
auraDefinitionIsDispellableDebuff,
auraDefinitionIsFilteredLongTerm,
auraDefinitionIsLongTerm,
auraDescription,
auraDisplayName,
@@ -20,10 +22,12 @@ interface AuraStripProps {
readonly dispositions?: readonly AuraDisposition[];
readonly className?: string;
readonly hideLongTerm?: boolean;
readonly keepLongTermUtilityTags?: readonly string[];
readonly showOnlyLongTerm?: boolean;
readonly highlightDispellableDebuffs?: boolean;
readonly targetLabels?: Readonly<Record<string, string>>;
readonly onInspect?: StatusEffectInspectHandler;
readonly onAuraContextMenu?: (aura: ActiveAura) => boolean;
readonly tooltipId?: string;
}
@@ -33,10 +37,12 @@ export function AuraStrip({
dispositions = ["buff", "debuff"],
className = "",
hideLongTerm = false,
keepLongTermUtilityTags = [],
showOnlyLongTerm = false,
highlightDispellableDebuffs = false,
targetLabels,
onInspect,
onAuraContextMenu,
tooltipId,
}: AuraStripProps) {
const requestedTargetIds = targetIds ?? [targetId];
@@ -44,7 +50,7 @@ export function AuraStrip({
requestedTargetIds.includes(aura.targetId)
&& dispositions.includes(aura.definition.disposition)
&& auraDefinitionDisplaysInStrip(aura.definition)
&& (!hideLongTerm || !auraDefinitionIsLongTerm(aura.definition))
&& (!hideLongTerm || !auraDefinitionIsFilteredLongTerm(aura.definition, keepLongTermUtilityTags))
&& (!showOnlyLongTerm || auraDefinitionIsLongTerm(aura.definition))
))));
const hasTimedAuras = matchingAuras.some((aura) => aura.expiresAt !== null);
@@ -93,6 +99,11 @@ export function AuraStrip({
} : undefined}
onFocus={onInspect ? () => onInspect(inspection) : undefined}
onBlur={onInspect ? () => onInspect(null) : undefined}
onContextMenu={onAuraContextMenu ? (event) => {
if (!onAuraContextMenu(aura)) return;
event.preventDefault();
event.stopPropagation();
} : undefined}
>
{icon
? <img className="aura__icon" src={icon} alt="" draggable={false} />
+11
View File
@@ -22,6 +22,10 @@ import { ResurrectionDestination, useResurrectionCheckpoints } from "./Resurrect
import { useGameStore } from "../game/store";
import { useHudSurface } from "./HudSurface";
import { FrameDamageText } from "./FloatingDamageText";
import {
deactivateRomBloodArrowFromAuraContextMenu,
ROM_SCOUT_BLOOD_ARROW_AURA_TAG,
} from "../game/romBloodArrow";
import {
PARTY_HEAL_POPUP_LIFETIME_MS,
addedPartyHealPopups,
@@ -217,6 +221,7 @@ function MemberFrame({ member, selected, onSelect, healPopups }: {
.map((aura) => aura.definition.name)
.join(", "));
const resourcePercent = healthPercent(member.resource, member.maxResource);
const deactivateRomScoutBloodArrow = useCombatStore((state) => state.deactivateRomScoutBloodArrow);
return (
<button
type="button"
@@ -242,7 +247,13 @@ function MemberFrame({ member, selected, onSelect, healPopups }: {
targetId={member.id}
className="aura-strip--party"
hideLongTerm
keepLongTermUtilityTags={[ROM_SCOUT_BLOOD_ARROW_AURA_TAG]}
highlightDispellableDebuffs
onAuraContextMenu={(aura) => deactivateRomBloodArrowFromAuraContextMenu(
aura,
member.id,
deactivateRomScoutBloodArrow,
)}
/>
</span>
<HealPopups popups={healPopups} targetId={member.id} />
+8
View File
@@ -3,6 +3,7 @@ import { abilityById } from "../game/abilityCatalog";
import { PLAYER_AGGRO_ID } from "../game/aggro";
import { useCombatStore } from "../game/combatStore";
import { formatTimedEffectTimer, groupTimedEffectsForDisplay, timedEffectRemainingMs } from "../game/timedEffectDisplay";
import { deactivateRomBloodArrowFromAuraContextMenu } from "../game/romBloodArrow";
import { AuraStrip } from "./AuraStrip";
import {
auraDisplaysInPlayerStatusPanel,
@@ -26,6 +27,7 @@ function statusLabel(disposition: StatusEffectInspection["disposition"]): string
export function PlayerStatusPanel({ now }: { readonly now: number }) {
const auras = useCombatStore((state) => state.auras);
const effects = useCombatStore((state) => state.effects);
const deactivateRomScoutBloodArrow = useCombatStore((state) => state.deactivateRomScoutBloodArrow);
const shield = useCombatStore((state) => state.shield);
const shieldExpiresAt = useCombatStore((state) => state.shieldExpiresAt);
const [inspected, setInspected] = useState<StatusEffectInspection | null>(null);
@@ -131,6 +133,12 @@ export function PlayerStatusPanel({ now }: { readonly now: number }) {
dispositions={["buff"]}
className="player-status-panel__strip"
onInspect={setInspected}
onAuraContextMenu={(aura) => deactivateRomBloodArrowFromAuraContextMenu(
aura,
PLAYER_AGGRO_ID,
deactivateRomScoutBloodArrow,
now,
)}
tooltipId={TOOLTIP_ID}
/>
<TimedEffectStrip
+8
View File
@@ -57,6 +57,14 @@ export function auraDefinitionIsLongTerm(definition: AuraDefinition): boolean {
return definition.durationMs === null || definition.durationMs >= LONG_TERM_AURA_DURATION_MS;
}
export function auraDefinitionIsFilteredLongTerm(
definition: AuraDefinition,
keepLongTermUtilityTags: readonly string[] = [],
): boolean {
return auraDefinitionIsLongTerm(definition)
&& !definition.utilityTags?.some((tag) => keepLongTermUtilityTags.includes(tag));
}
export function auraDefinitionIsDispellableDebuff(definition: AuraDefinition): boolean {
return definition.disposition === "debuff" && definition.dispelCategory !== undefined;
}
+31
View File
@@ -6,6 +6,7 @@ import { activateDungeonEncounterDefinitions, resetDungeonEncounters } from "../
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { usePartyStore } from "../game/partyStore";
import { useGameStore } from "../game/store";
import { ROM_SCOUT_BLOOD_ARROW_AURA_TAG } from "../game/romBloodArrow";
import { PartyFrames } from "./PartyFrames";
// Exercise the client HUD against live stores rather than their initial SSR snapshots.
@@ -79,6 +80,36 @@ describe("party resurrection UI", () => {
expect(render()).toContain(`value="boss:${boss.id}"`);
});
it("keeps an owned NPC's cancelable permanent Blood Arrow visible while filtering other permanent buffs", () => {
const ownerId = usePartyStore.getState().members[0]!.id;
const aura = (id: string, name: string, utilityTags?: readonly string[]) => ({
instanceId: id,
definition: {
id,
name,
disposition: "buff" as const,
durationMs: null,
...(utilityTags ? { utilityTags } : {}),
},
sourceId: ownerId,
targetId: ownerId,
appliedAt: 1_000,
expiresAt: null,
stacks: 1,
absorbs: [],
procStates: [],
});
useCombatStore.setState({ auras: [
aura("blood-arrow-permanent", "Blood Arrow", [ROM_SCOUT_BLOOD_ARROW_AURA_TAG]),
aura("other-permanent", "Other Persistent Buff"),
] });
const markup = render();
expect(markup).toContain("Blood Arrow, buff");
expect(markup).not.toContain("Other Persistent Buff, buff");
});
it("does not offer free resurrection in Manastorm", () => {
killMember();
useGameStore.setState({ gameMode: "manastorm" });