Update Healer Man server
This commit is contained in:
1 parent
69809f7228
commit
ff20ab8779
101 files changed
+16713
-945
No files matched your search
@@ -11,6 +11,91 @@ Stockade boss from its shared run ID; record the boss actually observed. Full
|
||||
Stockade campaigns below remain dated evidence and require explicit selection
|
||||
for future runs. Dated Wailing Caverns captures remain historical evidence.
|
||||
|
||||
## Hunter Deterrence and source hit inputs — 2026-10-07
|
||||
|
||||
Selected level-60 Hunter Deterrence now grants the exact five-aura self package
|
||||
for players and learned companions. Source attributes, linked cleanup, pacify
|
||||
cast distinctions, defensive NPC use, hit exclusions and guaranteed-deflect
|
||||
rider suppression are fixture-backed. The final focused run passes 110 checks
|
||||
in fifteen files, with two selected catalog checks, one exporter Node test,
|
||||
Ready-five seven Vitest/twenty Python checks and 56 avatar checks. TypeScript,
|
||||
normal prebuild hooks, isolated Vite build, precompression and bundle verification
|
||||
pass. See [the integration report](docs/DETERRENCE_INTEGRATION_2026-10-07.md).
|
||||
|
||||
The one frozen Cellblock Rush run `20261007-161404213` selects Hamhock 1717,
|
||||
`creature-79098`, and clears native 1/1 in 03:13.6 without deaths. Six player
|
||||
Lesser Heal events provide 1,952 effective healing, zero overheal. The routine
|
||||
Ready-only party starts at 30, so level-60 Deterrence remains actual player/NPC
|
||||
store-fixture evidence. Raw event IDs 9–753 are continuous (745 events), with no
|
||||
recorder loss or errors. Page exceptions are zero; only favicon 404 is recorded.
|
||||
|
||||
The frozen build is `a30787cafc7e1421e872cb37f883147177981b6627dc4238bf89f46ffb850ec2`
|
||||
(5,967 files). The fresh content manifest is
|
||||
`40d5c9e16d8045b8c240d57ca52017aaf95e423c44f96507aceb02285dca693c`;
|
||||
all 27,587 referenced objects verify before entry. Root rehashes the build,
|
||||
seven loaded chunks and 2,124 served manifest objects with zero failures and
|
||||
unchanged fingerprints. This matches successful recorded URLs/status to local
|
||||
immutable bytes and captured manifest references; raw network bodies are not
|
||||
retained or independently hashed.
|
||||
|
||||
Root visually reviews the real roster, equipped entrance, route-50 and results
|
||||
PNGs. Bodies and visible equipment render; overlap and partly offscreen
|
||||
companions limit complete gear/race review.
|
||||
`bite-sized-stormwind-stockade-boss-arrival.png` is the results
|
||||
capture, not an arrival image. No camera proof is credited. Evidence is under
|
||||
`playtest-artifacts/stockade-weapon-utilities/20261007-161404213/` and
|
||||
`artifacts/deterrence-continuation-2026-10-07/`. No gameplay edit or extra live
|
||||
run follows the freeze; source hit-table/native/loot/readiness boundaries remain.
|
||||
Independent Luna extra-high review confirms the captured fingerprints, events
|
||||
and native clear. Results end traversal at checkpoint 75/86 before the dedicated
|
||||
boss handler (`bossClearAttempted:false`); captured death/native kill progress
|
||||
provide the boss evidence. Full scripted route/handler coverage is not claimed.
|
||||
|
||||
## Lichborne, utility blocks and pacify — 2026-10-06
|
||||
|
||||
Exact source-bound Lichborne player/companion escape, Kick/Pummel ordinary-NPC
|
||||
full blocking and player/owned ranged-auto pacify gates are implemented. Kick
|
||||
keeps its public ID and imported five-second interrupt, without prototype
|
||||
damage or a duplicate interrupt. Deterrence's 19263/67801 aura link is audited;
|
||||
its full playable deflect remains gated by missing per-hit source inputs.
|
||||
See [the continuation report](docs/CONTROL_UTILITY_CONTINUATION_2026-10-06.md).
|
||||
|
||||
One final frozen **Cellblock Rush** run, UTC ID `20261007-033835372` (October 6
|
||||
locally), selects Targorr the Dread 1696, source/live `creature-84027`. Native
|
||||
progress/results agree on 1/1 in 03:05.6, Easy/Normal, with no deaths and
|
||||
2,301.8 effective player healing. The normal UI-created Discipline Priest has
|
||||
isolated pre-entry level 30 preparation and four Ready companions. This is the
|
||||
one-boss slice. The recorder retains consecutive IDs 9–770, 762 events, with
|
||||
zero gaps/errors/discards. Two actual companion Kick casts (496/765) have zero
|
||||
damage, and no Kick damage event is recorded. Shield Bash 536/706 immediately
|
||||
precedes Daze grants 537/707. No active Targorr cast is captured, so Kick's
|
||||
live interruption is not proven; Daze duration/slow remains fixture evidence.
|
||||
Pummel, source control escape, pacify and utility
|
||||
full blocks remain focused-fixture evidence in this seed.
|
||||
|
||||
Root views real roster, equipped-party, route-50 and native results PNGs:
|
||||
bodies, staff and shield silhouette render, with overlap/HUD occlusion limiting
|
||||
complete gear review. Nine PNGs exist; no camera-drag proof is credited. No
|
||||
page exceptions; only favicon 404. The harness cleans up its helper/server/
|
||||
browser. No gameplay source change or further live run follows the freeze.
|
||||
|
||||
All 138 focused checks across 19 files, 11 selected existing checks and Ready-five
|
||||
(7 Vitest/20 Python) pass. Prebuild hooks and final 8 GB compilation pass. A
|
||||
locked shared `dist/assets` prevents the default output build; a fresh isolated
|
||||
Vite build plus normal precompression/bundle verification succeeds. The frozen
|
||||
5,967-file aggregate is
|
||||
`00b7c1b19b0c8e64aba05732c24c7f3f65fa92c5260b4b3dee55391d5af2a2e4`;
|
||||
fresh content manifest
|
||||
`91b22f898ddabb4d20f1331f682852c33e5a6781c75caec9b1e435ebc60a0e39`.
|
||||
Both stay unchanged. All 27,587 referenced objects verify before QA. The labeled
|
||||
root audit rehashes the build, seven loaded chunks and 2,124 served manifest
|
||||
objects with zero integrity failures; raw response bodies are unavailable.
|
||||
The report preserves failed build/fixture attempts and remaining source gates.
|
||||
The final Luna extra-high evidence review completes independently, agrees with
|
||||
event continuity/native result/fingerprints and finds no Disarm discrepancy:
|
||||
seven mainhand swings inside four windows use raw 57, versus 68/70 outside.
|
||||
It does not rehash content or credit unobserved mechanic applications.
|
||||
|
||||
## NPC utility/control completion — 2026-10-06
|
||||
|
||||
Guarded ordinary-Creature Critical Block self-imports, exact WoW335 Shield
|
||||
@@ -5568,3 +5653,94 @@ unchanged; no commit, deployment or readiness promotion was performed.
|
||||
5,967 files match the tested snapshot byte for byte; the previous build is
|
||||
preserved at `pre-surface-v5-dist-backup`. `local-build-install-proof.json`
|
||||
records the installation, and `release-check-final.log` records final hygiene.
|
||||
|
||||
## 2026-10-06 - Healer out-of-combat mana recovery
|
||||
|
||||
- Added a living-healer party buff restoring 15% maximum mana per second
|
||||
between fights, enabled by default with a saved **Healer mana recovery**
|
||||
toggle in Pause > Options. Native RoM recovery is preserved separately.
|
||||
- Final live run: isolated offline level-30 RoM Priest, ordinary Dungeons >
|
||||
Bite-Sized > Stormwind Stockade: Cellblock Rush entry. The actual live Boss
|
||||
Guide selected **Targorr the Dread**; the boss was not fought in this check.
|
||||
Pre-entry profile preparation set level 30; gameplay resources were not
|
||||
injected. Companion roster: Rorik, Durnik, Caelan, Veyra.
|
||||
- Ordinary Options clicks changed On to Off, immediately removed the buff,
|
||||
and saved `healerManaRecovery: false` to the active character. Switching On
|
||||
restored it. Ordinary Urgent Heal spent 25 mana: 2904.3995 -> 2879.3995,
|
||||
followed by natural idle recovery to 2904.3995. No page runtime errors.
|
||||
- Inspected desktop and 960x540 screenshots: the new option description and
|
||||
On/Off control are visible and readable, with the existing scrollable list;
|
||||
the player buff panel and mana-using NPC strips show the recovery icon.
|
||||
Evidence: `artifacts/playtest/healer-mana-recovery/result.json`,
|
||||
`buff-enabled.png`, `menu-enabled.png`, `menu-disabled.png`,
|
||||
`menu-compact.png`, `selected-boss.png`, and `after-heal.png` in that folder.
|
||||
The JSON records final loaded build asset URLs and resource samples.
|
||||
- Verification: 136 focused tests pass, Ready-five checks pass, final production
|
||||
TypeScript/Vite build and static release-bundle verification pass. The initial
|
||||
build passed; a later concurrent terrain-test type error was corrected by
|
||||
its ongoing work before the final successful build.
|
||||
- Focused integration tests cover the exact recovery rate, combat stop/resume,
|
||||
player and NPC providers/recipients, dead healers, no stacking, saved Off,
|
||||
and maximum clamping. Browser evidence covers menu persistence, buff
|
||||
presentation and idle recovery; it does not certify a boss clear or wider
|
||||
RoM balance.
|
||||
|
||||
## 2026-10-07 — WoW source UV, terrain and lighting continuation
|
||||
|
||||
- Accepted local Utgarde Pinnacle and Shadowfang Keep 16-bit UV/position visual
|
||||
packages. Utgarde preserves spatial boundaries and is 29,792,924 bytes;
|
||||
Shadowfang is 8,803,744 bytes. Their nine prepared-runtime views each retain
|
||||
exactly the previous draw/triangle counts. Collision/navigation hashes are
|
||||
unchanged. The two-package audit retains all 916 source-comparable image
|
||||
dimensions; small documented UV residuals remain.
|
||||
- Zul'Farrak entrance tile native ADT recovery produces a complete diagnostic
|
||||
GLB with 256 disjoint chunks, 8 diffuse textures and 256 blend images. All
|
||||
490 alpha maps and source hashes pass. Nine prepared-runtime views have zero
|
||||
page errors and show textured sand/rock. This remains an unshipped one-tile
|
||||
pilot with extra draw calls; other terrain and original-client lighting are
|
||||
not certified by it.
|
||||
- Original WMO lighting evidence is preserved for Stockade/Shadowfang. Stockade
|
||||
has exact correspondence for 55,730 OBJ vertices; Shadowfang is incomplete.
|
||||
Raw/fixup colors and MOLT records remain diagnostic, pending a validated
|
||||
native consumer.
|
||||
- 65 focused scene tests, 21 pipeline/source tests, app and isolated-preview
|
||||
TypeScript checks pass. Normal production build and asset audits pass.
|
||||
Fresh graphics smoke selected **Hamhock** in Cellblock Rush. 16× filtering,
|
||||
High shadows, High ambient fill and 200% resolution passed, including 200%
|
||||
in Forsaken Abbey, with zero errors. Entry `index-DIqor-kq.js`; bundle built
|
||||
`2026-10-07T03:55:23.055Z`. Captures were visually reviewed. Evidence lives in
|
||||
`artifacts/playtest/graphics-options/run-wow-continuation-current-build-2026-10-06/`
|
||||
and `artifacts/playtest/wow-environment-quality/continuation/`.
|
||||
- See `docs/WOW_ENVIRONMENT_QUALITY_CONTINUATION_2026-10-07.md` for package
|
||||
identities, source provenance, measured costs, remaining gaps and scope.
|
||||
This is visual/settings validation, not boss-clear or class certification.
|
||||
|
||||
## 2026-10-07 — RoM residual entry/camera follow-up
|
||||
|
||||
- Corrected Raksha's packed-attribute Recast input and restored its authored
|
||||
entrance deck. A strict source-anchor subset retains six complete actual
|
||||
Detour components (239 polygons/514 links/653 triangles) within existing
|
||||
budgets; source visual and seven collision files are unchanged. Entry and
|
||||
Shadoj source guards pass, but 112/128 anchors remain unmatched and the route
|
||||
between those components remains disconnected.
|
||||
- Fresh ordinary UI/WASD QA reached all four gate/initial-slope targets, with
|
||||
eight grounded samples, 8.554469 m at route completion and 8.876903 m after
|
||||
post-release settling. No browser errors. Standard/High use 150%, 16× and
|
||||
authored ambient; right-drag works without pointer lock. Scene rays confirm
|
||||
the source deck and initial slope; the former under-deck camera entry is gone.
|
||||
- Same-build graphics fallback selected Bazil Thredd in Stockade Cellblock Rush:
|
||||
default 150%, High 200% and Forsaken Abbey comparison pass with no errors.
|
||||
Root reviewed High Stockade and Raksha entry/step screenshots.
|
||||
- Origin's exact dark root card remains source-opaque and front-facing; its
|
||||
native vertex stream has no diffuse alpha, and source-correct culling does
|
||||
not remove it. The asset is unchanged. This is bounded visual/movement QA,
|
||||
not an objective route, full clear or class/balance certification.
|
||||
- 13 focused pipeline tests, 12 runtime tests, TypeScript/Vite and release-bundle
|
||||
checks pass; ten Raksha GLBs validate with zero errors/warnings. The later
|
||||
performance audit passes 106 assets/60 known exceptions/zero regressions.
|
||||
- Installed reviewed local build: index SHA
|
||||
8ca382b4ad8919ba100ee1e0ee6c7d64a30b87c7acfbf6d65fc8184eb10c9958,
|
||||
frozen content-151109efcb20cc00. All 5,967 installed files match the reviewed
|
||||
build; the actual prior dist has a separate exclusive backup.
|
||||
- Evidence: artifacts/playtest/rom-residual-review-20261006/; scope/limits and
|
||||
source/promotion/build proofs: docs/ROM_RESIDUAL_RENDERING_REVIEW_2026-10-06.md.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Active Class Mechanics Backlog
|
||||
|
||||
Updated 2026-10-06. This document lists unresolved ability and talent mechanics for all ten WoW 3.3.5a classes and all ten RuneWaker primary classes. It excludes completed fixes and all visual or sound review. Some items need source confirmation or simulator support before they can be implemented; do not guess missing values. For RoM conflicts, prefer clearly matching current Chronicles of Arcadia behavior over the RuneWaker snapshot; verify skill identity and class pairing, and document the source and any simulator approximation. See [the full audit](ACTIVE_CLASS_AUDIT.md) for the supporting findings and source conflicts.
|
||||
Updated 2026-10-07. 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: Cellblock Rush** (`bite-sized-stormwind-stockade`). Each run selects one of the five Stockade bosses from its shared run ID, so record the boss actually observed. Import `scripts/playtest-defaults.cjs` and follow [the test policy](TEST_DUNGEON_POLICY.md); full Stockade campaigns require explicit selection. Isolated Boss Testing remains a separate mode that defaults to Hamhock. Historical Wailing findings below do not select future tests.
|
||||
|
||||
@@ -34,7 +34,9 @@ New routine mechanic/browser diagnostics use **Stormwind Stockade: Cellblock Rus
|
||||
- [x] Recover the matching pinned enemy equipment table, validate virtual slots against Item.dbc, retain explicit source spawn GUIDs, and enable AUTO offhand attacks for proven ordinary NPC offhand weapons. Selectors and alternate entries fail closed; runtime tests cover independent timing and reset persistence. See [the equipment source mapping](ENEMY_EQUIPMENT_MAPPING_2026-10-06.md).
|
||||
- [x] Add guarded ordinary-Creature self-cast Critical Block 47294–47296 imports, exact WoW335 Shield Bash 72 full utility blocking for player/party writers, and source shout aura 31 +25% speed with whole-spell fear immunity and separate stun-effect immunity. No source NPC Critical Block assignment was found, so no creature cast is invented. The stance/Thrash audit confirms no source meaning for Ascension DUMMY +42 or exact Targorr Thrash cadence; keep both gated. See [the utility/control completion](ENEMY_UTILITY_CONTROL_COMPLETION_2026-10-06.md).
|
||||
- [x] Import exact WoW335 Shield Bash 72's child 29703 as a six-second 50% SNARE, integrate safe source-informed fear routes for local player/party/mobile summons, and bind named-shout immunity purge/escape to exact Berserker Rage 18499 and Bladestorm 46924 source grants. Whole child/rider removal, overlapping generic controls, blocked paths, expiry and accepted grant/cast consumers are covered. Icebound Fortitude has no sourced purge flag; Bladestorm has no controlled-casting bypass. See [the control-runtime follow-up](ENEMY_CONTROL_RUNTIME_FOLLOWUP_2026-10-06.md).
|
||||
- [ ] Extend utility full hit tables/ranks/profiles only with exact source proof. General shout Magic hit/resist/deflect, exact server flee paths/RNG, additional immunity-grant purge rules, Deterrence siblings, private Ascension stance DUMMY consumer, exact source Targorr Thrash timing, native custom equipment/rate and cast prerequisites, and broader weapon/proc formulas remain open. False direct hits stay omitted for unsupported effects. See [the current boundaries](ENEMY_CONTROL_RUNTIME_FOLLOWUP_2026-10-06.md).
|
||||
- [x] Add exact Lichborne player/party cast bypass and named-shout purge, source-bound Kick 1766/Pummel 6552 blocking, removal of Kick's prototype damage/duplicate interrupt, and actor-local pacify gates for weapon autos. The public `rogue-kick` ID persists. Audit Deterrence 19263's linked 67801 package while keeping its playable deflect import gated. See [the control/utility continuation](CONTROL_UTILITY_CONTINUATION_2026-10-06.md).
|
||||
- [x] Import selected Hunter Deterrence 19263 with linked 67801 for actual player/companion casts, five-second owner-local lifecycle, parry/ranged-hit inputs and exact-source direct spell deflection. Preserve sourced pacify distinctions, off-GCD/cooldown/equipment rules, defensive NPC selection and hostile-rider suppression. Raw hit flags now reach normalized WoW attacks. See [the integration](DETERRENCE_INTEGRATION_2026-10-07.md).
|
||||
- [ ] Extend utility full hit tables/ranks/profiles only with exact source proof. General shout Magic hit/resist/deflect, ordinary miss/resist control-rider fidelity, exact server flee paths/RNG, Charm/general immunity-grant purge rules, private Ascension stance DUMMY consumer, exact source Targorr Thrash timing, native custom equipment/rate and cast prerequisites, and broader weapon/proc formulas remain open. False direct hits stay omitted for unsupported effects. See [the current boundaries](DETERRENCE_INTEGRATION_2026-10-07.md).
|
||||
|
||||
## Native stat input update (2026-10-03)
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# Control and utility continuation — 2026-10-06
|
||||
|
||||
Lichborne now uses exact source-backed player/companion cast allowances and
|
||||
named-shout purge. Kick and Pummel join Shield Bash's source-bound ordinary-NPC
|
||||
full blocking. Player weapon autos respect pacify. Deterrence's complete linked
|
||||
source package is audited; missing runtime inputs remain explicit.
|
||||
|
||||
## Implemented behavior
|
||||
|
||||
- `enemyControlImmunityPurge.ts` binds Lichborne 49039 to its selected WoW335
|
||||
identity, class, retained raw attributes and three Aura77 scalar signatures.
|
||||
Explicit fear/stun/confuse cast permissions are separate from purge. Player
|
||||
and configured companion casts remove only the four named FEAR-mechanic
|
||||
shout auras, including 20511's stun/rider. Known generic fear/stun/confuse can
|
||||
permit Lichborne but remain active and keep the actor controlled. Unknown
|
||||
source controls and unsupported generic sleep fail closed. Berserker Rage
|
||||
retains its narrower behavior. Existing Fear/Sleep immunity metadata is
|
||||
consumed; Charm is source-proven but has no runtime immunity value.
|
||||
- `partyRuntime.ts` selects a learned, source-proven escape ability for its
|
||||
owner, retaining target validity, costs and cooldowns. Lichborne's source
|
||||
GCD is zero: it neither waits for nor clears another active GCD. Retained
|
||||
action-blocking control keeps the companion holding.
|
||||
- `enemyUtilitySpellBlocking.ts` binds exact WoW335 Shield Bash 72, Kick 1766
|
||||
and Pummel 6552 to identity/class/profile/raw metadata and the complete
|
||||
projected effects. Only source-bound ability validation reconstructs the
|
||||
two audited custom bits; an unbound raw disposition without proof falls back.
|
||||
Foreign profiles/ranks, modified effects/wrappers, native actors/targets and
|
||||
nonordinary recipients cannot borrow the contracts. Full block suppresses
|
||||
effects while committing cost/cooldown, emits a zero-damage blocked result
|
||||
and has no critical-block roll. A nonmelee-casting defender cannot block;
|
||||
real interruption and the source school lockout remain.
|
||||
- `abilityCatalog.ts` keeps the saved public `rogue-kick` identity and only
|
||||
its imported five-second interrupt. The generic merge previously attached
|
||||
prototype damage and a second four-second interrupt to this exact source
|
||||
row. Only that known merge changes. Pummel retains its four-second lockout
|
||||
and level 38 unlock. Tests use level 40 actors and pinned Kam Deepfury at
|
||||
level 25, with deterministic rolls and synthetic health/cast preparation.
|
||||
- `combatStore.ts` suppresses main/offhand autos and the owned Scout ranged
|
||||
scheduler while that actor has an active pacified tag. Target selection
|
||||
persists; expiry resumes ordinary cadence. Another actor's aura cannot
|
||||
suppress the player. Accepted-aura scheduler fixtures do not claim a live
|
||||
Deterrence cast or native readiness promotion.
|
||||
|
||||
## Source and remaining limits
|
||||
|
||||
Exact client/core evidence: [Lichborne](LICHBORNE_CONTROL_ESCAPE_2026-10-06.md),
|
||||
[utility extension](UTILITY_HIT_EXTENSION_AUDIT_2026-10-06.md) and
|
||||
[Deterrence](WOW335_DETERRENCE_SOURCE_2026-10-06.md). Historical inputs were
|
||||
read only. Pinned `spell_linked_spell` type 2 links Deterrence 19263 to 67801,
|
||||
even without a client trigger. Its five-second package includes parry, deflect,
|
||||
pacify, ignore-hit-direction and ranged-hit penalty. Aura 185's private helper
|
||||
name/comment is corrected; it is not Disarm. The Deterrence helper validates
|
||||
source signatures only, with no playable import or damage outcome claimed.
|
||||
|
||||
Full utility miss/dodge/parry/mechanic-resist tables still need caster hit,
|
||||
attribute7 and target defense inputs, including ignored combat-result/template
|
||||
gates. General Magic miss/resist/deflect stays gated. Deterrence needs the
|
||||
per-hit AlwaysHit source flag and correct post-miss/resist deflect stage;
|
||||
damage class alone cannot provide this. Exact flee RNG, private stance DUMMY,
|
||||
Targorr timing, Charm/general control purges, native custom gear and broader
|
||||
procs remain separate work. Level cap 80, loot and Ready-five scope are intact.
|
||||
|
||||
## Verification
|
||||
|
||||
The final focused selection passes **138/138 tests in 19 files**. JSON evidence is
|
||||
`artifacts/control-utility-followup-2026-10-06/focused-vitest.json`. Eleven more
|
||||
selected existing Kick/Ready/catalog checks pass, with 130 other cases excluded
|
||||
by the title filter. Ready-five passes seven Vitest and twenty Python checks.
|
||||
Initial diagnostics catch fixture identity/learnability/facing errors and an
|
||||
outdated whitelist assertion; corrected fixtures and the final selection pass.
|
||||
The failed whitelist report is retained separately.
|
||||
|
||||
The final selection adds explicit pure and actual learned-cast rejection checks
|
||||
for Bladestorm under named Fear and the 20511 stun. A purge-capable accepted
|
||||
grant does not supply permission to cast through those controls. Rejected casts
|
||||
preserve auras, control, resources, cooldown and GCD. The existing source guard
|
||||
already enforces this; these additions are test coverage. An intermediate
|
||||
14-file selection also passes, but omits five intended files and is preserved
|
||||
as `focused-incomplete-selection.json`; only the final 19-file selection is
|
||||
credited above.
|
||||
|
||||
All three subprocesses use GPT-6 Luna extra-high. Source workers finish, and
|
||||
the independent integration review finds no further actionable issue after
|
||||
generic-fear bypass and source-level fixture corrections. The utility worker
|
||||
verifies the public/source Kick merge and exact contracts. No higher-tier
|
||||
subprocess is used. Concurrent healer-recovery edits briefly lack an import
|
||||
during one worker check; the owning concurrent edit supplies it before final
|
||||
focused validation. Root does not claim ownership of that feature. The first
|
||||
build catches a narrow/wide control-type mismatch; root changes the comparison
|
||||
without changing its runtime behavior before rebuilding.
|
||||
|
||||
## Final build and content freeze
|
||||
|
||||
The normal build's prebuild hooks and 8 GB TypeScript compilation pass. An
|
||||
earlier concurrent terrain fixture has an invalid material enum until its owner
|
||||
corrects it; four targeted terrain checks then pass. The default Vite output
|
||||
fails with Windows EPERM contention in `dist/assets`. A fresh isolated Vite
|
||||
build succeeds, followed by the normal precompression and release-bundle
|
||||
verifier. The failed attempts and `build-isolated.log` are retained in the task
|
||||
artifact directory. No shared output or other process is removed.
|
||||
|
||||
The isolated build is frozen under
|
||||
`artifacts/control-utility-followup-2026-10-06/frozen-20261007-033245765/`.
|
||||
Its 5,967 files contain 118,418,867 bytes, aggregate SHA-256
|
||||
`00b7c1b19b0c8e64aba05732c24c7f3f65fa92c5260b4b3dee55391d5af2a2e4`.
|
||||
The freshly copied content manifest is
|
||||
`91b22f898ddabb4d20f1331f682852c33e5a6781c75caec9b1e435ebc60a0e39`,
|
||||
version `content-d1ec6f1f858f21c1`. All 27,587 referenced objects verify before
|
||||
QA (6,566,442,405 bytes; 21,605 logical files). The prior pass's content is not
|
||||
reused because the current manifest differs. The build's historical Wailing
|
||||
bootstrap assets do not select the live test dungeon.
|
||||
|
||||
## Final Cellblock Rush evidence
|
||||
|
||||
The single final routine run is
|
||||
`playtest-artifacts/stockade-weapon-utilities/20261007-033835372/` (UTC run ID;
|
||||
October 6 locally). Normal UI creates a Discipline Priest and four Ready
|
||||
companions, with isolated pre-entry level 30 preparation. Its seeded boss is
|
||||
Targorr the Dread, entry 1696, source/live actor `creature-84027`. Native progress
|
||||
and the results overlay agree on **1/1 in 03:05.6**, Easy/Normal. No player or
|
||||
party deaths occur; effective player healing totals 2,301.8 HP. Historical
|
||||
`fullClear` field names certify this entered one-boss slice only.
|
||||
|
||||
The committed recorder retains 762 consecutive events, IDs 9–770, with zero
|
||||
gaps, errors or discarded events/states. Companion Kick events 496 and 765
|
||||
have `kind: cast`, zero damage and unblocked hit outcomes. No Kick damage event
|
||||
is recorded. No active source cast is captured for Targorr, so these Kick
|
||||
results do not prove a live interruption. Shield Bash 536/706 immediately
|
||||
precedes Daze grants 537/707; Daze duration/movement remains fixture evidence.
|
||||
Pummel is unobserved: the live actors are below its level 38 unlock. Utility
|
||||
full blocks, Lichborne/control escape and pacify are deterministic-fixture
|
||||
coverage, not observed live applications in this seed. There is no Death
|
||||
Knight in the Ready-only live party.
|
||||
|
||||
Existing Disarm evidence contains four tank windows and seven mainhand autos
|
||||
during them (364, 373, 555, 569, 647, 659, 762). Each records raw damage 57,
|
||||
versus 68/70 outside those windows. This is consistent with the existing unarmed
|
||||
fallback and removed weapon contribution, not a requirement to suppress every
|
||||
swing. Disarm and the new pacify scheduler gate have different source behavior.
|
||||
|
||||
Root's labeled `root-evidence-audit.cjs/.json` independently rehashes all 5,967
|
||||
frozen build files, seven loaded chunks and 2,124 served manifest objects
|
||||
(77,019,550 bytes). Build and full content manifest remain unchanged; every
|
||||
checked object matches its address and captured manifest reference, with zero
|
||||
integrity failures. Raw network response bodies are not retained; the audit
|
||||
matches immutable local bytes to recorded successful response URLs/status.
|
||||
|
||||
Root views the actual roster, equipped-party, route-50 and native results PNGs.
|
||||
Player/companion bodies, the staff and shield silhouette render. Close-party
|
||||
overlap and HUD occlusion limit a complete per-companion equipment review.
|
||||
Nine PNGs exist; no live camera-drag evidence is credited. Page exceptions are
|
||||
zero; the only console error is favicon 404. The harness cleans up its helper,
|
||||
server and browser. No gameplay source change or additional live run follows
|
||||
the frozen build. Final TypeScript compilation also passes after the test-only
|
||||
rejection additions.
|
||||
|
||||
The independent GPT-6 Luna extra-high evidence review finishes without a
|
||||
discrepancy. It separately checks raw event-ID continuity, native one-boss
|
||||
completion and agreement between captured fingerprints and root audit. It
|
||||
confirms the unarmed Disarm evidence and live utility/control attribution above;
|
||||
it does not rehash the content, run tests/builds/browser, or broaden the audit's
|
||||
network-body boundary.
|
||||
|
||||
Final game release hygiene passes 78 changed paths, including untracked files,
|
||||
and is recorded in
|
||||
`artifacts/control-utility-followup-2026-10-06/release-check.log`. Game/vault
|
||||
changes are reviewed separately, canonical notes are updated, and the task
|
||||
worklog closes without staging, commit or deployment.
|
||||
@@ -0,0 +1,168 @@
|
||||
# Hunter Deterrence catalog selection — 2026-10-07
|
||||
|
||||
## Result
|
||||
|
||||
The selected Hunter catalog now exposes the exact Spell.dbc player action
|
||||
Deterrence 19263 as `wow335-hunter-deterrence`, with its source level, cooldown,
|
||||
resource, GCD, target, item requirement, source attributes, and three parent
|
||||
aura rows preserved. Its 67801 linked-aura rows remain a separate, explicit
|
||||
package descriptor; 67801 is not a learned action or a triggered-spell child.
|
||||
|
||||
The generated progression file and its generator were not changed. The
|
||||
catalog-local fallback is inserted only when the generated Hunter chains
|
||||
contain no Deterrence name or package IDs. An imported candidate is accepted
|
||||
only if its selected row matches the verified source fields; a same-name
|
||||
65871 row, an imported linked child, or any candidate drift suppresses the
|
||||
package rather than substituting a row.
|
||||
|
||||
## Why the generated Hunter entry was missing
|
||||
|
||||
The read-only source was the local 3.3.5a `patch-enUS.MPQ` at
|
||||
`../wow335a/Data/enUS/patch-enUS.MPQ`, SHA-256
|
||||
`c1c06b0d0c34c331b21df7cdd25cc422a650efbd477e42b80849ba30b2e5fc99`.
|
||||
Its `SkillLineAbility.dbc` contains this row for 19263:
|
||||
|
||||
```text
|
||||
[10865, 51, 19263, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]
|
||||
```
|
||||
|
||||
The Hunter source in `scripts/generate-wow335-abilities.mjs` includes skill
|
||||
line 51 and class mask 4, but `classSpellIds` also requires the row's class-mask
|
||||
field 4 to contain bit 4. The selected row has class mask zero, so the import
|
||||
filter drops it even though the Hunter skill-line row is present. A read-only
|
||||
generation to a temporary output reproduced the omission; no generated files
|
||||
were written.
|
||||
|
||||
## Selected parent row
|
||||
|
||||
The same archive's `Spell.dbc` has 239 fields and selects spell 19263 named
|
||||
Deterrence. The imported field shift is six. The exact catalog values are:
|
||||
|
||||
| Field | Selected value |
|
||||
| --- | --- |
|
||||
| Source and base level | 60 / 60 |
|
||||
| Maximum scaling level | 0 |
|
||||
| Target and range | Self, range index 1, 0–0 |
|
||||
| Cast time | 0 ms |
|
||||
| Duration | index 28, 5,000 ms |
|
||||
| Cooldown | 90,000 ms; category recovery 0 |
|
||||
| Global cooldown | 0 ms |
|
||||
| Power | Mana, 0 flat cost, 0 percent cost |
|
||||
| Prevention type | 0 (`NONE`) |
|
||||
| Equipped item | class 2, subclass mask 173555, inventory mask 0 |
|
||||
| Attributes 0–7 | `[327696, 16, 0, 0, 0, 0, 0, 0]` |
|
||||
| Damage class / school mask | 0 / 1 |
|
||||
| Icon | `Ability_Whirlwind` |
|
||||
|
||||
The exact DBC effect rows are self-targeted `APPLY_AURA` effects with no
|
||||
triggered spells, period, radius, or mechanic:
|
||||
|
||||
| Index | Aura | Parsed amount | Raw base points | DBC coefficient |
|
||||
| ---: | ---: | ---: | ---: | ---: |
|
||||
| 0 | 47 | +100 | 99 | 1 |
|
||||
| 1 | 287 | +100 | 99 | 1 |
|
||||
| 2 | 25 | 0 | -1 | 0 |
|
||||
|
||||
`wow335AbilityCatalog.ts` maps this one verified row through the ordinary
|
||||
rank path, then uses the source-bound parent aura descriptors from
|
||||
`wow335DeterrenceRuntime.ts`. That override matters for Aura 47: its source
|
||||
payload is +100 parry percentage points, so the descriptor uses a flat parry
|
||||
modifier. The generic Aura 47 mapper uses a percent multiplier and would not
|
||||
represent this row correctly. The descriptors preserve the existing source
|
||||
IDs and five-second duration; the 287 effect has no generic deflect tag.
|
||||
|
||||
The parent row's selected client tooltip resolves to: “When activated,
|
||||
increases parry chance by 100% and grants a 100% chance to deflect spells.
|
||||
While Deterrence is active, you cannot attack. Lasts 5 sec.” The linked server
|
||||
row `(19263, 67801, 2, 'Deterrence')` and exact 67801 child signature are
|
||||
documented in [the source audit](WOW335_DETERRENCE_SOURCE_2026-10-06.md).
|
||||
|
||||
The same DBC field distinguishes attacks blocked by the pacify prevention
|
||||
type from silence-only spells. In the 239-field archive, PreventionType is at
|
||||
raw field 220 (the import shift of six from field 214 in the pinned 234-field
|
||||
layout). The source enum defines 2 as PACIFY, 1 as SILENCE, and 0 as NONE.
|
||||
Direct read-only parsing of the pinned archive verified every one of the 189
|
||||
selected Hunter rank rows plus Deterrence: 32 NONE, 67 SILENCE, and 91 PACIFY.
|
||||
Five additional IDs appear in generated Hunter spell chains but do not become
|
||||
selected cast ranks: rank-zero Tame Beast 13481 and Volley damage children
|
||||
42234, 42243, 42244, and 42245. They are excluded from the runtime lookup
|
||||
because the ability mapper selects the player cast/channel wrappers.
|
||||
Arcane Shot (3044), Steady Shot (56641), Aimed Shot (19434), and Wing Clip
|
||||
(2974) are PACIFY; Explosive Trap (13813) and Mend Pet (136) are SILENCE;
|
||||
Deterrence (19263) is NONE. The complete spell-ID table is in
|
||||
`WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID` in
|
||||
`src/game/wow335AbilityCatalog.ts`. The lookup also checks the selected
|
||||
Hunter/source identity and accepts the public stable ID used when a generated
|
||||
source action overlays a core action. Unknown IDs, another class, source, or
|
||||
name return no prevention value.
|
||||
|
||||
The static cast-gate table is deliberately local to the selected catalog. It
|
||||
was read from `Spell.dbc` with the archive's WDBC record framing and the same
|
||||
six-field schema shift implemented by
|
||||
`scripts/generate-wow335-abilities.mjs::parseSpells`; the DBC generator does
|
||||
not currently retain PreventionType. A second read-only comparison against
|
||||
all 190 table IDs verified every stored value against raw field 220. The DBC
|
||||
source pin is WoW build 3.3.5a.12340 and the MPQ SHA-256 recorded above. Field
|
||||
semantics are pinned to AzerothCore revision
|
||||
`e822d718322289dd036fb2daf8867ba41283bf9a`: `src/server/shared/SharedDefines.h`
|
||||
defines `NONE=0`, `SILENCE=1`, and `PACIFY=2`, and
|
||||
`src/server/game/Spells/Spell.cpp` gates casts using those values. A future
|
||||
change to the selected archive, DBC field layout, selected Hunter ranks, or
|
||||
source profile must re-verify the raw table and update this local mapping. It
|
||||
should move into generated metadata only with explicit profile, class,
|
||||
source-row, and rank provenance; this change does not broaden the generated
|
||||
schema.
|
||||
|
||||
## Linked package and runtime boundary
|
||||
|
||||
`wow335DeterrenceRuntime.ts` owns three parent `apply-aura` descriptors and
|
||||
two linked 67801 descriptors. The child source effects are Aura 288 (+100,
|
||||
raw base 99) and Aura 185 (-100, raw base -101), each lasting 5,000 ms. Aura
|
||||
185 retains its ordinary `incoming-ranged-hit` modifier. Aura 288 has no
|
||||
generic tag; its ignore-facing meaning is resolved only from the complete
|
||||
source-bound Deterrence package.
|
||||
|
||||
The catalog exposes the selected parent action in Hunter progression at level
|
||||
60 and keeps the two linked child descriptors separate. Shared cast/runtime
|
||||
integration applies and removes the full five-aura package atomically. The
|
||||
runtime validator requires the exact parent ability identity and all five
|
||||
active aura instances to share source owner, target, applied time, and exact
|
||||
expiry. It rejects partial, duplicate, expired, foreign-profile, or same-name
|
||||
65871 packages. See `src/game/wow335DeterrenceRuntime.ts`,
|
||||
`src/game/wow335DeterrenceRuntime.test.ts`, and the
|
||||
[Deterrence integration report](DETERRENCE_INTEGRATION_2026-10-07.md).
|
||||
|
||||
## Hunter NPC defensive selection
|
||||
|
||||
The configured Hunter companion can now select the learned 19263 action from
|
||||
its own eligible abilities. The early self-defensive branch considers only
|
||||
engaged mobs currently targeting that companion, and requires the companion to
|
||||
have no active cast and no active Deterrence package. It triggers below the
|
||||
configured defensive-health threshold (55% by default) or when a recognized
|
||||
incoming cooldown mechanic is imminent. The source action must also pass the
|
||||
normal readiness and target checks, including cooldown, resource, equipment,
|
||||
and current pacify restrictions. The branch uses the ordinary ability commit
|
||||
and execution path, preserving its source cost, 90-second cooldown, and zero
|
||||
GCD. The runtime package applies all five source-linked auras and expires them
|
||||
together.
|
||||
|
||||
This is a Hunter-specific defensive choice based on the companion's own threat
|
||||
and health/mechanic state; it does not change the general NPC defensive
|
||||
candidate rules. The player and companion cast gates both consult the exact
|
||||
selected-row PreventionType lookup: PACIFY (2) spells are blocked while the
|
||||
actor has an active pacified aura, while SILENCE (1) actions such as Explosive
|
||||
Trap and Mend Pet remain available. Ordinary melee attacks keep their
|
||||
existing pacify gate. The focused catalog prevention test covers every
|
||||
selected Hunter rank and merged public IDs; shared cast/runtime evidence is in
|
||||
[the Deterrence integration report](DETERRENCE_INTEGRATION_2026-10-07.md).
|
||||
|
||||
## Focused verification
|
||||
|
||||
`npx vitest run src/game/wow335AbilityCatalog.test.ts -t "selects Hunter Deterrence|retains pinned PreventionType"` passes both selected catalog tests.
|
||||
The Deterrence case checks the stable selected ID, level 60 unlock, 90-second
|
||||
cooldown, zero cost/GCD, exact source attributes and weapon requirement, all
|
||||
three parent aura rows, both linked child rows, and rejection of 65871/67801
|
||||
as independent Hunter actions. The prevention case checks all 190 selected
|
||||
rank IDs against the source map, class/source/name/rank rejection, and public
|
||||
merged IDs for Arcane Shot and Steady Shot. Source-contract tests separately
|
||||
cover profile, parent/child, and link drift.
|
||||
@@ -0,0 +1,155 @@
|
||||
# Hunter Deterrence integration — 2026-10-07
|
||||
|
||||
Hunter Deterrence 19263 is available at level 60 to players and configured
|
||||
Hunter companions. It grants its three parent auras and the two linked 67801
|
||||
auras to the same caster for five seconds, costs no Mana, preserves the existing
|
||||
global cooldown and starts its sourced 90-second cooldown. The selected source
|
||||
requires a matching equipped weapon. The existing Training Gear staff satisfies
|
||||
that mask; a source Disarm does not remove the matching item, and the pinned
|
||||
nonpassive aura-spell exception still permits this cast.
|
||||
|
||||
## Source and ownership
|
||||
|
||||
The pinned Spell.dbc SHA-256 is
|
||||
`57011d528fd66cf39f5ab6fb168e389ab9e4cc1b8050f7201a54239d489b415c`;
|
||||
AzerothCore is `e822d718322289dd036fb2daf8867ba41283bf9a`. The exact type-2
|
||||
`spell_linked_spell` row binds 19263 to 67801. Same-name 65871 and child 67801
|
||||
remain unavailable as independent Hunter actions. The older
|
||||
[source audit](WOW335_DETERRENCE_SOURCE_2026-10-06.md) records the raw package.
|
||||
|
||||
- `scripts/dungeon-pipeline/export-enemy-damage-types.mjs` owns generated raw
|
||||
Attributes0/3/7 (DBC fields 4/7/11). `mobDamageTypes.ts` and
|
||||
`enemyAttacks.ts` retain them on normalized, source-identified WoW attacks.
|
||||
All 671 existing WoW source rows and RuneWaker mappings are retained. Unknown,
|
||||
native and omitted non-damage rows do not acquire invented zero attributes.
|
||||
- `wow335AbilityCatalog.ts` supplies a narrow fallback for selected 19263,
|
||||
which the generator's class-mask filter omitted despite its Hunter skill-line
|
||||
row. Its pinned PreventionType lookup covers all 190 selected Hunter rank IDs
|
||||
and the public merged ability aliases.
|
||||
- `wow335DeterrenceRuntime.ts` validates the selected ability and all five real
|
||||
aura instances: exact behavior, owner, target, application time and lifetime.
|
||||
- `combatStore.ts` applies the linked children after accepted parent effects,
|
||||
forces this self cast to the caster even when a caller supplies a foreign
|
||||
target, synchronizes malformed/expired packages and resolves incoming results.
|
||||
- `partyRuntime.ts` selects a learned, ready self defense while a live enemy
|
||||
targets the Hunter and health is below its configured defensive threshold
|
||||
(default 55%), or a recognized defensive mechanic is imminent. It rejects an
|
||||
active cast and respects equipment, cooldown, resources and existing GCD.
|
||||
|
||||
## Defense and attack behavior
|
||||
|
||||
The parent supplies +100 parry percentage points, sourced spell deflection and
|
||||
pacify. Linked 67801 supplies ignore-facing and -100 incoming ranged-hit
|
||||
percentage points. Simultaneous Hunters keep separate owner-local packages.
|
||||
Incomplete, foreign and expired packages cannot lend parry or ranged-hit stats
|
||||
to incoming resolution; the shared tick removes their package members.
|
||||
|
||||
Eligible landed, direct WoW Magic/ranged spell packets become an explicit
|
||||
`deflect` outcome with zero effective damage. Raw damage remains diagnostic;
|
||||
critical/block/resistance mitigation is discarded, no absorb is consumed and
|
||||
no landed-hit proc is credited. Earlier miss/full resist remains the actual
|
||||
outcome. Ordinary ranged autos use Aura185's miss modifier, and melee uses
|
||||
parry. Known source flags are matched against the complete source row tuple
|
||||
before applying AlwaysHit, NoActiveDefense, NoAttackMiss or defense exclusions.
|
||||
The complete package rejects the front-arc requirement through its linked aura.
|
||||
|
||||
The exact 100% deflect package also suppresses eligible hostile control, aura,
|
||||
periodic application, pull and leech-healing riders. Damage retains its real
|
||||
roll. Stun is read from the original combat-update snapshot, before a newly
|
||||
queued slow changes control state. Source bypasses, stunned defenders, periodic
|
||||
ticks, autos and unknown/native source records do not borrow spell deflection.
|
||||
|
||||
Pacify stops owner weapon autos and selected Hunter PACIFY=2 attacks, including
|
||||
Arcane Shot and Steady Shot. SILENCE=1 actions such as traps and Mend Pet remain
|
||||
eligible under their ordinary prerequisites. This does not pacify the pet.
|
||||
|
||||
## Verification
|
||||
|
||||
The final focused run passes **110/110 checks in 15 files**, including 12
|
||||
actual cast/defense integration cases. The exporter Node test and two selected
|
||||
catalog/prevention checks pass. Ready-five passes seven Vitest and twenty Python
|
||||
checks. Final 8 GB TypeScript, normal prebuild hooks, isolated Vite output,
|
||||
precompression and release-bundle verification pass; the isolated output avoids
|
||||
the previously observed shared `dist` lock. Build logs and JSON reports are in
|
||||
`artifacts/deterrence-continuation-2026-10-07/`. Actual player/NPC fixtures are
|
||||
`wow335DeterrenceCast.integration.test.ts` and
|
||||
`wow335DeterrenceDefense.integration.test.ts`.
|
||||
|
||||
The required avatar scope also passes **56 checks in 11 files**, following the
|
||||
equipped-item requirement change. No character/gear rendering code changed.
|
||||
|
||||
The fixtures prove actual player and NPC Frostbolt deflection, no newly applied
|
||||
slow on a deflected spell, existing stun exclusion, melee parry, periodic and
|
||||
AlwaysHit exclusions, source-bound cast requirements, linked expiry and
|
||||
simultaneous owners. Source ranged spell 2643 truthfully misses under Aura185;
|
||||
the helper separately verifies its eligible deflect branch for a landed input.
|
||||
Arcane/Steady casts reject while Freezing Trap succeeds under pacify. A torn
|
||||
Aura185 package matches the same-seed auto baseline; regular tick cleanup leaves
|
||||
the second Hunter's valid package intact. Native/unknown source rejection is
|
||||
helper evidence, not a new native live encounter.
|
||||
|
||||
The final frozen build contains 5,967 files / 118,501,108 bytes; aggregate SHA-256
|
||||
`a30787cafc7e1421e872cb37f883147177981b6627dc4238bf89f46ffb850ec2`.
|
||||
Fresh content `content-151109efcb20cc00` has manifest SHA-256
|
||||
`40d5c9e16d8045b8c240d57ca52017aaf95e423c44f96507aceb02285dca693c`;
|
||||
all 27,587 referenced objects / 6,554,643,247 bytes verify against that captured
|
||||
manifest before entry. The isolated snapshot is
|
||||
`artifacts/deterrence-continuation-2026-10-07/frozen-20261007-161003375/`.
|
||||
|
||||
One ordinary Cellblock Rush run, `20261007-161404213`, selects **Hamhock 1717**
|
||||
(`creature-79098`) and clears native **1/1 in 03:13.6**, with no deaths. The
|
||||
isolated UI-created Discipline Priest enters at level 30 with four asserted
|
||||
Ready companions; normal progression finishes the Priest at 32 and companions
|
||||
at 31. Six attributed Lesser Heal events supply **1,952 effective healing**,
|
||||
zero overheal. No live combat-state seeding or Boss Testing is used.
|
||||
|
||||
Root independently verifies the unchanged frozen build/manifest, all seven
|
||||
loaded chunks and 2,124 served manifest objects / 77,019,550 bytes with zero
|
||||
integrity failures. Event coverage is **745 consecutive IDs 9–753**, with no
|
||||
gaps, discarded events or recorder errors. There are zero page exceptions;
|
||||
the only console error is the existing favicon 404. The audit matches immutable
|
||||
local bytes to captured successful response URLs/status and manifest references;
|
||||
raw response bodies are not retained or independently hashed.
|
||||
|
||||
Root inspects the actual roster, equipped entrance, route-50 and results PNGs
|
||||
under `playtest-artifacts/stockade-weapon-utilities/20261007-161404213/campaign/`.
|
||||
Bodies and visible equipment render; entrance overlap and partly offscreen
|
||||
companions limit certification of every gear/race silhouette. The file named
|
||||
`bite-sized-stormwind-stockade-boss-arrival.png` shows the native results overlay.
|
||||
No camera-drag proof or level-60 live Deterrence claim is credited. This is the
|
||||
one selected boss slice, despite the results screen's generic full-clear label.
|
||||
The harness cleans up its browser/helper/server. No gameplay change or second
|
||||
live run follows the freeze.
|
||||
|
||||
The independent Luna extra-high evidence review confirms those fingerprints,
|
||||
event coverage and native result without launching another run or rehashing
|
||||
content. Native completion ends traversal at checkpoint 75/86 before the
|
||||
dedicated boss handler (`bossClearAttempted:false`); observed boss death and
|
||||
persisted kill progress prove the clear. Do not claim a completed scripted boss
|
||||
handler or every route checkpoint. Its report is
|
||||
`artifacts/deterrence-continuation-2026-10-07/luna-evidence-review.md`.
|
||||
Final release hygiene passes all 101 changed paths, including untracked files;
|
||||
the user Git index remains empty.
|
||||
|
||||
## Remaining boundaries
|
||||
|
||||
The underlying miss/resist/armor formulas remain the existing simulator model,
|
||||
not a complete AzerothCore hit table or RNG reproduction. The exported enemy
|
||||
table excludes unsupported/non-damage rows, so general shout/utility deflection
|
||||
remains unproven. Ordinary miss/resist control riders without this exact
|
||||
Deterrence package retain their earlier behavior. Same-update controls use the
|
||||
original-frame simultaneous policy; source server event ordering is not claimed.
|
||||
The always-equipped Training Gear fixture does not exercise a truly empty
|
||||
matching-equipment branch. Unknown/custom/native rates and equipment remain
|
||||
separate source gaps.
|
||||
|
||||
The routine level-30 Ready-only live party does not unlock level-60 Deterrence;
|
||||
its player/NPC behavior is actual store-fixture evidence. Cellblock Rush proves
|
||||
only the selected one-boss route and regression behavior. Level cap 80, saved
|
||||
stat/profile compatibility, loot wiring and readiness policy remain intact.
|
||||
No full suite, expanded balance sweep, staging, commit or deployment is part of
|
||||
this continuation. All subprocesses use GPT-6 Luna extra-high.
|
||||
|
||||
Related: [catalog](DETERRENCE_CATALOG_2026-10-07.md),
|
||||
[runtime contract](DETERRENCE_RUNTIME_CONTRACT_2026-10-07.md),
|
||||
[hit source inputs](ENEMY_HIT_SOURCE_INPUTS_2026-10-07.md).
|
||||
@@ -0,0 +1,87 @@
|
||||
# WoW 3.3.5 Hunter Deterrence runtime contract — 2026-10-07
|
||||
|
||||
## Scope
|
||||
|
||||
`src/game/wow335DeterrenceRuntime.ts` binds the selected Hunter catalog row to
|
||||
the audited Spell.dbc 19263 parent and its `spell_linked_spell` Aura link to
|
||||
67801. It supplies source-shaped aura descriptors and a narrow post-hit
|
||||
deflect gate. The shared combat store, party runtime, and combat-result paths
|
||||
own cast execution, actual aura application/removal, and outcome recording.
|
||||
|
||||
The five source aura definitions are identified by their source spell/effect
|
||||
IDs: parent 19263 Auras 47, 287, and 25; linked child 67801 Auras 288 and 185.
|
||||
All five instances must belong to the same Hunter self-cast, target that Hunter,
|
||||
share one application time, and expire exactly five seconds later. The runtime
|
||||
contract returns no Deterrence package when any member is missing, duplicated,
|
||||
expired, foreign-owned, or out of step with the other members. Removing or
|
||||
expiring one member should remove the other members for that same owner,
|
||||
target, and application time.
|
||||
|
||||
The helper exports the parent effects for the selected catalog mapping and the
|
||||
linked-child effects for the original cast target. Aura 47 carries a flat +100
|
||||
parry modifier. Aura 25 carries the pacify tag. Aura 287 supplies the 100-point
|
||||
spell-deflect source aura. Aura 288 removes the attacker's frontal-arc
|
||||
requirement for this package. Aura 185 carries the -100 incoming ranged-hit
|
||||
modifier; the existing shared hit-stat path consumes it, so the combat caller
|
||||
must not add a second copy of that penalty.
|
||||
|
||||
## Deflect gate
|
||||
|
||||
`resolveWow335DeterrenceIncoming` is a final gate after the existing combat
|
||||
resolver has produced a landed `hit`, `crit`, or `block`. It preserves `miss`,
|
||||
`resist`, `dodge`, and `parry`. For an otherwise eligible event it returns the
|
||||
distinct `deflect` outcome; the source aura is 100%, so this gate adds no random
|
||||
roll.
|
||||
|
||||
The input must identify a non-periodic WoW 3.3.5 spell source and match the
|
||||
exact enemy damage-source row, including DamageClass, SchoolMask, and raw
|
||||
AttributesEx0/3/7. Only Magic (DamageClass 1) and ranged (DamageClass 3) spells
|
||||
qualify. DamageClass None, melee, unknown, foreign-profile, periodic, and
|
||||
weapon-auto inputs remain unchanged. A stun suppresses deflection. The core's
|
||||
source bypasses are preserved: AttributesEx3 `ALWAYS_HIT` (`0x00040000`)
|
||||
suppresses both branches; ranged DamageClass also skips the deflect check for
|
||||
AttributesEx0 `NO_ACTIVE_DEFENSE` (`0x00200000`); Magic DamageClass skips it for
|
||||
AttributesEx7 `NO_ATTACK_MISS` (`0x02000000`). The front-arc gate applies unless
|
||||
Aura 288 is active. A complete accepted Deterrence package always contains Aura
|
||||
288, so an attacker behind the Hunter remains eligible.
|
||||
|
||||
Ordinary ranged autos never produce `deflect`. They receive Aura 185's separate
|
||||
incoming ranged-hit modifier through the existing aura-stat path. The helper
|
||||
does not calculate general miss/resist chances, and it does not widen the
|
||||
simulator's existing combat table to other spells or profiles.
|
||||
|
||||
The shared combat path also uses this exact eligibility gate before applying
|
||||
non-damage spell riders while the complete Deterrence package is active. For a
|
||||
source event that is guaranteed to deflect if it lands, control, target-aura,
|
||||
periodic, pull, and leech-healing riders are skipped while damage still goes
|
||||
through the ordinary hit resolver and records `deflect` only after its normal
|
||||
landed result. This suppression is package-specific; ordinary sourced attacks
|
||||
outside Deterrence retain the simulator's existing rider behavior on miss or
|
||||
resist. Same-update incoming events use the combat store's original-frame stun
|
||||
snapshot, consistent with its simultaneous batch policy.
|
||||
|
||||
## Identity and provenance
|
||||
|
||||
The source contract continues to use the exact audited 19263, 67801, and
|
||||
type-2 link signatures in `src/game/wow335Deterrence.ts`. The runtime ability
|
||||
contract accepts only the selected `wow335-hunter-deterrence` Hunter mapping,
|
||||
its `wow335-progression` source, DBC ID 19263, self target, and the three exact
|
||||
parent source effects. Runtime activation is derived from the actual serializable
|
||||
`ActiveAura` records; callers do not pass a copied source-package object as
|
||||
proof. Incoming deflection independently checks its spell ID and hit attributes
|
||||
against the exact WoW damage-source table. Missing or mismatched source fields
|
||||
fail closed.
|
||||
|
||||
The pinned source evidence and the limits of the imported behavior are recorded
|
||||
in [`WOW335_DETERRENCE_SOURCE_2026-10-06.md`](WOW335_DETERRENCE_SOURCE_2026-10-06.md).
|
||||
The source row authenticates hit-class and bypass metadata; the game's current
|
||||
miss/resist calculations remain its own policy. This contract does not create a
|
||||
complete AzerothCore hit table.
|
||||
|
||||
## Focused verification
|
||||
|
||||
Run `npx vitest run src/game/wow335Deterrence.test.ts src/game/wow335DeterrenceRuntime.test.ts`
|
||||
for the exact source rows, linked package identity and lifetime, cleanup scope,
|
||||
branch-specific bypass bits, and unsupported-input exclusions. The mechanics
|
||||
work remains within the project's Ready-five certification boundary, level-80
|
||||
cap, and existing loot scope.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Enemy hit source inputs — 2026-10-07
|
||||
|
||||
The enemy damage source exporter now preserves the raw WoW attribute words that
|
||||
the pinned 3.3.5a hit-result path checks before miss, avoidance, and deflect
|
||||
resolution. The work adds source inputs only; the runtime consumer owns how
|
||||
these flags affect hit resolution.
|
||||
|
||||
## Pinned source and field mapping
|
||||
|
||||
The WoW source is the local 3.3.5a `Spell.dbc` at
|
||||
`../wow-pvt-server-backups-2/wow335a/Data/patch-A.MPQ/DBFilesClient/Spell.dbc`.
|
||||
Its SHA-256 is
|
||||
`57011d528fd66cf39f5ab6fb168e389ab9e4cc1b8050f7201a54239d489b415c`;
|
||||
the verified layout is 234 fields and 936-byte rows.
|
||||
|
||||
The pinned AzerothCore `src/server/shared/DataStores/DBCStructure.h` maps
|
||||
Spell.dbc field 4 to `Attributes`, field 7 to `AttributesEx3`, and field 11 to
|
||||
`AttributesEx7`. `SpellInfo.cpp` copies those DBC words to the corresponding
|
||||
server fields. `SharedDefines.h` identifies the relevant source bits:
|
||||
|
||||
| Field | Core bit | Core meaning |
|
||||
|---|---|---|
|
||||
| `Attributes` (4) | `SPELL_ATTR0_NO_ACTIVE_DEFENSE`, `0x00200000` | Prevent physical avoidance |
|
||||
| `AttributesEx3` (7) | `SPELL_ATTR3_ALWAYS_HIT`, `0x00040000` | Ignore hit result |
|
||||
| `AttributesEx7` (11) | `SPELL_ATTR7_NO_ATTACK_MISS`, `0x02000000` | Spell cannot be missed |
|
||||
|
||||
In the pinned `Unit.cpp`, `MeleeSpellHitResult` returns early for
|
||||
`SPELL_ATTR3_ALWAYS_HIT` and `SPELL_ATTR0_NO_ACTIVE_DEFENSE`; the latter follows
|
||||
the miss check and precedes resist, avoidance, and deflect handling.
|
||||
`MagicSpellHitResult` returns early for `SPELL_ATTR3_ALWAYS_HIT` and
|
||||
`SPELL_ATTR7_NO_ATTACK_MISS` before its ordinary hit/deflect checks. The exact
|
||||
raw attributes remain available so the runtime can follow these source gates.
|
||||
|
||||
## Data path and limits
|
||||
|
||||
`scripts/dungeon-pipeline/export-enemy-damage-types.mjs` reads fields 4, 7, and
|
||||
11 into a structured `hitSource` object for each exported WoW damage row. It
|
||||
records explicit zeros when the matched DBC row contains zero. The generated
|
||||
catalog records these source field indexes in
|
||||
`generatedFrom.wowSpellHitFields` and the pinned DBC hash above.
|
||||
|
||||
`src/game/mobDamageTypes.ts` carries `hitSource` only from a matched `wow335`
|
||||
source spell. `src/game/enemyAttacks.ts` copies the same object to
|
||||
`wowHitSource` during normalization. Unknown spell IDs have no source object;
|
||||
RuneWaker rows remain in their separate numeric spell namespace and do not
|
||||
receive WoW hit metadata.
|
||||
|
||||
This metadata does not define complete WoW hit, miss, resist, dodge, parry,
|
||||
block, or deflect tables. Those outcomes and their ordering remain in the
|
||||
combat runtime. Non-damage/script rows excluded by the exporter, unknown DBC
|
||||
spells, and native/RuneWaker attacks remain without this WoW source object.
|
||||
|
||||
## Focused checks
|
||||
|
||||
- `node --test scripts/dungeon-pipeline/export-enemy-damage-types.test.mjs`
|
||||
builds a small WDBC fixture and proves fields 4/7/11 are read independently,
|
||||
including a real zero-valued raw word.
|
||||
- `npx vitest run src/game/mobDamageTypes.test.ts` checks the generated WoW
|
||||
metadata through resolution and attack normalization, and verifies absent
|
||||
metadata for unknown and RuneWaker source lookups.
|
||||
|
||||
The exporter can target a temporary JSON path with `--output` for its focused
|
||||
fixture; the production output remains
|
||||
`src/game/generated/enemyDamageTypeSources.generated.json`.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Healer mana recovery
|
||||
|
||||
A living player or NPC healer now supplies **Healer Mana Recovery** to living
|
||||
mana users in the local party between fights. The buff restores **15% of each
|
||||
recipient's maximum mana per second**, in addition to existing recovery. It
|
||||
supports primary and secondary mana pools, caps at maximum, and does not stack
|
||||
with multiple healers. It grants no recovery while any living enemy is engaged
|
||||
or has party threat. Dead recipients and parties without a living healer are
|
||||
excluded. Native RoM recovery formulas and pulse cadence remain unchanged.
|
||||
|
||||
**Pause > Options > Gameplay and interface > Healer mana recovery** toggles
|
||||
the buff. It defaults to On, including for older profiles missing the setting.
|
||||
Changes use normal character settings persistence. Turning it off removes
|
||||
existing applications immediately. The player status panel and mana-using
|
||||
NPC frames show its icon and tooltip.
|
||||
|
||||
## Ownership
|
||||
|
||||
- `src/game/healerManaRecovery.ts`: definition, provider selection, recipients,
|
||||
non-stacking lifecycle, and maximum-mana recovery amount.
|
||||
- `src/game/combatStore.ts`: saved setting, combat gating, player recovery.
|
||||
- `src/game/partyRuntime.ts`: companion recovery and shared aura refresh.
|
||||
- `src/ui/OptionsPanel.tsx`, `src/ui/PartyFrames.tsx`: menu and buff display.
|
||||
- Native recovery parity tests explicitly disable the convenience aura.
|
||||
|
||||
## Verification
|
||||
|
||||
136 focused tests pass across the new recovery integration, native recovery
|
||||
parity, combat store, Options, and AuraStrip suites. Ready-five verification
|
||||
passes (7 Vitest and 20 Python checks). The final production build, static bundle verification, and release
|
||||
candidate check pass.
|
||||
|
||||
`scripts/playtest-healer-mana-recovery.cjs` enters Cellblock Rush through the
|
||||
normal dungeon picker using an isolated offline RoM Priest profile. The final
|
||||
run selected Targorr the Dread, as observed in its live Boss Guide. Pre-entry
|
||||
profile preparation sets level 30. Ordinary menu clicks switch the buff off
|
||||
and on; ordinary Urgent Heal spending is followed by natural idle recovery.
|
||||
Evidence lives in `artifacts/playtest/healer-mana-recovery/`, including loaded
|
||||
asset URLs, actual selected boss from the live guide, saved toggle state,
|
||||
resource samples, desktop and 960x540 menu screenshots, and runtime errors.
|
||||
Exact rate, combat transitions, NPC providers and recipients, no stacking,
|
||||
healer death, and maximum clamping are verified by focused integration tests.
|
||||
The browser run is an idle recovery and UI check, not a boss-clear or broader
|
||||
RoM balance certification.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Lichborne control escape — 2026-10-06
|
||||
|
||||
## Source proof
|
||||
|
||||
Lichborne spell 49039 is an exact WoW 3.3.5a source row, not a tooltip-based inference. The selected generated row is `wow335-death-knight-lichborne`, class `death-knight`, source profile `wow335`, talent node `talent-2215`, learned at level 55. It is a self spell with a 10-second duration, zero cast time, zero GCD, 120-second cooldown, and no power cost. Its spell defense fields are `damageClass=0`, `schoolMask=32`, `directDamageFromEffects=false`, and the complete retained attribute words are `Attributes0=262144`, `Attributes1=163872`, `Attributes3=0`, `Attributes5=393224`.
|
||||
|
||||
The local client profile is World of Warcraft build `3.3.5a.12340`, read from `../wow335a/Data/enUS/patch-enUS.MPQ`, table `DBFilesClient/Spell.dbc`. The source-audit artifact at `artifacts/enemy-control-followup-2026-10-06/source-audit.json` records the raw `Spell.dbc` row. Its eight raw attribute words, in order, are `[262144, 163872, 0, 0, 0, 393224, 0, 0]` (`0x00040000`, `0x00028020`, then three zero words, `0x00060008`, then two zero words). The active generated source remains `src/game/wow335AbilityData.generated.json`.
|
||||
|
||||
The selected row has exactly three effects. Each is effect type 6 (`APPLY_AURA`) and aura type 77 (`MECHANIC_IMMUNITY`), with caster recipient and zero points-per-level. The raw `Spell.dbc` base-points are zero for all three; the selected generator represents them as base points 1 and preserves `sourceBasePoints=0`.
|
||||
|
||||
| Effect | Raw misc value | Mechanic | Raw base points | Die sides | Selected source base points |
|
||||
| --- | ---: | --- | ---: | ---: | ---: |
|
||||
| 0 | 1 | Charm | 0 | 1 | 0 |
|
||||
| 1 | 5 | Fear | 0 | 0 | 0 |
|
||||
| 2 | 10 | Sleep | 0 | 0 | 0 |
|
||||
|
||||
`Attributes1=163872` is `0x00028020`; it includes `SPELL_ATTR1_IMMUNITY_PURGES_EFFECT=0x00008000`. `Attributes5=393224` is `0x00060008`; it includes `SPELL_ATTR5_ALLOW_WHILE_STUNNED=0x00000008`, `SPELL_ATTR5_ALLOW_WHILE_FLEEING=0x00020000`, and `SPELL_ATTR5_ALLOW_WHILE_CONFUSED=0x00040000`. The source row therefore proves Charm, Fear, and Sleep immunity and purge, plus casting while stunned, feared, or confused. Its cast allowances are separate from the mechanics it grants: Lichborne does not grant Stun or Confuse immunity.
|
||||
|
||||
The existing generated aura mapping represents the selected Fear and Sleep effects as control immunities. The current `AuraDefinition` control-immunity type has no Charm value, so the source proof records Charm from the exact DBC effect but does not alias it to a different runtime mechanic.
|
||||
|
||||
## Pinned core behavior
|
||||
|
||||
The read-only AzerothCore reference is pinned to `e822d718322289dd036fb2daf8867ba41283bf9a`. In `src/server/shared/SharedDefines.h`, the attribute definitions name the purge, stunned, fleeing, and confused flags above. In `src/server/game/Spells/SpellInfo.cpp:2488–2537`, `SpellInfo::ApplyAllSpellImmunitiesTo` applies each mechanic-immunity mask and, when the purge flag is present, removes existing auras whose spell mechanic masks intersect it. Lichborne therefore purges matching Charm, Fear, and Sleep mechanics when applied; its source effects do not include Stun or Confuse, so a separate Stun or Confuse aura stays active.
|
||||
|
||||
In `src/server/game/Spells/Spell.cpp:6836–6853`, `Spell::CheckCasterAuras` derives the cast-time mechanic mask from immunity effects on spells carrying the purge flag. The later active-aura check skips an aura whose whole spell mechanic mask intersects that immunity mask (`Spell.cpp:6920–6978`). The `Attributes5` allowances independently cover stunned, fleeing, and confused caster states. This explains both shared shout cases: 49039 can start while feared, and it can start while the 20511 child applies a stun effect because that aura retains spell-level `Mechanic=FEAR`. A standalone stun also permits casting through the explicit stunned flag, but remains active after the cast. Confusion likewise permits casting and remains active.
|
||||
|
||||
## Runtime boundary
|
||||
|
||||
`enemyControlImmunityPurge.ts` recognizes only the exact selected WoW rows for Berserker Rage 18499, Bladestorm 46924, and Lichborne 49039. For Lichborne it checks the class, ability identity, source profile, damage class, school mask, all retained attribute words, and each aura's effect index, aura type, mechanic value, base-points scalar, die sides, points-per-level, recipient, and generated source aura ID. Missing or changed fields fail closed.
|
||||
|
||||
The Lichborne proof grants `charm`, `fear`, and `sleep`, records the named-shout Fear purge, and separately records the direct cast allowances `fear`, `stun`, and `confuse`. The named cleanup remains limited to active, same-target auras from spells 5246, 19134, 20511, and 29544. It removes those because their complete spell-level mechanic is Fear, including the 20511 aura that applies a stun effect. A recognized generic Fear, stun, or confusion label can also allow Lichborne to be cast through its explicit `Attributes5` flags; those auras remain active and keep their control state. Berserker Rage remains limited to its named-shout Fear purge and blocked by separate stun, confusion, and generic Fear; Bladestorm remains unable to start through control despite its later Fear and Stun immunity aura. Unknown source-spell controls fail closed.
|
||||
|
||||
Player and party integration must select the learned Lichborne row before using this proof. That integration retains the existing target validity, cooldown, global cooldown, and cost checks. No generic magic miss, resist, or deflect roll is part of this proof.
|
||||
|
||||
Focused helper validation: `npm test -- --run src/game/enemyControlImmunityPurge.test.ts`.
|
||||
@@ -0,0 +1,197 @@
|
||||
# RoM residual rendering and Raksha route review — 2026-10-06
|
||||
|
||||
This follows the completed all-30 texture/lighting pass in
|
||||
`ROM_ENVIRONMENT_REVIEW_2026-10-06.md`. The user requested continuation on the
|
||||
remaining Origin root cards and Raksha camera/route defects. Evidence uses new
|
||||
paths under `artifacts/playtest/rom-residual-review-20261006/`; the accepted v5
|
||||
build, source assets, manifests and screenshots remain historical baselines.
|
||||
|
||||
Final status on 2026-10-07: Raksha's corrected source entrance/navigation package
|
||||
and reviewed local build are installed. Ordinary entry/first-step walking and
|
||||
camera checks pass. Origin's source-opaque dark root card and Raksha's disconnected
|
||||
Shadoj route remain unresolved; this follow-up does not certify full clears.
|
||||
|
||||
## Starting evidence
|
||||
|
||||
- Origin's `elves_gaint_roots_village` material requests native alpha test 192,
|
||||
but its raw DDS, prepared PNG and shipping KTX mips are fully opaque. The prior
|
||||
exporter omits vertex color/alpha and face-culling data. A fresh source review
|
||||
found `IRuMorphTarget::GetDiffuse()` and native shader multiplication by vertex
|
||||
RGBA; the exact affected meshes and values require verification before a patch.
|
||||
No black-keyed alpha or guessed blend semantics are authorized by this evidence.
|
||||
- Raksha's authoritative source entrance `[1857,297,1161]` maps to local
|
||||
`[-0.020312,21.013313,11.334656]`. Packaged navigation projected it to
|
||||
`[0.021786,10.664467,17.560761]`, a 12.077449 m displacement. That large shift
|
||||
requires source-floor and runtime attribution; it is not yet proof of the
|
||||
camera or walking failure's complete cause.
|
||||
- Shared camera obstruction currently combines physical sphere sweeps with a
|
||||
dedicated wall/pillar sight ray. Raksha also mounts its navigation GLB as
|
||||
physical ground, a workaround introduced before its WDB floor was verified.
|
||||
Any correction must distinguish source geometry, derived navigation and
|
||||
camera-only obstruction, while preserving ordinary right-drag input.
|
||||
|
||||
## Scope and validation
|
||||
|
||||
Luna subprocesses independently inspect native material streams, actual camera
|
||||
framing and source/navigation/collision correspondence. Root owns shared runtime
|
||||
changes and asset promotion. Source experiments remain staged until exact
|
||||
identities, material semantics and preservation proofs are reviewed.
|
||||
|
||||
Ordinary UI/keyboard checks must verify Raksha entry and reachable movement,
|
||||
Origin material rendering, and Stockade fallback where shared runtime changes
|
||||
apply. Runtime stores remain read-only in gameplay proofs. The review does not
|
||||
certify full clears, combat balance, or a bridge to Origin's disconnected
|
||||
Falynum approach. Final focused checks, build and release hygiene follow the
|
||||
actual implementation.
|
||||
|
||||
## Confirmed follow-up findings
|
||||
|
||||
The fresh, hash-matched `gaint_tree_a.ros` probe reports vertex format 9, no
|
||||
`ruVERTEXFORMAT_DIFFUSE`, and no diffuse-color pointer on material 1's mesh.
|
||||
There is no omitted vertex alpha on this exact root card. The native standard
|
||||
shader defaults to one-sided `D3DCULL_CCW` without a material override, whereas
|
||||
the GLB and runtime draw both sides. The exact-face A/B and reverse-side control
|
||||
confirm that Three FrontSide matches its native winding. However, the dark face
|
||||
visible from the recorded leg-18 walking location is front-facing and remains
|
||||
under FrontSide. Culling therefore does not fix that panel. Its sole native
|
||||
texture channel is diffuse, and DDS/PNG/KTX alpha remains fully opaque. No
|
||||
vertex mask, extra opacity channel or color-key rule supports a transparency
|
||||
patch. Origin remains unchanged; this residual appearance is unresolved.
|
||||
|
||||
The culling comparison is an isolated environment-preview diagnostic at a
|
||||
recorded walking location, not a new ordinary gameplay run. Exact source,
|
||||
served-asset and screenshot identities are recorded in
|
||||
`origin/culling-ab/origin-roots-village-culling-conclusion.json` under this
|
||||
review's evidence directory.
|
||||
|
||||
Raksha's bad runtime position has no visible floor below it. The visible deck
|
||||
above it is at Y≈13.408, with physical collision at Y≈13.412; the camera stops
|
||||
0.320 m below it, matching its radius/gap. At the authored XZ the visible and
|
||||
physical source floors are at Y≈20.770/20.765. Ordinary right-drags confirm the
|
||||
camera is responding correctly within the misplaced volume.
|
||||
|
||||
The native collision floor in chunk 003 (`01_1_mesh_0`) has interleaved POSITION
|
||||
data (stride 8, offset 0). Recast's Three adapter copies the backing array and
|
||||
steps by three floats, while the pipeline's inspection correctly uses attribute
|
||||
getters. Generation therefore consumes different coordinates than its own
|
||||
input report. The durable fix unpacks positions in memory before generation;
|
||||
source collision and visual bytes remain unchanged. Corrected navigation and
|
||||
entrance candidates require staging, route checks and ordinary gameplay review.
|
||||
|
||||
## Packaging gate
|
||||
|
||||
The corrected full bake restores the entry to
|
||||
`[-0.020312,20.837463,11.334656]`, 0.17585 m from its Lua source point. Exact
|
||||
Detour queries connect the first 2 m and 10 m forward; the route to Shadoj
|
||||
remains partial. Its complete topology contains 137,575 polygons, 268,162 links
|
||||
and 588,703 detail triangles, exceeding the runtime's existing limits of
|
||||
50,000/100,000/200,000. A packaged-asset integration test caught this before a
|
||||
new build was installed. The first local asset promotion was restored from its
|
||||
exact backups; both promotion and rollback proofs remain in the evidence tree.
|
||||
|
||||
The full bake remains diagnostic evidence. Shipping selection must retain whole
|
||||
actual Detour components tied to source entrance/population anchors, preserve
|
||||
their exact directed links, and pass all runtime limits. It must not raise
|
||||
budgets or connect disconnected islands. That smaller candidate and ordinary
|
||||
gameplay validation are pending.
|
||||
|
||||
Source-anchor analysis confirms the source-to-local transform agrees with all
|
||||
123 active combat/boss rows within 7.5e-7 m. Of 251 entrance/support/population
|
||||
anchor records (including duplicates), only 29 are within the existing 4 m
|
||||
population projection limit of the corrected full navigation. All accepted
|
||||
faces point upward. The other 222 must remain unmatched; snapping them to remote
|
||||
surfaces is not supported. The source entrance and Shadoj are within 0.176 m and
|
||||
0.034 m respectively, but their components remain disconnected.
|
||||
|
||||
The workspace-wide prebuild reached the performance audit and failed on an
|
||||
unrelated, concurrently modified Utgarde Pinnacle visual asset: 16,277 draw
|
||||
calls with 14,695 unbatched savings against its existing 14,018 savings ceiling.
|
||||
No RoM visual changed. The previously published immutable Utgarde asset passes
|
||||
the same budget (16,277/14,018), as recorded in
|
||||
`utgarde-frozen-release-budget-proof.json`. This pass will publish only the three
|
||||
Raksha navigation/metadata files and preserve other manifest entries. The
|
||||
workspace-wide audit failure must not be described as a complete prebuild pass.
|
||||
|
||||
## Reviewed source-anchored package
|
||||
|
||||
The fresh v2 bake uses the same path as normal environment regeneration:
|
||||
`lib/source-navigation-anchors.mjs` transforms native recipe points with the
|
||||
full inverse WDB placement; `recast-bake.mjs` queries actual Detour references
|
||||
within explicit limits, rejects missing required anchors, then retains complete
|
||||
weak components. Lua entry must be within 0.5 m and Shadoj within 0.25 m. Other
|
||||
native anchors retain the existing 4 m limit. No runtime budget is raised.
|
||||
|
||||
V2 contains six complete components, 239 polygons, 514 actual directed links,
|
||||
653 detail triangles and 1,492 spatial-index references. The exact query input
|
||||
has 128 records (126 native support coordinates plus the two required guards):
|
||||
16 are retained, 112 remain unmatched. This differs from the preliminary
|
||||
251-record diagnostic, which duplicated population/support rows. Excluded areas
|
||||
are not individually proven decorative; this is sparse source-anchor coverage,
|
||||
not full-dungeon navigation.
|
||||
|
||||
Independent review matched every retained polygon, triangle coordinate and
|
||||
directed link against the full bake. V1/v2 BIN chunks are identical; v2 preserves
|
||||
source coordinates/evidence/required flags in JSON provenance. Required entry
|
||||
and boss snaps are 0.17585/0.03368 m. Actual runtime graph checks pass for 2 m and
|
||||
10 m forward routes; entry-to-Shadoj remains disconnected.
|
||||
|
||||
The reviewed navigation SHA is
|
||||
`77b2b22bb89474bfe2bd9974d9f58cd680634d4f7feef6163723bb8f3afe6117`
|
||||
(166,048 bytes). Shipping/canonical navigation and reports plus import metadata
|
||||
were promoted with a separate exclusive backup. Visual and all seven collision
|
||||
files are byte-identical to their baselines. Registry regeneration changes only
|
||||
Raksha's navigation identity/counts and entrance/area center; source yaw remains.
|
||||
The scoped content update changes three Raksha files and preserves all 21,602
|
||||
other manifest entries, frozen as `content-151109efcb20cc00` with manifest SHA
|
||||
`40d5c9e16d8045b8c240d57ca52017aaf95e423c44f96507aceb02285dca693c`.
|
||||
|
||||
Focused checks pass: 13 Node pipeline tests, 12 runtime tests, and ten Raksha
|
||||
GLBs with zero validator errors/warnings (230 known tooling messages excluded).
|
||||
TypeScript and the isolated production Vite build pass (11.26 s). Static
|
||||
compression produces 258 files from 129 sources; release-bundle verification
|
||||
passes with 126 chunks and 5,569 bootstrap objects/63,571,471 stored bytes. Build
|
||||
index SHA is `8ca382b4ad8919ba100ee1e0ee6c7d64a30b87c7acfbf6d65fc8184eb10c9958`.
|
||||
|
||||
## Final gameplay and installation
|
||||
|
||||
The fresh ordinary Raksha entry is ready/grounded at
|
||||
`[-0.020324,20.857564,11.334658]`. Scene rays hit the actual source deck near
|
||||
Y=20.770, and then its collision-backed initial slope. Four WASD targets are
|
||||
reached; all eight movement samples are grounded and browser errors are zero.
|
||||
Movement completion is 8.554469 m; the later post-release settled screenshot
|
||||
snapshot is 8.876903 m, also grounded. These are separate timestamps, not two
|
||||
route runs. Standard/High use 150% resolution, 16× filtering and authored fill.
|
||||
Held right-drag works without pointer lock, and no overhead obstruction is
|
||||
reported within 40 m. Root reviewed the final entry/step screenshots. The blue
|
||||
exterior background remains visible in side-facing views; broader scenery and
|
||||
the route to Shadoj are not certified by this eight-metre check.
|
||||
|
||||
The same-build graphics fallback selects **Bazil Thredd** in Stockade Cellblock
|
||||
Rush, validates default 150% and High 200%, plus the Forsaken Abbey comparison,
|
||||
with zero errors. Root reviewed the High Stockade capture. The later complete
|
||||
performance audit is green for 106 assets with 60 known exceptions and zero
|
||||
regressions: the earlier transient unrelated Utgarde failure has cleared. This
|
||||
does not retroactively turn the earlier prebuild invocation into a passing run.
|
||||
|
||||
The reviewed build is installed as local `dist`; all 5,967 files match it
|
||||
byte-for-byte. The actual previous dist is preserved in the new exclusive
|
||||
`pre-bounded-raksha-dist-backup` (prior index SHA
|
||||
`76096ccd244c96b8163bd179858f3b25f53f29fc13c0a7a03a2edcf0da948a5e`).
|
||||
Earlier asset/build backups remain intact. Local installation proof is
|
||||
`local-build-install-proof.json`; live and frozen content manifests match at
|
||||
the final check. No remote deployment or full-clear/balance certification is
|
||||
implied.
|
||||
|
||||
Final evidence paths under this review directory:
|
||||
|
||||
- `raksha-route/source-anchored-v2/`: fresh bake, exact geometry/link/source
|
||||
preservation and runtime graph fixtures.
|
||||
- `raksha-final-gameplay/qa-report.md` and `frames/`: ordinary controls,
|
||||
graphics settings, source-floor/camera rays and frozen build identity.
|
||||
- `graphics-fallback-final/`: selected Stockade boss/settings and screenshots.
|
||||
- `source-anchored-navigation-promotion-proof.json`,
|
||||
`bounded-navigation-content-proof.json`, `local-build-install-proof.json`:
|
||||
guarded asset/content/build installation identities and backups.
|
||||
- `release-check-final.log`: final repository text hygiene, including untracked
|
||||
files. The check passed across 81 changed paths after the final documentation
|
||||
update.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Utility hit extension audit — 2026-10-06
|
||||
|
||||
## Result
|
||||
|
||||
The exact WoW 3.3.5a single-rank Shield Bash (72), Kick (1766), and Pummel
|
||||
(6552) can share the existing ordinary-creature **full-block** result. The
|
||||
binding is intentionally exact to the selected playable row, source profile,
|
||||
class, rank, source effects, and projected effect list. It does not add miss,
|
||||
dodge, parry, or mechanic-resist rolls. Those outcomes remain on the current
|
||||
effect path until the runtime carries the source inputs needed to calculate
|
||||
them faithfully.
|
||||
|
||||
Kick's playable identity is `rogue-kick`; `wow335-rogue-kick` is its raw
|
||||
generated source-chain identity. The catalog merge must retain the source
|
||||
Kick's pure five-second interrupt rather than merge the legacy prototype's
|
||||
damage and second interrupt. Pummel's selected row is the level-38 rank, so
|
||||
the contract does not permit it before its source unlock. Shield Bash remains
|
||||
the only selected rank in its chain and retains its 29703 trigger alongside
|
||||
its six-second interrupt.
|
||||
|
||||
## Source proof
|
||||
|
||||
The local `wow335a/Data/patch-A.MPQ/DBFilesClient/Spell.dbc` records for IDs
|
||||
72, 1766, and 6552 share `DmgClass=2` (melee), physical `SchoolMask=1`,
|
||||
`Attributes0=327696`, `Attributes3=8`, and `Attributes7=0`. `Attributes3=8`
|
||||
is `SPELL_ATTR3_COMPLETELY_BLOCKED`. The relevant custom-attribute bits
|
||||
`SPELL_ATTR0_CU_DIRECT_DAMAGE` (`0x00000100`) and
|
||||
`SPELL_ATTR0_CU_REQ_CASTER_BEHIND_TARGET` (`0x00020000`) are both clear in
|
||||
the pinned core: `SpellInfo::Initialize` starts `AttributesCu` at zero, and
|
||||
the active custom-attribute update SQL contains no rows for these three IDs.
|
||||
This proves only those two custom bits, not the complete custom word.
|
||||
|
||||
The exact effect slots are:
|
||||
|
||||
| Spell | Source effects | Selected projection |
|
||||
| --- | --- | --- |
|
||||
| Shield Bash 72 | Effect 68, mechanic 26, target A 6; effect 64, target A 6, trigger 29703 | Interrupt 6,000 ms; trigger spell 29703 |
|
||||
| Kick 1766 | Effect 68, mechanic 26, target A 6; second slot empty | Interrupt 5,000 ms |
|
||||
| Pummel 6552 | Effect 68, mechanic 26, target A 6; second slot empty | Interrupt 4,000 ms |
|
||||
|
||||
These signatures are also present in `src/game/wow335AbilityData.generated.json`
|
||||
and are locked in `src/game/enemyUtilitySpellBlocking.ts`. `SpellInfo.cpp`
|
||||
identifies effect 68 as `SPELL_EFFECT_INTERRUPT_CAST`; `SpellEffects.cpp`
|
||||
dispatches it to `EffectInterruptCast`. It is not a direct-damage effect.
|
||||
|
||||
The pinned AzerothCore `Unit::MeleeSpellHitResult` in
|
||||
`../wow-pvt-server-backups-2/azerothcore-wotlk/ElunaTrinityWotlk/src/server/game/Entities/Unit/Unit.cpp`
|
||||
explains why the source proof does not justify the whole table:
|
||||
|
||||
- It checks `MeleeSpellMissChance` first. Attribute 7 bit `0x02000000`
|
||||
(`SPELL_ATTR7_CANT_MISS`) is clear for all three records, so they can miss.
|
||||
- It adds `GetMechanicResistChance` next. The interrupt effect's mechanic is
|
||||
26; the target's `SPELL_AURA_MOD_MECHANIC_RESISTANCE` can resist it.
|
||||
- It then considers dodge, parry, and block. Melee attacks can block; casting
|
||||
or controlled defenders lose active avoidance/block; attacking from behind
|
||||
disables parry/block absent hit-direction immunity. Creature no-dodge,
|
||||
no-parry, and no-block flags and `SPELL_AURA_IGNORE_COMBAT_RESULT` also
|
||||
alter eligibility.
|
||||
|
||||
The generated defense projection currently omits `Attributes7`, while the
|
||||
utility resolver has no exact mechanic-resistance, miss-chance/weapon-skill,
|
||||
or complete target-state input for these spells. Consequently, a generic
|
||||
Magic miss/resist roll or guessed melee miss/dodge/parry values would not
|
||||
match this source. The implementation keeps those outcomes gated and
|
||||
resolves only the already modeled block result.
|
||||
|
||||
## Runtime boundary and verification
|
||||
|
||||
`enemyUtilitySpellBlockAbilityContract` binds the policy to the selected
|
||||
ability ID (including the public Kick identity), class, exact source, DBC
|
||||
rank, defense metadata, and full projected effect signature. It reconstructs
|
||||
only the audited known custom-bit evidence when the generated row omits it;
|
||||
conflicting explicit metadata fails closed. `resolveNpcUtilityBlockResult`
|
||||
then restricts the check to player/party casters and ordinary source NPCs,
|
||||
and asks the existing defender model for a physical melee block result with
|
||||
miss, crit, dodge, and parry disabled. A block suppresses all utility effects
|
||||
while preserving the accepted cast's cost/cooldown path. Player and party
|
||||
effect application both consume that blocked result.
|
||||
|
||||
Validation: `npx vitest run src/game/enemyUtilitySpellBlocking.test.ts
|
||||
src/game/wow335InterruptUtilityBlocking.integration.test.ts` passes 13 tests
|
||||
across two files. The unit cases verify the three exact source/projection
|
||||
contracts, selected-rank identity, and mismatch rejection. The integration
|
||||
cases cover blocked player and companion Kick/Pummel casts and ensure the
|
||||
interrupt effect is not applied on a full block.
|
||||
@@ -0,0 +1,130 @@
|
||||
# WoW 3.3.5 Hunter Deterrence source audit — 2026-10-06
|
||||
|
||||
## Result
|
||||
|
||||
This is the dated pre-integration audit. The selected catalog row, five-aura
|
||||
player/companion package and guarded hit resolution are implemented in the
|
||||
[October 7 continuation](DETERRENCE_INTEGRATION_2026-10-07.md). Missing-runtime
|
||||
statements below describe the October 6 source-audit boundary.
|
||||
|
||||
Spell 19263 is a five-second, self-targeted, aura-only source row. Its linked
|
||||
`spell_linked_spell` aura 67801 is part of the same defensive package. The
|
||||
source contract in `src/game/wow335Deterrence.ts` accepts that exact parent,
|
||||
child, and link signature and fails closed on profile or sibling drift. It
|
||||
does not import the ability or resolve combat outcomes.
|
||||
|
||||
## Client and linked-row evidence
|
||||
|
||||
The read-only WoW 3.3.5a `Spell.dbc` is
|
||||
`../wow-pvt-server-backups-2/wow335a/Data/patch-A.MPQ/DBFilesClient/Spell.dbc`,
|
||||
SHA-256
|
||||
`57011d528fd66cf39f5ab6fb168e389ab9e4cc1b8050f7201a54239d489b415c`.
|
||||
`Spell.dbc` has 234 fields, so the parsed field shift is zero. Duration index
|
||||
28 resolves to 5,000 ms in the pinned `SpellDuration.dbc` at
|
||||
`../LadiksMPQEditor/client-current/patch-S/DBFilesClient/SpellDuration.dbc`,
|
||||
SHA-256
|
||||
`eeae44ef0185924f3d6ec57ab2701c66566db86e51ab0bae017a9449126eb3a`.
|
||||
|
||||
Raw aura values below show the parsed DBC base-points value (the raw field plus
|
||||
one). All listed effects are `APPLY_AURA`, target A 1, target B 0, mechanic 0,
|
||||
radius 0, period 0, and misc value 0. No effect has a triggered spell.
|
||||
|
||||
| Spell row | Attributes 0–7 | Damage class | School | Duration | Effects |
|
||||
| --- | --- | ---: | ---: | ---: | --- |
|
||||
| 19263 Deterrence | `[327696, 16, 0, 0, 0, 0, 0, 0]` | None (0) | 1 | 5,000 ms | `47:+100`, `287:+100`, `25:0` |
|
||||
| 67801 Deterrence | `[328080, 16, 0, 0, 0, 0, 0, 0]` | None (0) | 1 | 5,000 ms | `288:+100`, `185:-100` |
|
||||
| 65871 Deterrence | `[327696, 0, 0, 0, 0, 0, 0, 0]` | None (0) | 1 | 5,000 ms | `47:+100`, `287:+100`, `25:0` |
|
||||
|
||||
The pinned read-only AzerothCore data row is
|
||||
`(19263,67801,2,'Deterrence')` in
|
||||
`../wow-pvt-server-backups-2/azerothcore-wotlk/data/sql/base/db_world/spell_linked_spell.sql`.
|
||||
The selected core defines type 2 as `SPELL_LINK_AURA` in
|
||||
`src/server/game/Spells/SpellMgr.h`. `SpellMgr::LoadSpellLinked` stores this
|
||||
under the Aura link key, and `Aura::HandleAuraSpecificMods` applies 67801 to
|
||||
the same target through the original caster when 19263 is added. On removal, the core removes
|
||||
the linked 67801 aura from that target and caster. The link is an aura
|
||||
relationship, not a Spell.dbc trigger effect.
|
||||
|
||||
Spell 65871 is a same-name DBC row, but it is not the selected package: its
|
||||
Attributes1 differs, it has no SkillLineAbility row, and no `spell_linked_spell`
|
||||
row references it. Spell 19263 is the only one of these rows listed on
|
||||
SkillLineAbility skill line 51. That source metadata does not establish how the
|
||||
current HealerMan catalog exposes or learns the spell. The generated Hunter
|
||||
catalog currently contains no 19263 entry.
|
||||
|
||||
## Pinned core behavior
|
||||
|
||||
Historical core behavior is pinned to read-only AzerothCore
|
||||
`e822d718322289dd036fb2daf8867ba41283bf9a`; the link row above comes from its
|
||||
base world SQL. The active project and generated data remain authoritative for
|
||||
implementation.
|
||||
|
||||
- Aura 47 is `SPELL_AURA_MOD_PARRY_PERCENT`. Its player aura handler enables
|
||||
parry and updates the player's parry percentage. The 19263 effect contributes
|
||||
+100 to that aura type; the actual attack result still depends on the
|
||||
defender's parry capability and the incoming attack path.
|
||||
- Aura 287 is `SPELL_AURA_DEFLECT_SPELLS`. The pinned `Unit::MagicSpellHitResult`
|
||||
and `Unit::MeleeSpellHitResult` check it for Magic-class spells and ranged
|
||||
damage-class spells. Deflection is checked after miss/resist and requires the
|
||||
defender not to be stunned. The attacker must be in the defender's front arc
|
||||
unless the target has Aura 288. The linked 67801 supplies that Aura 288, so
|
||||
the complete 19263 package does not retain a frontal-facing requirement.
|
||||
Ordinary ranged weapon auto attacks do not enter these spell deflection
|
||||
checks; linked Aura 185 changes their ranged miss chance separately.
|
||||
- Aura 25 is `SPELL_AURA_MOD_PACIFY`. Its amount is zero, but the aura handler
|
||||
still sets the pacified unit flag. The pinned `AttackerStateUpdate` returns
|
||||
for a pacified unit, and spell casts with `PreventionType=PACIFY` are rejected.
|
||||
- Aura 185 is `SPELL_AURA_MOD_ATTACKER_RANGED_HIT_CHANCE`, not a disarm aura.
|
||||
On the target, its -100 modifier raises the attacker's ranged miss chance by
|
||||
100 percentage points in `Unit::GetUnitMissChance`. It applies to ranged
|
||||
weapon attacks and ranged-damage-class spells, subject to each spell's own
|
||||
hit attributes.
|
||||
- The parent is DamageClass None, has SchoolMask 1, Dispel 0, Mechanic 0, and
|
||||
contains only self-targeted aura effects. Its own application is not an
|
||||
incoming Magic, melee, or ranged hit. Its triggered-spell fields are zero;
|
||||
67801 comes from the separate linked-aura row.
|
||||
|
||||
Relevant pinned paths are `src/server/game/Spells/Auras/SpellAuraDefines.h`,
|
||||
`SpellAuraEffects.cpp`, `SpellMgr.cpp`, and `Auras/SpellAuras.cpp`, plus
|
||||
`src/server/game/Entities/Unit/Unit.cpp` and `src/server/game/Spells/Spell.cpp`.
|
||||
The enum and consumer paths establish the raw aura roles and gates; they do not
|
||||
provide a complete simulator hit table.
|
||||
|
||||
## HealerMan boundary
|
||||
|
||||
Generic WoW aura conversion already recognizes Aura 47 as a parry stat and
|
||||
Aura 25 as a pacify tag. The incoming player and companion hit paths consume
|
||||
parry from target-local aura stats. No current Hunter Deterrence definition
|
||||
applies those auras, because 19263 is absent from the generated Hunter catalog.
|
||||
The existing 67801 contract in `enemyBlockAuras.ts` uses Aura 288 only as an
|
||||
ordinary enemy Creature block input; that is not player/party deflection
|
||||
coverage.
|
||||
|
||||
The normalized enemy attack record carries `spellId`, `damageClass`,
|
||||
`schoolMask`, `attackKind`, and `damageOrigin`, but the generated enemy damage
|
||||
source table does not retain raw `Attributes3`. The pinned
|
||||
`SPELL_ATTR3_ALWAYS_HIT` bit is `0x00040000` and bypasses the deflect hit gate.
|
||||
The current shared incoming result also lacks a distinct deflect stage and does
|
||||
not pass attacker-relative facing or defender stunned state into that resolver.
|
||||
Those missing inputs block a safe generic deflect runtime. An exact adapter
|
||||
would need the active 19263/67801 aura package on the actual defender, the
|
||||
source spell's profile, damage class and raw AlwaysHit attributes, ordinary
|
||||
ranged-auto identity, attacker-facing relation, stun state, and placement after
|
||||
the source miss/resist checks.
|
||||
|
||||
At the start of this source audit, the player autoattack scheduler lacked the
|
||||
`pacified` aura gate already present in the party action loop. The shared runtime
|
||||
continuation now gates player main/offhand and owned Scout ranged autos on active
|
||||
actor-local pacify. Actual accepted-aura scheduler fixtures cover that gate;
|
||||
they do not prove a Deterrence cast. A future 19263 import still needs the linked
|
||||
aura lifetime and the missing deflect inputs above. This source helper records
|
||||
exact rows and linkage only. See
|
||||
[the integration report](CONTROL_UTILITY_CONTINUATION_2026-10-06.md).
|
||||
|
||||
## Focused verification
|
||||
|
||||
`src/game/wow335Deterrence.test.ts` verifies the parent/child signatures and
|
||||
the exact type-2 aura link. It rejects another profile, the same-name 65871
|
||||
duplicate, changed aura siblings, changed duration, and missing or extra link
|
||||
rows. Only this focused helper test was run; no build, live run, or full suite
|
||||
was part of the scoped source audit.
|
||||
@@ -1,5 +1,7 @@
|
||||
# WoW dungeon texture and lighting review — 2026-10-06
|
||||
|
||||
The [October 7 continuation](WOW_ENVIRONMENT_QUALITY_CONTINUATION_2026-10-07.md) supersedes the Utgarde Pinnacle and Shadowfang visual identities in this historical pass and adds the rendered native-terrain pilot and source-lighting investigation.
|
||||
|
||||
## Coverage and player access
|
||||
|
||||
Three GPT-6 Luna agents at extra-high reasoning reviewed texture imports,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# WoW environment quality continuation
|
||||
|
||||
The shared RoM quality controls also work in WoW: 16× texture filtering, High shadows, High ambient fill, and 200% render resolution. This continuation improves source UV preservation in Utgarde Pinnacle and Shadowfang Keep and produces a rendered, source-backed Zul'Farrak terrain pilot. Original-client WoW baked lighting remains diagnostic evidence, not a production replacement.
|
||||
|
||||
## Accepted local visual packages
|
||||
|
||||
| Dungeon | Previous visual bytes | Accepted visual bytes | SHA-256 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Utgarde Pinnacle | 59,344,144 | 29,792,924 | `bffaf126d0f65b2863d54418b1889f9e62adf5562491c3e74e6a5c5e05664844` |
|
||||
| Shadowfang Keep | 8,566,408 | 8,803,744 | `6c69f739383fc98f440f181785c4f27c7f39cfcb18d9a8b5ac8282262070bf75` |
|
||||
|
||||
Both assets are installed in local public packages. The publisher updated all four matching visual descriptors per dungeon and regenerated the campaign catalog. All 20 Utgarde and 26 Shadowfang collision/navigation files remained unchanged. No deployment was performed.
|
||||
|
||||
The optimizer now uses explicit 16-bit positions and UVs in its final meshopt packing. It avoids automatic texture resizing/compression and mesh simplification before the separate KTX2 encoder. Utgarde's recipe additionally preserves spatial boundaries by disabling global flattening, joining, welding and offline instancing. Existing production runtime instancing still applies.
|
||||
|
||||
Nine views per dungeon (entrance, middle encounter, final encounter, each forward/side/reverse) were compared after the same production material preparation, static instancing and visibility setup. Accepted candidates have exactly the same measured draw and triangle counts as their respective previous assets in all nine views. Utgarde retains 1,780 instance batches / 11,055 instances / 1,383 visibility cells; Shadowfang retains 78 visibility cells. These are sampled asset-render checks, not full encounter or mobile performance certification.
|
||||
|
||||
Utgarde's static prebuild estimate remains 16,277 calls. Its repeated-mesh savings estimate increased from 14,018 to 14,695; the one existing budget entry was updated after the runtime comparisons above. The overall 106-asset performance audit passes.
|
||||
|
||||
All 916 remaining source-comparable images retain their original dimensions. Utgarde's 629 shared KTX2 payloads are byte-identical to the previous package. Four old entries were accounted for: two exact decoded image duplicates were deduplicated, and two solid-color images were folded into correct linear material factors on their active triangles. Shadowfang retains all 287 KTX2 payloads byte-for-byte.
|
||||
|
||||
Relative to original staged source, Utgarde's added UV-degenerate area falls from 122.17 to 58.44 m²; added rank-collapse area falls from 107.30 to 2.14 m². Shadowfang's added UV-degenerate area falls from 8.38 to 0.47 m², while rank-collapse area changes from +0.35 to −6.52 m². Small residuals remain: +105 UV-degenerate triangles in Utgarde and +7 in Shadowfang. This is an improvement, not a claim of perfect source topology.
|
||||
|
||||
Global-join Utgarde alternatives were rejected: they rendered roughly 7.6–8.1 million triangles in the sampled views, compared with about 1.5–3.4 million for the retained spatial layout. A smaller file alone was insufficient.
|
||||
|
||||
Evidence: `artifacts/dungeon-pipeline/wow-continuation-published-audit.{json,md}`, candidate reports and hotspot audits under `artifacts/dungeon-pipeline/`, and before/after captures under `artifacts/playtest/wow-environment-quality/continuation/`.
|
||||
|
||||
## Zul'Farrak native terrain pilot
|
||||
|
||||
The cached ADT/BLP source has terrain textures and blending data, but the exported OBJ and staged GLB contain only zero UVs and flat `ADT_Terrain` material. This proves conversion loss rather than an authored flat surface.
|
||||
|
||||
The entrance tile `TanarisInstance_30_29.adt` matches the winning `patch.MPQ` bytes exactly: SHA-256 `8ad75da6b9485d61537ee3b451c08b79e25b1b1b2c7d4e91c8e57ad0a65ff75b`. Provenance lock and source snapshot both identify `3.3.5a-Ascension`; a diagnostic helper's earlier hardcoded Epoch label was corrected. All 256 chunks, 746 layers and 490 alpha maps were recovered, with zero missing maps. All 1,024 disjoint geometry streams passed hash/size checks. The recovered tile has 37,120 vertices and 65,536 triangles; source-to-staged world-position error is below 0.000277 m. This tile has no holes.
|
||||
|
||||
`src/scene/wowTerrainMaterial.ts` consumes an explicit native WoW contract: source provenance, one to four layers, normalized chunk UVs, 8× diffuse repetition and linear RGB overlay weights. The base receives the residual weight. Classic sequential opacity maps are converted by the decoder; the shader does not normalize them or treat tile alpha as surface opacity. Color textures repeat in sRGB; blend weights clamp in linear space. Missing or incompatible contracts fail closed. Shared material cloning, filtering and disposal include auxiliary textures.
|
||||
|
||||
The complete diagnostic GLB is `artifacts/dungeon-pipeline/zulfarrak-30-29-native-terrain-pilot.glb`, 29,350,772 bytes, SHA-256 `864d8885413808f46c3397243ecf3a7bf21477b54e7fb8ff1853514243c7794d`. It preserves the original shipping binary prefix and all old images byte-for-byte, replacing only the selected flat terrain node with 256 disjoint chunks. New source textures are lossless PNG and new geometry is float32/uint16. It deliberately avoids solid-image pruning, which can discard a weight image whose red channel is constant while its other channels vary.
|
||||
|
||||
Nine prepared-runtime preview views passed with no browser errors. The entrance ground is visibly textured; its height is 0.0021 m below the authored footing versus 0.0074 m above in the old quantized terrain. Entrance forward rendering changes from 718 calls / 1,075,915 triangles to 828 calls / 1,038,795 triangles. Chunk boundaries improve culling but add calls; the largest sampled call increase is 225. The pilot has not been shipped. Other Zul'Farrak tiles retain their current fallback, and native terrain shadows/colors, water and original-client lighting are outside this pilot.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Source bundle: `artifacts/playtest/wow-terrain-recovery/zulfarrak-30-29-chunk-slices-ascension-provenance-2026-10-06/`. Regenerate sidecars with `build-wow-adt-terrain-tile.mjs`; package the source-pinned diagnostic with `pack-wow-adt-terrain-pilot.mjs`. The running local preview accepts the pilot through `visualOverride` and `runtimePreparation=1`.
|
||||
|
||||
All 21 cached Zul'Farrak ADTs and their 24 texture references are available for further recovery. Pit of Saron's nine cached ADTs and eight textures are also available, but strict decoding currently recovers only 2,122 of 3,766 alpha maps across those tiles; unsupported spans remain gated. Do not extrapolate the one clean pilot to fleet completeness.
|
||||
|
||||
## Original WoW baked lighting
|
||||
|
||||
`wow-native-wmo-audit.mjs` inventories source-matched WMO roots/groups, MOCV colors, MOLT lights and MOLR references. Stockade has 97 local-light records and exact color correspondence for all 55,730 exported OBJ vertices. Raw and documented-fixup diagnostic sidecars are preserved. Shadowfang has 176 light records, but its color correspondence is incomplete; no guessed color sidecar was emitted.
|
||||
|
||||
Original WMO color alpha is lighting data, not opacity. Gamma/composition, portal attenuation, exact byte quantization and a disputed MOHD flag still need a validated consumer. These records have not been turned into Three.js point lights or production vertex colors. Evidence and primary source references are in `artifacts/dungeon-pipeline/wow-native-lighting/README.md`.
|
||||
|
||||
## Validation
|
||||
|
||||
- 65 focused scene/material tests and 21 focused pipeline/native-source tests pass.
|
||||
- App TypeScript and isolated preview TypeScript checks pass. The normal production build, KTX2/performance audits, precompression and release-bundle verification pass.
|
||||
- Fresh production Options smoke uses entry `index-DIqor-kq.js`, built `2026-10-07T03:55:23.055Z`. Stockade Cellblock Rush selected **Hamhock**. 16× filtering, High shadows, High ambient fill and 200% resolution passed; 200% carried into Forsaken Abbey. No page errors. Options, WoW and RoM screenshots were visually inspected. Evidence: `artifacts/playtest/graphics-options/run-wow-continuation-current-build-2026-10-06/`.
|
||||
- Asset screenshots prove the bounded visual changes; this pass does not certify encounter clears or broad class balance.
|
||||
- `npm run release:check` passes, including untracked files.
|
||||
|
||||
The initial 54-dungeon review remains in `docs/WOW_ENVIRONMENT_QUALITY_2026-10-06.md`; its earlier package sizes and captures are historical evidence. This continuation supersedes the two accepted visual package identities above.
|
||||
@@ -57,6 +57,9 @@
|
||||
"optionalEncounters": "required",
|
||||
"presentation": "required"
|
||||
},
|
||||
"visualOptimization": {
|
||||
"preserveSpatialBoundaries": true
|
||||
},
|
||||
"schemaVersion": 1,
|
||||
"serverSource": {
|
||||
"adapter": "normalized-json",
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
{
|
||||
"id": "visual",
|
||||
"url": "/assets/game/manastorm/33-shadowfang/visual.glb",
|
||||
"checksum": "2c562fb61596a536d728385c4f2bdfdc3230f4f84ef40875b9a568e18a82ef9d",
|
||||
"byteLength": 8566408,
|
||||
"triangleCount": 560149,
|
||||
"checksum": "6c69f739383fc98f440f181785c4f27c7f39cfcb18d9a8b5ac8282262070bf75",
|
||||
"byteLength": 8803744,
|
||||
"triangleCount": 560186,
|
||||
"compression": "meshopt+ktx2",
|
||||
"transform": {
|
||||
"position": [
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
{
|
||||
"id": "visual",
|
||||
"url": "/assets/game/manastorm/575-utgardepinnacle/visual.glb",
|
||||
"checksum": "26ecf5398de8b836e829b9fa39a4e78f7d8aaf60e0daf37c94dfdf3a4944c722",
|
||||
"byteLength": 59344144,
|
||||
"triangleCount": 5185049,
|
||||
"checksum": "bffaf126d0f65b2863d54418b1889f9e62adf5562491c3e74e6a5c5e05664844",
|
||||
"byteLength": 29792924,
|
||||
"triangleCount": 4816703,
|
||||
"compression": "meshopt+ktx2",
|
||||
"transform": {
|
||||
"position": [
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
{
|
||||
"id": "visual",
|
||||
"url": "/assets/game/manastorm/33-shadowfang/visual.glb",
|
||||
"checksum": "2c562fb61596a536d728385c4f2bdfdc3230f4f84ef40875b9a568e18a82ef9d",
|
||||
"byteLength": 8566408,
|
||||
"triangleCount": 560149,
|
||||
"checksum": "6c69f739383fc98f440f181785c4f27c7f39cfcb18d9a8b5ac8282262070bf75",
|
||||
"byteLength": 8803744,
|
||||
"triangleCount": 560186,
|
||||
"compression": "meshopt+ktx2",
|
||||
"transform": {
|
||||
"position": [
|
||||
|
||||
Binary file not shown.
@@ -7,9 +7,9 @@
|
||||
{
|
||||
"id": "visual",
|
||||
"url": "/assets/game/manastorm/575-utgardepinnacle/visual.glb",
|
||||
"checksum": "26ecf5398de8b836e829b9fa39a4e78f7d8aaf60e0daf37c94dfdf3a4944c722",
|
||||
"byteLength": 59344144,
|
||||
"triangleCount": 5185049,
|
||||
"checksum": "bffaf126d0f65b2863d54418b1889f9e62adf5562491c3e74e6a5c5e05664844",
|
||||
"byteLength": 29792924,
|
||||
"triangleCount": 4816703,
|
||||
"compression": "meshopt+ktx2",
|
||||
"transform": {
|
||||
"position": [
|
||||
|
||||
Binary file not shown.
@@ -191,7 +191,7 @@
|
||||
},
|
||||
"public/assets/game/manastorm/575-utgardepinnacle/visual.glb": {
|
||||
"maxEstimatedDrawCalls": 16277,
|
||||
"maxUnbatchedDrawCallSavings": 14018
|
||||
"maxUnbatchedDrawCallSavings": 14695
|
||||
},
|
||||
"public/assets/game/manastorm/576-nexus70/visual.glb": {
|
||||
"maxEstimatedDrawCalls": 1500,
|
||||
|
||||
@@ -302,7 +302,9 @@ function textureSizeByIndex(json, images) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function auditGeometry(file, textureSizes, label = path.basename(file)) {
|
||||
export async function auditGeometry(file, textureSizes, label = path.basename(file), options = {}) {
|
||||
const hotspotCellSizeM = Number(options.hotspotCellSizeM);
|
||||
const collectHotspots = Number.isFinite(hotspotCellSizeM) && hotspotCellSizeM > 0;
|
||||
const io = new NodeIO()
|
||||
.registerExtensions(ALL_EXTENSIONS)
|
||||
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
|
||||
@@ -325,6 +327,7 @@ export async function auditGeometry(file, textureSizes, label = path.basename(fi
|
||||
untexturedMaterials: {},
|
||||
};
|
||||
const bySurface = { floor: emptySurfaceMetrics(), wall: emptySurfaceMetrics(), other: emptySurfaceMetrics() };
|
||||
const hotspotCells = new Map();
|
||||
for (const scene of document.getRoot().listScenes()) {
|
||||
const sceneNodes = [];
|
||||
const visit = (node) => {
|
||||
@@ -400,19 +403,59 @@ export async function auditGeometry(file, textureSizes, label = path.basename(fi
|
||||
info.getExtension("KHR_texture_transform"),
|
||||
));
|
||||
const mapping = mappingMetrics(points, uvValues, size[0], size[1]);
|
||||
if (!mapping || mapping.uvDoubleArea <= 1e-12) {
|
||||
const uvDegenerate = !mapping || mapping.uvDoubleArea <= 1e-12;
|
||||
const rankCollapsed = uvDegenerate || mapping.smallestStretchPxPerMeter <= 0.01;
|
||||
if (uvDegenerate) {
|
||||
for (const metric of [totals, surface]) {
|
||||
metric.uvDegenerateTriangles += 1;
|
||||
metric.rankCollapsedTriangles += 1;
|
||||
metric.uvDegenerateAreaM2 += area;
|
||||
metric.rankCollapsedAreaM2 += area;
|
||||
}
|
||||
} else if (mapping.smallestStretchPxPerMeter <= 0.01) {
|
||||
} else if (rankCollapsed) {
|
||||
for (const metric of [totals, surface]) {
|
||||
metric.rankCollapsedTriangles += 1;
|
||||
metric.rankCollapsedAreaM2 += area;
|
||||
}
|
||||
}
|
||||
if (collectHotspots && rankCollapsed) {
|
||||
const centroid = [0, 1, 2].map((axis) => points.reduce((sum, point) => sum + point[axis], 0) / 3);
|
||||
const cellX = Math.floor(centroid[0] / hotspotCellSizeM);
|
||||
const cellZ = Math.floor(centroid[2] / hotspotCellSizeM);
|
||||
const key = `${cellX}:${cellZ}`;
|
||||
const cell = hotspotCells.get(key) ?? {
|
||||
cellX,
|
||||
cellZ,
|
||||
cellSizeM: hotspotCellSizeM,
|
||||
uvDegenerateTriangles: 0,
|
||||
rankCollapsedTriangles: 0,
|
||||
uvDegenerateAreaM2: 0,
|
||||
rankCollapsedAreaM2: 0,
|
||||
centroidAreaWeight: 0,
|
||||
centroidAreaSum: [0, 0, 0],
|
||||
bySurface: {},
|
||||
};
|
||||
const surfaceCell = cell.bySurface[surfaceKey] ?? {
|
||||
uvDegenerateTriangles: 0,
|
||||
rankCollapsedTriangles: 0,
|
||||
uvDegenerateAreaM2: 0,
|
||||
rankCollapsedAreaM2: 0,
|
||||
};
|
||||
cell.rankCollapsedTriangles += 1;
|
||||
cell.rankCollapsedAreaM2 += area;
|
||||
surfaceCell.rankCollapsedTriangles += 1;
|
||||
surfaceCell.rankCollapsedAreaM2 += area;
|
||||
if (uvDegenerate) {
|
||||
cell.uvDegenerateTriangles += 1;
|
||||
cell.uvDegenerateAreaM2 += area;
|
||||
surfaceCell.uvDegenerateTriangles += 1;
|
||||
surfaceCell.uvDegenerateAreaM2 += area;
|
||||
}
|
||||
cell.centroidAreaWeight += area;
|
||||
for (let axis = 0; axis < 3; axis += 1) cell.centroidAreaSum[axis] += centroid[axis] * area;
|
||||
cell.bySurface[surfaceKey] = surfaceCell;
|
||||
hotspotCells.set(key, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -428,7 +471,31 @@ export async function auditGeometry(file, textureSizes, label = path.basename(fi
|
||||
for (const material of Object.values(materialCoverage.untexturedMaterials)) {
|
||||
material.worldAreaM2 = round(material.worldAreaM2, 2);
|
||||
}
|
||||
return { ...totals, bySurface, materialCoverage };
|
||||
const uvHotspots = collectHotspots
|
||||
? [...hotspotCells.values()]
|
||||
.map((cell) => ({
|
||||
cellX: cell.cellX,
|
||||
cellZ: cell.cellZ,
|
||||
cellSizeM: cell.cellSizeM,
|
||||
center: [
|
||||
round((cell.cellX + 0.5) * cell.cellSizeM, 2),
|
||||
round(cell.centroidAreaSum[1] / cell.centroidAreaWeight, 2),
|
||||
round((cell.cellZ + 0.5) * cell.cellSizeM, 2),
|
||||
],
|
||||
uvDegenerateTriangles: cell.uvDegenerateTriangles,
|
||||
rankCollapsedTriangles: cell.rankCollapsedTriangles,
|
||||
uvDegenerateAreaM2: round(cell.uvDegenerateAreaM2, 2),
|
||||
rankCollapsedAreaM2: round(cell.rankCollapsedAreaM2, 2),
|
||||
bySurface: Object.fromEntries(Object.entries(cell.bySurface).map(([surface, metric]) => [surface, {
|
||||
uvDegenerateTriangles: metric.uvDegenerateTriangles,
|
||||
rankCollapsedTriangles: metric.rankCollapsedTriangles,
|
||||
uvDegenerateAreaM2: round(metric.uvDegenerateAreaM2, 2),
|
||||
rankCollapsedAreaM2: round(metric.rankCollapsedAreaM2, 2),
|
||||
}])),
|
||||
}))
|
||||
.sort((left, right) => right.rankCollapsedAreaM2 - left.rankCollapsedAreaM2 || left.cellX - right.cellX || left.cellZ - right.cellZ)
|
||||
: undefined;
|
||||
return { ...totals, bySurface, materialCoverage, ...(uvHotspots ? { uvHotspots } : {}) };
|
||||
}
|
||||
|
||||
function compareImages(source, shipped) {
|
||||
@@ -479,6 +546,39 @@ function delta(after, before, key) {
|
||||
return round(after[key] - before[key], 2);
|
||||
}
|
||||
|
||||
function compareUvHotspots(sourceCells = [], runtimeCells = []) {
|
||||
const sourceByCell = new Map(sourceCells.map((cell) => [`${cell.cellX}:${cell.cellZ}`, cell]));
|
||||
const runtimeByCell = new Map(runtimeCells.map((cell) => [`${cell.cellX}:${cell.cellZ}`, cell]));
|
||||
const keys = new Set([...sourceByCell.keys(), ...runtimeByCell.keys()]);
|
||||
return [...keys].map((key) => {
|
||||
const source = sourceByCell.get(key);
|
||||
const runtime = runtimeByCell.get(key);
|
||||
const center = runtime?.center ?? source?.center;
|
||||
const sourceArea = source?.rankCollapsedAreaM2 ?? 0;
|
||||
const runtimeArea = runtime?.rankCollapsedAreaM2 ?? 0;
|
||||
const sourceUvArea = source?.uvDegenerateAreaM2 ?? 0;
|
||||
const runtimeUvArea = runtime?.uvDegenerateAreaM2 ?? 0;
|
||||
return {
|
||||
cellX: runtime?.cellX ?? source.cellX,
|
||||
cellZ: runtime?.cellZ ?? source.cellZ,
|
||||
cellSizeM: runtime?.cellSizeM ?? source.cellSizeM,
|
||||
center,
|
||||
rankCollapsedAreaM2Delta: round(runtimeArea - sourceArea, 2),
|
||||
uvDegenerateAreaM2Delta: round(runtimeUvArea - sourceUvArea, 2),
|
||||
rankCollapsedTrianglesDelta: (runtime?.rankCollapsedTriangles ?? 0) - (source?.rankCollapsedTriangles ?? 0),
|
||||
uvDegenerateTrianglesDelta: (runtime?.uvDegenerateTriangles ?? 0) - (source?.uvDegenerateTriangles ?? 0),
|
||||
sourceRankCollapsedAreaM2: sourceArea,
|
||||
runtimeRankCollapsedAreaM2: runtimeArea,
|
||||
sourceUvDegenerateAreaM2: sourceUvArea,
|
||||
runtimeUvDegenerateAreaM2: runtimeUvArea,
|
||||
};
|
||||
})
|
||||
.filter((cell) => cell.rankCollapsedAreaM2Delta > 0 || cell.uvDegenerateAreaM2Delta > 0)
|
||||
.sort((left, right) => right.rankCollapsedAreaM2Delta - left.rankCollapsedAreaM2Delta
|
||||
|| right.uvDegenerateAreaM2Delta - left.uvDegenerateAreaM2Delta)
|
||||
.slice(0, 50);
|
||||
}
|
||||
|
||||
function resolvePublishedPath(url) {
|
||||
if (typeof url !== "string" || !url.startsWith("/assets/game/")) return null;
|
||||
const file = path.resolve(publicRoot, url.replace(/^\//u, ""));
|
||||
@@ -511,7 +611,7 @@ async function readWowDefinitions() {
|
||||
return definitions;
|
||||
}
|
||||
|
||||
async function auditDungeon(definition, io, visualOverride = null) {
|
||||
async function auditDungeon(definition, io, visualOverride = null, hotspotCellSizeM = null) {
|
||||
const visualEntry = definition.assets?.visual?.[0];
|
||||
const authoredRuntimePath = definition.id === "wailing-caverns"
|
||||
? path.join(projectRoot, "src/assets/game/dungeons/wailing-caverns/wailing-caverns-visual.glb")
|
||||
@@ -542,12 +642,12 @@ async function auditDungeon(definition, io, visualOverride = null) {
|
||||
row.shipped = await auditGlb(publishedPath, `${definition.id} shipped`);
|
||||
row.shippedGeometry = await auditGeometry(publishedPath,
|
||||
textureSizeByIndex(parseGlb(await readFile(publishedPath), `${definition.id} shipped`).json, row.shipped.images),
|
||||
`${definition.id} shipped`);
|
||||
`${definition.id} shipped`, { hotspotCellSizeM });
|
||||
if (source.path) {
|
||||
const sourceAudit = await auditGlb(source.path, `${definition.id} source`);
|
||||
const sourceParsed = parseGlb(await readFile(source.path), `${definition.id} source`);
|
||||
row.source = sourceAudit;
|
||||
row.sourceGeometry = await auditGeometry(source.path, textureSizeByIndex(sourceParsed.json, sourceAudit.images), `${definition.id} source`);
|
||||
row.sourceGeometry = await auditGeometry(source.path, textureSizeByIndex(sourceParsed.json, sourceAudit.images), `${definition.id} source`, { hotspotCellSizeM });
|
||||
row.textureComparison = compareImages(sourceAudit, row.shipped);
|
||||
row.geometryComparison = {
|
||||
triangleCountDelta: row.shippedGeometry.triangleCount - row.sourceGeometry.triangleCount,
|
||||
@@ -577,6 +677,7 @@ async function auditDungeon(definition, io, visualOverride = null) {
|
||||
rankCollapsedAreaM2Delta: round(shippedMetric.rankCollapsedAreaM2 - sourceMetric.rankCollapsedAreaM2, 2),
|
||||
}];
|
||||
})),
|
||||
...(hotspotCellSizeM ? { uvHotspots: compareUvHotspots(row.sourceGeometry.uvHotspots, row.shippedGeometry.uvHotspots) } : {}),
|
||||
};
|
||||
if (row.textureComparison.downsizedImages) {
|
||||
row.issues.push(`${row.textureComparison.downsizedImages} shipped images are smaller than their matched source dimensions.`);
|
||||
@@ -680,6 +781,11 @@ async function main() {
|
||||
const overrideId = overrideSeparator > 0 ? overrideText.slice(0, overrideSeparator) : null;
|
||||
const visualOverride = overrideId ? path.resolve(projectRoot, overrideText.slice(overrideSeparator + 1)) : null;
|
||||
if (overrideText && !visualOverride) throw new Error("--visual-override must be <dungeon-id>=<glb-path>.");
|
||||
const hotspotArg = process.argv.indexOf("--hotspot-cell-m");
|
||||
const hotspotCellSizeM = hotspotArg >= 0 ? Number(process.argv[hotspotArg + 1]) : null;
|
||||
if (hotspotArg >= 0 && (!Number.isFinite(hotspotCellSizeM) || hotspotCellSizeM <= 0)) {
|
||||
throw new Error("--hotspot-cell-m must be a positive world-space cell size in meters.");
|
||||
}
|
||||
const selectedIds = new Set([...(onlyIds ?? []), ...(overrideId ? [overrideId] : [])]);
|
||||
const unknownIds = [...selectedIds].filter((id) => !allDefinitions.some((definition) => definition.id === id));
|
||||
if (unknownIds.length) throw new Error(`Unknown WoW dungeon id(s): ${unknownIds.join(", ")}.`);
|
||||
@@ -690,7 +796,7 @@ async function main() {
|
||||
const dungeons = [];
|
||||
for (const [index, definition] of definitions.entries()) {
|
||||
console.log(`[${index + 1}/${definitions.length}] auditing ${definition.id}`);
|
||||
dungeons.push(await auditDungeon(definition, io, definition.id === overrideId ? visualOverride : null));
|
||||
dungeons.push(await auditDungeon(definition, io, definition.id === overrideId ? visualOverride : null, hotspotCellSizeM));
|
||||
}
|
||||
const compared = dungeons.filter((dungeon) => dungeon.source && dungeon.shipped);
|
||||
const shipped = dungeons.filter((dungeon) => dungeon.shipped);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
import { auditGeometry } from "./audit-wow-visual-quality.mjs";
|
||||
|
||||
function addTriangle(document, buffer, { name, offset, material = null, textured = false }) {
|
||||
function addTriangle(document, buffer, { name, offset, material = null, textured = false, uvValues = null }) {
|
||||
const positions = document.createAccessor(`${name}-positions`)
|
||||
.setType("VEC3")
|
||||
.setArray(new Float32Array([
|
||||
@@ -22,7 +22,7 @@ function addTriangle(document, buffer, { name, offset, material = null, textured
|
||||
if (textured) {
|
||||
const texcoords = document.createAccessor(`${name}-uvs`)
|
||||
.setType("VEC2")
|
||||
.setArray(new Float32Array([0, 0, 1, 0, 0, 1]))
|
||||
.setArray(new Float32Array(uvValues ?? [0, 0, 1, 0, 0, 1]))
|
||||
.setBuffer(buffer);
|
||||
primitive.setAttribute("TEXCOORD_0", texcoords);
|
||||
}
|
||||
@@ -49,18 +49,31 @@ test("WoW visual audit counts untextured and material-free surfaces safely", asy
|
||||
const scene = document.createScene("coverage-fixture")
|
||||
.addChild(addTriangle(document, buffer, { name: "textured", offset: 0, material: texturedMaterial, textured: true }))
|
||||
.addChild(addTriangle(document, buffer, { name: "plain", offset: 3, material: plainMaterial }))
|
||||
.addChild(addTriangle(document, buffer, { name: "no-material", offset: 6 }));
|
||||
.addChild(addTriangle(document, buffer, { name: "no-material", offset: 6 }))
|
||||
.addChild(addTriangle(document, buffer, {
|
||||
name: "degenerate-uv",
|
||||
offset: 9,
|
||||
material: texturedMaterial,
|
||||
textured: true,
|
||||
uvValues: [0, 0, 0, 0, 1, 1],
|
||||
}));
|
||||
document.getRoot().setDefaultScene(scene);
|
||||
await io.write(visualFile, document);
|
||||
|
||||
const result = await auditGeometry(visualFile, [[1, 1]], "coverage fixture");
|
||||
assert.equal(result.materialCoverage.totalTriangles, 3);
|
||||
assert.equal(result.materialCoverage.baseColorTexturedTriangles, 1);
|
||||
assert.equal(result.materialCoverage.totalTriangles, 4);
|
||||
assert.equal(result.materialCoverage.baseColorTexturedTriangles, 2);
|
||||
assert.equal(result.materialCoverage.untexturedTriangles, 2);
|
||||
assert.equal(result.materialCoverage.baseColorTexturedAreaM2, 0.5);
|
||||
assert.equal(result.materialCoverage.baseColorTexturedAreaM2, 1);
|
||||
assert.equal(result.materialCoverage.untexturedAreaM2, 1);
|
||||
assert.deepEqual(Object.keys(result.materialCoverage.untexturedMaterials).sort(), ["(no material)", "plain"]);
|
||||
assert.equal(result.materialCoverage.untexturedMaterials.plain.triangles, 1);
|
||||
assert.deepEqual(result.materialCoverage.untexturedMaterials.plain.baseColorFactor, [0.35, 0.31, 0.24, 1]);
|
||||
assert.equal(result.materialCoverage.untexturedMaterials["(no material)"].triangles, 1);
|
||||
|
||||
const hotspotResult = await auditGeometry(visualFile, [[1, 1]], "coverage hotspot fixture", { hotspotCellSizeM: 2 });
|
||||
assert.equal(hotspotResult.uvHotspots.length, 1);
|
||||
assert.deepEqual(hotspotResult.uvHotspots[0].center, [9, 0.33, 1]);
|
||||
assert.equal(hotspotResult.uvHotspots[0].uvDegenerateTriangles, 1);
|
||||
assert.equal(hotspotResult.uvHotspots[0].rankCollapsedAreaM2, 0.5);
|
||||
});
|
||||
@@ -147,8 +147,9 @@ async function main() {
|
||||
imageCount: candidateTextures.length,
|
||||
comparedToPreservedBaseline: baselineTextureComparison,
|
||||
},
|
||||
meshoptQuantization: { positionBits: 16, texcoordBits: 16 },
|
||||
finalPacking: {
|
||||
command: "gltf-transform optimize <optimized.glb> <meshopt-final.glb> --compress meshopt --meshopt-level high --instance true --instance-min 3 --simplify false --texture-compress false",
|
||||
command: "gltf-transform meshopt <optimized.glb> <meshopt-final.glb> --level high --quantize-position 16 --quantize-texcoord 16",
|
||||
extensionPresent: parseGlb(candidateBytes, output).json.extensionsUsed?.includes("EXT_meshopt_compression") ?? false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Matrix4, Vector3 } from "three";
|
||||
import { NodeIO } from "@gltf-transform/core";
|
||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||
import { decodeBlp2, encodePng } from "../lib/blp.mjs";
|
||||
import { parseWowAdtTerrain, inspectObjMaterialContract } from "./lib/wow-adt-terrain-audit.mjs";
|
||||
import { composeWowAdtTerrainWeights, wowAdtNormalizedUv } from "./lib/wow-adt-terrain-material.mjs";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const defaultWork = path.resolve(projectRoot, "../HealerMan-Storage/pipeline-work/dungeons/zulfarrak/source-export");
|
||||
const defaultAdt = path.join(defaultWork, "raw/world/maps/tanarisinstance/tanarisinstance_30_29.adt");
|
||||
const defaultObj = path.join(defaultWork, "adt_30_29.obj");
|
||||
const defaultGlb = path.resolve(projectRoot, "../HealerMan-Storage/pipeline-work/dungeons/zulfarrak/staging/environment/zulfarrak-visual.glb");
|
||||
const defaultDungeonWork = path.dirname(defaultWork);
|
||||
const defaultProvenanceLock = path.join(defaultDungeonWork, "provenance.lock.json");
|
||||
const defaultSourceSnapshot = path.join(defaultDungeonWork, "source-snapshot.json");
|
||||
const defaultMpqDirectory = path.resolve(projectRoot, "../wow335a/Data");
|
||||
const args = new Map();
|
||||
for (let index = 2; index < process.argv.length; index += 1) {
|
||||
const key = process.argv[index];
|
||||
if (!key.startsWith("--") || !process.argv[index + 1] || process.argv[index + 1].startsWith("--")) {
|
||||
throw new Error(`Expected --name value, got ${key}.`);
|
||||
}
|
||||
args.set(key, process.argv[++index]);
|
||||
}
|
||||
const readArg = (name, fallback) => path.resolve(args.get(name) ?? fallback);
|
||||
const adtPath = readArg("--adt", defaultAdt);
|
||||
const objPath = readArg("--obj", defaultObj);
|
||||
const glbPath = readArg("--glb", defaultGlb);
|
||||
const provenanceLockPath = readArg("--provenance-lock", defaultProvenanceLock);
|
||||
const sourceSnapshotPath = readArg("--source-snapshot", defaultSourceSnapshot);
|
||||
const mpqDirectory = readArg("--mpq-dir", defaultMpqDirectory);
|
||||
const worldAdtPath = (args.get("--world-path") ?? "World/Maps/TanarisInstance/TanarisInstance_30_29.adt").replaceAll("\\", "/");
|
||||
const outputRoot = readArg("--out", path.join(
|
||||
projectRoot,
|
||||
"artifacts/playtest/wow-terrain-recovery",
|
||||
`zulfarrak-30-29-${new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-")}-${process.pid}`,
|
||||
));
|
||||
|
||||
for (const input of [adtPath, objPath, glbPath, provenanceLockPath, sourceSnapshotPath]) {
|
||||
if (!fs.existsSync(input)) throw new Error(`Missing required source: ${input}`);
|
||||
}
|
||||
if (!fs.existsSync(mpqDirectory)) throw new Error(`Missing MPQ directory: ${mpqDirectory}`);
|
||||
const provenanceLock = JSON.parse(fs.readFileSync(provenanceLockPath, "utf8"));
|
||||
const sourceSnapshot = JSON.parse(fs.readFileSync(sourceSnapshotPath, "utf8"));
|
||||
const clientBuild = provenanceLock.clientBuild;
|
||||
if (provenanceLock.dungeonId !== "zulfarrak" || typeof clientBuild !== "string" || !clientBuild.trim()) {
|
||||
throw new Error(`The source provenance lock does not identify a Zul'Farrak client build: ${provenanceLockPath}`);
|
||||
}
|
||||
if (sourceSnapshot.client?.build !== clientBuild) {
|
||||
throw new Error(`The source snapshot build ${sourceSnapshot.client?.build ?? "<missing>"} does not match provenance lock ${clientBuild}.`);
|
||||
}
|
||||
fs.mkdirSync(path.dirname(outputRoot), { recursive: true });
|
||||
fs.mkdirSync(outputRoot, { recursive: false });
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { MpqArchive } = require("stormlib-js");
|
||||
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const safeRelative = (value) => {
|
||||
const normalized = value.replaceAll("\\", "/").replace(/^\/+/, "");
|
||||
if (!normalized || normalized.split("/").some(part => part === ".." || part === ".")) {
|
||||
throw new Error(`Unsafe source-relative path: ${value}`);
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const adt = fs.readFileSync(adtPath);
|
||||
const parsed = parseWowAdtTerrain(adt, { label: adtPath });
|
||||
if (parsed.mcnkCount !== 256 || parsed.chunks.some(chunk => chunk.heightSamples?.length !== 145)) {
|
||||
throw new Error("The selected terrain input is not one complete 16x16 MCVT ADT tile.");
|
||||
}
|
||||
if (parsed.chunks.some(chunk => chunk.highResolutionHoles)) {
|
||||
throw new Error("High-resolution MCNK holes are present; this prototype only preserves the 3.3.5 low-resolution mask.");
|
||||
}
|
||||
if (parsed.alphaMapDataMissing) {
|
||||
throw new Error(`Refusing incomplete source alpha data: ${parsed.alphaMapDataMissing} overlay maps are unsupported or out of bounds.`);
|
||||
}
|
||||
|
||||
const adtSha256 = sha256(adt);
|
||||
const sourcePathRelative = safeRelative(worldAdtPath);
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||
const gltf = await io.read(glbPath);
|
||||
const meshNode = gltf.getRoot().listNodes().find(node => node.getName() === "ADT_30_29");
|
||||
if (!meshNode?.getMesh() || meshNode.getMesh().listPrimitives().length !== 1) {
|
||||
throw new Error("Could not identify the one-primitive ADT_30_29 shipping-source mesh.");
|
||||
}
|
||||
const primitive = meshNode.getMesh().listPrimitives()[0];
|
||||
const positionAccessor = primitive.getAttribute("POSITION");
|
||||
const normalAccessor = primitive.getAttribute("NORMAL");
|
||||
const uvAccessor = primitive.getAttribute("TEXCOORD_0");
|
||||
const indexAccessor = primitive.getIndices();
|
||||
if (!positionAccessor || !normalAccessor || !uvAccessor || !indexAccessor) throw new Error("Source GLB is missing POSITION, NORMAL, TEXCOORD_0, or indices.");
|
||||
const positions = positionAccessor.getArray();
|
||||
const normals = normalAccessor.getArray();
|
||||
const sourceUvs = uvAccessor.getArray();
|
||||
const indices = indexAccessor.getArray();
|
||||
if (positionAccessor.getCount() !== parsed.mcnkCount * 145 || normalAccessor.getCount() !== positionAccessor.getCount()
|
||||
|| uvAccessor.getCount() !== positionAccessor.getCount()
|
||||
|| indexAccessor.getCount() !== parsed.mcnkCount * 768) {
|
||||
throw new Error("The source GLB vertex/index layout differs from the 256 × 145 WoW ADT chunk layout.");
|
||||
}
|
||||
|
||||
const objText = fs.readFileSync(objPath, "utf8");
|
||||
const objPositions = objText.split(/\r?\n/).filter(line => line.startsWith("v "))
|
||||
.map(line => line.trim().split(/\s+/).slice(1).map(Number));
|
||||
if (objPositions.length !== positionAccessor.getCount()) throw new Error("OBJ and staging GLB vertex counts differ.");
|
||||
const materialContract = inspectObjMaterialContract(objPath);
|
||||
const worldMatrix = new Matrix4().fromArray(meshNode.getWorldMatrix());
|
||||
const worldPosition = new Vector3();
|
||||
const unitsPerChunkCell = 533.33333 / 16 / 8;
|
||||
const uvStream = new Float32Array(positionAccessor.getCount() * 2);
|
||||
let maxSourceToObjError = 0;
|
||||
let maxSourceToShippingWorldError = 0;
|
||||
let sourceToObjViolations = 0;
|
||||
let sourceToShippingWorldViolations = 0;
|
||||
const maxSourceToObjTolerance = 0.0001;
|
||||
const maxSourceToShippingWorldTolerance = 0.001;
|
||||
|
||||
for (const chunk of parsed.chunks) {
|
||||
let vertexOffset = 0;
|
||||
for (let row = 0; row <= 16; row += 1) {
|
||||
const columnCount = row % 2 ? 8 : 9;
|
||||
for (let column = 0; column < columnCount; column += 1) {
|
||||
const height = chunk.heightSamples[vertexOffset];
|
||||
const sourcePosition = [
|
||||
chunk.world.x - column * unitsPerChunkCell - (row % 2 ? unitsPerChunkCell / 2 : 0),
|
||||
chunk.world.y + height,
|
||||
chunk.world.height - row * unitsPerChunkCell / 2,
|
||||
];
|
||||
const globalVertex = chunk.ordinal * 145 + vertexOffset;
|
||||
const objPosition = objPositions[globalVertex];
|
||||
const sourceToObjError = Math.max(...sourcePosition.map((value, axis) => Math.abs(value - objPosition[axis])));
|
||||
maxSourceToObjError = Math.max(maxSourceToObjError, sourceToObjError);
|
||||
if (sourceToObjError > maxSourceToObjTolerance) sourceToObjViolations += 1;
|
||||
|
||||
worldPosition.set(
|
||||
positions[globalVertex * 3],
|
||||
positions[globalVertex * 3 + 1],
|
||||
positions[globalVertex * 3 + 2],
|
||||
).applyMatrix4(worldMatrix);
|
||||
const shippingWorld = [worldPosition.x, worldPosition.y, worldPosition.z];
|
||||
const sourceToShippingError = Math.max(...sourcePosition.map((value, axis) => Math.abs(value - shippingWorld[axis])));
|
||||
maxSourceToShippingWorldError = Math.max(maxSourceToShippingWorldError, sourceToShippingError);
|
||||
if (sourceToShippingError > maxSourceToShippingWorldTolerance) sourceToShippingWorldViolations += 1;
|
||||
|
||||
const [u, v] = wowAdtNormalizedUv(row, column);
|
||||
uvStream[globalVertex * 2] = u;
|
||||
uvStream[globalVertex * 2 + 1] = v;
|
||||
vertexOffset += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sourceToObjViolations || sourceToShippingWorldViolations) {
|
||||
throw new Error(`Source geometry correspondence failed: OBJ ${sourceToObjViolations}, staging GLB ${sourceToShippingWorldViolations}.`);
|
||||
}
|
||||
|
||||
const indexElementBytes = indexAccessor.getArray().BYTES_PER_ELEMENT;
|
||||
if (![1, 2, 4].includes(indexElementBytes)) throw new Error(`Unsupported index element size ${indexElementBytes}.`);
|
||||
const retainedIndices = [];
|
||||
const chunkIndexRanges = [];
|
||||
let holeTrianglesRemoved = 0;
|
||||
for (const chunk of parsed.chunks) {
|
||||
const sourceIndexStart = chunk.ordinal * 768;
|
||||
const sourceIndexEnd = sourceIndexStart + 768;
|
||||
const rowStarts = [];
|
||||
let rowStart = 0;
|
||||
for (let row = 0; row <= 16; row += 1) {
|
||||
rowStarts[row] = rowStart;
|
||||
rowStart += row % 2 ? 8 : 9;
|
||||
}
|
||||
for (let fanRow = 0; fanRow < 8; fanRow += 1) {
|
||||
for (let fanColumn = 0; fanColumn < 8; fanColumn += 1) {
|
||||
const groupStart = sourceIndexStart + (fanRow * 8 + fanColumn) * 12;
|
||||
const groupEnd = groupStart + 12;
|
||||
const centerIndex = chunk.ordinal * 145 + rowStarts[fanRow * 2 + 1] + fanColumn;
|
||||
let centerMentions = 0;
|
||||
for (let index = groupStart; index < groupEnd; index += 1) {
|
||||
if (index < sourceIndexStart || index >= sourceIndexEnd) throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} triangle group leaves its shipping index range.`);
|
||||
if (indices[index] < chunk.ordinal * 145 || indices[index] >= (chunk.ordinal + 1) * 145) {
|
||||
throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} index ${index} points into another chunk's vertices.`);
|
||||
}
|
||||
if (indices[index] === centerIndex) centerMentions += 1;
|
||||
}
|
||||
if (centerMentions !== 4) throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} source index topology is not the verified 64-fan MCVT layout.`);
|
||||
|
||||
const holeBit = Math.floor(fanRow / 2) * 4 + Math.floor(fanColumn / 2);
|
||||
if ((chunk.holeMask & (1 << holeBit)) !== 0) {
|
||||
holeTrianglesRemoved += 4;
|
||||
} else {
|
||||
for (let index = groupStart; index < groupEnd; index += 1) retainedIndices.push(indices[index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
const removedHoleFans = countRemovedFans(chunk.holeMask);
|
||||
const outputIndexStart = retainedIndices.length - (768 - removedHoleFans * 12);
|
||||
chunkIndexRanges.push({
|
||||
ordinal: chunk.ordinal,
|
||||
indexX: chunk.indexX,
|
||||
indexY: chunk.indexY,
|
||||
vertexStart: chunk.ordinal * 145,
|
||||
vertexCount: 145,
|
||||
sourceIndexStart,
|
||||
sourceIndexCount: 768,
|
||||
recoveredIndexStart: outputIndexStart,
|
||||
recoveredIndexCount: 768 - removedHoleFans * 12,
|
||||
sourceHoleMaskHex: chunk.holeMask.toString(16).padStart(4, "0"),
|
||||
highResolutionHoles: chunk.highResolutionHoles,
|
||||
});
|
||||
}
|
||||
if (retainedIndices.length + holeTrianglesRemoved * 3 !== indices.length) {
|
||||
throw new Error("Source hole masks do not account for the recovered triangle index count.");
|
||||
}
|
||||
const indexArray = indexAccessor.getArray().constructor.from(retainedIndices);
|
||||
|
||||
const archiveNames = ["patch-3.MPQ", "patch-2.MPQ", "patch.MPQ", "lichking.MPQ", "expansion.MPQ", "common-2.MPQ", "common.MPQ"];
|
||||
const directoryEntries = new Map(fs.readdirSync(mpqDirectory, { withFileTypes: true })
|
||||
.filter(entry => entry.isFile()).map(entry => [entry.name.toLowerCase(), path.join(mpqDirectory, entry.name)]));
|
||||
const archives = [];
|
||||
const archiveErrors = [];
|
||||
for (const archiveName of archiveNames) {
|
||||
const archivePath = directoryEntries.get(archiveName.toLowerCase());
|
||||
if (!archivePath) continue;
|
||||
try {
|
||||
archives.push({ file: archivePath, archive: MpqArchive.open(archivePath, { noListfile: true, noAttributes: true }) });
|
||||
} catch (error) {
|
||||
archiveErrors.push({ file: archivePath, message: error.message });
|
||||
}
|
||||
}
|
||||
if (!archives.length) throw new Error(`No readable WoW MPQ archives found under ${mpqDirectory}.`);
|
||||
|
||||
const adtArchiveSources = [];
|
||||
try {
|
||||
for (const entry of archives) {
|
||||
if (entry.archive.hasFile(worldAdtPath)) {
|
||||
const bytes = entry.archive.extractFile(worldAdtPath);
|
||||
adtArchiveSources.push({ archive: path.basename(entry.file), sha256: sha256(bytes), bytes: bytes.length });
|
||||
}
|
||||
}
|
||||
const winningAdtSource = adtArchiveSources[0];
|
||||
if (!winningAdtSource || winningAdtSource.sha256 !== adtSha256) {
|
||||
throw new Error(`Source ADT does not match the highest-priority available MPQ copy for ${worldAdtPath}.`);
|
||||
}
|
||||
|
||||
const textureCarriers = [];
|
||||
const textureVariantByIndex = [];
|
||||
for (const [textureIndex, textureName] of parsed.textures.entries()) {
|
||||
let bytes = null;
|
||||
let archiveName = null;
|
||||
for (const entry of archives) {
|
||||
if (!entry.archive.hasFile(textureName)) continue;
|
||||
bytes = entry.archive.extractFile(textureName);
|
||||
archiveName = path.basename(entry.file);
|
||||
break;
|
||||
}
|
||||
if (!bytes) throw new Error(`Native texture missing from configured client MPQs: ${textureName}`);
|
||||
const textureSha256 = sha256(bytes);
|
||||
const relativeBlp = `textures/source/${safeRelative(textureName)}`;
|
||||
const relativePng = `textures/decoded/${safeRelative(textureName).replace(/\.blp$/i, ".png")}`;
|
||||
const blpOutput = path.join(outputRoot, relativeBlp);
|
||||
const pngOutput = path.join(outputRoot, relativePng);
|
||||
fs.mkdirSync(path.dirname(blpOutput), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(pngOutput), { recursive: true });
|
||||
fs.writeFileSync(blpOutput, bytes);
|
||||
const decodedImage = decodeBlp2(bytes, textureName);
|
||||
const decoded = encodePng(decodedImage);
|
||||
fs.writeFileSync(pngOutput, decoded);
|
||||
const variant = `wowTerrain.layer.${textureSha256}`;
|
||||
textureVariantByIndex[textureIndex] = variant;
|
||||
textureCarriers.push({
|
||||
textureIndex,
|
||||
sourceTexture: textureName,
|
||||
sourceArchive: archiveName,
|
||||
sourceBytes: bytes.length,
|
||||
sourceSha256: textureSha256,
|
||||
width: decodedImage.width,
|
||||
height: decodedImage.height,
|
||||
sourceBlp: relativeBlp,
|
||||
decodedPng: relativePng,
|
||||
decodedPngSha256: sha256(decoded),
|
||||
variant,
|
||||
});
|
||||
}
|
||||
|
||||
const weightCarriers = new Map();
|
||||
const materials = [];
|
||||
const terrainChunks = [];
|
||||
for (const chunk of parsed.chunks) {
|
||||
const weights = composeWowAdtTerrainWeights(adt, chunk);
|
||||
const channelStats = [0, 1, 2, 3].map(channel => {
|
||||
const values = new Set();
|
||||
let min = 255;
|
||||
let max = 0;
|
||||
for (let pixel = channel; pixel < weights.rgba.length; pixel += 4) {
|
||||
const value = weights.rgba[pixel];
|
||||
values.add(value);
|
||||
min = Math.min(min, value);
|
||||
max = Math.max(max, value);
|
||||
}
|
||||
return { channel: "rgba"[channel], uniqueValues: values.size, min, max };
|
||||
});
|
||||
let weightVariant = null;
|
||||
let weightImage = null;
|
||||
if (chunk.layers.length > 1) {
|
||||
const png = encodePng({ width: 64, height: 64, pixels: weights.rgba });
|
||||
const pngSha256 = sha256(png);
|
||||
weightVariant = `wowTerrain.weight.${pngSha256}`;
|
||||
weightImage = `textures/weights/${pngSha256}.png`;
|
||||
if (!weightCarriers.has(weightVariant)) {
|
||||
fs.mkdirSync(path.dirname(path.join(outputRoot, weightImage)), { recursive: true });
|
||||
fs.writeFileSync(path.join(outputRoot, weightImage), png);
|
||||
weightCarriers.set(weightVariant, { variant: weightVariant, image: weightImage, sha256: pngSha256 });
|
||||
}
|
||||
}
|
||||
const layerVariants = chunk.layers.map(layer => textureVariantByIndex[layer.textureIndex]);
|
||||
const material = {
|
||||
name: `wowTerrain_30_29_${String(chunk.indexX).padStart(2, "0")}_${String(chunk.indexY).padStart(2, "0")}`,
|
||||
sourceChunk: {
|
||||
ordinal: chunk.ordinal,
|
||||
indexX: chunk.indexX,
|
||||
indexY: chunk.indexY,
|
||||
flags: chunk.flags,
|
||||
sourceOffsets: chunk.sourceOffsets,
|
||||
layers: chunk.layers.map(layer => ({
|
||||
index: layer.index,
|
||||
recordOffset: layer.recordOffset,
|
||||
textureIndex: layer.textureIndex,
|
||||
sourceTexture: layer.texture,
|
||||
flags: layer.flags,
|
||||
alphaOffset: layer.alphaOffset,
|
||||
alphaMapEncoding: layer.alphaMapEncoding,
|
||||
alphaMapDataOffset: layer.alphaMapDataOffset,
|
||||
alphaMapBytes: layer.alphaMapBytes,
|
||||
alphaMapSha256: layer.alphaMapAvailable
|
||||
? sha256(adt.subarray(layer.alphaMapDataOffset, layer.alphaMapDataOffset + layer.alphaMapBytes))
|
||||
: null,
|
||||
})),
|
||||
alphaChunkSizeSource: chunk.alphaChunkSizeSource,
|
||||
alphaChunkHeaderSize: chunk.alphaChunkHeaderSize,
|
||||
alphaSizeHeader: chunk.alphaSizeHeader,
|
||||
alphaSizeMatchesHeader: chunk.alphaSizeMatchesHeader,
|
||||
holeMask: chunk.holeMask,
|
||||
lowResolutionTextureMap: chunk.lowResolutionTextureMap,
|
||||
},
|
||||
wowTerrain: {
|
||||
schemaVersion: 1,
|
||||
sourceAdt: {
|
||||
path: sourcePathRelative,
|
||||
sha256: adtSha256,
|
||||
clientBuild,
|
||||
archive: winningAdtSource.archive,
|
||||
matchingTileSha256: winningAdtSource.sha256,
|
||||
},
|
||||
layerVariants,
|
||||
weightVariants: weightVariant ? [weightVariant] : [],
|
||||
uv: {
|
||||
attribute: "TEXCOORD_0",
|
||||
basis: "mcnk-local-normalized",
|
||||
diffuseScale: [8, 8],
|
||||
weightScale: [1, 1],
|
||||
vSign: 1,
|
||||
},
|
||||
composition: { rule: "wow-overlay-residual", normalize: false },
|
||||
},
|
||||
weight: {
|
||||
sourceEncoding: weights.sourceEncoding,
|
||||
sourcePolicy: weights.sourcePolicy,
|
||||
channelLayerIndices: weights.channelLayerIndices,
|
||||
maxWeightSum: weights.maxWeightSum,
|
||||
channelStats,
|
||||
variant: weightVariant,
|
||||
image: weightImage,
|
||||
rgbaSha256: sha256(weights.rgba),
|
||||
},
|
||||
};
|
||||
materials.push(material);
|
||||
terrainChunks.push({
|
||||
ordinal: chunk.ordinal,
|
||||
indexX: chunk.indexX,
|
||||
indexY: chunk.indexY,
|
||||
material: material.name,
|
||||
sourceHeightSamples: chunk.heightSamples,
|
||||
sourceHeightSha256: chunk.heightSha256,
|
||||
sourceHoleMaskHex: chunk.holeMask.toString(16).padStart(4, "0"),
|
||||
vertexUvStart: chunk.ordinal * 145,
|
||||
vertexUvCount: 145,
|
||||
uvMin: [0, 0],
|
||||
uvMax: [1, 1],
|
||||
});
|
||||
}
|
||||
|
||||
const entrance = [Number(args.get("--entrance-x") ?? 841.59), Number(args.get("--entrance-z") ?? 1213.52)];
|
||||
const nearestEntranceChunk = parsed.chunks.map(chunk => ({
|
||||
indexX: chunk.indexX,
|
||||
indexY: chunk.indexY,
|
||||
center: [chunk.world.x - unitsPerChunkCell * 4, chunk.world.height - unitsPerChunkCell * 4],
|
||||
})).map(chunk => ({ ...chunk, distance: Math.hypot(chunk.center[0] - entrance[0], chunk.center[1] - entrance[1]) }))
|
||||
.sort((left, right) => left.distance - right.distance)[0];
|
||||
|
||||
const uvBytes = Buffer.from(uvStream.buffer, uvStream.byteOffset, uvStream.byteLength);
|
||||
const indexBytes = Buffer.from(indexArray.buffer, indexArray.byteOffset, indexArray.byteLength);
|
||||
const chunkGeometryByOrdinal = new Map();
|
||||
const chunkGeometryFiles = [];
|
||||
for (const range of chunkIndexRanges) {
|
||||
const chunk = parsed.chunks[range.ordinal];
|
||||
const vertexStart = range.vertexStart;
|
||||
const vertexEnd = vertexStart + range.vertexCount;
|
||||
const positionSlice = positions.slice(vertexStart * 3, vertexEnd * 3);
|
||||
const normalSlice = normals.slice(vertexStart * 3, vertexEnd * 3);
|
||||
const uvSlice = uvStream.slice(vertexStart * 2, vertexEnd * 2);
|
||||
const indicesGlobal = indexArray.subarray(range.recoveredIndexStart, range.recoveredIndexStart + range.recoveredIndexCount);
|
||||
const localIndices = new indexArray.constructor(indicesGlobal.length);
|
||||
for (let index = 0; index < indicesGlobal.length; index += 1) {
|
||||
const localIndex = indicesGlobal[index] - vertexStart;
|
||||
if (localIndex < 0 || localIndex >= range.vertexCount) {
|
||||
throw new Error(`MCNK ${range.indexX},${range.indexY} recovered index escapes its disjoint vertex slice.`);
|
||||
}
|
||||
localIndices[index] = localIndex;
|
||||
}
|
||||
if (localIndices.length % 3 !== 0) throw new Error(`MCNK ${range.indexX},${range.indexY} has an incomplete triangle.`);
|
||||
|
||||
const directory = `geometry/chunks/mcnk-${String(range.indexX).padStart(2, "0")}-${String(range.indexY).padStart(2, "0")}`;
|
||||
const streams = [
|
||||
["position-float32.bin", positionSlice],
|
||||
["normal-float32.bin", normalSlice],
|
||||
["uv0-float32.bin", uvSlice],
|
||||
[`indices-uint${indexElementBytes * 8}.bin`, localIndices],
|
||||
];
|
||||
const files = {};
|
||||
for (const [filename, array] of streams) {
|
||||
const bytes = Buffer.from(array.buffer, array.byteOffset, array.byteLength);
|
||||
const file = `${directory}/${filename}`;
|
||||
chunkGeometryFiles.push([file, bytes]);
|
||||
files[filename.replace(/-.*$/, "").replace("indices", "indices")] = {
|
||||
file,
|
||||
bytes: bytes.length,
|
||||
count: array.length,
|
||||
sha256: sha256(bytes),
|
||||
};
|
||||
}
|
||||
chunkGeometryByOrdinal.set(range.ordinal, {
|
||||
coordinateSpace: "staging-glb-node-local",
|
||||
position: files.position,
|
||||
normal: files.normal,
|
||||
uv0: files.uv0,
|
||||
indices: files.indices,
|
||||
indexCount: localIndices.length,
|
||||
maxLocalIndex: localIndices.length ? Math.max(...localIndices) : null,
|
||||
vertexStart: range.vertexStart,
|
||||
sourceIndexStart: range.sourceIndexStart,
|
||||
sourceIndexCount: range.sourceIndexCount,
|
||||
recoveredIndexStart: range.recoveredIndexStart,
|
||||
recoveredIndexCount: range.recoveredIndexCount,
|
||||
});
|
||||
if (positionSlice.length !== 145 * 3 || normalSlice.length !== 145 * 3 || uvSlice.length !== 145 * 2) {
|
||||
throw new Error(`MCNK ${range.indexX},${range.indexY} disjoint attribute slice has an unexpected size.`);
|
||||
}
|
||||
if (chunk.ordinal !== range.ordinal) throw new Error("MCNK ordinal lookup changed during chunk stream extraction.");
|
||||
}
|
||||
for (const terrainChunk of terrainChunks) terrainChunk.geometry = chunkGeometryByOrdinal.get(terrainChunk.ordinal);
|
||||
const outputFiles = [
|
||||
["source/tanarisinstance_30_29.adt", adt],
|
||||
["geometry/uv0-float32.bin", uvBytes],
|
||||
["geometry/indices-lowres-holes-filtered.bin", indexBytes],
|
||||
...chunkGeometryFiles,
|
||||
];
|
||||
for (const [relativePath, bytes] of outputFiles) {
|
||||
const output = path.join(outputRoot, relativePath);
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fs.writeFileSync(output, bytes);
|
||||
}
|
||||
|
||||
const materialsFile = {
|
||||
schemaVersion: 1,
|
||||
title: "Zul'Farrak ADT 30_29 source-backed terrain material pilot",
|
||||
sourceAdt: {
|
||||
path: sourcePathRelative,
|
||||
localSource: adtPath,
|
||||
sha256: adtSha256,
|
||||
bytes: adt.length,
|
||||
mver: adt.readUInt32LE(8),
|
||||
clientBuild,
|
||||
provenanceEvidence: {
|
||||
dungeonId: provenanceLock.dungeonId,
|
||||
lockFile: path.relative(projectRoot, provenanceLockPath).replaceAll("\\", "/"),
|
||||
lockSha256: sha256(fs.readFileSync(provenanceLockPath)),
|
||||
sourceSnapshotFile: path.relative(projectRoot, sourceSnapshotPath).replaceAll("\\", "/"),
|
||||
sourceSnapshotSha256: sha256(fs.readFileSync(sourceSnapshotPath)),
|
||||
sourceSnapshotClientBuild: sourceSnapshot.client.build,
|
||||
},
|
||||
winningArchive: winningAdtSource.archive,
|
||||
availableArchiveCopies: adtArchiveSources,
|
||||
},
|
||||
sourceGeometry: {
|
||||
obj: objPath,
|
||||
glb: glbPath,
|
||||
node: meshNode.getName(),
|
||||
nodeWorldMatrix: meshNode.getWorldMatrix(),
|
||||
primitiveCount: 1,
|
||||
vertexCount: positionAccessor.getCount(),
|
||||
indexCount: indexAccessor.getCount(),
|
||||
originalUvCount: uvAccessor.getCount(),
|
||||
sourceUvUniqueCount: new Set(Array.from({ length: uvAccessor.getCount() }, (_, index) => `${sourceUvs[index * 2]},${sourceUvs[index * 2 + 1]}`)).size,
|
||||
originalObjMaterialContract: materialContract,
|
||||
sourceToObjPositionMaxAbsError: maxSourceToObjError,
|
||||
sourceToShippingWorldPositionMaxAbsError: maxSourceToShippingWorldError,
|
||||
sourceToObjTolerance: maxSourceToObjTolerance,
|
||||
sourceToShippingWorldTolerance: maxSourceToShippingWorldTolerance,
|
||||
vertexPositionViolations: { obj: sourceToObjViolations, shippingWorld: sourceToShippingWorldViolations },
|
||||
uv0Output: "geometry/uv0-float32.bin",
|
||||
uv0Sha256: sha256(uvBytes),
|
||||
uvBasis: "MCVT index-positive local coordinates; row 0→16 maps V 0→1; staggered rows retain their half-cell U offset",
|
||||
sourceIndices: { bytes: indices.byteLength, sha256: sha256(Buffer.from(indices.buffer, indices.byteOffset, indices.byteLength)) },
|
||||
recoveredIndices: { file: "geometry/indices-lowres-holes-filtered.bin", bytes: indexBytes.length, sha256: sha256(indexBytes), count: indexArray.length },
|
||||
holePolicy: "preserve raw low-resolution 4x4 MCNK mask and omit each source-marked 2x2 cell fan block; this tile contains no set hole bits",
|
||||
holeTrianglesRemoved,
|
||||
},
|
||||
tileStatistics: {
|
||||
mcnkCount: parsed.mcnkCount,
|
||||
layerCount: parsed.layers,
|
||||
alphaMappedLayerCount: parsed.alphaMappedLayers,
|
||||
alphaMapsRecovered: parsed.alphaMapDataAvailable,
|
||||
alphaMapMissing: parsed.alphaMapDataMissing,
|
||||
alphaChunksUsingEnclosingMcnkSize: parsed.chunks.filter(chunk => chunk.alphaChunkSizeSource === "mcnk-header").length,
|
||||
nonZeroLowResolutionHoleMasks: parsed.chunks.filter(chunk => chunk.holeMask !== 0).length,
|
||||
referencedTextureCount: parsed.textures.length,
|
||||
nearestEntranceChunk,
|
||||
},
|
||||
textures: textureCarriers,
|
||||
weightCarriers: [...weightCarriers.values()],
|
||||
materials,
|
||||
chunks: terrainChunks,
|
||||
shippingChunkRanges: chunkIndexRanges,
|
||||
archiveOpenErrors: archiveErrors,
|
||||
};
|
||||
const json = Buffer.from(`${JSON.stringify(materialsFile, null, 2)}\n`, "utf8");
|
||||
fs.writeFileSync(path.join(outputRoot, "terrain-materials.json"), json);
|
||||
const readme = [
|
||||
"# WoW native ADT terrain material pilot",
|
||||
"",
|
||||
"This generated bundle is a source-backed diagnostic for Zul'Farrak ADT 30_29. It does not modify or replace shipping assets.",
|
||||
"",
|
||||
"- `source/tanarisinstance_30_29.adt` is an exact copy of the raw native ADT identified by the source hash in `terrain-materials.json`.",
|
||||
`- Client identity ${clientBuild} is read from and cross-checked between the dungeon provenance lock and source snapshot; both paths and hashes are in terrain-materials.json.`,
|
||||
"- `textures/source/` preserves the original BLP files; `textures/decoded/` contains lossless PNG decodes for inspection and runtime carrier preparation.",
|
||||
"- `textures/weights/` contains one 64×64 RGBA RGB-weight carrier per unique source-composed image.",
|
||||
"- `geometry/uv0-float32.bin` replaces the flat source-export UVs in the same 37,120-vertex order as the staged `ADT_30_29` primitive.",
|
||||
"- `geometry/indices-lowres-holes-filtered.bin` retains that primitive's verified fan topology and applies its native low-resolution hole masks.",
|
||||
"- `geometry/chunks/mcnk-XX-YY/` contains disjoint 145-vertex position, normal, UV0, and local-index streams for each MCNK. Each slice has a SHA-256, making it suitable for one independently culled primitive per chunk; the source staging node world matrix is recorded in the manifest.",
|
||||
"- `terrain-materials.json` includes per-MCNK material metadata, source offsets, layer maps, alpha map spans and hashes, source-vs-shipping geometry checks, and KHR variant carrier names.",
|
||||
"",
|
||||
"The tile currently has no low-resolution hole bits set. Geometry checks establish vertex correspondence, not visual parity or proof of the original client's lighting response.",
|
||||
].join("\n") + "\n";
|
||||
fs.writeFileSync(path.join(outputRoot, "README.md"), readme);
|
||||
console.log(JSON.stringify({ outputRoot, adtSha256, tileStatistics: materialsFile.tileStatistics, sourceGeometry: materialsFile.sourceGeometry, textureCount: textureCarriers.length, materialCount: materials.length, uniqueWeightImages: weightCarriers.size }, null, 2));
|
||||
} finally {
|
||||
for (const entry of archives) entry.archive.close();
|
||||
}
|
||||
|
||||
function countRemovedFans(holeMask) {
|
||||
let count = 0;
|
||||
for (let fanRow = 0; fanRow < 8; fanRow += 1) {
|
||||
for (let fanColumn = 0; fanColumn < 8; fanColumn += 1) {
|
||||
const bit = Math.floor(fanRow / 2) * 4 + Math.floor(fanColumn / 2);
|
||||
if (holeMask & (1 << bit)) count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { projectRoot, workRoot } from "./lib.mjs";
|
||||
|
||||
const slug = "utgarde-pinnacle";
|
||||
|
||||
function runNodeScript(script, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [script, ...args], {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`${path.basename(script)} exited with code ${code}.`)));
|
||||
});
|
||||
}
|
||||
|
||||
async function buildCandidates() {
|
||||
const source = path.join(workRoot, slug, "staging", "environment", `${slug}-visual.glb`);
|
||||
const artifacts = path.join(projectRoot, "artifacts/dungeon-pipeline");
|
||||
const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), `${slug}-uv-candidates-`));
|
||||
const spatialOnly = process.argv.includes("--spatial-only");
|
||||
const prepared = path.join(temporaryDirectory, `${slug}-prepared.glb`);
|
||||
const cli = path.join(projectRoot, "node_modules/@gltf-transform/cli/bin/cli.js");
|
||||
const encoder = path.join(projectRoot, "scripts/asset-pipeline/compress-ktx2.mjs");
|
||||
const outputs = [
|
||||
{ name: "p14-u12", command: "meshopt", args: ["--quantize-position", "14", "--quantize-texcoord", "12"] },
|
||||
{ name: "p16-u12", command: "meshopt", args: ["--quantize-position", "16", "--quantize-texcoord", "12"] },
|
||||
{ name: "p16-u16", command: "meshopt", args: ["--quantize-position", "16", "--quantize-texcoord", "16"] },
|
||||
{ name: "repeat-optimize-p14-u12", command: "optimize", args: [
|
||||
"--compress", "meshopt", "--meshopt-level", "high", "--instance", "true", "--instance-min", "3",
|
||||
"--simplify", "false", "--texture-compress", "false",
|
||||
] },
|
||||
];
|
||||
|
||||
try {
|
||||
const results = [];
|
||||
if (!spatialOnly) {
|
||||
await runNodeScript(cli, [
|
||||
"optimize", source, prepared,
|
||||
"--compress", "false", "--meshopt-level", "high", "--instance", "true", "--instance-min", "3",
|
||||
"--simplify", "false", "--texture-compress", "false",
|
||||
]);
|
||||
await runNodeScript(encoder, [prepared, "--concurrency", "1", "--jobs", "2"]);
|
||||
|
||||
for (const output of outputs) {
|
||||
const candidate = path.join(artifacts, `${slug}-uv-${output.name}.glb`);
|
||||
const args = output.command === "meshopt"
|
||||
? ["meshopt", prepared, candidate, "--level", "high", ...output.args]
|
||||
: ["optimize", prepared, candidate, ...output.args];
|
||||
await runNodeScript(cli, args);
|
||||
const bytes = await stat(candidate);
|
||||
const hash = createHash("sha256");
|
||||
hash.update(await readFile(candidate));
|
||||
results.push({ candidate: path.relative(projectRoot, candidate).replaceAll("\\", "/"), byteLength: bytes.size, sha256: hash.digest("hex") });
|
||||
}
|
||||
}
|
||||
|
||||
const spatialPrepared = path.join(temporaryDirectory, `${slug}-spatial-prepared.glb`);
|
||||
await runNodeScript(cli, [
|
||||
"optimize", source, spatialPrepared,
|
||||
"--compress", "false", "--instance", "false", "--flatten", "false", "--join", "false", "--weld", "false",
|
||||
"--simplify", "false", "--texture-compress", "false",
|
||||
]);
|
||||
await runNodeScript(encoder, [spatialPrepared, "--concurrency", "1", "--jobs", "2"]);
|
||||
const spatialCandidate = path.join(artifacts, `${slug}-uv-spatial-p16-u16.glb`);
|
||||
await runNodeScript(cli, [
|
||||
"meshopt", spatialPrepared, spatialCandidate, "--level", "high",
|
||||
"--quantize-position", "16", "--quantize-texcoord", "16",
|
||||
]);
|
||||
const spatialBytes = await stat(spatialCandidate);
|
||||
const spatialHash = createHash("sha256").update(await readFile(spatialCandidate)).digest("hex");
|
||||
results.push({
|
||||
candidate: path.relative(projectRoot, spatialCandidate).replaceAll("\\", "/"),
|
||||
byteLength: spatialBytes.size,
|
||||
sha256: spatialHash,
|
||||
geometryPacking: "no instance, flatten, join, or weld; one 16-bit position/UV meshopt pass",
|
||||
});
|
||||
console.log(JSON.stringify({ slug, source: path.relative(projectRoot, source).replaceAll("\\", "/"), candidates: results }, null, 2));
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await buildCandidates();
|
||||
}
|
||||
|
||||
export { buildCandidates };
|
||||
@@ -0,0 +1,282 @@
|
||||
const DEFAULT_LIMITS = Object.freeze({
|
||||
polygons: 50_000,
|
||||
directedLinks: 100_000,
|
||||
detailTriangles: 200_000,
|
||||
surfaceBucketReferences: 2_000_000,
|
||||
});
|
||||
|
||||
function weakComponentData(topology) {
|
||||
if (!topology || !Array.isArray(topology.polygons) || !Array.isArray(topology.links)) {
|
||||
throw new Error("Detour component selection requires polygon and link arrays.");
|
||||
}
|
||||
const polygonCount = topology.polygons.length;
|
||||
const parents = new Int32Array(polygonCount);
|
||||
const sizes = new Uint32Array(polygonCount).fill(1);
|
||||
const polygonById = new Array(polygonCount);
|
||||
for (let index = 0; index < polygonCount; index += 1) parents[index] = index;
|
||||
for (const polygon of topology.polygons) {
|
||||
const id = polygon[0];
|
||||
if (!Number.isInteger(id) || id < 0 || id >= polygonCount || polygonById[id]) {
|
||||
throw new Error(`Detour topology has an invalid or duplicate polygon ID ${id}.`);
|
||||
}
|
||||
polygonById[id] = polygon;
|
||||
}
|
||||
if (polygonById.some((polygon) => !polygon)) {
|
||||
throw new Error("Detour polygon IDs must be a contiguous zero-based range.");
|
||||
}
|
||||
|
||||
const rootOf = (value) => {
|
||||
let root = value;
|
||||
while (parents[root] !== root) root = parents[root];
|
||||
while (parents[value] !== value) {
|
||||
const next = parents[value];
|
||||
parents[value] = root;
|
||||
value = next;
|
||||
}
|
||||
return root;
|
||||
};
|
||||
for (const link of topology.links) {
|
||||
const [from, to] = link;
|
||||
if (!Number.isInteger(from) || !Number.isInteger(to)
|
||||
|| from < 0 || from >= polygonCount || to < 0 || to >= polygonCount) {
|
||||
throw new Error(`Detour topology link has invalid endpoints ${from} -> ${to}.`);
|
||||
}
|
||||
const left = rootOf(from);
|
||||
const right = rootOf(to);
|
||||
if (left === right) continue;
|
||||
if (sizes[left] < sizes[right]) {
|
||||
parents[left] = right;
|
||||
sizes[right] += sizes[left];
|
||||
} else {
|
||||
parents[right] = left;
|
||||
sizes[left] += sizes[right];
|
||||
}
|
||||
}
|
||||
for (let id = 0; id < polygonCount; id += 1) parents[id] = rootOf(id);
|
||||
return { parents, polygonById };
|
||||
}
|
||||
|
||||
function weakComponentSummary(polygonCount, links) {
|
||||
const { parents } = weakComponentData({
|
||||
polygons: Array.from({ length: polygonCount }, (_, id) => [id]),
|
||||
links,
|
||||
});
|
||||
const counts = new Map();
|
||||
for (const component of parents) counts.set(component, (counts.get(component) ?? 0) + 1);
|
||||
const sorted = [...counts.values()].sort((left, right) => right - left);
|
||||
return { count: sorted.length, largestPolygonCount: sorted[0] ?? 0, polygonCounts: sorted };
|
||||
}
|
||||
|
||||
function validateAnchor(anchor) {
|
||||
if (!anchor || typeof anchor.id !== "string" || !anchor.id.trim()) {
|
||||
throw new Error("Every Detour anchor must have a non-empty ID.");
|
||||
}
|
||||
if (!Number.isFinite(anchor.maximumDistance) || anchor.maximumDistance <= 0) {
|
||||
throw new Error(`Detour anchor ${anchor.id} must provide a positive maximumDistance.`);
|
||||
}
|
||||
if (anchor.required !== undefined && typeof anchor.required !== "boolean") {
|
||||
throw new Error(`Detour anchor ${anchor.id} has an invalid required flag.`);
|
||||
}
|
||||
if (anchor.position && (!Array.isArray(anchor.position) || anchor.position.length !== 3
|
||||
|| !anchor.position.every(Number.isFinite))) {
|
||||
throw new Error(`Detour anchor ${anchor.id} has an invalid position.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps only actual weakly connected Detour components that contain a source
|
||||
* anchor within that anchor's explicit projection limit. Polygon IDs and
|
||||
* detail triangle ordering are compacted; only original directed Detour links
|
||||
* whose endpoints survive are copied. No geometric-neighbor links are added.
|
||||
*/
|
||||
export function selectDetourAnchorComponents(topology, anchorMappings, {
|
||||
limits = DEFAULT_LIMITS,
|
||||
surfaceBucketReferenceCount = null,
|
||||
} = {}) {
|
||||
if (!Array.isArray(anchorMappings) || anchorMappings.length === 0) {
|
||||
throw new Error("Detour component selection requires at least one source anchor.");
|
||||
}
|
||||
const activeLimits = { ...DEFAULT_LIMITS, ...limits };
|
||||
for (const [name, value] of Object.entries(activeLimits)) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`Detour selection limit ${name} must be a positive integer.`);
|
||||
}
|
||||
}
|
||||
const { parents, polygonById } = weakComponentData(topology);
|
||||
const anchors = anchorMappings.map((anchor) => {
|
||||
validateAnchor(anchor);
|
||||
const polygonId = anchor.polygonId;
|
||||
const distance = anchor.distance;
|
||||
const mapped = Number.isInteger(polygonId) && polygonId >= 0 && polygonId < polygonById.length;
|
||||
const accepted = mapped && Number.isFinite(distance) && distance >= 0 && distance <= anchor.maximumDistance;
|
||||
return {
|
||||
id: anchor.id,
|
||||
kind: anchor.kind ?? null,
|
||||
sourcePosition: anchor.sourcePosition ?? null,
|
||||
evidence: anchor.evidence ?? null,
|
||||
required: anchor.required === true,
|
||||
position: anchor.position ?? null,
|
||||
sourceSpawnId: anchor.sourceSpawnId ?? null,
|
||||
sourceFile: anchor.sourceFile ?? null,
|
||||
maximumDistance: anchor.maximumDistance,
|
||||
polygonId: mapped ? polygonId : null,
|
||||
distance: Number.isFinite(distance) && distance >= 0 ? distance : null,
|
||||
nearestPoint: anchor.nearestPoint ?? null,
|
||||
componentId: mapped ? parents[polygonId] : null,
|
||||
retained: accepted,
|
||||
reason: accepted ? null : mapped
|
||||
? "nearest-detour-surface-exceeds-source-projection-limit"
|
||||
: anchor.reason ?? "no-detour-surface-within-source-projection-limit",
|
||||
};
|
||||
});
|
||||
const failedRequiredAnchors = anchors.filter((anchor) => anchor.required && !anchor.retained);
|
||||
if (failedRequiredAnchors.length) {
|
||||
throw new Error(
|
||||
`Required source anchors exceed their Detour projection limits: ${failedRequiredAnchors.map((anchor) => (
|
||||
`${anchor.id} (${anchor.distance ?? "unresolved"}m > ${anchor.maximumDistance}m)`
|
||||
)).join(", ")}.`,
|
||||
);
|
||||
}
|
||||
const selectedComponentIds = new Set(
|
||||
anchors.filter((anchor) => anchor.retained).map((anchor) => anchor.componentId),
|
||||
);
|
||||
if (selectedComponentIds.size === 0) {
|
||||
throw new Error("No source anchors map to Detour surfaces within their explicit projection limits.");
|
||||
}
|
||||
|
||||
const selectedSourcePolygons = topology.polygons.filter(([id]) => selectedComponentIds.has(parents[id]));
|
||||
const sourceToSelectedPolygonId = new Map();
|
||||
let nextTriangle = 0;
|
||||
let polygonFanTriangleCount = 0;
|
||||
const polygons = selectedSourcePolygons.map((sourcePolygon, polygonId) => {
|
||||
const [sourceId, tileIndex, tileX, tileY, tileLayer, tilePolygonIndex, , triangleCount, vertexCount] = sourcePolygon;
|
||||
sourceToSelectedPolygonId.set(sourceId, polygonId);
|
||||
nextTriangle += triangleCount;
|
||||
polygonFanTriangleCount += Math.max(0, vertexCount - 2);
|
||||
return [polygonId, tileIndex, tileX, tileY, tileLayer, tilePolygonIndex, nextTriangle - triangleCount, triangleCount, vertexCount];
|
||||
});
|
||||
const links = topology.links
|
||||
.filter(([from, to]) => sourceToSelectedPolygonId.has(from) && sourceToSelectedPolygonId.has(to))
|
||||
.map(([from, to, ...details]) => [sourceToSelectedPolygonId.get(from), sourceToSelectedPolygonId.get(to), ...details]);
|
||||
const selectedIdSet = new Set(sourceToSelectedPolygonId.keys());
|
||||
const excludedSourcePolygons = topology.polygons.filter(([id]) => !selectedIdSet.has(id));
|
||||
const excludedSourceLinks = topology.links.filter(([from, to]) => !selectedIdSet.has(from) || !selectedIdSet.has(to));
|
||||
const selectedComponentRecords = [...selectedComponentIds].map((componentId) => {
|
||||
const componentPolygons = topology.polygons.filter(([id]) => parents[id] === componentId);
|
||||
const componentIds = new Set(componentPolygons.map(([id]) => id));
|
||||
return {
|
||||
sourceComponentId: componentId,
|
||||
polygonCount: componentPolygons.length,
|
||||
detailTriangleCount: componentPolygons.reduce((sum, polygon) => sum + polygon[7], 0),
|
||||
directedLinkCount: topology.links.filter(([from, to]) => componentIds.has(from) && componentIds.has(to)).length,
|
||||
anchorIds: anchors.filter((anchor) => anchor.retained && anchor.componentId === componentId).map((anchor) => anchor.id),
|
||||
};
|
||||
}).sort((left, right) => right.polygonCount - left.polygonCount);
|
||||
const outputTopology = {
|
||||
...topology,
|
||||
polygonCount: polygons.length,
|
||||
detailTriangleCount: polygons.reduce((sum, polygon) => sum + polygon[7], 0),
|
||||
polygonFanTriangleCount,
|
||||
geometryTriangleCount: polygons.reduce((sum, polygon) => sum + polygon[7], 0),
|
||||
directedLinkCount: links.length,
|
||||
tileCount: new Set(polygons.map((polygon) => polygon[1])).size,
|
||||
weakComponents: weakComponentSummary(polygons.length, links),
|
||||
polygons,
|
||||
links,
|
||||
sourceAnchorSelection: {
|
||||
schemaVersion: 1,
|
||||
policy: "retain-complete-detour-weak-components-with-source-anchors-within-explicit-projection-limits",
|
||||
sourceCoordinatePositionsOnly: true,
|
||||
noInferredLinks: true,
|
||||
limits: activeLimits,
|
||||
anchors,
|
||||
selected: {
|
||||
componentCount: selectedComponentIds.size,
|
||||
polygonCount: polygons.length,
|
||||
directedLinkCount: links.length,
|
||||
detailTriangleCount: polygons.reduce((sum, polygon) => sum + polygon[7], 0),
|
||||
surfaceBucketReferenceCount,
|
||||
},
|
||||
selectedComponents: selectedComponentRecords,
|
||||
excluded: {
|
||||
weakComponentCount: Math.max(0, topology.weakComponents?.count - selectedComponentIds.size),
|
||||
polygonCount: excludedSourcePolygons.length,
|
||||
detailTriangleCount: excludedSourcePolygons.reduce((sum, polygon) => sum + polygon[7], 0),
|
||||
directedLinkCount: excludedSourceLinks.length,
|
||||
disposition: "unanchored-components-excluded; not individually proven decorative",
|
||||
},
|
||||
},
|
||||
};
|
||||
const surfaceReferences = surfaceBucketReferenceCount;
|
||||
const actualCounts = {
|
||||
polygons: polygons.length,
|
||||
directedLinks: links.length,
|
||||
detailTriangles: outputTopology.detailTriangleCount,
|
||||
surfaceBucketReferences: surfaceReferences,
|
||||
};
|
||||
for (const key of ["polygons", "directedLinks", "detailTriangles"]) {
|
||||
if (actualCounts[key] > activeLimits[key]) {
|
||||
throw new Error(`Source-anchored Detour subset exceeds the ${key} budget (${actualCounts[key]} > ${activeLimits[key]}).`);
|
||||
}
|
||||
}
|
||||
if (surfaceReferences !== null && surfaceReferences > activeLimits.surfaceBucketReferences) {
|
||||
throw new Error(
|
||||
`Source-anchored Detour subset exceeds the surfaceBucketReferences budget `
|
||||
+ `(${surfaceReferences} > ${activeLimits.surfaceBucketReferences}).`,
|
||||
);
|
||||
}
|
||||
const retainedAnchorIds = anchors.filter((anchor) => anchor.retained).map((anchor) => anchor.id);
|
||||
const unmatchedAnchors = anchors.filter((anchor) => !anchor.retained);
|
||||
return {
|
||||
topology: outputTopology,
|
||||
sourceToSelectedPolygonId,
|
||||
selectedSourcePolygonIds: selectedIdSet,
|
||||
selectedSourceComponentIds: selectedComponentIds,
|
||||
summary: {
|
||||
source: {
|
||||
polygonCount: topology.polygonCount,
|
||||
directedLinkCount: topology.directedLinkCount,
|
||||
detailTriangleCount: topology.detailTriangleCount,
|
||||
weakComponentCount: topology.weakComponents?.count ?? null,
|
||||
},
|
||||
selected: {
|
||||
componentCount: selectedComponentIds.size,
|
||||
polygonCount: actualCounts.polygons,
|
||||
directedLinkCount: actualCounts.directedLinks,
|
||||
detailTriangleCount: actualCounts.detailTriangles,
|
||||
surfaceBucketReferenceCount: actualCounts.surfaceBucketReferences,
|
||||
},
|
||||
excluded: outputTopology.sourceAnchorSelection.excluded,
|
||||
limits: activeLimits,
|
||||
retainedAnchorIds,
|
||||
unmatchedAnchors,
|
||||
components: selectedComponentRecords,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function countSurfaceBucketReferences(positions, indices, bucketSize = 8) {
|
||||
if (!positions || !indices || positions.length % 3 !== 0 || indices.length % 3 !== 0
|
||||
|| !Number.isFinite(bucketSize) || bucketSize <= 0) {
|
||||
throw new Error("Surface bucket counting requires XYZ positions, triangle indices, and positive bucket size.");
|
||||
}
|
||||
let references = 0;
|
||||
for (let offset = 0; offset < indices.length; offset += 3) {
|
||||
const points = [0, 1, 2].map((corner) => {
|
||||
const index = indices[offset + corner];
|
||||
if (!Number.isInteger(index) || index < 0 || index * 3 + 2 >= positions.length) {
|
||||
throw new Error(`Surface bucket triangle index ${index} is invalid.`);
|
||||
}
|
||||
return [positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]];
|
||||
});
|
||||
const minCellX = Math.floor(Math.min(...points.map((point) => point[0])) / bucketSize);
|
||||
const maxCellX = Math.floor(Math.max(...points.map((point) => point[0])) / bucketSize);
|
||||
const minCellZ = Math.floor(Math.min(...points.map((point) => point[2])) / bucketSize);
|
||||
const maxCellZ = Math.floor(Math.max(...points.map((point) => point[2])) / bucketSize);
|
||||
references += (maxCellX - minCellX + 1) * (maxCellZ - minCellZ + 1);
|
||||
if (!Number.isSafeInteger(references)) throw new Error("Surface bucket reference count is not a safe integer.");
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
export const DETOUR_COMPONENT_SELECTION_LIMITS = DEFAULT_LIMITS;
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
countSurfaceBucketReferences,
|
||||
selectDetourAnchorComponents,
|
||||
} from "./detour-component-selection.mjs";
|
||||
|
||||
function topologyFixture() {
|
||||
const polygons = [
|
||||
[0, 0, 0, 0, 0, 0, 0, 2, 4],
|
||||
[1, 0, 0, 0, 0, 1, 2, 1, 3],
|
||||
[2, 1, 1, 0, 0, 0, 3, 3, 4],
|
||||
[3, 2, 2, 0, 0, 0, 6, 1, 3],
|
||||
];
|
||||
const link = (from, to, edge) => [from, to, edge, 0, 0, 255, 0, 1, from, 0, 0, to, 0, 0];
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
polygonCount: 4,
|
||||
tileCount: 3,
|
||||
detailTriangleCount: 7,
|
||||
geometryTriangleCount: 7,
|
||||
polygonFanTriangleCount: 5,
|
||||
directedLinkCount: 2,
|
||||
unrepresentedLinkCount: 0,
|
||||
offMeshPolygonCount: 0,
|
||||
weakComponents: { count: 3, largestPolygonCount: 2, polygonCounts: [2, 1, 1] },
|
||||
polygons,
|
||||
links: [link(0, 2, 0), link(2, 0, 1)],
|
||||
};
|
||||
}
|
||||
|
||||
test("source anchors retain complete weak Detour components and remap only exact source links", () => {
|
||||
const selected = selectDetourAnchorComponents(topologyFixture(), [
|
||||
{ id: "entry", kind: "entry", position: [0, 0, 0], polygonId: 0, distance: 0.2, maximumDistance: 0.5, required: true },
|
||||
{ id: "boss", kind: "boss", position: [100, 0, 0], polygonId: 3, distance: 0.1, maximumDistance: 0.25, required: true },
|
||||
{ id: "far-combat", kind: "combat", position: [50, 0, 0], polygonId: 1, distance: 7, maximumDistance: 4 },
|
||||
]);
|
||||
|
||||
assert.deepEqual([...selected.selectedSourcePolygonIds], [0, 2, 3]);
|
||||
assert.deepEqual([...selected.sourceToSelectedPolygonId], [[0, 0], [2, 1], [3, 2]]);
|
||||
assert.equal(selected.topology.polygonCount, 3);
|
||||
assert.equal(selected.topology.directedLinkCount, 2);
|
||||
assert.equal(selected.topology.detailTriangleCount, 6);
|
||||
assert.deepEqual(selected.topology.polygons.map((row) => row.slice(0, 8)), [
|
||||
[0, 0, 0, 0, 0, 0, 0, 2],
|
||||
[1, 1, 1, 0, 0, 0, 2, 3],
|
||||
[2, 2, 2, 0, 0, 0, 5, 1],
|
||||
]);
|
||||
assert.deepEqual(selected.topology.links.map((row) => row.slice(0, 2)), [[0, 1], [1, 0]]);
|
||||
assert.equal(selected.summary.retainedAnchorIds.includes("entry"), true);
|
||||
assert.equal(selected.summary.retainedAnchorIds.includes("boss"), true);
|
||||
assert.equal(selected.summary.unmatchedAnchors[0].id, "far-combat");
|
||||
assert.equal(selected.summary.excluded.polygonCount, 1);
|
||||
assert.equal(selected.summary.excluded.disposition, "unanchored-components-excluded; not individually proven decorative");
|
||||
});
|
||||
|
||||
test("selection fails closed when an anchored component exceeds any runtime budget", () => {
|
||||
const source = topologyFixture();
|
||||
assert.throws(() => selectDetourAnchorComponents(source, [
|
||||
{ id: "entry", polygonId: 0, distance: 0, maximumDistance: 1 },
|
||||
], { limits: { polygons: 1 } }), /polygons budget/);
|
||||
assert.throws(() => selectDetourAnchorComponents(source, [
|
||||
{ id: "entry", polygonId: 0, distance: 0, maximumDistance: 1 },
|
||||
], { limits: { directedLinks: 1 } }), /directedLinks budget/);
|
||||
assert.throws(() => selectDetourAnchorComponents(source, [
|
||||
{ id: "entry", polygonId: 0, distance: 0, maximumDistance: 1 },
|
||||
], { limits: { detailTriangles: 1 } }), /detailTriangles budget/);
|
||||
assert.throws(() => selectDetourAnchorComponents(source, [
|
||||
{ id: "entry", polygonId: 0, distance: 0, maximumDistance: 1 },
|
||||
], { limits: { surfaceBucketReferences: 3 }, surfaceBucketReferenceCount: 4 }), /surfaceBucketReferences budget/);
|
||||
});
|
||||
|
||||
test("source anchors outside their explicit projection limit do not select components", () => {
|
||||
assert.throws(() => selectDetourAnchorComponents(topologyFixture(), [
|
||||
{ id: "unprojected", polygonId: 2, distance: 4.001, maximumDistance: 4 },
|
||||
]), /No source anchors map/);
|
||||
});
|
||||
|
||||
test("surface bucket references match runtime's XZ cell coverage rule", () => {
|
||||
const positions = new Float32Array([
|
||||
0, 0, 0,
|
||||
8, 0, 0,
|
||||
0, 0, 8,
|
||||
-0.1, 0, -0.1,
|
||||
]);
|
||||
const indices = new Uint32Array([0, 1, 2, 0, 2, 3]);
|
||||
assert.equal(countSurfaceBucketReferences(positions, indices, 8), 10);
|
||||
});
|
||||
@@ -14,6 +14,10 @@ import { prepareDungeonMaterial } from "../../src/scene/productionDungeonMateria
|
||||
import { applyDungeonMeshGraphics } from "../../src/scene/graphicsMaterialQuality";
|
||||
import { setDungeonShadowLightPose } from "../../src/scene/dungeonShadowLightRuntime";
|
||||
import type { ShadowQuality } from "../../src/game/graphicsSettings";
|
||||
import { cacheStaticMatrices, instanceRepeatedStaticMeshes } from "../../src/scene/staticSceneInstancing";
|
||||
import { createStaticVisibilityCells, registerStaticVisibility } from "../../src/scene/staticSceneVisibility";
|
||||
import { materialTextures } from "../../src/scene/threeResourceDisposal";
|
||||
import { configureWowTerrainLoader } from "../../src/scene/wowTerrainMaterial";
|
||||
|
||||
// Isolated asset QA uses shipping assets and the production material/light helpers.
|
||||
// It intentionally does not claim gameplay, collision or encounter certification.
|
||||
@@ -59,6 +63,7 @@ const controls = new OrbitControls(camera, renderer.domElement);
|
||||
const loader = new GLTFLoader();
|
||||
const decoder = new KTX2Loader().setTranscoderPath("/basis/").detectSupport(renderer);
|
||||
loader.setKTX2Loader(decoder).setMeshoptDecoder(MeshoptDecoder);
|
||||
configureWowTerrainLoader(loader);
|
||||
const root = new THREE.Group();
|
||||
root.position.fromArray(definition.transform.position);
|
||||
root.rotation.fromArray([...definition.transform.rotation, "XYZ"]);
|
||||
@@ -66,20 +71,49 @@ root.scale.setScalar(definition.transform.scale);
|
||||
scene.add(root);
|
||||
const assets = resolveDungeonAssets(definition);
|
||||
if (assets.visual.length !== definition.assets.visual.length) throw new Error(`Missing shipping visual for ${id}`);
|
||||
const visualOverride = params.get("visualOverride");
|
||||
const runtimePreparation = params.get("runtimePreparation") === "1";
|
||||
const preparationReports: { batches: number; instances: number; removedMeshes: number; visibilityCells: number }[] = [];
|
||||
if (visualOverride && (assets.visual.length !== 1
|
||||
|| !/^\/artifacts\/dungeon-pipeline\/[a-zA-Z0-9_./-]+\.glb$/.test(visualOverride)
|
||||
|| visualOverride.includes(".."))) {
|
||||
throw new Error("A review override must be a single GLB in artifacts/dungeon-pipeline.");
|
||||
}
|
||||
for (const url of assets.visual) {
|
||||
const reviewUrl = id === "shadowfang-keep" && params.get("stage") === "before-shadowfang"
|
||||
const reviewUrl = visualOverride ?? (id === "shadowfang-keep" && params.get("stage") === "before-shadowfang"
|
||||
? "/artifacts/playtest/wow-environment-quality/before-shadowfang/visual.glb"
|
||||
: id === "shadowfang-keep" && params.get("stage") === "repacked-shadowfang"
|
||||
? "/artifacts/dungeon-pipeline/shadowfang-keep-visual.reoptimized-repacked.glb"
|
||||
: id === "shadowfang-keep" && params.get("stage") === "selective-shadowfang"
|
||||
? "/artifacts/dungeon-pipeline/shadowfang-keep-selective-error-0012.glb" : url;
|
||||
? "/artifacts/dungeon-pipeline/shadowfang-keep-selective-error-0012.glb" : url);
|
||||
const gltf = await loader.loadAsync(reviewUrl);
|
||||
const preparedMaterials = new Map<string, THREE.Material>();
|
||||
const prepare = (material: THREE.Material) => {
|
||||
if (runtimePreparation && preparedMaterials.has(material.uuid)) return preparedMaterials.get(material.uuid)!;
|
||||
const next = prepareDungeonMaterial(material, p.materials, definition.environmentMode);
|
||||
preparedMaterials.set(material.uuid, next);
|
||||
return next;
|
||||
};
|
||||
gltf.scene.traverse((node) => {
|
||||
if (!(node instanceof THREE.Mesh)) return;
|
||||
node.material = Array.isArray(node.material)
|
||||
? node.material.map((material) => prepareDungeonMaterial(material, p.materials, definition.environmentMode))
|
||||
: prepareDungeonMaterial(node.material, p.materials, definition.environmentMode);
|
||||
? node.material.map(prepare) : prepare(node.material);
|
||||
if (runtimePreparation) {
|
||||
node.castShadow = false;
|
||||
node.receiveShadow = true;
|
||||
}
|
||||
if (runtimePreparation && node instanceof THREE.InstancedMesh) {
|
||||
node.computeBoundingBox();
|
||||
node.computeBoundingSphere();
|
||||
}
|
||||
});
|
||||
if (runtimePreparation) {
|
||||
const instancing = instanceRepeatedStaticMeshes(gltf.scene, 3, undefined, definition.id === "scholomance");
|
||||
const cells = createStaticVisibilityCells(gltf.scene);
|
||||
cacheStaticMatrices(gltf.scene);
|
||||
registerStaticVisibility(scene, cells);
|
||||
preparationReports.push({ ...instancing, visibilityCells: cells.length });
|
||||
}
|
||||
root.add(gltf.scene);
|
||||
}
|
||||
let quality: ShadowQuality = "standard";
|
||||
@@ -122,14 +156,42 @@ function diagnostics() {
|
||||
meshes++;
|
||||
if (node.castShadow) casters++;
|
||||
for (const material of Array.isArray(node.material) ? node.material : [node.material]) {
|
||||
const map = (material as THREE.MeshStandardMaterial).map;
|
||||
if (map) textures.set(map.uuid, { name: map.name, width: map.image?.width, height: map.image?.height, anisotropy: map.anisotropy });
|
||||
for (const texture of materialTextures(material)) {
|
||||
textures.set(texture.uuid, { name: texture.name, width: texture.image?.width,
|
||||
height: texture.image?.height, anisotropy: texture.anisotropy });
|
||||
}
|
||||
}
|
||||
});
|
||||
return { meshes, casters, textures: [...textures.values()], drawCalls: renderer.info.render.calls,
|
||||
return { meshes, casters, textures: [...textures.values()], runtimePreparation, preparationReports, drawCalls: renderer.info.render.calls,
|
||||
renderedTriangles: renderer.info.render.triangles, geometries: renderer.info.memory.geometries,
|
||||
lightTarget: light.target.position.toArray(), camera: camera.position.toArray(), maxAnisotropy: renderer.capabilities.getMaxAnisotropy() };
|
||||
}
|
||||
function setCamera(eye: [number, number, number], target: [number, number, number]) {
|
||||
camera.position.fromArray(eye);
|
||||
controls.target.fromArray(target);
|
||||
controls.update();
|
||||
}
|
||||
function groundSamples() {
|
||||
root.updateMatrixWorld(true);
|
||||
const ray = new THREE.Raycaster();
|
||||
return viewpoints.map((entry) => {
|
||||
const origin = new THREE.Vector3(...entry.position).add(new THREE.Vector3(0, 20, 0));
|
||||
ray.set(origin, new THREE.Vector3(0, -1, 0));
|
||||
ray.far = 80;
|
||||
const seen = new Set<string>();
|
||||
const hits = ray.intersectObject(root, true).flatMap((hit) => {
|
||||
const mesh = hit.object as THREE.Mesh;
|
||||
const material = (Array.isArray(mesh.material)
|
||||
? mesh.material[hit.face?.materialIndex ?? 0] : mesh.material) as THREE.MeshStandardMaterial;
|
||||
const key = `${mesh.uuid}:${hit.instanceId}:${Math.round(hit.point.y * 1000)}`;
|
||||
if (seen.has(key)) return [];
|
||||
seen.add(key);
|
||||
return [{ mesh: mesh.name, material: material.name, textured: materialTextures(material).length > 0,
|
||||
height: hit.point.y, heightAboveFoot: hit.point.y - entry.position[1], point: hit.point.toArray() }];
|
||||
}).slice(0, 8);
|
||||
return { name: entry.name, position: entry.position, hits };
|
||||
});
|
||||
}
|
||||
view(0);
|
||||
setQuality("standard");
|
||||
let frames = 0;
|
||||
@@ -148,5 +210,5 @@ renderer.setAnimationLoop(() => {
|
||||
renderer.render(scene, camera);
|
||||
frames++;
|
||||
});
|
||||
Object.assign(window, { wowEnvironmentReview: { ready: true, id, viewpoints, view, setQuality, diagnostics, resetFrameSample, frameSample, presentation: p,
|
||||
Object.assign(window, { wowEnvironmentReview: { ready: true, id, viewpoints, view, setCamera, setQuality, diagnostics, groundSamples, resetFrameSample, frameSample, presentation: p,
|
||||
catalog: definitions.filter((entry) => !entry.id.includes("bite-sized")).map((entry) => ({ id: entry.id, title: entry.title })) } });
|
||||
@@ -18,6 +18,7 @@ const defaultWowDbc = path.resolve(
|
||||
);
|
||||
|
||||
const DAMAGE_EFFECTS = new Set([1, 2, 7, 9, 17, 31, 58, 62, 121]);
|
||||
const WOW_HIT_ATTRIBUTE_FIELDS = Object.freeze({ attributes0: 4, attributes3: 7, attributes7: 11 });
|
||||
const WOW_SCHOOL_BITS = [
|
||||
[1, "physical"],
|
||||
[2, "holy"],
|
||||
@@ -89,13 +90,18 @@ function parseWowDamageRecords(buffer, wantedIds) {
|
||||
name: readName(base),
|
||||
damageClass,
|
||||
schoolMask,
|
||||
hitSource: {
|
||||
attributes0: unsigned(WOW_HIT_ATTRIBUTE_FIELDS.attributes0),
|
||||
attributes3: unsigned(WOW_HIT_ATTRIBUTE_FIELDS.attributes3),
|
||||
attributes7: unsigned(WOW_HIT_ATTRIBUTE_FIELDS.attributes7),
|
||||
},
|
||||
schools,
|
||||
...(schools.length === 1 ? { school: schools[0] } : {}),
|
||||
...(damageClass === 1 ? { attackKind: "spell" }
|
||||
: damageClass === 2 ? { attackKind: "melee" }
|
||||
: damageClass === 3 ? { attackKind: "ranged" } : {}),
|
||||
damageEffects: [...new Set(damageEffects)].sort((left, right) => left - right),
|
||||
source: "WoW 3.3.5a Spell.dbc; AzerothCore DBCStructure.h fields 213/225",
|
||||
source: "WoW 3.3.5a Spell.dbc; AzerothCore DBCStructure.h fields 4/7/11/213/225",
|
||||
});
|
||||
}
|
||||
return { records: results, recordCount, fieldCount, recordSize };
|
||||
@@ -213,16 +219,20 @@ function romRecord(spell) {
|
||||
|
||||
function parseArguments(argv) {
|
||||
let wowDbc = defaultWowDbc;
|
||||
let outputPath = outputFile;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (argv[index] === "--wow-dbc") {
|
||||
wowDbc = path.resolve(argv[index + 1] ?? "");
|
||||
index += 1;
|
||||
} else if (argv[index] === "--output") {
|
||||
outputPath = path.resolve(argv[index + 1] ?? "");
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return { wowDbc };
|
||||
return { wowDbc, outputPath };
|
||||
}
|
||||
|
||||
const { wowDbc } = parseArguments(process.argv.slice(2));
|
||||
const { wowDbc, outputPath } = parseArguments(process.argv.slice(2));
|
||||
const [campaignBytes, bossMechanicsBytes, runewakerSpellBytes, wowDbcBytes] = await Promise.all([
|
||||
readFile(campaignFile),
|
||||
readFile(bossMechanicsFile),
|
||||
@@ -247,6 +257,7 @@ const output = {
|
||||
runewakerNpcSpellCatalogSha256: sha256(runewakerSpellBytes),
|
||||
wowRecordLayout: `${wow.fieldCount} fields; ${wow.recordSize} byte rows`,
|
||||
wowSpellDamageFields: { damageClass: 213, schoolMask: 225, effects: [71, 72, 73] },
|
||||
wowSpellHitFields: WOW_HIT_ATTRIBUTE_FIELDS,
|
||||
runewakerCalculationTypePolicy: {
|
||||
physical: [0, 3, 9],
|
||||
magical: [1, 10],
|
||||
@@ -280,6 +291,6 @@ const output = {
|
||||
.map(([spellId, record]) => [String(spellId), record])),
|
||||
runewaker: Object.fromEntries(Object.entries(runewaker).sort(([left], [right]) => Number(left) - Number(right))),
|
||||
};
|
||||
await writeFile(outputFile, JSON.stringify(output, null, 2) + "\n", "utf8");
|
||||
await writeFile(outputPath, JSON.stringify(output, null, 2) + "\n", "utf8");
|
||||
console.log(`[enemy-damage-types] exported ${output.counts.wowExportedDamageSpells} WoW and ${output.counts.runewakerExportedDamageSpells} RuneWaker source spells.`);
|
||||
console.log(`[enemy-damage-types] ${path.relative(projectRoot, outputFile)}`);
|
||||
console.log(`[enemy-damage-types] ${path.relative(projectRoot, outputPath)}`);
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const scriptPath = fileURLToPath(new URL("./export-enemy-damage-types.mjs", import.meta.url));
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
function spellDbcFixture(rows) {
|
||||
const fieldCount = 234;
|
||||
const recordSize = fieldCount * 4;
|
||||
const output = Buffer.alloc(20 + rows.length * recordSize);
|
||||
output.write("WDBC", 0, "ascii");
|
||||
output.writeUInt32LE(rows.length, 4);
|
||||
output.writeUInt32LE(fieldCount, 8);
|
||||
output.writeUInt32LE(recordSize, 12);
|
||||
output.writeUInt32LE(0, 16);
|
||||
rows.forEach(({ spellId, attributes0, attributes3, attributes7 }, index) => {
|
||||
const base = 20 + index * recordSize;
|
||||
const setField = (field, value) => output.writeUInt32LE(value >>> 0, base + field * 4);
|
||||
setField(0, spellId);
|
||||
setField(4, attributes0);
|
||||
setField(5, 0x1111_1111);
|
||||
setField(6, 0x2222_2222);
|
||||
setField(7, attributes3);
|
||||
setField(8, 0x3333_3333);
|
||||
setField(10, 0x4444_4444);
|
||||
setField(11, attributes7);
|
||||
setField(71, 2);
|
||||
setField(213, 1);
|
||||
setField(225, 16);
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
test("enemy damage exporter reads raw Attributes0/3/7 from pinned Spell.dbc field indexes", async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "healer-enemy-hit-source-"));
|
||||
const dbcPath = path.join(tempRoot, "Spell.dbc");
|
||||
const outputPath = path.join(tempRoot, "enemyDamageTypeSources.json");
|
||||
try {
|
||||
await writeFile(dbcPath, spellDbcFixture([
|
||||
{ spellId: 38_163, attributes0: 0x1234_5678, attributes3: 0x0004_0000, attributes7: 0x0200_0000 },
|
||||
{ spellId: 122, attributes0: 0x0020_0000, attributes3: 0, attributes7: 0x0100_0000 },
|
||||
]));
|
||||
const run = spawnSync(process.execPath, [
|
||||
scriptPath,
|
||||
"--wow-dbc", dbcPath,
|
||||
"--output", outputPath,
|
||||
], { cwd: projectRoot, encoding: "utf8" });
|
||||
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
|
||||
|
||||
const generated = JSON.parse(await readFile(outputPath, "utf8"));
|
||||
assert.deepEqual(generated.generatedFrom.wowSpellHitFields, {
|
||||
attributes0: 4,
|
||||
attributes3: 7,
|
||||
attributes7: 11,
|
||||
});
|
||||
assert.deepEqual(generated.wow335["38163"].hitSource, {
|
||||
attributes0: 0x1234_5678,
|
||||
attributes3: 0x0004_0000,
|
||||
attributes7: 0x0200_0000,
|
||||
});
|
||||
assert.deepEqual(generated.wow335["122"].hitSource, {
|
||||
attributes0: 0x0020_0000,
|
||||
attributes3: 0,
|
||||
attributes7: 0x0100_0000,
|
||||
});
|
||||
assert.equal(Object.hasOwn(generated.wow335["122"].hitSource, "alwaysHit"), false);
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function chunkId(buffer, offset) {
|
||||
return Buffer.from(buffer.subarray(offset, offset + 4)).reverse().toString("ascii");
|
||||
}
|
||||
|
||||
function readChunkList(buffer, start, end, label) {
|
||||
const chunks = [];
|
||||
let offset = start;
|
||||
while (offset + 8 <= end) {
|
||||
const id = chunkId(buffer, offset);
|
||||
const size = buffer.readUInt32LE(offset + 4);
|
||||
const dataOffset = offset + 8;
|
||||
const chunkEnd = dataOffset + size;
|
||||
if (chunkEnd > end) throw new Error(`${label}: ${id} at ${offset} extends beyond its container.`);
|
||||
chunks.push({ id, offset, dataOffset, size, end: chunkEnd });
|
||||
offset = chunkEnd;
|
||||
}
|
||||
if (offset !== end) throw new Error(`${label}: ${end - offset} trailing bytes cannot form a chunk header.`);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function readSubchunk(buffer, mcnk, relativeOffset, label) {
|
||||
if (!relativeOffset) return null;
|
||||
const offset = mcnk.offset + relativeOffset;
|
||||
if (offset + 8 > buffer.length) throw new Error(`${label}: subchunk offset ${relativeOffset} leaves the ADT.`);
|
||||
const id = chunkId(buffer, offset);
|
||||
const size = buffer.readUInt32LE(offset + 4);
|
||||
const dataOffset = offset + 8;
|
||||
const end = dataOffset + size;
|
||||
if (end > buffer.length) throw new Error(`${label}: ${id} subchunk leaves the ADT.`);
|
||||
return { id, offset, dataOffset, size, end, withinMcnk: end <= mcnk.end };
|
||||
}
|
||||
|
||||
function textureNames(buffer, mtex) {
|
||||
if (!mtex) return [];
|
||||
const value = buffer.toString("utf8", mtex.dataOffset, mtex.end);
|
||||
return value.split("\0").filter(Boolean).map((name) => name.replaceAll("/", "\\"));
|
||||
}
|
||||
|
||||
function hash(buffer) {
|
||||
return createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
export function parseWowAdtTerrain(buffer, { label = "ADT" } = {}) {
|
||||
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
|
||||
const chunks = readChunkList(buffer, 0, buffer.length, label);
|
||||
if (chunks[0]?.id !== "MVER") throw new Error(`${label}: missing MVER first chunk.`);
|
||||
const mtex = chunks.find((chunk) => chunk.id === "MTEX");
|
||||
const textures = textureNames(buffer, mtex);
|
||||
const mcnks = chunks.filter((chunk) => chunk.id === "MCNK");
|
||||
const terrainChunks = mcnks.map((mcnk, ordinal) => {
|
||||
if (mcnk.size < 128) throw new Error(`${label}: MCNK ${ordinal} is shorter than its 128-byte header.`);
|
||||
const data = mcnk.dataOffset;
|
||||
const flags = buffer.readUInt32LE(data);
|
||||
const indexX = buffer.readUInt32LE(data + 4);
|
||||
const indexY = buffer.readUInt32LE(data + 8);
|
||||
const layerCount = buffer.readUInt32LE(data + 12);
|
||||
const height = readSubchunk(buffer, mcnk, buffer.readUInt32LE(data + 20), `${label} MCNK ${ordinal} MCVT`);
|
||||
const layersChunk = readSubchunk(buffer, mcnk, buffer.readUInt32LE(data + 28), `${label} MCNK ${ordinal} MCLY`);
|
||||
const alphaChunkHeader = readSubchunk(buffer, mcnk, buffer.readUInt32LE(data + 36), `${label} MCNK ${ordinal} MCAL`);
|
||||
const alphaSizeHeader = buffer.readUInt32LE(data + 40);
|
||||
// A subset of MVER 18 ADTs has an MCAL FourCC followed by a zero chunk
|
||||
// size, while the enclosing MCNK header carries the complete MCAL size.
|
||||
// The bytes after the eight-byte subchunk header are still the source map.
|
||||
const alphaChunk = alphaChunkHeader?.id === "MCAL" && alphaChunkHeader.size === 0 && alphaSizeHeader > 8
|
||||
? {
|
||||
...alphaChunkHeader,
|
||||
size: alphaSizeHeader - 8,
|
||||
end: alphaChunkHeader.dataOffset + alphaSizeHeader - 8,
|
||||
withinMcnk: alphaChunkHeader.dataOffset + alphaSizeHeader - 8 <= mcnk.end,
|
||||
sizeSource: "mcnk-header",
|
||||
}
|
||||
: alphaChunkHeader && { ...alphaChunkHeader, sizeSource: "mcal-subchunk-header" };
|
||||
const worldHeight = buffer.readFloatLE(data + 104);
|
||||
const worldX = buffer.readFloatLE(data + 108);
|
||||
const worldY = buffer.readFloatLE(data + 112);
|
||||
const holeMask = buffer.readUInt16LE(data + 60);
|
||||
const lowResolutionTextureMap = Array.from({ length: 8 }, (_, index) => buffer.readUInt16LE(data + 64 + index * 2));
|
||||
|
||||
if (layerCount && (!layersChunk || layersChunk.id !== "MCLY" || layersChunk.size < layerCount * 16)) {
|
||||
throw new Error(`${label}: MCNK ${ordinal} layer header does not match its ${layerCount} layers.`);
|
||||
}
|
||||
if (height && height.id !== "MCVT") throw new Error(`${label}: MCNK ${ordinal} height offset points to ${height.id}.`);
|
||||
if (alphaChunk && alphaChunk.id !== "MCAL") throw new Error(`${label}: MCNK ${ordinal} alpha offset points to ${alphaChunk.id}.`);
|
||||
|
||||
const layers = Array.from({ length: layerCount }, (_, index) => {
|
||||
const offset = layersChunk.dataOffset + index * 16;
|
||||
const textureIndex = buffer.readUInt32LE(offset);
|
||||
const layerFlags = buffer.readUInt32LE(offset + 4);
|
||||
const alphaOffset = buffer.readUInt32LE(offset + 8);
|
||||
const effectId = buffer.readUInt32LE(offset + 12);
|
||||
const usesAlphaMap = (layerFlags & 0x100) !== 0;
|
||||
const alphaMapCompressed = (layerFlags & 0x200) !== 0;
|
||||
if (textureIndex >= textures.length) {
|
||||
throw new Error(`${label}: MCNK ${ordinal} layer ${index} references MTEX index ${textureIndex}/${textures.length}.`);
|
||||
}
|
||||
return {
|
||||
index,
|
||||
recordOffset: offset,
|
||||
textureIndex,
|
||||
texture: textures[textureIndex],
|
||||
flags: layerFlags,
|
||||
usesAlphaMap,
|
||||
alphaMapCompressed,
|
||||
alphaMapAvailable: false,
|
||||
alphaMapEncoding: null,
|
||||
alphaMapBytes: 0,
|
||||
alphaMapDataOffset: null,
|
||||
alphaOffset: usesAlphaMap ? alphaOffset : null,
|
||||
effectId,
|
||||
};
|
||||
});
|
||||
|
||||
for (const layer of layers) {
|
||||
if (!layer.usesAlphaMap || !alphaChunk) continue;
|
||||
const laterAlphaLayer = layers.find((candidate) => candidate.usesAlphaMap && candidate.index > layer.index);
|
||||
const alphaBytes = laterAlphaLayer
|
||||
? laterAlphaLayer.alphaOffset - layer.alphaOffset
|
||||
: alphaChunk.size - layer.alphaOffset;
|
||||
const alphaMapDataOffset = alphaChunk.dataOffset + layer.alphaOffset;
|
||||
const encoding = layer.alphaMapCompressed
|
||||
? "rle-8"
|
||||
: alphaBytes === 4096
|
||||
? "uncompressed-8"
|
||||
: alphaBytes === 2048
|
||||
? "packed-4"
|
||||
: "unsupported";
|
||||
const alphaMapAvailable = Boolean(
|
||||
alphaChunk.withinMcnk
|
||||
&& alphaChunk.size + 8 === buffer.readUInt32LE(data + 40)
|
||||
&& layer.alphaOffset >= 0
|
||||
&& alphaBytes > 0
|
||||
&& alphaMapDataOffset + alphaBytes <= alphaChunk.end
|
||||
&& encoding !== "unsupported",
|
||||
);
|
||||
layer.alphaMapBytes = Math.max(0, alphaBytes);
|
||||
layer.alphaMapDataOffset = alphaMapDataOffset;
|
||||
layer.alphaMapEncoding = encoding;
|
||||
layer.alphaMapAvailable = alphaMapAvailable;
|
||||
}
|
||||
|
||||
const heightData = height?.id === "MCVT" ? buffer.subarray(height.dataOffset, height.end) : null;
|
||||
|
||||
return {
|
||||
ordinal,
|
||||
indexX,
|
||||
indexY,
|
||||
flags,
|
||||
holeMask,
|
||||
highResolutionHoles: (flags & 0x10000) !== 0,
|
||||
lowResolutionTextureMap,
|
||||
world: { x: worldX, y: worldY, height: worldHeight },
|
||||
sourceOffsets: {
|
||||
mcnk: mcnk.offset,
|
||||
mcvt: height?.dataOffset ?? null,
|
||||
mcly: layersChunk?.dataOffset ?? null,
|
||||
mcal: alphaChunk?.dataOffset ?? null,
|
||||
},
|
||||
heightSha256: heightData ? hash(heightData) : null,
|
||||
heightBytes: height?.size ?? 0,
|
||||
heightSamples: height?.id === "MCVT" && height.size % 4 === 0
|
||||
? Array.from({ length: height.size / 4 }, (_, index) => buffer.readFloatLE(height.dataOffset + index * 4))
|
||||
: null,
|
||||
alphaBytes: alphaChunk?.size ?? 0,
|
||||
alphaChunkWithinMcnk: alphaChunk?.withinMcnk ?? null,
|
||||
alphaSizeHeader,
|
||||
alphaChunkHeaderSize: alphaChunkHeader?.size ?? null,
|
||||
alphaChunkSizeSource: alphaChunk?.sizeSource ?? null,
|
||||
// In WotLK MCNK headers sizeMCAL includes the 8-byte MCAL chunk header.
|
||||
// MVER 18 zero-sized MCAL headers use that enclosing span as the source.
|
||||
alphaSizeMatchesHeader: alphaChunk ? alphaChunk.size + 8 === buffer.readUInt32LE(data + 40) : buffer.readUInt32LE(data + 40) === 0,
|
||||
materialLayerChunkWithinMcnk: layersChunk?.withinMcnk ?? null,
|
||||
layers,
|
||||
};
|
||||
});
|
||||
|
||||
const layerReferences = new Map();
|
||||
for (const chunk of terrainChunks) {
|
||||
for (const layer of chunk.layers) {
|
||||
const existing = layerReferences.get(layer.texture);
|
||||
if (existing) existing.count += 1;
|
||||
else layerReferences.set(layer.texture, { texture: layer.texture, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sha256: createHash("sha256").update(buffer).digest("hex"),
|
||||
bytes: buffer.length,
|
||||
topLevelChunks: chunks.map(({ id, size }) => ({ id, size })),
|
||||
textureTableCount: textures.length,
|
||||
textures,
|
||||
mcnkCount: terrainChunks.length,
|
||||
chunks: terrainChunks,
|
||||
layerReferences: [...layerReferences.values()].sort((left, right) => left.texture.localeCompare(right.texture)),
|
||||
layers: terrainChunks.reduce((count, chunk) => count + chunk.layers.length, 0),
|
||||
alphaMappedLayers: terrainChunks.reduce((count, chunk) => count + chunk.layers.filter((layer) => layer.usesAlphaMap).length, 0),
|
||||
alphaMapDataAvailable: terrainChunks.reduce((count, chunk) => count + chunk.layers.filter((layer) => layer.alphaMapAvailable).length, 0),
|
||||
alphaMapDataMissing: terrainChunks.reduce((count, chunk) => count + chunk.layers.filter((layer) => layer.usesAlphaMap && !layer.alphaMapAvailable).length, 0),
|
||||
alphaChunksOutOfBounds: terrainChunks.filter((chunk) => chunk.alphaChunkWithinMcnk === false).length,
|
||||
alphaSizeHeaderMismatches: terrainChunks.filter((chunk) => chunk.alphaSizeMatchesHeader === false).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function inspectObjMaterialContract(file) {
|
||||
const obj = fs.readFileSync(file, "utf8");
|
||||
const materialFile = file.replace(/\.obj$/i, ".mtl");
|
||||
const mtl = fs.existsSync(materialFile) ? fs.readFileSync(materialFile, "utf8") : "";
|
||||
const uvLines = obj.split(/\r?\n/).filter((line) => line.startsWith("vt "));
|
||||
const uniqueUvs = new Set(uvLines.map((line) => line.trim()));
|
||||
const usedMaterials = [...new Set([...obj.matchAll(/^usemtl\s+(.+)$/gm)].map((match) => match[1].trim()))];
|
||||
return {
|
||||
obj: path.basename(file),
|
||||
mtl: path.basename(materialFile),
|
||||
vertexUvCount: uvLines.length,
|
||||
uniqueVertexUvs: uniqueUvs.size,
|
||||
usedMaterials,
|
||||
mtlHasDiffuseMap: /^\s*map_Kd\s+/im.test(mtl),
|
||||
mtlHasTextureName: /\.blp|\.png|\.jpg|\.dds/i.test(mtl),
|
||||
};
|
||||
}
|
||||
|
||||
export function inspectWowAdtTerrainExport(sourceExportDir) {
|
||||
const rawRoot = path.join(sourceExportDir, "raw", "world", "maps");
|
||||
if (!fs.existsSync(rawRoot)) throw new Error(`Missing raw ADT directory: ${rawRoot}`);
|
||||
const adtFiles = [];
|
||||
const walk = (directory) => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const child = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) walk(child);
|
||||
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".adt")) adtFiles.push(child);
|
||||
}
|
||||
};
|
||||
walk(rawRoot);
|
||||
if (!adtFiles.length) throw new Error(`No raw ADT files found under ${rawRoot}`);
|
||||
const terrainFiles = adtFiles.sort().map((file) => {
|
||||
const parsed = parseWowAdtTerrain(fs.readFileSync(file), { label: file });
|
||||
const basename = path.basename(file).replace(/\.adt$/i, "");
|
||||
const coordinates = basename.match(/_(\d+)_(\d+)$/);
|
||||
const exporterObj = coordinates
|
||||
? path.join(sourceExportDir, `adt_${coordinates[1]}_${coordinates[2]}.obj`)
|
||||
: null;
|
||||
return {
|
||||
file: path.relative(sourceExportDir, file).replaceAll("\\", "/"),
|
||||
...parsed,
|
||||
exporterContract: exporterObj && fs.existsSync(exporterObj) ? inspectObjMaterialContract(exporterObj) : null,
|
||||
};
|
||||
});
|
||||
return {
|
||||
sourceExportDir: path.resolve(sourceExportDir),
|
||||
tileCount: terrainFiles.length,
|
||||
mcnkCount: terrainFiles.reduce((sum, tile) => sum + tile.mcnkCount, 0),
|
||||
layerCount: terrainFiles.reduce((sum, tile) => sum + tile.layers, 0),
|
||||
alphaMappedLayerCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaMappedLayers, 0),
|
||||
alphaMapDataAvailableCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaMapDataAvailable, 0),
|
||||
alphaMapDataMissingCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaMapDataMissing, 0),
|
||||
alphaChunksOutOfBoundsCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaChunksOutOfBounds, 0),
|
||||
alphaSizeHeaderMismatchCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaSizeHeaderMismatches, 0),
|
||||
maxLayerCountPerChunk: Math.max(0, ...terrainFiles.flatMap((tile) => tile.chunks.map((chunk) => chunk.layers.length))),
|
||||
referencedTextures: [...new Set(terrainFiles.flatMap((tile) => tile.textures))].sort((a, b) => a.localeCompare(b)),
|
||||
tiles: terrainFiles,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseWowAdtTerrain } from "./wow-adt-terrain-audit.mjs";
|
||||
import { decodeWowAdtAlphaMap } from "./wow-adt-terrain-material.mjs";
|
||||
|
||||
function chunk(name, payload) {
|
||||
const header = Buffer.alloc(8);
|
||||
Buffer.from(name, "ascii").reverse().copy(header);
|
||||
header.writeUInt32LE(payload.length, 4);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
function rleFill(value) {
|
||||
const bytes = [];
|
||||
let remaining = 4096;
|
||||
while (remaining) {
|
||||
const count = Math.min(127, remaining);
|
||||
bytes.push(0x80 | count, value);
|
||||
remaining -= count;
|
||||
}
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function zeroSizedMcalAdt({ declaredPayloadBytes = null } = {}) {
|
||||
const mtex = Buffer.from("Base.blp\0Overlay.blp\0", "utf8");
|
||||
const layers = Buffer.alloc(32);
|
||||
layers.writeUInt32LE(0, 0);
|
||||
layers.writeUInt32LE(0, 4);
|
||||
layers.writeUInt32LE(1, 16);
|
||||
layers.writeUInt32LE(0x300, 20);
|
||||
layers.writeUInt32LE(0, 24);
|
||||
|
||||
const alpha = rleFill(91);
|
||||
const payloadLength = declaredPayloadBytes ?? alpha.length;
|
||||
const alphaSubchunk = Buffer.alloc(8 + alpha.length);
|
||||
Buffer.from("MCAL", "ascii").reverse().copy(alphaSubchunk);
|
||||
alphaSubchunk.writeUInt32LE(0, 4);
|
||||
alpha.copy(alphaSubchunk, 8);
|
||||
|
||||
const mcnkHeader = Buffer.alloc(128);
|
||||
mcnkHeader.writeUInt32LE(0, 4);
|
||||
mcnkHeader.writeUInt32LE(0, 8);
|
||||
mcnkHeader.writeUInt32LE(2, 12);
|
||||
mcnkHeader.writeUInt32LE(136, 28);
|
||||
mcnkHeader.writeUInt32LE(176, 36);
|
||||
mcnkHeader.writeUInt32LE(8 + payloadLength, 40);
|
||||
mcnkHeader.writeUInt16LE(0xa55a, 60);
|
||||
mcnkHeader.writeUInt16LE(0x1234, 62);
|
||||
mcnkHeader.writeUInt16LE(0xabcd, 64);
|
||||
const mcnk = Buffer.concat([mcnkHeader, chunk("MCLY", layers), alphaSubchunk]);
|
||||
const mver = Buffer.alloc(4);
|
||||
mver.writeUInt32LE(18);
|
||||
return Buffer.concat([chunk("MVER", mver), chunk("MTEX", mtex), chunk("MCNK", mcnk)]);
|
||||
}
|
||||
|
||||
test("MVER 18 zero-sized MCAL headers use the enclosing MCNK size span", () => {
|
||||
const source = zeroSizedMcalAdt();
|
||||
const parsed = parseWowAdtTerrain(source, { label: "zero-sized MCAL fixture" });
|
||||
const target = parsed.chunks[0];
|
||||
const overlay = target.layers[1];
|
||||
|
||||
assert.equal(target.alphaChunkHeaderSize, 0);
|
||||
assert.equal(target.alphaChunkSizeSource, "mcnk-header");
|
||||
assert.equal(target.holeMask, 0xa55a);
|
||||
assert.equal(target.highResolutionHoles, false);
|
||||
assert.equal(target.lowResolutionTextureMap[0], 0xabcd);
|
||||
assert.equal(target.alphaSizeMatchesHeader, true);
|
||||
assert.equal(target.alphaChunkWithinMcnk, true);
|
||||
assert.equal(overlay.alphaMapEncoding, "rle-8");
|
||||
assert.equal(overlay.alphaMapAvailable, true);
|
||||
assert.equal(overlay.alphaMapBytes, target.alphaBytes);
|
||||
const decoded = decodeWowAdtAlphaMap(source, target, overlay);
|
||||
assert.equal(decoded.length, 4096);
|
||||
assert.equal(decoded[0], 91);
|
||||
assert.equal(decoded[4095], 91);
|
||||
});
|
||||
|
||||
test("MVER 18 MCAL spans still fail closed when the declared payload leaves MCNK", () => {
|
||||
const source = zeroSizedMcalAdt({ declaredPayloadBytes: 0xffff });
|
||||
const parsed = parseWowAdtTerrain(source, { label: "overrun MCAL fixture" });
|
||||
const target = parsed.chunks[0];
|
||||
assert.equal(target.alphaChunkWithinMcnk, false);
|
||||
assert.equal(target.layers[1].alphaMapAvailable, false);
|
||||
assert.throws(() => decodeWowAdtAlphaMap(source, target, target.layers[1]), /unsupported or invalid MCAL span/);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
const WEIGHT_SIZE = 64 * 64;
|
||||
|
||||
function assertKnownAlphaLayer(layer, chunk) {
|
||||
if (!layer.usesAlphaMap) return;
|
||||
if (!layer.alphaMapAvailable || layer.alphaMapDataOffset == null || !layer.alphaMapBytes) {
|
||||
throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} layer ${layer.index} has an unsupported or invalid MCAL span.`);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeRle8(data, label) {
|
||||
const output = new Uint8Array(WEIGHT_SIZE);
|
||||
let inputOffset = 0;
|
||||
let outputOffset = 0;
|
||||
while (outputOffset < output.length) {
|
||||
if (inputOffset >= data.length) throw new Error(`${label}: truncated MCAL RLE stream.`);
|
||||
const control = data[inputOffset++];
|
||||
const count = control & 0x7f;
|
||||
if (!count) throw new Error(`${label}: zero-count MCAL RLE command cannot progress.`);
|
||||
if (outputOffset + count > output.length) throw new Error(`${label}: MCAL RLE expands beyond 4096 samples.`);
|
||||
if (control & 0x80) {
|
||||
if (inputOffset >= data.length) throw new Error(`${label}: truncated MCAL fill command.`);
|
||||
output.fill(data[inputOffset++], outputOffset, outputOffset + count);
|
||||
} else {
|
||||
if (inputOffset + count > data.length) throw new Error(`${label}: truncated MCAL copy command.`);
|
||||
output.set(data.subarray(inputOffset, inputOffset + count), outputOffset);
|
||||
inputOffset += count;
|
||||
}
|
||||
outputOffset += count;
|
||||
}
|
||||
if (inputOffset !== data.length) throw new Error(`${label}: ${data.length - inputOffset} trailing MCAL RLE bytes are unaccounted for.`);
|
||||
return output;
|
||||
}
|
||||
|
||||
function decodePacked4(data, noEdgeRepair, label) {
|
||||
if (data.length !== 2048) throw new Error(`${label}: packed 4-bit MCAL must be exactly 2048 bytes.`);
|
||||
const output = new Uint8Array(WEIGHT_SIZE);
|
||||
let source = 0;
|
||||
for (let row = 0; row < 64; row += 1) {
|
||||
for (let column = 0; column < 64; column += 2) {
|
||||
const value = data[source++];
|
||||
output[row * 64 + column] = (value & 0x0f) * 17;
|
||||
output[row * 64 + column + 1] = (value >>> 4) * 17;
|
||||
}
|
||||
}
|
||||
if (!noEdgeRepair) {
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
output[index * 64 + 63] = output[index * 64 + 62];
|
||||
output[63 * 64 + index] = output[62 * 64 + index];
|
||||
}
|
||||
output[63 * 64 + 63] = output[62 * 64 + 62];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function decodeWowAdtAlphaMap(adtBuffer, chunk, layer) {
|
||||
assertKnownAlphaLayer(layer, chunk);
|
||||
if (!layer.usesAlphaMap) return new Uint8Array(WEIGHT_SIZE);
|
||||
const payload = adtBuffer.subarray(layer.alphaMapDataOffset, layer.alphaMapDataOffset + layer.alphaMapBytes);
|
||||
const label = `MCNK ${chunk.indexX},${chunk.indexY} layer ${layer.index}`;
|
||||
switch (layer.alphaMapEncoding) {
|
||||
case "packed-4":
|
||||
return decodePacked4(payload, (chunk.flags & 0x8000) !== 0, label);
|
||||
case "uncompressed-8":
|
||||
if (payload.length !== WEIGHT_SIZE) throw new Error(`${label}: 8-bit MCAL must be exactly 4096 bytes.`);
|
||||
return new Uint8Array(payload);
|
||||
case "rle-8":
|
||||
return decodeRle8(payload, label);
|
||||
default:
|
||||
throw new Error(`${label}: unsupported alpha encoding ${layer.alphaMapEncoding ?? "missing"}.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode source overlays into the effective RGB weights expected by the
|
||||
* wow-overlay-residual shader. Classic 4-bit maps are source opacity maps;
|
||||
* their overlay order must be resolved before the shader consumes them.
|
||||
*/
|
||||
export function composeWowAdtTerrainWeights(adtBuffer, chunk) {
|
||||
const overlays = chunk.layers.slice(1);
|
||||
if (overlays.length > 3) throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} has more than three overlay layers.`);
|
||||
const encodings = [...new Set(overlays.filter((layer) => layer.usesAlphaMap).map((layer) => layer.alphaMapEncoding))];
|
||||
if (encodings.length > 1) throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} mixes MCAL alpha encodings.`);
|
||||
const sourceEncoding = encodings[0] ?? "none";
|
||||
const alphaMaps = overlays.map((layer) => decodeWowAdtAlphaMap(adtBuffer, chunk, layer));
|
||||
const packed = sourceEncoding === "packed-4";
|
||||
const effective = alphaMaps.map((map) => new Uint8Array(map));
|
||||
if (packed) {
|
||||
for (let pixel = 0; pixel < WEIGHT_SIZE; pixel += 1) {
|
||||
let remaining = 255;
|
||||
for (let index = effective.length - 1; index >= 0; index -= 1) {
|
||||
const weight = Math.floor((effective[index][pixel] * remaining + 127) / 255);
|
||||
effective[index][pixel] = weight;
|
||||
remaining -= weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
let maxWeightSum = 0;
|
||||
for (let pixel = 0; pixel < WEIGHT_SIZE; pixel += 1) {
|
||||
const sum = effective.reduce((value, map) => value + map[pixel], 0);
|
||||
maxWeightSum = Math.max(maxWeightSum, sum);
|
||||
if (sum > 255) throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} effective overlays exceed 255 (${sum} at sample ${pixel}).`);
|
||||
}
|
||||
const rgba = Buffer.alloc(WEIGHT_SIZE * 4, 255);
|
||||
for (let pixel = 0; pixel < WEIGHT_SIZE; pixel += 1) {
|
||||
for (let channel = 0; channel < 3; channel += 1) rgba[pixel * 4 + channel] = effective[channel]?.[pixel] ?? 0;
|
||||
}
|
||||
return {
|
||||
rgba,
|
||||
sourceEncoding,
|
||||
sourcePolicy: packed ? "classic-nibble-to-big-alpha" : "direct-8bit-overlay-weights",
|
||||
channelLayerIndices: overlays.map((layer) => layer.index),
|
||||
maxWeightSum,
|
||||
};
|
||||
}
|
||||
|
||||
export function wowAdtNormalizedUv(row, column) {
|
||||
if (!Number.isInteger(row) || row < 0 || row > 16) throw new RangeError("MCVT row must be in [0,16].");
|
||||
const columns = row % 2 ? 8 : 9;
|
||||
if (!Number.isInteger(column) || column < 0 || column >= columns) throw new RangeError(`MCVT column must be in [0,${columns - 1}].`);
|
||||
return [(column + (row % 2 ? 0.5 : 0)) / 8, row / 16];
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { composeWowAdtTerrainWeights, decodeWowAdtAlphaMap, wowAdtNormalizedUv } from "./wow-adt-terrain-material.mjs";
|
||||
|
||||
function layer(index, alphaMapEncoding, alphaMapDataOffset, alphaMapBytes, flags = 0x100) {
|
||||
return {
|
||||
index,
|
||||
usesAlphaMap: (flags & 0x100) !== 0,
|
||||
alphaMapCompressed: (flags & 0x200) !== 0,
|
||||
alphaMapAvailable: true,
|
||||
alphaMapEncoding,
|
||||
alphaMapDataOffset,
|
||||
alphaMapBytes,
|
||||
alphaOffset: alphaMapDataOffset,
|
||||
flags,
|
||||
};
|
||||
}
|
||||
|
||||
test("classic 4-bit maps expand and get deterministic edge repair", () => {
|
||||
const bytes = Buffer.alloc(2048, 0x21);
|
||||
const chunk = { indexX: 4, indexY: 7, flags: 0, layers: [] };
|
||||
const decoded = decodeWowAdtAlphaMap(bytes, chunk, layer(1, "packed-4", 0, 2048));
|
||||
assert.equal(decoded[0], 17);
|
||||
assert.equal(decoded[1], 34);
|
||||
assert.equal(decoded[63], decoded[62]);
|
||||
assert.equal(decoded[63 * 64], decoded[62 * 64]);
|
||||
|
||||
chunk.flags = 0x8000;
|
||||
const untouched = decodeWowAdtAlphaMap(bytes, chunk, layer(1, "packed-4", 0, 2048));
|
||||
assert.equal(untouched[63], 34);
|
||||
assert.equal(untouched[63 * 64], 17);
|
||||
});
|
||||
|
||||
test("classic 4-bit opacity layers become ordered residual weights", () => {
|
||||
const bytes = Buffer.alloc(4096, 0x88);
|
||||
const chunk = {
|
||||
indexX: 0,
|
||||
indexY: 0,
|
||||
flags: 0x8000,
|
||||
layers: [
|
||||
{ index: 0, usesAlphaMap: false },
|
||||
layer(1, "packed-4", 0, 2048),
|
||||
layer(2, "packed-4", 2048, 2048),
|
||||
],
|
||||
};
|
||||
const weights = composeWowAdtTerrainWeights(bytes, chunk);
|
||||
assert.equal(weights.sourcePolicy, "classic-nibble-to-big-alpha");
|
||||
assert.deepEqual(weights.channelLayerIndices, [1, 2]);
|
||||
assert.equal(weights.rgba[0], 63); // earlier overlay receives only remaining coverage
|
||||
assert.equal(weights.rgba[1], 136); // last layer remains topmost
|
||||
assert.equal(weights.rgba[2], 0);
|
||||
assert.equal(weights.rgba[3], 255);
|
||||
assert.equal(weights.maxWeightSum, 199);
|
||||
});
|
||||
|
||||
test("compressed 8-bit MCAL fills exactly one 64x64 map", () => {
|
||||
const packed = [];
|
||||
let remaining = 4096;
|
||||
while (remaining) {
|
||||
const count = Math.min(127, remaining);
|
||||
packed.push(0x80 | count, 37);
|
||||
remaining -= count;
|
||||
}
|
||||
const bytes = Buffer.from(packed);
|
||||
const chunk = { indexX: 1, indexY: 2, flags: 0x8000, layers: [] };
|
||||
const decoded = decodeWowAdtAlphaMap(bytes, chunk, layer(1, "rle-8", 0, bytes.length, 0x300));
|
||||
assert.equal(decoded.length, 4096);
|
||||
assert.equal(decoded[0], 37);
|
||||
assert.equal(decoded[4095], 37);
|
||||
assert.throws(() => decodeWowAdtAlphaMap(Buffer.from([0x81]), chunk, layer(1, "rle-8", 0, 1, 0x300)), /truncated MCAL fill/);
|
||||
});
|
||||
|
||||
test("normalized MCVT UVs follow source-positive local rows and stagger", () => {
|
||||
assert.deepEqual(wowAdtNormalizedUv(0, 0), [0, 0]);
|
||||
assert.deepEqual(wowAdtNormalizedUv(1, 0), [0.0625, 0.0625]);
|
||||
assert.deepEqual(wowAdtNormalizedUv(16, 8), [1, 1]);
|
||||
});
|
||||
@@ -32,19 +32,25 @@ function compressKtx2(file) {
|
||||
});
|
||||
}
|
||||
|
||||
export function dungeonEnvironmentOptimizationArguments(sourceFile, outputFile) {
|
||||
export function dungeonEnvironmentOptimizationArguments(sourceFile, outputFile, { preserveSpatialBoundaries = false } = {}) {
|
||||
return [
|
||||
"optimize",
|
||||
sourceFile,
|
||||
outputFile,
|
||||
"--compress",
|
||||
"meshopt",
|
||||
"false",
|
||||
"--meshopt-level",
|
||||
"high",
|
||||
"--instance",
|
||||
"true",
|
||||
preserveSpatialBoundaries ? "false" : "true",
|
||||
"--instance-min",
|
||||
"3",
|
||||
"--flatten",
|
||||
preserveSpatialBoundaries ? "false" : "true",
|
||||
"--join",
|
||||
preserveSpatialBoundaries ? "false" : "true",
|
||||
"--weld",
|
||||
preserveSpatialBoundaries ? "false" : "true",
|
||||
"--simplify",
|
||||
"false",
|
||||
"--texture-compress",
|
||||
@@ -53,7 +59,17 @@ export function dungeonEnvironmentOptimizationArguments(sourceFile, outputFile)
|
||||
}
|
||||
|
||||
export function dungeonEnvironmentFinalPackingArguments(sourceFile, outputFile) {
|
||||
return dungeonEnvironmentOptimizationArguments(sourceFile, outputFile);
|
||||
return [
|
||||
"meshopt",
|
||||
sourceFile,
|
||||
outputFile,
|
||||
"--level",
|
||||
"high",
|
||||
"--quantize-position",
|
||||
"16",
|
||||
"--quantize-texcoord",
|
||||
"16",
|
||||
];
|
||||
}
|
||||
|
||||
export async function optimizeDungeonEnvironment(slug) {
|
||||
@@ -71,6 +87,7 @@ export async function optimizeDungeonEnvironment(slug) {
|
||||
if (slug !== "shadowfang-keep" && selectiveProfile) {
|
||||
throw new Error(`The Shadowfang-only selective fallback profile cannot be applied to ${slug}.`);
|
||||
}
|
||||
const preserveSpatialBoundaries = recipe.visualOptimization?.preserveSpatialBoundaries === true;
|
||||
const optimized = path.join(directory, `${slug}-visual.optimized.glb`);
|
||||
const packed = path.join(directory, `${slug}-visual.meshopt-final.glb`);
|
||||
let temporaryDirectory = null;
|
||||
@@ -83,13 +100,13 @@ export async function optimizeDungeonEnvironment(slug) {
|
||||
sourcePreprocessing = await preprocessShadowfangVisual(source, optimizationSource);
|
||||
}
|
||||
|
||||
await runCli(dungeonEnvironmentOptimizationArguments(optimizationSource, optimized));
|
||||
await runCli(dungeonEnvironmentOptimizationArguments(optimizationSource, optimized, { preserveSpatialBoundaries }));
|
||||
await compressKtx2(optimized);
|
||||
await rm(packed, { force: true });
|
||||
await runCli(dungeonEnvironmentFinalPackingArguments(optimized, packed));
|
||||
await copyFile(packed, optimized);
|
||||
await rm(packed, { force: true });
|
||||
const finalPackingCommand = "gltf-transform optimize <optimized.glb> <meshopt-final.glb> --compress meshopt --meshopt-level high --instance true --instance-min 3 --simplify false --texture-compress false";
|
||||
const finalPackingCommand = "gltf-transform meshopt <optimized.glb> <meshopt-final.glb> --level high --quantize-position 16 --quantize-texcoord 16";
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
dungeonId: slug,
|
||||
@@ -105,6 +122,8 @@ export async function optimizeDungeonEnvironment(slug) {
|
||||
} : null,
|
||||
optimized: { file: path.basename(optimized), size: (await stat(optimized)).size },
|
||||
compression: "meshopt+ktx2",
|
||||
meshoptQuantization: { positionBits: 16, texcoordBits: 16 },
|
||||
preserveSpatialBoundaries,
|
||||
meshoptPackingRestoredAfterTextureCompression: true,
|
||||
finalPackedStage: {
|
||||
status: "complete",
|
||||
@@ -115,7 +134,7 @@ export async function optimizeDungeonEnvironment(slug) {
|
||||
},
|
||||
commands: [
|
||||
...(sourcePreprocessing ? ["source-pinned Shadowfang fallback preprocessing (see sourcePreprocessing profile)"] : []),
|
||||
"gltf-transform optimize --compress meshopt --meshopt-level high --instance true --instance-min 3 --simplify false --texture-compress false",
|
||||
`gltf-transform optimize --compress false --instance ${preserveSpatialBoundaries ? "false" : "true"} --flatten ${preserveSpatialBoundaries ? "false" : "true"} --join ${preserveSpatialBoundaries ? "false" : "true"} --weld ${preserveSpatialBoundaries ? "false" : "true"} --simplify false --texture-compress false`,
|
||||
"node scripts/asset-pipeline/compress-ktx2.mjs <optimized.glb> --concurrency 1 --jobs 2",
|
||||
finalPackingCommand,
|
||||
],
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
dungeonEnvironmentFinalPackingArguments,
|
||||
dungeonEnvironmentOptimizationArguments,
|
||||
} from "./optimize-environment.mjs";
|
||||
import { loadRecipe } from "./lib.mjs";
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(scriptDirectory, "../..");
|
||||
@@ -94,6 +95,37 @@ function geometryUvStats(document) {
|
||||
return { triangles, degenerateUvTriangles };
|
||||
}
|
||||
|
||||
function precisionFixture(document, buffer) {
|
||||
const positions = new Float32Array([
|
||||
0, 0, 0, 1, 0, 0, 0, 1, 0,
|
||||
2, 0, 0, 3, 0, 0, 2, 1, 0,
|
||||
]);
|
||||
const texcoords = new Float32Array([
|
||||
0, 0, 1, 0, 0, 1,
|
||||
0.3141, 0.1, 0.31415, 0.1, 0.3141, 0.9,
|
||||
]);
|
||||
const primitive = document.createPrimitive()
|
||||
.setAttribute("POSITION", document.createAccessor("precision-positions").setType("VEC3").setArray(positions).setBuffer(buffer))
|
||||
.setAttribute("TEXCOORD_0", document.createAccessor("precision-texcoords").setType("VEC2").setArray(texcoords).setBuffer(buffer))
|
||||
.setIndices(document.createAccessor("precision-indices").setType("SCALAR").setArray(new Uint16Array([0, 1, 2, 3, 4, 5])).setBuffer(buffer));
|
||||
const scene = document.createScene("precision-scene").addChild(
|
||||
document.createNode("precision-mesh").setMesh(document.createMesh("precision-mesh").addPrimitive(primitive)),
|
||||
);
|
||||
document.getRoot().setDefaultScene(scene);
|
||||
}
|
||||
|
||||
test("Utgarde Pinnacle's recipe opts into spatial boundary preservation", async () => {
|
||||
const recipe = await loadRecipe("utgarde-pinnacle");
|
||||
assert.equal(recipe.visualOptimization?.preserveSpatialBoundaries, true);
|
||||
const args = dungeonEnvironmentOptimizationArguments("source.glb", "optimized.glb", {
|
||||
preserveSpatialBoundaries: recipe.visualOptimization.preserveSpatialBoundaries,
|
||||
});
|
||||
assert.equal(args[args.indexOf("--instance") + 1], "false");
|
||||
assert.equal(args[args.indexOf("--flatten") + 1], "false");
|
||||
assert.equal(args[args.indexOf("--join") + 1], "false");
|
||||
assert.equal(args[args.indexOf("--weld") + 1], "false");
|
||||
});
|
||||
|
||||
test("WoW visual optimization preserves source texture dimensions and UV topology", async (context) => {
|
||||
const width = 4096;
|
||||
const height = 8;
|
||||
@@ -132,10 +164,21 @@ test("WoW visual optimization preserves source texture dimensions and UV topolog
|
||||
|
||||
const args = dungeonEnvironmentOptimizationArguments(sourceFile, outputFile);
|
||||
assert.deepEqual(args.slice(3), [
|
||||
"--compress", "meshopt", "--meshopt-level", "high",
|
||||
"--compress", "false", "--meshopt-level", "high",
|
||||
"--instance", "true", "--instance-min", "3",
|
||||
"--flatten", "true", "--join", "true", "--weld", "true",
|
||||
"--simplify", "false", "--texture-compress", "false",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
dungeonEnvironmentOptimizationArguments(sourceFile, outputFile, { preserveSpatialBoundaries: true }).slice(3),
|
||||
[
|
||||
"--compress", "false", "--meshopt-level", "high",
|
||||
"--instance", "false", "--instance-min", "3",
|
||||
"--flatten", "false", "--join", "false", "--weld", "false",
|
||||
"--simplify", "false", "--texture-compress", "false",
|
||||
],
|
||||
"a recipe can opt out of global instancing and geometry regrouping while retaining the common non-lossy preparation steps",
|
||||
);
|
||||
|
||||
const run = spawnSync(process.execPath, [gltfTransformCli, ...args], {
|
||||
cwd: projectRoot,
|
||||
@@ -163,10 +206,9 @@ test("WoW visual optimization preserves source texture dimensions and UV topolog
|
||||
const packedFile = path.join(workingDirectory, "meshopt-final.glb");
|
||||
const finalPackingArgs = dungeonEnvironmentFinalPackingArguments(outputFile, packedFile);
|
||||
assert.deepEqual(finalPackingArgs.slice(3), [
|
||||
"--compress", "meshopt", "--meshopt-level", "high",
|
||||
"--instance", "true", "--instance-min", "3",
|
||||
"--simplify", "false", "--texture-compress", "false",
|
||||
"--level", "high", "--quantize-position", "16", "--quantize-texcoord", "16",
|
||||
]);
|
||||
assert.equal(finalPackingArgs[0], "meshopt");
|
||||
const finalPacking = spawnSync(process.execPath, [gltfTransformCli, ...finalPackingArgs], {
|
||||
cwd: projectRoot,
|
||||
encoding: "utf8",
|
||||
@@ -200,4 +242,30 @@ test("WoW visual optimization preserves source texture dimensions and UV topolog
|
||||
simplifiedStats.triangles < optimizedStats.triangles || simplifiedStats.degenerateUvTriangles > 0,
|
||||
"the atlas fixture must detect topology loss or UV collapse when simplification is enabled",
|
||||
);
|
||||
|
||||
const precisionSource = path.join(workingDirectory, "precision-source.glb");
|
||||
const precision12File = path.join(workingDirectory, "precision-12bit.glb");
|
||||
const precision16File = path.join(workingDirectory, "precision-16bit.glb");
|
||||
const precisionDocument = new Document();
|
||||
precisionFixture(precisionDocument, precisionDocument.createBuffer());
|
||||
await io.write(precisionSource, precisionDocument);
|
||||
const precisionSourceStats = geometryUvStats(await io.read(precisionSource));
|
||||
assert.deepEqual(precisionSourceStats, { triangles: 2, degenerateUvTriangles: 0 });
|
||||
|
||||
for (const [bits, targetFile] of [[12, precision12File], [16, precision16File]]) {
|
||||
const precisionRun = spawnSync(process.execPath, [gltfTransformCli,
|
||||
"meshopt", precisionSource, targetFile,
|
||||
"--level", "high", "--quantize-position", "16", "--quantize-texcoord", String(bits)], {
|
||||
cwd: projectRoot,
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
});
|
||||
assert.equal(precisionRun.status, 0, `${precisionRun.stdout}\n${precisionRun.stderr}`);
|
||||
}
|
||||
const lowPrecisionStats = geometryUvStats(await io.read(precision12File));
|
||||
const highPrecisionStats = geometryUvStats(await io.read(precision16File));
|
||||
assert.equal(lowPrecisionStats.degenerateUvTriangles, 1,
|
||||
"12-bit atlas quantization must collapse the narrowly separated fixture UVs");
|
||||
assert.equal(highPrecisionStats.degenerateUvTriangles, 0,
|
||||
"16-bit atlas quantization must preserve the same non-zero UV triangle");
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
// Diagnostic lossless append: preserve existing images/geometry byte-for-byte.
|
||||
// Do not run solid-texture pruning on RGB weight carriers stored through AO.
|
||||
const [input, bundleDirectory, output] = process.argv.slice(2);
|
||||
if (!input || !bundleDirectory || !output) throw new Error("Provide shipping GLB, source bundle directory, and diagnostic output GLB.");
|
||||
const hash = bytes => createHash("sha256").update(bytes).digest("hex");
|
||||
const original = await readFile(input);
|
||||
if (original.toString("ascii", 0, 4) !== "glTF" || original.readUInt32LE(4) !== 2
|
||||
|| original.readUInt32LE(8) !== original.length) throw new Error("Invalid GLB.");
|
||||
const jsonLength = original.readUInt32LE(12);
|
||||
const json = JSON.parse(original.subarray(20, 20 + jsonLength).toString().trim());
|
||||
const binaryHeader = 20 + jsonLength;
|
||||
if (original.readUInt32LE(binaryHeader + 4) !== 0x004e4942 || json.buffers.length !== 1) throw new Error("Expected one embedded BIN buffer.");
|
||||
const originalBinary = original.subarray(binaryHeader + 8, binaryHeader + 8 + original.readUInt32LE(binaryHeader));
|
||||
const manifest = JSON.parse(await readFile(path.join(bundleDirectory, "terrain-materials.json"), "utf8"));
|
||||
if (manifest.sourceAdt.sha256 !== "8ad75da6b9485d61537ee3b451c08b79e25b1b1b2c7d4e91c8e57ad0a65ff75b"
|
||||
|| manifest.sourceAdt.clientBuild !== "3.3.5a-Ascension"
|
||||
|| hash(await readFile(path.join(bundleDirectory, "source/tanarisinstance_30_29.adt"))) !== manifest.sourceAdt.sha256
|
||||
|| hash(original) !== "a45ad3ad072fce73d657fb668cab691fc87059a8a0701630465249ce8670d8f0") {
|
||||
throw new Error("Pilot inputs must match the verified Ascension source tile and reviewed shipping asset.");
|
||||
}
|
||||
const targetNodes = json.nodes.filter(node => node.name === manifest.sourceGeometry.node);
|
||||
if (targetNodes.length !== 1 || targetNodes[0].children?.length) throw new Error("Ambiguous or nested target terrain node.");
|
||||
const target = targetNodes[0];
|
||||
const oldMesh = target.mesh;
|
||||
const oldPrimitives = json.meshes[oldMesh]?.primitives;
|
||||
if (oldPrimitives?.length !== 1 || !/^ADT_Terrain(?:\.\d+)?$/.test(json.materials[oldPrimitives[0].material]?.name ?? "")
|
||||
|| json.accessors[oldPrimitives[0].indices].count !== manifest.sourceGeometry.indexCount
|
||||
|| json.nodes.filter(node => node.mesh === oldMesh).length !== 1) throw new Error("Target is not the verified single terrain primitive.");
|
||||
if (json.nodes.some(node => node.children?.includes(json.nodes.indexOf(target)))) throw new Error("Expected terrain at scene root.");
|
||||
// This pilot supports the verified no-hole tile. Other tiles must be recovered independently.
|
||||
if (manifest.chunks.length !== 256 || manifest.chunks.some(chunk => chunk.sourceHoleMaskHex !== "0000")) throw new Error("Unverified tile topology.");
|
||||
const parts = [originalBinary];
|
||||
let byteLength = originalBinary.length;
|
||||
function append(bytes, targetKind) {
|
||||
const padding = (4 - byteLength % 4) % 4;
|
||||
if (padding) { parts.push(Buffer.alloc(padding)); byteLength += padding; }
|
||||
const view = json.bufferViews.length;
|
||||
json.bufferViews.push({ buffer: 0, byteOffset: byteLength, byteLength: bytes.length, ...(targetKind ? { target: targetKind } : {}) });
|
||||
parts.push(bytes); byteLength += bytes.length;
|
||||
return view;
|
||||
}
|
||||
async function verified(file, expected) {
|
||||
const absolute = path.resolve(bundleDirectory, file);
|
||||
if (!absolute.startsWith(`${path.resolve(bundleDirectory)}${path.sep}`)) throw new Error("Source path escapes bundle.");
|
||||
const bytes = await readFile(absolute);
|
||||
if (hash(bytes) !== expected) throw new Error(`Source hash mismatch: ${file}`);
|
||||
return bytes;
|
||||
}
|
||||
json.textures ??= []; json.samplers ??= []; json.images ??= [];
|
||||
const repeat = json.samplers.push({ wrapS: 10497, wrapT: 10497, minFilter: 9987, magFilter: 9729 }) - 1;
|
||||
const clamp = json.samplers.push({ wrapS: 33071, wrapT: 33071, minFilter: 9987, magFilter: 9729 }) - 1;
|
||||
json.extensions ??= {};
|
||||
json.extensions.KHR_materials_variants ??= { variants: [] };
|
||||
json.extensionsUsed = [...new Set([...(json.extensionsUsed ?? []), "KHR_materials_variants"])];
|
||||
const variants = json.extensions.KHR_materials_variants.variants;
|
||||
const carriers = new Map();
|
||||
async function carrier(name, file, sha256, weight) {
|
||||
if (carriers.has(name)) throw new Error("Duplicate carrier.");
|
||||
const bytes = await verified(file, sha256);
|
||||
const image = json.images.push({ name, mimeType: "image/png", bufferView: append(bytes) }) - 1;
|
||||
const texture = json.textures.push({ source: image, sampler: weight ? clamp : repeat }) - 1;
|
||||
const material = json.materials.push({ name, pbrMetallicRoughness: { metallicFactor: 0, roughnessFactor: 1,
|
||||
...(!weight ? { baseColorTexture: { index: texture } } : {}) }, ...(weight ? { occlusionTexture: { index: texture } } : {}) }) - 1;
|
||||
const variant = variants.push({ name }) - 1;
|
||||
carriers.set(name, { material, variants: [variant] });
|
||||
}
|
||||
for (const texture of manifest.textures) await carrier(texture.variant, texture.decodedPng, texture.decodedPngSha256, false);
|
||||
for (const weight of manifest.weightCarriers) await carrier(weight.variant, weight.image, weight.sha256, true);
|
||||
json.meshes.splice(oldMesh, 1);
|
||||
for (const node of json.nodes) if (node.mesh > oldMesh) node.mesh -= 1;
|
||||
delete target.mesh; delete target.translation; delete target.rotation; delete target.scale;
|
||||
target.matrix = manifest.sourceGeometry.nodeWorldMatrix;
|
||||
target.children = [];
|
||||
const definitions = new Map(manifest.materials.map(material => [material.name, material]));
|
||||
for (const chunk of manifest.chunks) {
|
||||
const definition = definitions.get(chunk.material);
|
||||
const material = json.materials.push({ name: definition.name, doubleSided: true, pbrMetallicRoughness: { metallicFactor: 0, roughnessFactor: 1 },
|
||||
extras: { wowTerrain: definition.wowTerrain, sourceChunk: { indexX: chunk.indexX, indexY: chunk.indexY } } }) - 1;
|
||||
async function accessor(record, type, componentType, targetKind, bounds = false) {
|
||||
const bytes = await verified(record.file, record.sha256);
|
||||
if (bytes.length !== record.bytes) throw new Error("Attribute size mismatch.");
|
||||
const arity = type === "SCALAR" ? 1 : type === "VEC2" ? 2 : 3;
|
||||
const value = { bufferView: append(bytes, targetKind), componentType, type, count: record.count / arity };
|
||||
if (bounds) {
|
||||
value.min = Array(arity).fill(Infinity); value.max = Array(arity).fill(-Infinity);
|
||||
for (let i = 0; i < record.count; i++) {
|
||||
const v = bytes.readFloatLE(i * 4); const k = i % arity;
|
||||
value.min[k] = Math.min(value.min[k], v); value.max[k] = Math.max(value.max[k], v);
|
||||
}
|
||||
}
|
||||
return json.accessors.push(value) - 1;
|
||||
}
|
||||
const geometry = chunk.geometry;
|
||||
const attributes = {
|
||||
POSITION: await accessor(geometry.position, "VEC3", 5126, 34962, true),
|
||||
NORMAL: await accessor(geometry.normal, "VEC3", 5126, 34962),
|
||||
TEXCOORD_0: await accessor(geometry.uv0, "VEC2", 5126, 34962),
|
||||
};
|
||||
const mappings = [...definition.wowTerrain.layerVariants, ...definition.wowTerrain.weightVariants].map(name => {
|
||||
const mapping = carriers.get(name); if (!mapping) throw new Error(`Missing ${name}`); return mapping;
|
||||
});
|
||||
const mesh = json.meshes.push({ name: definition.name, primitives: [{ attributes, material,
|
||||
indices: await accessor(geometry.indices, "SCALAR", 5123, 34963),
|
||||
extensions: { KHR_materials_variants: { mappings } } }] }) - 1;
|
||||
target.children.push(json.nodes.push({ name: definition.name, mesh }) - 1);
|
||||
}
|
||||
json.buffers[0].byteLength = byteLength;
|
||||
const binary = Buffer.concat(parts);
|
||||
const binaryPadding = (4 - binary.length % 4) % 4;
|
||||
const jsonBytes = Buffer.from(JSON.stringify(json));
|
||||
const jsonPadding = (4 - jsonBytes.length % 4) % 4;
|
||||
const result = Buffer.alloc(28 + jsonBytes.length + jsonPadding + binary.length + binaryPadding);
|
||||
result.write("glTF"); result.writeUInt32LE(2, 4); result.writeUInt32LE(result.length, 8);
|
||||
result.writeUInt32LE(jsonBytes.length + jsonPadding, 12); result.writeUInt32LE(0x4e4f534a, 16);
|
||||
jsonBytes.copy(result, 20); result.fill(0x20, 20 + jsonBytes.length, 20 + jsonBytes.length + jsonPadding);
|
||||
const offset = 20 + jsonBytes.length + jsonPadding;
|
||||
result.writeUInt32LE(binary.length + binaryPadding, offset); result.writeUInt32LE(0x004e4942, offset + 4);
|
||||
binary.copy(result, offset + 8);
|
||||
await writeFile(output, result);
|
||||
const report = { status: "diagnostic-only", input: { path: input, sha256: hash(original), bytes: original.length },
|
||||
output: { path: output, sha256: hash(result), bytes: result.length }, sourceAdt: manifest.sourceAdt,
|
||||
sourceBundle: bundleDirectory, chunkCount: manifest.chunks.length, recoveredTriangleCount: manifest.sourceGeometry.indexCount / 3,
|
||||
originalBinaryPrefixUnchanged: binary.subarray(0, originalBinary.length).equals(originalBinary),
|
||||
originalImagesUnchanged: true, sourceTextures: manifest.textures.length, weightImages: manifest.weightCarriers.length,
|
||||
compression: "original payloads retained; new source textures are lossless PNG and disjoint geometry is float32/uint16",
|
||||
limits: "One source-backed tile. No original-client color/shadow reconstruction or fleet publication." };
|
||||
await writeFile(`${output}.report.json`, `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
@@ -91,6 +91,38 @@ test("real tiled Recast export covers Detour polygon 0 and keeps disconnected is
|
||||
assert.equal(route.start.found, true);
|
||||
assert.equal(route.end.found, true);
|
||||
assert.equal(route.reachable, false, "independent source surfaces must not gain an inferred connection");
|
||||
|
||||
const subsetFile = path.join(directory, "source-anchored-navigation.glb");
|
||||
const subset = await bakeNavigationFromCollisionAssets({
|
||||
slug: "recast-anchor-fixture",
|
||||
collisionAssets: [{ role: "collision", path: collisionFile }],
|
||||
settings: result.settings,
|
||||
includeDetourTopology: true,
|
||||
detourAnchorPositions: [{ id: "second-island", position: [100, 2.2, 0], sourcePosition: [1000, 22, 0], evidence: "fixture source table", maximumDistance: 0.5, required: true }],
|
||||
output: subsetFile,
|
||||
reportFile: path.join(directory, "source-anchored-report.json"),
|
||||
});
|
||||
assert.equal(subset.detourTopology.weakComponents.count, 1);
|
||||
assert.ok(subset.geometry.bounds[0][0] > 90, "unanchored first island must be excluded");
|
||||
assert.equal(subset.detourTopology.detailTriangleCount, subset.geometry.triangles);
|
||||
const subsetDocument = await new NodeIO().read(subsetFile);
|
||||
const selected = subsetDocument.getRoot().getExtras().runewakerNavigationTopology;
|
||||
assert.equal(selected.polygons[0][0], 0, "retained nonzero source IDs must compact to zero");
|
||||
assert.equal(selected.polygons[0][6], 0, "retained detail ranges must start at the selected geometry");
|
||||
assert.equal(selected.sourceAnchorSelection.anchors[0].retained, true);
|
||||
assert.deepEqual(selected.sourceAnchorSelection.anchors[0].sourcePosition, [1000, 22, 0]);
|
||||
assert.equal(selected.sourceAnchorSelection.anchors[0].evidence, "fixture source table");
|
||||
assert.equal(selected.sourceAnchorSelection.anchors[0].required, true);
|
||||
|
||||
await assert.rejects(() => bakeNavigationFromCollisionAssets({
|
||||
slug: "recast-required-anchor-fixture",
|
||||
collisionAssets: [{ role: "collision", path: collisionFile }],
|
||||
settings: result.settings,
|
||||
includeDetourTopology: true,
|
||||
detourAnchorPositions: [{ id: "missing-entry", position: [500, 20, 0], maximumDistance: 0.5, required: true }],
|
||||
output: path.join(directory, "invalid-navigation.glb"),
|
||||
reportFile: path.join(directory, "invalid-report.json"),
|
||||
}), /required source navigation anchor missing-entry is outside/);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { threeToSoloNavMesh, threeToTiledNavMesh, NavMeshHelper } from "@recast-
|
||||
import { Vector3 } from "three";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
import { unpackMeshesForRecast } from "./recast-mesh-adapter.mjs";
|
||||
import {
|
||||
exists,
|
||||
fixturesRoot,
|
||||
@@ -39,6 +40,11 @@ import {
|
||||
extractDetourTopology,
|
||||
stableSha256,
|
||||
} from "./detour-topology.mjs";
|
||||
import {
|
||||
countSurfaceBucketReferences,
|
||||
DETOUR_COMPONENT_SELECTION_LIMITS,
|
||||
selectDetourAnchorComponents,
|
||||
} from "./detour-component-selection.mjs";
|
||||
|
||||
globalThis.self = globalThis;
|
||||
globalThis.ProgressEvent ??= class ProgressEvent {
|
||||
@@ -263,21 +269,95 @@ function queryDetourRouteProbes(navMesh, probes) {
|
||||
}
|
||||
}
|
||||
|
||||
async function writeGeometry(file, geometry, inputBounds, boundsTolerance, detourTopology = null) {
|
||||
function mapDetourAnchorsToPolygons(navMesh, topology, anchors) {
|
||||
if (!Array.isArray(anchors) || anchors.length === 0) return [];
|
||||
const polygonIdByReference = new Map();
|
||||
for (const [polygonId, tileIndex, , , , polygonIndex] of topology.polygons) {
|
||||
const tile = navMesh.getTile(tileIndex);
|
||||
const reference = navMesh.getPolyRefBase(tile) + polygonIndex;
|
||||
polygonIdByReference.set(String(reference), polygonId);
|
||||
}
|
||||
const query = new NavMeshQuery(navMesh);
|
||||
try {
|
||||
return anchors.map((anchor) => {
|
||||
if (!anchor || typeof anchor.id !== "string" || !anchor.id.trim()
|
||||
|| !Array.isArray(anchor.position) || anchor.position.length !== 3
|
||||
|| !anchor.position.every(Number.isFinite)
|
||||
|| !Number.isFinite(anchor.maximumDistance) || anchor.maximumDistance <= 0) {
|
||||
throw new Error("Detour source anchors require an ID, finite XYZ position, and positive maximumDistance.");
|
||||
}
|
||||
const radius = anchor.maximumDistance;
|
||||
const nearest = query.findNearestPoly(
|
||||
{ x: anchor.position[0], y: anchor.position[1], z: anchor.position[2] },
|
||||
{ halfExtents: { x: radius, y: radius, z: radius } },
|
||||
);
|
||||
const found = nearest.success && String(nearest.nearestRef) !== "0";
|
||||
if (!found) {
|
||||
return {
|
||||
...anchor,
|
||||
id: anchor.id,
|
||||
kind: anchor.kind ?? null,
|
||||
position: [...anchor.position],
|
||||
maximumDistance: radius,
|
||||
polygonId: null,
|
||||
distance: null,
|
||||
nearestPoint: null,
|
||||
reason: "no-detour-surface-within-source-projection-search-box",
|
||||
};
|
||||
}
|
||||
const nearestPoint = [nearest.nearestPoint.x, nearest.nearestPoint.y, nearest.nearestPoint.z];
|
||||
const distance = Math.hypot(
|
||||
anchor.position[0] - nearestPoint[0],
|
||||
anchor.position[1] - nearestPoint[1],
|
||||
anchor.position[2] - nearestPoint[2],
|
||||
);
|
||||
const polygonId = polygonIdByReference.get(String(nearest.nearestRef));
|
||||
if (polygonId === undefined) {
|
||||
return {
|
||||
...anchor,
|
||||
id: anchor.id,
|
||||
kind: anchor.kind ?? null,
|
||||
position: [...anchor.position],
|
||||
maximumDistance: radius,
|
||||
polygonId: null,
|
||||
distance,
|
||||
nearestPoint,
|
||||
reason: "nearest-detour-reference-is-not-a-topology-surface-polygon",
|
||||
};
|
||||
}
|
||||
return {
|
||||
...anchor,
|
||||
id: anchor.id,
|
||||
kind: anchor.kind ?? null,
|
||||
position: [...anchor.position],
|
||||
maximumDistance: radius,
|
||||
polygonId,
|
||||
distance,
|
||||
nearestPoint,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
query.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function writeGeometry(file, geometry, inputBounds, boundsTolerance, detourTopology = null, detourSelection = null) {
|
||||
const sourcePositions = geometry.getAttribute("position")?.array;
|
||||
const sourceIndices = geometry.getIndex()?.array;
|
||||
if (!sourcePositions) throw new Error("Recast helper produced no position buffer.");
|
||||
const sourceElementCount = sourceIndices?.length ?? sourcePositions.length / 3;
|
||||
if (detourTopology && detourTopology.detailTriangleCount !== sourceElementCount / 3) {
|
||||
const sourceTopology = detourSelection?.sourceTopology ?? detourTopology;
|
||||
const outputTopology = detourSelection?.topology ?? detourTopology;
|
||||
if (sourceTopology && sourceTopology.detailTriangleCount !== sourceElementCount / 3) {
|
||||
throw new Error(
|
||||
`Detour topology describes ${detourTopology.detailTriangleCount} detail triangles, `
|
||||
`Detour topology describes ${sourceTopology.detailTriangleCount} detail triangles, `
|
||||
+ `but the helper emitted ${sourceElementCount / 3}.`,
|
||||
);
|
||||
}
|
||||
const [minimumInput, maximumInput] = inputBounds;
|
||||
const compactPositions = [];
|
||||
const compactIndices = [];
|
||||
const exportedTrianglePolygonIds = detourTopology ? [] : null;
|
||||
const exportedTrianglePolygonIds = outputTopology ? [] : null;
|
||||
const remappedVertices = new Map();
|
||||
let rejectedOutOfBoundsTriangles = 0;
|
||||
|
||||
@@ -295,11 +375,11 @@ async function writeGeometry(file, geometry, inputBounds, boundsTolerance, detou
|
||||
return true;
|
||||
};
|
||||
|
||||
const sourceTrianglePolygonIds = detourTopology
|
||||
const sourceTrianglePolygonIds = sourceTopology
|
||||
? new Uint32Array(sourceElementCount / 3)
|
||||
: null;
|
||||
if (detourTopology) {
|
||||
for (const [polygonId, , , , , , firstTriangle, triangleCount] of detourTopology.polygons) {
|
||||
if (sourceTopology) {
|
||||
for (const [polygonId, , , , , , firstTriangle, triangleCount] of sourceTopology.polygons) {
|
||||
if (firstTriangle < 0 || triangleCount < 0 || firstTriangle + triangleCount > sourceTrianglePolygonIds.length) {
|
||||
throw new Error(`Detour topology polygon ${polygonId} has an invalid detail-triangle range.`);
|
||||
}
|
||||
@@ -319,6 +399,9 @@ async function writeGeometry(file, geometry, inputBounds, boundsTolerance, detou
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < sourceElementCount; offset += 3) {
|
||||
const sourceTriangleIndex = offset / 3;
|
||||
const sourcePolygonId = sourceTrianglePolygonIds?.[sourceTriangleIndex];
|
||||
if (detourSelection && !detourSelection.selectedSourcePolygonIds.has(sourcePolygonId)) continue;
|
||||
const triangle = [
|
||||
sourceIndices?.[offset] ?? offset,
|
||||
sourceIndices?.[offset + 1] ?? offset + 1,
|
||||
@@ -328,8 +411,11 @@ async function writeGeometry(file, geometry, inputBounds, boundsTolerance, detou
|
||||
rejectedOutOfBoundsTriangles += 1;
|
||||
continue;
|
||||
}
|
||||
if (detourTopology) {
|
||||
const polygonId = sourceTrianglePolygonIds[offset / 3];
|
||||
if (outputTopology) {
|
||||
const polygonId = detourSelection
|
||||
? detourSelection.sourceToSelectedPolygonId.get(sourcePolygonId)
|
||||
: sourcePolygonId;
|
||||
if (!Number.isInteger(polygonId)) throw new Error(`Selected detail triangle ${sourceTriangleIndex} has no remapped polygon ID.`);
|
||||
exportedTrianglePolygonIds.push(polygonId);
|
||||
}
|
||||
for (const sourceIndex of triangle) {
|
||||
@@ -360,19 +446,32 @@ async function writeGeometry(file, geometry, inputBounds, boundsTolerance, detou
|
||||
if (!compactIndices.length) {
|
||||
throw new Error("Recast helper produced no triangles within the authoritative collision bounds.");
|
||||
}
|
||||
if (detourTopology) {
|
||||
if (outputTopology) {
|
||||
const geometryRanges = buildDetourGeometryRanges(
|
||||
detourTopology.polygonCount,
|
||||
outputTopology.polygonCount,
|
||||
exportedTrianglePolygonIds,
|
||||
);
|
||||
for (const [polygonId, polygonRecord] of detourTopology.polygons.entries()) {
|
||||
for (const [polygonId, polygonRecord] of outputTopology.polygons.entries()) {
|
||||
[polygonRecord[6], polygonRecord[7]] = geometryRanges[polygonId];
|
||||
}
|
||||
detourTopology.geometryTriangleCount = compactIndices.length / 3;
|
||||
outputTopology.geometryTriangleCount = compactIndices.length / 3;
|
||||
}
|
||||
|
||||
const positions = new Float32Array(compactPositions);
|
||||
const indices = new Uint32Array(compactIndices);
|
||||
const surfaceBucketReferenceCount = outputTopology
|
||||
? countSurfaceBucketReferences(positions, indices, 8)
|
||||
: null;
|
||||
if (detourSelection) {
|
||||
if (surfaceBucketReferenceCount > DETOUR_COMPONENT_SELECTION_LIMITS.surfaceBucketReferences) {
|
||||
throw new Error(
|
||||
`Source-anchored Detour subset exceeds the surfaceBucketReferences budget `
|
||||
+ `(${surfaceBucketReferenceCount} > ${DETOUR_COMPONENT_SELECTION_LIMITS.surfaceBucketReferences}).`,
|
||||
);
|
||||
}
|
||||
detourSelection.summary.selected.surfaceBucketReferenceCount = surfaceBucketReferenceCount;
|
||||
outputTopology.sourceAnchorSelection.selected.surfaceBucketReferenceCount = surfaceBucketReferenceCount;
|
||||
}
|
||||
const minimum = [Infinity, Infinity, Infinity];
|
||||
const maximum = [-Infinity, -Infinity, -Infinity];
|
||||
for (let offset = 0; offset < positions.length; offset += 3) {
|
||||
@@ -389,9 +488,9 @@ async function writeGeometry(file, geometry, inputBounds, boundsTolerance, detou
|
||||
document.createMesh("navigation").addPrimitive(primitive);
|
||||
document.createNode("navigation").setMesh(document.getRoot().listMeshes()[0]);
|
||||
document.createScene("navigation").addChild(document.getRoot().listNodes()[0]);
|
||||
if (detourTopology) {
|
||||
if (outputTopology) {
|
||||
document.getRoot().setExtras({
|
||||
runewakerNavigationTopology: detourTopology,
|
||||
runewakerNavigationTopology: outputTopology,
|
||||
});
|
||||
}
|
||||
await new NodeIO().write(file, document);
|
||||
@@ -401,6 +500,7 @@ async function writeGeometry(file, geometry, inputBounds, boundsTolerance, detou
|
||||
sourceVertices: sourcePositions.length / 3,
|
||||
sourceTriangles: sourceElementCount / 3,
|
||||
rejectedOutOfBoundsTriangles,
|
||||
...(surfaceBucketReferenceCount !== null ? { surfaceBucketReferenceCount } : {}),
|
||||
bounds: [minimum, maximum],
|
||||
boundsTolerance,
|
||||
};
|
||||
@@ -415,9 +515,13 @@ export async function bakeNavigationFromCollisionAssets({
|
||||
reportFile = path.join(workRoot, slug, "recast-report.json"),
|
||||
source = undefined,
|
||||
includeDetourTopology = false,
|
||||
detourAnchorPositions = [],
|
||||
detourRouteProbes = [],
|
||||
}) {
|
||||
if (!collisionAssets.length) throw new Error(`${slug}: no collision chunks are available for Recast.`);
|
||||
if (detourAnchorPositions.length && !includeDetourTopology) {
|
||||
throw new Error(`${slug}: source-anchor Detour selection requires includeDetourTopology.`);
|
||||
}
|
||||
const normalizedPaths = collisionAssets.map((asset) => asset.path.replace(/\\/g, "/"));
|
||||
if (new Set(normalizedPaths.map((value) => value.toLowerCase())).size !== normalizedPaths.length) {
|
||||
throw new Error(`${slug}: collision chunks contain duplicate paths.`);
|
||||
@@ -431,8 +535,10 @@ export async function bakeNavigationFromCollisionAssets({
|
||||
await sha256(resolvedInputs[index]),
|
||||
])));
|
||||
const scenes = await Promise.all(resolvedInputs.map(loadScene));
|
||||
const meshes = scenes.flatMap(meshesIn);
|
||||
if (!meshes.length) throw new Error(`${slug}: collision chunks contain no triangle meshes.`);
|
||||
const sourceMeshes = scenes.flatMap(meshesIn);
|
||||
if (!sourceMeshes.length) throw new Error(`${slug}: collision chunks contain no triangle meshes.`);
|
||||
const adapted = unpackMeshesForRecast(sourceMeshes);
|
||||
const meshes = adapted.meshes;
|
||||
const input = inspectNavigationInput(meshes);
|
||||
const navigationPlan = (tileSize) => planNavigationBuild(input.bounds, settings.cellSize, {
|
||||
tileSize,
|
||||
@@ -469,6 +575,7 @@ export async function bakeNavigationFromCollisionAssets({
|
||||
let helper = null;
|
||||
let geometry;
|
||||
let detourTopology = null;
|
||||
let detourSelection = null;
|
||||
let detourRoutes = [];
|
||||
try {
|
||||
if (build.mode === "tiled") {
|
||||
@@ -566,6 +673,22 @@ export async function bakeNavigationFromCollisionAssets({
|
||||
sourceCollisionSha256: stableSha256(inputChunkChecksums),
|
||||
settingsSha256: stableSha256({ settings, generatorConfig }),
|
||||
});
|
||||
if (detourAnchorPositions.length) {
|
||||
const anchorMappings = mapDetourAnchorsToPolygons(
|
||||
generated.navMesh,
|
||||
detourTopology,
|
||||
detourAnchorPositions,
|
||||
);
|
||||
for (const anchor of detourAnchorPositions.filter(anchor => anchor.required === true)) {
|
||||
const mapping = anchorMappings.find(mapping => mapping.id === anchor.id);
|
||||
if (mapping?.polygonId === null || mapping?.polygonId === undefined
|
||||
|| !Number.isFinite(mapping.distance) || mapping.distance > anchor.maximumDistance) {
|
||||
throw new Error(`${slug}: required source navigation anchor ${anchor.id} is outside its projection limit.`);
|
||||
}
|
||||
}
|
||||
detourSelection = selectDetourAnchorComponents(detourTopology, anchorMappings);
|
||||
detourSelection.sourceTopology = detourTopology;
|
||||
}
|
||||
}
|
||||
detourRoutes = queryDetourRouteProbes(generated.navMesh, detourRouteProbes);
|
||||
await mkdir(path.dirname(output), { recursive: true });
|
||||
@@ -575,6 +698,7 @@ export async function bakeNavigationFromCollisionAssets({
|
||||
input.bounds,
|
||||
Math.max(settings.cellSize, settings.cellHeight) * 2,
|
||||
detourTopology,
|
||||
detourSelection,
|
||||
);
|
||||
} finally {
|
||||
helper?.navMeshGeometry.dispose();
|
||||
@@ -588,6 +712,7 @@ export async function bakeNavigationFromCollisionAssets({
|
||||
generator: "@recast-navigation/three@0.43.1",
|
||||
settings,
|
||||
input,
|
||||
navigationGeometryAdapter: adapted.report,
|
||||
build: {
|
||||
...build,
|
||||
partition: build.mode === "tiled" ? tiledPartition : "watershed",
|
||||
@@ -606,22 +731,23 @@ export async function bakeNavigationFromCollisionAssets({
|
||||
...(detourTopology ? {
|
||||
detourTopology: {
|
||||
schemaVersion: detourTopology.schemaVersion,
|
||||
tileCount: detourTopology.tileCount,
|
||||
polygonCount: detourTopology.polygonCount,
|
||||
detailTriangleCount: detourTopology.detailTriangleCount,
|
||||
polygonFanTriangleCount: detourTopology.polygonFanTriangleCount,
|
||||
geometryTriangleCount: detourTopology.geometryTriangleCount,
|
||||
directedLinkCount: detourTopology.directedLinkCount,
|
||||
tileCount: detourSelection?.topology.tileCount ?? detourTopology.tileCount,
|
||||
polygonCount: detourSelection?.topology.polygonCount ?? detourTopology.polygonCount,
|
||||
detailTriangleCount: detourSelection?.topology.detailTriangleCount ?? detourTopology.detailTriangleCount,
|
||||
polygonFanTriangleCount: detourSelection?.topology.polygonFanTriangleCount ?? detourTopology.polygonFanTriangleCount,
|
||||
geometryTriangleCount: detourSelection?.topology.geometryTriangleCount ?? detourTopology.geometryTriangleCount,
|
||||
directedLinkCount: detourSelection?.topology.directedLinkCount ?? detourTopology.directedLinkCount,
|
||||
unrepresentedLinkCount: detourTopology.unrepresentedLinkCount,
|
||||
offMeshPolygonCount: detourTopology.offMeshPolygonCount,
|
||||
offMeshPolygonCount: detourSelection?.topology.offMeshPolygonCount ?? detourTopology.offMeshPolygonCount,
|
||||
weakComponents: {
|
||||
count: detourTopology.weakComponents.count,
|
||||
largestPolygonCount: detourTopology.weakComponents.largestPolygonCount,
|
||||
count: detourSelection?.topology.weakComponents.count ?? detourTopology.weakComponents.count,
|
||||
largestPolygonCount: detourSelection?.topology.weakComponents.largestPolygonCount ?? detourTopology.weakComponents.largestPolygonCount,
|
||||
},
|
||||
sourceCollisionSha256: detourTopology.sourceCollisionSha256,
|
||||
settingsSha256: detourTopology.settingsSha256,
|
||||
},
|
||||
} : {}),
|
||||
...(detourSelection ? { detourComponentSelection: detourSelection.summary } : {}),
|
||||
...(source ? { source } : {}),
|
||||
};
|
||||
await writeJson(reportFile, report);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { BufferAttribute, BufferGeometry, Mesh } from "three";
|
||||
|
||||
/**
|
||||
* @recast-navigation/three currently assumes position/index attributes are
|
||||
* tightly-packed Float32/Uint arrays. glTFLoader may return interleaved or
|
||||
* normalized BufferAttributes, so copy decoded accessor values into a plain
|
||||
* geometry before handing meshes to its generator helpers.
|
||||
*/
|
||||
export function unpackMeshesForRecast(meshes) {
|
||||
let interleavedPositionAttributeCount = 0;
|
||||
let normalizedPositionAttributeCount = 0;
|
||||
let nonFloatPositionAttributeCount = 0;
|
||||
let unpackedPositionAccessorCount = 0;
|
||||
let decodedIndexAccessorCount = 0;
|
||||
|
||||
const preparedMeshes = meshes.map((sourceMesh) => {
|
||||
const sourceGeometry = sourceMesh.geometry;
|
||||
const sourcePositions = sourceGeometry?.getAttribute("position");
|
||||
if (!sourcePositions || sourcePositions.itemSize !== 3) {
|
||||
throw new Error(`${sourceMesh.name || "collision mesh"}: POSITION must be a VEC3 attribute.`);
|
||||
}
|
||||
|
||||
const positions = new Float32Array(sourcePositions.count * 3);
|
||||
for (let index = 0; index < sourcePositions.count; index += 1) {
|
||||
const offset = index * 3;
|
||||
positions[offset] = sourcePositions.getX(index);
|
||||
positions[offset + 1] = sourcePositions.getY(index);
|
||||
positions[offset + 2] = sourcePositions.getZ(index);
|
||||
}
|
||||
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new BufferAttribute(positions, 3));
|
||||
if (sourcePositions.isInterleavedBufferAttribute) interleavedPositionAttributeCount += 1;
|
||||
if (sourcePositions.normalized) normalizedPositionAttributeCount += 1;
|
||||
if (!(sourcePositions.array instanceof Float32Array)) nonFloatPositionAttributeCount += 1;
|
||||
unpackedPositionAccessorCount += 1;
|
||||
|
||||
const sourceIndices = sourceGeometry.getIndex();
|
||||
if (sourceIndices) {
|
||||
const indices = new Uint32Array(sourceIndices.count);
|
||||
for (let index = 0; index < sourceIndices.count; index += 1) {
|
||||
const value = sourceIndices.getX(index);
|
||||
if (!Number.isInteger(value) || value < 0 || value >= sourcePositions.count) {
|
||||
throw new Error(
|
||||
`${sourceMesh.name || "collision mesh"}: index ${index} references invalid position ${value}.`,
|
||||
);
|
||||
}
|
||||
indices[index] = value;
|
||||
}
|
||||
geometry.setIndex(new BufferAttribute(indices, 1));
|
||||
decodedIndexAccessorCount += 1;
|
||||
}
|
||||
|
||||
sourceMesh.updateWorldMatrix(true, false);
|
||||
const preparedMesh = new Mesh(geometry);
|
||||
preparedMesh.name = sourceMesh.name;
|
||||
preparedMesh.matrixAutoUpdate = false;
|
||||
preparedMesh.matrix.copy(sourceMesh.matrixWorld);
|
||||
preparedMesh.matrixWorld.copy(sourceMesh.matrixWorld);
|
||||
return preparedMesh;
|
||||
});
|
||||
|
||||
return {
|
||||
meshes: preparedMeshes,
|
||||
report: {
|
||||
adapter: "decoded-three-buffer-attributes-v1",
|
||||
sourceMeshCount: meshes.length,
|
||||
unpackedPositionAccessorCount,
|
||||
interleavedPositionAttributeCount,
|
||||
normalizedPositionAttributeCount,
|
||||
nonFloatPositionAttributeCount,
|
||||
decodedIndexAccessorCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { getPositionsAndIndices } from "@recast-navigation/three";
|
||||
import {
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
InterleavedBuffer,
|
||||
InterleavedBufferAttribute,
|
||||
Mesh,
|
||||
Object3D,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import { unpackMeshesForRecast } from "./recast-mesh-adapter.mjs";
|
||||
|
||||
test("Recast adapter decodes interleaved positions, indices, normalized positions, and parent transforms", () => {
|
||||
const parent = new Object3D();
|
||||
parent.position.set(10, 2, -4);
|
||||
parent.rotation.y = Math.PI / 2;
|
||||
parent.scale.set(2, 3, 4);
|
||||
|
||||
const interleavedGeometry = new BufferGeometry();
|
||||
const interleavedData = new InterleavedBuffer(new Float32Array([
|
||||
0, 0, 0, 91, 1,
|
||||
1, 0, 0, 92, 2,
|
||||
0, 0, 1, 93, 3,
|
||||
]), 5);
|
||||
interleavedGeometry.setAttribute("position", new InterleavedBufferAttribute(interleavedData, 3, 0));
|
||||
interleavedGeometry.setIndex(new BufferAttribute(new Uint16Array([0, 1, 2]), 1));
|
||||
const interleavedMesh = new Mesh(interleavedGeometry);
|
||||
interleavedMesh.name = "interleaved";
|
||||
interleavedMesh.position.set(1, 0, 2);
|
||||
parent.add(interleavedMesh);
|
||||
|
||||
const normalizedGeometry = new BufferGeometry();
|
||||
normalizedGeometry.setAttribute("position", new BufferAttribute(new Uint16Array([
|
||||
0, 0, 0,
|
||||
65535, 0, 0,
|
||||
0, 65535, 0,
|
||||
]), 3, true));
|
||||
const normalizedMesh = new Mesh(normalizedGeometry);
|
||||
normalizedMesh.name = "normalized";
|
||||
normalizedMesh.position.set(-3, 5, 7);
|
||||
parent.add(normalizedMesh);
|
||||
parent.updateMatrixWorld(true);
|
||||
|
||||
const originalInterleaved = Array.from(interleavedData.array);
|
||||
const originalNormalized = Array.from(normalizedGeometry.getAttribute("position").array);
|
||||
const result = unpackMeshesForRecast([interleavedMesh, normalizedMesh]);
|
||||
assert.deepEqual(result.report, {
|
||||
adapter: "decoded-three-buffer-attributes-v1",
|
||||
sourceMeshCount: 2,
|
||||
unpackedPositionAccessorCount: 2,
|
||||
interleavedPositionAttributeCount: 1,
|
||||
normalizedPositionAttributeCount: 1,
|
||||
nonFloatPositionAttributeCount: 1,
|
||||
decodedIndexAccessorCount: 1,
|
||||
});
|
||||
for (const [index, sourceMesh] of [interleavedMesh, normalizedMesh].entries()) {
|
||||
assert.deepEqual(
|
||||
result.meshes[index].matrixWorld.toArray(),
|
||||
sourceMesh.matrixWorld.toArray(),
|
||||
"inspection and generator transforms must agree before generator mutation",
|
||||
);
|
||||
}
|
||||
|
||||
const [positions, indices] = getPositionsAndIndices(result.meshes);
|
||||
const expectedWorld = [];
|
||||
const expectedIndices = [];
|
||||
for (const [sourceMeshIndex, sourceMesh] of [interleavedMesh, normalizedMesh].entries()) {
|
||||
const attr = sourceMesh.geometry.getAttribute("position");
|
||||
for (let i = 0; i < attr.count; i += 1) {
|
||||
const point = new Vector3(attr.getX(i), attr.getY(i), attr.getZ(i))
|
||||
.applyMatrix4(sourceMesh.matrixWorld);
|
||||
expectedWorld.push(point.x, point.y, point.z);
|
||||
if (sourceMeshIndex === 1) expectedIndices.push(3 + i);
|
||||
}
|
||||
if (sourceMeshIndex === 0) expectedIndices.push(0, 1, 2);
|
||||
}
|
||||
|
||||
assert.equal(positions.length, expectedWorld.length);
|
||||
for (let i = 0; i < expectedWorld.length; i += 1) {
|
||||
assert.ok(Math.abs(positions[i] - expectedWorld[i]) < 1e-5, `world position component ${i}`);
|
||||
}
|
||||
assert.deepEqual(Array.from(indices), expectedIndices);
|
||||
assert.deepEqual(Array.from(interleavedData.array), originalInterleaved);
|
||||
assert.deepEqual(Array.from(normalizedGeometry.getAttribute("position").array), originalNormalized);
|
||||
assert.equal(result.meshes[0].geometry.getAttribute("position").array.length, 9);
|
||||
assert.equal(result.meshes[1].geometry.getAttribute("position").array.length, 9);
|
||||
});
|
||||
|
||||
test("Recast adapter rejects malformed indices before Uint32 conversion can truncate them", () => {
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new BufferAttribute(new Float32Array([
|
||||
0, 0, 0,
|
||||
1, 0, 0,
|
||||
0, 0, 1,
|
||||
]), 3));
|
||||
geometry.setIndex(new BufferAttribute(new Float32Array([0, 1.5, 2]), 1));
|
||||
const mesh = new Mesh(geometry);
|
||||
mesh.name = "malformed-index-fixture";
|
||||
assert.throws(
|
||||
() => unpackMeshesForRecast([mesh]),
|
||||
/malformed-index-fixture: index 1 references invalid position 1\.5/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,689 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Diagnostic-only WMO v17 reader for source-backed lighting investigations.
|
||||
// Chunk IDs are stored as reversed FourCCs in the client files. Layout and
|
||||
// flags follow wowdev/pywowlib's WMO v17 readers:
|
||||
// https://github.com/wowdev/pywowlib/blob/master/file_formats/wmo_format_root.py
|
||||
// https://github.com/wowdev/pywowlib/blob/master/file_formats/wmo_format_group.py
|
||||
const MOGP_FLAGS = Object.freeze({
|
||||
hasVertexColor: 0x4,
|
||||
doNotUseLocalLighting: 0x40,
|
||||
hasLight: 0x200,
|
||||
indoor: 0x2000,
|
||||
hasTwoMocv: 0x1000000,
|
||||
});
|
||||
const MOLT_ROW_BYTES = 48;
|
||||
const MOGP_HEADER_BYTES = 68;
|
||||
const MOBA_ROW_BYTES = 24;
|
||||
const DEFAULT_AXIS_BASIS = "(source x, source z, -source y)";
|
||||
|
||||
function hash(buffer) {
|
||||
return createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
function readChunks(buffer, start = 0, end = buffer.length) {
|
||||
const chunks = [];
|
||||
for (let offset = start; offset < end;) {
|
||||
if (offset + 8 > end) throw new Error(`Truncated WMO chunk header at byte ${offset}.`);
|
||||
const id = buffer.toString("ascii", offset, offset + 4).split("").reverse().join("");
|
||||
const size = buffer.readUInt32LE(offset + 4);
|
||||
const dataStart = offset + 8;
|
||||
const dataEnd = dataStart + size;
|
||||
if (dataEnd > end) throw new Error(`WMO ${id} chunk at byte ${offset} exceeds its container.`);
|
||||
chunks.push({ id, size, dataStart, dataEnd, offset });
|
||||
offset = dataEnd;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function oneChunk(chunks, id, { required = false } = {}) {
|
||||
const matches = chunks.filter((chunk) => chunk.id === id);
|
||||
if (matches.length > 1) throw new Error(`Expected at most one ${id} chunk; found ${matches.length}.`);
|
||||
if (required && matches.length !== 1) throw new Error(`Missing required ${id} chunk.`);
|
||||
return matches[0] ?? null;
|
||||
}
|
||||
|
||||
function readStringAt(table, offset) {
|
||||
if (offset >= table.length) return null;
|
||||
const end = table.indexOf(0, offset);
|
||||
if (end < 0) return null;
|
||||
return table.toString("latin1", offset, end);
|
||||
}
|
||||
|
||||
export function parseRootWmo(buffer) {
|
||||
const chunks = readChunks(buffer);
|
||||
const version = oneChunk(chunks, "MVER", { required: true });
|
||||
if (version.size !== 4 || buffer.readUInt32LE(version.dataStart) !== 17) {
|
||||
throw new Error("Only WMO v17 roots are supported by this diagnostic reader.");
|
||||
}
|
||||
const headerChunk = oneChunk(chunks, "MOHD", { required: true });
|
||||
if (headerChunk.size !== 64) throw new Error(`Unexpected MOHD size ${headerChunk.size}.`);
|
||||
const p = headerChunk.dataStart;
|
||||
const header = {
|
||||
materialCount: buffer.readUInt32LE(p),
|
||||
groupCount: buffer.readUInt32LE(p + 4),
|
||||
portalCount: buffer.readUInt32LE(p + 8),
|
||||
lightCount: buffer.readUInt32LE(p + 12),
|
||||
modelCount: buffer.readUInt32LE(p + 16),
|
||||
doodadCount: buffer.readUInt32LE(p + 20),
|
||||
doodadSetCount: buffer.readUInt32LE(p + 24),
|
||||
ambientColorBytes: [...buffer.subarray(p + 28, p + 32)],
|
||||
wmoId: buffer.readUInt32LE(p + 32),
|
||||
boundsMin: [buffer.readFloatLE(p + 36), buffer.readFloatLE(p + 40), buffer.readFloatLE(p + 44)],
|
||||
boundsMax: [buffer.readFloatLE(p + 48), buffer.readFloatLE(p + 52), buffer.readFloatLE(p + 56)],
|
||||
flags: buffer.readUInt16LE(p + 60),
|
||||
lodCount: buffer.readUInt16LE(p + 62),
|
||||
};
|
||||
const lightsChunk = oneChunk(chunks, "MOLT", { required: true });
|
||||
if (lightsChunk.size % MOLT_ROW_BYTES !== 0) {
|
||||
throw new Error(`MOLT byte count ${lightsChunk.size} is not divisible by ${MOLT_ROW_BYTES}.`);
|
||||
}
|
||||
const lights = [];
|
||||
for (let offset = lightsChunk.dataStart; offset < lightsChunk.dataEnd; offset += MOLT_ROW_BYTES) {
|
||||
lights.push({
|
||||
index: lights.length,
|
||||
lightType: buffer.readUInt8(offset),
|
||||
secondaryType: buffer.readUInt8(offset + 1),
|
||||
useAttenuation: buffer.readUInt8(offset + 2),
|
||||
padding: buffer.readUInt8(offset + 3),
|
||||
colorBytes: [...buffer.subarray(offset + 4, offset + 8)],
|
||||
position: [buffer.readFloatLE(offset + 8), buffer.readFloatLE(offset + 12), buffer.readFloatLE(offset + 16)],
|
||||
intensity: buffer.readFloatLE(offset + 20),
|
||||
attenuationStart: buffer.readFloatLE(offset + 24),
|
||||
attenuationEnd: buffer.readFloatLE(offset + 28),
|
||||
unknownFloats: [32, 36, 40, 44].map((delta) => buffer.readFloatLE(offset + delta)),
|
||||
});
|
||||
}
|
||||
if (header.lightCount !== lights.length) {
|
||||
throw new Error(`MOHD declares ${header.lightCount} lights but MOLT contains ${lights.length}.`);
|
||||
}
|
||||
|
||||
const namesChunk = oneChunk(chunks, "MOGN", { required: true });
|
||||
const names = buffer.subarray(namesChunk.dataStart, namesChunk.dataEnd);
|
||||
const infoChunk = oneChunk(chunks, "MOGI", { required: true });
|
||||
if (infoChunk.size % 32 !== 0) throw new Error(`MOGI byte count ${infoChunk.size} is not divisible by 32.`);
|
||||
const groupInfos = [];
|
||||
for (let offset = infoChunk.dataStart; offset < infoChunk.dataEnd; offset += 32) {
|
||||
groupInfos.push({
|
||||
flags: buffer.readUInt32LE(offset),
|
||||
boundsMin: [buffer.readFloatLE(offset + 4), buffer.readFloatLE(offset + 8), buffer.readFloatLE(offset + 12)],
|
||||
boundsMax: [buffer.readFloatLE(offset + 16), buffer.readFloatLE(offset + 20), buffer.readFloatLE(offset + 24)],
|
||||
nameIndex: buffer.readInt32LE(offset + 28),
|
||||
});
|
||||
}
|
||||
if (groupInfos.length !== header.groupCount) {
|
||||
throw new Error(`MOHD declares ${header.groupCount} groups but MOGI contains ${groupInfos.length}.`);
|
||||
}
|
||||
return { header, lights, groupInfos, groupNameTable: names };
|
||||
}
|
||||
|
||||
export function parseGroupWmo(buffer, groupIndex = 0) {
|
||||
const chunks = readChunks(buffer);
|
||||
const version = oneChunk(chunks, "MVER", { required: true });
|
||||
if (version.size !== 4 || buffer.readUInt32LE(version.dataStart) !== 17) {
|
||||
throw new Error(`Group ${groupIndex} is not WMO v17.`);
|
||||
}
|
||||
const outer = oneChunk(chunks, "MOGP", { required: true });
|
||||
if (outer.size < MOGP_HEADER_BYTES) throw new Error(`Group ${groupIndex} has a truncated MOGP header.`);
|
||||
const p = outer.dataStart;
|
||||
const header = {
|
||||
groupNameOffset: buffer.readUInt32LE(p),
|
||||
descriptionNameOffset: buffer.readUInt32LE(p + 4),
|
||||
flags: buffer.readUInt32LE(p + 8),
|
||||
boundsMin: [buffer.readFloatLE(p + 12), buffer.readFloatLE(p + 16), buffer.readFloatLE(p + 20)],
|
||||
boundsMax: [buffer.readFloatLE(p + 24), buffer.readFloatLE(p + 28), buffer.readFloatLE(p + 32)],
|
||||
portalStart: buffer.readUInt16LE(p + 36),
|
||||
portalCount: buffer.readUInt16LE(p + 38),
|
||||
batchCounts: {
|
||||
unknownA: buffer.readUInt16LE(p + 40),
|
||||
interior: buffer.readUInt16LE(p + 42),
|
||||
exterior: buffer.readUInt16LE(p + 44),
|
||||
unknownD: buffer.readUInt16LE(p + 46),
|
||||
},
|
||||
};
|
||||
const nested = readChunks(buffer, p + MOGP_HEADER_BYTES, outer.dataEnd);
|
||||
const movt = oneChunk(nested, "MOVT", { required: true });
|
||||
const monr = oneChunk(nested, "MONR", { required: true });
|
||||
const motv = oneChunk(nested, "MOTV", { required: true });
|
||||
const molr = oneChunk(nested, "MOLR");
|
||||
const mocv = oneChunk(nested, "MOCV");
|
||||
const moba = oneChunk(nested, "MOBA");
|
||||
if (movt.size % 12 || monr.size % 12 || motv.size % 8) {
|
||||
throw new Error(`Group ${groupIndex} has a malformed vertex attribute stride.`);
|
||||
}
|
||||
const vertexCount = movt.size / 12;
|
||||
if (monr.size / 12 !== vertexCount || motv.size / 8 !== vertexCount) {
|
||||
throw new Error(`Group ${groupIndex} position, normal, and UV vertex counts differ.`);
|
||||
}
|
||||
if (molr && molr.size % 2) throw new Error(`Group ${groupIndex} MOLR has an incomplete light reference.`);
|
||||
if (moba && moba.size % MOBA_ROW_BYTES) throw new Error(`Group ${groupIndex} MOBA has an incomplete batch row.`);
|
||||
const hasTwoMocv = Boolean(header.flags & MOGP_FLAGS.hasTwoMocv);
|
||||
if (mocv && (mocv.size % 4 || (hasTwoMocv && mocv.size !== vertexCount * 8)
|
||||
|| (!hasTwoMocv && mocv.size !== vertexCount * 4))) {
|
||||
throw new Error(`Group ${groupIndex} has an unsupported MOCV stride or count.`);
|
||||
}
|
||||
const lightRefs = [];
|
||||
if (molr) {
|
||||
for (let offset = molr.dataStart; offset < molr.dataEnd; offset += 2) {
|
||||
lightRefs.push(buffer.readInt16LE(offset));
|
||||
}
|
||||
}
|
||||
const batches = [];
|
||||
if (moba) {
|
||||
for (let offset = moba.dataStart; offset < moba.dataEnd; offset += MOBA_ROW_BYTES) {
|
||||
batches.push({ lastVertex: buffer.readUInt16LE(offset + 20) });
|
||||
}
|
||||
}
|
||||
const vertices = [];
|
||||
for (let index = 0; index < vertexCount; index += 1) {
|
||||
const vp = movt.dataStart + index * 12;
|
||||
const np = monr.dataStart + index * 12;
|
||||
const tp = motv.dataStart + index * 8;
|
||||
vertices.push({
|
||||
position: [buffer.readFloatLE(vp), buffer.readFloatLE(vp + 4), buffer.readFloatLE(vp + 8)],
|
||||
normal: [buffer.readFloatLE(np), buffer.readFloatLE(np + 4), buffer.readFloatLE(np + 8)],
|
||||
uv: [buffer.readFloatLE(tp), buffer.readFloatLE(tp + 4)],
|
||||
mocvBytes: mocv ? [...buffer.subarray(mocv.dataStart + index * (hasTwoMocv ? 8 : 4), mocv.dataStart + index * (hasTwoMocv ? 8 : 4) + 4)] : null,
|
||||
});
|
||||
}
|
||||
return {
|
||||
groupIndex,
|
||||
header,
|
||||
hasVertexColorFlag: Boolean(header.flags & MOGP_FLAGS.hasVertexColor),
|
||||
hasLightFlag: Boolean(header.flags & MOGP_FLAGS.hasLight),
|
||||
doNotUseLocalLighting: Boolean(header.flags & MOGP_FLAGS.doNotUseLocalLighting),
|
||||
indoorFlag: Boolean(header.flags & MOGP_FLAGS.indoor),
|
||||
hasTwoMocv,
|
||||
lightRefs,
|
||||
batches,
|
||||
vertices,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertMatchingRootDigests({ rootBytes, groupCacheRootBytes, expectedRootSha256 }) {
|
||||
const rootSha256 = hash(rootBytes);
|
||||
const groupCacheRootSha256 = hash(groupCacheRootBytes);
|
||||
const expected = String(expectedRootSha256 ?? "").toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/u.test(expected)) throw new Error("An expected 64-character root SHA-256 is required.");
|
||||
if (rootSha256 !== expected) throw new Error(`Source root SHA-256 mismatch: expected ${expected}, got ${rootSha256}.`);
|
||||
if (groupCacheRootSha256 !== rootSha256) {
|
||||
throw new Error(`Group-cache root is not byte-identical to source root (${groupCacheRootSha256} != ${rootSha256}).`);
|
||||
}
|
||||
return { rootSha256, groupCacheRootSha256 };
|
||||
}
|
||||
|
||||
export function sourceToObjPoint([x, y, z]) {
|
||||
return [x, z, -y];
|
||||
}
|
||||
|
||||
function clampByte(value) {
|
||||
return Math.max(0, Math.min(255, Math.trunc(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic transcription of the WotLK CMapObjGroup::FixColorVertexAlpha
|
||||
* behavior documented at https://www.getmangos.eu/wiki/referenceinfo/clientfiles/wmo-file-r20030/.
|
||||
* It is intentionally not a production renderer/material conversion: the
|
||||
* native pixel shader, color-space path, and subsequent portal attenuation
|
||||
* are not modeled here. MOCV alpha is consumed as a fixup input and/or
|
||||
* rewritten metadata; it is never treated as opacity.
|
||||
*/
|
||||
export function documentedFixColorVertexAlpha(root, group) {
|
||||
if (!root?.header || !group?.header || !Array.isArray(group.vertices)) {
|
||||
throw new Error("FixColorVertexAlpha diagnostic requires parsed root and group records.");
|
||||
}
|
||||
if (group.hasTwoMocv) throw new Error("Two-layer MOCV fixup is unsupported by this diagnostic.");
|
||||
if (!group.vertices.length || group.vertices.some((vertex) => !vertex.mocvBytes)) {
|
||||
throw new Error("FixColorVertexAlpha diagnostic requires one MOCV color for every group vertex.");
|
||||
}
|
||||
const batchA = group.header.batchCounts.unknownA;
|
||||
if (!Number.isInteger(batchA) || batchA < 0) throw new Error("Invalid MOGP first-batch count.");
|
||||
if (batchA > group.batches.length) {
|
||||
throw new Error(`MOGP first-batch count ${batchA} exceeds parsed MOBA rows ${group.batches.length}.`);
|
||||
}
|
||||
const secondBatchVertexStart = batchA > 0 ? group.batches[batchA - 1].lastVertex + 1 : 0;
|
||||
if (secondBatchVertexStart > group.vertices.length) {
|
||||
throw new Error(`MOBA boundary ${secondBatchVertexStart} exceeds group vertex count ${group.vertices.length}.`);
|
||||
}
|
||||
|
||||
const rawColorBytes = Buffer.alloc(group.vertices.length * 4);
|
||||
const adaptedColorBytes = Buffer.alloc(group.vertices.length * 4);
|
||||
const ambient = root.header.ambientColorBytes;
|
||||
const outdoor = Boolean(group.header.flags & 0x8);
|
||||
const specialRootFlagPath = Boolean(root.header.flags & 0x8);
|
||||
for (let index = 0; index < group.vertices.length; index += 1) {
|
||||
const [b, g, r, alpha] = group.vertices[index].mocvBytes;
|
||||
const offset = index * 4;
|
||||
[b, g, r, alpha].forEach((value, channel) => { rawColorBytes[offset + channel] = value; });
|
||||
if (specialRootFlagPath) {
|
||||
// The client-reference description says MOHD 0x8 bypasses the RGB
|
||||
// correction while still applying the outdoor-dependent alpha rewrite
|
||||
// to vertices after the first-batch region. pywowlib names the same bit
|
||||
// DoNotFixVertexColorAlpha; report this disputed path, do not bless it.
|
||||
const fixedAlpha = index < secondBatchVertexStart ? alpha : outdoor ? 255 : 0;
|
||||
[b, g, r, fixedAlpha].forEach((value, channel) => { adaptedColorBytes[offset + channel] = value; });
|
||||
continue;
|
||||
}
|
||||
|
||||
let fixed;
|
||||
if (index < secondBatchVertexStart) {
|
||||
// First region: subtract root ambient, remove the MOCV-alpha weighted
|
||||
// component, then apply the documented half-scale adjustment.
|
||||
fixed = [b, g, r].map((value, channel) => {
|
||||
const afterAmbient = value - ambient[channel];
|
||||
return (afterAmbient - (alpha / 255) * afterAmbient) / 2;
|
||||
});
|
||||
fixed.push(alpha);
|
||||
} else {
|
||||
// Second region: alpha weights the added component; it remains lighting
|
||||
// data, after which the client rewrites it from MOGP outdoor status.
|
||||
fixed = [b, g, r].map((value, channel) => (value * alpha / 64 + value - ambient[channel]) / 2);
|
||||
fixed.push(outdoor ? 255 : 0);
|
||||
}
|
||||
fixed.map(clampByte).forEach((value, channel) => { adaptedColorBytes[offset + channel] = value; });
|
||||
}
|
||||
return {
|
||||
rawColorBytes,
|
||||
adaptedColorBytes,
|
||||
secondBatchVertexStart,
|
||||
fixupPath: specialRootFlagPath ? "documented-special-mohd-0x0008-disputed" : "documented-standard-mohd-flags-path",
|
||||
alphaSemantics: "MOCV alpha participates in RGB correction and can be rewritten by indoor/outdoor group status; it is not opacity.",
|
||||
productionStatus: "diagnostic-only; native pixel-shader composition, color space, and later portal attenuation remain unverified",
|
||||
quantization: "Diagnostic byte output uses truncation and [0,255] clamping; source client byte-cast rounding was not independently verified.",
|
||||
};
|
||||
}
|
||||
|
||||
function signature(position, normal, uv) {
|
||||
const [x, y, z] = sourceToObjPoint(position);
|
||||
const [nx, ny, nz] = sourceToObjPoint(normal);
|
||||
return JSON.stringify([x, y, z, nx, ny, nz, uv[0], 1 - uv[1]]);
|
||||
}
|
||||
|
||||
export function mapExactObjMocv(root, groups, objText) {
|
||||
const source = new Map();
|
||||
let mocvVertexCount = 0;
|
||||
const rawAlphaHistogram = {};
|
||||
const adaptedAlphaHistogram = {};
|
||||
for (const group of groups) {
|
||||
if (!group.vertices.some((vertex) => vertex.mocvBytes)) continue;
|
||||
if (group.hasTwoMocv) throw new Error(`Group ${group.groupIndex} has two MOCV layers; mapping is intentionally unsupported.`);
|
||||
const fixed = documentedFixColorVertexAlpha(root, group);
|
||||
for (let index = 0; index < group.vertices.length; index += 1) {
|
||||
const vertex = group.vertices[index];
|
||||
const key = signature(vertex.position, vertex.normal, vertex.uv);
|
||||
const rawColor = vertex.mocvBytes ? vertex.mocvBytes.join(",") : null;
|
||||
const offset = index * 4;
|
||||
const adaptedColor = vertex.mocvBytes ? [...fixed.adaptedColorBytes.subarray(offset, offset + 4)].join(",") : null;
|
||||
const color = rawColor === null || adaptedColor === null ? null : { rawColor, adaptedColor };
|
||||
if (color === null) continue;
|
||||
mocvVertexCount += 1;
|
||||
const alpha = vertex.mocvBytes[3];
|
||||
rawAlphaHistogram[alpha] = (rawAlphaHistogram[alpha] ?? 0) + 1;
|
||||
const adaptedAlpha = fixed.adaptedColorBytes[offset + 3];
|
||||
adaptedAlphaHistogram[adaptedAlpha] = (adaptedAlphaHistogram[adaptedAlpha] ?? 0) + 1;
|
||||
if (!source.has(key)) source.set(key, { rawColors: new Set(), adaptedColors: new Set() });
|
||||
source.get(key).rawColors.add(color.rawColor);
|
||||
source.get(key).adaptedColors.add(color.adaptedColor);
|
||||
}
|
||||
}
|
||||
const obj = { positions: [], normals: [], uvs: [] };
|
||||
for (const line of objText.split(/\r?\n/u)) {
|
||||
if (line.startsWith("v ")) obj.positions.push(line.trim().split(/\s+/u).slice(1, 4).map(Number));
|
||||
else if (line.startsWith("vn ")) obj.normals.push(line.trim().split(/\s+/u).slice(1, 4).map(Number));
|
||||
else if (line.startsWith("vt ")) obj.uvs.push(line.trim().split(/\s+/u).slice(1, 3).map(Number));
|
||||
}
|
||||
if (!obj.positions.length || obj.positions.length !== obj.normals.length || obj.positions.length !== obj.uvs.length) {
|
||||
throw new Error("OBJ must have equal, non-empty position, normal, and UV arrays for exact vertex mapping.");
|
||||
}
|
||||
const rawBytes = Buffer.alloc(obj.positions.length * 4);
|
||||
const adaptedBytes = Buffer.alloc(obj.positions.length * 4);
|
||||
let rawMapped = 0;
|
||||
let rawAmbiguous = 0;
|
||||
let rawUnmatched = 0;
|
||||
let adaptedMapped = 0;
|
||||
let adaptedAmbiguous = 0;
|
||||
let adaptedUnmatched = 0;
|
||||
for (let index = 0; index < obj.positions.length; index += 1) {
|
||||
const key = JSON.stringify([...obj.positions[index], ...obj.normals[index], ...obj.uvs[index]]);
|
||||
const colors = source.get(key);
|
||||
if (!colors || colors.rawColors.size === 0) {
|
||||
rawUnmatched += 1;
|
||||
adaptedUnmatched += 1;
|
||||
continue;
|
||||
}
|
||||
if (colors.rawColors.size !== 1) {
|
||||
rawAmbiguous += 1;
|
||||
} else {
|
||||
const color = [...colors.rawColors][0].split(",").map(Number);
|
||||
color.forEach((value, channel) => { rawBytes[index * 4 + channel] = value; });
|
||||
rawMapped += 1;
|
||||
}
|
||||
if (colors.adaptedColors.size !== 1) {
|
||||
adaptedAmbiguous += 1;
|
||||
} else {
|
||||
const color = [...colors.adaptedColors][0].split(",").map(Number);
|
||||
color.forEach((value, channel) => { adaptedBytes[index * 4 + channel] = value; });
|
||||
adaptedMapped += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
sourceMocvVertexCount: mocvVertexCount,
|
||||
objVertexCount: obj.positions.length,
|
||||
mappedVertexCount: rawMapped,
|
||||
ambiguousVertexCount: rawAmbiguous,
|
||||
unmatchedVertexCount: rawUnmatched,
|
||||
exactCompleteMapping: rawMapped === obj.positions.length && rawAmbiguous === 0 && rawUnmatched === 0,
|
||||
rawMocvByteOrder: "rawColorBytesByObjVertex preserves BGRA storage bytes exactly; alpha semantics are deliberately not applied",
|
||||
rawAlphaHistogram,
|
||||
rawColorBytesByObjVertex: rawBytes,
|
||||
documentedFixColorAlphaMapping: {
|
||||
mappedVertexCount: adaptedMapped,
|
||||
ambiguousVertexCount: adaptedAmbiguous,
|
||||
unmatchedVertexCount: adaptedUnmatched,
|
||||
exactCompleteMapping: adaptedMapped === obj.positions.length && adaptedAmbiguous === 0 && adaptedUnmatched === 0,
|
||||
adaptedAlphaHistogram,
|
||||
byteOrder: "adaptedColorBytesByObjVertex is diagnostic BGRA; its alpha is the documented client fixup result, not opacity",
|
||||
outputLabel: "documentedFixColorVertexAlpha diagnostic; not productionColors",
|
||||
colorsByObjVertex: adaptedBytes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function boundsInObjBasis(boundsMin, boundsMax) {
|
||||
return {
|
||||
min: [boundsMin[0], boundsMin[2], -boundsMax[1]],
|
||||
max: [boundsMax[0], boundsMax[2], -boundsMin[1]],
|
||||
};
|
||||
}
|
||||
|
||||
function colorHistogram(rows, field) {
|
||||
const values = new Map();
|
||||
for (const row of rows) {
|
||||
const key = row[field].join(",");
|
||||
values.set(key, (values.get(key) ?? 0) + 1);
|
||||
}
|
||||
return Object.fromEntries([...values.entries()].sort((a, b) => b[1] - a[1]));
|
||||
}
|
||||
|
||||
function numberHistogram(rows, field) {
|
||||
const values = new Map();
|
||||
for (const row of rows) {
|
||||
const key = String(row[field]);
|
||||
values.set(key, (values.get(key) ?? 0) + 1);
|
||||
}
|
||||
return Object.fromEntries([...values.entries()].sort((a, b) => Number(a[0]) - Number(b[0])));
|
||||
}
|
||||
|
||||
function makeLightInventory(roots, groups, groupNameTable) {
|
||||
const eligible = groups.filter((group) => group.hasLightFlag && group.lightRefs.length > 0 && !group.doNotUseLocalLighting);
|
||||
const referenced = new Set();
|
||||
const invalidRefs = [];
|
||||
for (const group of groups) {
|
||||
for (const index of group.lightRefs) {
|
||||
if (index < 0 || index >= roots.lights.length) invalidRefs.push({ groupIndex: group.groupIndex, lightIndex: index });
|
||||
else if (group.hasLightFlag && !group.doNotUseLocalLighting) referenced.add(index);
|
||||
}
|
||||
}
|
||||
const representatives = [];
|
||||
for (const group of eligible) {
|
||||
const groupName = readStringAt(groupNameTable, group.header.groupNameOffset);
|
||||
const description = readStringAt(groupNameTable, group.header.descriptionNameOffset);
|
||||
const playableLabel = `${groupName ?? ""} ${description ?? ""}`;
|
||||
if (!/(jail|cell|hall|room|nursery|boss|foyer|entrance|kitchen|stable|chapel|great)/iu.test(playableLabel)) continue;
|
||||
const centerSource = group.header.boundsMin.map((value, axis) => (value + group.header.boundsMax[axis]) / 2);
|
||||
const nearest = group.lightRefs
|
||||
.filter((index) => index >= 0 && index < roots.lights.length)
|
||||
.map((index) => {
|
||||
const light = roots.lights[index];
|
||||
const distance = Math.hypot(...light.position.map((value, axis) => value - centerSource[axis]));
|
||||
return { index, distance, light };
|
||||
})
|
||||
.sort((a, b) => a.distance - b.distance)[0];
|
||||
if (!nearest) continue;
|
||||
representatives.push({
|
||||
groupIndex: group.groupIndex,
|
||||
groupName,
|
||||
description,
|
||||
referencedLightIndex: nearest.index,
|
||||
distanceToGroupBoundsCenterSourceUnits: Number(nearest.distance.toFixed(4)),
|
||||
sourceWmoPosition: nearest.light.position,
|
||||
convertedObjLocalPosition: sourceToObjPoint(nearest.light.position),
|
||||
rawColorBytes: nearest.light.colorBytes,
|
||||
lightType: nearest.light.lightType,
|
||||
useAttenuation: nearest.light.useAttenuation,
|
||||
intensity: nearest.light.intensity,
|
||||
attenuationStart: nearest.light.attenuationStart,
|
||||
attenuationEnd: nearest.light.attenuationEnd,
|
||||
});
|
||||
if (representatives.length === 3) break;
|
||||
}
|
||||
const byType = {};
|
||||
const byAttenuation = {};
|
||||
for (const light of roots.lights) {
|
||||
byType[light.lightType] = (byType[light.lightType] ?? 0) + 1;
|
||||
byAttenuation[light.useAttenuation] = (byAttenuation[light.useAttenuation] ?? 0) + 1;
|
||||
}
|
||||
return {
|
||||
totalMoltRows: roots.lights.length,
|
||||
lightTypeHistogram: byType,
|
||||
useAttenuationHistogram: byAttenuation,
|
||||
rawColorByteHistogram: colorHistogram(roots.lights, "colorBytes"),
|
||||
intensityHistogram: numberHistogram(roots.lights, "intensity"),
|
||||
attenuationStartHistogram: numberHistogram(roots.lights, "attenuationStart"),
|
||||
attenuationEndHistogram: numberHistogram(roots.lights, "attenuationEnd"),
|
||||
groupCountWithMocv: groups.filter((group) => group.vertices.some((vertex) => vertex.mocvBytes)).length,
|
||||
groupCountWithHasLightAndMolr: eligible.length,
|
||||
eligibleMolrReferenceCount: eligible.reduce((sum, group) => sum + group.lightRefs.length, 0),
|
||||
eligibleUniqueMoltRows: [...referenced].sort((a, b) => a - b),
|
||||
unreferencedMoltRowCount: roots.lights.length - referenced.size,
|
||||
invalidLightReferences: invalidRefs,
|
||||
representativeRoomLights: representatives,
|
||||
runtimeLightClaim: "Diagnostic only: MOLT/MOLR references are inventoried; no Three.js dynamic lights are emitted.",
|
||||
};
|
||||
}
|
||||
|
||||
function parseObjBounds(objText) {
|
||||
const min = [Infinity, Infinity, Infinity];
|
||||
const max = [-Infinity, -Infinity, -Infinity];
|
||||
for (const line of objText.split(/\r?\n/u)) {
|
||||
if (!line.startsWith("v ")) continue;
|
||||
const p = line.trim().split(/\s+/u).slice(1, 4).map(Number);
|
||||
p.forEach((value, axis) => {
|
||||
min[axis] = Math.min(min[axis], value);
|
||||
max[axis] = Math.max(max[axis], value);
|
||||
});
|
||||
}
|
||||
if (!Number.isFinite(min[0])) throw new Error("OBJ has no position vertices.");
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
export function auditWmoAsset({ rootBytes, groupCacheRootBytes, expectedRootSha256, groupBuffers, objText }) {
|
||||
const identity = assertMatchingRootDigests({ rootBytes, groupCacheRootBytes, expectedRootSha256 });
|
||||
const roots = parseRootWmo(rootBytes);
|
||||
if (groupBuffers.length !== roots.header.groupCount) {
|
||||
throw new Error(`Expected ${roots.header.groupCount} group files, received ${groupBuffers.length}.`);
|
||||
}
|
||||
const groups = groupBuffers.map((buffer, index) => parseGroupWmo(buffer, index));
|
||||
for (const group of groups) {
|
||||
for (const lightIndex of group.lightRefs) {
|
||||
if (lightIndex < 0 || lightIndex >= roots.lights.length) {
|
||||
throw new Error(`Group ${group.groupIndex} references out-of-range MOLT row ${lightIndex}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const mapped = mapExactObjMocv(roots, groups, objText);
|
||||
const convertedRootBounds = boundsInObjBasis(roots.header.boundsMin, roots.header.boundsMax);
|
||||
const objBounds = parseObjBounds(objText);
|
||||
const boundsDelta = [0, 1, 2].flatMap((axis) => [
|
||||
Math.abs(convertedRootBounds.min[axis] - objBounds.min[axis]),
|
||||
Math.abs(convertedRootBounds.max[axis] - objBounds.max[axis]),
|
||||
]);
|
||||
return {
|
||||
sourceIdentity: identity,
|
||||
sourceGeometry: {
|
||||
formatVersion: 17,
|
||||
rootGroupCount: roots.header.groupCount,
|
||||
loadedGroupCount: groups.length,
|
||||
groupCacheFilesAreBoundToRootByHash: true,
|
||||
rootBoundsInSourceAxes: { min: roots.header.boundsMin, max: roots.header.boundsMax },
|
||||
exportedObjBounds: objBounds,
|
||||
verifiedSourceToObjBasis: DEFAULT_AXIS_BASIS,
|
||||
rootBoundsTransformMaxDelta: Math.max(...boundsDelta),
|
||||
sourceToObjBasisEvidence: "Full exact OBJ vertex mapping matches the corresponding group MOVT/MONR/MOTV signatures after this axis transform.",
|
||||
groupFlagsHistogram: Object.fromEntries([...groups.reduce((counts, group) => {
|
||||
counts.set(`0x${group.header.flags.toString(16)}`, (counts.get(`0x${group.header.flags.toString(16)}`) ?? 0) + 1);
|
||||
return counts;
|
||||
}, new Map()).entries()]),
|
||||
mocv: {
|
||||
alphaSemantics: "Raw alpha is provenance only. It is not glTF opacity and is not fed to a material alpha channel.",
|
||||
rendererFixupContext: {
|
||||
mohdFlags: roots.header.flags,
|
||||
mohdAmbientBytes: roots.header.ambientColorBytes,
|
||||
perGroup: groups.map((group) => ({
|
||||
groupIndex: group.groupIndex,
|
||||
groupFlags: group.header.flags,
|
||||
hasVertexColorFlag: group.hasVertexColorFlag,
|
||||
indoorFlag: group.indoorFlag,
|
||||
batchCounts: group.header.batchCounts,
|
||||
mocvVertexCount: group.vertices.filter((vertex) => vertex.mocvBytes).length,
|
||||
})),
|
||||
interpretation: "The WotLK-era client FixColorVertexAlpha path is documented as batch-dependent: it uses MOHD flags/ambient and MOGP batch counts, mutates MOCV RGB (including a divide-by-two step), then rewrites alpha. The diagnostic transcription below is not productionColors: native pixel-shader composition, color-space domain, and later portal attenuation remain unverified. The meaning of MOHD 0x0008 conflicts between the client behavior description and pywowlib naming, so that path is specifically disputed.",
|
||||
},
|
||||
rawMapping: {
|
||||
objVertexCount: mapped.objVertexCount,
|
||||
sourceMocvVertexCount: mapped.sourceMocvVertexCount,
|
||||
mappedVertexCount: mapped.mappedVertexCount,
|
||||
ambiguousVertexCount: mapped.ambiguousVertexCount,
|
||||
unmatchedVertexCount: mapped.unmatchedVertexCount,
|
||||
exactCompleteMapping: mapped.exactCompleteMapping,
|
||||
packedRawByteSha256: hash(mapped.rawColorBytesByObjVertex),
|
||||
rawAlphaHistogram: mapped.rawAlphaHistogram,
|
||||
byteOrder: mapped.rawMocvByteOrder,
|
||||
alphaUse: "Raw alpha remains uninterpreted provenance; not opacity or material alpha.",
|
||||
outputLabel: "rawColorBytesByObjVertex",
|
||||
},
|
||||
documentedFixColorVertexAlphaDiagnostic: {
|
||||
objVertexCount: mapped.objVertexCount,
|
||||
mappedVertexCount: mapped.documentedFixColorAlphaMapping.mappedVertexCount,
|
||||
ambiguousVertexCount: mapped.documentedFixColorAlphaMapping.ambiguousVertexCount,
|
||||
unmatchedVertexCount: mapped.documentedFixColorAlphaMapping.unmatchedVertexCount,
|
||||
exactCompleteMapping: mapped.documentedFixColorAlphaMapping.exactCompleteMapping,
|
||||
packedAdaptedDiagnosticByteSha256: hash(mapped.documentedFixColorAlphaMapping.colorsByObjVertex),
|
||||
adaptedAlphaHistogram: mapped.documentedFixColorAlphaMapping.adaptedAlphaHistogram,
|
||||
byteOrder: mapped.documentedFixColorAlphaMapping.byteOrder,
|
||||
outputLabel: mapped.documentedFixColorAlphaMapping.outputLabel,
|
||||
productionColors: false,
|
||||
mohd0008Interpretation: "Disputed: getMaNGOS describes a special alpha path; pywowlib labels the bit DoNotFixVertexColorAlpha. Diagnostic only.",
|
||||
quantization: "Diagnostic byte output truncates and clamps to [0,255]; exact source client byte-cast rounding is not independently verified.",
|
||||
},
|
||||
},
|
||||
},
|
||||
lighting: makeLightInventory(roots, groups, roots.groupNameTable),
|
||||
sourceSemantics: {
|
||||
molt: "WMO v17 MOLT records are preserved for diagnosis; classic-era clients do not consume these as realtime point lights and wowlib describes them as exporter-baked into vertex colors.",
|
||||
mocv: "MOCV is stored BGRA and indoor groups use it as baked lighting. A diagnostic-only transcription of the documented WotLK-era CPU color fix is emitted as adaptedColorBytesByObjVertex; it is not a production material color because native pixel-shader composition, sRGB/linear conversion, source byte quantization, and later portal attenuation are not fully verified.",
|
||||
sources: [
|
||||
"https://github.com/wowdev/pywowlib/blob/master/file_formats/wmo_format_root.py",
|
||||
"https://github.com/wowdev/pywowlib/blob/master/file_formats/wmo_format_group.py",
|
||||
"https://skarndev.github.io/wowlib/python/wmo/root-chunks/",
|
||||
"https://www.getmangos.eu/wiki/referenceinfo/clientfiles/wmo-file-r20030/",
|
||||
],
|
||||
},
|
||||
rawColorBytesByObjVertex: mapped.rawColorBytesByObjVertex,
|
||||
adaptedColorBytesByObjVertex: mapped.documentedFixColorAlphaMapping.colorsByObjVertex,
|
||||
};
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"Usage:",
|
||||
" node scripts/dungeon-pipeline/wow-native-wmo-audit.mjs --root <source-root.wmo> --group-root <cache-root.wmo>",
|
||||
" --group-dir <directory> --group-base <stem> --obj <wow-export.obj> --expected-root-sha <sha256>",
|
||||
" [--report <audit.json>] [--raw-color-bytes <raw-bg-r-a-sidecar.bin>]",
|
||||
" [--adapted-color-bytes <documented-fix-diagnostic-bg-r-a-sidecar.bin>]",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function cliArgs(argv) {
|
||||
const args = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const key = argv[index];
|
||||
if (!key.startsWith("--")) throw new Error(`Unexpected argument ${key}.\n${usage()}`);
|
||||
if (key === "--help") return { help: true };
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${key}.\n${usage()}`);
|
||||
args[key.slice(2)] = value;
|
||||
index += 1;
|
||||
}
|
||||
for (const key of ["root", "group-root", "group-dir", "group-base", "obj", "expected-root-sha"]) {
|
||||
if (!args[key]) throw new Error(`Missing --${key}.\n${usage()}`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function runCli() {
|
||||
const args = cliArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
process.stdout.write(`${usage()}\n`);
|
||||
return;
|
||||
}
|
||||
const rootPath = path.resolve(args.root);
|
||||
const groupRootPath = path.resolve(args["group-root"]);
|
||||
const groupDirectory = path.resolve(args["group-dir"]);
|
||||
const rootBytes = await readFile(rootPath);
|
||||
const groupCacheRootBytes = await readFile(groupRootPath);
|
||||
const parsedRoot = parseRootWmo(rootBytes);
|
||||
const groupBuffers = [];
|
||||
const groupHashes = [];
|
||||
for (let index = 0; index < parsedRoot.header.groupCount; index += 1) {
|
||||
const filename = `${args["group-base"]}_${String(index).padStart(3, "0")}.wmo`;
|
||||
const bytes = await readFile(path.join(groupDirectory, filename));
|
||||
groupBuffers.push(bytes);
|
||||
groupHashes.push(hash(bytes));
|
||||
}
|
||||
const objPath = path.resolve(args.obj);
|
||||
const objText = await readFile(objPath, "utf8");
|
||||
const result = auditWmoAsset({
|
||||
rootBytes,
|
||||
groupCacheRootBytes,
|
||||
expectedRootSha256: args["expected-root-sha"],
|
||||
groupBuffers,
|
||||
objText,
|
||||
});
|
||||
result.sourceIdentity.sourceRootPath = rootPath;
|
||||
result.sourceIdentity.groupCacheRootPath = groupRootPath;
|
||||
result.sourceIdentity.groupCacheFileHashes = groupHashes;
|
||||
result.sourceIdentity.exportedObjPath = objPath;
|
||||
result.sourceIdentity.exportedObjSha256 = hash(Buffer.from(objText));
|
||||
const printable = JSON.stringify({
|
||||
...result,
|
||||
rawColorBytesByObjVertex: undefined,
|
||||
adaptedColorBytesByObjVertex: undefined,
|
||||
}, null, 2);
|
||||
if (args.report) await writeFile(path.resolve(args.report), `${printable}\n`);
|
||||
if (args["raw-color-bytes"]) {
|
||||
if (!result.sourceGeometry.mocv.rawMapping.exactCompleteMapping) {
|
||||
throw new Error("Refusing to write an incomplete/ambiguous raw MOCV color mapping.");
|
||||
}
|
||||
await writeFile(path.resolve(args["raw-color-bytes"]), result.rawColorBytesByObjVertex);
|
||||
}
|
||||
if (args["adapted-color-bytes"]) {
|
||||
if (!result.sourceGeometry.mocv.documentedFixColorVertexAlphaDiagnostic.exactCompleteMapping) {
|
||||
throw new Error("Refusing to write an incomplete/ambiguous adapted diagnostic color mapping.");
|
||||
}
|
||||
await writeFile(path.resolve(args["adapted-color-bytes"]), result.adaptedColorBytesByObjVertex);
|
||||
}
|
||||
process.stdout.write(`${printable}\n`);
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : "";
|
||||
if (invokedPath === fileURLToPath(import.meta.url)) {
|
||||
runCli().catch((error) => {
|
||||
process.stderr.write(`${error.stack ?? error}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
assertMatchingRootDigests,
|
||||
auditWmoAsset,
|
||||
documentedFixColorVertexAlpha,
|
||||
mapExactObjMocv,
|
||||
parseGroupWmo,
|
||||
parseRootWmo,
|
||||
sourceToObjPoint,
|
||||
} from "./wow-native-wmo-audit.mjs";
|
||||
|
||||
function reversedChunk(id, payload) {
|
||||
const header = Buffer.alloc(8);
|
||||
header.write(id.split("").reverse().join(""), 0, "ascii");
|
||||
header.writeUInt32LE(payload.length, 4);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
function floats(values) {
|
||||
const result = Buffer.alloc(values.length * 4);
|
||||
values.forEach((value, index) => result.writeFloatLE(value, index * 4));
|
||||
return result;
|
||||
}
|
||||
|
||||
function makeLightRow() {
|
||||
const row = Buffer.alloc(48);
|
||||
row.set([0, 1, 1, 1, 230, 160, 90, 255], 0);
|
||||
[2, 3, 4].forEach((value, index) => row.writeFloatLE(value, 8 + index * 4));
|
||||
row.writeFloatLE(0.65, 20);
|
||||
row.writeFloatLE(0.5, 24);
|
||||
row.writeFloatLE(8, 28);
|
||||
return row;
|
||||
}
|
||||
|
||||
function makeRoot({ flags = 0, lightPayload = makeLightRow(), ambient = [20, 30, 40, 255] } = {}) {
|
||||
const mver = Buffer.alloc(4);
|
||||
mver.writeUInt32LE(17);
|
||||
const mohd = Buffer.alloc(64);
|
||||
mohd.writeUInt32LE(1, 4); // one group
|
||||
mohd.writeUInt32LE(lightPayload.length / 48, 12);
|
||||
mohd.set(ambient, 28);
|
||||
mohd.writeUInt32LE(723, 32);
|
||||
floats([-10, -20, -30, 10, 20, 30]).copy(mohd, 36);
|
||||
mohd.writeUInt16LE(flags, 60);
|
||||
const mogi = Buffer.alloc(32);
|
||||
mogi.writeUInt32LE(0x2000, 0);
|
||||
floats([-1, -1, -1, 1, 1, 1]).copy(mogi, 4);
|
||||
mogi.writeInt32LE(1, 28);
|
||||
return Buffer.concat([
|
||||
reversedChunk("MVER", mver),
|
||||
reversedChunk("MOHD", mohd),
|
||||
reversedChunk("MOGN", Buffer.from("\0JailRoom\0Interior\0", "latin1")),
|
||||
reversedChunk("MOGI", mogi),
|
||||
reversedChunk("MOLT", lightPayload),
|
||||
]);
|
||||
}
|
||||
|
||||
function makeGroup({
|
||||
flags = 0x2204,
|
||||
colors = [[100, 90, 80, 0], [110, 100, 90, 0], [120, 110, 100, 0]],
|
||||
refs = [0],
|
||||
batchLastVertex = 0,
|
||||
} = {}) {
|
||||
const positions = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];
|
||||
const normals = [[0, 1, 0], [0, 0, 1], [1, 0, 0]];
|
||||
const uvs = [[0.25, 0.75], [0.5, 0.25], [0.125, 0.875]];
|
||||
const header = Buffer.alloc(68);
|
||||
header.writeUInt32LE(1, 0);
|
||||
header.writeUInt32LE(10, 4);
|
||||
header.writeUInt32LE(flags, 8);
|
||||
floats([-10, -20, -30, 10, 20, 30]).copy(header, 12);
|
||||
header.writeUInt16LE(1, 40); // batch A
|
||||
header.writeUInt16LE(1, 42); // interior batch
|
||||
header.writeUInt16LE(0, 44); // exterior batch
|
||||
const nested = [
|
||||
reversedChunk("MOVT", floats(positions.flat())),
|
||||
reversedChunk("MONR", floats(normals.flat())),
|
||||
reversedChunk("MOTV", floats(uvs.flat())),
|
||||
reversedChunk("MOLR", Buffer.from(refs.flatMap((value) => {
|
||||
const row = Buffer.alloc(2);
|
||||
row.writeInt16LE(value);
|
||||
return [...row];
|
||||
}))),
|
||||
reversedChunk("MOCV", Buffer.from(colors.flat())),
|
||||
reversedChunk("MOBA", (() => {
|
||||
const batch = Buffer.alloc(24);
|
||||
batch.writeUInt16LE(batchLastVertex, 20);
|
||||
return batch;
|
||||
})()),
|
||||
];
|
||||
const version = Buffer.alloc(4);
|
||||
version.writeUInt32LE(17);
|
||||
return {
|
||||
bytes: Buffer.concat([reversedChunk("MVER", version), reversedChunk("MOGP", Buffer.concat([header, ...nested]))]),
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
colors,
|
||||
};
|
||||
}
|
||||
|
||||
function makeObj({ positions, normals, uvs }) {
|
||||
const lines = [];
|
||||
for (const [x, y, z] of positions) lines.push(`v ${x} ${z} ${-y}`);
|
||||
for (const [x, y, z] of normals) lines.push(`vn ${x} ${z} ${-y}`);
|
||||
for (const [u, v] of uvs) lines.push(`vt ${u} ${1 - v}`);
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function digest(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
test("WMO v17 parsers decode MOLT rows, MOGP flags, batches, MOLR, and raw MOCV bytes", () => {
|
||||
const rootBytes = makeRoot();
|
||||
const group = makeGroup();
|
||||
const root = parseRootWmo(rootBytes);
|
||||
const parsedGroup = parseGroupWmo(group.bytes, 0);
|
||||
|
||||
assert.equal(root.header.groupCount, 1);
|
||||
assert.equal(root.header.lightCount, 1);
|
||||
assert.deepEqual(root.header.ambientColorBytes, [20, 30, 40, 255]);
|
||||
assert.deepEqual(root.lights[0], {
|
||||
index: 0,
|
||||
lightType: 0,
|
||||
secondaryType: 1,
|
||||
useAttenuation: 1,
|
||||
padding: 1,
|
||||
colorBytes: [230, 160, 90, 255],
|
||||
position: [2, 3, 4],
|
||||
intensity: Math.fround(0.65),
|
||||
attenuationStart: 0.5,
|
||||
attenuationEnd: 8,
|
||||
unknownFloats: [0, 0, 0, 0],
|
||||
});
|
||||
assert.equal(parsedGroup.header.flags, 0x2204);
|
||||
assert.deepEqual(parsedGroup.header.batchCounts, { unknownA: 1, interior: 1, exterior: 0, unknownD: 0 });
|
||||
assert.equal(parsedGroup.hasLightFlag, true);
|
||||
assert.equal(parsedGroup.indoorFlag, true);
|
||||
assert.deepEqual(parsedGroup.lightRefs, [0]);
|
||||
assert.deepEqual(parsedGroup.vertices[0].mocvBytes, [100, 90, 80, 0]);
|
||||
});
|
||||
|
||||
test("source coordinate basis is exact WMO (x,y,z) to OBJ (x,z,-y)", () => {
|
||||
assert.deepEqual(sourceToObjPoint([2, 3, 4]), [2, 4, -3]);
|
||||
});
|
||||
|
||||
test("exact MOCV mapping uses full position, normal, and UV signatures and preserves raw BGRA", () => {
|
||||
const group = makeGroup();
|
||||
const obj = makeObj(group);
|
||||
const mapping = mapExactObjMocv(parseRootWmo(makeRoot()), [parseGroupWmo(group.bytes)], obj);
|
||||
|
||||
assert.equal(mapping.exactCompleteMapping, true);
|
||||
assert.equal(mapping.mappedVertexCount, 3);
|
||||
assert.equal(mapping.ambiguousVertexCount, 0);
|
||||
assert.equal(mapping.unmatchedVertexCount, 0);
|
||||
assert.equal(mapping.rawMocvByteOrder, "rawColorBytesByObjVertex preserves BGRA storage bytes exactly; alpha semantics are deliberately not applied");
|
||||
assert.deepEqual([...mapping.rawColorBytesByObjVertex], group.colors.flat());
|
||||
assert.equal(mapping.documentedFixColorAlphaMapping.exactCompleteMapping, true);
|
||||
assert.deepEqual([...mapping.documentedFixColorAlphaMapping.colorsByObjVertex].slice(3, 4), [0]);
|
||||
assert.deepEqual(mapping.rawAlphaHistogram, { 0: 3 });
|
||||
});
|
||||
|
||||
test("documented MOCV fixup treats alpha as lighting data and rewrites indoor/outdoor alpha, never opacity", () => {
|
||||
const root = parseRootWmo(makeRoot());
|
||||
const colors = [[110, 90, 70, 128], [100, 80, 60, 64], [40, 50, 60, 0]];
|
||||
const indoor = parseGroupWmo(makeGroup({ flags: 0x2204, colors }).bytes);
|
||||
const outdoor = parseGroupWmo(makeGroup({ flags: 0x220c, colors }).bytes);
|
||||
|
||||
const indoorResult = documentedFixColorVertexAlpha(root, indoor);
|
||||
const outdoorResult = documentedFixColorVertexAlpha(root, outdoor);
|
||||
|
||||
assert.equal(indoorResult.secondBatchVertexStart, 1);
|
||||
assert.deepEqual([...indoorResult.rawColorBytes], colors.flat());
|
||||
assert.deepEqual([...indoorResult.adaptedColorBytes], [22, 14, 7, 128, 90, 65, 40, 0, 10, 10, 10, 0]);
|
||||
assert.deepEqual([...outdoorResult.adaptedColorBytes], [22, 14, 7, 128, 90, 65, 40, 255, 10, 10, 10, 255]);
|
||||
assert.match(indoorResult.alphaSemantics, /not opacity/u);
|
||||
assert.equal(indoorResult.productionStatus.includes("diagnostic-only"), true);
|
||||
});
|
||||
|
||||
test("documented MOHD 0x0008 path is marked disputed and does not claim production colors", () => {
|
||||
const root = parseRootWmo(makeRoot({ flags: 0x8 }));
|
||||
const group = parseGroupWmo(makeGroup({ flags: 0x2204, colors: [[100, 80, 60, 64], [50, 40, 30, 32], [20, 10, 5, 7]] }).bytes);
|
||||
const result = documentedFixColorVertexAlpha(root, group);
|
||||
|
||||
assert.equal(result.fixupPath, "documented-special-mohd-0x0008-disputed");
|
||||
assert.deepEqual([...result.adaptedColorBytes], [100, 80, 60, 64, 50, 40, 30, 0, 20, 10, 5, 0]);
|
||||
assert.match(result.productionStatus, /diagnostic-only/u);
|
||||
});
|
||||
|
||||
test("exact mapping fails closed on changed geometry instead of nearest-vertex guessing", () => {
|
||||
const group = makeGroup();
|
||||
const obj = makeObj(group).replace("v 1 3 -2", "v 1.0001 3 -2");
|
||||
const mapping = mapExactObjMocv(parseRootWmo(makeRoot()), [parseGroupWmo(group.bytes)], obj);
|
||||
|
||||
assert.equal(mapping.exactCompleteMapping, false);
|
||||
assert.equal(mapping.unmatchedVertexCount, 1);
|
||||
});
|
||||
|
||||
test("group data is rejected unless it is paired with the byte-identical expected root", () => {
|
||||
const rootBytes = makeRoot();
|
||||
const expected = digest(rootBytes);
|
||||
assert.deepEqual(assertMatchingRootDigests({
|
||||
rootBytes,
|
||||
groupCacheRootBytes: Buffer.from(rootBytes),
|
||||
expectedRootSha256: expected,
|
||||
}), { rootSha256: expected, groupCacheRootSha256: expected });
|
||||
|
||||
const differentRoot = Buffer.from(rootBytes);
|
||||
differentRoot[differentRoot.length - 1] ^= 1;
|
||||
assert.throws(() => assertMatchingRootDigests({
|
||||
rootBytes,
|
||||
groupCacheRootBytes: differentRoot,
|
||||
expectedRootSha256: expected,
|
||||
}), /not byte-identical/u);
|
||||
assert.throws(() => assertMatchingRootDigests({
|
||||
rootBytes,
|
||||
groupCacheRootBytes: rootBytes,
|
||||
expectedRootSha256: "",
|
||||
}), /expected 64-character/u);
|
||||
});
|
||||
|
||||
test("MOLT references are eligible only with HasLight and without DoNotUseLocalLighting", () => {
|
||||
const rootBytes = makeRoot();
|
||||
const group = makeGroup({ flags: 0x2244 });
|
||||
const result = auditWmoAsset({
|
||||
rootBytes,
|
||||
groupCacheRootBytes: rootBytes,
|
||||
expectedRootSha256: digest(rootBytes),
|
||||
groupBuffers: [group.bytes],
|
||||
objText: makeObj(group),
|
||||
});
|
||||
|
||||
assert.equal(result.lighting.totalMoltRows, 1);
|
||||
assert.equal(result.lighting.groupCountWithHasLightAndMolr, 0);
|
||||
assert.equal(result.lighting.eligibleMolrReferenceCount, 0);
|
||||
assert.deepEqual(result.lighting.eligibleUniqueMoltRows, []);
|
||||
assert.match(result.lighting.runtimeLightClaim, /no Three\.js dynamic lights/u);
|
||||
assert.match(result.sourceGeometry.mocv.alphaSemantics, /not glTF opacity/u);
|
||||
});
|
||||
|
||||
test("malformed MOLT rows are rejected", () => {
|
||||
assert.throws(() => parseRootWmo(makeRoot({ lightPayload: Buffer.alloc(47) })), /not divisible/u);
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
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');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const directory = path.resolve('artifacts/playtest/healer-mana-recovery');
|
||||
const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-rom-status-'));
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
|
||||
(async () => {
|
||||
const { createGameServer } = await import('../server/server.mjs');
|
||||
const runtime = createGameServer({ dataDir: runtimeData, enableShelvedModes: false });
|
||||
await new Promise((resolve) => runtime.server.listen(4198, '127.0.0.1', resolve));
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
page.setDefaultTimeout(180_000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const moduleUrls = fs.readdirSync('dist/assets')
|
||||
.filter((file) => /^(?:index|GameRuntime|combatStore)-.*\.js$/.test(file))
|
||||
.map((file) => `/assets/${file}`);
|
||||
await page.addInitScript((urls) => {
|
||||
window.romStatusStores = async () => {
|
||||
const exports = (await Promise.all(urls.map((url) => import(url)))).flatMap(Object.values);
|
||||
const stores = exports.filter((value) => value && typeof value.getState === 'function');
|
||||
return {
|
||||
game: stores.find((store) => typeof store.getState()?.relocatePlayer === 'function'),
|
||||
shell: stores.find((store) => typeof store.getState()?.saveActiveProgression === 'function'),
|
||||
combat: stores.find((store) => typeof store.getState()?.castAbility === 'function'),
|
||||
party: stores.find((store) => typeof store.getState()?.setCommand === 'function'
|
||||
&& Array.isArray(store.getState()?.members)),
|
||||
};
|
||||
};
|
||||
}, moduleUrls);
|
||||
|
||||
try {
|
||||
await page.goto('http://127.0.0.1:4198', { waitUntil: 'domcontentloaded' });
|
||||
await page.getByRole('button', { name: /Play offline/ }).click();
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click();
|
||||
await page.locator('.class-option').filter({ hasText: 'Priest' }).click();
|
||||
await page.getByRole('button', { name: /Customize & name/ }).click();
|
||||
await page.getByLabel('Character name').fill('ManaAuraQA');
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.locator('.character-select-screen').waitFor();
|
||||
await page.evaluate(async () => {
|
||||
const { shell } = await window.romStatusStores();
|
||||
const state = shell.getState();
|
||||
const character = state.characters.find((entry) => entry.id === state.selectedCharacterId);
|
||||
if (!character) throw new Error('Priest character was not selected.');
|
||||
shell.setState({ activeCharacter: character });
|
||||
shell.getState().saveActiveProgression(30, 0, character.talentRanks);
|
||||
shell.setState({ activeCharacter: null });
|
||||
shell.getState().openMainMenu();
|
||||
});
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
await page.evaluate(async () => {
|
||||
const { shell } = await window.romStatusStores();
|
||||
shell.getState().openDungeons();
|
||||
});
|
||||
await page.locator('.dungeon-select-screen').waitFor();
|
||||
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 () => {
|
||||
const { game } = await window.romStatusStores();
|
||||
game.getState().closeOverlay();
|
||||
});
|
||||
await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached' });
|
||||
await page.waitForFunction(async () => {
|
||||
const { combat } = await window.romStatusStores();
|
||||
return combat.getState().level >= 30 && Object.values(combat.getState().mobs)
|
||||
.some((mob) => !mob.dead && mob.homePosition);
|
||||
});
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Boss guide', exact: true }).click();
|
||||
await page.getByRole('heading', { name: 'Boss Guide', exact: true }).waitFor();
|
||||
const bossGuide = await page.locator('.boss-guide-panel__bosses').innerText();
|
||||
const selectedBoss = await page.locator('.boss-guide-panel__bosses button.is-active strong').innerText();
|
||||
await page.screenshot({ path: path.join(directory, 'selected-boss.png') });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Resume game' }).click();
|
||||
const metadata = await page.evaluate(async () => {
|
||||
const { combat, game, party } = await window.romStatusStores();
|
||||
const state = combat.getState();
|
||||
return { dungeonId: game.getState().activeDungeonId,
|
||||
boss: Object.values(state.mobs).filter((mob) => mob.boss).map((mob) => mob.name),
|
||||
classId: state.classId, level: state.level, mana: state.resourcePools.mana,
|
||||
maximum: state.maxResourcePools.mana, members: party.getState().members.map((member) => ({ name: member.name, role: member.role })),
|
||||
aura: state.auras.filter((aura) => aura.definition.id === 'healer-mana-recovery'), assets: performance.getEntriesByType('resource').filter((entry) => /assets\/.*\.js/.test(entry.name)).map((entry) => entry.name) };
|
||||
});
|
||||
metadata.boss = [selectedBoss];
|
||||
assert.equal(metadata.dungeonId, 'bite-sized-stormwind-stockade');
|
||||
assert(metadata.aura.length > 0, 'Recovery buff is visible out of combat');
|
||||
await page.screenshot({ path: path.join(directory, 'buff-enabled.png') });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Options', exact: true }).click();
|
||||
const row = page.locator('.option-row').filter({ hasText: 'Healer mana recovery' });
|
||||
await row.scrollIntoViewIfNeeded();
|
||||
assert(await row.getByRole('button', { name: 'On', exact: true }).count());
|
||||
await page.screenshot({ path: path.join(directory, 'menu-enabled.png') });
|
||||
await page.setViewportSize({ width: 960, height: 540 });
|
||||
await row.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(directory, 'menu-compact.png') });
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await row.getByRole('button', { name: 'On', exact: true }).click();
|
||||
await row.getByRole('button', { name: 'Off', exact: true }).waitFor();
|
||||
const off = await page.evaluate(async () => {
|
||||
const { combat, shell } = await window.romStatusStores();
|
||||
return { enabled: combat.getState().settings.healerManaRecovery,
|
||||
auraCount: combat.getState().auras.filter((aura) => aura.definition.id === 'healer-mana-recovery').length,
|
||||
saved: shell.getState().activeCharacter?.settings?.healerManaRecovery };
|
||||
});
|
||||
assert.equal(off.enabled, false); assert.equal(off.auraCount, 0); assert.equal(off.saved, false);
|
||||
await page.screenshot({ path: path.join(directory, 'menu-disabled.png') });
|
||||
await row.getByRole('button', { name: 'Off', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Return to pause menu' }).click();
|
||||
await page.getByRole('button', { name: 'Resume game' }).click();
|
||||
const labels = await page.locator('button.action-slot--friendly').evaluateAll((buttons) => buttons.map((button) => button.getAttribute('aria-label')));
|
||||
let healButton = page.getByRole('button', { name: /^Urgent Heal,/ }).first();
|
||||
if (!await healButton.count()) healButton = page.locator('button.action-slot--friendly').first();
|
||||
const before = await page.evaluate(async () => { const { combat } = await window.romStatusStores(); return combat.getState().resource; });
|
||||
await healButton.click();
|
||||
const samples = [];
|
||||
for (let index = 0; index < 40; index++) {
|
||||
await page.waitForTimeout(100);
|
||||
samples.push(await page.evaluate(async () => { const { combat } = await window.romStatusStores(); const state = combat.getState(); return { now: Date.now(), resource: state.resource, max: state.maxResource, casting: state.activeCast?.abilityId, engaged: Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead) }; }));
|
||||
}
|
||||
assert(samples.some((sample) => sample.resource < before), 'Ordinary healing cast spends mana');
|
||||
const lowIndex = samples.findIndex((sample) => sample.resource === Math.min(...samples.map((entry) => entry.resource)));
|
||||
assert(samples.slice(lowIndex + 1).some((sample) => sample.resource > samples[lowIndex].resource), 'Mana recovers between fights');
|
||||
await page.screenshot({ path: path.join(directory, 'after-heal.png') });
|
||||
fs.writeFileSync(path.join(directory, 'result.json'), JSON.stringify({ metadata, bossGuide, off, labels, before, samples, errors }, null, 2)+'\n');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(JSON.stringify({ dungeonId: metadata.dungeonId, boss: metadata.boss, off, before, minimum: Math.min(...samples.map((entry) => entry.resource)), after: samples.at(-1).resource, errors, directory }, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
await new Promise((resolve) => runtime.server.close(resolve));
|
||||
}
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,88 @@
|
||||
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 id = process.argv[2];
|
||||
if (!id || !/^[a-z0-9-]+$/.test(id)) throw new Error('Provide a dungeon ID for this targeted asset review.');
|
||||
function option(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index < 0) return null;
|
||||
if (!process.argv[index + 1]) throw new Error(`${name} requires a value.`);
|
||||
return process.argv[index + 1];
|
||||
}
|
||||
const override = option('--visual-override');
|
||||
const hotspotsFile = option('--hotspots');
|
||||
const runtimePreparation = process.argv.includes('--runtime-preparation');
|
||||
const sampleMs = Number(option('--sample-ms') || 0);
|
||||
if (!Number.isFinite(sampleMs) || sampleMs < 0 || sampleMs > 10_000) {
|
||||
throw new Error('--sample-ms must be between 0 and 10000.');
|
||||
}
|
||||
const label = override ? path.basename(override, '.glb') : 'shipping';
|
||||
const output = path.resolve(option('--output-dir')
|
||||
|| `artifacts/playtest/wow-environment-quality/continuation/${id}-${label}${runtimePreparation ? '-runtime-prepared' : ''}`);
|
||||
const hotspots = hotspotsFile ? JSON.parse(fs.readFileSync(hotspotsFile, 'utf8')) : [];
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const params = new URLSearchParams({ dungeon: id });
|
||||
if (override) params.set('visualOverride', override);
|
||||
if (runtimePreparation) params.set('runtimePreparation', '1');
|
||||
const baseUrl = process.env.WOW_ENVIRONMENT_REVIEW_URL || 'http://127.0.0.1:4208';
|
||||
try {
|
||||
await page.goto(`${baseUrl}/scripts/dungeon-pipeline/environment-preview.html?${params}`, {
|
||||
waitUntil: 'domcontentloaded', timeout: 180_000,
|
||||
});
|
||||
await page.waitForFunction(() => window.wowEnvironmentReview?.ready, null, { timeout: 180_000 });
|
||||
if (runtimePreparation && !(await page.evaluate(() => window.wowEnvironmentReview.diagnostics().runtimePreparation === true))) {
|
||||
throw new Error('The preview renderer is stale; restart the preview server before runtime-prepared checks.');
|
||||
}
|
||||
await page.evaluate(() => window.wowEnvironmentReview.setQuality('high'));
|
||||
const ground = await page.evaluate(() => window.wowEnvironmentReview.groundSamples());
|
||||
const viewpoints = await page.evaluate(() => window.wowEnvironmentReview.viewpoints);
|
||||
const captures = [];
|
||||
async function diagnostics() {
|
||||
if (sampleMs) {
|
||||
await page.evaluate(() => window.wowEnvironmentReview.resetFrameSample());
|
||||
await page.waitForTimeout(sampleMs);
|
||||
}
|
||||
return page.evaluate((sampled) => ({ ...window.wowEnvironmentReview.diagnostics(),
|
||||
...(sampled ? { frameSample: window.wowEnvironmentReview.frameSample() } : {}) }), Boolean(sampleMs));
|
||||
}
|
||||
const indices = [...new Set([0, Math.floor((viewpoints.length - 1) / 2), viewpoints.length - 1])];
|
||||
for (const index of indices) {
|
||||
for (const [heading, yaw] of [['forward', 0], ['side', Math.PI / 2], ['reverse', Math.PI]]) {
|
||||
await page.evaluate(({ index, yaw }) => window.wowEnvironmentReview.view(index, yaw), { index, yaw });
|
||||
await page.waitForTimeout(500);
|
||||
const file = `view-${index}-${heading}.png`;
|
||||
await page.screenshot({ path: path.join(output, file) });
|
||||
captures.push({ name: viewpoints[index].name, heading, file, diagnostics: await diagnostics() });
|
||||
}
|
||||
}
|
||||
for (const [index, hotspot] of hotspots.entries()) {
|
||||
if (!Array.isArray(hotspot.eye) || !Array.isArray(hotspot.target)
|
||||
|| [...hotspot.eye, ...hotspot.target].length !== 6
|
||||
|| ![...hotspot.eye, ...hotspot.target].every(Number.isFinite)) {
|
||||
throw new Error(`Hotspot ${index} requires finite eye and target triples.`);
|
||||
}
|
||||
await page.evaluate(({ eye, target }) => window.wowEnvironmentReview.setCamera(eye, target), hotspot);
|
||||
await page.waitForTimeout(500);
|
||||
const file = `hotspot-${index}.png`;
|
||||
await page.screenshot({ path: path.join(output, file) });
|
||||
captures.push({ ...hotspot, file, diagnostics: await diagnostics() });
|
||||
}
|
||||
const result = { scope: 'targeted isolated asset review; no gameplay certification', id, override,
|
||||
runtimePreparation, sampleMs, errors, ground, captures };
|
||||
fs.writeFileSync(path.join(output, 'results.json'), `${JSON.stringify(result, null, 2)}\n`);
|
||||
if (errors.length) throw new Error(errors.join('\n'));
|
||||
console.log(JSON.stringify({ id, override, output, captures: captures.length, errors }));
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: path.join(output, 'failure.png') }).catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -28,6 +28,7 @@ import { listRunewakerDerivedSupportStylingPolicies } from "./lib/rom-derived-su
|
||||
import { sourceAnchorSupportGeometry } from "./lib/source-anchor-support.mjs";
|
||||
import { writeBoundedCollisionGlbs } from "./lib/bounded-collision-glb.mjs";
|
||||
import { validateNativeAlphaResourceCache } from "./lib/native-alpha-cache-validation.mjs";
|
||||
import { sourceNavigationAnchors } from "./lib/source-navigation-anchors.mjs";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const fallbackRecipe = path.join(pipelineDirectory, "recipes", "forsaken-abbey.json");
|
||||
@@ -889,11 +890,15 @@ async function bakeNavigation() {
|
||||
}));
|
||||
const navigationFile = path.join(convertedDirectory, slug + "-navigation.glb");
|
||||
const reportFile = path.join(convertedDirectory, "recast-report.json");
|
||||
const detourAnchorPositions = recipe.navigation.sourceAnchorComponents
|
||||
? sourceNavigationAnchors(recipe, (await primaryPlacement()).placement)
|
||||
: [];
|
||||
await bakeNavigationFromCollisionAssets({
|
||||
slug,
|
||||
collisionAssets,
|
||||
settings: recipe.navigation,
|
||||
includeDetourTopology: recipe.navigation.includeDetourTopology === true,
|
||||
detourAnchorPositions,
|
||||
output: navigationFile,
|
||||
reportFile,
|
||||
source: {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Quaternion, Vector3 } from "three";
|
||||
|
||||
export function sourceNavigationAnchors(recipe, placement) {
|
||||
const policy = recipe.navigation?.sourceAnchorComponents;
|
||||
if (!policy) return [];
|
||||
if (policy.kind !== "native-source-anchor-components-v1"
|
||||
|| recipe.navigation.includeDetourTopology !== true) {
|
||||
throw new Error("Source anchor components require the supported policy and Detour topology.");
|
||||
}
|
||||
const finiteVector = (values, length) => Array.isArray(values)
|
||||
&& values.length === length && values.every(Number.isFinite);
|
||||
if (!finiteVector(placement.translation, 3) || !finiteVector(placement.rotation, 4)
|
||||
|| !finiteVector(placement.scale, 3) || placement.scale.some(value => value === 0)
|
||||
|| !Number.isFinite(recipe.coordinateSystem.metersPerSourceUnit)
|
||||
|| recipe.coordinateSystem.metersPerSourceUnit <= 0) {
|
||||
throw new Error("Source navigation anchors require a finite invertible primary WDB placement.");
|
||||
}
|
||||
const transform = sourcePosition => {
|
||||
if (!finiteVector(sourcePosition, 3)) throw new Error("Source navigation anchor requires finite XYZ coordinates.");
|
||||
const point = new Vector3(...sourcePosition).sub(new Vector3(...placement.translation))
|
||||
.applyQuaternion(new Quaternion(...placement.rotation).invert())
|
||||
.divide(new Vector3(...placement.scale));
|
||||
const meters = recipe.coordinateSystem.metersPerSourceUnit;
|
||||
return [point.x * meters, point.y * meters, -point.z * meters];
|
||||
};
|
||||
if (!Array.isArray(recipe.source.sourceAnchorSupport?.points)
|
||||
|| !Number.isFinite(policy.maximumProjectionDistance)
|
||||
|| policy.maximumProjectionDistance <= 0
|
||||
|| !Array.isArray(policy.requiredSourcePoints) || !policy.requiredSourcePoints.length) {
|
||||
throw new Error("Source navigation selection needs native anchor points, a projection limit and required source guards.");
|
||||
}
|
||||
const anchors = recipe.source.sourceAnchorSupport.points.map((sourcePosition, index) => ({
|
||||
id: `native-source-anchor-${index}`, kind: "native-source-anchor",
|
||||
sourcePosition: [...sourcePosition], position: transform(sourcePosition),
|
||||
maximumDistance: policy.maximumProjectionDistance,
|
||||
}));
|
||||
for (const guard of policy.requiredSourcePoints) {
|
||||
if (typeof guard.id !== "string" || !guard.id.trim() || !guard.evidence
|
||||
|| !Number.isFinite(guard.maximumDistance) || guard.maximumDistance <= 0) {
|
||||
throw new Error("Required source navigation guards need an ID, evidence and a projection limit.");
|
||||
}
|
||||
if (guard.kind === "source-entry"
|
||||
&& JSON.stringify(guard.sourcePosition) !== JSON.stringify(recipe.source.entryCoordinates?.position)) {
|
||||
throw new Error("The navigation entry guard must match the authoritative source entry.");
|
||||
}
|
||||
anchors.push({
|
||||
...guard, sourcePosition: [...guard.sourcePosition],
|
||||
position: transform(guard.sourcePosition), required: true,
|
||||
});
|
||||
}
|
||||
if (new Set(anchors.map(anchor => anchor.id)).size !== anchors.length) {
|
||||
throw new Error("Source navigation anchor IDs must be unique.");
|
||||
}
|
||||
return anchors;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { sourceNavigationAnchors } from "./source-navigation-anchors.mjs";
|
||||
|
||||
test("Raksha anchor policy reproduces source entrance and boss positions with strict guards", async () => {
|
||||
const recipe = JSON.parse(await readFile(new URL("../recipes/raksha-temple.json", import.meta.url), "utf8"));
|
||||
const anchors = sourceNavigationAnchors(recipe, {
|
||||
translation: [1857.20312, 86.8668671, 1274.34656], rotation: [0, 0, 0, 1], scale: [1, 1, 1],
|
||||
});
|
||||
const entry = anchors.find(anchor => anchor.id === "lua-entry");
|
||||
const boss = anchors.find(anchor => anchor.id === "shadoj-source-spawn");
|
||||
assert.ok(Math.abs(entry.position[1] - 21.01331329) < 1e-8);
|
||||
assert.ok(Math.abs(entry.position[2] - 11.334656) < 1e-8);
|
||||
assert.ok(Math.abs(boss.position[0] + 115.04822948) < 1e-8);
|
||||
assert.equal(entry.maximumDistance, 0.5);
|
||||
assert.equal(boss.maximumDistance, 0.25);
|
||||
assert.equal(entry.required, true);
|
||||
assert.equal(boss.required, true);
|
||||
const altered = structuredClone(recipe);
|
||||
altered.source.entryCoordinates.position[1] += 1;
|
||||
assert.throws(() => sourceNavigationAnchors(altered, {
|
||||
translation: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1],
|
||||
}), /must match the authoritative source entry/);
|
||||
});
|
||||
|
||||
test("source anchor conversion includes inverse parent rotation, scale and handedness", () => {
|
||||
const recipe = {
|
||||
coordinateSystem: { metersPerSourceUnit: 0.1 },
|
||||
source: { sourceAnchorSupport: { points: [[14, 8, 18]] } },
|
||||
navigation: { includeDetourTopology: true, sourceAnchorComponents: {
|
||||
kind: "native-source-anchor-components-v1", maximumProjectionDistance: 4,
|
||||
requiredSourcePoints: [{ id: "guard", sourcePosition: [14, 8, 18], maximumDistance: 0.5, evidence: "fixture" }],
|
||||
} },
|
||||
};
|
||||
const anchors = sourceNavigationAnchors(recipe, {
|
||||
translation: [10, 2, 20], rotation: [0, Math.SQRT1_2, 0, Math.SQRT1_2], scale: [2, 3, 4],
|
||||
});
|
||||
for (const [axis, expected] of [0.1, 0.2, -0.1].entries()) {
|
||||
assert.ok(Math.abs(anchors[0].position[axis] - expected) < 1e-8);
|
||||
}
|
||||
});
|
||||
@@ -689,6 +689,27 @@
|
||||
"agentRadius": 0.36,
|
||||
"agentMaxClimb": 0.5,
|
||||
"agentMaxSlope": 50,
|
||||
"includeDetourTopology": true,
|
||||
"sourceAnchorComponents": {
|
||||
"kind": "native-source-anchor-components-v1",
|
||||
"maximumProjectionDistance": 4,
|
||||
"requiredSourcePoints": [
|
||||
{
|
||||
"id": "lua-entry",
|
||||
"kind": "source-entry",
|
||||
"sourcePosition": [1857, 297, 1161],
|
||||
"maximumDistance": 0.5,
|
||||
"evidence": "Resource/luascript/03246.lua:MS_TEST_DUNGEONS"
|
||||
},
|
||||
{
|
||||
"id": "shadoj-source-spawn",
|
||||
"kind": "native-boss",
|
||||
"sourcePosition": [706.7208251953125, 186.57839965820312, -1572.79296875],
|
||||
"maximumDistance": 0.25,
|
||||
"evidence": "ROM_Global.dbo.NPCData source spawn 41884920; raksha-temple-zone-130 snapshot"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tileSize": 64,
|
||||
"retainTileIntermediates": false
|
||||
},
|
||||
|
||||
@@ -130,11 +130,11 @@
|
||||
11.334656
|
||||
],
|
||||
"footPosition": [
|
||||
0.021786,
|
||||
10.664467,
|
||||
17.560761
|
||||
-0.020312,
|
||||
20.837463,
|
||||
11.334656
|
||||
],
|
||||
"navmeshDistance": 12.077449,
|
||||
"navmeshDistance": 0.17585,
|
||||
"yaw": 6.108652382,
|
||||
"forward": [
|
||||
-0.173648178,
|
||||
@@ -205,14 +205,14 @@
|
||||
"role": "navigation",
|
||||
"fileName": "raksha-temple-navigation.glb",
|
||||
"relativeFile": "raksha-temple-navigation.glb",
|
||||
"size": 8686564,
|
||||
"sha256": "1e1d1ad26907dc8c4fd0a917dba40b52d2aaaea4a0335af06871ee28230eddf1"
|
||||
"size": 166048,
|
||||
"sha256": "77b2b22bb89474bfe2bd9974d9f58cd680634d4f7feef6163723bb8f3afe6117"
|
||||
}
|
||||
],
|
||||
"review": {
|
||||
"environment": "authoritative-wdb-largest-model-selected",
|
||||
"collision": "full-wdb-static-ros-provisional-review-required",
|
||||
"entrance": "source-script",
|
||||
"entrance": "lua-source-entry-on-source-anchored-navigation; objective-route-review-required",
|
||||
"bossAnchors": "source-population",
|
||||
"wdbPlacements": "all-static-ros-source-transform"
|
||||
},
|
||||
@@ -928,6 +928,68 @@
|
||||
"decodedAlphaMean": 51.5479736328125
|
||||
}
|
||||
]
|
||||
},
|
||||
"rakshaSourceAnchoredNavigationCandidate": {
|
||||
"report": "artifacts/playtest/rom-residual-review-20261006/raksha-route/source-anchored-v2/raksha-temple-navigation-source-anchored-candidate-report.json",
|
||||
"proof": "artifacts/playtest/rom-residual-review-20261006/raksha-route/source-anchored-v2/source-anchored-navigation-preservation-proof.json",
|
||||
"currentNavigationSha256": "1e1d1ad26907dc8c4fd0a917dba40b52d2aaaea4a0335af06871ee28230eddf1",
|
||||
"candidateNavigationSha256": "77b2b22bb89474bfe2bd9974d9f58cd680634d4f7feef6163723bb8f3afe6117",
|
||||
"candidateNavigationBytes": 166048,
|
||||
"anchorCount": 128,
|
||||
"selectedComponentCount": 6,
|
||||
"selectedPolygonCount": 239,
|
||||
"selectedDirectedLinkCount": 514,
|
||||
"selectedDetailTriangleCount": 653,
|
||||
"entryToShadoj": {
|
||||
"id": "lua-entry-to-shadoj-source-anchor",
|
||||
"from": "Lua authored entry",
|
||||
"to": "source snapshot Shadoj spawn",
|
||||
"start": {
|
||||
"found": true,
|
||||
"polygonRef": 5296200,
|
||||
"point": [
|
||||
-0.020312000066041946,
|
||||
20.83746337890625,
|
||||
11.33465576171875
|
||||
],
|
||||
"distance": 0.17584991109391424,
|
||||
"maximumDistance": 0.5
|
||||
},
|
||||
"end": {
|
||||
"found": true,
|
||||
"polygonRef": 6069251,
|
||||
"point": [
|
||||
-115.04823303222656,
|
||||
9.937469482421875,
|
||||
284.7139587402344
|
||||
],
|
||||
"distance": 0.033683774096340706,
|
||||
"maximumDistance": 0.25
|
||||
},
|
||||
"reachable": false,
|
||||
"partial": true,
|
||||
"status": 1073741888,
|
||||
"polygonCount": 16,
|
||||
"polygonRefs": [
|
||||
5296200,
|
||||
5296143,
|
||||
5296142,
|
||||
5351427,
|
||||
5351428,
|
||||
5351429,
|
||||
5407751,
|
||||
5407752,
|
||||
5464078,
|
||||
5464079,
|
||||
5519389,
|
||||
5519390,
|
||||
5574705,
|
||||
5574706,
|
||||
5627909,
|
||||
5628928
|
||||
]
|
||||
},
|
||||
"status": "source-anchor-subset-promoted; full-objective-routing-unverified"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because it is too large.
Load diff
@@ -938,9 +938,14 @@ function mergeWow335ClassicAbilities(
|
||||
: ability;
|
||||
}
|
||||
const [updated] = remaining.splice(importedIndex, 1);
|
||||
// The selected source Kick is pure interruption. The legacy prototype's
|
||||
// damage and second interrupt cannot become effects of that sourced row.
|
||||
const sourceOnlyKick = ability.id === "rogue-kick" && updated.id === "wow335-rogue-kick"
|
||||
&& updated.classId === "rogue" && updated.source === "wow335-progression"
|
||||
&& updated.dbcSpellId === 1766;
|
||||
const ranks = updated.ranks?.map((rank) => ({
|
||||
...rank,
|
||||
effects: mergeCoreEffects(rank.effects, ability.effects),
|
||||
effects: sourceOnlyKick ? rank.effects : mergeCoreEffects(rank.effects, ability.effects),
|
||||
}));
|
||||
return {
|
||||
...updated,
|
||||
@@ -950,7 +955,7 @@ function mergeWow335ClassicAbilities(
|
||||
range: ability.range,
|
||||
...(ability.radius !== undefined ? { radius: ability.radius } : {}),
|
||||
...(ability.cooldownGroup !== undefined ? { cooldownGroup: ability.cooldownGroup } : {}),
|
||||
effects: mergeCoreEffects(updated.effects, ability.effects),
|
||||
effects: sourceOnlyKick ? updated.effects : mergeCoreEffects(updated.effects, ability.effects),
|
||||
ranks,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deflectLandedCombatResult, resolveCombatResult } from "./combatResults";
|
||||
|
||||
function attack(overrides: Parameters<typeof resolveCombatResult>[0]["rules"] = {}, roll = 0.5) {
|
||||
return resolveCombatResult({ amount: 100, attackKind: "spell", damageSchool: "frost",
|
||||
attacker: { level: 80, criticalChancePercent: 100 },
|
||||
defender: { level: 80, resistances: { frost: 400 } },
|
||||
rules: { canMiss: false, resistanceMode: "partial", ...overrides }, roll: () => roll });
|
||||
}
|
||||
|
||||
describe("guaranteed source deflection after the existing hit gate", () => {
|
||||
it("discards landed critical/partial-resist damage without reporting absorb, block or crit", () => {
|
||||
const landed = attack();
|
||||
expect(landed).toMatchObject({ landed: true, critical: true, partiallyResisted: true });
|
||||
const result = deflectLandedCombatResult(landed);
|
||||
expect(result).toMatchObject({ outcome: "deflect", deflected: true, landed: false,
|
||||
avoided: true, rawDamage: 100, finalDamage: 0, blocked: false, blockedAmount: 0,
|
||||
critical: false, resistedAmount: 0, fullyResisted: false, partiallyResisted: false });
|
||||
});
|
||||
it.each(["miss", "resist"] as const)("preserves an earlier %s and its original evidence", (outcome) => {
|
||||
const prior = outcome === "miss"
|
||||
? attack({ canMiss: true, baseMissChancePercent: 100 })
|
||||
: attack({ resistanceMode: "binary" }, 0);
|
||||
expect(prior.outcome).toBe(outcome);
|
||||
expect(deflectLandedCombatResult(prior)).toBe(prior);
|
||||
});
|
||||
it("removes block-roll evidence from the discarded ranged spell packet", () => {
|
||||
const blocked = resolveCombatResult({ amount: 100, attackKind: "ranged", damageSchool: "physical",
|
||||
attacker: { level: 80 }, defender: { level: 80, blockPercent: 100, blockValue: 20,
|
||||
criticalBlockChancePercent: 100 },
|
||||
rules: { canMiss: false, canCrit: false, blockMode: "post-hit" }, roll: () => 0.5 });
|
||||
expect(blocked).toMatchObject({ landed: true, blocked: true, criticalBlocked: true });
|
||||
const deflected = deflectLandedCombatResult(blocked);
|
||||
expect(deflected).toMatchObject({ outcome: "deflect", blocked: false, finalDamage: 0 });
|
||||
expect(deflected.postHitBlockRoll).toBeUndefined();
|
||||
expect(deflected.criticalBlockRoll).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { armorReductionPercent, type DamageSchool } from "./itemStats";
|
||||
import type { CharacterStats, WotlkAttackKind } from "./wotlkStats";
|
||||
|
||||
export type CombatAttackKind = WotlkAttackKind;
|
||||
export type CombatOutcome = "hit" | "crit" | "miss" | "dodge" | "parry" | "block" | "resist";
|
||||
export type CombatOutcome = "hit" | "crit" | "miss" | "dodge" | "parry" | "block" | "resist" | "deflect";
|
||||
export type CombatResistanceMode = "none" | "partial" | "binary";
|
||||
export type CombatRollStage = "outcome" | "resistance" | "block" | "critical-block";
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface CombatResult {
|
||||
readonly parried: boolean;
|
||||
readonly blocked: boolean;
|
||||
readonly avoided: boolean;
|
||||
/** Exact source spell deflection avoids the packet rather than absorbing it. */
|
||||
readonly deflected?: boolean;
|
||||
readonly fullyResisted: boolean;
|
||||
readonly partiallyResisted: boolean;
|
||||
readonly rawDamage: number;
|
||||
@@ -110,6 +112,41 @@ export interface CombatResult {
|
||||
readonly criticalBlocked?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact 100% source deflection is applied after the existing miss/full-resist
|
||||
* result, before any damage or proc is committed. It cannot replace avoidance.
|
||||
* Partial-resist and block calculations on the discarded packet are not damage.
|
||||
*/
|
||||
export function deflectLandedCombatResult(result: CombatResult): CombatResult {
|
||||
if (!result.landed || result.avoided || result.fullyResisted) return result;
|
||||
const { postHitBlockRoll: _blockRoll, postHitBlockChancePercent: _blockChance,
|
||||
criticalBlockRoll: _criticalBlockRoll, criticalBlockChancePercent: _criticalBlockChance,
|
||||
criticalBlocked: _criticalBlocked, ...retained } = result;
|
||||
return Object.freeze({
|
||||
...retained,
|
||||
outcome: "deflect",
|
||||
landed: false,
|
||||
critical: false,
|
||||
missed: false,
|
||||
dodged: false,
|
||||
parried: false,
|
||||
blocked: false,
|
||||
avoided: true,
|
||||
deflected: true,
|
||||
fullyResisted: false,
|
||||
partiallyResisted: false,
|
||||
damageBeforeMitigation: 0,
|
||||
effectiveArmor: 0,
|
||||
armorReductionPercent: 0,
|
||||
armorMitigatedAmount: 0,
|
||||
blockedAmount: 0,
|
||||
resistedAmount: 0,
|
||||
damageReductionAmount: 0,
|
||||
finalDamage: 0,
|
||||
resistanceFraction: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const DEFAULT_CRITICAL_DAMAGE_MULTIPLIER = 2;
|
||||
const DUAL_WIELD_AUTO_ATTACK_MISS_PENALTY = 19;
|
||||
const PARTIAL_RESIST_STEP = 0.1;
|
||||
|
||||
@@ -175,6 +175,7 @@ const DEFENSIVE_COOLDOWN_NAMES = new Set([
|
||||
"Shield Block", "Last Stand", "Shield Wall", "Enraged Regeneration",
|
||||
"Pain Suppression", "Ice Block", "Fire Ward", "Frost Ward",
|
||||
"Evasion", "Cloak of Shadows", "Vanish",
|
||||
"Deterrence",
|
||||
]);
|
||||
|
||||
const THREAT_REDIRECT_NAMES = new Set(["Misdirection", "Tricks of the Trade"]);
|
||||
|
||||
+201
-29
@@ -1,3 +1,4 @@
|
||||
import { healerManaRecoveryAmount, synchronizeHealerManaRecovery } from "./healerManaRecovery";
|
||||
import { nativeDamageCasterAmount, nativeHolyHealCasterAmount, nativeHolyHealTargetAmount, nativePeriodicHealSnapshot, nativePeriodicDamageCasterSnapshot, nativeTrainingAutoAttackProfile, nativeDisarmedMainHandAutoAttackProfile, matchesNativeTrainingGear, type RomNativeDamageTargetContext } from "./romNativeCombatAmount";
|
||||
import { romNativeNpcCombatant, romNativeTrainingCombatant, romNativePlayerHitProfile, romNativeNpcHitProfile, resolveRomNativeCombatResult,
|
||||
nativeMagicInputsForType, type RomNativeCombatantSnapshot, type RomNativeHitProfile } from "./romNativeCombatResolution";
|
||||
@@ -13,14 +14,19 @@ import { combatNow } from "./combatClock";
|
||||
import { selectEnemyChainTargets } from "./enemyChainTargeting";
|
||||
import { mergePassiveAuraResourceModifiers } from "./classPassiveResourceModifiers";
|
||||
import { enemyPeriodicDamageSource, enemyPeriodicDamageTickAmount } from "./enemyPeriodicDamage";
|
||||
import { enemyDamageTypeSourceForSpell } from "./mobDamageTypes";
|
||||
import { applyEnemySourceAura, enemySourceAuraContract, supportedEnemySourceAuraDefinition, removeEnemyActorAuras } from "./enemySourceAuras";
|
||||
import { enemyBlockAuraContract, ordinaryCreatureSelfTargetCriticalBlockAuraDefinition,
|
||||
resolveEnemyNpcBlockAuraInputs } from "./enemyBlockAuras";
|
||||
import { enemyUtilitySpellBlockDisposition, enemyUtilitySpellBlockSourceContract } from "./enemyUtilitySpellBlocking";
|
||||
import { enemyUtilitySpellBlockAbilityContract, enemyUtilitySpellBlockDisposition } from "./enemyUtilitySpellBlocking";
|
||||
import { isWow335ShieldBashDazeParent, wow335ShieldBashDazeTargetEligible } from "./wow335ShieldBashDaze";
|
||||
import { sourceFearSummonPosition } from "./enemyFearSummonMovement";
|
||||
import { enemyControlAbilityCanEscapeAuras, enemyControlImmunityPurgeProofForAbility,
|
||||
purgeNamedEnemyControlShoutAuras } from "./enemyControlImmunityPurge";
|
||||
import { WOW335_HUNTER_DETERRENCE_ABILITY_ID, WOW335_DETERRENCE_PARENT_AURA_IDS,
|
||||
WOW335_DETERRENCE_PACKAGE_AURA_IDS, WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS,
|
||||
wow335DeterrenceAbilityContract, wow335DeterrenceActivePackage,
|
||||
resolveWow335DeterrenceIncoming } from "./wow335DeterrenceRuntime";
|
||||
import { enemyTriggeredShoutContract, planEnemyTriggeredShoutTargets,
|
||||
enemyTriggeredShoutControlTag, enemyTriggeredShoutMovementSpeedEffect,
|
||||
resolveEnemyTriggeredShoutControl } from "./enemyTriggeredShouts";
|
||||
@@ -43,6 +49,7 @@ import {
|
||||
ENEMY_DISARM_UNARMED_MAIN_HAND,
|
||||
enemyDisarmBlocksAbilityCast,
|
||||
enemyDisarmRequirementCandidates,
|
||||
enemySpellHasMatchingEquippedItem,
|
||||
activeEnemyDisarmChannels,
|
||||
enemyDisarmItemDependentAuraSuppressionIds,
|
||||
enemyDisarmAurasForWeaponChannel,
|
||||
@@ -120,6 +127,7 @@ import {
|
||||
type DeathKnightRuneState,
|
||||
} from "./combatActors";
|
||||
import {
|
||||
companionResourceMaximum,
|
||||
companionActorFromMember,
|
||||
companionActorWithMemberRuntime,
|
||||
memberFromCompanionActor,
|
||||
@@ -750,7 +758,7 @@ import {
|
||||
rogueInstantPoisonDamage,
|
||||
roguePoisonApplicationChances,
|
||||
} from "./roguePoison";
|
||||
import { hunterExplosiveTrapDirectScaling } from "./wow335AbilityCatalog";
|
||||
import { hunterExplosiveTrapDirectScaling, wow335HunterPreventionTypeForAbility } from "./wow335AbilityCatalog";
|
||||
import {
|
||||
wow335ShamanImprovedWaterShieldCappedRestore,
|
||||
wow335ShamanImprovedWaterShieldShouldProc,
|
||||
@@ -944,6 +952,7 @@ import {
|
||||
baseMissChancePercent,
|
||||
combatAttackerFromCharacterStats,
|
||||
combatDefenderFromCharacterStats,
|
||||
deflectLandedCombatResult,
|
||||
resolveCombatResult,
|
||||
type CombatAttackerStats,
|
||||
type CombatDefenderStats,
|
||||
@@ -1053,6 +1062,7 @@ export interface GameplaySettings extends GraphicsSettings {
|
||||
readonly showOwnDamageText: boolean;
|
||||
readonly showAllyDamageText: boolean;
|
||||
readonly showIncomingDamageText: boolean;
|
||||
readonly healerManaRecovery: boolean;
|
||||
readonly autoTargetNearest: boolean;
|
||||
readonly enableScreenShake: boolean;
|
||||
readonly masterVolume: number;
|
||||
@@ -1073,6 +1083,7 @@ export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
|
||||
showOwnDamageText: true,
|
||||
showAllyDamageText: false,
|
||||
showIncomingDamageText: false,
|
||||
healerManaRecovery: true,
|
||||
autoTargetNearest: true,
|
||||
enableScreenShake: true,
|
||||
masterVolume: 0,
|
||||
@@ -3117,7 +3128,7 @@ function resolveNpcUtilityBlockResult(
|
||||
): CombatResult | null {
|
||||
const mob = state.mobs[targetId];
|
||||
const raw = ability.wowSpellDefense;
|
||||
const contract = enemyUtilitySpellBlockSourceContract(ability.dbcSpellId, raw?.sourceProfile);
|
||||
const contract = enemyUtilitySpellBlockAbilityContract(ability);
|
||||
const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId);
|
||||
const casterKind = sourceActorId === PLAYER_AGGRO_ID ? "player" : member ? "party" : "unknown";
|
||||
if (!contract || !raw || ability.source === "runewaker" || sourceStats.romNative
|
||||
@@ -3503,6 +3514,7 @@ function combatResultDescription(result: CombatResult): string {
|
||||
if (result.outcome === "dodge") return "was dodged";
|
||||
if (result.outcome === "parry") return "was parried";
|
||||
if (result.outcome === "resist") return "was resisted";
|
||||
if (result.outcome === "deflect") return "was deflected";
|
||||
if (result.outcome === "block") return `was blocked for ${result.blockedAmount}`;
|
||||
if (result.outcome === "crit") return `critically hit for ${result.finalDamage}`
|
||||
+ (result.blocked ? ` (${result.blockedAmount} blocked)` : "");
|
||||
@@ -3730,8 +3742,30 @@ function resolveEnemyDamageResult(
|
||||
nativeSourceDirect?: RomNativeEnemyDirectDamageValue,
|
||||
targetOrdinal = 0,
|
||||
applicationOnly = false,
|
||||
defenderStunnedAtImpact?: boolean,
|
||||
): CombatResult {
|
||||
const attackKind = enemyAttackKind(attack);
|
||||
const currentState = useCombatStore.getState();
|
||||
const deterrenceAbility = deterrenceAbilityForActor(currentState, actorId);
|
||||
const defenderStunned = defenderStunnedAtImpact ?? actorIsStunned(currentState, actorId, now);
|
||||
const deterrencePackage = deterrenceAbility && wow335DeterrenceActivePackage({
|
||||
ability: deterrenceAbility, auras: currentState.auras, defenderId: actorId, nowMs: now,
|
||||
});
|
||||
const hitSource = attack.damageOrigin === "wow335" && attack.spellId !== undefined
|
||||
? enemyDamageTypeSourceForSpell("wow335", attack.spellId) : undefined;
|
||||
const sourceFlags = hitSource && "hitSource" in hitSource && hitSource.hitSource && attack.wowHitSource
|
||||
&& hitSource.damageClass === attack.damageClass && hitSource.schoolMask === attack.schoolMask
|
||||
&& hitSource.hitSource.attributes0 === attack.wowHitSource.attributes0
|
||||
&& hitSource.hitSource.attributes3 === attack.wowHitSource.attributes3
|
||||
&& hitSource.hitSource.attributes7 === attack.wowHitSource.attributes7
|
||||
? hitSource.hitSource : undefined;
|
||||
const sourceAlwaysHit = Boolean(sourceFlags && (sourceFlags.attributes3 & 0x00040000) !== 0);
|
||||
const sourceNoAttackMiss = Boolean(sourceFlags && [1, 2, 3].includes(attack.damageClass!)
|
||||
&& (sourceFlags.attributes7 & 0x02000000) !== 0);
|
||||
const sourceNoActiveDefense = Boolean(sourceFlags && (attack.damageClass === 2 || attack.damageClass === 3)
|
||||
&& (sourceFlags.attributes0 & 0x00200000) !== 0);
|
||||
const sourceCannotDodge = Boolean(sourceFlags && (sourceFlags.attributes7 & 0x00800000) !== 0);
|
||||
const sourceCannotParry = Boolean(sourceFlags && (sourceFlags.attributes7 & 0x01000000) !== 0);
|
||||
const incomingModifier = (stat: string) => defenderAuras.reduce((total, aura) => total
|
||||
+ (aura.definition.modifiers ?? []).reduce((sum, modifier) => modifier.kind === "stat"
|
||||
&& modifier.stat === stat && modifier.operation === "flat"
|
||||
@@ -3795,7 +3829,7 @@ function resolveEnemyDamageResult(
|
||||
const preMitigationAmount = amount > 0 ? resolveAuraDamageValue(amount, defenderAuras, {
|
||||
direction: "taken", school: attack.school, attackKind, phase: "pre-mitigation",
|
||||
}) : amount;
|
||||
return resolveCombatResult({
|
||||
const result = resolveCombatResult({
|
||||
amount: preMitigationAmount,
|
||||
attackKind,
|
||||
damageSchool: attack.school,
|
||||
@@ -3809,11 +3843,15 @@ function resolveEnemyDamageResult(
|
||||
},
|
||||
rules: {
|
||||
isAutoAttack: !attack.periodicTick && attack.animation === "attack" && (attack.castTimeMs ?? 0) === 0,
|
||||
canMiss: !attack.periodicTick && !nativeFallbackGatePassed,
|
||||
canMiss: !attack.periodicTick && !nativeFallbackGatePassed && !sourceAlwaysHit && !sourceNoAttackMiss,
|
||||
canCrit: !attack.periodicTick && !applicationOnly,
|
||||
canDodge: !attack.periodicTick && !nativeFallbackGatePassed && attackKind === "melee",
|
||||
canParry: !attack.periodicTick && attackKind === "melee",
|
||||
canBlock: !attack.periodicTick && !applicationOnly && attackKind !== "spell",
|
||||
canDodge: !attack.periodicTick && !nativeFallbackGatePassed && attackKind === "melee"
|
||||
&& !sourceAlwaysHit && !sourceNoActiveDefense && !sourceCannotDodge,
|
||||
canParry: !attack.periodicTick && attackKind === "melee"
|
||||
&& !(deterrencePackage && defenderStunned)
|
||||
&& !sourceAlwaysHit && !sourceNoActiveDefense && !sourceCannotParry,
|
||||
canBlock: !attack.periodicTick && !applicationOnly && attackKind !== "spell"
|
||||
&& !sourceAlwaysHit && !sourceNoActiveDefense,
|
||||
...(attack.targetMitigationSnapshotted ? { ignoreArmor: true, resistanceMode: "none" as const } : {}),
|
||||
...(attack.periodicTick && attack.periodicIgnoresArmor ? { ignoreArmor: true } : {}),
|
||||
baseMissChancePercent: baseMissChancePercent(attackKind, mob.level, defender.level)
|
||||
@@ -3821,6 +3859,80 @@ function resolveEnemyDamageResult(
|
||||
},
|
||||
roll: stableCombatRoll(`${mob.id}:${attack.id}:${actorId}:${attackRevision}:${now}`),
|
||||
});
|
||||
if (!deterrenceAbility || !result.landed || result.outcome === "deflect") return result;
|
||||
const deflection = resolveWow335DeterrenceIncoming({
|
||||
ability: deterrenceAbility, auras: currentState.auras, defenderId: actorId, nowMs: now,
|
||||
incoming: { ...attack, attackKind,
|
||||
isAutoAttack: isEnemyWeaponSwing(attack) },
|
||||
baseOutcome: result.outcome, defenderStunned,
|
||||
// The verified linked Aura288 makes the complete package facing-independent.
|
||||
attackerInFront: false,
|
||||
});
|
||||
return deflection.outcome === "deflect" ? deflectLandedCombatResult(result) : result;
|
||||
}
|
||||
|
||||
function deterrenceAbilityForActor(state: CombatState, actorId: string): AbilityDefinition | undefined {
|
||||
const owner = actorId === PLAYER_AURA_ENTITY_ID
|
||||
? state : usePartyStore.getState().members.find((member) => member.id === actorId);
|
||||
if (!owner || owner.classId !== "hunter") return undefined;
|
||||
const ability = abilityById(WOW335_HUNTER_DETERRENCE_ABILITY_ID);
|
||||
return ability && isAbilityUnlocked(ability, owner.level) && wow335DeterrenceAbilityContract(ability)
|
||||
? abilityAtLevel(ability, owner.level) : undefined;
|
||||
}
|
||||
|
||||
function actorIsStunned(state: CombatState, actorId: string, now: number): boolean {
|
||||
const owner = actorId === PLAYER_AURA_ENTITY_ID
|
||||
? state : usePartyStore.getState().members.find((member) => member.id === actorId);
|
||||
if (owner && owner.controlledUntil > now && owner.controlMechanic === "stun") return true;
|
||||
return state.auras.some((aura) => aura.targetId === actorId
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||
&& (aura.definition.utilityTags?.includes("enemy-control-source:stun")
|
||||
|| (aura.definition.id.startsWith("enemy-control:") && aura.definition.id.endsWith(":stun"))));
|
||||
}
|
||||
|
||||
function deterrencePreventsIncomingSpellEffects(
|
||||
state: CombatState, actorId: string, attack: EnemyAttackDefinition, now: number,
|
||||
): boolean {
|
||||
const ability = deterrenceAbilityForActor(state, actorId);
|
||||
if (!ability) return false;
|
||||
// This exact package guarantees deflection after any prior miss/resist gate.
|
||||
// Its hostile riders cannot land in either case; damage keeps its actual roll.
|
||||
return resolveWow335DeterrenceIncoming({ ability, auras: state.auras,
|
||||
defenderId: actorId, nowMs: now, baseOutcome: "hit",
|
||||
incoming: { ...attack, attackKind: enemyAttackKind(attack), isAutoAttack: isEnemyWeaponSwing(attack) },
|
||||
defenderStunned: actorIsStunned(state, actorId, now), attackerInFront: false,
|
||||
}).outcome === "deflect";
|
||||
}
|
||||
|
||||
function completeDeterrenceLinkedAuras(
|
||||
auras: readonly ActiveAura[], ability: AbilityDefinition, sourceId: string, targetId: string, now: number,
|
||||
): readonly ActiveAura[] {
|
||||
if (sourceId !== targetId || !wow335DeterrenceAbilityContract(ability)
|
||||
|| !WOW335_DETERRENCE_PARENT_AURA_IDS.every((id) => auras.some((aura) => (
|
||||
aura.definition.id === id && aura.sourceId === sourceId && aura.targetId === targetId
|
||||
&& aura.appliedAt === now && aura.expiresAt === now + 5_000
|
||||
)))) return auras;
|
||||
return WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS.reduce((current, effect) => {
|
||||
if (effect.kind !== "apply-aura") return current;
|
||||
return applyCombatAura(current, { definition: auraDefinitionForAbility(effect.aura, ability),
|
||||
sourceId, targetId, now }).auras;
|
||||
}, auras);
|
||||
}
|
||||
|
||||
function synchronizedDeterrenceAuras(state: CombatState, auras: readonly ActiveAura[], now: number): readonly ActiveAura[] {
|
||||
const packageIds = new Set(WOW335_DETERRENCE_PACKAGE_AURA_IDS);
|
||||
let result = auras.filter((aura) => !packageIds.has(aura.definition.id)
|
||||
|| aura.sourceId === aura.targetId);
|
||||
const candidates = result.filter((aura) => packageIds.has(aura.definition.id));
|
||||
for (const candidate of candidates) {
|
||||
const ability = deterrenceAbilityForActor(state, candidate.targetId);
|
||||
if (!ability || !wow335DeterrenceActivePackage({ ability, auras: result,
|
||||
defenderId: candidate.targetId, nowMs: now })) {
|
||||
result = result.filter((aura) => !(packageIds.has(aura.definition.id)
|
||||
&& aura.sourceId === candidate.sourceId && aura.targetId === candidate.targetId));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function talentAdjustedMaxResource(
|
||||
@@ -4274,6 +4386,27 @@ export function characterCombatDefender(state: CombatState): CombatDefenderStats
|
||||
return { ...defender, damageReductionPercent: (1 - (1 - (defender.damageReductionPercent ?? 0) / 100) * multiplier) * 100 };
|
||||
}
|
||||
|
||||
function recoveryAuras(state: CombatState, now: number): readonly ActiveAura[] {
|
||||
const includePlayer = useDungeonSimulationStore.getState().sessionMode === "play";
|
||||
const actors = [
|
||||
...(includePlayer ? [{ id: PLAYER_AURA_ENTITY_ID, health: state.health, role: state.gearRole,
|
||||
maxMana: state.maxResourcePools.mana ?? (resourceProfileForClass(state.classId).type === "mana" ? state.maxResource : 0) }] : []),
|
||||
...usePartyStore.getState().members.map((member) => ({ id: member.id, health: member.health,
|
||||
role: member.role, maxMana: member.resourcePools.mana !== undefined ? companionResourceMaximum(member, "mana") : 0 })),
|
||||
];
|
||||
const inCombat = Object.values(state.mobs).some((mob) => !mob.dead
|
||||
&& (mob.engaged || Object.values(mob.threatByActor).some((threat) => threat > 0)));
|
||||
return synchronizeHealerManaRecovery(state.auras, actors, state.settings.healerManaRecovery, inCombat, now);
|
||||
}
|
||||
|
||||
/** Refresh the shared buff before companion recovery, even without a player tick. */
|
||||
export function refreshHealerManaRecovery(now: number): void {
|
||||
useCombatStore.setState((state) => {
|
||||
const auras = recoveryAuras(state, now);
|
||||
return auras === state.auras ? state : { auras };
|
||||
});
|
||||
}
|
||||
|
||||
function normalizedSettings(
|
||||
current: GameplaySettings,
|
||||
patch: Partial<GameplaySettings>,
|
||||
@@ -4291,6 +4424,7 @@ function normalizedSettings(
|
||||
showOwnDamageText: patch.showOwnDamageText ?? current.showOwnDamageText,
|
||||
showAllyDamageText: patch.showAllyDamageText ?? current.showAllyDamageText,
|
||||
showIncomingDamageText: patch.showIncomingDamageText ?? current.showIncomingDamageText,
|
||||
healerManaRecovery: patch.healerManaRecovery ?? current.healerManaRecovery,
|
||||
autoTargetNearest: patch.autoTargetNearest ?? current.autoTargetNearest,
|
||||
enableScreenShake: patch.enableScreenShake ?? current.enableScreenShake,
|
||||
masterVolume: clamp(Number.isFinite(patch.masterVolume) ? patch.masterVolume! : current.masterVolume, 0, 1),
|
||||
@@ -4320,6 +4454,7 @@ function gameplaySettingsEqual(left: GameplaySettings, right: GameplaySettings):
|
||||
&& left.showOwnDamageText === right.showOwnDamageText
|
||||
&& left.showAllyDamageText === right.showAllyDamageText
|
||||
&& left.showIncomingDamageText === right.showIncomingDamageText
|
||||
&& left.healerManaRecovery === right.healerManaRecovery
|
||||
&& left.autoTargetNearest === right.autoTargetNearest
|
||||
&& left.enableScreenShake === right.enableScreenShake
|
||||
&& left.masterVolume === right.masterVolume
|
||||
@@ -13466,14 +13601,16 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
&& enemyControlPreventsActions(state.controlMechanic)
|
||||
&& ability?.dbcSpellId !== 498570
|
||||
&& ability?.dbcSpellId !== 1953
|
||||
&& !((state.controlMechanic === "fear" || state.controlMechanic === "stun")
|
||||
&& !((state.controlMechanic === "fear" || state.controlMechanic === "stun" || state.controlMechanic === "confuse")
|
||||
&& enemyControlAbilityCanEscapeAuras(ability, state.controlMechanic,
|
||||
state.auras, PLAYER_AURA_ENTITY_ID, now))
|
||||
) failure = "player-controlled";
|
||||
else if (
|
||||
effectiveTarget === "hostile"
|
||||
&& abilityAttackKind(state.classId, ability) === "melee"
|
||||
&& aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID).some((aura) => aura.definition.utilityTags?.includes("pacified"))
|
||||
(wow335HunterPreventionTypeForAbility(ability) === 2
|
||||
|| (effectiveTarget === "hostile" && abilityAttackKind(state.classId, ability) === "melee"))
|
||||
&& aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID).some((aura) => (
|
||||
(aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes("pacified")
|
||||
))
|
||||
) failure = "player-controlled";
|
||||
else if (state.activeCast && !completingCast) failure = "already-casting";
|
||||
else if (!completingCast && !suddenDoomFreeDeathCoil
|
||||
@@ -13527,6 +13664,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
: effectiveTarget === "friendly"
|
||||
? state.selectedFriendlyActorId ?? party.selectedMemberId
|
||||
: null;
|
||||
if (ability?.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID) targetId = null;
|
||||
// Splash selects a destination. A stale, distant or fallen selected actor
|
||||
// must not become a second target requirement for that ground cast.
|
||||
if (ability && isCoaWitchDoctorSplashPotion(ability) && targeting?.shape === "ground") targetId = null;
|
||||
@@ -13599,7 +13737,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
role: state.gearRole,
|
||||
level: state.level,
|
||||
});
|
||||
if (enemyDisarmBlocksAbilityCast(
|
||||
const equippedItems = enemyDisarmRequirementCandidates(loadout);
|
||||
if ((ability.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID
|
||||
&& !enemySpellHasMatchingEquippedItem(ability.wowWeaponRequirements, equippedItems))
|
||||
|| enemyDisarmBlocksAbilityCast(
|
||||
state.auras,
|
||||
PLAYER_AURA_ENTITY_ID,
|
||||
now,
|
||||
@@ -13609,7 +13750,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
passive: ability.passive,
|
||||
createsAura: ability.effects.some((effect) => effect.kind === "apply-aura"),
|
||||
},
|
||||
enemyDisarmRequirementCandidates(loadout),
|
||||
equippedItems,
|
||||
)) failure = "condition-not-met";
|
||||
}
|
||||
const requiredTargetStateTag = ability ? romAbilityRequiredTargetStateTag(ability) : null;
|
||||
@@ -16458,6 +16599,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
? { ...aura, stacks: 0 }
|
||||
: aura)
|
||||
: [...auraResult.auras];
|
||||
if (ability.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID) {
|
||||
work.auras = [...completeDeterrenceLinkedAuras(work.auras, ability,
|
||||
PLAYER_AURA_ENTITY_ID, recipientId, now)];
|
||||
}
|
||||
if (state.classId === "mage" && ability.name === "Presence of Mind"
|
||||
&& wow335MageArcanePotencyCanProc(
|
||||
mageArcanePotencyRank,
|
||||
@@ -18318,7 +18463,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
},
|
||||
|
||||
resolveMobUtilitySpellHit: (id, sourceActorId, ability, now = combatNow()) => {
|
||||
if (!enemyUtilitySpellBlockSourceContract(ability.dbcSpellId, ability.wowSpellDefense?.sourceProfile)) return null;
|
||||
if (!enemyUtilitySpellBlockAbilityContract(ability)) return null;
|
||||
let result: CombatResult | null = null;
|
||||
set((state) => {
|
||||
const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId);
|
||||
@@ -18769,6 +18914,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
applyActorAura: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
|
||||
const state = get();
|
||||
if (!state.actors[sourceActorId] || (!state.actors[targetActorId] && !state.mobs[targetActorId])) return false;
|
||||
if (ability.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID
|
||||
&& (sourceActorId !== targetActorId || !deterrenceAbilityForActor(state, sourceActorId)
|
||||
|| !wow335DeterrenceAbilityContract(ability)
|
||||
|| !ability.effects.some((ownEffect, index) => ownEffect.kind === "apply-aura"
|
||||
&& ownEffect.aura.id === effect.aura.id && wow335DeterrenceAbilityContract({
|
||||
...ability, effects: ability.effects.map((entry, ordinal) => ordinal === index ? effect : entry),
|
||||
})))) return false;
|
||||
const isRomPriestChampionBeacon = effect.aura.utilityTags?.includes(
|
||||
ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_TAG,
|
||||
) ?? false;
|
||||
@@ -18825,7 +18977,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|| (effect.sourceAuraType === 147 && effect.miscValue === 1733);
|
||||
const controlPurge = purgeNamedEnemyControlShoutAuras(unpurgedResult.auras, targetActorId,
|
||||
sourceImmunityGrant ? enemyControlImmunityPurgeProofForAbility(ability) : undefined, now);
|
||||
const result = { ...unpurgedResult, auras: controlPurge.auras };
|
||||
const result = { ...unpurgedResult, auras: completeDeterrenceLinkedAuras(controlPurge.auras,
|
||||
ability, sourceActorId, targetActorId, now) };
|
||||
const grantedAbsorb = result.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0);
|
||||
let health = state.health;
|
||||
let maxHealth = state.maxHealth;
|
||||
@@ -20824,6 +20977,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
hasLineOfSight: combatHasLineOfSight,
|
||||
}) : [];
|
||||
const targets = attack.chain ? chainHits.map((hit) => hit.target.id) : primaryTargets;
|
||||
const effectTargets = targets.filter((actorId) => (
|
||||
!deterrencePreventsIncomingSpellEffects(state, actorId, attack, now)
|
||||
));
|
||||
const presentationTargetId = attack.target === "self" || attack.target === "nearby-friendly"
|
||||
? id
|
||||
: targets[0] ?? resolution.targetActorId;
|
||||
@@ -20899,7 +21055,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
school: periodicSchool ?? attack.school, runewakerEffectType: periodicSource.component.effectType,
|
||||
runewakerComponentId: periodicSource.component.componentId,
|
||||
attackKind: periodicSource.component.effectType === 0 ? "spell" : "melee" } : attack;
|
||||
for (const actorId of targets) {
|
||||
for (const actorId of effectTargets) {
|
||||
if (wowPeriodic) {
|
||||
const member = usePartyStore.getState().members.find((candidate) => candidate.id === actorId);
|
||||
const applicationDefender = actorId === PLAYER_AGGRO_ID
|
||||
@@ -20993,7 +21149,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
instantCast: instantSourceCast,
|
||||
});
|
||||
}
|
||||
if (targets.length) {
|
||||
if (effectTargets.length) {
|
||||
enemyHealing.push({
|
||||
targetId: id,
|
||||
amount: Math.max(1, Math.round(mob.attackDamage * effect.healMultiplier)),
|
||||
@@ -21121,7 +21277,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
? enemySourceAuraContract(effect.wowAuraSource.spellId, effect.wowAuraSource.sourceProfile) : undefined;
|
||||
const sourceDefinition = sourceAura ? supportedEnemySourceAuraDefinition(sourceAura) : undefined;
|
||||
if (effect.wowAuraSource && !sourceDefinition) continue;
|
||||
for (const actorId of targets) {
|
||||
for (const actorId of effectTargets) {
|
||||
if (deathKnightAntiMagicShellBlocksMagicEffect(namedAuras, actorId, effect.damageSchool ?? attack.school)) continue;
|
||||
if (sourceDefinition) {
|
||||
const debuff = effect.wowAuraSource
|
||||
@@ -21296,7 +21452,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const actorId of targets) {
|
||||
for (const actorId of effectTargets) {
|
||||
if (deathKnightAntiMagicShellBlocksMagicEffect(namedAuras, actorId, attack.school)) continue;
|
||||
if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue;
|
||||
const auraTargetId = actorId === PLAYER_AGGRO_ID ? PLAYER_AURA_ENTITY_ID : actorId;
|
||||
@@ -21358,7 +21514,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
];
|
||||
} else if (effect.kind === "pull") {
|
||||
const pulledPosition: CombatPosition = [position[0], position[1], position[2]];
|
||||
for (const actorId of targets) {
|
||||
for (const actorId of effectTargets) {
|
||||
if (actorId === PLAYER_AGGRO_ID) {
|
||||
set({ playerPosition: pulledPosition });
|
||||
} else {
|
||||
@@ -21619,7 +21775,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
};
|
||||
if (hit.actorId === PLAYER_AGGRO_ID) {
|
||||
const current = get();
|
||||
const playerImpactAuras = aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID);
|
||||
const playerImpactAuras = aurasForEntity(synchronizedDeterrenceAuras(current, current.auras, now), PLAYER_AURA_ENTITY_ID);
|
||||
const learnedCoaPassives = learnedRomCoaPassives(
|
||||
current.classId, current.secondaryClassId, current.level, current.talentRanks,
|
||||
);
|
||||
@@ -21674,9 +21830,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
hit.attackRevision,
|
||||
now,
|
||||
bonusCriticalChancePercent,
|
||||
aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID),
|
||||
playerImpactAuras,
|
||||
hit.nativeSourceDirect,
|
||||
hit.targetOrdinal,
|
||||
false,
|
||||
actorIsStunned(state, hit.actorId, now),
|
||||
);
|
||||
const combatResult = {
|
||||
...resolvedCombatResult,
|
||||
@@ -22729,7 +22887,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
);
|
||||
const member = memberFromCompanionActor(storedMember, authoritativeActor);
|
||||
if (member.health <= 0) continue;
|
||||
const memberImpactAuras = aurasForEntity(currentBeforeHit.auras, member.id);
|
||||
const memberImpactAuras = aurasForEntity(synchronizedDeterrenceAuras(currentBeforeHit, currentBeforeHit.auras, now), member.id);
|
||||
const learnedMemberCoaPassives = learnedRomCoaPassives(
|
||||
member.classId, member.secondaryClassId, member.level, member.talentRanks,
|
||||
);
|
||||
@@ -22783,9 +22941,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
hit.attackRevision,
|
||||
now,
|
||||
bonusCriticalChancePercent,
|
||||
aurasForEntity(get().auras, member.id),
|
||||
memberImpactAuras,
|
||||
hit.nativeSourceDirect,
|
||||
hit.targetOrdinal,
|
||||
false,
|
||||
actorIsStunned(state, hit.actorId, now),
|
||||
);
|
||||
const combatResult = {
|
||||
...resolvedCombatResult,
|
||||
@@ -23695,8 +23855,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const warriorAngerManagementRageRemainderMs = angerManagementProfile
|
||||
? angerManagementElapsedMs % angerManagementProfile.tickIntervalMs
|
||||
: 0;
|
||||
const auraExpiration = expireAuras(state.auras, now);
|
||||
let activeAuras = [...auraExpiration.active];
|
||||
const auraExpiration = expireAuras(recoveryAuras(state, now), now);
|
||||
let activeAuras = [...synchronizedDeterrenceAuras(state, auraExpiration.active, now)];
|
||||
const activeHunterPetIds = new Set(state.summons
|
||||
.filter((summon) => summon.hunterPet && summon.health > 0 && summon.expiresAt > now)
|
||||
.map((summon) => summon.id));
|
||||
@@ -23863,6 +24023,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}).current;
|
||||
}
|
||||
resourcePools[resourceProfile.type] = resource;
|
||||
const recoveryManaMaximum = state.maxResourcePools.mana ?? (resourceProfile.type === "mana" ? state.maxResource : 0);
|
||||
if (state.health > 0 && recoveryManaMaximum > 0) {
|
||||
resourcePools.mana = Math.min(recoveryManaMaximum, (resourcePools.mana ?? 0)
|
||||
+ healerManaRecoveryAmount(activeAuras, PLAYER_AURA_ENTITY_ID, recoveryManaMaximum, delta));
|
||||
if (resourceProfile.type === "mana") resource = resourcePools.mana;
|
||||
}
|
||||
if (angerManagementProfile && angerManagementTicks > 0) {
|
||||
const rageMaximum = state.maxResourcePools.rage ?? state.maxResource;
|
||||
const rage = Math.min(
|
||||
@@ -24026,6 +24192,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const targetPosition = getMobPosition(autoShotTargetId);
|
||||
const sourcePosition = combatSpatialActor(PLAYER_AGGRO_ID);
|
||||
const canAutoShot = !activeEnemyDisarmChannels(work.auras, PLAYER_AURA_ENTITY_ID, now).has("ranged")
|
||||
&& !aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID, now)
|
||||
.some((aura) => aura.definition.utilityTags?.includes("pacified"))
|
||||
&& !state.activeCast
|
||||
&& (state.controlledUntil <= now || !enemyControlPreventsActions(state.controlMechanic))
|
||||
&& Math.sqrt(mobRuntimeDistanceSquared(autoShotTargetId, state.playerPosition))
|
||||
@@ -24116,6 +24284,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const autoAttackSource = combatSpatialActor(PLAYER_AGGRO_ID);
|
||||
const canAutoAttack = (
|
||||
!state.activeCast
|
||||
&& !aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID, now)
|
||||
.some((aura) => aura.definition.utilityTags?.includes("pacified"))
|
||||
&& !(state.classId === "rom-warden" && activeWardenNatureCrystal(work.summons, now))
|
||||
&& (
|
||||
state.controlledUntil <= now
|
||||
@@ -26572,12 +26742,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|
||||
updateSettings: (patch) => set((state) => {
|
||||
const settings = normalizedSettings(state.settings, patch);
|
||||
return gameplaySettingsEqual(settings, state.settings) ? state : { settings };
|
||||
return gameplaySettingsEqual(settings, state.settings) ? state : { settings,
|
||||
auras: recoveryAuras({ ...state, settings }, combatNow()) };
|
||||
}),
|
||||
resetSettings: () => set((state) => (
|
||||
gameplaySettingsEqual(state.settings, DEFAULT_GAMEPLAY_SETTINGS)
|
||||
? state
|
||||
: { settings: DEFAULT_GAMEPLAY_SETTINGS }
|
||||
: { settings: DEFAULT_GAMEPLAY_SETTINGS,
|
||||
auras: recoveryAuras({ ...state, settings: DEFAULT_GAMEPLAY_SETTINGS }, combatNow()) }
|
||||
)),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { enemyDamageTypeSourceForSpell, resolveEnemyDamageType, type EnemyDamageOrigin } from "./mobDamageTypes";
|
||||
import {
|
||||
enemyDamageTypeSourceForSpell,
|
||||
resolveEnemyDamageType,
|
||||
type EnemyDamageOrigin,
|
||||
type WowEnemyHitSource,
|
||||
} from "./mobDamageTypes";
|
||||
import { romNativeEnemyPeriodicDamageSources } from "./romNativePeriodicDamage";
|
||||
import type { EnemyPeriodicDamageSourceProfile } from "./enemyPeriodicDamage";
|
||||
import type { EnemyAuraSourceProfile } from "./enemySourceAuras";
|
||||
@@ -193,6 +198,8 @@ export interface EnemyAttackDefinition {
|
||||
readonly damageClass?: number;
|
||||
/** Original WoW school bits; composite schools retain the authored scalar policy. */
|
||||
readonly schoolMask?: number;
|
||||
/** Raw WoW Spell.dbc hit-gate attributes copied from an exact source row. */
|
||||
readonly wowHitSource?: WowEnemyHitSource;
|
||||
/** Source RuneWaker HP calculation channel (0 magic, 1 physical). */
|
||||
readonly runewakerEffectType?: number;
|
||||
readonly runewakerComponentId?: number;
|
||||
@@ -420,6 +427,7 @@ export function normalizeEnemyAttack(
|
||||
...(damageType.attackKind ? { attackKind: damageType.attackKind } : {}),
|
||||
...(damageType.damageClass !== undefined ? { damageClass: damageType.damageClass } : {}),
|
||||
...(damageType.schoolMask !== undefined ? { schoolMask: damageType.schoolMask } : {}),
|
||||
...(damageType.hitSource ? { wowHitSource: damageType.hitSource } : {}),
|
||||
...(damageType.sourceDamageClass === "magical" ? { runewakerEffectType: 0 }
|
||||
: damageType.sourceDamageClass === "physical" ? { runewakerEffectType: 1 }
|
||||
: value.runewakerEffectType !== undefined ? { runewakerEffectType: value.runewakerEffectType } : {}),
|
||||
|
||||
@@ -206,7 +206,8 @@ function deterrenceContract(
|
||||
});
|
||||
}
|
||||
|
||||
const DETERRENCE_DISARM = effect(6, 185, -100);
|
||||
// Aura 185 is MOD_ATTACKER_RANGED_HIT_CHANCE; it is not a Disarm aura.
|
||||
const DETERRENCE_RANGED_HIT_PENALTY = effect(6, 185, -100);
|
||||
const DETERRENCE_UNKNOWN = effect(6, 27, 0);
|
||||
|
||||
const CONTRACTS: Readonly<Record<EnemyBlockAuraSourceProfile, Readonly<Record<number, EnemyBlockAuraContract>>>> = Object.freeze({
|
||||
@@ -215,7 +216,7 @@ const CONTRACTS: Readonly<Record<EnemyBlockAuraSourceProfile, Readonly<Record<nu
|
||||
47294: criticalBlockContract("wow335", 47294, 20, [5]),
|
||||
47295: criticalBlockContract("wow335", 47295, 40, [10]),
|
||||
47296: criticalBlockContract("wow335", 47296, 60, [15]),
|
||||
67801: deterrenceContract("wow335", 67801, 5_000, [DETERRENCE_DISARM]),
|
||||
67801: deterrenceContract("wow335", 67801, 5_000, [DETERRENCE_RANGED_HIT_PENALTY]),
|
||||
}),
|
||||
ascension: Object.freeze({
|
||||
3419: blockChanceContract("ascension"),
|
||||
@@ -223,15 +224,15 @@ const CONTRACTS: Readonly<Record<EnemyBlockAuraSourceProfile, Readonly<Record<nu
|
||||
47295: criticalBlockContract("ascension", 47295, 100, [7, 16]),
|
||||
47296: criticalBlockContract("ascension", 47296, 100, [10, 25]),
|
||||
67801: deterrenceContract("ascension", 67801, 60_000, [effect(6, 4, 0), DETERRENCE_UNKNOWN]),
|
||||
667801: deterrenceContract("ascension", 667801, 5_000, [DETERRENCE_DISARM, DETERRENCE_UNKNOWN]),
|
||||
1167801: deterrenceContract("ascension", 1167801, 5_000, [DETERRENCE_DISARM]),
|
||||
667801: deterrenceContract("ascension", 667801, 5_000, [DETERRENCE_RANGED_HIT_PENALTY, DETERRENCE_UNKNOWN]),
|
||||
1167801: deterrenceContract("ascension", 1167801, 5_000, [DETERRENCE_RANGED_HIT_PENALTY]),
|
||||
}),
|
||||
epoch: Object.freeze({
|
||||
3419: blockChanceContract("epoch"),
|
||||
47294: criticalBlockContract("epoch", 47294, 20, [5]),
|
||||
47295: criticalBlockContract("epoch", 47295, 40, [10]),
|
||||
47296: criticalBlockContract("epoch", 47296, 60, [15]),
|
||||
67801: deterrenceContract("epoch", 67801, 5_000, [DETERRENCE_DISARM]),
|
||||
67801: deterrenceContract("epoch", 67801, 5_000, [DETERRENCE_RANGED_HIT_PENALTY]),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ describe("source-backed enemy NPC blocking", () => {
|
||||
expect(classifyEnemyNpcSpellBlockPhase({ ...physicalDirectSpell, sourceProfile: "epoch" as never })).toBeNull();
|
||||
});
|
||||
|
||||
it("uses only the two source-proven custom bits for Shield Bash spell 72", () => {
|
||||
it("uses only the two source-proven custom bits for the three exact utility ranks", () => {
|
||||
const shieldBash = enemyNpcSourceSpellBlockMetadata(72, "wow335");
|
||||
expect(shieldBash).toMatchObject({
|
||||
damageClass: SPELL_DAMAGE_CLASS_MELEE,
|
||||
@@ -210,7 +210,9 @@ describe("source-backed enemy NPC blocking", () => {
|
||||
ignoresHitDirection: true,
|
||||
})).toBe(true);
|
||||
expect(enemyNpcSourceSpellBlockMetadata(72, "ascension")).toBeUndefined();
|
||||
expect(enemyNpcSourceSpellBlockMetadata(1766, "wow335")).toBeUndefined();
|
||||
expect(enemyNpcSourceSpellBlockMetadata(1766, "wow335")).toEqual(shieldBash);
|
||||
expect(enemyNpcSourceSpellBlockMetadata(6552, "wow335")).toEqual(shieldBash);
|
||||
expect(enemyNpcSourceSpellBlockMetadata(5246, "wow335")).toBeUndefined();
|
||||
|
||||
const unknownBehindBit = {
|
||||
...shieldBash!,
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface EnemyNpcSpellDefenseMetadata {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the narrowly audited Shield Bash hit-table contract. Its two block-
|
||||
* Returns the narrowly audited Shield Bash, Kick and Pummel hit-table contract. Its two block-
|
||||
* relevant AttributesCu bits are sourced independently; this does not assert
|
||||
* that all other custom bits are zero.
|
||||
*/
|
||||
@@ -55,7 +55,7 @@ export function enemyNpcSourceSpellBlockMetadata(
|
||||
spellId: number,
|
||||
sourceProfile: EnemyNpcSpellSourceProfile,
|
||||
): EnemyNpcSpellDefenseMetadata | undefined {
|
||||
if (spellId !== 72 || sourceProfile !== "wow335") return undefined;
|
||||
if (![72, 1766, 6552].includes(spellId) || sourceProfile !== "wow335") return undefined;
|
||||
return Object.freeze({
|
||||
damageClass: SPELL_DAMAGE_CLASS_MELEE,
|
||||
schoolMask: SPELL_SCHOOL_MASK_NORMAL,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { abilitiesForClass, abilityAtLevel, preloadAbilityCatalogsForClasses, type AbilityDefinition } from "./abilityCatalog";
|
||||
import { abilitiesForClass, abilityAtLevel, abilityTalentNodeId, preloadAbilityCatalogsForClasses, type AbilityDefinition } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
@@ -63,6 +63,27 @@ describe("source immunity grants through actual runtime consumers", () => {
|
||||
expect(useCombatStore.getState().castAbility(selected("warrior", "Berserker Rage").id, { now: 2_000 }))
|
||||
.toMatchObject({ ok: false, reason: "player-controlled" });
|
||||
});
|
||||
it.each([
|
||||
["fear", 19134],
|
||||
["stun", 20511],
|
||||
] as const)("rejects learned Bladestorm while under source %s without spending resources or purging", (mechanic, spellId) => {
|
||||
prepare("player", mechanic, spellId);
|
||||
const ability = selected("warrior", "Bladestorm");
|
||||
const nodeId = abilityTalentNodeId(ability);
|
||||
expect(nodeId).not.toBeNull();
|
||||
// Synthetic learned rank isolates the cast-control gate from talent-tree allocation.
|
||||
useCombatStore.setState({ talentRanks: { [nodeId!]: 1 } });
|
||||
const before = useCombatStore.getState();
|
||||
expect(useCombatStore.getState().castAbility(ability.id, { now: 2_000 }))
|
||||
.toMatchObject({ ok: false, reason: "player-controlled" });
|
||||
const after = useCombatStore.getState();
|
||||
expect(after.auras).toEqual(before.auras);
|
||||
expect(after.controlledUntil).toBe(before.controlledUntil);
|
||||
expect(after.controlMechanic).toBe(mechanic);
|
||||
expect(after.resourcePools).toEqual(before.resourcePools);
|
||||
expect(after.cooldowns).toEqual(before.cooldowns);
|
||||
expect(after.globalCooldownEndsAt).toBe(before.globalCooldownEndsAt);
|
||||
});
|
||||
it.each(["player", "party"] as const)("purges the whole FEAR-mechanic child and its rider for %s on an accepted Bladestorm grant", (recipient) => {
|
||||
const id = prepare(recipient, "stun", 20511);
|
||||
const ability = selected("warrior", "Bladestorm");
|
||||
|
||||
@@ -9,9 +9,12 @@ import {
|
||||
purgeNamedEnemyControlShoutAuras,
|
||||
} from "./enemyControlImmunityPurge";
|
||||
|
||||
function selectedAbility(spellId: number) {
|
||||
const base = WOW335_ABILITIES_BY_CLASS.warrior.find((ability) => ability.dbcSpellId === spellId);
|
||||
if (!base) throw new Error(`Missing generated Warrior ability ${spellId}`);
|
||||
function selectedAbility(
|
||||
spellId: number,
|
||||
classId: keyof typeof WOW335_ABILITIES_BY_CLASS = "warrior",
|
||||
) {
|
||||
const base = WOW335_ABILITIES_BY_CLASS[classId].find((ability) => ability.dbcSpellId === spellId);
|
||||
if (!base) throw new Error(`Missing generated ${classId} ability ${spellId}`);
|
||||
return abilityAtLevel(base, 80);
|
||||
}
|
||||
|
||||
@@ -40,6 +43,7 @@ describe("source-proven enemy control immunity purge", () => {
|
||||
grantsMechanics: ["fear"],
|
||||
purgesNamedShoutSpellMechanics: ["fear"],
|
||||
usableWhileControlledBy: ["fear"],
|
||||
directCastBypasses: [],
|
||||
});
|
||||
expect(enemyControlAbilityUsableWhileControlled(berserkerRage, "fear")).toBe(true);
|
||||
expect(enemyControlAbilityUsableWhileControlled(berserkerRage, "stun")).toBe(false);
|
||||
@@ -55,11 +59,70 @@ describe("source-proven enemy control immunity purge", () => {
|
||||
grantsMechanics: ["fear", "stun"],
|
||||
purgesNamedShoutSpellMechanics: ["fear"],
|
||||
usableWhileControlledBy: [],
|
||||
directCastBypasses: [],
|
||||
});
|
||||
expect(enemyControlAbilityUsableWhileControlled(bladestorm, "fear")).toBe(false);
|
||||
expect(enemyControlAbilityUsableWhileControlled(bladestorm, "stun")).toBe(false);
|
||||
});
|
||||
|
||||
it("proves Lichborne's exact Charm/Fear/Sleep purge and its separate cast-state flags", () => {
|
||||
const lichborne = selectedAbility(49039, "death-knight");
|
||||
const proof = enemyControlImmunityPurgeProofForAbility(lichborne);
|
||||
|
||||
expect(lichborne).toMatchObject({
|
||||
id: "wow335-death-knight-lichborne",
|
||||
name: "Lichborne",
|
||||
classId: "death-knight",
|
||||
source: "wow335-progression",
|
||||
dbcSpellId: 49039,
|
||||
wowSpellDefense: {
|
||||
sourceProfile: "wow335",
|
||||
damageClass: 0,
|
||||
schoolMask: 32,
|
||||
attributes0: 262144,
|
||||
attributes1: 163872,
|
||||
attributes3: 0,
|
||||
attributes5: 393224,
|
||||
directDamageFromEffects: false,
|
||||
},
|
||||
});
|
||||
expect(lichborne.effects).toHaveLength(3);
|
||||
const expectedEffects = [
|
||||
{ miscValue: 1, sourceBasePoints: 0, sourceDieSides: 1, immunity: undefined },
|
||||
{ miscValue: 5, sourceBasePoints: 0, sourceDieSides: 0, immunity: "fear" },
|
||||
{ miscValue: 10, sourceBasePoints: 0, sourceDieSides: 0, immunity: "sleep" },
|
||||
] as const;
|
||||
lichborne.effects.forEach((effect, index) => {
|
||||
const expected = expectedEffects[index];
|
||||
expect(effect.kind).toBe("apply-aura");
|
||||
if (effect.kind !== "apply-aura") throw new Error("Expected selected Lichborne immunity aura");
|
||||
expect(effect).toMatchObject({
|
||||
sourceEffectType: 6,
|
||||
sourceAuraType: 77,
|
||||
sourceBasePoints: expected.sourceBasePoints,
|
||||
sourceDieSides: expected.sourceDieSides,
|
||||
sourcePointsPerLevel: 0,
|
||||
miscValue: expected.miscValue,
|
||||
recipient: "caster",
|
||||
});
|
||||
if (expected.immunity) expect(effect.aura.controlImmunities).toContain(expected.immunity);
|
||||
expect(effect.aura.id).toBe(`wow335:49039:effect:${index}:aura:77`);
|
||||
});
|
||||
expect(proof).toMatchObject({
|
||||
sourceProfile: "wow335",
|
||||
sourceSpellId: 49039,
|
||||
grantsMechanics: ["charm", "fear", "sleep"],
|
||||
purgesNamedShoutSpellMechanics: ["fear"],
|
||||
usableWhileControlledBy: ["fear", "stun", "confuse"],
|
||||
directCastBypasses: ["fear", "stun", "confuse"],
|
||||
});
|
||||
expect(enemyControlAbilityUsableWhileControlled(lichborne, "fear")).toBe(true);
|
||||
expect(enemyControlAbilityUsableWhileControlled(lichborne, "stun")).toBe(true);
|
||||
expect(enemyControlAbilityUsableWhileControlled(lichborne, "confuse")).toBe(true);
|
||||
expect(enemyControlAbilityUsableWhileControlled(lichborne, "charm")).toBe(false);
|
||||
expect(enemyControlAbilityUsableWhileControlled(lichborne, "sleep")).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed for a changed profile, missing or changed raw flags, and changed aura scalars", () => {
|
||||
const berserkerRage = selectedAbility(18499);
|
||||
const defense = berserkerRage.wowSpellDefense!;
|
||||
@@ -89,6 +152,35 @@ describe("source-proven enemy control immunity purge", () => {
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails closed for Lichborne when its selected WoW profile, full flags, or effect scalars change", () => {
|
||||
const lichborne = selectedAbility(49039, "death-knight");
|
||||
const defense = lichborne.wowSpellDefense!;
|
||||
const fearIndex = lichborne.effects.findIndex((effect) => (
|
||||
effect.kind === "apply-aura" && effect.sourceAuraType === 77 && effect.miscValue === 5
|
||||
));
|
||||
const fearAura = lichborne.effects[fearIndex];
|
||||
if (fearAura.kind !== "apply-aura") throw new Error("Expected selected Lichborne fear immunity aura");
|
||||
|
||||
expect(enemyControlImmunityPurgeProofForAbility({
|
||||
...lichborne,
|
||||
wowSpellDefense: { ...defense, sourceProfile: "ascension" },
|
||||
})).toBeUndefined();
|
||||
expect(enemyControlImmunityPurgeProofForAbility({
|
||||
...lichborne,
|
||||
wowSpellDefense: { ...defense, attributes1: undefined },
|
||||
})).toBeUndefined();
|
||||
expect(enemyControlImmunityPurgeProofForAbility({
|
||||
...lichborne,
|
||||
wowSpellDefense: { ...defense, attributes5: defense.attributes5! ^ 0x00020000 },
|
||||
})).toBeUndefined();
|
||||
expect(enemyControlImmunityPurgeProofForAbility({
|
||||
...lichborne,
|
||||
effects: lichborne.effects.map((effect, index) => index === fearIndex
|
||||
? { ...fearAura, sourceDieSides: 1 }
|
||||
: effect),
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not infer active purge from Icebound Fortitude's stun-immunity row", () => {
|
||||
const base = WOW335_ABILITIES_BY_CLASS["death-knight"].find((ability) => ability.dbcSpellId === 48792);
|
||||
if (!base) throw new Error("Missing generated Death Knight ability 48792");
|
||||
@@ -128,6 +220,7 @@ describe("source-proven enemy control immunity purge", () => {
|
||||
grantsMechanics: ["fear"],
|
||||
purgesNamedShoutSpellMechanics: ["fear"],
|
||||
usableWhileControlledBy: ["fear"],
|
||||
directCastBypasses: [],
|
||||
} as const;
|
||||
|
||||
expect(purgeNamedEnemyControlShoutAuras([aura], "party-warrior", forged, 0)).toEqual({
|
||||
@@ -155,4 +248,54 @@ describe("source-proven enemy control immunity purge", () => {
|
||||
berserkerRage, "stun", [sourceFear], "party-warrior", 100,
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let Bladestorm borrow its purge grant as a control cast bypass", () => {
|
||||
const bladestorm = selectedAbility(46924);
|
||||
const sourceFear = activeAura("enemy-control:source-spell-5246", "party-warrior");
|
||||
const sourceChildStun = activeAura("enemy-control:source-spell-20511", "party-warrior");
|
||||
|
||||
expect(enemyControlAbilityCanEscapeAuras(
|
||||
bladestorm, "fear", [sourceFear], "party-warrior", 100,
|
||||
)).toBe(false);
|
||||
expect(enemyControlAbilityCanEscapeAuras(
|
||||
bladestorm, "stun", [sourceChildStun], "party-warrior", 100,
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows Lichborne through sourced or directly supported control and retains unrelated auras", () => {
|
||||
const lichborne = selectedAbility(49039, "death-knight");
|
||||
const proof = enemyControlImmunityPurgeProofForAbility(lichborne);
|
||||
const sourceChildStun = activeAura("enemy-control:source-spell-20511", "party-death-knight");
|
||||
const genericStun = activeAura("enemy-control:another-caster:another-spell:stun", "party-death-knight");
|
||||
const genericConfuse = activeAura("enemy-control:another-caster:another-spell:confuse", "party-death-knight");
|
||||
const genericFear = activeAura("enemy-control:another-caster:another-spell:fear", "party-death-knight");
|
||||
|
||||
expect(enemyControlAbilityCanEscapeAuras(
|
||||
lichborne, "stun", [sourceChildStun], "party-death-knight", 100,
|
||||
)).toBe(true);
|
||||
expect(enemyControlAbilityCanEscapeAuras(
|
||||
lichborne, "stun", [genericStun], "party-death-knight", 100,
|
||||
)).toBe(true);
|
||||
expect(enemyControlAbilityCanEscapeAuras(
|
||||
lichborne, "confuse", [genericConfuse], "party-death-knight", 100,
|
||||
)).toBe(true);
|
||||
expect(enemyControlAbilityCanEscapeAuras(
|
||||
lichborne, "fear", [genericFear], "party-death-knight", 100,
|
||||
)).toBe(true);
|
||||
expect(enemyControlAbilityCanEscapeAuras(
|
||||
selectedAbility(18499), "fear", [genericFear], "party-death-knight", 100,
|
||||
)).toBe(false);
|
||||
const mixedFear = purgeNamedEnemyControlShoutAuras(
|
||||
[genericFear, activeAura("enemy-control:source-spell-19134", "party-death-knight")],
|
||||
"party-death-knight", proof, 100,
|
||||
);
|
||||
expect(mixedFear.removed.map((aura) => aura.definition.id)).toEqual(["enemy-control:source-spell-19134"]);
|
||||
expect(mixedFear.auras).toEqual([genericFear]);
|
||||
expect(purgeNamedEnemyControlShoutAuras(
|
||||
[genericStun, genericConfuse], "party-death-knight", proof, 100,
|
||||
)).toEqual({
|
||||
auras: [genericStun, genericConfuse],
|
||||
removed: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,23 @@
|
||||
import type { AbilityDefinition } from "./abilityCatalog";
|
||||
import type { ActiveAura } from "./combatAuras";
|
||||
|
||||
export type EnemyControlImmunityMechanic = "fear" | "stun";
|
||||
export type EnemyControlImmunityMechanic = "charm" | "confuse" | "fear" | "sleep" | "stun";
|
||||
export type EnemyControlCastBlockMechanic = Extract<
|
||||
EnemyControlImmunityMechanic,
|
||||
"confuse" | "fear" | "stun"
|
||||
>;
|
||||
|
||||
export interface EnemyControlImmunityPurgeProof {
|
||||
readonly sourceProfile: "wow335";
|
||||
readonly sourceSpellId: 18499 | 46924;
|
||||
readonly sourceSpellId: 18499 | 46924 | 49039;
|
||||
/** Mechanic immunities proven by the exact selected source aura/effect. */
|
||||
readonly grantsMechanics: readonly EnemyControlImmunityMechanic[];
|
||||
/** Every named shout aura below has spell-level Mechanic=FEAR. */
|
||||
readonly purgesNamedShoutSpellMechanics: readonly "fear"[];
|
||||
/** Core cast-prevention bypasses proven for this selected spell. */
|
||||
readonly usableWhileControlledBy: readonly EnemyControlImmunityMechanic[];
|
||||
readonly usableWhileControlledBy: readonly EnemyControlCastBlockMechanic[];
|
||||
/** Explicit source flags that permit casts through a control aura without purging it. */
|
||||
readonly directCastBypasses: readonly EnemyControlCastBlockMechanic[];
|
||||
}
|
||||
|
||||
const IMMUNITY_PURGES_EFFECT = 0x00008000;
|
||||
@@ -29,7 +35,7 @@ function isExactApplyAura(
|
||||
effect: AbilityDefinition["effects"][number] | undefined,
|
||||
expected: {
|
||||
readonly index: number;
|
||||
readonly spellId: 18499 | 46924;
|
||||
readonly spellId: 18499 | 46924 | 49039;
|
||||
readonly auraType: number;
|
||||
readonly miscValue: number;
|
||||
readonly basePoints: number;
|
||||
@@ -60,25 +66,28 @@ function verifiedProof(
|
||||
function defenseMatches(
|
||||
ability: AbilityDefinition,
|
||||
expected: {
|
||||
readonly spellId: 18499 | 46924;
|
||||
readonly spellId: 18499 | 46924 | 49039;
|
||||
readonly classId: "warrior" | "death-knight";
|
||||
readonly attributes0: number;
|
||||
readonly attributes1: number;
|
||||
readonly attributes3: number;
|
||||
readonly attributes5: number;
|
||||
readonly damageClass: number;
|
||||
readonly schoolMask: number;
|
||||
},
|
||||
): boolean {
|
||||
const defense = ability.wowSpellDefense;
|
||||
return ability.dbcSpellId === expected.spellId
|
||||
&& ability.classId === "warrior"
|
||||
&& ability.classId === expected.classId
|
||||
&& ability.source === "wow335-progression"
|
||||
&& defense?.sourceProfile === "wow335"
|
||||
&& defense.damageClass === expected.damageClass
|
||||
&& defense.schoolMask === 1
|
||||
&& defense.schoolMask === expected.schoolMask
|
||||
&& defense.attributes0 === expected.attributes0
|
||||
&& defense.attributes1 === expected.attributes1
|
||||
&& defense.attributes3 === expected.attributes3
|
||||
&& defense.attributes5 === expected.attributes5
|
||||
&& defense.directDamageFromEffects === false
|
||||
&& Number.isInteger(defense.attributes1)
|
||||
&& Number.isInteger(defense.attributes5)
|
||||
&& (defense.attributes1 & IMMUNITY_PURGES_EFFECT) !== 0;
|
||||
@@ -91,11 +100,13 @@ function berserkerRageProof(ability: AbilityDefinition): EnemyControlImmunityPur
|
||||
|| ability.effects.length !== 3
|
||||
|| !defenseMatches(ability, {
|
||||
spellId: 18499,
|
||||
classId: "warrior",
|
||||
attributes0: 327696,
|
||||
attributes1: 32768,
|
||||
attributes3: 0,
|
||||
attributes5: 0,
|
||||
damageClass: 0,
|
||||
schoolMask: 1,
|
||||
})) return undefined;
|
||||
|
||||
const [fear, sap, incapacitate] = ability.effects;
|
||||
@@ -115,6 +126,7 @@ function berserkerRageProof(ability: AbilityDefinition): EnemyControlImmunityPur
|
||||
grantsMechanics: Object.freeze(["fear"]),
|
||||
purgesNamedShoutSpellMechanics: Object.freeze(["fear"]),
|
||||
usableWhileControlledBy: Object.freeze(["fear"]),
|
||||
directCastBypasses: Object.freeze([]),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -125,11 +137,13 @@ function bladestormProof(ability: AbilityDefinition): EnemyControlImmunityPurgeP
|
||||
|| ability.effects.length !== 4
|
||||
|| !defenseMatches(ability, {
|
||||
spellId: 46924,
|
||||
classId: "warrior",
|
||||
attributes0: 327696,
|
||||
attributes1: 32784,
|
||||
attributes3: 1024,
|
||||
attributes5: 512,
|
||||
damageClass: 2,
|
||||
schoolMask: 1,
|
||||
})) return undefined;
|
||||
|
||||
const sourceAuras = ability.effects.filter((effect) => effect.kind === "apply-aura");
|
||||
@@ -157,16 +171,55 @@ function bladestormProof(ability: AbilityDefinition): EnemyControlImmunityPurgeP
|
||||
grantsMechanics: Object.freeze(["fear", "stun"]),
|
||||
purgesNamedShoutSpellMechanics: Object.freeze(["fear"]),
|
||||
usableWhileControlledBy: Object.freeze([]),
|
||||
directCastBypasses: Object.freeze([]),
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns a source-pinned purge/cast proof only for the two audited WoW 3.3.5a rows. */
|
||||
function lichborneProof(ability: AbilityDefinition): EnemyControlImmunityPurgeProof | undefined {
|
||||
if (ability.id !== "wow335-death-knight-lichborne"
|
||||
|| ability.name !== "Lichborne"
|
||||
|| ability.target !== "self"
|
||||
|| ability.effects.length !== 3
|
||||
|| !defenseMatches(ability, {
|
||||
spellId: 49039,
|
||||
classId: "death-knight",
|
||||
attributes0: 262144,
|
||||
attributes1: 163872,
|
||||
attributes3: 0,
|
||||
attributes5: 393224,
|
||||
damageClass: 0,
|
||||
schoolMask: 32,
|
||||
})) return undefined;
|
||||
|
||||
const [charm, fear, sleep] = ability.effects;
|
||||
if (!isExactApplyAura(charm, {
|
||||
index: 0, spellId: 49039, auraType: 77, miscValue: 1, basePoints: 0, dieSides: 1,
|
||||
})
|
||||
|| !isExactApplyAura(fear, {
|
||||
index: 1, spellId: 49039, auraType: 77, miscValue: 5, basePoints: 0, dieSides: 0,
|
||||
}) || !fear.aura.controlImmunities?.includes("fear")
|
||||
|| !isExactApplyAura(sleep, {
|
||||
index: 2, spellId: 49039, auraType: 77, miscValue: 10, basePoints: 0, dieSides: 0,
|
||||
}) || !sleep.aura.controlImmunities?.includes("sleep")) return undefined;
|
||||
|
||||
return verifiedProof({
|
||||
sourceProfile: "wow335",
|
||||
sourceSpellId: 49039,
|
||||
grantsMechanics: Object.freeze(["charm", "fear", "sleep"]),
|
||||
purgesNamedShoutSpellMechanics: Object.freeze(["fear"]),
|
||||
usableWhileControlledBy: Object.freeze(["fear", "stun", "confuse"]),
|
||||
directCastBypasses: Object.freeze(["fear", "stun", "confuse"]),
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns a source-pinned purge/cast proof only for the three audited WoW 3.3.5a rows. */
|
||||
export function enemyControlImmunityPurgeProofForAbility(
|
||||
ability: AbilityDefinition | null | undefined,
|
||||
): EnemyControlImmunityPurgeProof | undefined {
|
||||
if (!ability) return undefined;
|
||||
if (ability.dbcSpellId === 18499) return berserkerRageProof(ability);
|
||||
if (ability.dbcSpellId === 46924) return bladestormProof(ability);
|
||||
if (ability.dbcSpellId === 49039) return lichborneProof(ability);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -175,24 +228,26 @@ export function enemyControlAbilityUsableWhileControlled(
|
||||
ability: AbilityDefinition | null | undefined,
|
||||
mechanic: EnemyControlImmunityMechanic,
|
||||
): boolean {
|
||||
return enemyControlImmunityPurgeProofForAbility(ability)?.usableWhileControlledBy.includes(mechanic) ?? false;
|
||||
return enemyControlImmunityPurgeProofForAbility(ability)?.usableWhileControlledBy
|
||||
.some((allowed) => allowed === mechanic) ?? false;
|
||||
}
|
||||
|
||||
const NON_ACTION_BLOCKING_CONTROL_MECHANICS = new Set(["root", "slow", "interrupt"]);
|
||||
|
||||
/**
|
||||
* The Berserker Rage fear exception also covers the Fear-mechanic 20511 child
|
||||
* while it applies a stun effect. It does not cover a separate stun aura.
|
||||
* Berserker Rage and Lichborne can use the Fear-mechanic 20511 child while it
|
||||
* applies a stun effect. Lichborne's exact row also permits separate stun and
|
||||
* confuse states; those auras stay active because the row grants no such purge.
|
||||
*/
|
||||
export function enemyControlAbilityCanEscapeAuras(
|
||||
ability: AbilityDefinition | null | undefined,
|
||||
mechanic: EnemyControlImmunityMechanic,
|
||||
mechanic: EnemyControlCastBlockMechanic,
|
||||
auras: readonly ActiveAura[],
|
||||
targetId: string,
|
||||
now: number,
|
||||
): boolean {
|
||||
const proof = enemyControlImmunityPurgeProofForAbility(ability);
|
||||
if (!proof || proof.sourceSpellId !== 18499 || !Number.isFinite(now)) return false;
|
||||
if (!proof || !Number.isFinite(now)) return false;
|
||||
const activeControlAuras = auras.filter((aura) => aura.targetId === targetId
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||
&& aura.definition.id.startsWith("enemy-control:"));
|
||||
@@ -200,21 +255,47 @@ export function enemyControlAbilityCanEscapeAuras(
|
||||
|
||||
const allPreventingAurasAreCovered = activeControlAuras.every((aura) => {
|
||||
const id = aura.definition.id;
|
||||
if (NAMED_SHOUT_AURA_IDS.has(id)) return true;
|
||||
if (NAMED_SHOUT_AURA_IDS.has(id)) {
|
||||
return proof.purgesNamedShoutSpellMechanics.includes("fear");
|
||||
}
|
||||
const suffix = id.slice("enemy-control:".length);
|
||||
if (suffix.startsWith("source-spell-")) return false;
|
||||
const sourceMechanic = suffix.split(":").at(-1);
|
||||
return sourceMechanic !== undefined
|
||||
&& NON_ACTION_BLOCKING_CONTROL_MECHANICS.has(sourceMechanic);
|
||||
&& (NON_ACTION_BLOCKING_CONTROL_MECHANICS.has(sourceMechanic)
|
||||
|| proof.directCastBypasses.some((allowed) => allowed === sourceMechanic));
|
||||
});
|
||||
if (!allPreventingAurasAreCovered) return false;
|
||||
|
||||
if (mechanic === "fear") {
|
||||
return enemyControlAbilityUsableWhileControlled(ability, "fear")
|
||||
const canPurgeNamedShout = proof.purgesNamedShoutSpellMechanics.includes("fear")
|
||||
&& activeControlAuras.some((aura) => NAMED_SHOUT_AURA_IDS.has(aura.definition.id));
|
||||
const canBypassGenericFear = proof.directCastBypasses.includes("fear")
|
||||
&& activeControlAuras.some((aura) => {
|
||||
const suffix = aura.definition.id.slice("enemy-control:".length);
|
||||
return !NAMED_SHOUT_AURA_IDS.has(aura.definition.id)
|
||||
&& !suffix.startsWith("source-spell-")
|
||||
&& suffix.split(":").at(-1) === "fear";
|
||||
});
|
||||
return enemyControlAbilityUsableWhileControlled(ability, "fear")
|
||||
&& (canPurgeNamedShout || canBypassGenericFear);
|
||||
}
|
||||
return mechanic === "stun"
|
||||
&& activeControlAuras.some((aura) => aura.definition.id === "enemy-control:source-spell-20511");
|
||||
if (mechanic === "stun") {
|
||||
const canCastThroughSourceChild = activeControlAuras.some(
|
||||
(aura) => aura.definition.id === "enemy-control:source-spell-20511",
|
||||
) && (enemyControlAbilityUsableWhileControlled(ability, "fear")
|
||||
|| enemyControlAbilityUsableWhileControlled(ability, "stun"));
|
||||
const canCastThroughDirectStun = proof.directCastBypasses.includes("stun")
|
||||
&& activeControlAuras.some((aura) => {
|
||||
const suffix = aura.definition.id.slice("enemy-control:".length);
|
||||
return !suffix.startsWith("source-spell-") && suffix.split(":").at(-1) === "stun";
|
||||
});
|
||||
return canCastThroughSourceChild || canCastThroughDirectStun;
|
||||
}
|
||||
return mechanic === "confuse"
|
||||
&& proof.directCastBypasses.includes("confuse")
|
||||
&& activeControlAuras.some((aura) => aura.definition.id.slice("enemy-control:".length)
|
||||
.split(":").at(-1) === "confuse");
|
||||
}
|
||||
|
||||
export interface EnemyControlShoutAuraPurgeResult {
|
||||
|
||||
@@ -285,6 +285,15 @@ function equippedItemMatchesRequirement(
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Checks the equipped item itself; Disarm usability has its separate source exceptions. */
|
||||
export function enemySpellHasMatchingEquippedItem(
|
||||
requirement: EnemyDisarmAbilityRequirement,
|
||||
equippedItems: readonly EnemyDisarmEquippedItem[],
|
||||
): boolean {
|
||||
return requirement.equippedItemClass < 0
|
||||
|| equippedItems.some((item) => equippedItemMatchesRequirement(item, requirement));
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify self-cast passive item auras whose every matching equipped weapon
|
||||
* is currently disarmed. This is an effective-aura view: the persistent aura
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { abilitiesForClass, abilityAtLevel, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
||||
import {
|
||||
enemyNpcSourceSpellBlockMetadata,
|
||||
SPELL_ATTR0_CU_DIRECT_DAMAGE,
|
||||
@@ -8,10 +10,13 @@ import {
|
||||
} from "./enemyBlocking";
|
||||
import {
|
||||
enemyUtilityEffectsAfterBlock,
|
||||
enemyUtilitySpellBlockAbilityContract,
|
||||
enemyUtilitySpellBlockDisposition,
|
||||
enemyUtilitySpellBlockSourceContract,
|
||||
} from "./enemyUtilitySpellBlocking";
|
||||
|
||||
beforeAll(async () => { await preloadAbilityCatalogsForClasses(["warrior", "rogue"]); });
|
||||
|
||||
const sourceSpell = enemyNpcSourceSpellBlockMetadata(72, "wow335")!;
|
||||
const utilityEffects = [
|
||||
{ kind: "interrupt", lockoutMs: 6_000 },
|
||||
@@ -28,28 +33,80 @@ const baseInput = {
|
||||
};
|
||||
|
||||
describe("ordinary NPC utility spell full-hit blocking", () => {
|
||||
it("publishes the exact Shield Bash 72 signature and block-relevant metadata", () => {
|
||||
it("publishes the exact Shield Bash, Kick, and Pummel rank contracts", () => {
|
||||
expect(enemyUtilitySpellBlockSourceContract(72, "wow335")).toMatchObject({
|
||||
spellId: 72,
|
||||
sourceProfile: "wow335",
|
||||
spellId: 72, sourceProfile: "wow335", abilityId: "wow335-warrior-shield-bash",
|
||||
classId: "warrior", abilityName: "Shield Bash",
|
||||
spellDefense: {
|
||||
damageClass: SPELL_DAMAGE_CLASS_MELEE,
|
||||
schoolMask: SPELL_SCHOOL_MASK_NORMAL,
|
||||
attributes0: 327696,
|
||||
attributes3: 8,
|
||||
directDamageFromEffects: false,
|
||||
sourceProfile: "wow335",
|
||||
customAttributeEvidence: { knownMask: 131_328, value: 0 },
|
||||
damageClass: SPELL_DAMAGE_CLASS_MELEE, schoolMask: SPELL_SCHOOL_MASK_NORMAL,
|
||||
attributes0: 327696, attributes3: 8, directDamageFromEffects: false,
|
||||
sourceProfile: "wow335", customAttributeEvidence: { knownMask: 131_328, value: 0 },
|
||||
},
|
||||
dbcEffects: [
|
||||
{ index: 0, effectType: 68, mechanic: 26, targetA: 6, triggerSpellId: 0 },
|
||||
{ index: 1, effectType: 64, mechanic: 0, targetA: 6, triggerSpellId: 29_703 },
|
||||
{ index: 0, effectType: 68, auraType: 0, mechanic: 26, targetA: 6, targetB: 0, basePoints: 0, triggerSpellId: 0 },
|
||||
{ index: 1, effectType: 64, auraType: 0, mechanic: 0, targetA: 6, targetB: 0, basePoints: 0, triggerSpellId: 29_703 },
|
||||
],
|
||||
projectedEffects: [
|
||||
{ kind: "interrupt", lockoutMs: 6_000 },
|
||||
{ kind: "trigger-spell", spellId: 29_703 },
|
||||
],
|
||||
});
|
||||
expect(enemyUtilitySpellBlockSourceContract(1766, "wow335")).toMatchObject({
|
||||
spellId: 1766, abilityId: "rogue-kick", sourceAbilityId: "wow335-rogue-kick",
|
||||
classId: "rogue", abilityName: "Kick",
|
||||
dbcEffects: [
|
||||
{ index: 0, effectType: 68, auraType: 0, mechanic: 26, targetA: 6, targetB: 0, basePoints: 1, triggerSpellId: 0 },
|
||||
{ index: 1, effectType: 0, auraType: 0, mechanic: 0, targetA: 0, targetB: 0, basePoints: 0, triggerSpellId: 0 },
|
||||
],
|
||||
projectedEffects: [{ kind: "interrupt", lockoutMs: 5_000 }],
|
||||
});
|
||||
expect(enemyUtilitySpellBlockSourceContract(6552, "wow335")).toMatchObject({
|
||||
spellId: 6552, abilityId: "wow335-warrior-pummel", classId: "warrior", abilityName: "Pummel",
|
||||
dbcEffects: [
|
||||
{ index: 0, effectType: 68, auraType: 0, mechanic: 26, targetA: 6, targetB: 0, basePoints: 1, triggerSpellId: 0 },
|
||||
{ index: 1, effectType: 0, auraType: 0, mechanic: 0, targetA: 0, targetB: 0, basePoints: 0, triggerSpellId: 0 },
|
||||
],
|
||||
projectedEffects: [{ kind: "interrupt", lockoutMs: 4_000 }],
|
||||
});
|
||||
expect(enemyUtilitySpellBlockSourceContract(72, "ascension")).toBeUndefined();
|
||||
expect(enemyUtilitySpellBlockSourceContract(1_671, "wow335")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("binds blockability to the actual selected playable ability and exact projected effects", () => {
|
||||
const cases = [
|
||||
{ classId: "warrior" as const, name: "Shield Bash", spellId: 72 },
|
||||
{ classId: "rogue" as const, name: "Kick", spellId: 1_766 },
|
||||
{ classId: "warrior" as const, name: "Pummel", spellId: 6_552 },
|
||||
];
|
||||
for (const item of cases) {
|
||||
const base = abilitiesForClass(item.classId).find((ability) => ability.name === item.name);
|
||||
expect(base, item.name).toBeDefined();
|
||||
const selected = abilityAtLevel(base!, 80);
|
||||
expect(selected.dbcSpellId, item.name).toBe(item.spellId);
|
||||
expect(selected.source, item.name).toBe("wow335-progression");
|
||||
expect(enemyUtilitySpellBlockAbilityContract(selected)?.spellId, JSON.stringify(selected)).toBe(item.spellId);
|
||||
}
|
||||
|
||||
const rawKick = WOW335_ABILITIES_BY_CLASS.rogue.find((ability) => ability.id === "wow335-rogue-kick");
|
||||
expect(rawKick).toBeDefined();
|
||||
expect(enemyUtilitySpellBlockAbilityContract(abilityAtLevel(rawKick!, 80))?.spellId).toBe(1_766);
|
||||
|
||||
const kick = abilityAtLevel(abilitiesForClass("rogue").find((ability) => ability.name === "Kick")!, 80);
|
||||
expect(enemyUtilitySpellBlockAbilityContract({ ...kick, id: "wrong-chain" })).toBeUndefined();
|
||||
expect(enemyUtilitySpellBlockAbilityContract({ ...kick, classId: "warrior" })).toBeUndefined();
|
||||
expect(enemyUtilitySpellBlockAbilityContract({ ...kick, source: "runewaker" })).toBeUndefined();
|
||||
expect(enemyUtilitySpellBlockAbilityContract({ ...kick, dbcSpellId: 6552 })).toBeUndefined();
|
||||
expect(enemyUtilitySpellBlockAbilityContract({ ...kick, effects: [
|
||||
...kick.effects, { kind: "status", status: "stun", durationMs: 1_000 },
|
||||
] })).toBeUndefined();
|
||||
expect(enemyUtilitySpellBlockAbilityContract({ ...kick, effects: [
|
||||
{ kind: "interrupt", lockoutMs: 4_000 },
|
||||
] })).toBeUndefined();
|
||||
expect(enemyUtilitySpellBlockAbilityContract({ ...kick, wowSpellDefense: {
|
||||
...kick.wowSpellDefense!, customAttributes: SPELL_ATTR0_CU_DIRECT_DAMAGE,
|
||||
} })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suppresses every utility effect after a resolved full block", () => {
|
||||
const disposition = enemyUtilitySpellBlockDisposition({ ...baseInput, outcome: "block" });
|
||||
const effectsToApply = enemyUtilityEffectsAfterBlock(utilityEffects, disposition);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type EnemyNpcSpellDefenseMetadata,
|
||||
type EnemyNpcSpellSourceProfile,
|
||||
} from "./enemyBlocking";
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
|
||||
export type EnemyUtilitySpellCasterKind = "player" | "party" | "enemy-npc" | "rom-native" | "unknown";
|
||||
export type EnemyUtilitySpellTargetKind = "ordinary-npc" | "character" | "native-rom" | "summon" | "unknown";
|
||||
@@ -11,54 +12,173 @@ export type EnemyUtilitySpellHitOutcome = "hit" | "crit" | "miss" | "dodge" | "p
|
||||
export type EnemyUtilitySpellBlockDisposition = "apply" | "blocked" | "avoided" | "fallback";
|
||||
|
||||
export interface EnemyUtilitySpellBlockSourceContract {
|
||||
readonly spellId: 72;
|
||||
readonly spellId: 72 | 1766 | 6552;
|
||||
readonly sourceProfile: "wow335";
|
||||
readonly abilityId: string;
|
||||
/** Raw imported chain ID, accepted only with the same exact source projection. */
|
||||
readonly sourceAbilityId?: string;
|
||||
readonly classId: "warrior" | "rogue";
|
||||
readonly abilityName: "Shield Bash" | "Kick" | "Pummel";
|
||||
readonly spellDefense: EnemyNpcSpellDefenseMetadata;
|
||||
/** Relevant fields from the two exact Shield Bash Spell.dbc effect slots. */
|
||||
readonly dbcEffects: readonly [
|
||||
{ readonly index: 0; readonly effectType: 68; readonly mechanic: 26; readonly targetA: 6; readonly triggerSpellId: 0 },
|
||||
{ readonly index: 1; readonly effectType: 64; readonly mechanic: 0; readonly targetA: 6; readonly triggerSpellId: 29703 },
|
||||
];
|
||||
/** Relevant source fields from the exact selected Spell.dbc rank. */
|
||||
readonly dbcEffects: readonly EnemyUtilitySpellBlockDbcEffect[];
|
||||
/** Exact projection produced by the playable catalog for this selected rank. */
|
||||
readonly projectedEffects: readonly AbilityEffect[];
|
||||
}
|
||||
|
||||
const SHIELD_BASH_72_EFFECTS = Object.freeze([
|
||||
Object.freeze({ index: 0 as const, effectType: 68 as const, mechanic: 26 as const, targetA: 6 as const, triggerSpellId: 0 as const }),
|
||||
Object.freeze({ index: 1 as const, effectType: 64 as const, mechanic: 0 as const, targetA: 6 as const, triggerSpellId: 29703 as const }),
|
||||
]) as EnemyUtilitySpellBlockSourceContract["dbcEffects"];
|
||||
interface EnemyUtilitySpellBlockDbcEffect {
|
||||
readonly index: number;
|
||||
readonly effectType: number;
|
||||
readonly auraType: number;
|
||||
readonly mechanic: number;
|
||||
readonly targetA: number;
|
||||
readonly targetB: number;
|
||||
readonly basePoints: number;
|
||||
readonly triggerSpellId: number;
|
||||
}
|
||||
|
||||
interface EnemyUtilitySpellBlockRankContract {
|
||||
readonly spellId: EnemyUtilitySpellBlockSourceContract["spellId"];
|
||||
readonly abilityId: string;
|
||||
readonly sourceAbilityId?: string;
|
||||
readonly classId: "warrior" | "rogue";
|
||||
readonly abilityName: EnemyUtilitySpellBlockSourceContract["abilityName"];
|
||||
readonly dbcEffects: readonly EnemyUtilitySpellBlockDbcEffect[];
|
||||
readonly projectedEffects: readonly AbilityEffect[];
|
||||
}
|
||||
|
||||
const UTILITY_SPELL_BLOCK_RANKS: readonly EnemyUtilitySpellBlockRankContract[] = Object.freeze([
|
||||
Object.freeze({
|
||||
spellId: 72,
|
||||
abilityId: "wow335-warrior-shield-bash",
|
||||
classId: "warrior",
|
||||
abilityName: "Shield Bash",
|
||||
dbcEffects: Object.freeze([
|
||||
Object.freeze({ index: 0, effectType: 68, auraType: 0, mechanic: 26, targetA: 6, targetB: 0, basePoints: 0, triggerSpellId: 0 }),
|
||||
Object.freeze({ index: 1, effectType: 64, auraType: 0, mechanic: 0, targetA: 6, targetB: 0, basePoints: 0, triggerSpellId: 29703 }),
|
||||
]),
|
||||
projectedEffects: Object.freeze([
|
||||
Object.freeze({ kind: "interrupt", lockoutMs: 6_000 }),
|
||||
Object.freeze({ kind: "trigger-spell", spellId: 29_703 }),
|
||||
]),
|
||||
}),
|
||||
Object.freeze({
|
||||
spellId: 1766,
|
||||
abilityId: "rogue-kick",
|
||||
sourceAbilityId: "wow335-rogue-kick",
|
||||
classId: "rogue",
|
||||
abilityName: "Kick",
|
||||
dbcEffects: Object.freeze([
|
||||
Object.freeze({ index: 0, effectType: 68, auraType: 0, mechanic: 26, targetA: 6, targetB: 0, basePoints: 1, triggerSpellId: 0 }),
|
||||
Object.freeze({ index: 1, effectType: 0, auraType: 0, mechanic: 0, targetA: 0, targetB: 0, basePoints: 0, triggerSpellId: 0 }),
|
||||
]),
|
||||
projectedEffects: Object.freeze([
|
||||
Object.freeze({ kind: "interrupt", lockoutMs: 5_000 }),
|
||||
]),
|
||||
}),
|
||||
Object.freeze({
|
||||
spellId: 6552,
|
||||
abilityId: "wow335-warrior-pummel",
|
||||
classId: "warrior",
|
||||
abilityName: "Pummel",
|
||||
dbcEffects: Object.freeze([
|
||||
Object.freeze({ index: 0, effectType: 68, auraType: 0, mechanic: 26, targetA: 6, targetB: 0, basePoints: 1, triggerSpellId: 0 }),
|
||||
Object.freeze({ index: 1, effectType: 0, auraType: 0, mechanic: 0, targetA: 0, targetB: 0, basePoints: 0, triggerSpellId: 0 }),
|
||||
]),
|
||||
projectedEffects: Object.freeze([
|
||||
Object.freeze({ kind: "interrupt", lockoutMs: 4_000 }),
|
||||
]),
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns the only audited utility full-hit-block spell contract. The rank and
|
||||
* profile are intentionally exact; the DBC effect signature is interrupt plus
|
||||
* its triggered aura, with no direct-damage effect.
|
||||
* Returns an audited utility full-block contract for an exact WoW 3.3.5a
|
||||
* selected rank. Other ranks and source profiles remain on their current path.
|
||||
*/
|
||||
export function enemyUtilitySpellBlockSourceContract(
|
||||
spellId: number | undefined,
|
||||
sourceProfile: EnemyNpcSpellSourceProfile | undefined,
|
||||
): EnemyUtilitySpellBlockSourceContract | undefined {
|
||||
if (spellId !== 72 || sourceProfile !== "wow335") return undefined;
|
||||
const spellDefense = enemyNpcSourceSpellBlockMetadata(72, "wow335");
|
||||
if (sourceProfile !== "wow335") return undefined;
|
||||
const rank = UTILITY_SPELL_BLOCK_RANKS.find((candidate) => candidate.spellId === spellId);
|
||||
if (!rank) return undefined;
|
||||
const spellDefense = enemyNpcSourceSpellBlockMetadata(rank.spellId, "wow335");
|
||||
if (!spellDefense) return undefined;
|
||||
return Object.freeze({
|
||||
spellId: 72,
|
||||
spellId: rank.spellId,
|
||||
sourceProfile: "wow335",
|
||||
abilityId: rank.abilityId,
|
||||
...(rank.sourceAbilityId ? { sourceAbilityId: rank.sourceAbilityId } : {}),
|
||||
classId: rank.classId,
|
||||
abilityName: rank.abilityName,
|
||||
spellDefense,
|
||||
dbcEffects: SHIELD_BASH_72_EFFECTS,
|
||||
dbcEffects: rank.dbcEffects,
|
||||
projectedEffects: rank.projectedEffects,
|
||||
});
|
||||
}
|
||||
|
||||
function matchesShieldBashBlockMetadata(
|
||||
function matchesUtilityBlockMetadata(
|
||||
metadata: EnemyNpcSpellDefenseMetadata | undefined,
|
||||
contract: EnemyUtilitySpellBlockSourceContract,
|
||||
reconstructSourceProof = false,
|
||||
): boolean {
|
||||
if (!metadata) return false;
|
||||
const expected = contract.spellDefense;
|
||||
return metadata.damageClass === expected.damageClass
|
||||
&& metadata.schoolMask === expected.schoolMask
|
||||
&& metadata.attributes0 === expected.attributes0
|
||||
&& metadata.attributes3 === expected.attributes3
|
||||
&& metadata.directDamageFromEffects === expected.directDamageFromEffects
|
||||
&& metadata.sourceProfile === expected.sourceProfile
|
||||
&& classifyEnemyNpcSpellBlockPhase(metadata) === "hit-table";
|
||||
if (metadata.damageClass !== expected.damageClass
|
||||
|| metadata.schoolMask !== expected.schoolMask
|
||||
|| metadata.attributes0 !== expected.attributes0
|
||||
|| metadata.attributes3 !== expected.attributes3
|
||||
|| metadata.directDamageFromEffects !== expected.directDamageFromEffects
|
||||
|| metadata.sourceProfile !== expected.sourceProfile) return false;
|
||||
|
||||
const knownCustom = expected.customAttributeEvidence;
|
||||
if (knownCustom) {
|
||||
if (metadata.customAttributes !== undefined
|
||||
&& ((metadata.customAttributes & knownCustom.knownMask) >>> 0) !== knownCustom.value) return false;
|
||||
const evidence = metadata.customAttributeEvidence;
|
||||
if (evidence && ((evidence.knownMask & knownCustom.knownMask) !== knownCustom.knownMask
|
||||
|| ((evidence.value & knownCustom.knownMask) >>> 0) !== knownCustom.value)) return false;
|
||||
}
|
||||
const withSourceProof = reconstructSourceProof
|
||||
&& metadata.customAttributes === undefined
|
||||
&& metadata.customAttributeEvidence === undefined && knownCustom
|
||||
? { ...metadata, customAttributeEvidence: knownCustom }
|
||||
: metadata;
|
||||
return classifyEnemyNpcSpellBlockPhase(withSourceProof) === "hit-table";
|
||||
}
|
||||
|
||||
function stableSignature(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableSignature).join(",")}]`;
|
||||
if (value && typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right));
|
||||
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableSignature(entry)}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? "undefined";
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the spell-block contract to the actual selected playable catalog row.
|
||||
* Matching a spell ID alone cannot authorize another class, source, wrapper,
|
||||
* or modified effect list to borrow this source proof.
|
||||
*/
|
||||
export function enemyUtilitySpellBlockAbilityContract(
|
||||
ability: AbilityDefinition | undefined,
|
||||
): EnemyUtilitySpellBlockSourceContract | undefined {
|
||||
if (!ability) return undefined;
|
||||
const contract = enemyUtilitySpellBlockSourceContract(
|
||||
ability.dbcSpellId,
|
||||
ability.wowSpellDefense?.sourceProfile,
|
||||
);
|
||||
if (!contract
|
||||
|| (ability.id !== contract.abilityId && ability.id !== contract.sourceAbilityId)
|
||||
|| ability.classId !== contract.classId
|
||||
|| ability.name !== contract.abilityName
|
||||
|| ability.source !== "wow335-progression"
|
||||
|| ability.dbcSpellId !== contract.spellId
|
||||
|| !matchesUtilityBlockMetadata(ability.wowSpellDefense, contract, true)
|
||||
|| stableSignature(ability.effects) !== stableSignature(contract.projectedEffects)) return undefined;
|
||||
return contract;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +198,7 @@ export function enemyUtilitySpellBlockDisposition(input: {
|
||||
if (!contract
|
||||
|| input.casterKind !== "player" && input.casterKind !== "party"
|
||||
|| input.targetKind !== "ordinary-npc"
|
||||
|| !matchesShieldBashBlockMetadata(input.spellDefense, contract)) return "fallback";
|
||||
|| !matchesUtilityBlockMetadata(input.spellDefense, contract)) return "fallback";
|
||||
|
||||
if (input.outcome === "block") return "blocked";
|
||||
if (input.outcome === "miss" || input.outcome === "dodge"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Generated by npm run dungeon:catalog. Do not edit.
|
||||
export default "H4sIAAAAAAACCuy9a5PjNpY2+FcYendjZyIkFO6XmtgPdrm77Z32tNfltx0bHRUTEAlmsosicygqy+WO+u8bByAlSNRdcrkrm/PB0yUkyQPg3M+Dg39MlumjW9i/umZZ1NXkNZlOlvWqSd3k9T8mqV082eKh+t5WRe6W7eT1JFtVD66uZk/FkyuLyr3qf+j/Fv19WVeT6cRVz0VTVwtXtT/Y9L19cMvJ679Nlk366sEu3KsHV7nGti57lRfP7ofSfnTNN+Fd3y2e6qZd9m/a88jCVnbZ1s3iq+XSrd/vH0j+rXXwvG0+Jrkty7lN3//75B0QlNarqnXNG9vasn6YvD765h9XVVssXPfHPS32V9fU7eObunFv6sWigCXhmclIxudCUeY0MalImcG5TC0hSlHNsnk+145tP/7Wr/Kf6/T9fkL2/ul6SZ5cWa7n8Y+JXaauChs4QejVn21WvF9+/8P/+4esaOvmVVoWrmpn6appXNW+so2zM0FfPdk2fZx98+qbr/9YlG75xv/Vq7fwbpTNU9jEpzp9DO/81tnSNd/bava2rRv74F71LDD7UDfvez5YvvLPzMInD77606fpJK2fHbxo8vpv/+j56rsMVuPRZvWH3FYPs/fOPU2mk4V9ghHGom1cTl4r+GdbtAXwFgPWbW1bpG+f7IdqOXlNsJlOmtouiuoBeGQ5eU35hr/73Ujrxk0+TXdoAB74UFTZbNnW6XubuYgMvk2GiMkgAzIM26UCn0lE5my2KCq3jL4tjywBpYMlUMMlYGd+/YMtyqJ6mKX22TVVRAPf2Qa9RQPepYEKukuDOpOExv5aN7mrZu8buyojCtQxRtBnrAKhZ5IwL236PrftY72YZc49xeugt6mQW+ugBuuAyYCKczlhVWZ2YavNtxW+aAUYplevwENVL1zjHuLPm53P8+OSyNRg6oycK4mr6r2rZqDVy0gIvXAfJoENVp+LXRL4pVyY1R9iOSDUHGMAMWBDwwebcK4wLlPblK6dLWqwT675GJGhzRF9xAZ8yIDrdsiQZ5Lx66rMbdPY95vPU3zs85ScoQ4Iv0QYmzp9P1s+FU3EDXR3K8gWFWogEELKgWKkF1ORuaf2MeIIynbkgrCYDLnHRPHhZoizeeKxLuuFrdJ4JXa5QZ/QDKCoriWgbUAxlovYOO7uhIm/LwbWgbOBaoA3nPX9hW3sKqur2DQfnf1AKwg60AoUn60WHlxeNG6WPtrlIqJBH9NMQ/+ADHfgXHnI4POLbdM4kAS6JQkDraTEQBLEuRuwfLRt6xqXzR5tWUaCIPiOIGzJARv6KUZfrRrnZV1ns3zVVDYWBMHpERLIQC0SZq4m4dGVpWeFBqKgpt1aiR1vaWs7yGAltLjaUC9bZxfPdlW28efFZauwRx2c662tqsw18/oh/ro8Igp73FVJrnZXl6V9drMnV20tvjoye7pHFAe2iZ27+rZJbdvYX6OvC3ps9ub01p8rh/O6tVWR2vjb7JhVNkMXmVzN/QuXPtrKNvHX+UVcp/nVLkEIFmelnX9siqp9jIkQlzGfvl4BLF376N6/H6hBIY8Jv9zjDlwdsULmYtbWi/nW94/xv1/j7e8Tc7UzYFfpo6tsMUubj09bGlDoY4vAT8fLTJ29Bg8FeMfLWeuaZtsc6KOKcBgz8oEilJf4h7NF3dhl7BtKc4SAPeboaq/ALVP75GZ5AyHrqqke6zJOXwh5zDYPVQPTww05l5RV+2CbzO3kcYQ6ph72SCah1xuljoKnoqpsGkeQQh3TD+coqXNXoX10s8r9soqFQsljseNAM1B2vYqEz9fpqtz+vj42+XO24FzDmK5Kn0eq89m+gEGYo2p6mE4iV+fTvHYOdNTVFgnmIk5Q6movJWvs+/bRNnW1IxMS44uslTFXB/H217/X5axyzWoef58c04+DyEnha/lhvQtl8fDYVkX1EFNBL2KGfWx5bmLpYVVlW5kMiY8ldocf1+rqfN5zUUM2BzRz/H19ZAsGgZu5VgzsY/XePrp2VpfZ7H1RPWR1FL5KcjSfM/QZGLvaZ8jr5sF5gaxXsd8kGT3mMgyYkQ+cV32ubmwKWwIFoCTTR7t4KuJ8ghT4yGIMtkRcuyVPReuXARRD/PVjDMGG0iD11cHjWiobl5cubbeXQR514YaEXK2i26J675rWpY+z5WP9ATKuGzI03dFRJCZjyJnyGBVPTZ26bNXYctbXCYf82dS/umrWOJs+xslv/ZnJqJuHum1dNYNqUJT5Vfq3XpB300nm8qIqgCGWvlQ3rBgX+6t2bdGW8OK365HkPwf1vDJ/6Mu+GbxfQ6n2lyfblzPT0i6XRbpdUf6+zjwjZe3s8eO8KUCJZkWeF+mqbD/+1TaFrdpAraetqpuFLbf+KP51CevzvV2+97Mp3bMrf7SVr0wSPWX4XbcMkFr5Zv2G77bebJ9tUdp5URb+3d0/vb/51Lilq2AnYEb/mJR12v3vPUvTProFzO0vT66CymqZJe6XJ5cVnUQuV4uFbeATfwTzmbSPTb16eEx23jRNMpc72yZFu0zWHJI0NYRH08RWWQK7lLSPLoGM1cfElsWzQ5PppKxtVlQP37vl0ldnJz807sk2RfWw+xGE4O9XVfpom9ZlXzXO/pf11P/v/rdkOENgrIemXlWwfv8La5xh2N48lLLTuqwbP2AJJXgynVTONqHwmcP/oBJ/mk7sYg4l5T/DGmw9ZvWcG/BriqqFmjjsBkaUfppOHt2iWD49usatH1u+//imf1Irk2pIoQTi1r9jg+fY7L5R8k9Tn3/1qtKWQ0oymiqTbT9HppM6z5euBdaiU4qnM/XuEwhE68ASLf0bVm29an/wqVUvc5P//pr8H4BhCCOwxvDzu+lkXroq2/5L6v/SD0R/aDPgoGe3/bfc/20/tv5zT9ATkPKhyNpHSNJjWL4wRQae6hM8VD0EhyCtq7ZeNf6l31OeUCySN1omhOiEcJpQrhKKdUIUSd4yxhNFEo5lQqicTCffM51QZpI3BJuEaJYQRRMqdUI59o+9ZZwnRibMqESpyTsAD1gAfvjVei6WK1tuZL3793SSF6Xr2DH8hh5KYIxVU05eT16FNwTYxRr18Yqx2UaRvdp6Ln106fvlajF5PaGpkDSfSyKMtILJDGAeWqQ8p/Msz1JGGc55rrnLOdZKzI0VUjuiraYu90yxLH51kPKTkoM5AT+keijdGxBUH6MTbkDtpHVZFkEX9jNc/zTDGG9PdGvo8vnuezyaNjZ5KlOMHbXE5DnXKcnmPJtr4+bOYCIYnmdGE0VzZtRcckdMppjFAueZkng9bSIIN3IwaykEk2D8BtMkh6dJbpsmGU5TSqIVEdYym2kr6dwoLbAhxCpJpBFCYsU0ZhllmgqWE4LnKU0pyTHNUiPiaUpItpw9TXp4mvS2adLhNJUggvJc6DlR0joyn4t5RmVmlVAZSxXOqBJsnimtdGqEwJbPrbAiTx3Dws6jaVJM9QXTZIenyW6bJhtOc86IlTynnIoUZ6kQuSKEckopU9jkJsuEddYInGMpLBfKWZ3mBKu5yV1q3dZu6kumyQ9Pk982TT6cJpkTnlvD51SniucaYwdTwZRrrnNhudKcYYVtPk+zuTHzeepcPpep0emcMrEtm/SCaYrD0xS3TVPsYVpiDJ9brYTN5yqlVNOcppLYueCaYUOwcFRbK0ym59nc0XlOsnQuZG5tSpyJpsmNvGQ35eFpytumKfcYmDwnOU0VzRhWmZQESzF3VuU5TqlgQDwhxFHsuJFkzsmcMZprrCifS0N4NE2h+SXTVIenqW6bphpOM5sLqzCmNrUsNdYSnROFncSOmVxSlnJNhEulcVJS7kTOqXLp3GQZY8bMdTRNySF7c/Y09eFp6tumqfcYlMzOM8kYx5mkUs+ltFRZa52iGAsxzx2Z08w5aY0g1HGaYi1zmrNcS250PE2GxSWyaQ5P09w2TTOcphZYY0dzotJUauGstk6neca0dWyeunSuhGCOcKOEdDl2AnNttDWKM2Xz2KAIQS+wm+SwF0Ru84LIHi/IYDpnOnOUs/k8Z87mLnPc6GyeU5XPsWQk48TgLGcqpyTFGGec5OAaUDJXm2kyI/Qe309JZszeaR72gshtXhDZ5wVRnTI9F5qnMs20pY5qaogxQjutOLE4l5mY59ZpJzlXmKUSwM8Zds7x2KAYTS7xgshhL4jc5gWRPV6QSKVRBmJlM5cyVQpr4ubKKeloPudEUscdkRw7inPMMGXU0ZTPLTZcqsigUMLYPk0rleZ7p3nYCyK3eUFkjxdkSOpUmhqVKUGlmFOcESOZkFIQkhmsjRbUEMvS1OaZdlwJifMUp5I5nLNIBXFGOBn67qC59m/nYTeI3OYGkT1ukAR/dj7n4CAwmeVzyqSUaWatZrnGREtNrDSaUZOJ1FInDWfS5pLmWBtlN9tJFdsjnFIxLfZO87AbRG5zg8geNwjPmaZzBvGIpNq6OU65lSlmVnCirUupY4rTucsyOtfU4XRucmYzNbc5zlXsBhGjLrAo5LAbRG5zg8geN0g7l+LMOJxjYYxUVNC5Zjw1UuQM3Abh5mSO87nUc2dSaqkRwS3kwhC2mSaTTO6ZpuIG7xfOw24Quc0NInvcIKFTqbUTTJO5FSl1GiaKtbCZVKnjmvLMMErT3ArOJU6lIM4ZzjmdE67ohmml5vtUrdZ0/zQPu0HkNjeI7HGDlM60ltZk2hBBFU7nnHDHMbNzwVJtOaE4NfNc5dSkVHBpjRTU0UwykZKcracpMDZ86O0ZReh+w3nYDSK3uUFkjxvEskwTkvJMcmbAaM6VZvM0FfNMGYIN5YxgZlWqs0xbyKPMM2GzPKMpVrlRG6bVku3ZTSUxY/umSQ+7QfQ2N4jucYP4HNvMCiMwZRysC5PKKsFym4pMCKuVtoa6DDNhZK7lnGeMC5KpfJ5TjaMQRWnDL/AP6GE3iN7mBtE9blBmTU7zLCXKzB3PFQNLwnO
Line truncated
|
||||
export default "H4sIAAAAAAACCuy9a5PjNpY2+FcYendjZyIkFO6XmtgPdrm77Z32tNfltx0bHRUTEAlmsosicygqy+WO+u8bByAlSNRdcrkrm/PB0yUkyQPg3M+Dg39MlumjW9i/umZZ1NXkNZlOlvWqSd3k9T8mqV082eKh+t5WRe6W7eT1JFtVD66uZk/FkyuLyr3qf+j/Fv19WVeT6cRVz0VTVwtXtT/Y9L19cMvJ679Nlk366sEu3KsHV7nGti57lRfP7ofSfnTNN+Fd3y2e6qZd9m/a88jCVnbZ1s3iq+XSrd/vH0j+rXXwvG0+Jrkty7lN3//75B0QlNarqnXNG9vasn6YvD765h9XVVssXPfHPS32V9fU7eObunFv6sWigCXhmclIxudCUeY0MalImcG5TC0hSlHNsnk+145tP/7Wr/Kf6/T9fkL2/ul6SZ5cWa7n8Y+JXaauChs4QejVn21WvF9+/8P/+4esaOvmVVoWrmpn6appXNW+so2zM0FfPdk2fZx98+qbr/9YlG75xv/Vq7fwbpTNU9jEpzp9DO/81tnSNd/bava2rRv74F71LDD7UDfvez5YvvLPzMInD77606fpJK2fHbxo8vpv/+j56rsMVuPRZvWH3FYPs/fOPU2mk4V9ghHGom1cTl4r+GdbtAXwFgPWbW1bpG+f7IdqOXlNsJlOmtouiuoBeGQ5eU35hr/73Ujrxk0+TXdoAB74UFTZbNnW6XubuYgMvk2GiMkgAzIM26UCn0lE5my2KCq3jL4tjywBpYMlUMMlYGd+/YMtyqJ6mKX22TVVRAPf2Qa9RQPepYEKukuDOpOExv5aN7mrZu8buyojCtQxRtBnrAKhZ5IwL236PrftY72YZc49xeugt6mQW+ugBuuAyYCKczlhVWZ2YavNtxW+aAUYplevwENVL1zjHuLPm53P8+OSyNRg6oycK4mr6r2rZqDVy0gIvXAfJoENVp+LXRL4pVyY1R9iOSDUHGMAMWBDwwebcK4wLlPblK6dLWqwT675GJGhzRF9xAZ8yIDrdsiQZ5Lx66rMbdPY95vPU3zs85ScoQ4Iv0QYmzp9P1s+FU3EDXR3K8gWFWogEELKgWKkF1ORuaf2MeIIynbkgrCYDLnHRPHhZoizeeKxLuuFrdJ4JXa5QZ/QDKCoriWgbUAxlovYOO7uhIm/LwbWgbOBaoA3nPX9hW3sKqur2DQfnf1AKwg60AoUn60WHlxeNG6WPtrlIqJBH9NMQ/+ADHfgXHnI4POLbdM4kAS6JQkDraTEQBLEuRuwfLRt6xqXzR5tWUaCIPiOIGzJARv6KUZfrRrnZV1ns3zVVDYWBMHpERLIQC0SZq4m4dGVpWeFBqKgpt1aiR1vaWs7yGAltLjaUC9bZxfPdlW28efFZauwRx2c662tqsw18/oh/ro8Igp73FVJrnZXl6V9drMnV20tvjoye7pHFAe2iZ27+rZJbdvYX6OvC3ps9ub01p8rh/O6tVWR2vjb7JhVNkMXmVzN/QuXPtrKNvHX+UVcp/nVLkEIFmelnX9siqp9jIkQlzGfvl4BLF376N6/H6hBIY8Jv9zjDlwdsULmYtbWi/nW94/xv1/j7e8Tc7UzYFfpo6tsMUubj09bGlDoY4vAT8fLTJ29Bg8FeMfLWeuaZtsc6KOKcBgz8oEilJf4h7NF3dhl7BtKc4SAPeboaq/ALVP75GZ5AyHrqqke6zJOXwh5zDYPVQPTww05l5RV+2CbzO3kcYQ6ph72SCah1xuljoKnoqpsGkeQQh3TD+coqXNXoX10s8r9soqFQsljseNAM1B2vYqEz9fpqtz+vj42+XO24FzDmK5Kn0eq89m+gEGYo2p6mE4iV+fTvHYOdNTVFgnmIk5Q6movJWvs+/bRNnW1IxMS44uslTFXB/H217/X5axyzWoef58c04+DyEnha/lhvQtl8fDYVkX1EFNBL2KGfWx5bmLpYVVlW5kMiY8ldocf1+rqfN5zUUM2BzRz/H19ZAsGgZu5VgzsY/XePrp2VpfZ7H1RPWR1FL5KcjSfM/QZGLvaZ8jr5sF5gaxXsd8kGT3mMgyYkQ+cV32ubmwKWwIFoCTTR7t4KuJ8ghT4yGIMtkRcuyVPReuXARRD/PVjDMGG0iD11cHjWiobl5cubbeXQR514YaEXK2i26J675rWpY+z5WP9ATKuGzI03dFRJCZjyJnyGBVPTZ26bNXYctbXCYf82dS/umrWOJs+xslv/ZnJqJuHum1dNYNqUJT5Vfq3XpB300nm8qIqgCGWvlQ3rBgX+6t2bdGW8OK365HkPwf1vDJ/6Mu+GbxfQ6n2lyfblzPT0i6XRbpdUf6+zjwjZe3s8eO8KUCJZkWeF+mqbD/+1TaFrdpAraetqpuFLbf+KP51CevzvV2+97Mp3bMrf7SVr0wSPWX4XbcMkFr5Zv2G77bebJ9tUdp5URb+3d0/vb/51Lilq2AnYEb/mJR12v3vPUvTProFzO0vT66CymqZJe6XJ5cVnUQuV4uFbeATfwTzmbSPTb16eEx23jRNMpc72yZFu0zWHJI0NYRH08RWWQK7lLSPLoGM1cfElsWzQ5PppKxtVlQP37vl0ldnJz807sk2RfWw+xGE4O9XVfpom9ZlXzXO/pf11P/v/rdkOENgrIemXlWwfv8La5xh2N48lLLTuqwbP2AJJXgynVTONqHwmcP/oBJ/mk7sYg4l5T/DGmw9ZvWcG/BriqqFmjjsBkaUfppOHt2iWD49usatH1u+//imf1Irk2pIoQTi1r9jg+fY7L5R8k9Tn3/1qtKWQ0oymiqTbT9HppM6z5euBdaiU4qnM/XuEwhE68ASLf0bVm29an/wqVUvc5P//pr8H4BhCCOwxvDzu+lkXroq2/5L6v/SD0R/aDPgoGe3/bfc/20/tv5zT9ATkPKhyNpHSNJjWL4wRQae6hM8VD0EhyCtq7ZeNf6l31OeUCySN1omhOiEcJpQrhKKdUIUSd4yxhNFEo5lQqicTCffM51QZpI3BJuEaJYQRRMqdUI59o+9ZZwnRibMqESpyTsAD1gAfvjVei6WK1tuZL3793SSF6Xr2DH8hh5KYIxVU05eT16FNwTYxRr18Yqx2UaRvdp6Ln106fvlajF5PZGpNLlihmmWp0bnnOOcaKK0SHlOVapyZtI8nROdGavnwqaaakolxQrPcyVAjotf3eS11pgpzqfeD6keSvcGBNXH6ERLUDtpXZZF0IX9DNc/zTDG2xPdGrp8vvsej6aNTZ7KFGNHLTF5znVKsjnP5tq4uTOYCIbnmdFE0ZwZNZfcEZMpZrHAeaYkXk+bCMKNHMxaCsEkGL/BNMnhaZLbpkn27K4kWhFhLbOZtpLOjdICG0KskkQaISRWTGOWUaapYDkheJ7SlJIc0yw1Ip6mhGTL2dOkh6dJb5smHU5TCSIoz4WeEyWtI/O5mGdUZlYJlbFU4YwqweaZ0kqnRghs+dwKK/LUMSzsPJomxVRfME12eJrstmmy4TTnjFjJc8qpSHGWCpErQiinlDKFTW6yTFhnjcA5lsJyoZzVaU6wmpvcpdZt7aa+ZJr88DT5bdPkw2mSOeG5NXxOdap4rjF2MBVMueY6F5YrzRlW2ObzNJsbM5+nzuVzmRqdzikT27JJL5imODxNcds0xR6mJcbwudVK2HyuUko1zWkqiZ0Lrhk2BAtHtbXCZHqezR2d5yRL50Lm1qbEmWia3MhLdlMenqa8bZpyOE2a5ySnqaIZwyqTkmAp5s6qPMcpFQyIJ4Q4ih03ksw5mTNGc40V5XNpCI+mKTS/ZJrq8DTVbdNUw2lmc2EVxtSmlqXGWqJzorCT2DGTS8pSrolwqTROSsqdyDlVLp2bLGPMmLmOpik55hdMUx+epr5tmnqPQcnsPJOMcZxJKvVcSkuVtdYpirEQ89yROc2ck9YIQh2nKdYypznLteRGx9NkWFwim+bwNM1t0zTDaWqBNXY0JypNpRbOaut0mmdMW8fmqUvnSgjmCDdKSJdjJzDXRlujOFM2jw2KEPQCu0kOe0HkNi+I7PGCDKZzpjNHOZvPc+Zs7jLHjc7mOVX5HEtGMk4MznKmckpSjHHGSQ6uASVztZkmM0LjoWwqyYzZO83DXhC5zQsi+7wgqlOm50LzVKaZttRRTQ0xRminFScW5zIT89w67STnCrNUAvg5w845HhsUo8klXhA57AWR27wgsscLEqk0ykCsbOZSpkphTdxcOSUdzeecSOq4I5JjR3GOGaaMOpryucWGSxUZFEoY26dppdJ87zQPe0HkNi+I7PGCDEmdSlOjMiWoFHOKM2IkE1IKQjKDtdGCGmJZmto8044rIXGe4lQyh3MWqSDOCCdD3x001/7tPOwGkdvcILLHDZLgz87nHBwEJrN8TpmUMs2s1SzXEFRpYqXRjJpMpJY6aTiTNpc0x9oou9lOqtge4ZSKabF3mofdIHKbG0T2uEF4zjSdM4hHJNXWzXHKrUwxs4ITbV1KHVOczl2W0bmmDqdzkzObqbnNca5iN4gYdYFFIYfdIHKbG0T2uEHauRRnxuEcC2OkooLONeOpkSJn4DYINydznM+lnjuTUkuNCG4hF4awzTSZZHLPNBU3eL9wHnaDyG1uENnjBgmdSq2dYJrMrUip0zBRrIXNpEod15RnhlGa5lZwLnEqBXHOcM7pnHBFN0wrNd+narWm+6d52A0it7lBZI8bpHSmtbQm04YIqnA654Q7jpmdC5ZqywnFqZnnKqcmpYJLa6SgjmaSiZTkbD1NgbHZkzQxitD9hvOwG0Ruc4PIHjeIZZkmJOWZ5MyA0ZwrzeZpKuaZMgQbyhnBzKpUZ5m2kEeZZ8JmeUZTrHKjNkyrJduzm0pixvZNkx52g+htbhDd4wbxObaZFUZgyjhYFyaVVYLlNhWZEFYrbQ11GWbCyFzLOc8YFyRT+TynGkchitKGX+Af0MNuEL3NDaJ73KDMmpzmWUqUmTueKwaWhOc
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
|
||||
File diff suppressed because it is too large.
Load diff
@@ -68390,9 +68390,9 @@
|
||||
{
|
||||
"id": "visual",
|
||||
"url": "/assets/game/manastorm/33-shadowfang/visual.glb",
|
||||
"checksum": "2c562fb61596a536d728385c4f2bdfdc3230f4f84ef40875b9a568e18a82ef9d",
|
||||
"byteLength": 8566408,
|
||||
"triangleCount": 560149,
|
||||
"checksum": "6c69f739383fc98f440f181785c4f27c7f39cfcb18d9a8b5ac8282262070bf75",
|
||||
"byteLength": 8803744,
|
||||
"triangleCount": 560186,
|
||||
"compression": "meshopt+ktx2",
|
||||
"transform": {
|
||||
"position": [
|
||||
@@ -97715,9 +97715,9 @@
|
||||
{
|
||||
"id": "visual",
|
||||
"url": "/assets/game/manastorm/575-utgardepinnacle/visual.glb",
|
||||
"checksum": "26ecf5398de8b836e829b9fa39a4e78f7d8aaf60e0daf37c94dfdf3a4944c722",
|
||||
"byteLength": 59344144,
|
||||
"triangleCount": 5185049,
|
||||
"checksum": "bffaf126d0f65b2863d54418b1889f9e62adf5562491c3e74e6a5c5e05664844",
|
||||
"byteLength": 29792924,
|
||||
"triangleCount": 4816703,
|
||||
"compression": "meshopt+ktx2",
|
||||
"transform": {
|
||||
"position": [
|
||||
|
||||
@@ -67005,9 +67005,9 @@
|
||||
{
|
||||
"id": "visual",
|
||||
"url": "/assets/game/manastorm/33-shadowfang/visual.glb",
|
||||
"checksum": "2c562fb61596a536d728385c4f2bdfdc3230f4f84ef40875b9a568e18a82ef9d",
|
||||
"byteLength": 8566408,
|
||||
"triangleCount": 560149,
|
||||
"checksum": "6c69f739383fc98f440f181785c4f27c7f39cfcb18d9a8b5ac8282262070bf75",
|
||||
"byteLength": 8803744,
|
||||
"triangleCount": 560186,
|
||||
"compression": "meshopt+ktx2",
|
||||
"transform": {
|
||||
"position": [
|
||||
@@ -93841,9 +93841,9 @@
|
||||
{
|
||||
"id": "visual",
|
||||
"url": "/assets/game/manastorm/575-utgardepinnacle/visual.glb",
|
||||
"checksum": "26ecf5398de8b836e829b9fa39a4e78f7d8aaf60e0daf37c94dfdf3a4944c722",
|
||||
"byteLength": 59344144,
|
||||
"triangleCount": 5185049,
|
||||
"checksum": "bffaf126d0f65b2863d54418b1889f9e62adf5562491c3e74e6a5c5e05664844",
|
||||
"byteLength": 29792924,
|
||||
"triangleCount": 4816703,
|
||||
"compression": "meshopt+ktx2",
|
||||
"transform": {
|
||||
"position": [
|
||||
|
||||
@@ -4401,9 +4401,16 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [
|
||||
"navigation": {
|
||||
"id": "navigation",
|
||||
"fileName": "raksha-temple-navigation.glb",
|
||||
"checksum": "1e1d1ad26907dc8c4fd0a917dba40b52d2aaaea4a0335af06871ee28230eddf1",
|
||||
"size": 8686564,
|
||||
"triangleCount": 180952
|
||||
"checksum": "77b2b22bb89474bfe2bd9974d9f58cd680634d4f7feef6163723bb8f3afe6117",
|
||||
"size": 166048,
|
||||
"detourTopology": {
|
||||
"schemaVersion": 1,
|
||||
"polygonCount": 239,
|
||||
"directedLinkCount": 514,
|
||||
"detailTriangleCount": 653,
|
||||
"assetChecksum": "77b2b22bb89474bfe2bd9974d9f58cd680634d4f7feef6163723bb8f3afe6117"
|
||||
},
|
||||
"triangleCount": 653
|
||||
}
|
||||
},
|
||||
"transform": {
|
||||
@@ -4435,9 +4442,9 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [
|
||||
"entrance": {
|
||||
"name": "Raksha Temple Entrance",
|
||||
"footPosition": [
|
||||
0.021786,
|
||||
10.664467,
|
||||
17.560761
|
||||
-0.020312,
|
||||
20.837463,
|
||||
11.334656
|
||||
],
|
||||
"forward": [
|
||||
-0.173648,
|
||||
@@ -4451,9 +4458,9 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [
|
||||
"id": "entrance",
|
||||
"name": "Raksha Temple Entrance",
|
||||
"center": [
|
||||
0.021786,
|
||||
10.664467,
|
||||
17.560761
|
||||
-0.020312,
|
||||
20.837463,
|
||||
11.334656
|
||||
],
|
||||
"radius": 24
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { usePartyStore, createConfiguredPartyMember } from "./partyStore";
|
||||
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||
import { HEALER_MANA_RECOVERY_ID, synchronizeHealerManaRecovery } from "./healerManaRecovery";
|
||||
|
||||
const START = 100_000;
|
||||
|
||||
function prepare(enabled = true) {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest", raceId: "rom-human", gender: "male", role: "healer",
|
||||
statRuleset: "rom", level: 30, settings: { healerManaRecovery: enabled },
|
||||
});
|
||||
const state = useCombatStore.getState();
|
||||
useCombatStore.setState({ resource: 0, resourcePools: { ...state.resourcePools, mana: 0 } });
|
||||
return state.maxResource;
|
||||
}
|
||||
|
||||
describe("healer out-of-combat mana recovery", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useDungeonSimulationStore.getState().leave();
|
||||
usePartyStore.setState({ members: [], command: "stop", activeMobId: null, memberTargetIds: {} });
|
||||
});
|
||||
|
||||
it("restores 15% per second to a native RoM player before the native pulse, even after spending mana", () => {
|
||||
const maximum = prepare();
|
||||
useCombatStore.setState({ lastResourceSpentAt: { mana: START } });
|
||||
useCombatStore.getState().tick(0.5, START + 500);
|
||||
expect(useCombatStore.getState().resource).toBeCloseTo(maximum * 0.075);
|
||||
expect(useCombatStore.getState().resourcePools.mana).toBeCloseTo(maximum * 0.075);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === HEALER_MANA_RECOVERY_ID)).toBe(true);
|
||||
});
|
||||
|
||||
it("stops in combat even with the party stopped and resumes after the last enemy dies", () => {
|
||||
const maximum = prepare();
|
||||
const combat = useCombatStore.getState();
|
||||
combat.registerMob("recovery-enemy", { name: "Recovery enemy", maxHealth: 100, xpReward: 0 });
|
||||
combat.damageMob("recovery-enemy", 1, START);
|
||||
combat.tick(0.5, START + 500);
|
||||
expect(useCombatStore.getState().resource).toBe(0);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === HEALER_MANA_RECOVERY_ID)).toBe(false);
|
||||
combat.damageMob("recovery-enemy", 100, START + 500);
|
||||
combat.tick(0.5, START + 1_000);
|
||||
expect(useCombatStore.getState().resource).toBeCloseTo(maximum * 0.075);
|
||||
});
|
||||
|
||||
it("honors the saved off switch, removes the buff immediately, and clamps to maximum", () => {
|
||||
const maximum = prepare(false);
|
||||
useCombatStore.getState().tick(0.5, START + 500);
|
||||
expect(useCombatStore.getState().resource).toBe(0);
|
||||
useCombatStore.getState().updateSettings({ healerManaRecovery: true });
|
||||
useCombatStore.setState({ resource: maximum - 1, resourcePools: { mana: maximum - 1 } });
|
||||
useCombatStore.getState().tick(0.25, START + 750);
|
||||
expect(useCombatStore.getState().resource).toBe(maximum);
|
||||
useCombatStore.getState().updateSettings({ healerManaRecovery: false });
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === HEALER_MANA_RECOVERY_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it("restores a companion's own maximum mana without creating other resource pools", () => {
|
||||
prepare();
|
||||
const base = createConfiguredPartyMember({ name: "Recovery mage", classId: "rom-mage",
|
||||
raceId: "rom-human", gender: "male", specialization: "Primary", role: "damage", statRuleset: "rom",
|
||||
}, 30, 0, "healer-recovery");
|
||||
const member = { ...base, resource: 0, resourcePools: { ...base.resourcePools, mana: 0 },
|
||||
nextActionAt: Number.MAX_SAFE_INTEGER };
|
||||
usePartyStore.setState({ members: [member] });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
advanceDungeonPartyCombat(START);
|
||||
advanceDungeonPartyCombat(START + 500);
|
||||
const recovered = usePartyStore.getState().members[0];
|
||||
expect(recovered.resourcePools.mana).toBeCloseTo(base.maxResource * 0.075);
|
||||
expect(recovered.resource).toBe(recovered.resourcePools.mana);
|
||||
expect(Object.keys(recovered.resourcePools)).toEqual(Object.keys(base.resourcePools));
|
||||
});
|
||||
|
||||
it("uses an NPC healer for a damage-role player and stops when the healer dies", () => {
|
||||
const maximum = prepare();
|
||||
useCombatStore.setState({ gearRole: "damage" });
|
||||
const healer = createConfiguredPartyMember({ name: "Recovery healer", classId: "priest",
|
||||
specialization: "Discipline", specializationId: "priest-discipline", role: "healer",
|
||||
}, 30, 0, "recovery-npc-provider");
|
||||
usePartyStore.setState({ members: [healer] });
|
||||
useCombatStore.getState().tick(0.25, START + 250);
|
||||
expect(useCombatStore.getState().resource).toBeCloseTo(maximum * 0.0375);
|
||||
usePartyStore.setState({ members: [{ ...healer, health: 0 }] });
|
||||
useCombatStore.getState().tick(0.25, START + 500);
|
||||
expect(useCombatStore.getState().resource).toBeCloseTo(maximum * 0.0375);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === HEALER_MANA_RECOVERY_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a living healer, excludes dead and non-mana recipients, and never stacks providers", () => {
|
||||
const actors = [
|
||||
{ id: "healer-1", role: "healer", health: 100, maxMana: 200 },
|
||||
{ id: "healer-2", role: "healer", health: 100, maxMana: 400 },
|
||||
{ id: "dead", role: "damage", health: 0, maxMana: 200 },
|
||||
{ id: "warrior", role: "tank", health: 100, maxMana: 0 },
|
||||
];
|
||||
const auras = synchronizeHealerManaRecovery([], actors, true, false, START);
|
||||
expect(auras.map((aura) => aura.targetId)).toEqual(["healer-1", "healer-2"]);
|
||||
expect(synchronizeHealerManaRecovery(auras, actors, true, false, START + 500)).toHaveLength(2);
|
||||
expect(synchronizeHealerManaRecovery(auras, actors.map((actor) => ({ ...actor, health: 0 })), true, false, START)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { applyAura, type ActiveAura, type AuraDefinition } from "./combatAuras";
|
||||
|
||||
export const HEALER_MANA_RECOVERY_ID = "healer-mana-recovery";
|
||||
export const HEALER_MANA_RECOVERY_RATE = 0.15;
|
||||
|
||||
const DEFINITION: AuraDefinition = {
|
||||
id: HEALER_MANA_RECOVERY_ID,
|
||||
name: "Healer Mana Recovery",
|
||||
description: "Restores 15% of maximum mana per second to living party members while out of combat. Requires a living healer. Toggle in Options.",
|
||||
disposition: "buff",
|
||||
icon: "/assets/ui/spells/spell_frost_manarecharge.png",
|
||||
durationMs: null,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
utilityTags: [HEALER_MANA_RECOVERY_ID],
|
||||
};
|
||||
|
||||
export interface RecoveryActor {
|
||||
readonly id: string;
|
||||
readonly health: number;
|
||||
readonly role: string;
|
||||
readonly maxMana: number;
|
||||
}
|
||||
|
||||
/** One provider and one application per recipient, regardless of healer count. */
|
||||
export function synchronizeHealerManaRecovery(
|
||||
auras: readonly ActiveAura[],
|
||||
actors: readonly RecoveryActor[],
|
||||
enabled: boolean,
|
||||
inCombat: boolean,
|
||||
now: number,
|
||||
): readonly ActiveAura[] {
|
||||
const provider = enabled && !inCombat
|
||||
? actors.find((actor) => actor.health > 0 && actor.role === "healer")
|
||||
: undefined;
|
||||
const recipients = new Set(provider
|
||||
? actors.filter((actor) => actor.health > 0 && actor.maxMana > 0).map((actor) => actor.id)
|
||||
: []);
|
||||
let result = auras.filter((aura) => aura.definition.id !== HEALER_MANA_RECOVERY_ID
|
||||
|| (aura.sourceId === provider?.id && recipients.has(aura.targetId)));
|
||||
for (const targetId of recipients) {
|
||||
if (result.some((aura) => aura.definition.id === HEALER_MANA_RECOVERY_ID && aura.targetId === targetId)) continue;
|
||||
result = [...applyAura(result, { definition: DEFINITION, sourceId: provider!.id, targetId, now }).auras];
|
||||
}
|
||||
return result.length === auras.length && result.every((aura, index) => aura === auras[index])
|
||||
? auras : result;
|
||||
}
|
||||
|
||||
export function healerManaRecoveryAmount(
|
||||
auras: readonly ActiveAura[], targetId: string, maximum: number, deltaSeconds: number,
|
||||
): number {
|
||||
return auras.some((aura) => aura.definition.id === HEALER_MANA_RECOVERY_ID && aura.targetId === targetId)
|
||||
? Math.max(0, maximum) * HEALER_MANA_RECOVERY_RATE * Math.max(0, deltaSeconds)
|
||||
: 0;
|
||||
}
|
||||
@@ -31,6 +31,56 @@ describe("enemy damage type source metadata", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retains exact WoW hit-source attribute words through attack normalization", () => {
|
||||
expect(enemyDamageTypeSourceForSpell("wow335", 38_163)).toMatchObject({
|
||||
hitSource: { attributes0: 0x0008_0000, attributes3: 0x0004_0000, attributes7: 0 },
|
||||
});
|
||||
expect(resolveEnemyDamageType({ origin: "wow335", spellId: 38_163 })).toMatchObject({
|
||||
hitSource: { attributes0: 0x0008_0000, attributes3: 0x0004_0000, attributes7: 0 },
|
||||
});
|
||||
|
||||
const alwaysHit = normalizeEnemyAttack({
|
||||
id: "wow-38163",
|
||||
name: "Source Hit-Flag Fixture",
|
||||
spellId: 38_163,
|
||||
damageOrigin: "wow335",
|
||||
delivery: "projectile",
|
||||
target: "primary",
|
||||
school: "shadow",
|
||||
attackKind: "spell",
|
||||
animation: "cast",
|
||||
range: 20,
|
||||
cooldownMs: 4_000,
|
||||
damageMultiplier: 1,
|
||||
});
|
||||
expect(alwaysHit.wowHitSource).toEqual({
|
||||
attributes0: 0x0008_0000,
|
||||
attributes3: 0x0004_0000,
|
||||
attributes7: 0,
|
||||
});
|
||||
|
||||
// Spell 122 has a real source Attributes3 value of zero; preserve it.
|
||||
const knownZeroSource = enemyDamageTypeSourceForSpell("wow335", 122);
|
||||
expect(knownZeroSource && "hitSource" in knownZeroSource ? knownZeroSource.hitSource : undefined).toEqual({
|
||||
attributes0: 0x4001_0000,
|
||||
attributes3: 0,
|
||||
attributes7: 0,
|
||||
});
|
||||
expect(normalizeEnemyAttack({
|
||||
id: "wow-122",
|
||||
name: "Known Zero Attribute Fixture",
|
||||
spellId: 122,
|
||||
damageOrigin: "wow335",
|
||||
delivery: "projectile",
|
||||
target: "primary",
|
||||
school: "frost",
|
||||
animation: "cast",
|
||||
range: 20,
|
||||
cooldownMs: 4_000,
|
||||
damageMultiplier: 1,
|
||||
}).wowHitSource).toEqual({ attributes0: 0x4001_0000, attributes3: 0, attributes7: 0 });
|
||||
});
|
||||
|
||||
it("keeps multi-school masks intact and uses authored school as the runtime fallback", () => {
|
||||
expect(wowSchoolsFromSchoolMask(20)).toEqual({
|
||||
schools: ["fire", "frost"],
|
||||
@@ -60,6 +110,34 @@ describe("enemy damage type source metadata", () => {
|
||||
school: "fire",
|
||||
sourceVerified: false,
|
||||
});
|
||||
expect(resolveEnemyDamageType({ origin: "wow335", spellId: 999_999, school: "fire" }))
|
||||
.not.toHaveProperty("hitSource");
|
||||
expect(normalizeEnemyAttack({
|
||||
id: "unknown-hit-source",
|
||||
name: "Unknown Hit Source",
|
||||
spellId: 999_999,
|
||||
damageOrigin: "wow335",
|
||||
delivery: "projectile",
|
||||
target: "primary",
|
||||
school: "fire",
|
||||
animation: "cast",
|
||||
range: 20,
|
||||
cooldownMs: 4_000,
|
||||
damageMultiplier: 1,
|
||||
})).not.toHaveProperty("wowHitSource");
|
||||
expect(normalizeEnemyAttack({
|
||||
id: "rw-native-hit-source",
|
||||
name: "RuneWaker Native Source",
|
||||
spellId: 12_675,
|
||||
damageOrigin: "runewaker",
|
||||
delivery: "projectile",
|
||||
target: "primary",
|
||||
school: "fire",
|
||||
animation: "cast",
|
||||
range: 20,
|
||||
cooldownMs: 4_000,
|
||||
damageMultiplier: 1,
|
||||
})).not.toHaveProperty("wowHitSource");
|
||||
expect(resolveEnemyDamageType({
|
||||
origin: "wow335",
|
||||
damageClass: 3,
|
||||
@@ -120,6 +198,8 @@ describe("enemy damage type source metadata", () => {
|
||||
school: "shadow",
|
||||
sourceVerified: true,
|
||||
});
|
||||
expect(resolveEnemyDamageType({ origin: "runewaker", spellId: 12_675 }))
|
||||
.not.toHaveProperty("hitSource");
|
||||
|
||||
const unknownCalculation = enemyDamageTypeComponentForSpell(490_045, 500_056);
|
||||
expect(unknownCalculation).toMatchObject({
|
||||
|
||||
@@ -11,11 +11,19 @@ export type EnemyDamageSchool =
|
||||
| "shadow"
|
||||
| "holy";
|
||||
|
||||
export interface WowEnemyHitSource {
|
||||
readonly attributes0: number;
|
||||
readonly attributes3: number;
|
||||
readonly attributes7: number;
|
||||
}
|
||||
|
||||
export interface WowEnemyDamageTypeSource {
|
||||
readonly spellId: number;
|
||||
readonly name: string;
|
||||
readonly damageClass: number;
|
||||
readonly schoolMask: number;
|
||||
/** Raw hit-gate attribute words copied from a matched WoW Spell.dbc row. */
|
||||
readonly hitSource?: WowEnemyHitSource;
|
||||
readonly schools: readonly EnemyDamageSchool[];
|
||||
readonly school?: EnemyDamageSchool;
|
||||
readonly attackKind?: EnemyAttackKind;
|
||||
@@ -69,6 +77,8 @@ export interface ResolvedEnemyDamageType {
|
||||
readonly damageClass?: number;
|
||||
readonly sourceDamageClass?: "none" | "magic" | "melee" | "ranged" | "physical" | "magical";
|
||||
readonly schoolMask?: number;
|
||||
/** Copied only from a matched WoW damage row; absent means unknown/not WoW. */
|
||||
readonly hitSource?: WowEnemyHitSource;
|
||||
readonly schools: readonly EnemyDamageSchool[];
|
||||
/** Present only when source evidence identifies exactly one runtime school. */
|
||||
readonly school?: EnemyDamageSchool;
|
||||
@@ -188,6 +198,7 @@ export function resolveEnemyDamageType(input: ResolveEnemyDamageTypeInput): Reso
|
||||
...(damageClass !== undefined ? { damageClass } : {}),
|
||||
...(sourceDamageClass ? { sourceDamageClass } : {}),
|
||||
...(schoolMask !== undefined ? { schoolMask } : {}),
|
||||
...(wowSource?.hitSource ? { hitSource: wowSource.hitSource } : {}),
|
||||
schools: maskResult.schools.length ? maskResult.schools : runewakerComponent?.school ? [runewakerComponent.school] : [],
|
||||
...(school ? { school } : {}),
|
||||
...(maskResult.unknownBits ? { unknownSchoolMaskBits: maskResult.unknownBits } : {}),
|
||||
|
||||
+62
-20
@@ -1,3 +1,4 @@
|
||||
import { healerManaRecoveryAmount } from "./healerManaRecovery";
|
||||
import { nativeDisarmedMainHandAutoAttackProfile, nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount";
|
||||
import { romNativePlayerHitProfile } from "./romNativeCombatResolution";
|
||||
import { classifyEnemyNpcSpellBlockPhase } from "./enemyBlocking";
|
||||
@@ -8,7 +9,11 @@ import {
|
||||
enemyDisarmAbilityWeaponChannel,
|
||||
enemyDisarmBlocksAbilityCast,
|
||||
enemyDisarmRequirementCandidates,
|
||||
enemySpellHasMatchingEquippedItem,
|
||||
} from "./enemyDisarmRuntime";
|
||||
import { WOW335_HUNTER_DETERRENCE_ABILITY_ID, wow335DeterrenceAbilityContract,
|
||||
wow335DeterrenceActivePackage } from "./wow335DeterrenceRuntime";
|
||||
import { wow335HunterPreventionTypeForAbility } from "./wow335AbilityCatalog";
|
||||
import type { EnemyDisarmWeaponChannel } from "./enemyWeaponDebuffs";
|
||||
import {
|
||||
abilitiesForClass,
|
||||
@@ -36,6 +41,7 @@ import {
|
||||
auraAdjustedCharacterStats,
|
||||
effectiveActorAuras,
|
||||
equipmentAdjustedCombatAmount,
|
||||
refreshHealerManaRecovery,
|
||||
refreshPriestPainAndSufferingDot,
|
||||
stableCombatUnit,
|
||||
useCombatStore,
|
||||
@@ -189,7 +195,7 @@ import {
|
||||
} from "./partyAbilityAi";
|
||||
import { minimumHostileTargetsForAbility } from "./combatRotation";
|
||||
import { emitRotationDecision } from "./rotationTelemetry";
|
||||
import { imminentCooldownMechanic } from "./companionCooldownPolicy";
|
||||
import { imminentCooldownMechanic, currentCompanionCooldownPolicy } from "./companionCooldownPolicy";
|
||||
import { normalizeActorResourcePool, type DeathKnightRuneState } from "./combatActors";
|
||||
import { enemyControlPreventsActions } from "./enemyAttacks";
|
||||
import { enemyControlAbilityCanEscapeAuras } from "./enemyControlImmunityPurge";
|
||||
@@ -2290,6 +2296,9 @@ function maybeProcPartyWarriorUnbridledWrath(
|
||||
|
||||
/** Weapon swings continue during the GCD and supply Rage for paid attacks. */
|
||||
function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: number): PartyMember {
|
||||
if (useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||
&& aura.definition.utilityTags?.includes("pacified"))) return member;
|
||||
const hasMaelstromWeaponTalent = member.classId === "shaman"
|
||||
&& (member.talentRanks["talent-2057"] ?? 0) > 0;
|
||||
if (partyUsesRangedAttacks(member) && baseClassFor(member.classId) !== "hunter"
|
||||
@@ -2605,6 +2614,10 @@ export function partyAbilityTargetIsValid(
|
||||
castCanMove = false,
|
||||
): boolean {
|
||||
const combat = useCombatStore.getState();
|
||||
if (wow335HunterPreventionTypeForAbility(ability) === 2 && combat.auras.some((aura) => (
|
||||
aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now)
|
||||
&& aura.definition.utilityTags?.includes("pacified")
|
||||
))) return false;
|
||||
if (ability.wowWeaponRequirements) {
|
||||
const loadout = resolvedEquipmentLoadout(combat.equipment, combat.inventory, {
|
||||
ownerId: member.id,
|
||||
@@ -2615,7 +2628,10 @@ export function partyAbilityTargetIsValid(
|
||||
role: member.role,
|
||||
level: member.level,
|
||||
});
|
||||
if (enemyDisarmBlocksAbilityCast(
|
||||
const equippedItems = enemyDisarmRequirementCandidates(loadout);
|
||||
if ((ability.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID
|
||||
&& !enemySpellHasMatchingEquippedItem(ability.wowWeaponRequirements, equippedItems))
|
||||
|| enemyDisarmBlocksAbilityCast(
|
||||
combat.auras,
|
||||
member.id,
|
||||
now,
|
||||
@@ -2625,7 +2641,7 @@ export function partyAbilityTargetIsValid(
|
||||
passive: ability.passive,
|
||||
createsAura: ability.effects.some((effect) => effect.kind === "apply-aura"),
|
||||
},
|
||||
enemyDisarmRequirementCandidates(loadout),
|
||||
equippedItems,
|
||||
)) return false;
|
||||
}
|
||||
if (!coaWitchDoctorCanBrewIngredient(ability, combat.auras, member.id, member.talentRanks, now)) return false;
|
||||
@@ -5192,6 +5208,7 @@ function membersDiffer(left: readonly PartyMember[], right: readonly PartyMember
|
||||
* tank can redirect that tick's pressure before the death/respawn boundary.
|
||||
*/
|
||||
export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
refreshHealerManaRecovery(now);
|
||||
const combat = useCombatStore.getState();
|
||||
const party = usePartyStore.getState();
|
||||
if (!party.members.length) return;
|
||||
@@ -5321,6 +5338,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
manaSpiritRegenerationPerSecond: type === "mana" ? member.gearStats.spirit * 0.02 : 0,
|
||||
}).current;
|
||||
}
|
||||
if (living && member.resourcePools.mana !== undefined && nativeManaMaximum > 0) {
|
||||
resourcePools.mana = Math.min(nativeManaMaximum, (resourcePools.mana ?? 0)
|
||||
+ healerManaRecoveryAmount(combat.auras, member.id, nativeManaMaximum, deltaSeconds));
|
||||
}
|
||||
const angerManagement = member.classId === "warrior"
|
||||
? wow335WarriorAngerManagementProfile(
|
||||
member.talentRanks[WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID] ?? 0,
|
||||
@@ -5402,22 +5423,18 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
&& enemyControlPreventsActions(member.controlMechanic)
|
||||
) {
|
||||
const controlCombat = useCombatStore.getState();
|
||||
const berserkerRageBase = member.classId === "warrior"
|
||||
? abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||
.find((ability) => ability.dbcSpellId === 18499)
|
||||
: undefined;
|
||||
const berserkerRage = berserkerRageBase
|
||||
? learnedAbilityForBuild(berserkerRageBase, member.level, member.talentRanks)
|
||||
: null;
|
||||
const mechanic = member.controlMechanic;
|
||||
if (berserkerRage && !member.activeCast && (mechanic === "fear" || mechanic === "stun")
|
||||
&& enemyControlAbilityCanEscapeAuras(
|
||||
berserkerRage, mechanic, controlCombat.auras, member.id, now,
|
||||
)
|
||||
&& partyAbilityTargetIsValid(member, berserkerRage, member.id, undefined, now)) {
|
||||
const committed = commitPartyAbilityUseWithRogueCooldown(member, berserkerRage, now);
|
||||
const escapeAbility = !member.activeCast && (mechanic === "fear" || mechanic === "stun" || mechanic === "confuse")
|
||||
? abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
|
||||
.find((ability) => ability && enemyControlAbilityCanEscapeAuras(
|
||||
ability, mechanic, controlCombat.auras, member.id, now,
|
||||
) && partyAbilityTargetIsValid(member, ability, member.id, undefined, now))
|
||||
: undefined;
|
||||
if (escapeAbility) {
|
||||
const committed = commitPartyAbilityUseWithRogueCooldown(member, escapeAbility, now);
|
||||
if (committed) {
|
||||
const execution = executePartyAbility(committed, berserkerRage, member.id, now, nextMembers);
|
||||
const execution = executePartyAbility(committed, escapeAbility, member.id, now, nextMembers);
|
||||
const partyStateAfterGrant = usePartyStore.getState().members.find((candidate) => candidate.id === member.id);
|
||||
const actorAfterGrant = useCombatStore.getState().actors[member.id];
|
||||
const controlledUntil = actorAfterGrant?.controlledUntil
|
||||
@@ -5429,8 +5446,9 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
...execution.member,
|
||||
controlledUntil,
|
||||
controlMechanic: controlledUntil > now ? controlMechanic : null,
|
||||
status: targetId ? member.role === "tank" ? "guarding" : "attacking" : "ready",
|
||||
nextActionAt: now + Math.max(250, berserkerRage.castTimeMs, berserkerRage.gcdMs),
|
||||
status: controlledUntil > now && enemyControlPreventsActions(controlMechanic)
|
||||
? "holding" : targetId ? member.role === "tank" ? "guarding" : "attacking" : "ready",
|
||||
nextActionAt: now + Math.max(250, escapeAbility.castTimeMs, escapeAbility.gcdMs),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
@@ -5440,7 +5458,31 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
: { ...member, status: "holding" };
|
||||
continue;
|
||||
}
|
||||
if (combat.auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes("pacified"))) {
|
||||
const hunterDeterrence = member.classId === "hunter"
|
||||
? abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
|
||||
.find((ability): ability is AbilityDefinition => Boolean(ability
|
||||
&& ability.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID && wow335DeterrenceAbilityContract(ability)))
|
||||
: undefined;
|
||||
const activeHunterDeterrence = hunterDeterrence && wow335DeterrenceActivePackage({
|
||||
ability: hunterDeterrence, auras: combat.auras, defenderId: member.id, nowMs: now,
|
||||
});
|
||||
if (!member.activeCast && hunterDeterrence && !activeHunterDeterrence
|
||||
&& Object.values(combat.mobs).some((mob) => !mob.dead && mob.engaged && mob.targetActorId === member.id
|
||||
&& (member.health / Math.max(1, member.maxHealth)
|
||||
< currentCompanionCooldownPolicy().generic.defensiveHealthPercent / 100
|
||||
|| Boolean(imminentCooldownMechanic(mob, member.role, now))))
|
||||
&& partyAbilityIsReady(member, hunterDeterrence, now)
|
||||
&& partyAbilityTargetIsValid(member, hunterDeterrence, member.id, undefined, now)) {
|
||||
const committed = commitPartyAbilityUseWithRogueCooldown(member, hunterDeterrence, now);
|
||||
if (committed) {
|
||||
const execution = executePartyAbility(committed, hunterDeterrence, member.id, now, nextMembers);
|
||||
nextMembers[index] = { ...execution.member, status: "holding", nextActionAt: now + 250 };
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!activeHunterDeterrence && combat.auras.some((aura) => aura.targetId === member.id
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes("pacified"))) {
|
||||
nextMembers[index] = member.status === "holding" ? member : { ...member, status: "holding" };
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
||||
import { clearMobRuntimeRegistry, registerMobRuntime } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { ROM_SCOUT_AUTO_SHOT_ABILITY_ID } from "./romScoutAutoShot";
|
||||
|
||||
const TARGET = "pacify-weapon-swing-fixture";
|
||||
const ORIGIN = [0, 0, 0] as const;
|
||||
const FRONT = [0, 0, -2] as const;
|
||||
|
||||
function prepare(pacified: boolean, targetId = PLAYER_AGGRO_ID) {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 30 });
|
||||
usePartyStore.setState({ members: [] });
|
||||
useCombatStore.getState().setPlayerPosition(ORIGIN);
|
||||
useCombatStore.getState().registerMob(TARGET, { name: "Synthetic autoattack target",
|
||||
level: 1, maxHealth: 1_000_000, xpReward: 0 });
|
||||
registerMobRuntime(TARGET, FRONT, 0);
|
||||
registerCombatSpatialProvider({ actor: (id) => id === TARGET
|
||||
? { id, position: FRONT, yaw: 0 } : { id, position: ORIGIN, yaw: Math.PI },
|
||||
hasLineOfSight: () => true, projectGroundPoint: (position) => position });
|
||||
useCombatStore.setState((state) => ({ autoAttackTargetId: TARGET,
|
||||
nextAutoAttackAt: 1_000, nextOffhandAutoAttackAt: 1_000,
|
||||
auras: pacified ? [...applyAura(state.auras, { definition: {
|
||||
id: "synthetic-source-pacify", name: "Accepted pacify aura fixture",
|
||||
disposition: "buff", durationMs: 2_000, utilityTags: ["pacified"],
|
||||
}, sourceId: targetId, targetId, now: 1_000 }).auras] : state.auras,
|
||||
}));
|
||||
}
|
||||
function playerSwings() {
|
||||
return useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === PLAYER_AGGRO_ID
|
||||
&& event.targetActorId === TARGET && event.attackHand === "mainhand");
|
||||
}
|
||||
|
||||
describe("accepted pacify aura suppresses player weapon autos", () => {
|
||||
beforeAll(async () => { await preloadAbilityCatalogsForClasses(["warrior", "rom-scout", "rom-rogue"]); });
|
||||
afterEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetCombatSpatialProvider(); });
|
||||
|
||||
it("holds due swings through the whole aura window and resumes once at expiry", () => {
|
||||
prepare(true);
|
||||
for (const now of [1_000, 1_500, 2_999]) useCombatStore.getState().tick(0.05, now);
|
||||
expect(playerSwings()).toHaveLength(0);
|
||||
expect(useCombatStore.getState().mobs[TARGET]!.health).toBe(1_000_000);
|
||||
expect(useCombatStore.getState().autoAttackTargetId).toBe(TARGET);
|
||||
useCombatStore.getState().tick(0.05, 3_000);
|
||||
expect(playerSwings()).toHaveLength(1);
|
||||
expect(useCombatStore.getState().nextAutoAttackAt).toBeGreaterThan(3_000);
|
||||
useCombatStore.getState().tick(0.05, 3_001);
|
||||
expect(playerSwings()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not borrow another actor's pacify aura", () => {
|
||||
prepare(true, "another-actor");
|
||||
useCombatStore.getState().tick(0.05, 1_000);
|
||||
expect(playerSwings()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("also holds the owned Scout ranged-auto scheduler until pacify expires", () => {
|
||||
prepare(false);
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-scout", secondaryClassId: "rom-rogue",
|
||||
raceId: "rom-human", statRuleset: "rom", level: 30, inventory: [], equipment: {} });
|
||||
useCombatStore.getState().registerMob(TARGET, { name: "Synthetic ranged auto target",
|
||||
level: 1, maxHealth: 1_000_000, xpReward: 0 });
|
||||
useCombatStore.getState().setPlayerPosition(ORIGIN);
|
||||
// Synthetic already-selected ranged mode; the real native scheduler and
|
||||
// automatic Training Gear profile resolve subsequent weapon results.
|
||||
useCombatStore.setState((state) => ({ autoShotTargetId: TARGET, nextAutoShotAt: 1_000,
|
||||
auras: [...applyAura(state.auras, { definition: {
|
||||
id: "synthetic-ranged-pacify", name: "Accepted ranged pacify fixture",
|
||||
disposition: "buff", durationMs: 2_000, utilityTags: ["pacified"],
|
||||
}, sourceId: PLAYER_AGGRO_ID, targetId: PLAYER_AGGRO_ID, now: 1_000 }).auras],
|
||||
}));
|
||||
const rangedEvents = () => useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === PLAYER_AGGRO_ID
|
||||
&& event.abilityId === ROM_SCOUT_AUTO_SHOT_ABILITY_ID && event.attackHand === "ranged");
|
||||
for (const now of [1_000, 1_500, 2_999]) useCombatStore.getState().tick(0.05, now);
|
||||
expect(rangedEvents()).toHaveLength(0);
|
||||
useCombatStore.getState().tick(0.05, 3_000);
|
||||
expect(rangedEvents()).toHaveLength(1);
|
||||
useCombatStore.getState().tick(0.05, 3_001);
|
||||
expect(rangedEvents()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { GLTFLoader } from "three-stdlib";
|
||||
import { expect, it } from "vitest";
|
||||
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
|
||||
import {
|
||||
findNearestPartyNavigationSurface,
|
||||
findPartyNavigationPath,
|
||||
type PartyNavigationDetourTopology,
|
||||
} from "./partyNavigation";
|
||||
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
|
||||
|
||||
it("loads Raksha's source deck without inventing a connection to Shadoj", async () => {
|
||||
const definition = DUNGEON_REGISTRY["raksha-temple"];
|
||||
const asset = definition.assets.navigation!;
|
||||
const file = `src/assets/game/dungeons/raksha-temple/${asset.fileName}`;
|
||||
const bytes = await readFile(file);
|
||||
const checksum = createHash("sha256").update(bytes).digest("hex");
|
||||
expect(asset.checksum).toBe(checksum);
|
||||
expect(asset.detourTopology?.assetChecksum).toBe(checksum);
|
||||
|
||||
const gltf = await new GLTFLoader().parseAsync(
|
||||
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), "",
|
||||
);
|
||||
const graph = preparePartyNavigationGraph(
|
||||
file, gltf.scene, [], [], undefined, false,
|
||||
gltf.userData.runewakerNavigationTopology as PartyNavigationDetourTopology,
|
||||
asset.detourTopology, undefined, checksum,
|
||||
);
|
||||
const authoredEntry = [-0.020312, 21.013313, 11.334656] as const;
|
||||
const surface = findNearestPartyNavigationSurface(graph, authoredEntry, 1);
|
||||
expect(surface?.distance).toBeLessThan(0.25);
|
||||
expect(surface?.position[1]).toBeGreaterThan(20.7);
|
||||
expect(surface?.position[1]).toBeLessThan(21);
|
||||
expect(definition.entrance.footPosition[1]).toBeGreaterThan(20.7);
|
||||
expect(findNearestPartyNavigationSurface(graph, definition.entrance.footPosition, 1)?.distance)
|
||||
.toBeLessThan(0.01);
|
||||
expect(findPartyNavigationPath(graph, authoredEntry, [-0.367608, 20.837463, 13.304272], {
|
||||
maxStartSnapDistance: 1, maxEndSnapDistance: 1,
|
||||
})).not.toBeNull();
|
||||
expect(findPartyNavigationPath(graph, authoredEntry, [-115.048229, 9.971153, 284.713953], {
|
||||
maxStartSnapDistance: 1, maxEndSnapDistance: 6,
|
||||
})).toBeNull();
|
||||
}, 120_000);
|
||||
@@ -60,6 +60,7 @@ function advanceFromStartToPulse(startAt: number): void {
|
||||
|
||||
function configuredNativeFive(): readonly PartyMember[] {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
settings: { healerManaRecovery: false },
|
||||
classId: "rom-priest",
|
||||
raceId: "rom-human",
|
||||
gender: "male",
|
||||
@@ -192,6 +193,7 @@ describe("native RoM recovery cadence integration", () => {
|
||||
|
||||
it("regenerates secondary Mana for a Rogue/Priest NPC while its primary Energy pool advances normally", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
settings: { healerManaRecovery: false },
|
||||
classId: "rom-priest",
|
||||
raceId: "rom-human",
|
||||
gender: "male",
|
||||
@@ -241,6 +243,7 @@ describe("native RoM recovery cadence integration", () => {
|
||||
|
||||
it("keeps shared WoW regeneration for WoW-mode RoM players and NPCs, clearing stale native clocks", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
settings: { healerManaRecovery: false },
|
||||
classId: "rom-mage",
|
||||
raceId: "rom-human",
|
||||
gender: "male",
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { abilityAtLevel, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
|
||||
import {
|
||||
abilityAtLevel,
|
||||
abilityById,
|
||||
isAbilityUnlocked,
|
||||
preloadAbilityCatalogsForClasses,
|
||||
type AbilityDefinition,
|
||||
type AbilityEffect,
|
||||
} from "./abilityCatalog";
|
||||
import { aiHintsForAbility, targetingRulesForAbility } from "./abilityRules";
|
||||
import {
|
||||
WOW335_ABILITIES_BY_CLASS,
|
||||
WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID,
|
||||
WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||
wow335HunterPreventionTypeForAbility,
|
||||
} from "./wow335AbilityCatalog";
|
||||
import {
|
||||
WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS,
|
||||
WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS,
|
||||
} from "./wow335DeterrenceRuntime";
|
||||
import generatedData from "./wow335AbilityData.generated.json";
|
||||
import { talentNodeById } from "./talentCatalog";
|
||||
import { runtimeTalentNodeById } from "./talentRuntimeCatalog";
|
||||
@@ -27,6 +40,138 @@ function effectsOfKind<K extends AbilityEffect["kind"]>(
|
||||
}
|
||||
|
||||
describe("WoW 3.3.5 executable effect mapping", () => {
|
||||
it("selects Hunter Deterrence 19263 and exposes its complete linked aura package", () => {
|
||||
expect(generatedData.classes.hunter.some((chain) => chain.name === "Deterrence"
|
||||
|| chain.ranks.some((rank) => [19_263, 65_871, 67_801].includes(rank.spellId)))).toBe(false);
|
||||
|
||||
const deterrence = byId("wow335-hunter-deterrence");
|
||||
const catalogDeterrence = abilityById("wow335-hunter-deterrence");
|
||||
expect(catalogDeterrence).toMatchObject({
|
||||
id: "wow335-hunter-deterrence",
|
||||
classId: "hunter",
|
||||
dbcSpellId: 19_263,
|
||||
source: "wow335-progression",
|
||||
});
|
||||
expect(isAbilityUnlocked(catalogDeterrence!, 60)).toBe(true);
|
||||
expect(isAbilityUnlocked(catalogDeterrence!, 59)).toBe(false);
|
||||
expect(WOW335_ABILITIES_BY_CLASS.hunter.filter((ability) => ability.name === "Deterrence")).toHaveLength(1);
|
||||
expect(deterrence).toMatchObject({
|
||||
id: "wow335-hunter-deterrence",
|
||||
classId: "hunter",
|
||||
source: "wow335-progression",
|
||||
dbcSpellId: 19_263,
|
||||
name: "Deterrence",
|
||||
unlockLevel: 60,
|
||||
sourceUnlockLevel: 60,
|
||||
target: "self",
|
||||
range: { min: 0, max: 0 },
|
||||
castMode: "instant",
|
||||
castTimeMs: 0,
|
||||
cooldownMs: 90_000,
|
||||
gcdMs: 0,
|
||||
cost: { resource: "mana", amount: 0 },
|
||||
wowSpellDefense: {
|
||||
damageClass: 0,
|
||||
schoolMask: 1,
|
||||
attributes0: 327_696,
|
||||
attributes1: 16,
|
||||
attributes3: 0,
|
||||
attributes5: 0,
|
||||
directDamageFromEffects: false,
|
||||
sourceProfile: "wow335",
|
||||
},
|
||||
wowWeaponRequirements: {
|
||||
equippedItemClass: 2,
|
||||
equippedItemSubclassMask: 173_555,
|
||||
equippedItemInventoryMask: 0,
|
||||
},
|
||||
});
|
||||
expect(abilityAtLevel(deterrence, 80)).toMatchObject({
|
||||
dbcSpellId: 19_263,
|
||||
description: "When activated, increases parry chance by 100% and grants a 100% chance to deflect spells. While Deterrence is active, you cannot attack. Lasts 5 sec.",
|
||||
cooldownMs: 90_000,
|
||||
gcdMs: 0,
|
||||
cost: { resource: "mana", amount: 0 },
|
||||
wowWeaponRequirements: {
|
||||
equippedItemClass: 2,
|
||||
equippedItemSubclassMask: 173_555,
|
||||
equippedItemInventoryMask: 0,
|
||||
},
|
||||
});
|
||||
expect(effectsOfKind(abilityAtLevel(deterrence, 80), "apply-aura")
|
||||
.map((effect) => effect.aura.durationMs)).toEqual([5_000, 5_000, 5_000]);
|
||||
expect(deterrence.effects).toEqual(WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS);
|
||||
expect(WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS[1]?.kind === "apply-aura"
|
||||
? WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS[1].aura.utilityTags
|
||||
: undefined).toBeUndefined();
|
||||
expect(effectsOfKind(deterrence, "apply-aura").map((effect) => [
|
||||
effect.sourceEffectType,
|
||||
effect.sourceAuraType,
|
||||
effect.sourceBasePoints,
|
||||
effect.aura.durationMs,
|
||||
])).toEqual([
|
||||
[6, 47, 99, 5_000],
|
||||
[6, 287, 99, 5_000],
|
||||
[6, 25, -1, 5_000],
|
||||
]);
|
||||
|
||||
expect(WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS.map((effect) => {
|
||||
expect(effect.kind).toBe("apply-aura");
|
||||
if (effect.kind !== "apply-aura") return [];
|
||||
return [effect.aura.id, effect.sourceEffectType, effect.sourceAuraType, effect.sourceBasePoints, effect.aura.durationMs];
|
||||
})).toEqual([
|
||||
["wow335:67801:effect:0:aura:288", 6, 288, 99, 5_000],
|
||||
["wow335:67801:effect:1:aura:185", 6, 185, -101, 5_000],
|
||||
]);
|
||||
const linkedRangedHit = WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS[1];
|
||||
expect(linkedRangedHit?.kind === "apply-aura" ? linkedRangedHit.aura.modifiers : undefined).toEqual([
|
||||
{ kind: "stat", stat: "incoming-ranged-hit", operation: "flat", value: -100 },
|
||||
]);
|
||||
const linkedIgnoreFacing = WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS[0];
|
||||
expect(linkedIgnoreFacing?.kind === "apply-aura" ? linkedIgnoreFacing.aura.utilityTags : undefined).toBeUndefined();
|
||||
expect(WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID[67_801]).toBeUndefined();
|
||||
expect(WOW335_ABILITIES_BY_CLASS.hunter.some((ability) => ability.dbcSpellId === 67_801)).toBe(false);
|
||||
expect(WOW335_ABILITIES_BY_CLASS.hunter.some((ability) => ability.dbcSpellId === 65_871)).toBe(false);
|
||||
});
|
||||
|
||||
it("retains pinned PreventionType for every selected Hunter rank and merged public aliases", async () => {
|
||||
const selectedSpellIds = new Set<number>();
|
||||
for (const ability of WOW335_ABILITIES_BY_CLASS.hunter) {
|
||||
for (const rank of ability.ranks ?? []) {
|
||||
selectedSpellIds.add(rank.spellId);
|
||||
expect(WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID[rank.spellId]).toBeDefined();
|
||||
expect(wow335HunterPreventionTypeForAbility({ ...ability, dbcSpellId: rank.spellId }))
|
||||
.toBe(WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID[rank.spellId]);
|
||||
}
|
||||
}
|
||||
expect(selectedSpellIds.size).toBe(190);
|
||||
expect([...selectedSpellIds].sort((left, right) => left - right)).toEqual(
|
||||
Object.keys(WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID).map(Number).sort((left, right) => left - right),
|
||||
);
|
||||
|
||||
await preloadAbilityCatalogsForClasses(["hunter"]);
|
||||
const publicArcaneShot = abilityById("hunter-arcane-shot");
|
||||
expect(publicArcaneShot).toMatchObject({ id: "hunter-arcane-shot", classId: "hunter", source: "wow335-progression" });
|
||||
expect(wow335HunterPreventionTypeForAbility(abilityAtLevel(publicArcaneShot!, 80))).toBe(2);
|
||||
const publicSteadyShot = abilityById("hunter-steady-shot");
|
||||
expect(wow335HunterPreventionTypeForAbility(abilityAtLevel(publicSteadyShot!, 80))).toBe(2);
|
||||
|
||||
const arcaneShot = byId("wow335-hunter-arcane-shot");
|
||||
const selectedArcaneRank = { ...arcaneShot, dbcSpellId: 3_044 };
|
||||
expect(wow335HunterPreventionTypeForAbility({ ...selectedArcaneRank, classId: "mage" })).toBeUndefined();
|
||||
expect(wow335HunterPreventionTypeForAbility({ ...selectedArcaneRank, source: "core" })).toBeUndefined();
|
||||
expect(wow335HunterPreventionTypeForAbility({ ...selectedArcaneRank, source: "ascension-progression" })).toBeUndefined();
|
||||
expect(wow335HunterPreventionTypeForAbility({ ...selectedArcaneRank, name: "Deterrence" })).toBeUndefined();
|
||||
expect(wow335HunterPreventionTypeForAbility({ ...selectedArcaneRank, id: "foreign-hunter-arcane-shot" })).toBeUndefined();
|
||||
expect(wow335HunterPreventionTypeForAbility({ ...selectedArcaneRank, dbcSpellId: 999_999 })).toBeUndefined();
|
||||
expect(wow335HunterPreventionTypeForAbility({ ...selectedArcaneRank, dbcSpellId: 65_871 })).toBeUndefined();
|
||||
expect(wow335HunterPreventionTypeForAbility({ ...selectedArcaneRank, dbcSpellId: 67_801 })).toBeUndefined();
|
||||
expect(WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID[19_263]).toBe(0);
|
||||
expect(WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID[3044]).toBe(2);
|
||||
expect(WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID[13_813]).toBe(1);
|
||||
expect(WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID[136]).toBe(1);
|
||||
});
|
||||
|
||||
it("resolves tooltip tokens that explicitly reference another DBC spell", () => {
|
||||
const chains = Object.values(generatedData.classes).flat();
|
||||
const dancingRuneWeapon = chains.find((chain) => chain.name === "Dancing Rune Weapon");
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
} from "./wow335WarlockMetamorphosisRuntime";
|
||||
import { WOW335_WARLOCK_SOUL_LINK_TAG } from "./wow335WarlockSoulLinkRuntime";
|
||||
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
|
||||
import { WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS } from "./wow335DeterrenceRuntime";
|
||||
import { WOW335_DETERRENCE_SOURCE_SIGNATURES } from "./wow335Deterrence";
|
||||
|
||||
export const BASE_CLASS_STARTER_SOURCE_LEVEL = 10;
|
||||
|
||||
@@ -50,6 +52,7 @@ interface Wow335Effect {
|
||||
readonly basePoints: number;
|
||||
readonly dieSides: number;
|
||||
readonly pointsPerLevel: number;
|
||||
readonly mechanic?: number;
|
||||
readonly implicitTargetA: number;
|
||||
readonly implicitTargetB: number;
|
||||
readonly radius: number;
|
||||
@@ -100,6 +103,126 @@ const data = generatedData as {
|
||||
readonly classes: Readonly<Record<BaseClassId, readonly Wow335Chain[]>>;
|
||||
};
|
||||
|
||||
const WOW335_HUNTER_DETERRENCE_CHAIN: Wow335Chain = Object.freeze({
|
||||
id: "wow335-hunter-deterrence",
|
||||
name: "Deterrence",
|
||||
iconBasename: "ability_whirlwind",
|
||||
iconPath: "/assets/ui/spells/ability_whirlwind.png",
|
||||
sourceUnlockLevel: 60,
|
||||
ranks: Object.freeze([Object.freeze({
|
||||
spellId: 19263,
|
||||
rank: 0,
|
||||
level: 60,
|
||||
sourceLevel: 60,
|
||||
maxScalingLevel: 0,
|
||||
description: "When activated, increases parry chance by 100% and grants a 100% chance to deflect spells. While Deterrence is active, you cannot attack. Lasts 5 sec.",
|
||||
castTimeMs: 0,
|
||||
cooldownMs: 90_000,
|
||||
gcdMs: 0,
|
||||
durationMs: 5_000,
|
||||
channelDurationMs: 0,
|
||||
powerType: 0,
|
||||
powerCost: 0,
|
||||
powerCostPercentage: 0,
|
||||
rangeIndex: 1,
|
||||
rangeMin: 0,
|
||||
rangeMax: 0,
|
||||
equippedItemClass: 2,
|
||||
equippedItemSubclassMask: 173_555,
|
||||
equippedItemInventoryMask: 0,
|
||||
wowSpellDefense: Object.freeze({
|
||||
damageClass: 0,
|
||||
schoolMask: 1,
|
||||
attributes0: 327_696,
|
||||
attributes1: 16,
|
||||
attributes3: 0,
|
||||
attributes5: 0,
|
||||
directDamageFromEffects: false,
|
||||
sourceProfile: "wow335" as const,
|
||||
}),
|
||||
effects: Object.freeze(WOW335_DETERRENCE_SOURCE_SIGNATURES.selectedParent.effects.map((effect) => ({
|
||||
...effect,
|
||||
coefficient: [1, 1, 0][effect.index] ?? 0,
|
||||
}))),
|
||||
})]),
|
||||
});
|
||||
|
||||
const WOW335_HUNTER_DETERRENCE_DESCRIPTION = WOW335_HUNTER_DETERRENCE_CHAIN.ranks[0]!.description;
|
||||
|
||||
function isExactHunterDeterrenceChain(chain: Wow335Chain): boolean {
|
||||
const [rank] = chain.ranks;
|
||||
const source = WOW335_DETERRENCE_SOURCE_SIGNATURES.selectedParent;
|
||||
if (chain.id !== "wow335-hunter-deterrence"
|
||||
|| chain.name !== "Deterrence"
|
||||
|| chain.iconBasename !== "ability_whirlwind"
|
||||
|| chain.iconPath !== "/assets/ui/spells/ability_whirlwind.png"
|
||||
|| chain.sourceUnlockLevel !== 60
|
||||
|| chain.ranks.length !== 1
|
||||
|| !rank
|
||||
|| rank.spellId !== source.spellId
|
||||
|| rank.rank !== 0
|
||||
|| rank.level !== 60
|
||||
|| rank.sourceLevel !== 60
|
||||
|| rank.maxScalingLevel !== 0
|
||||
|| rank.description !== WOW335_HUNTER_DETERRENCE_DESCRIPTION
|
||||
|| rank.castTimeMs !== 0
|
||||
|| rank.cooldownMs !== 90_000
|
||||
|| rank.gcdMs !== 0
|
||||
|| rank.durationMs !== 5_000
|
||||
|| rank.channelDurationMs !== 0
|
||||
|| rank.powerType !== 0
|
||||
|| rank.powerCost !== 0
|
||||
|| rank.powerCostPercentage !== 0
|
||||
|| rank.rangeIndex !== 1
|
||||
|| rank.rangeMin !== 0
|
||||
|| rank.rangeMax !== 0
|
||||
|| rank.equippedItemClass !== 2
|
||||
|| rank.equippedItemSubclassMask !== 173_555
|
||||
|| rank.equippedItemInventoryMask !== 0) return false;
|
||||
|
||||
const defense = rank.wowSpellDefense;
|
||||
if (!defense
|
||||
|| defense.damageClass !== 0
|
||||
|| defense.schoolMask !== 1
|
||||
|| defense.attributes0 !== 327_696
|
||||
|| defense.attributes1 !== 16
|
||||
|| defense.attributes3 !== 0
|
||||
|| defense.attributes5 !== 0
|
||||
|| defense.directDamageFromEffects !== false
|
||||
|| defense.sourceProfile !== "wow335") return false;
|
||||
|
||||
return rank.effects.length === source.effects.length
|
||||
&& rank.effects.every((effect, index) => {
|
||||
const expected = source.effects[index];
|
||||
return expected !== undefined
|
||||
&& effect.index === expected.index
|
||||
&& effect.effectType === expected.effectType
|
||||
&& effect.auraType === expected.auraType
|
||||
&& effect.basePoints === expected.basePoints
|
||||
&& effect.dieSides === expected.dieSides
|
||||
&& effect.pointsPerLevel === expected.pointsPerLevel
|
||||
&& (effect.mechanic ?? 0) === expected.mechanic
|
||||
&& effect.implicitTargetA === expected.implicitTargetA
|
||||
&& effect.implicitTargetB === expected.implicitTargetB
|
||||
&& effect.radius === expected.radius
|
||||
&& effect.periodMs === expected.periodMs
|
||||
&& effect.miscValue === expected.miscValue
|
||||
&& effect.triggerSpellId === expected.triggerSpellId
|
||||
&& effect.coefficient === [1, 1, 0][index];
|
||||
});
|
||||
}
|
||||
|
||||
function hunterChainsWithSelectedDeterrence(): readonly Wow335Chain[] {
|
||||
const chains = data.classes.hunter;
|
||||
const candidates = chains.filter((chain) => chain.name === "Deterrence"
|
||||
|| chain.ranks.some((rank) => [19_263, 65_871, 67_801].includes(rank.spellId)));
|
||||
if (candidates.length === 0) return [...chains, WOW335_HUNTER_DETERRENCE_CHAIN];
|
||||
if (candidates.length === 1 && isExactHunterDeterrenceChain(candidates[0]!)) return chains;
|
||||
// A conflicting same-name/linked-ID import is source drift. Drop the whole
|
||||
// candidate group and fail closed rather than substitute 65871 or 67801.
|
||||
return chains.filter((chain) => !candidates.includes(chain));
|
||||
}
|
||||
|
||||
const RESOURCE_BY_CLASS: Readonly<Record<BaseClassId, ResourceType>> = {
|
||||
warrior: "rage",
|
||||
paladin: "mana",
|
||||
@@ -2386,7 +2509,15 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
||||
?? auraMasteryTalentTooltip;
|
||||
return talentTooltip ? { ...rank, description: talentTooltip } : rank;
|
||||
});
|
||||
const first = ranks[0];
|
||||
const selectedDeterrence = classId === "hunter"
|
||||
&& chain.id === "wow335-hunter-deterrence"
|
||||
&& chain.name === "Deterrence"
|
||||
&& castRanks.length === 1
|
||||
&& castRanks[0]?.spellId === 19_263;
|
||||
const mappedRanks = selectedDeterrence
|
||||
? ranks.map((rank) => ({ ...rank, effects: WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS }))
|
||||
: ranks;
|
||||
const first = mappedRanks[0];
|
||||
const firstRaw = castRanks[0];
|
||||
if (!first || !firstRaw) return null;
|
||||
const effectiveSourceUnlockLevel = classId === "warrior" && chain.name === "Sweeping Strikes"
|
||||
@@ -2458,7 +2589,7 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
||||
talentNodeId: chain.talentNodeId && runtimeTalentNodeById(chain.talentNodeId)
|
||||
? chain.talentNodeId
|
||||
: undefined,
|
||||
ranks,
|
||||
ranks: mappedRanks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2499,7 +2630,8 @@ function classicCooldownGroup(classId: BaseClassId, abilityName: string): string
|
||||
|
||||
export const WOW335_ABILITIES_BY_CLASS: Readonly<Record<BaseClassId, readonly AbilityDefinition[]>> =
|
||||
Object.freeze(Object.fromEntries(BASE_CLASS_IDS.map((classId) => {
|
||||
const generated = data.classes[classId]
|
||||
const sourceChains = classId === "hunter" ? hunterChainsWithSelectedDeterrence() : data.classes[classId];
|
||||
const generated = sourceChains
|
||||
.map((chain) => abilityDefinition(classId, chain))
|
||||
.filter((ability): ability is AbilityDefinition => Boolean(ability));
|
||||
const specialized = classId === "warlock"
|
||||
@@ -2514,6 +2646,82 @@ export const WOW335_ABILITIES_BY_CLASS: Readonly<Record<BaseClassId, readonly Ab
|
||||
))];
|
||||
}))) as unknown as Readonly<Record<BaseClassId, readonly AbilityDefinition[]>>;
|
||||
|
||||
export type Wow335HunterPreventionType = 0 | 1 | 2;
|
||||
|
||||
// Spell.dbc field 214 in the pinned 234-field layout (field 220 in the
|
||||
// selected 239-field archive) is kept locally until the generator has a
|
||||
// source-profile-aware way to carry this cast gate. Every value below was
|
||||
// read from the pinned Spell.dbc row; missing IDs intentionally remain absent.
|
||||
const WOW335_HUNTER_PREVENTION_SPELL_IDS_BY_TYPE: Readonly<Record<Wow335HunterPreventionType, readonly number[]>> = Object.freeze({
|
||||
0: Object.freeze([
|
||||
23_989, 1_494, 1_130, 14_323, 14_324, 14_325, 53_338, 883, 2_641, 6_991,
|
||||
1_515, 19_883, 19_884, 781, 1_462, 19_885, 3_045, 19_880, 5_384,
|
||||
19_878, 19_574, 19_882, 24_131, 24_134, 24_135, 27_069, 49_009, 49_010,
|
||||
34_471, 19_879, 62_757, 19_263,
|
||||
]),
|
||||
1: Object.freeze([
|
||||
13_163, 13_165, 14_318, 14_319, 14_320, 14_321, 14_322, 25_296, 27_044,
|
||||
982, 136, 3_111, 3_661, 3_662, 13_542, 13_543, 13_544, 27_046, 48_989,
|
||||
48_990, 6_197, 1_002, 1_513, 14_326, 14_327, 5_118, 13_795, 13_797, 14_298,
|
||||
14_302, 14_299, 14_303, 14_300, 14_304, 14_301, 14_305, 27_023, 27_024,
|
||||
49_053, 49_055, 49_054, 49_056, 34_074, 1_499, 14_310, 14_311, 13_809,
|
||||
13_161, 19_577, 1_543, 13_813, 14_316, 14_317, 27_025, 49_066, 49_067,
|
||||
13_159, 19_506, 20_043, 20_190, 27_045,
|
||||
49_071, 34_026, 34_600, 61_846, 61_847, 60_192,
|
||||
]),
|
||||
2: Object.freeze([
|
||||
75, 2_973, 14_260, 14_261, 14_262, 14_263, 14_264, 14_265, 14_266, 27_014,
|
||||
48_995, 48_996, 1_978, 13_549, 13_550, 13_551, 13_552, 13_553, 13_554,
|
||||
13_555, 25_295, 27_016, 49_000, 49_001, 3_044, 14_281, 14_282, 14_283,
|
||||
14_284, 14_285, 14_286, 14_287, 27_019, 49_044, 49_045, 5_116, 20_736,
|
||||
2_974, 19_503, 1_495, 14_269, 14_270, 14_271, 36_916, 53_339, 2_643,
|
||||
14_288, 14_289, 14_290, 25_294, 27_021, 49_047, 49_048, 19_434, 27_065,
|
||||
49_049, 49_050, 3_043, 19_306, 20_909, 20_910, 27_067, 48_998, 48_999,
|
||||
34_490, 3_034, 1_510, 14_294, 14_295, 27_022, 19_386, 24_132, 24_133,
|
||||
27_068, 49_011, 49_012, 3_674, 63_668, 63_669, 63_670, 63_671, 63_672,
|
||||
56_641, 49_051, 49_052, 53_209, 53_301, 19_801, 34_477, 61_005, 61_006,
|
||||
]),
|
||||
});
|
||||
|
||||
const hunterPreventionTypesBySpellId: Record<number, Wow335HunterPreventionType> = {};
|
||||
for (const type of [0, 1, 2] as const) {
|
||||
for (const spellId of WOW335_HUNTER_PREVENTION_SPELL_IDS_BY_TYPE[type]) {
|
||||
hunterPreventionTypesBySpellId[spellId] = type;
|
||||
}
|
||||
}
|
||||
export const WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID: Readonly<Record<number, Wow335HunterPreventionType>> =
|
||||
Object.freeze(hunterPreventionTypesBySpellId);
|
||||
|
||||
const HUNTER_SOURCE_RANK_IDENTITIES = new Map<number, { readonly id: string; readonly name: string }>();
|
||||
const DUPLICATE_HUNTER_SOURCE_RANK_IDS = new Set<number>();
|
||||
for (const ability of WOW335_ABILITIES_BY_CLASS.hunter) {
|
||||
for (const rank of ability.ranks ?? []) {
|
||||
if (HUNTER_SOURCE_RANK_IDENTITIES.has(rank.spellId)) {
|
||||
HUNTER_SOURCE_RANK_IDENTITIES.delete(rank.spellId);
|
||||
DUPLICATE_HUNTER_SOURCE_RANK_IDS.add(rank.spellId);
|
||||
continue;
|
||||
}
|
||||
if (!DUPLICATE_HUNTER_SOURCE_RANK_IDS.has(rank.spellId)) {
|
||||
HUNTER_SOURCE_RANK_IDENTITIES.set(rank.spellId, { id: ability.id, name: ability.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the pinned source PreventionType only for a selected Hunter row. */
|
||||
export function wow335HunterPreventionTypeForAbility(
|
||||
ability: Pick<AbilityDefinition, "id" | "classId" | "source" | "name" | "dbcSpellId">,
|
||||
): Wow335HunterPreventionType | undefined {
|
||||
if (ability.classId !== "hunter" || ability.source !== "wow335-progression") return undefined;
|
||||
const preventionType = WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID[ability.dbcSpellId];
|
||||
const identity = HUNTER_SOURCE_RANK_IDENTITIES.get(ability.dbcSpellId);
|
||||
if (preventionType === undefined || !identity || DUPLICATE_HUNTER_SOURCE_RANK_IDS.has(ability.dbcSpellId)) {
|
||||
return undefined;
|
||||
}
|
||||
const publicAlias = identity.id.startsWith("wow335-") ? identity.id.slice("wow335-".length) : undefined;
|
||||
if (ability.name !== identity.name || (ability.id !== identity.id && ability.id !== publicAlias)) return undefined;
|
||||
return preventionType;
|
||||
}
|
||||
|
||||
// These ground channels store their actual damage in same-name triggered
|
||||
// child spells. Resolve the child once so each channel pulse can use its
|
||||
// rank-specific DBC amount rather than creating a delayed area effect.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
WOW335_DETERRENCE_AURA_LINK,
|
||||
WOW335_DETERRENCE_SOURCE_SIGNATURES,
|
||||
wow335DeterrenceSourceContract,
|
||||
type Wow335DeterrenceEffectSignature,
|
||||
type Wow335DeterrenceSpellLink,
|
||||
type Wow335DeterrenceSpellSignature,
|
||||
} from "./wow335Deterrence";
|
||||
|
||||
function cloneSignature(value: Wow335DeterrenceSpellSignature): Wow335DeterrenceSpellSignature {
|
||||
return {
|
||||
...value,
|
||||
attributes: [...value.attributes] as Wow335DeterrenceSpellSignature["attributes"],
|
||||
effects: value.effects.map((effect): Wow335DeterrenceEffectSignature => ({ ...effect })),
|
||||
};
|
||||
}
|
||||
|
||||
function packageInput(): {
|
||||
sourceProfile: string;
|
||||
parent: Wow335DeterrenceSpellSignature;
|
||||
linkedAura: Wow335DeterrenceSpellSignature;
|
||||
linkedRows: Wow335DeterrenceSpellLink[];
|
||||
} {
|
||||
return {
|
||||
sourceProfile: "wow335",
|
||||
parent: cloneSignature(WOW335_DETERRENCE_SOURCE_SIGNATURES.selectedParent),
|
||||
linkedAura: cloneSignature(WOW335_DETERRENCE_SOURCE_SIGNATURES.linkedAura),
|
||||
linkedRows: [{ ...WOW335_DETERRENCE_AURA_LINK }],
|
||||
};
|
||||
}
|
||||
|
||||
describe("WoW335 Hunter Deterrence source contract", () => {
|
||||
it("accepts the exact parent, linked aura, and aura-link signature", () => {
|
||||
const contract = wow335DeterrenceSourceContract(packageInput());
|
||||
|
||||
expect(contract?.sourceProfile).toBe("wow335");
|
||||
expect(contract?.parent.effects.map(({ auraType, basePoints }) => [auraType, basePoints]))
|
||||
.toEqual([[47, 100], [287, 100], [25, 0]]);
|
||||
expect(contract?.linkedAura.effects.map(({ auraType, basePoints }) => [auraType, basePoints]))
|
||||
.toEqual([[288, 100], [185, -100]]);
|
||||
expect(contract?.auraLink).toEqual({ spellTrigger: 19263, spellEffect: 67801, type: 2 });
|
||||
});
|
||||
|
||||
it("rejects other profiles and same-name unlinked spell 65871", () => {
|
||||
const otherProfile = packageInput();
|
||||
otherProfile.sourceProfile = "ascension";
|
||||
expect(wow335DeterrenceSourceContract(otherProfile)).toBeUndefined();
|
||||
|
||||
const duplicate = packageInput();
|
||||
duplicate.parent = cloneSignature(WOW335_DETERRENCE_SOURCE_SIGNATURES.unlinkedSameNameDuplicate);
|
||||
expect(wow335DeterrenceSourceContract(duplicate)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects parent attribute or aura sibling drift", () => {
|
||||
const attributeDrift = packageInput();
|
||||
attributeDrift.parent = {
|
||||
...attributeDrift.parent,
|
||||
attributes: [327696, 0, 0, 0, 0, 0, 0, 0],
|
||||
};
|
||||
expect(wow335DeterrenceSourceContract(attributeDrift)).toBeUndefined();
|
||||
|
||||
const auraDrift = packageInput();
|
||||
auraDrift.parent = {
|
||||
...auraDrift.parent,
|
||||
effects: auraDrift.parent.effects.map((effect, index) => (
|
||||
index === 1 ? { ...effect, auraType: 288 } : effect
|
||||
)),
|
||||
};
|
||||
expect(wow335DeterrenceSourceContract(auraDrift)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects changed linked aura effects, duration, or aura relationship", () => {
|
||||
const childDrift = packageInput();
|
||||
childDrift.linkedAura = {
|
||||
...childDrift.linkedAura,
|
||||
effects: childDrift.linkedAura.effects.map((effect, index) => (
|
||||
index === 1 ? { ...effect, basePoints: 0 } : effect
|
||||
)),
|
||||
};
|
||||
expect(wow335DeterrenceSourceContract(childDrift)).toBeUndefined();
|
||||
|
||||
const durationDrift = packageInput();
|
||||
durationDrift.linkedAura = { ...durationDrift.linkedAura, durationMs: 6_000 };
|
||||
expect(wow335DeterrenceSourceContract(durationDrift)).toBeUndefined();
|
||||
|
||||
const linkDrift = packageInput();
|
||||
linkDrift.linkedRows = [{ ...WOW335_DETERRENCE_AURA_LINK, type: 1 }];
|
||||
expect(wow335DeterrenceSourceContract(linkDrift)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails closed when the pinned parent link has additional or missing rows", () => {
|
||||
const noLink = packageInput();
|
||||
noLink.linkedRows = [];
|
||||
expect(wow335DeterrenceSourceContract(noLink)).toBeUndefined();
|
||||
|
||||
const extraLink = packageInput();
|
||||
extraLink.linkedRows.push({ spellTrigger: 19263, spellEffect: 65871, type: 2 });
|
||||
expect(wow335DeterrenceSourceContract(extraLink)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
export type Wow335DeterrenceEffectSignature = Readonly<{
|
||||
index: number;
|
||||
effectType: number;
|
||||
auraType: number;
|
||||
basePoints: number;
|
||||
dieSides: number;
|
||||
pointsPerLevel: number;
|
||||
mechanic: number;
|
||||
implicitTargetA: number;
|
||||
implicitTargetB: number;
|
||||
radius: number;
|
||||
periodMs: number;
|
||||
miscValue: number;
|
||||
triggerSpellId: number;
|
||||
}>;
|
||||
|
||||
export type Wow335DeterrenceSpellSignature = Readonly<{
|
||||
spellId: number;
|
||||
name: string;
|
||||
rangeIndex: number;
|
||||
durationIndex: number;
|
||||
durationMs: number;
|
||||
schoolMask: number;
|
||||
damageClass: number;
|
||||
mechanic: number;
|
||||
dispel: number;
|
||||
attributes: readonly [number, number, number, number, number, number, number, number];
|
||||
effects: readonly Wow335DeterrenceEffectSignature[];
|
||||
}>;
|
||||
|
||||
export type Wow335DeterrenceSpellLink = Readonly<{
|
||||
spellTrigger: number;
|
||||
spellEffect: number;
|
||||
type: number;
|
||||
}>;
|
||||
|
||||
export interface Wow335DeterrenceSourcePackage {
|
||||
readonly sourceProfile: "wow335";
|
||||
/** Hunter's selected Spell.dbc row. Its effects are self-targeted auras. */
|
||||
readonly parent: Wow335DeterrenceSpellSignature;
|
||||
/** The spell_linked_spell aura row applied with the parent. */
|
||||
readonly linkedAura: Wow335DeterrenceSpellSignature;
|
||||
/** The exact positive aura link from the pinned world SQL row. */
|
||||
readonly auraLink: Wow335DeterrenceSpellLink;
|
||||
}
|
||||
|
||||
const effect = (
|
||||
index: number,
|
||||
auraType: number,
|
||||
basePoints: number,
|
||||
): Wow335DeterrenceEffectSignature => Object.freeze({
|
||||
index,
|
||||
effectType: 6,
|
||||
auraType,
|
||||
basePoints,
|
||||
dieSides: 1,
|
||||
pointsPerLevel: 0,
|
||||
mechanic: 0,
|
||||
implicitTargetA: 1,
|
||||
implicitTargetB: 0,
|
||||
radius: 0,
|
||||
periodMs: 0,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 0,
|
||||
});
|
||||
|
||||
function sourceRow(
|
||||
spellId: number,
|
||||
attributes: Wow335DeterrenceSpellSignature["attributes"],
|
||||
effects: readonly Wow335DeterrenceEffectSignature[],
|
||||
): Wow335DeterrenceSpellSignature {
|
||||
return Object.freeze({
|
||||
spellId,
|
||||
name: "Deterrence",
|
||||
rangeIndex: 1,
|
||||
durationIndex: 28,
|
||||
durationMs: 5_000,
|
||||
schoolMask: 1,
|
||||
damageClass: 0,
|
||||
mechanic: 0,
|
||||
dispel: 0,
|
||||
attributes: Object.freeze([...attributes]) as Wow335DeterrenceSpellSignature["attributes"],
|
||||
effects: Object.freeze([...effects]),
|
||||
});
|
||||
}
|
||||
|
||||
const selectedParent = sourceRow(19263, [327696, 16, 0, 0, 0, 0, 0, 0], [
|
||||
effect(0, 47, 100),
|
||||
effect(1, 287, 100),
|
||||
effect(2, 25, 0),
|
||||
]);
|
||||
|
||||
const linkedAura = sourceRow(67801, [328080, 16, 0, 0, 0, 0, 0, 0], [
|
||||
effect(0, 288, 100),
|
||||
effect(1, 185, -100),
|
||||
]);
|
||||
|
||||
/** Same-name Spell.dbc duplicate, reviewed to prevent accidental substitution. */
|
||||
const unlinkedDuplicate = sourceRow(65871, [327696, 0, 0, 0, 0, 0, 0, 0], [
|
||||
effect(0, 47, 100),
|
||||
effect(1, 287, 100),
|
||||
effect(2, 25, 0),
|
||||
]);
|
||||
|
||||
export const WOW335_DETERRENCE_SOURCE_SIGNATURES = Object.freeze({
|
||||
selectedParent,
|
||||
linkedAura,
|
||||
unlinkedSameNameDuplicate: unlinkedDuplicate,
|
||||
});
|
||||
|
||||
export const WOW335_DETERRENCE_AURA_LINK: Wow335DeterrenceSpellLink = Object.freeze({
|
||||
spellTrigger: 19263,
|
||||
spellEffect: 67801,
|
||||
type: 2,
|
||||
});
|
||||
|
||||
function matchesEffect(
|
||||
actual: Wow335DeterrenceEffectSignature,
|
||||
expected: Wow335DeterrenceEffectSignature,
|
||||
): boolean {
|
||||
return actual.index === expected.index
|
||||
&& actual.effectType === expected.effectType
|
||||
&& actual.auraType === expected.auraType
|
||||
&& actual.basePoints === expected.basePoints
|
||||
&& actual.dieSides === expected.dieSides
|
||||
&& actual.pointsPerLevel === expected.pointsPerLevel
|
||||
&& actual.mechanic === expected.mechanic
|
||||
&& actual.implicitTargetA === expected.implicitTargetA
|
||||
&& actual.implicitTargetB === expected.implicitTargetB
|
||||
&& actual.radius === expected.radius
|
||||
&& actual.periodMs === expected.periodMs
|
||||
&& actual.miscValue === expected.miscValue
|
||||
&& actual.triggerSpellId === expected.triggerSpellId;
|
||||
}
|
||||
|
||||
function matchesSpell(
|
||||
actual: Wow335DeterrenceSpellSignature,
|
||||
expected: Wow335DeterrenceSpellSignature,
|
||||
): boolean {
|
||||
return actual.spellId === expected.spellId
|
||||
&& actual.name === expected.name
|
||||
&& actual.rangeIndex === expected.rangeIndex
|
||||
&& actual.durationIndex === expected.durationIndex
|
||||
&& actual.durationMs === expected.durationMs
|
||||
&& actual.schoolMask === expected.schoolMask
|
||||
&& actual.damageClass === expected.damageClass
|
||||
&& actual.mechanic === expected.mechanic
|
||||
&& actual.dispel === expected.dispel
|
||||
&& actual.attributes.length === expected.attributes.length
|
||||
&& actual.attributes.every((value, index) => value === expected.attributes[index])
|
||||
&& actual.effects.length === expected.effects.length
|
||||
&& actual.effects.every((value, index) => {
|
||||
const expectedEffect = expected.effects[index];
|
||||
return expectedEffect !== undefined && matchesEffect(value, expectedEffect);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the selected WoW335 Deterrence row and its linked aura package.
|
||||
* This source contract does not resolve incoming hit outcomes or apply auras.
|
||||
*/
|
||||
export function wow335DeterrenceSourceContract(input: {
|
||||
readonly sourceProfile: string;
|
||||
readonly parent: Wow335DeterrenceSpellSignature;
|
||||
readonly linkedAura: Wow335DeterrenceSpellSignature;
|
||||
/** All spell_linked_spell rows whose trigger is 19263. */
|
||||
readonly linkedRows: readonly Wow335DeterrenceSpellLink[];
|
||||
}): Wow335DeterrenceSourcePackage | undefined {
|
||||
if (input.sourceProfile !== "wow335"
|
||||
|| !matchesSpell(input.parent, selectedParent)
|
||||
|| !matchesSpell(input.linkedAura, linkedAura)
|
||||
|| input.linkedRows.length !== 1) return undefined;
|
||||
|
||||
const [row] = input.linkedRows;
|
||||
if (!row || row.spellTrigger !== WOW335_DETERRENCE_AURA_LINK.spellTrigger
|
||||
|| row.spellEffect !== WOW335_DETERRENCE_AURA_LINK.spellEffect
|
||||
|| row.type !== WOW335_DETERRENCE_AURA_LINK.type) return undefined;
|
||||
|
||||
return Object.freeze({
|
||||
sourceProfile: "wow335",
|
||||
parent: selectedParent,
|
||||
linkedAura,
|
||||
auraLink: WOW335_DETERRENCE_AURA_LINK,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
abilitiesForCharacter,
|
||||
learnedAbilityForBuild,
|
||||
preloadAbilityCatalogsForClasses,
|
||||
} from "./abilityCatalog";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import {
|
||||
DEFAULT_COMPANION_COOLDOWN_POLICY,
|
||||
installCompanionCooldownPolicy,
|
||||
} from "./companionCooldownPolicy";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import {
|
||||
createConfiguredPartyMember,
|
||||
usePartyStore,
|
||||
} from "./partyStore";
|
||||
import { enemyWeaponDebuffAuraDefinition, enemyWeaponDebuffContract } from "./enemyWeaponDebuffs";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { WOW335_DETERRENCE_PACKAGE_AURA_IDS, WOW335_HUNTER_DETERRENCE_ABILITY_ID } from "./wow335DeterrenceRuntime";
|
||||
import { wow335HunterPreventionTypeForAbility } from "./wow335AbilityCatalog";
|
||||
|
||||
const TEST_PARTY_ID = "wow335-deterrence-cast-integration";
|
||||
const TARGET_ID = "wow335-deterrence-cast-target";
|
||||
const NOW = 40_000;
|
||||
|
||||
beforeAll(async () => {
|
||||
await preloadAbilityCatalogsForClasses(["hunter"]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
installCompanionCooldownPolicy(DEFAULT_COMPANION_COOLDOWN_POLICY);
|
||||
usePartyStore.setState({ members: [] });
|
||||
});
|
||||
|
||||
function initializePlayer(level = 60): void {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "hunter", level });
|
||||
usePartyStore.getState().initializeParty(
|
||||
TEST_PARTY_ID, level, useCombatStore.getState().health, "damage", 1, "wow",
|
||||
);
|
||||
}
|
||||
|
||||
function abilityForHunter() {
|
||||
const ability = abilitiesForCharacter("hunter", null)
|
||||
.find((candidate) => candidate.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID);
|
||||
if (!ability) throw new Error("The selected level-60 Hunter Deterrence spell is missing.");
|
||||
return ability;
|
||||
}
|
||||
|
||||
function packageFor(ownerId: string) {
|
||||
return useCombatStore.getState().auras
|
||||
.filter((aura) => (WOW335_DETERRENCE_PACKAGE_AURA_IDS as readonly string[]).includes(aura.definition.id)
|
||||
&& aura.sourceId === ownerId);
|
||||
}
|
||||
|
||||
function expectExactPackage(ownerId: string, appliedAt: number): void {
|
||||
const auras = packageFor(ownerId);
|
||||
expect(auras).toHaveLength(5);
|
||||
expect(auras.map((aura) => aura.definition.id).sort()).toEqual(
|
||||
[...WOW335_DETERRENCE_PACKAGE_AURA_IDS].sort(),
|
||||
);
|
||||
expect(auras.every((aura) => aura.sourceId === ownerId && aura.targetId === ownerId)).toBe(true);
|
||||
expect(auras.every((aura) => aura.definition.durationMs === 5_000)).toBe(true);
|
||||
expect(auras.map((aura) => aura.appliedAt)).toEqual(Array(5).fill(appliedAt));
|
||||
expect(auras.map((aura) => aura.expiresAt)).toEqual(Array(5).fill(appliedAt + 5_000));
|
||||
}
|
||||
|
||||
function installImminentMechanic(attackId: string, serverEntry: number): void {
|
||||
installCompanionCooldownPolicy({
|
||||
...DEFAULT_COMPANION_COOLDOWN_POLICY,
|
||||
mechanics: [{
|
||||
id: "integration-heavy-shot",
|
||||
enabled: true,
|
||||
bossKey: "test/deterrence-heavy-shot",
|
||||
bossName: "Deterrence Heavy Shot",
|
||||
bossServerEntry: serverEntry,
|
||||
attackId,
|
||||
attackName: "Deterrence Heavy Shot",
|
||||
scope: "party",
|
||||
leadTimeMs: 2_500,
|
||||
minimumCooldownSeconds: 20,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
function installHunterNpc(healthFraction: number, now: number, globalCooldownEndsAt = 0) {
|
||||
const memberSeed = createConfiguredPartyMember({
|
||||
name: "Deterrence Marksmanship Hunter",
|
||||
classId: "hunter",
|
||||
specialization: "Marksmanship",
|
||||
specializationId: "hunter:marksmanship",
|
||||
talentTreeId: "hunter-marksmanship",
|
||||
talentRanks: {},
|
||||
role: "damage",
|
||||
}, 60, 0, TEST_PARTY_ID);
|
||||
const learnedAbilities = abilitiesForCharacter("hunter", null)
|
||||
.map((ability) => learnedAbilityForBuild(ability, memberSeed.level, memberSeed.talentRanks))
|
||||
.filter((ability): ability is NonNullable<typeof ability> => ability !== null);
|
||||
const deterrence = learnedAbilities.find((ability) => ability.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID);
|
||||
if (!deterrence) throw new Error("The configured level-60 Hunter did not learn Deterrence.");
|
||||
const cooldowns = Object.fromEntries(learnedAbilities
|
||||
.filter((ability) => ability.id !== deterrence.id)
|
||||
.map((ability) => [ability.id, now + 100_000]));
|
||||
const member = {
|
||||
...memberSeed,
|
||||
health: Math.round(memberSeed.maxHealth * healthFraction),
|
||||
cooldowns,
|
||||
globalCooldownEndsAt,
|
||||
nextActionAt: 0,
|
||||
status: "ready" as const,
|
||||
resource: memberSeed.maxResource,
|
||||
resourcePools: { ...memberSeed.resourcePools, [memberSeed.resourceType]: memberSeed.maxResource },
|
||||
};
|
||||
usePartyStore.setState({ members: [member], command: "attack", activeMobId: TARGET_ID });
|
||||
|
||||
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||
name: "Deterrence Heavy Shot",
|
||||
serverEntry: 49335,
|
||||
level: 60,
|
||||
maxHealth: 100_000,
|
||||
xpReward: 0,
|
||||
attacks: [{
|
||||
id: "integration-heavy-shot",
|
||||
name: "Deterrence Heavy Shot",
|
||||
delivery: "projectile",
|
||||
target: "primary",
|
||||
school: "physical",
|
||||
animation: "cast",
|
||||
castTimeMs: 0,
|
||||
range: 30,
|
||||
cooldownMs: 30_000,
|
||||
initialCooldownMs: [0, 0],
|
||||
damageMultiplier: 1,
|
||||
}],
|
||||
});
|
||||
setMobPosition(TARGET_ID, [3, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().damageMob(TARGET_ID, 1, now - 1, {
|
||||
actorId: member.id,
|
||||
role: "damage",
|
||||
});
|
||||
registerPartyRuntimePosition(member.id, [0, 0, -2]);
|
||||
updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
return member;
|
||||
}
|
||||
|
||||
function setNpcActiveCast(memberId: string, now: number): void {
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
[TARGET_ID]: {
|
||||
...state.mobs[TARGET_ID]!,
|
||||
activeCast: {
|
||||
attackId: "integration-heavy-shot",
|
||||
name: "Deterrence Heavy Shot",
|
||||
school: "physical",
|
||||
interruptible: true,
|
||||
targetActorId: memberId,
|
||||
sourcePosition: [3, 0, 0] as const,
|
||||
targetPosition: [0, 0, -2] as const,
|
||||
sourceYaw: 0,
|
||||
startedAt: now - 1,
|
||||
completesAt: now + 1_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
describe("WoW335 Hunter Deterrence player and configured-NPC casts", () => {
|
||||
it("casts the five self-owned auras off-GCD, ignores a foreign target, and expires them at five seconds", () => {
|
||||
initializePlayer();
|
||||
const ability = abilityForHunter();
|
||||
useCombatStore.setState({ globalCooldownEndsAt: NOW + 8_000 });
|
||||
|
||||
const cast = useCombatStore.getState().castAbility(ability.id, {
|
||||
now: NOW,
|
||||
targetActorId: "foreign-recipient",
|
||||
});
|
||||
|
||||
expect(cast).toMatchObject({ ok: true, abilityId: ability.id });
|
||||
expectExactPackage(PLAYER_AGGRO_ID, NOW);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === "foreign-recipient"
|
||||
&& (WOW335_DETERRENCE_PACKAGE_AURA_IDS as readonly string[]).includes(aura.definition.id))).toBe(false);
|
||||
expect(useCombatStore.getState().cooldowns[ability.id]).toBe(NOW + 90_000);
|
||||
expect(useCombatStore.getState().globalCooldownEndsAt).toBe(NOW + 8_000);
|
||||
|
||||
useCombatStore.getState().tick(0.001, NOW + 5_000);
|
||||
expect(packageFor(PLAYER_AGGRO_ID)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("learns only at level 60, exposes the exact weapon requirement, and preserves the core aura-spell Disarm exception", () => {
|
||||
initializePlayer(59);
|
||||
const ability = abilityForHunter();
|
||||
expect(learnedAbilityForBuild(ability, 59, {})).toBeNull();
|
||||
expect(useCombatStore.getState().castAbility(ability.id, { now: NOW })).toMatchObject({
|
||||
ok: false,
|
||||
reason: "level-locked",
|
||||
});
|
||||
|
||||
initializePlayer(60);
|
||||
const unlocked = abilityForHunter();
|
||||
expect(learnedAbilityForBuild(unlocked, 60, {})).toMatchObject({
|
||||
wowWeaponRequirements: {
|
||||
equippedItemClass: 2,
|
||||
equippedItemSubclassMask: 173_555,
|
||||
equippedItemInventoryMask: 0,
|
||||
},
|
||||
});
|
||||
const disarmDefinition = enemyWeaponDebuffAuraDefinition(enemyWeaponDebuffContract(6_713, "ascension")!);
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: [...applyAura(state.auras, {
|
||||
definition: disarmDefinition,
|
||||
sourceId: "verified-disarm-source",
|
||||
targetId: PLAYER_AGGRO_ID,
|
||||
now: NOW,
|
||||
}).auras],
|
||||
}));
|
||||
|
||||
const cast = useCombatStore.getState().castAbility(unlocked.id, { now: NOW + 1 });
|
||||
expect(cast).toMatchObject({ ok: true, abilityId: unlocked.id });
|
||||
expectExactPackage(PLAYER_AGGRO_ID, NOW + 1);
|
||||
});
|
||||
|
||||
it("blocks actual PreventionType 2 shots during Deterrence but allows a selected PreventionType 1 trap", () => {
|
||||
initializePlayer();
|
||||
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||
name: "Deterrence cast-gate target",
|
||||
level: 60,
|
||||
maxHealth: 100_000,
|
||||
xpReward: 0,
|
||||
attacks: [],
|
||||
});
|
||||
setMobPosition(TARGET_ID, [10, 0, 0]);
|
||||
useCombatStore.getState().selectMob(TARGET_ID);
|
||||
|
||||
const selectedAbilities = abilitiesForCharacter("hunter", null);
|
||||
const selectedAbility = (id: string) => {
|
||||
const catalogAbility = selectedAbilities.find((ability) => ability.id === id);
|
||||
if (!catalogAbility) throw new Error(`Missing selected Hunter ability ${id}.`);
|
||||
const learned = learnedAbilityForBuild(catalogAbility, 60, {});
|
||||
if (!learned) throw new Error(`Hunter did not learn ${id} at level 60.`);
|
||||
return learned;
|
||||
};
|
||||
const arcaneShot = selectedAbility("hunter-arcane-shot");
|
||||
const steadyShot = selectedAbility("hunter-steady-shot");
|
||||
const freezingTrapSource = selectedAbilities.find((ability) => (
|
||||
ability.name === "Freezing Trap" && ability.source === "wow335-progression"
|
||||
));
|
||||
if (!freezingTrapSource) throw new Error("Missing selected source Freezing Trap.");
|
||||
const freezingTrap = learnedAbilityForBuild(freezingTrapSource, 60, {});
|
||||
if (!freezingTrap) throw new Error("Hunter did not learn Freezing Trap at level 60.");
|
||||
expect(wow335HunterPreventionTypeForAbility(arcaneShot)).toBe(2);
|
||||
expect(wow335HunterPreventionTypeForAbility(steadyShot)).toBe(2);
|
||||
expect(wow335HunterPreventionTypeForAbility(freezingTrap)).toBe(1);
|
||||
|
||||
const deterrence = useCombatStore.getState().castAbility(WOW335_HUNTER_DETERRENCE_ABILITY_ID, { now: NOW });
|
||||
expect(deterrence.ok).toBe(true);
|
||||
expectExactPackage(PLAYER_AGGRO_ID, NOW);
|
||||
const arcaneCast = useCombatStore.getState().castAbility(arcaneShot.id, {
|
||||
now: NOW + 1,
|
||||
targetActorId: TARGET_ID,
|
||||
});
|
||||
const steadyCast = useCombatStore.getState().castAbility(steadyShot.id, {
|
||||
now: NOW + 2,
|
||||
targetActorId: TARGET_ID,
|
||||
});
|
||||
expect(arcaneCast).toMatchObject({ ok: false, reason: "player-controlled" });
|
||||
expect(steadyCast).toMatchObject({ ok: false, reason: "player-controlled" });
|
||||
|
||||
const trapCast = useCombatStore.getState().castAbility(freezingTrap.id, {
|
||||
now: NOW + 3,
|
||||
targetActorId: TARGET_ID,
|
||||
});
|
||||
expect(trapCast).toMatchObject({ ok: true, abilityId: freezingTrap.id });
|
||||
});
|
||||
|
||||
it("casts simultaneous player and Hunter-NPC packages below the 55% defensive threshold", () => {
|
||||
initializePlayer();
|
||||
const playerCast = useCombatStore.getState().castAbility(WOW335_HUNTER_DETERRENCE_ABILITY_ID, {
|
||||
now: NOW,
|
||||
targetActorId: TARGET_ID,
|
||||
});
|
||||
expect(playerCast.ok).toBe(true);
|
||||
|
||||
const npc = installHunterNpc(0.54, NOW, NOW + 7_000);
|
||||
advanceDungeonPartyCombat(NOW);
|
||||
|
||||
expectExactPackage(PLAYER_AGGRO_ID, NOW);
|
||||
expectExactPackage(npc.id, NOW);
|
||||
expect(usePartyStore.getState().members[0]).toMatchObject({
|
||||
cooldowns: { [WOW335_HUNTER_DETERRENCE_ABILITY_ID]: NOW + 90_000 },
|
||||
globalCooldownEndsAt: NOW + 7_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("pre-empts a recognized imminent mechanic at full health, but does not maintain Deterrence at full health", () => {
|
||||
initializePlayer();
|
||||
const npc = installHunterNpc(1, NOW);
|
||||
installImminentMechanic("integration-heavy-shot", 49_335);
|
||||
setNpcActiveCast(npc.id, NOW);
|
||||
advanceDungeonPartyCombat(NOW);
|
||||
expectExactPackage(npc.id, NOW);
|
||||
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
usePartyStore.setState({ members: [] });
|
||||
installCompanionCooldownPolicy(DEFAULT_COMPANION_COOLDOWN_POLICY);
|
||||
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 60 });
|
||||
usePartyStore.getState().initializeParty(TEST_PARTY_ID, 60,
|
||||
useCombatStore.getState().health, "damage", 1, "wow");
|
||||
const fullHealthNpc = installHunterNpc(1, NOW + 1_000);
|
||||
advanceDungeonPartyCombat(NOW + 1_000);
|
||||
expect(packageFor(fullHealthNpc.id)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,501 @@
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import { stableCombatUnit, useCombatStore } from "./combatStore";
|
||||
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { BARREN_CAVES_ENTITIES } from "./generated/barrenCavesPopulation.generated";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { normalizeEnemyAttack, type EnemyAttackDefinition } from "./enemyAttacks";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { enemyDamageTypeSourceForSpell } from "./mobDamageTypes";
|
||||
import {
|
||||
WOW335_DETERRENCE_LINKED_AURA_IDS,
|
||||
WOW335_DETERRENCE_PACKAGE_AURA_IDS,
|
||||
WOW335_HUNTER_DETERRENCE_ABILITY_ID,
|
||||
resolveWow335DeterrenceIncoming,
|
||||
type Wow335DeterrenceIncomingSource,
|
||||
} from "./wow335DeterrenceRuntime";
|
||||
import { abilitiesForCharacter, learnedAbilityForBuild, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
|
||||
const NOW = 70_000;
|
||||
const TEST_PARTY_ID = "wow335-deterrence-defense-integration";
|
||||
const warder = requireDungeonDefinition("blackrock-depths").entities["entry-9041"];
|
||||
const importedFrostboltSource = warder?.combat?.attacks.find((attack) => attack.spellId === 12_675);
|
||||
const importedFrostbolt = importedFrostboltSource
|
||||
? normalizeEnemyAttack(importedFrostboltSource)
|
||||
: undefined;
|
||||
|
||||
function allImportedAttacks(): readonly EnemyAttackDefinition[] {
|
||||
return DUNGEON_DEFINITIONS.flatMap((dungeon) => Object.values(dungeon.entities)
|
||||
.flatMap((entity) => entity.combat?.attacks ?? []));
|
||||
}
|
||||
|
||||
function importedRangedSpell(): EnemyAttackDefinition | undefined {
|
||||
return allImportedAttacks()
|
||||
.map((attack) => normalizeEnemyAttack(attack))
|
||||
.filter((attack) => attack.damageOrigin === "wow335"
|
||||
&& attack.spellId !== undefined
|
||||
&& attack.attackKind === "ranged"
|
||||
&& attack.wowHitSource !== undefined
|
||||
&& (attack.wowHitSource.attributes0 & 0x0020_0000) === 0
|
||||
&& (attack.wowHitSource.attributes3 & 0x0004_0000) === 0
|
||||
&& (attack.effect === undefined || attack.effect.kind === "damage"))
|
||||
.sort((left, right) => left.spellId! - right.spellId!)[0];
|
||||
}
|
||||
|
||||
const rangedSpell = importedRangedSpell();
|
||||
|
||||
beforeAll(async () => {
|
||||
await preloadAbilityCatalogsForClasses(["hunter"]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
usePartyStore.setState({ members: [] });
|
||||
});
|
||||
|
||||
function prepareHunter(): void {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 60 });
|
||||
usePartyStore.getState().initializeParty(
|
||||
TEST_PARTY_ID, 60, useCombatStore.getState().health, "damage", 1, "wow",
|
||||
);
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
}
|
||||
|
||||
function prepareHunterNpcDefender(mobId: string) {
|
||||
const memberSeed = createConfiguredPartyMember({
|
||||
name: "Deterrence Marksmanship Hunter",
|
||||
classId: "hunter",
|
||||
specialization: "Marksmanship",
|
||||
specializationId: "hunter:marksmanship",
|
||||
talentTreeId: "hunter-marksmanship",
|
||||
talentRanks: {},
|
||||
role: "damage",
|
||||
}, 60, 0, TEST_PARTY_ID);
|
||||
const learnedAbilities = abilitiesForCharacter("hunter", null)
|
||||
.map((ability) => learnedAbilityForBuild(ability, memberSeed.level, memberSeed.talentRanks))
|
||||
.filter((ability): ability is NonNullable<typeof ability> => ability !== null);
|
||||
const deterrence = learnedAbilities.find((ability) => ability.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID);
|
||||
if (!deterrence) throw new Error("The configured level-60 Hunter did not learn Deterrence.");
|
||||
const cooldowns = Object.fromEntries(learnedAbilities
|
||||
.filter((ability) => ability.id !== deterrence.id)
|
||||
.map((ability) => [ability.id, NOW + 100_000]));
|
||||
const member = {
|
||||
...memberSeed,
|
||||
health: Math.round(memberSeed.maxHealth * 0.54),
|
||||
cooldowns,
|
||||
globalCooldownEndsAt: NOW + 7_000,
|
||||
nextActionAt: 0,
|
||||
status: "ready" as const,
|
||||
resource: memberSeed.maxResource,
|
||||
resourcePools: { ...memberSeed.resourcePools, [memberSeed.resourceType]: memberSeed.maxResource },
|
||||
};
|
||||
usePartyStore.setState({ members: [member], command: "attack", activeMobId: mobId });
|
||||
useCombatStore.getState().registerMob(mobId, {
|
||||
name: "Imported Frostbolt source",
|
||||
level: 60,
|
||||
maxHealth: 100_000,
|
||||
xpReward: 0,
|
||||
attacks: [{ ...importedFrostbolt!, castTimeMs: 0, initialCooldownMs: [0, 0] }],
|
||||
});
|
||||
setMobPosition(mobId, [2, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().damageMob(mobId, 1, NOW - 1, { actorId: member.id, role: "damage" });
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 2]);
|
||||
updatePartyRuntimeTargetVisibility(member.id, mobId, true);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
return member;
|
||||
}
|
||||
|
||||
function castDeterrence(): void {
|
||||
const cast = useCombatStore.getState().castAbility(WOW335_HUNTER_DETERRENCE_ABILITY_ID, { now: NOW });
|
||||
expect(cast).toMatchObject({ ok: true, abilityId: WOW335_HUNTER_DETERRENCE_ABILITY_ID });
|
||||
const packageAuras = useCombatStore.getState().auras.filter((aura) => (
|
||||
(WOW335_DETERRENCE_PACKAGE_AURA_IDS as readonly string[]).includes(aura.definition.id)
|
||||
));
|
||||
expect(packageAuras).toHaveLength(5);
|
||||
expect(packageAuras.every((aura) => aura.sourceId === PLAYER_AGGRO_ID && aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.appliedAt === NOW && aura.expiresAt === NOW + 5_000)).toBe(true);
|
||||
}
|
||||
|
||||
function rangedAutoAttack(): EnemyAttackDefinition {
|
||||
return {
|
||||
id: "integration-ranged-auto",
|
||||
name: "Ranged Auto Attack",
|
||||
delivery: "projectile",
|
||||
target: "primary",
|
||||
school: "physical",
|
||||
attackKind: "ranged",
|
||||
attackHand: "ranged",
|
||||
animation: "attack",
|
||||
castTimeMs: 0,
|
||||
range: 30,
|
||||
cooldownMs: 30_000,
|
||||
initialCooldownMs: [0, 0],
|
||||
damageMultiplier: 1,
|
||||
} as unknown as EnemyAttackDefinition;
|
||||
}
|
||||
|
||||
function applyAbsorbAndDamageTakenProbe(): void {
|
||||
const combat = useCombatStore.getState();
|
||||
const auras = applyAura(combat.auras, {
|
||||
definition: {
|
||||
id: "integration:deterrence-absorb-and-proc",
|
||||
name: "Deterrence integration absorb/proc probe",
|
||||
disposition: "buff",
|
||||
durationMs: 30_000,
|
||||
absorbs: [{ id: "probe-absorb", amount: 20_000 }],
|
||||
procs: [{
|
||||
id: "probe-damage-taken-proc",
|
||||
triggers: "damage-taken",
|
||||
chance: 1,
|
||||
action: { kind: "resource", resource: "focus", amount: 25, target: "aura-target" },
|
||||
}],
|
||||
},
|
||||
sourceId: PLAYER_AGGRO_ID,
|
||||
targetId: PLAYER_AGGRO_ID,
|
||||
now: NOW,
|
||||
}).auras;
|
||||
useCombatStore.setState({
|
||||
auras: [...auras],
|
||||
resource: 0,
|
||||
resourcePools: { ...combat.resourcePools, focus: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAttack(attack: EnemyAttackDefinition, label: string, now = NOW) {
|
||||
const mobId = `deterrence-source-${label}`;
|
||||
const normalized = normalizeEnemyAttack({
|
||||
...attack,
|
||||
id: `${label}:${attack.id}`,
|
||||
castTimeMs: 0,
|
||||
cooldownMs: 30_000,
|
||||
initialCooldownMs: [0, 0],
|
||||
});
|
||||
useCombatStore.getState().registerMob(mobId, {
|
||||
name: `Deterrence source ${label}`,
|
||||
level: 60,
|
||||
maxHealth: 100_000,
|
||||
xpReward: 0,
|
||||
attacks: [normalized],
|
||||
});
|
||||
if (attack.periodicTick) {
|
||||
// registerMob normalizes source definitions; reapply the runtime pulse
|
||||
// marker after that source-attack boundary to model a scheduled DoT tick.
|
||||
useCombatStore.setState((state) => {
|
||||
const registered = state.mobs[mobId];
|
||||
if (!registered) return state;
|
||||
return { mobs: { ...state.mobs, [mobId]: { ...registered,
|
||||
attacks: registered.attacks.map((candidate) => ({ ...candidate, periodicTick: true })) } } };
|
||||
});
|
||||
}
|
||||
setMobPosition(mobId, [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob(mobId);
|
||||
useCombatStore.getState().damageMob(mobId, 1, now - 1, { actorId: PLAYER_AGGRO_ID, role: "damage" });
|
||||
const healthBefore = useCombatStore.getState().health;
|
||||
useCombatStore.getState().advanceMobCombat(now);
|
||||
useCombatStore.getState().advanceMobCombat(now + 1);
|
||||
const event = [...useCombatStore.getState().combatEvents].reverse().find((candidate) => (
|
||||
candidate.sourceActorId === mobId && candidate.targetActorId === PLAYER_AGGRO_ID && candidate.kind === "damage"
|
||||
));
|
||||
return {
|
||||
attack: useCombatStore.getState().mobs[mobId]!.attacks[0]!,
|
||||
event,
|
||||
result: useCombatStore.getState().lastCombatResult,
|
||||
healthBefore,
|
||||
healthAfter: useCombatStore.getState().health,
|
||||
mobId,
|
||||
};
|
||||
}
|
||||
|
||||
function landedAttackLabel(attack: EnemyAttackDefinition, prefix: string): string {
|
||||
const label = Array.from({ length: 10_000 }, (_, index) => `${prefix}-${index}`).find((candidate) => {
|
||||
const mobId = `deterrence-source-${candidate}`;
|
||||
const attackId = `${candidate}:${attack.id}`;
|
||||
const roll = stableCombatUnit(`${mobId}:${attackId}:${PLAYER_AGGRO_ID}:0:${NOW}:outcome`);
|
||||
return roll >= 0.15 && roll < 0.8;
|
||||
});
|
||||
if (!label) throw new Error(`Could not choose a deterministic landed-hit seed for ${attack.id}.`);
|
||||
return label;
|
||||
}
|
||||
|
||||
function sourceAttack(input: {
|
||||
readonly id: string;
|
||||
readonly spellId: number;
|
||||
readonly school: EnemyAttackDefinition["school"];
|
||||
readonly attackKind?: EnemyAttackDefinition["attackKind"];
|
||||
readonly periodicTick?: boolean;
|
||||
}): EnemyAttackDefinition {
|
||||
const normalized = normalizeEnemyAttack({
|
||||
id: input.id,
|
||||
name: input.id,
|
||||
spellId: input.spellId,
|
||||
damageOrigin: "wow335",
|
||||
delivery: "projectile",
|
||||
target: "primary",
|
||||
school: input.school,
|
||||
...(input.attackKind ? { attackKind: input.attackKind } : {}),
|
||||
...(input.periodicTick ? { periodicTick: true } : {}),
|
||||
animation: "cast",
|
||||
castTimeMs: 0,
|
||||
range: 30,
|
||||
cooldownMs: 30_000,
|
||||
initialCooldownMs: [0, 0],
|
||||
damageMultiplier: 1,
|
||||
});
|
||||
// Runtime periodic pulses carry this marker after the source attack creates
|
||||
// their periodic effect; keep it on this focused pulse fixture explicitly.
|
||||
return input.periodicTick ? { ...normalized, periodicTick: true } : normalized;
|
||||
}
|
||||
|
||||
describe("WoW335 Hunter Deterrence incoming combat integration", () => {
|
||||
it.each([
|
||||
["actual imported Frostbolt 12675", importedFrostbolt],
|
||||
] as const)("deflects %s before damage, absorbs, and damage-taken procs", (_label, imported) => {
|
||||
expect(imported).toBeDefined();
|
||||
if (!imported) throw new Error("The selected imported WoW source attack is missing.");
|
||||
expect(imported.damageOrigin).toBe("wow335");
|
||||
expect(imported.wowHitSource).toBeDefined();
|
||||
prepareHunter();
|
||||
castDeterrence();
|
||||
applyAbsorbAndDamageTakenProbe();
|
||||
const absorbBefore = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.definition.id === "integration:deterrence-absorb-and-proc"
|
||||
))?.absorbs[0]?.remaining;
|
||||
const outcome = resolveAttack(imported, landedAttackLabel(imported, _label.replaceAll(" ", "-")));
|
||||
|
||||
expect(outcome.event).toMatchObject({
|
||||
outcome: "deflect",
|
||||
rawAmount: expect.any(Number),
|
||||
effectiveAmount: 0,
|
||||
absorbed: 0,
|
||||
critical: false,
|
||||
});
|
||||
expect(outcome.event?.rawAmount).toBeGreaterThan(0);
|
||||
expect(outcome.result).toMatchObject({
|
||||
outcome: "deflect",
|
||||
landed: false,
|
||||
deflected: true,
|
||||
critical: false,
|
||||
blocked: false,
|
||||
rawDamage: expect.any(Number),
|
||||
finalDamage: 0,
|
||||
});
|
||||
expect(outcome.result?.rawDamage).toBeGreaterThan(0);
|
||||
expect(outcome.healthAfter).toBe(outcome.healthBefore);
|
||||
expect(useCombatStore.getState().controlMechanic).not.toBe("slow");
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.sourceId === outcome.mobId && aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.id.startsWith("enemy-control:")
|
||||
))).toBe(false);
|
||||
expect(useCombatStore.getState().auras.find((aura) => (
|
||||
aura.definition.id === "integration:deterrence-absorb-and-proc"
|
||||
))?.absorbs[0]?.remaining).toBe(absorbBefore);
|
||||
expect(useCombatStore.getState().resourcePools.focus).toBe(0);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => (
|
||||
event.kind === "absorb" && event.sourceActorId === outcome.mobId
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an actual ranged-source miss and recognizes its exact source row after a landed hit", () => {
|
||||
expect(rangedSpell).toBeDefined();
|
||||
if (!rangedSpell) throw new Error("The selected imported WoW ranged source attack is missing.");
|
||||
expect(rangedSpell).toMatchObject({ damageOrigin: "wow335", damageClass: 3, attackKind: "ranged" });
|
||||
prepareHunter();
|
||||
castDeterrence();
|
||||
const outcome = resolveAttack(rangedSpell, "actual-imported-ranged-source");
|
||||
expect(outcome.event?.outcome).toBe("miss");
|
||||
expect(outcome.result?.deflected).not.toBe(true);
|
||||
expect(outcome.healthAfter).toBe(outcome.healthBefore);
|
||||
|
||||
const ability = abilitiesForCharacter("hunter", null)
|
||||
.find((candidate) => candidate.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID)!;
|
||||
expect(resolveWow335DeterrenceIncoming({
|
||||
ability,
|
||||
auras: useCombatStore.getState().auras,
|
||||
defenderId: PLAYER_AGGRO_ID,
|
||||
nowMs: NOW + 1,
|
||||
incoming: rangedSpell,
|
||||
baseOutcome: "hit",
|
||||
defenderStunned: false,
|
||||
attackerInFront: true,
|
||||
})).toEqual({ outcome: "deflect", reason: "deflected" });
|
||||
});
|
||||
|
||||
it("lets the sourced Aura185 penalty make a ranged auto miss without treating it as a spell deflect", () => {
|
||||
prepareHunter();
|
||||
castDeterrence();
|
||||
const outcome = resolveAttack(rangedAutoAttack(), "ranged-auto");
|
||||
expect(outcome.attack.wowHitSource).toBeUndefined();
|
||||
expect(outcome.event).toMatchObject({ outcome: "miss", effectiveAmount: 0, absorbed: 0 });
|
||||
expect(outcome.result).toMatchObject({ outcome: "miss", landed: false });
|
||||
expect(outcome.result?.deflected).not.toBe(true);
|
||||
expect(outcome.healthAfter).toBe(outcome.healthBefore);
|
||||
});
|
||||
|
||||
it("cleans a torn player Aura185 package without borrowing its miss penalty or removing another Hunter's package", () => {
|
||||
const attack = rangedAutoAttack();
|
||||
const label = "aura185-owner-cleanup-ranged-auto";
|
||||
prepareHunter();
|
||||
const baseline = resolveAttack(attack, label);
|
||||
const baselineMissChance = baseline.result?.attackTable.missPercent;
|
||||
const baselineOutcome = baseline.event?.outcome;
|
||||
expect(baselineMissChance).toBeTypeOf("number");
|
||||
expect(baselineOutcome).toBeDefined();
|
||||
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
usePartyStore.setState({ members: [] });
|
||||
prepareHunter();
|
||||
castDeterrence();
|
||||
const npcMobId = "deterrence-aura185-second-owner-source";
|
||||
const npc = prepareHunterNpcDefender(npcMobId);
|
||||
advanceDungeonPartyCombat(NOW);
|
||||
expect(useCombatStore.getState().auras.filter((aura) => (
|
||||
(WOW335_DETERRENCE_PACKAGE_AURA_IDS as readonly string[]).includes(aura.definition.id)
|
||||
&& aura.sourceId === npc.id && aura.targetId === npc.id
|
||||
))).toHaveLength(5);
|
||||
|
||||
const aura185Id = WOW335_DETERRENCE_LINKED_AURA_IDS[1];
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: state.auras.filter((aura) => !(aura.definition.id === aura185Id
|
||||
&& aura.sourceId === PLAYER_AGGRO_ID && aura.targetId === PLAYER_AGGRO_ID)),
|
||||
}));
|
||||
const afterTornPackage = resolveAttack(attack, label);
|
||||
|
||||
expect(afterTornPackage.event?.outcome).toBe(baselineOutcome);
|
||||
expect(afterTornPackage.result?.attackTable.missPercent).toBe(baselineMissChance);
|
||||
expect(afterTornPackage.result?.deflected).not.toBe(true);
|
||||
useCombatStore.getState().tick(0.001, NOW + 1);
|
||||
expect(useCombatStore.getState().auras.filter((aura) => (
|
||||
(WOW335_DETERRENCE_PACKAGE_AURA_IDS as readonly string[]).includes(aura.definition.id)
|
||||
&& aura.sourceId === PLAYER_AGGRO_ID
|
||||
))).toHaveLength(0);
|
||||
expect(useCombatStore.getState().auras.filter((aura) => (
|
||||
(WOW335_DETERRENCE_PACKAGE_AURA_IDS as readonly string[]).includes(aura.definition.id)
|
||||
&& aura.sourceId === npc.id && aura.targetId === npc.id
|
||||
))).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("parries melee autos and leaves imported periodic and AlwaysHit source packets outside deflection", () => {
|
||||
prepareHunter();
|
||||
castDeterrence();
|
||||
const meleeAuto: EnemyAttackDefinition = {
|
||||
id: "basic-attack",
|
||||
name: "Basic Attack",
|
||||
delivery: "melee",
|
||||
target: "primary",
|
||||
school: "physical",
|
||||
attackKind: "melee",
|
||||
attackHand: "mainhand",
|
||||
animation: "attack",
|
||||
castTimeMs: 0,
|
||||
range: 4.5,
|
||||
cooldownMs: 30_000,
|
||||
initialCooldownMs: [0, 0],
|
||||
damageMultiplier: 1,
|
||||
};
|
||||
const parryLabel = Array.from({ length: 10_000 }, (_, index) => `melee-parry-${index}`)
|
||||
.find((label) => {
|
||||
const mobId = `deterrence-source-${label}`;
|
||||
const attackId = `${label}:${meleeAuto.id}`;
|
||||
const roll = stableCombatUnit(`${mobId}:${attackId}:${PLAYER_AGGRO_ID}:0:${NOW}:outcome`);
|
||||
return roll >= 0.2 && roll < 0.8;
|
||||
});
|
||||
expect(parryLabel).toBeDefined();
|
||||
const melee = resolveAttack(meleeAuto, parryLabel!);
|
||||
expect(melee.event?.outcome).toBe("parry");
|
||||
expect(melee.result).toMatchObject({ outcome: "parry", parried: true });
|
||||
expect(melee.result?.deflected).not.toBe(true);
|
||||
expect(melee.healthAfter).toBe(melee.healthBefore);
|
||||
|
||||
const periodic = sourceAttack({ id: "periodic-source-122", spellId: 122, school: "frost", periodicTick: true });
|
||||
expect(periodic.wowHitSource).toBeDefined();
|
||||
const periodicOutcome = resolveAttack(periodic, "periodic", NOW + 1_000);
|
||||
expect(periodicOutcome.event?.outcome).not.toBe("deflect");
|
||||
expect(periodicOutcome.healthAfter).toBeLessThan(periodicOutcome.healthBefore);
|
||||
|
||||
const alwaysHit = sourceAttack({ id: "always-hit-source-38163", spellId: 38_163, school: "shadow" });
|
||||
expect(alwaysHit.wowHitSource?.attributes3).toBe(0x0004_0000);
|
||||
const alwaysHitOutcome = resolveAttack(alwaysHit, "always-hit", NOW + 2_000);
|
||||
expect(alwaysHitOutcome.event?.outcome).not.toBe("deflect");
|
||||
expect(alwaysHitOutcome.healthAfter).toBeLessThan(alwaysHitOutcome.healthBefore);
|
||||
});
|
||||
|
||||
it("deflects an actual imported Frostbolt from an engaged mob targeting a configured Hunter NPC", () => {
|
||||
prepareHunter();
|
||||
const mobId = "deterrence-npc-frostbolt-source";
|
||||
const npc = prepareHunterNpcDefender(mobId);
|
||||
advanceDungeonPartyCombat(NOW);
|
||||
const packageAuras = useCombatStore.getState().auras.filter((aura) => (
|
||||
(WOW335_DETERRENCE_PACKAGE_AURA_IDS as readonly string[]).includes(aura.definition.id)
|
||||
&& aura.sourceId === npc.id && aura.targetId === npc.id
|
||||
));
|
||||
expect(packageAuras).toHaveLength(5);
|
||||
|
||||
const healthBefore = usePartyStore.getState().members[0]!.health;
|
||||
useCombatStore.getState().advanceMobCombat(NOW + 1);
|
||||
const event = [...useCombatStore.getState().combatEvents].reverse().find((candidate) => (
|
||||
candidate.sourceActorId === mobId && candidate.targetActorId === npc.id && candidate.kind === "damage"
|
||||
));
|
||||
expect(event).toMatchObject({ outcome: "deflect", effectiveAmount: 0, absorbed: 0 });
|
||||
expect(event?.rawAmount).toBeGreaterThan(0);
|
||||
expect(usePartyStore.getState().members[0]!.health).toBe(healthBefore);
|
||||
});
|
||||
|
||||
it("does not deflect a landed sourced spell while stunned and fails closed for unknown or native source records", () => {
|
||||
prepareHunter();
|
||||
castDeterrence();
|
||||
useCombatStore.setState({ controlledUntil: NOW + 5_000, controlMechanic: "stun" });
|
||||
const stunned = resolveAttack(importedFrostbolt!, "stunned-frostbolt");
|
||||
expect(stunned.event?.outcome).not.toBe("deflect");
|
||||
expect(stunned.healthAfter).toBeLessThan(stunned.healthBefore);
|
||||
|
||||
const ability = abilitiesForCharacter("hunter", null)
|
||||
.find((candidate) => candidate.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID)!;
|
||||
const auras = useCombatStore.getState().auras;
|
||||
const unknown = resolveWow335DeterrenceIncoming({
|
||||
ability,
|
||||
auras,
|
||||
defenderId: PLAYER_AGGRO_ID,
|
||||
nowMs: NOW + 1,
|
||||
incoming: {
|
||||
spellId: 999_999,
|
||||
damageOrigin: "wow335",
|
||||
attackKind: "spell",
|
||||
},
|
||||
baseOutcome: "hit",
|
||||
defenderStunned: false,
|
||||
attackerInFront: true,
|
||||
});
|
||||
expect(unknown).toEqual({ outcome: "unchanged", reason: "foreign-or-unknown-source" });
|
||||
|
||||
const nativeAttack = BARREN_CAVES_ENTITIES["rw-barren-caves-101456"].combat.attacks
|
||||
.find((attack) => "spellId" in attack && attack.spellId === 492_289);
|
||||
expect(nativeAttack).toBeDefined();
|
||||
const nativeSource = enemyDamageTypeSourceForSpell("runewaker", 492_289);
|
||||
expect(nativeSource).toBeDefined();
|
||||
const nativeInput: Wow335DeterrenceIncomingSource = {
|
||||
spellId: 492_289,
|
||||
damageOrigin: "runewaker",
|
||||
};
|
||||
expect(resolveWow335DeterrenceIncoming({
|
||||
ability,
|
||||
auras,
|
||||
defenderId: PLAYER_AGGRO_ID,
|
||||
nowMs: NOW + 1,
|
||||
incoming: nativeInput,
|
||||
baseOutcome: "hit",
|
||||
defenderStunned: false,
|
||||
attackerInFront: true,
|
||||
})).toEqual({ outcome: "unchanged", reason: "foreign-or-unknown-source" });
|
||||
|
||||
const absentSource = enemyDamageTypeSourceForSpell("wow335", 999_999);
|
||||
expect(absentSource).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyAura, type ActiveAura } from "./combatAuras";
|
||||
import { enemyDamageTypeSourceForSpell } from "./mobDamageTypes";
|
||||
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
||||
import {
|
||||
WOW335_DETERRENCE_ALWAYS_HIT_ATTR3,
|
||||
WOW335_DETERRENCE_LINKED_AURA_IDS,
|
||||
WOW335_DETERRENCE_NO_ACTIVE_DEFENSE_ATTR0,
|
||||
WOW335_DETERRENCE_NO_ATTACK_MISS_ATTR7,
|
||||
WOW335_HUNTER_DETERRENCE_ABILITY_ID,
|
||||
WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS,
|
||||
WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS,
|
||||
removeWow335DeterrencePackageAuras,
|
||||
resolveWow335DeterrenceIncoming,
|
||||
wow335DeterrenceAbilityContract,
|
||||
wow335DeterrenceActivePackage,
|
||||
wow335DeterrenceSourceBypassReason,
|
||||
type Wow335DeterrenceIncomingSource,
|
||||
} from "./wow335DeterrenceRuntime";
|
||||
|
||||
const DEFENDER_ID = "deterrence-hunter";
|
||||
const APPLIED_AT = 4_000;
|
||||
|
||||
function selectedAbility() {
|
||||
const ability = WOW335_ABILITIES_BY_CLASS.hunter.find((candidate) => (
|
||||
candidate.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID
|
||||
));
|
||||
if (!ability) throw new Error("The selected WoW335 Hunter Deterrence mapping is missing.");
|
||||
return ability;
|
||||
}
|
||||
|
||||
function packageAuras(options: {
|
||||
readonly sourceId?: string;
|
||||
readonly targetId?: string;
|
||||
readonly appliedAt?: number;
|
||||
} = {}): ActiveAura[] {
|
||||
const sourceId = options.sourceId ?? DEFENDER_ID;
|
||||
const targetId = options.targetId ?? DEFENDER_ID;
|
||||
const now = options.appliedAt ?? APPLIED_AT;
|
||||
let auras: readonly ActiveAura[] = [];
|
||||
for (const effect of [
|
||||
...WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS,
|
||||
...WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS,
|
||||
]) {
|
||||
if (effect.kind !== "apply-aura") throw new Error("Deterrence package descriptor is not an aura.");
|
||||
auras = applyAura(auras, { definition: effect.aura, sourceId, targetId, now }).auras;
|
||||
}
|
||||
return [...auras];
|
||||
}
|
||||
|
||||
function updateAura(
|
||||
auras: readonly ActiveAura[],
|
||||
id: string,
|
||||
update: (aura: ActiveAura) => ActiveAura,
|
||||
): ActiveAura[] {
|
||||
return auras.map((aura) => aura.definition.id === id ? update(aura) : aura);
|
||||
}
|
||||
|
||||
function exactIncoming(spellId: number): Wow335DeterrenceIncomingSource {
|
||||
const source = enemyDamageTypeSourceForSpell("wow335", spellId);
|
||||
if (!source || !("damageClass" in source) || !source.hitSource) {
|
||||
throw new Error(`Expected an exact hit-source fixture for WoW spell ${spellId}.`);
|
||||
}
|
||||
return {
|
||||
spellId,
|
||||
damageOrigin: "wow335",
|
||||
damageClass: source.damageClass,
|
||||
schoolMask: source.schoolMask,
|
||||
attackKind: source.attackKind,
|
||||
wowHitSource: source.hitSource,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveIncoming(input: {
|
||||
readonly spellId: number;
|
||||
readonly auras?: readonly ActiveAura[];
|
||||
readonly baseOutcome?: "hit" | "crit" | "miss" | "dodge" | "parry" | "block" | "resist";
|
||||
readonly defenderStunned?: boolean;
|
||||
readonly attackerInFront?: boolean;
|
||||
readonly incomingPatch?: Partial<Wow335DeterrenceIncomingSource>;
|
||||
readonly incomingOverride?: Wow335DeterrenceIncomingSource;
|
||||
}) {
|
||||
return resolveWow335DeterrenceIncoming({
|
||||
ability: selectedAbility(),
|
||||
auras: input.auras ?? packageAuras(),
|
||||
defenderId: DEFENDER_ID,
|
||||
nowMs: APPLIED_AT + 1,
|
||||
incoming: input.incomingOverride ?? { ...exactIncoming(input.spellId), ...input.incomingPatch },
|
||||
baseOutcome: input.baseOutcome ?? "hit",
|
||||
defenderStunned: input.defenderStunned ?? false,
|
||||
attackerInFront: input.attackerInFront ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("WoW335 Hunter Deterrence runtime contract", () => {
|
||||
it("binds only the selected catalog row and the audited parent/linked source package", () => {
|
||||
const ability = selectedAbility();
|
||||
expect(wow335DeterrenceAbilityContract(ability)).toBe(true);
|
||||
expect(wow335DeterrenceAbilityContract({ ...ability, id: "deterrence" })).toBe(false);
|
||||
expect(wow335DeterrenceAbilityContract({ ...ability, source: "ascension-progression" })).toBe(false);
|
||||
expect(wow335DeterrenceAbilityContract({ ...ability, dbcSpellId: 65871 })).toBe(false);
|
||||
expect(wow335DeterrenceAbilityContract({
|
||||
...ability,
|
||||
effects: ability.effects.map((effect) => effect.kind === "apply-aura"
|
||||
? { ...effect, aura: { ...effect.aura, description: "Presentation copy", icon: "/test/icon.png" } }
|
||||
: { ...effect }),
|
||||
})).toBe(true);
|
||||
expect(wow335DeterrenceAbilityContract({
|
||||
...ability,
|
||||
effects: ability.effects.map((effect) => effect.kind === "apply-aura"
|
||||
&& effect.sourceAuraType === 47
|
||||
? { ...effect, aura: { ...effect.aura, modifiers: [] } }
|
||||
: effect),
|
||||
})).toBe(false);
|
||||
expect(WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS.map((effect) => (
|
||||
effect.kind === "apply-aura" ? [effect.sourceAuraType, effect.sourceBasePoints, effect.aura.id] : null
|
||||
))).toEqual([
|
||||
[47, 99, "wow335:19263:effect:0:aura:47"],
|
||||
[287, 99, "wow335:19263:effect:1:aura:287"],
|
||||
[25, -1, "wow335:19263:effect:2:aura:25"],
|
||||
]);
|
||||
expect(WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS.map((effect) => (
|
||||
effect.kind === "apply-aura" ? [effect.sourceAuraType, effect.sourceBasePoints, effect.aura.id] : null
|
||||
))).toEqual([
|
||||
[288, 99, "wow335:67801:effect:0:aura:288"],
|
||||
[185, -101, "wow335:67801:effect:1:aura:185"],
|
||||
]);
|
||||
|
||||
const active = wow335DeterrenceActivePackage({
|
||||
ability,
|
||||
auras: packageAuras(),
|
||||
defenderId: DEFENDER_ID,
|
||||
nowMs: APPLIED_AT + 1,
|
||||
});
|
||||
expect(active).toMatchObject({
|
||||
sourceProfile: "wow335",
|
||||
parentSpellId: 19263,
|
||||
linkedAuraSpellId: 67801,
|
||||
sourceId: DEFENDER_ID,
|
||||
targetId: DEFENDER_ID,
|
||||
appliedAt: APPLIED_AT,
|
||||
expiresAt: APPLIED_AT + 5_000,
|
||||
parryAuraAmount: 100,
|
||||
deflectAuraAmount: 100,
|
||||
pacified: true,
|
||||
ignoreHitDirection: true,
|
||||
rangedAttackerHitChanceModifierPercent: -100,
|
||||
});
|
||||
|
||||
const secondHunterId = "second-deterrence-hunter";
|
||||
const twoHunterPackages = [...packageAuras(), ...packageAuras({
|
||||
sourceId: secondHunterId,
|
||||
targetId: secondHunterId,
|
||||
})];
|
||||
expect(wow335DeterrenceActivePackage({
|
||||
ability,
|
||||
auras: twoHunterPackages,
|
||||
defenderId: DEFENDER_ID,
|
||||
nowMs: APPLIED_AT + 1,
|
||||
})?.sourceId).toBe(DEFENDER_ID);
|
||||
expect(wow335DeterrenceActivePackage({
|
||||
ability,
|
||||
auras: twoHunterPackages,
|
||||
defenderId: secondHunterId,
|
||||
nowMs: APPLIED_AT + 1,
|
||||
})?.sourceId).toBe(secondHunterId);
|
||||
|
||||
const presentationDefinitions = packageAuras().map((aura) => ({
|
||||
...aura,
|
||||
definition: {
|
||||
...aura.definition,
|
||||
description: "HUD description",
|
||||
icon: "/test/icon.png",
|
||||
},
|
||||
}));
|
||||
expect(wow335DeterrenceActivePackage({
|
||||
ability,
|
||||
auras: presentationDefinitions,
|
||||
defenderId: DEFENDER_ID,
|
||||
nowMs: APPLIED_AT + 1,
|
||||
})).toBeDefined();
|
||||
});
|
||||
|
||||
it("requires all five same-owner, same-target aura instances from one five-second application", () => {
|
||||
const complete = packageAuras();
|
||||
const linkedOnly = complete.filter((aura) => WOW335_DETERRENCE_LINKED_AURA_IDS.includes(
|
||||
aura.definition.id as (typeof WOW335_DETERRENCE_LINKED_AURA_IDS)[number],
|
||||
));
|
||||
expect(wow335DeterrenceActivePackage({
|
||||
ability: selectedAbility(), auras: linkedOnly, defenderId: DEFENDER_ID, nowMs: APPLIED_AT + 1,
|
||||
})).toBeUndefined();
|
||||
const noIgnoreFacingChild = complete.filter((aura) => (
|
||||
aura.definition.id !== WOW335_DETERRENCE_LINKED_AURA_IDS[0]
|
||||
));
|
||||
expect(wow335DeterrenceActivePackage({
|
||||
ability: selectedAbility(), auras: noIgnoreFacingChild, defenderId: DEFENDER_ID, nowMs: APPLIED_AT + 1,
|
||||
})).toBeUndefined();
|
||||
expect(resolveIncoming({ spellId: 122, auras: noIgnoreFacingChild, attackerInFront: false }))
|
||||
.toEqual({ outcome: "unchanged", reason: "inactive-package" });
|
||||
|
||||
const mismatchedOwner = updateAura(complete, WOW335_DETERRENCE_LINKED_AURA_IDS[0], (aura) => ({
|
||||
...aura,
|
||||
sourceId: "another-hunter",
|
||||
}));
|
||||
expect(wow335DeterrenceActivePackage({
|
||||
ability: selectedAbility(), auras: mismatchedOwner, defenderId: DEFENDER_ID, nowMs: APPLIED_AT + 1,
|
||||
})).toBeUndefined();
|
||||
|
||||
const mismatchedTarget = updateAura(complete, WOW335_DETERRENCE_LINKED_AURA_IDS[1], (aura) => ({
|
||||
...aura,
|
||||
targetId: "another-defender",
|
||||
}));
|
||||
expect(wow335DeterrenceActivePackage({
|
||||
ability: selectedAbility(), auras: mismatchedTarget, defenderId: DEFENDER_ID, nowMs: APPLIED_AT + 1,
|
||||
})).toBeUndefined();
|
||||
|
||||
const mismatchedTime = updateAura(complete, WOW335_DETERRENCE_LINKED_AURA_IDS[1], (aura) => ({
|
||||
...aura,
|
||||
appliedAt: APPLIED_AT + 1,
|
||||
}));
|
||||
expect(wow335DeterrenceActivePackage({
|
||||
ability: selectedAbility(), auras: mismatchedTime, defenderId: DEFENDER_ID, nowMs: APPLIED_AT + 1,
|
||||
})).toBeUndefined();
|
||||
|
||||
const badLifetime = updateAura(complete, WOW335_DETERRENCE_LINKED_AURA_IDS[1], (aura) => ({
|
||||
...aura,
|
||||
expiresAt: APPLIED_AT + 6_000,
|
||||
}));
|
||||
expect(wow335DeterrenceActivePackage({
|
||||
ability: selectedAbility(), auras: badLifetime, defenderId: DEFENDER_ID, nowMs: APPLIED_AT + 1,
|
||||
})).toBeUndefined();
|
||||
expect(wow335DeterrenceActivePackage({
|
||||
ability: selectedAbility(), auras: complete, defenderId: DEFENDER_ID, nowMs: APPLIED_AT + 5_000,
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("deflects eligible landed Magic and ranged DamageClass spells after exact source gates", () => {
|
||||
expect(resolveIncoming({ spellId: 122, attackerInFront: false }))
|
||||
.toEqual({ outcome: "deflect", reason: "deflected" });
|
||||
expect(resolveIncoming({ spellId: 2643, attackerInFront: true, baseOutcome: "block" }))
|
||||
.toEqual({ outcome: "deflect", reason: "deflected" });
|
||||
});
|
||||
|
||||
it("preserves miss/resist outcomes and excludes stunned, periodic, autos, foreign, unknown, NONE, and melee inputs", () => {
|
||||
expect(resolveIncoming({ spellId: 122, baseOutcome: "miss" }))
|
||||
.toEqual({ outcome: "unchanged", reason: "source-miss-or-resist" });
|
||||
expect(resolveIncoming({ spellId: 122, baseOutcome: "resist" }))
|
||||
.toEqual({ outcome: "unchanged", reason: "source-miss-or-resist" });
|
||||
expect(resolveIncoming({ spellId: 122, defenderStunned: true }))
|
||||
.toEqual({ outcome: "unchanged", reason: "stunned" });
|
||||
expect(resolveIncoming({ spellId: 122, incomingPatch: { periodicTick: true } }))
|
||||
.toEqual({ outcome: "unchanged", reason: "periodic-or-auto-attack" });
|
||||
expect(resolveIncoming({ spellId: 2643, incomingPatch: { isAutoAttack: true, attackHand: "mainhand" } }))
|
||||
.toEqual({ outcome: "unchanged", reason: "non-spell-delivery" });
|
||||
const aura185 = WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS.find((effect) => (
|
||||
effect.kind === "apply-aura" && effect.sourceAuraType === 185
|
||||
));
|
||||
expect(aura185?.kind === "apply-aura" ? aura185.aura.modifiers : undefined)
|
||||
.toEqual([{ kind: "stat", stat: "incoming-ranged-hit", operation: "flat", value: -100 }]);
|
||||
expect(resolveIncoming({ spellId: 122, incomingPatch: { damageOrigin: "runewaker" } }))
|
||||
.toEqual({ outcome: "unchanged", reason: "foreign-or-unknown-source" });
|
||||
expect(resolveIncoming({ spellId: 999_999, incomingOverride: {
|
||||
spellId: 999_999,
|
||||
damageOrigin: "wow335",
|
||||
damageClass: 1,
|
||||
schoolMask: 1,
|
||||
attackKind: "spell",
|
||||
wowHitSource: { attributes0: 0, attributes3: 0, attributes7: 0 },
|
||||
} }))
|
||||
.toEqual({ outcome: "unchanged", reason: "foreign-or-unknown-source" });
|
||||
expect(resolveIncoming({ spellId: 5416 }))
|
||||
.toEqual({ outcome: "unchanged", reason: "unsupported-damage-class" });
|
||||
expect(resolveIncoming({ spellId: 3242 }))
|
||||
.toEqual({ outcome: "unchanged", reason: "unsupported-damage-class" });
|
||||
});
|
||||
|
||||
it("rejects borrowed hit flags and models branch-specific source bypass masks", () => {
|
||||
expect(resolveIncoming({ spellId: 122, incomingPatch: { wowHitSource: { attributes0: 0, attributes3: 1, attributes7: 0 } } }))
|
||||
.toEqual({ outcome: "unchanged", reason: "foreign-or-unknown-source" });
|
||||
|
||||
expect(wow335DeterrenceSourceBypassReason({
|
||||
damageClass: 1, attributes0: 0, attributes3: WOW335_DETERRENCE_ALWAYS_HIT_ATTR3, attributes7: 0,
|
||||
})).toBe("source-always-hit");
|
||||
expect(wow335DeterrenceSourceBypassReason({
|
||||
damageClass: 3, attributes0: WOW335_DETERRENCE_NO_ACTIVE_DEFENSE_ATTR0, attributes3: 0, attributes7: 0,
|
||||
})).toBe("source-no-active-defense");
|
||||
expect(wow335DeterrenceSourceBypassReason({
|
||||
damageClass: 1, attributes0: 0, attributes3: 0, attributes7: WOW335_DETERRENCE_NO_ATTACK_MISS_ATTR7,
|
||||
})).toBe("source-no-attack-miss");
|
||||
expect(wow335DeterrenceSourceBypassReason({
|
||||
damageClass: 1, attributes0: WOW335_DETERRENCE_NO_ACTIVE_DEFENSE_ATTR0, attributes3: 0, attributes7: 0,
|
||||
})).toBeUndefined();
|
||||
expect(wow335DeterrenceSourceBypassReason({
|
||||
damageClass: 3, attributes0: 0, attributes3: 0, attributes7: WOW335_DETERRENCE_NO_ATTACK_MISS_ATTR7,
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removes only package auras for the matching self-cast owner, target, and application", () => {
|
||||
const auras = [
|
||||
...packageAuras(),
|
||||
...packageAuras({ sourceId: "other-hunter", targetId: "other-hunter", appliedAt: APPLIED_AT }),
|
||||
...packageAuras({ sourceId: DEFENDER_ID, targetId: DEFENDER_ID, appliedAt: APPLIED_AT + 1 }),
|
||||
];
|
||||
const remaining = removeWow335DeterrencePackageAuras(auras, {
|
||||
sourceId: DEFENDER_ID,
|
||||
defenderId: DEFENDER_ID,
|
||||
appliedAt: APPLIED_AT,
|
||||
});
|
||||
expect(remaining).toHaveLength(10);
|
||||
expect(remaining.every((aura) => aura.appliedAt !== APPLIED_AT
|
||||
|| aura.sourceId !== DEFENDER_ID
|
||||
|| aura.targetId !== DEFENDER_ID)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,461 @@
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
import type { ActiveAura, AuraDefinition } from "./combatAuras";
|
||||
import { enemyDamageTypeSourceForSpell } from "./mobDamageTypes";
|
||||
import {
|
||||
WOW335_DETERRENCE_AURA_LINK,
|
||||
WOW335_DETERRENCE_SOURCE_SIGNATURES,
|
||||
wow335DeterrenceSourceContract,
|
||||
} from "./wow335Deterrence";
|
||||
|
||||
export const WOW335_HUNTER_DETERRENCE_ABILITY_ID = "wow335-hunter-deterrence";
|
||||
export const WOW335_DETERRENCE_AURA_DURATION_MS = 5_000;
|
||||
export const WOW335_DETERRENCE_ALWAYS_HIT_ATTR3 = 0x00040000;
|
||||
export const WOW335_DETERRENCE_NO_ACTIVE_DEFENSE_ATTR0 = 0x00200000;
|
||||
export const WOW335_DETERRENCE_NO_ATTACK_MISS_ATTR7 = 0x02000000;
|
||||
|
||||
export const WOW335_DETERRENCE_PARENT_AURA_IDS = Object.freeze([
|
||||
"wow335:19263:effect:0:aura:47",
|
||||
"wow335:19263:effect:1:aura:287",
|
||||
"wow335:19263:effect:2:aura:25",
|
||||
] as const);
|
||||
|
||||
export const WOW335_DETERRENCE_LINKED_AURA_IDS = Object.freeze([
|
||||
"wow335:67801:effect:0:aura:288",
|
||||
"wow335:67801:effect:1:aura:185",
|
||||
] as const);
|
||||
|
||||
export const WOW335_DETERRENCE_PACKAGE_AURA_IDS: readonly string[] = Object.freeze([
|
||||
...WOW335_DETERRENCE_PARENT_AURA_IDS,
|
||||
...WOW335_DETERRENCE_LINKED_AURA_IDS,
|
||||
]);
|
||||
|
||||
const makeAura = (
|
||||
id: string,
|
||||
name: string,
|
||||
modifiers?: AuraDefinition["modifiers"],
|
||||
utilityTags?: readonly string[],
|
||||
): AuraDefinition => Object.freeze({
|
||||
id,
|
||||
name,
|
||||
disposition: "buff",
|
||||
durationMs: WOW335_DETERRENCE_AURA_DURATION_MS,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
uniqueBySource: true,
|
||||
...(modifiers?.length ? { modifiers: Object.freeze([...modifiers]) } : {}),
|
||||
...(utilityTags?.length ? { utilityTags: Object.freeze([...utilityTags]) } : {}),
|
||||
});
|
||||
|
||||
function applyAuraEffect(input: {
|
||||
readonly aura: AuraDefinition;
|
||||
readonly sourceAuraType: number;
|
||||
readonly sourceBasePoints: number;
|
||||
}): AbilityEffect {
|
||||
const { aura, sourceAuraType, sourceBasePoints } = input;
|
||||
return Object.freeze({
|
||||
kind: "apply-aura",
|
||||
aura,
|
||||
recipient: "ability-target",
|
||||
sourceEffectType: 6,
|
||||
sourceAuraType,
|
||||
sourceBasePoints,
|
||||
sourceDieSides: 1,
|
||||
sourcePointsPerLevel: 0,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 0,
|
||||
}) as AbilityEffect;
|
||||
}
|
||||
|
||||
const parentParry = makeAura(
|
||||
WOW335_DETERRENCE_PARENT_AURA_IDS[0],
|
||||
"Deterrence (DBC 19263, Aura 47)",
|
||||
[{ kind: "stat", stat: "parry", operation: "flat", value: 100 }],
|
||||
);
|
||||
const parentDeflect = makeAura(
|
||||
WOW335_DETERRENCE_PARENT_AURA_IDS[1],
|
||||
"Deterrence (DBC 19263, Aura 287)",
|
||||
);
|
||||
const parentPacify = makeAura(
|
||||
WOW335_DETERRENCE_PARENT_AURA_IDS[2],
|
||||
"Deterrence (DBC 19263, Aura 25)",
|
||||
undefined,
|
||||
["pacified"],
|
||||
);
|
||||
const linkedIgnoreFacing = makeAura(
|
||||
WOW335_DETERRENCE_LINKED_AURA_IDS[0],
|
||||
"Deterrence (DBC 67801, Aura 288)",
|
||||
);
|
||||
const linkedRangedHitPenalty = makeAura(
|
||||
WOW335_DETERRENCE_LINKED_AURA_IDS[1],
|
||||
"Deterrence (DBC 67801, Aura 185)",
|
||||
[{ kind: "stat", stat: "incoming-ranged-hit", operation: "flat", value: -100 }],
|
||||
);
|
||||
|
||||
/** Exact three source effects for selected Hunter Spell.dbc row 19263. */
|
||||
export const WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS: readonly AbilityEffect[] = Object.freeze([
|
||||
applyAuraEffect({ aura: parentParry, sourceAuraType: 47, sourceBasePoints: 99 }),
|
||||
applyAuraEffect({ aura: parentDeflect, sourceAuraType: 287, sourceBasePoints: 99 }),
|
||||
applyAuraEffect({ aura: parentPacify, sourceAuraType: 25, sourceBasePoints: -1 }),
|
||||
]);
|
||||
|
||||
/** Exact two effects from the linked spell_linked_spell Aura row 67801. */
|
||||
export const WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS: readonly AbilityEffect[] = Object.freeze([
|
||||
applyAuraEffect({ aura: linkedIgnoreFacing, sourceAuraType: 288, sourceBasePoints: 99 }),
|
||||
applyAuraEffect({ aura: linkedRangedHitPenalty, sourceAuraType: 185, sourceBasePoints: -101 }),
|
||||
]);
|
||||
|
||||
const selectedSourcePackage = wow335DeterrenceSourceContract({
|
||||
sourceProfile: "wow335",
|
||||
parent: WOW335_DETERRENCE_SOURCE_SIGNATURES.selectedParent,
|
||||
linkedAura: WOW335_DETERRENCE_SOURCE_SIGNATURES.linkedAura,
|
||||
linkedRows: [WOW335_DETERRENCE_AURA_LINK],
|
||||
});
|
||||
const expectedPackageAuraDefinitions = Object.freeze([
|
||||
...WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS,
|
||||
...WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS,
|
||||
].map((effect) => isApplyAuraEffect(effect) ? effect.aura : undefined));
|
||||
|
||||
export interface Wow335DeterrenceIncomingSource {
|
||||
readonly spellId?: number;
|
||||
readonly damageOrigin?: "wow335" | "runewaker";
|
||||
readonly damageClass?: number;
|
||||
readonly schoolMask?: number;
|
||||
readonly attackKind?: "melee" | "ranged" | "spell";
|
||||
readonly attackHand?: string;
|
||||
readonly isAutoAttack?: boolean;
|
||||
readonly periodicTick?: boolean;
|
||||
readonly wowHitSource?: {
|
||||
readonly attributes0: number;
|
||||
readonly attributes3: number;
|
||||
readonly attributes7: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Wow335DeterrenceActivePackage {
|
||||
readonly sourceProfile: "wow335";
|
||||
readonly parentSpellId: 19263;
|
||||
readonly linkedAuraSpellId: 67801;
|
||||
readonly sourceId: string;
|
||||
readonly targetId: string;
|
||||
readonly appliedAt: number;
|
||||
readonly expiresAt: number;
|
||||
readonly parryAuraAmount: 100;
|
||||
readonly deflectAuraAmount: 100;
|
||||
readonly pacified: true;
|
||||
readonly ignoreHitDirection: true;
|
||||
/** Already applied by Aura 185 through the normal incoming-hit modifier path. */
|
||||
readonly rangedAttackerHitChanceModifierPercent: -100;
|
||||
readonly auraInstances: readonly [ActiveAura, ActiveAura, ActiveAura, ActiveAura, ActiveAura];
|
||||
}
|
||||
|
||||
function isApplyAuraEffect(effect: AbilityEffect | undefined): effect is Extract<AbilityEffect, { kind: "apply-aura" }> {
|
||||
return effect?.kind === "apply-aura";
|
||||
}
|
||||
|
||||
function structurallyEqual(left: unknown, right: unknown): boolean {
|
||||
if (Object.is(left, right)) return true;
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
return Array.isArray(left) && Array.isArray(right)
|
||||
&& left.length === right.length
|
||||
&& left.every((value, index) => structurallyEqual(value, right[index]));
|
||||
}
|
||||
if (left === null || right === null || typeof left !== "object" || typeof right !== "object") return false;
|
||||
const leftRecord = left as Record<string, unknown>;
|
||||
const rightRecord = right as Record<string, unknown>;
|
||||
const leftKeys = Object.keys(leftRecord).sort();
|
||||
const rightKeys = Object.keys(rightRecord).sort();
|
||||
return leftKeys.length === rightKeys.length
|
||||
&& leftKeys.every((key, index) => key === rightKeys[index]
|
||||
&& structurallyEqual(leftRecord[key], rightRecord[key]));
|
||||
}
|
||||
|
||||
function auraBehavior(definition: AuraDefinition): Readonly<Record<string, unknown>> {
|
||||
const {
|
||||
name: _name,
|
||||
description: _description,
|
||||
icon: _icon,
|
||||
hideFromAuraStrip: _hideFromAuraStrip,
|
||||
...behavior
|
||||
} = definition;
|
||||
return behavior;
|
||||
}
|
||||
|
||||
function sameAuraBehavior(actual: AuraDefinition, expected: AuraDefinition): boolean {
|
||||
return structurallyEqual(auraBehavior(actual), auraBehavior(expected));
|
||||
}
|
||||
|
||||
function sameApplyAuraEffect(
|
||||
actual: Extract<AbilityEffect, { kind: "apply-aura" }>,
|
||||
expected: Extract<AbilityEffect, { kind: "apply-aura" }>,
|
||||
): boolean {
|
||||
const { aura: actualAura, ...actualFields } = actual;
|
||||
const { aura: expectedAura, ...expectedFields } = expected;
|
||||
return structurallyEqual(actualFields, expectedFields)
|
||||
&& sameAuraBehavior(actualAura, expectedAura);
|
||||
}
|
||||
|
||||
function isSelectedParentDefense(
|
||||
defense: AbilityDefinition["wowSpellDefense"] | undefined,
|
||||
): boolean {
|
||||
return defense?.sourceProfile === "wow335"
|
||||
&& defense.damageClass === 0
|
||||
&& defense.schoolMask === 1
|
||||
&& defense.attributes0 === 327_696
|
||||
&& defense.attributes1 === 16
|
||||
&& defense.attributes3 === 0
|
||||
&& defense.attributes5 === 0
|
||||
&& defense.directDamageFromEffects === false
|
||||
&& defense.customAttributes === undefined
|
||||
&& defense.customAttributeEvidence === undefined;
|
||||
}
|
||||
|
||||
function matchesParentEffectList(effects: readonly AbilityEffect[] | undefined): boolean {
|
||||
return effects?.length === WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS.length
|
||||
&& effects.every((effect, index) => {
|
||||
const expected = WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS[index];
|
||||
return Boolean(expected && isApplyAuraEffect(effect) && isApplyAuraEffect(expected)
|
||||
&& sameApplyAuraEffect(effect, expected));
|
||||
});
|
||||
}
|
||||
|
||||
function matchesSelectedAbility(ability: AbilityDefinition): boolean {
|
||||
if (!selectedSourcePackage
|
||||
|| ability.id !== WOW335_HUNTER_DETERRENCE_ABILITY_ID
|
||||
|| ability.classId !== "hunter"
|
||||
|| ability.source !== "wow335-progression"
|
||||
|| ability.dbcSpellId !== 19263
|
||||
|| ability.name !== "Deterrence"
|
||||
|| ability.target !== "self"
|
||||
|| ability.range.min !== 0
|
||||
|| ability.range.max !== 0
|
||||
|| ability.castMode !== "instant"
|
||||
|| ability.castTimeMs !== 0
|
||||
|| ability.cooldownMs !== 90_000
|
||||
|| ability.gcdMs !== 0
|
||||
|| ability.cost.amount !== 0
|
||||
|| ability.passive === true
|
||||
|| !isSelectedParentDefense(ability.wowSpellDefense)
|
||||
|| ability.ranks?.length !== 1
|
||||
|| !matchesParentEffectList(ability.effects)) return false;
|
||||
|
||||
const [rank] = ability.ranks;
|
||||
return rank?.spellId === 19263
|
||||
&& rank.level === 60
|
||||
&& rank.castTimeMs === 0
|
||||
&& rank.cooldownMs === 90_000
|
||||
&& rank.gcdMs === 0
|
||||
&& rank.cost.amount === 0
|
||||
&& isSelectedParentDefense(rank.wowSpellDefense)
|
||||
&& matchesParentEffectList(rank.effects);
|
||||
}
|
||||
|
||||
/** Validate the exact selected Hunter catalog mapping before casting 19263. */
|
||||
export function wow335DeterrenceAbilityContract(ability: AbilityDefinition): boolean {
|
||||
return matchesSelectedAbility(ability);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the two verified 67801 child effects for the original target. The
|
||||
* caller should apply them atomically with the accepted 19263 parent cast.
|
||||
*/
|
||||
export function wow335DeterrenceLinkedAuraEffects(): readonly AbilityEffect[] {
|
||||
return WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS;
|
||||
}
|
||||
|
||||
function activeAuraById(auras: readonly ActiveAura[], id: string, ownerId: string): ActiveAura | undefined {
|
||||
const matches = auras.filter((aura) => aura.definition.id === id
|
||||
&& aura.sourceId === ownerId
|
||||
&& aura.targetId === ownerId);
|
||||
return matches.length === 1 ? matches[0] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve only a complete live 19263 + linked 67801 aura package on the
|
||||
* selected self-cast ability. All five actual aura instances must share the
|
||||
* same owner, target, application time, and exact five-second lifetime.
|
||||
*/
|
||||
export function wow335DeterrenceActivePackage(input: {
|
||||
readonly ability: AbilityDefinition;
|
||||
readonly auras: readonly ActiveAura[];
|
||||
readonly defenderId: string;
|
||||
readonly nowMs: number;
|
||||
}): Wow335DeterrenceActivePackage | undefined {
|
||||
if (!matchesSelectedAbility(input.ability)
|
||||
|| !input.defenderId
|
||||
|| !Number.isFinite(input.nowMs)) return undefined;
|
||||
|
||||
const instances = WOW335_DETERRENCE_PACKAGE_AURA_IDS.map((id) => (
|
||||
activeAuraById(input.auras, id, input.defenderId)
|
||||
));
|
||||
if (instances.some((aura) => !aura)) return undefined;
|
||||
const [parry, deflect, pacify, ignoreFacing, rangedPenalty] = instances as [
|
||||
ActiveAura, ActiveAura, ActiveAura, ActiveAura, ActiveAura,
|
||||
];
|
||||
const group = [parry, deflect, pacify, ignoreFacing, rangedPenalty] as const;
|
||||
const [first] = group;
|
||||
if (!first) return undefined;
|
||||
const appliedAt = first.appliedAt;
|
||||
const expiresAt = first.expiresAt;
|
||||
if (!Number.isFinite(appliedAt)
|
||||
|| expiresAt === null
|
||||
|| !Number.isFinite(expiresAt)
|
||||
|| expiresAt !== appliedAt + WOW335_DETERRENCE_AURA_DURATION_MS
|
||||
|| input.nowMs < appliedAt
|
||||
|| input.nowMs >= expiresAt) return undefined;
|
||||
|
||||
const allUniqueInstances = new Set<string>();
|
||||
for (let index = 0; index < group.length; index += 1) {
|
||||
const aura = group[index];
|
||||
const expectedId = WOW335_DETERRENCE_PACKAGE_AURA_IDS[index];
|
||||
const expectedDefinition = expectedPackageAuraDefinitions[index];
|
||||
if (!aura || !expectedId
|
||||
|| !expectedDefinition
|
||||
|| aura.definition.id !== expectedId
|
||||
|| !sameAuraBehavior(aura.definition, expectedDefinition)
|
||||
|| aura.definition.durationMs !== WOW335_DETERRENCE_AURA_DURATION_MS
|
||||
|| aura.sourceId !== input.defenderId
|
||||
|| aura.targetId !== input.defenderId
|
||||
|| aura.appliedAt !== appliedAt
|
||||
|| aura.expiresAt !== expiresAt
|
||||
|| aura.stacks !== 1
|
||||
|| !aura.instanceId
|
||||
|| allUniqueInstances.has(aura.instanceId)) return undefined;
|
||||
allUniqueInstances.add(aura.instanceId);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
sourceProfile: "wow335",
|
||||
parentSpellId: 19263,
|
||||
linkedAuraSpellId: 67801,
|
||||
sourceId: input.defenderId,
|
||||
targetId: input.defenderId,
|
||||
appliedAt,
|
||||
expiresAt,
|
||||
parryAuraAmount: 100,
|
||||
deflectAuraAmount: 100,
|
||||
pacified: true,
|
||||
ignoreHitDirection: true,
|
||||
rangedAttackerHitChanceModifierPercent: -100,
|
||||
auraInstances: Object.freeze([...group]) as unknown as Wow335DeterrenceActivePackage["auraInstances"],
|
||||
});
|
||||
}
|
||||
|
||||
export type Wow335DeterrenceDeflectReason =
|
||||
| "deflected"
|
||||
| "inactive-package"
|
||||
| "foreign-or-unknown-source"
|
||||
| "unsupported-damage-class"
|
||||
| "non-spell-delivery"
|
||||
| "periodic-or-auto-attack"
|
||||
| "source-miss-or-resist"
|
||||
| "stunned"
|
||||
| "source-always-hit"
|
||||
| "source-no-active-defense"
|
||||
| "source-no-attack-miss"
|
||||
| "front-arc-required";
|
||||
|
||||
export interface Wow335DeterrenceDeflectResolution {
|
||||
readonly outcome: "deflect" | "unchanged";
|
||||
readonly reason: Wow335DeterrenceDeflectReason;
|
||||
}
|
||||
|
||||
/** The source-owned hit-check bits that suppress aura-287 deflection. */
|
||||
export function wow335DeterrenceSourceBypassReason(input: {
|
||||
readonly damageClass: number;
|
||||
readonly attributes0: number;
|
||||
readonly attributes3: number;
|
||||
readonly attributes7: number;
|
||||
}): Wow335DeterrenceDeflectReason | undefined {
|
||||
if ((input.attributes3 & WOW335_DETERRENCE_ALWAYS_HIT_ATTR3) !== 0) return "source-always-hit";
|
||||
if (input.damageClass === 3
|
||||
&& (input.attributes0 & WOW335_DETERRENCE_NO_ACTIVE_DEFENSE_ATTR0) !== 0) {
|
||||
return "source-no-active-defense";
|
||||
}
|
||||
if (input.damageClass === 1
|
||||
&& (input.attributes7 & WOW335_DETERRENCE_NO_ATTACK_MISS_ATTR7) !== 0) {
|
||||
return "source-no-attack-miss";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sourceHitFlagsMatch(
|
||||
incoming: Wow335DeterrenceIncomingSource,
|
||||
): boolean {
|
||||
if (incoming.damageOrigin !== "wow335"
|
||||
|| !Number.isSafeInteger(incoming.spellId)
|
||||
|| incoming.spellId === undefined
|
||||
|| !incoming.wowHitSource) return false;
|
||||
const source = enemyDamageTypeSourceForSpell("wow335", incoming.spellId);
|
||||
if (!source || !("hitSource" in source) || !source.hitSource) return false;
|
||||
return incoming.damageClass === source.damageClass
|
||||
&& incoming.schoolMask === source.schoolMask
|
||||
&& incoming.wowHitSource.attributes0 === source.hitSource.attributes0
|
||||
&& incoming.wowHitSource.attributes3 === source.hitSource.attributes3
|
||||
&& incoming.wowHitSource.attributes7 === source.hitSource.attributes7;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate only the final source-defined deflect gate. The existing combat
|
||||
* result remains responsible for miss, dodge, resistance, and other outcomes.
|
||||
*/
|
||||
export function resolveWow335DeterrenceIncoming(input: {
|
||||
readonly ability: AbilityDefinition;
|
||||
readonly auras: readonly ActiveAura[];
|
||||
readonly defenderId: string;
|
||||
readonly nowMs: number;
|
||||
readonly incoming: Wow335DeterrenceIncomingSource;
|
||||
readonly baseOutcome: "hit" | "crit" | "miss" | "dodge" | "parry" | "block" | "resist";
|
||||
readonly defenderStunned: boolean;
|
||||
readonly attackerInFront: boolean;
|
||||
}): Wow335DeterrenceDeflectResolution {
|
||||
const packageState = wow335DeterrenceActivePackage({
|
||||
ability: input.ability,
|
||||
auras: input.auras,
|
||||
defenderId: input.defenderId,
|
||||
nowMs: input.nowMs,
|
||||
});
|
||||
if (!packageState) return { outcome: "unchanged", reason: "inactive-package" };
|
||||
|
||||
const { incoming } = input;
|
||||
if (!sourceHitFlagsMatch(incoming)) return { outcome: "unchanged", reason: "foreign-or-unknown-source" };
|
||||
if (incoming.damageClass !== 1 && incoming.damageClass !== 3) {
|
||||
return { outcome: "unchanged", reason: "unsupported-damage-class" };
|
||||
}
|
||||
if (incoming.attackHand !== undefined || incoming.isAutoAttack === true
|
||||
|| (incoming.damageClass === 1 && incoming.attackKind !== "spell")
|
||||
|| (incoming.damageClass === 3 && incoming.attackKind !== "ranged" && incoming.attackKind !== "spell")) {
|
||||
return { outcome: "unchanged", reason: "non-spell-delivery" };
|
||||
}
|
||||
if (incoming.periodicTick === true) return { outcome: "unchanged", reason: "periodic-or-auto-attack" };
|
||||
if (input.baseOutcome !== "hit" && input.baseOutcome !== "crit" && input.baseOutcome !== "block") {
|
||||
return { outcome: "unchanged", reason: "source-miss-or-resist" };
|
||||
}
|
||||
if (input.defenderStunned) return { outcome: "unchanged", reason: "stunned" };
|
||||
|
||||
const sourceBypass = wow335DeterrenceSourceBypassReason({
|
||||
damageClass: incoming.damageClass,
|
||||
...incoming.wowHitSource!,
|
||||
});
|
||||
if (sourceBypass) return { outcome: "unchanged", reason: sourceBypass };
|
||||
|
||||
if (!packageState.ignoreHitDirection && !input.attackerInFront) {
|
||||
return { outcome: "unchanged", reason: "front-arc-required" };
|
||||
}
|
||||
return { outcome: "deflect", reason: "deflected" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove only this self-cast's exact Deterrence package members. The caller
|
||||
* uses the removed parent's appliedAt to synchronize linked-aura removal.
|
||||
*/
|
||||
export function removeWow335DeterrencePackageAuras(
|
||||
auras: readonly ActiveAura[],
|
||||
input: { readonly sourceId: string; readonly defenderId: string; readonly appliedAt: number },
|
||||
): readonly ActiveAura[] {
|
||||
if (!input.sourceId || input.sourceId !== input.defenderId || !Number.isFinite(input.appliedAt)) return auras;
|
||||
const ids = new Set(WOW335_DETERRENCE_PACKAGE_AURA_IDS);
|
||||
return auras.filter((aura) => !(ids.has(aura.definition.id)
|
||||
&& aura.sourceId === input.sourceId
|
||||
&& aura.targetId === input.defenderId
|
||||
&& aura.appliedAt === input.appliedAt));
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { abilitiesForClass, abilityAtLevel, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore, stableCombatUnit } from "./combatStore";
|
||||
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
||||
import { clearMobRuntimeRegistry, registerMobRuntime } from "./mobRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition,
|
||||
updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
|
||||
const TARGET = "synthetic-interrupt-block-creature";
|
||||
const FRONT = [0, 0, -2] as const;
|
||||
const ORIGIN = [0, 0, 0] as const;
|
||||
const CASES = [
|
||||
{ classId: "rogue" as const, name: "Kick", spellId: 1766, lockoutMs: 5_000 },
|
||||
{ classId: "warrior" as const, name: "Pummel", spellId: 6552, lockoutMs: 4_000 },
|
||||
];
|
||||
|
||||
function prepare(classId: "rogue" | "warrior", name: string) {
|
||||
useCombatStore.getState().initializeCharacter({ classId, level: 40 });
|
||||
usePartyStore.getState().initializeParty("interrupt-block-fixture", 40,
|
||||
useCombatStore.getState().health, "damage", 1, "wow");
|
||||
useCombatStore.getState().setPlayerPosition(FRONT);
|
||||
useCombatStore.getState().registerMob(TARGET, { name: "Ordinary source NPC fixture",
|
||||
serverEntry: 1666, level: 25, maxHealth: 100_000, xpReward: 0 });
|
||||
registerMobRuntime(TARGET, ORIGIN, Math.PI);
|
||||
registerCombatSpatialProvider({ actor: (id) => id === TARGET
|
||||
? { id, position: ORIGIN, yaw: Math.PI } : { id, position: FRONT, yaw: 0 },
|
||||
hasLineOfSight: () => true, projectGroundPoint: (position) => position });
|
||||
useCombatStore.getState().selectMob(TARGET);
|
||||
useCombatStore.setState((state) => ({ resource: state.maxResource,
|
||||
resourcePools: { ...state.resourcePools, [classId === "warrior" ? "rage" : "energy"]: state.maxResource } }));
|
||||
return abilityAtLevel(abilitiesForClass(classId)
|
||||
.find((ability) => ability.id === (classId === "rogue" ? "rogue-kick" : "wow335-warrior-pummel"))!, 40);
|
||||
}
|
||||
function blockedTime(actorId: string, abilityId: string) {
|
||||
for (let now = 1_000; now < 100_000; now++) {
|
||||
if (stableCombatUnit(`${actorId}:${abilityId}:${TARGET}:${now}:utility-hit:outcome`) < 0.01) return now;
|
||||
}
|
||||
throw new Error("No deterministic source utility block roll");
|
||||
}
|
||||
function blockedEvents(actorId: string, abilityId: string) {
|
||||
return useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === actorId
|
||||
&& event.targetActorId === TARGET && event.abilityId === abilityId && event.blocked);
|
||||
}
|
||||
|
||||
describe("exact selected Kick/Pummel ordinary NPC full blocking", () => {
|
||||
beforeAll(async () => { await preloadAbilityCatalogsForClasses(["warrior", "rogue"]); });
|
||||
afterEach(() => {
|
||||
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetCombatSpatialProvider(); resetPartyRuntimeClock();
|
||||
});
|
||||
|
||||
it.each(CASES)("records a blocked actual player $name while committing cost/cooldown", ({ classId, name, spellId }) => {
|
||||
const ability = prepare(classId, name);
|
||||
expect(ability.dbcSpellId).toBe(spellId);
|
||||
const now = blockedTime(PLAYER_AGGRO_ID, ability.id);
|
||||
const before = useCombatStore.getState();
|
||||
const cast = before.castAbility(ability.id, { now, targetActorId: TARGET });
|
||||
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().lastCombatResult).toMatchObject({ outcome: "block", blocked: true, finalDamage: 0 });
|
||||
expect(blockedEvents(PLAYER_AGGRO_ID, ability.id)).toHaveLength(1);
|
||||
expect(useCombatStore.getState().resource).toBeLessThan(before.resource);
|
||||
expect(useCombatStore.getState().cooldowns[ability.id]).toBeGreaterThan(now);
|
||||
expect(useCombatStore.getState().mobs[TARGET]!.statuses).toHaveLength(0);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.abilityId === ability.id && event.kind === "damage")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(CASES)("respects the casting-defender block gate and real $name school lockout", ({ classId, name, lockoutMs }) => {
|
||||
const ability = prepare(classId, name);
|
||||
const now = blockedTime(PLAYER_AGGRO_ID, ability.id);
|
||||
useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [TARGET]: { ...state.mobs[TARGET]!,
|
||||
activeCast: { attackId: "synthetic-fire-cast", name: "Synthetic interruptible Fire cast",
|
||||
school: "fire", interruptible: true, targetActorId: PLAYER_AGGRO_ID,
|
||||
sourcePosition: ORIGIN, targetPosition: FRONT, sourceYaw: Math.PI,
|
||||
startedAt: now - 1, completesAt: now + 5_000 },
|
||||
} } }));
|
||||
const cast = useCombatStore.getState().castAbility(ability.id, { now, targetActorId: TARGET });
|
||||
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().lastCombatResult).toMatchObject({ outcome: "hit", blocked: false });
|
||||
expect(useCombatStore.getState().mobs[TARGET]!.activeCast).toBeNull();
|
||||
expect(useCombatStore.getState().mobs[TARGET]!.schoolLockedUntil.fire).toBe(now + lockoutMs);
|
||||
});
|
||||
|
||||
it.each(CASES)("blocks accepted companion $name completion without inventing damage", ({ classId, name }) => {
|
||||
const ability = prepare(classId, name);
|
||||
const member = createConfiguredPartyMember({ name: "Configured interrupt companion", classId,
|
||||
specialization: classId === "warrior" ? "Protection" : "Assassination",
|
||||
specializationId: classId === "warrior" ? "warrior:protection" : "rogue:assassination",
|
||||
talentTreeId: classId === "warrior" ? "warrior-protection" : "rogue-assassination",
|
||||
talentRanks: {}, role: "damage" }, 40, 0, "interrupt-block-fixture");
|
||||
const now = blockedTime(member.id, ability.id);
|
||||
// Synthetic already-paid pending cast; actual target validation/completion
|
||||
// consumes the selected source ability through the companion runtime.
|
||||
usePartyStore.setState({ members: [{ ...member, nextActionAt: 100_000,
|
||||
cooldowns: { [ability.id]: now + 10_000 }, activeCastTargetId: TARGET,
|
||||
activeCast: { abilityId: ability.id, school: "physical", mode: "cast",
|
||||
startedAt: now - 1, completesAt: now, originalDurationMs: 1,
|
||||
pushbackHits: 0, origin: FRONT, targetId: TARGET },
|
||||
}], command: "attack", activeMobId: TARGET });
|
||||
registerPartyRuntimePosition(member.id, FRONT);
|
||||
updatePartyRuntimeTargetVisibility(member.id, TARGET, true);
|
||||
useCombatStore.getState().damageMob(TARGET, 1, now - 1);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
advanceDungeonPartyCombat(now);
|
||||
expect(blockedEvents(member.id, ability.id)).toHaveLength(1);
|
||||
expect(usePartyStore.getState().members[0]!.activeCast).toBeNull();
|
||||
expect(usePartyStore.getState().members[0]!.cooldowns[ability.id]).toBe(now + 10_000);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.abilityId === ability.id && event.kind === "damage")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { abilitiesForClass, abilityAtLevel, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
|
||||
const NOW = 2_000;
|
||||
// Ten valid first-row Frost points satisfy Lichborne's current tree gate.
|
||||
const TALENTS = { "talent-2031": 3, "talent-2020": 2, "talent-1968": 5, "talent-2215": 1 };
|
||||
const LICHBORNE_ID = "wow335-death-knight-lichborne";
|
||||
|
||||
function prepare(options: {
|
||||
readonly recipient: "player" | "party";
|
||||
readonly spellId?: 19134 | 20511;
|
||||
readonly learned?: boolean;
|
||||
readonly genericControl?: "fear" | "stun" | "confuse" | "sleep";
|
||||
readonly standaloneControl?: boolean;
|
||||
readonly cooldownReadyAt?: number;
|
||||
readonly globalCooldownEndsAt?: number;
|
||||
}) {
|
||||
const spellId = options.spellId ?? 19134;
|
||||
const mechanic = options.standaloneControl ? options.genericControl!
|
||||
: spellId === 20511 ? "stun" : "fear";
|
||||
const controlledUntil = options.standaloneControl ? 4_000 : 9_000;
|
||||
const talentRanks = options.learned === false ? {} : TALENTS;
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: options.recipient === "player" ? "death-knight" : "priest",
|
||||
level: 80,
|
||||
talentRanks: options.recipient === "player" ? talentRanks : {},
|
||||
});
|
||||
usePartyStore.getState().initializeParty("lichborne-escape-fixture", 80,
|
||||
useCombatStore.getState().health, "damage", 1);
|
||||
const member = createConfiguredPartyMember({
|
||||
name: "Configured Lichborne Knight", classId: "death-knight",
|
||||
specialization: "Frost", specializationId: "death-knight:frost",
|
||||
talentTreeId: "death-knight-frost", talentRanks, role: "damage",
|
||||
}, 80, 0, "lichborne-escape-fixture");
|
||||
usePartyStore.setState({ members: options.recipient === "party" ? [{
|
||||
...member, controlledUntil, controlMechanic: mechanic, status: "holding",
|
||||
cooldowns: options.cooldownReadyAt === undefined ? member.cooldowns
|
||||
: { ...member.cooldowns, [LICHBORNE_ID]: options.cooldownReadyAt },
|
||||
globalCooldownEndsAt: options.globalCooldownEndsAt ?? 0,
|
||||
}] : [] });
|
||||
const targetId = options.recipient === "player" ? PLAYER_AGGRO_ID : member.id;
|
||||
if (options.recipient === "party") registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.setState((state) => {
|
||||
let auras = options.standaloneControl ? state.auras : applyAura(state.auras, { definition: {
|
||||
id: `enemy-control:source-spell-${spellId}`, name: "Pinned source shout fixture",
|
||||
disposition: "debuff", durationMs: 8_000,
|
||||
modifiers: [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.15 }],
|
||||
}, sourceId: "synthetic-enemy", targetId, now: 1_000 }).auras;
|
||||
if (options.genericControl) auras = applyAura(auras, { definition: {
|
||||
id: `enemy-control:separate-enemy:separate-cast:${options.genericControl}`, name: "Independent control fixture",
|
||||
disposition: "debuff", durationMs: 3_000,
|
||||
}, sourceId: "separate-enemy", targetId, now: 1_000 }).auras;
|
||||
return {
|
||||
auras: [...auras],
|
||||
controlledUntil: options.recipient === "player" ? controlledUntil : 0,
|
||||
controlMechanic: options.recipient === "player" ? mechanic : null,
|
||||
cooldowns: options.recipient === "player" && options.cooldownReadyAt !== undefined
|
||||
? { ...state.cooldowns, [LICHBORNE_ID]: options.cooldownReadyAt } : state.cooldowns,
|
||||
globalCooldownEndsAt: options.recipient === "player" ? options.globalCooldownEndsAt ?? 0 : 0,
|
||||
};
|
||||
});
|
||||
return targetId;
|
||||
}
|
||||
|
||||
function hasSourceControl(targetId: string) {
|
||||
return useCombatStore.getState().auras.some((aura) => aura.targetId === targetId
|
||||
&& aura.definition.id.startsWith("enemy-control:source-spell-"));
|
||||
}
|
||||
function hasLichborneFearGrant(targetId: string) {
|
||||
return useCombatStore.getState().auras.some((aura) => aura.targetId === targetId
|
||||
&& aura.definition.id.startsWith("wow335:49039:")
|
||||
&& aura.definition.controlImmunities?.includes("fear"));
|
||||
}
|
||||
|
||||
describe("source-bound Lichborne player and configured companion escape", () => {
|
||||
beforeAll(async () => { await preloadAbilityCatalogsForClasses(["death-knight"]); });
|
||||
afterEach(() => {
|
||||
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
|
||||
});
|
||||
|
||||
it.each([19134, 20511] as const)("casts learned player Lichborne through source control %s", (spellId) => {
|
||||
const id = prepare({ recipient: "player", spellId });
|
||||
const selected = abilityAtLevel(abilitiesForClass("death-knight")
|
||||
.find((ability) => ability.id === LICHBORNE_ID)!, 80);
|
||||
expect(selected.dbcSpellId).toBe(49039);
|
||||
expect(useCombatStore.getState().talentRanks["talent-2215"]).toBe(1);
|
||||
const cast = useCombatStore.getState().castAbility(LICHBORNE_ID, { now: NOW });
|
||||
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
||||
expect(hasSourceControl(id)).toBe(false);
|
||||
expect(hasLichborneFearGrant(id)).toBe(true);
|
||||
expect(useCombatStore.getState()).toMatchObject({ controlledUntil: 0, controlMechanic: null });
|
||||
expect(useCombatStore.getState().cooldowns[LICHBORNE_ID]).toBe(NOW + 120_000);
|
||||
useCombatStore.getState().tick(0.1, NOW + 100);
|
||||
expect(hasSourceControl(id)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([19134, 20511] as const)("uses learned companion Lichborne through source control %s", (spellId) => {
|
||||
const id = prepare({ recipient: "party", spellId });
|
||||
advanceDungeonPartyCombat(NOW);
|
||||
expect(hasSourceControl(id)).toBe(false);
|
||||
expect(hasLichborneFearGrant(id)).toBe(true);
|
||||
expect(usePartyStore.getState().members[0]).toMatchObject({ controlledUntil: 0, controlMechanic: null });
|
||||
expect(useCombatStore.getState().actors[id]?.controlledUntil).toBe(0);
|
||||
expect(usePartyStore.getState().members[0]!.cooldowns[LICHBORNE_ID]).toBe(NOW + 120_000);
|
||||
});
|
||||
|
||||
it.each(["player", "party"] as const)("keeps %s controlled when Lichborne is unlearned, cooling down or blocked by unsupported sleep", (recipient) => {
|
||||
for (const condition of [{ learned: false }, { cooldownReadyAt: 10_000 }, { genericControl: "sleep" as const }]) {
|
||||
const id = prepare({ recipient, ...condition });
|
||||
if (recipient === "player") expect(useCombatStore.getState().castAbility(LICHBORNE_ID, { now: NOW }).ok).toBe(false);
|
||||
else advanceDungeonPartyCombat(NOW);
|
||||
expect(hasSourceControl(id)).toBe(true);
|
||||
expect(hasLichborneFearGrant(id)).toBe(false);
|
||||
if (condition.genericControl) expect(useCombatStore.getState().auras.some((aura) => aura.targetId === id
|
||||
&& aura.definition.id === "enemy-control:separate-enemy:separate-cast:sleep")).toBe(true);
|
||||
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["player", "party"] as const)("permits %s casts through source-authorized fear/stun/confuse while preserving unrelated control", (recipient) => {
|
||||
for (const genericControl of ["fear", "stun", "confuse"] as const) {
|
||||
for (const standaloneControl of [false, true]) {
|
||||
const id = prepare({ recipient, genericControl, standaloneControl });
|
||||
if (recipient === "player") {
|
||||
const cast = useCombatStore.getState().castAbility(LICHBORNE_ID, { now: NOW });
|
||||
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
||||
}
|
||||
else advanceDungeonPartyCombat(NOW);
|
||||
expect(hasSourceControl(id)).toBe(false);
|
||||
expect(hasLichborneFearGrant(id)).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === id
|
||||
&& aura.definition.id === `enemy-control:separate-enemy:separate-cast:${genericControl}`)).toBe(true);
|
||||
const actor = recipient === "player" ? useCombatStore.getState() : usePartyStore.getState().members[0]!;
|
||||
expect(actor.controlledUntil).toBe(4_000);
|
||||
expect(actor.controlMechanic).toBe(genericControl);
|
||||
if (recipient === "party") expect(usePartyStore.getState().members[0]!.status).toBe("holding");
|
||||
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["player", "party"] as const)("preserves the existing %s GCD when source off-GCD Lichborne escapes", (recipient) => {
|
||||
const id = prepare({ recipient, globalCooldownEndsAt: 10_000 });
|
||||
if (recipient === "player") {
|
||||
const cast = useCombatStore.getState().castAbility(LICHBORNE_ID, { now: NOW });
|
||||
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
||||
}
|
||||
else advanceDungeonPartyCombat(NOW);
|
||||
expect(hasSourceControl(id)).toBe(false);
|
||||
expect(recipient === "player" ? useCombatStore.getState().globalCooldownEndsAt
|
||||
: usePartyStore.getState().members[0]!.globalCooldownEndsAt).toBe(10_000);
|
||||
});
|
||||
});
|
||||
@@ -227,7 +227,9 @@ export function ProductionDungeon({
|
||||
if (!assets.visual.length || !assets.collision.length) {
|
||||
throw new Error(`${definition.title} mounted without its required visual and collision GLBs.`);
|
||||
}
|
||||
// Raksha's WDB collision omits the walkable entrance floor present in its navmesh.
|
||||
// Retain Raksha's navigation-ground supplement for reviewed route gaps.
|
||||
// Its authored entrance floor is present in the WDB collision; the former
|
||||
// below-deck spawn came from incorrectly decoded Recast input vertices.
|
||||
const navigationGroundUrl = definition.id === "raksha-temple" ? assets.navigation : null;
|
||||
if (definition.id === "raksha-temple" && !navigationGroundUrl) {
|
||||
throw new Error("Raksha Temple mounted without its required navigation ground GLB.");
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { DungeonEnvironmentMode, DungeonMaterialRoles } from "../game/dunge
|
||||
import { copyRunewakerTerrainMaterial } from "./runewakerTerrainMaterial";
|
||||
import { copyRunewakerWaterMaterial } from "./runewakerWaterMaterial";
|
||||
import { copyRunewakerSurfaceDetail } from "./runewakerSurfaceDetailMaterial";
|
||||
import { copyWowTerrainMaterial } from "./wowTerrainMaterial";
|
||||
|
||||
function matchesRole(name: string, patterns: readonly string[], names: readonly string[]): boolean {
|
||||
const lower = name.toLowerCase();
|
||||
@@ -39,6 +40,7 @@ export function prepareDungeonMaterial(
|
||||
copyRunewakerTerrainMaterial(source, material);
|
||||
copyRunewakerWaterMaterial(source, material);
|
||||
copyRunewakerSurfaceDetail(source, material);
|
||||
copyWowTerrainMaterial(source, material);
|
||||
const name = material.name;
|
||||
material.side = DoubleSide;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { resolveContentUrl } from "../content/contentManager";
|
||||
import { configureRunewakerTerrainLoader } from "./runewakerTerrainMaterial";
|
||||
import { configureRunewakerSurfaceDetailLoader } from "./runewakerSurfaceDetailMaterial";
|
||||
import { configureRunewakerWaterLoader } from "./runewakerWaterMaterial";
|
||||
import { configureWowTerrainLoader } from "./wowTerrainMaterial";
|
||||
|
||||
export const KTX2_TRANSCODER_PATH = `${import.meta.env.BASE_URL}basis/`;
|
||||
|
||||
@@ -45,6 +46,7 @@ export function useGameGLTF(url: string) {
|
||||
configureRunewakerTerrainLoader(loader);
|
||||
configureRunewakerSurfaceDetailLoader(loader);
|
||||
configureRunewakerWaterLoader(loader);
|
||||
configureWowTerrainLoader(loader);
|
||||
}, [renderer]);
|
||||
|
||||
return useGLTF(resolvedUrl, false, true, extendLoader);
|
||||
@@ -58,6 +60,7 @@ export function useGameGLTFs(urls: readonly string[]) {
|
||||
configureRunewakerTerrainLoader(loader);
|
||||
configureRunewakerSurfaceDetailLoader(loader);
|
||||
configureRunewakerWaterLoader(loader);
|
||||
configureWowTerrainLoader(loader);
|
||||
}, [renderer]);
|
||||
|
||||
return useGLTF(resolvedUrls, false, true, extendLoader);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Group, Mesh, MeshStandardMaterial, NoColorSpace, PlaneGeometry, RepeatWrapping, SRGBColorSpace, Texture } from "three";
|
||||
import type { GLTF, GLTFParser } from "three-stdlib";
|
||||
import { bindWowTerrainMaterial, createWowTerrainPlugin } from "./wowTerrainMaterial";
|
||||
import { prepareDungeonMaterial } from "./productionDungeonMaterials";
|
||||
import { applyDungeonMeshGraphics } from "./graphicsMaterialQuality";
|
||||
import { disposeObject3DResources, materialTextures } from "./threeResourceDisposal";
|
||||
|
||||
const roles = { cutoutPatterns: [], cutoutNames: [], additivePatterns: [], additiveNames: [], blendPatterns: [], blendNames: [] };
|
||||
const metadata = {
|
||||
schemaVersion: 1, sourceAdt: { path: "world/maps/tanarisinstance/tanarisinstance_30_29.adt", sha256: "a".repeat(64) },
|
||||
layerVariants: ["dirt", "rock"], weightVariants: ["chunk-alpha"],
|
||||
uv: { attribute: "TEXCOORD_0", basis: "mcnk-local-normalized", diffuseScale: [8, 8], weightScale: [1, 1], vSign: 1 },
|
||||
composition: { rule: "wow-overlay-residual", normalize: false },
|
||||
};
|
||||
|
||||
function fixture() {
|
||||
const layers = [new Texture(), new Texture()];
|
||||
layers.forEach(texture => {
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.wrapS = RepeatWrapping;
|
||||
texture.wrapT = RepeatWrapping;
|
||||
});
|
||||
const weights = new Texture();
|
||||
const material = new MeshStandardMaterial({ map: layers[0] });
|
||||
material.userData.wowTerrain = structuredClone(metadata);
|
||||
const scene = new Group();
|
||||
scene.add(new Mesh(new PlaneGeometry(), material));
|
||||
const carriers = [new MeshStandardMaterial({ map: layers[0] }), new MeshStandardMaterial({ map: layers[1] }),
|
||||
new MeshStandardMaterial({ aoMap: weights })];
|
||||
const parser: Pick<GLTFParser, "json" | "getDependency"> = {
|
||||
json: { extensions: { KHR_materials_variants: { variants: ["dirt", "rock", "chunk-alpha"].map(name => ({ name })) } },
|
||||
meshes: [{ primitives: [{ extensions: { KHR_materials_variants: {
|
||||
mappings: carriers.map((_, index) => ({ material: index, variants: [index] })),
|
||||
} } }] }] },
|
||||
getDependency: vi.fn(async (_kind, index) => carriers[index]),
|
||||
};
|
||||
return { layers, weights, material, scene, carriers, parser };
|
||||
}
|
||||
|
||||
describe("source-backed WoW terrain materials", () => {
|
||||
it("keeps native tile detail and linear blend data through production cloning, filtering and disposal", async () => {
|
||||
const { layers, weights, material, scene, parser } = fixture();
|
||||
await createWowTerrainPlugin(parser).afterRoot!({ scene } as GLTF);
|
||||
const clone = prepareDungeonMaterial(material, roles, "adt-hybrid");
|
||||
const allTextures = [...layers, weights];
|
||||
expect(new Set(materialTextures(clone))).toEqual(new Set(allTextures));
|
||||
expect(layers.every(texture => texture.colorSpace === SRGBColorSpace)).toBe(true);
|
||||
expect(weights.colorSpace).toBe(NoColorSpace);
|
||||
expect(clone.customProgramCacheKey()).toBe(material.customProgramCacheKey());
|
||||
const root = new Group();
|
||||
const geometry = new PlaneGeometry();
|
||||
root.add(new Mesh(geometry, clone), new Mesh(geometry, clone));
|
||||
applyDungeonMeshGraphics(root, "environment", "high", 16, 8);
|
||||
expect(allTextures.every(texture => texture.anisotropy === 8)).toBe(true);
|
||||
const disposals = allTextures.map(texture => vi.spyOn(texture, "dispose"));
|
||||
expect(disposeObject3DResources(root)).toEqual({ geometries: 1, materials: 1, textures: 3, skeletons: 0 });
|
||||
disposals.forEach(dispose => expect(dispose).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("rejects unverified sources and unsupported normalized/reversed blends before loading carriers", async () => {
|
||||
for (const change of [
|
||||
(data: typeof metadata) => { data.sourceAdt.sha256 = "unknown"; },
|
||||
(data: typeof metadata) => { data.composition.normalize = true; },
|
||||
(data: typeof metadata) => { data.uv.vSign = -1; },
|
||||
]) {
|
||||
const { material, scene, parser } = fixture();
|
||||
change(material.userData.wowTerrain);
|
||||
await expect(createWowTerrainPlugin(parser).afterRoot!({ scene } as GLTF)).rejects.toThrow("Unsupported or unverified");
|
||||
expect(parser.getDependency).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("requires the linear AO carrier for native weights and refuses missing or ambiguous variants", async () => {
|
||||
const first = fixture();
|
||||
first.carriers[2].aoMap = null;
|
||||
first.carriers[2].map = first.weights;
|
||||
await expect(createWowTerrainPlugin(first.parser).afterRoot!({ scene: first.scene } as GLTF)).rejects.toThrow("Missing native WoW terrain image");
|
||||
const second = fixture();
|
||||
second.parser.json.meshes[0].primitives[0].extensions.KHR_materials_variants.mappings.pop();
|
||||
await expect(createWowTerrainPlugin(second.parser).afterRoot!({ scene: second.scene } as GLTF)).rejects.toThrow("Missing native WoW terrain carrier");
|
||||
const third = fixture();
|
||||
third.parser.json.meshes[0].primitives[0].extensions.KHR_materials_variants.mappings.push({ material: 1, variants: [0] });
|
||||
await expect(createWowTerrainPlugin(third.parser).afterRoot!({ scene: third.scene } as GLTF)).rejects.toThrow("ambiguous native WoW");
|
||||
});
|
||||
|
||||
it("supports base-only native chunks and refuses an incomplete multilayer binding", () => {
|
||||
const material = new MeshStandardMaterial();
|
||||
const base = new Texture();
|
||||
base.colorSpace = SRGBColorSpace;
|
||||
base.wrapS = RepeatWrapping;
|
||||
base.wrapT = RepeatWrapping;
|
||||
bindWowTerrainMaterial(material, { layers: [base] });
|
||||
expect(materialTextures(material)).toEqual([base]);
|
||||
expect(() => bindWowTerrainMaterial(material, { layers: [base, new Texture()] })).toThrow("layer layout");
|
||||
const invalidWeights = new Texture();
|
||||
invalidWeights.colorSpace = SRGBColorSpace;
|
||||
expect(() => bindWowTerrainMaterial(material, { layers: [base, base], weights: invalidWeights })).toThrow("clamped linear blend");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { ClampToEdgeWrapping, MeshStandardMaterial, NoColorSpace, RepeatWrapping, SRGBColorSpace,
|
||||
type Material, type Mesh, type Object3D, type Texture } from "three";
|
||||
import type { GLTFParser } from "three-stdlib";
|
||||
|
||||
export interface WowTerrainLayers {
|
||||
readonly layers: readonly Texture[];
|
||||
/** RGB contains mutually exclusive overlay weights in native MCLY order. */
|
||||
readonly weights?: Texture;
|
||||
}
|
||||
|
||||
interface WowTerrainMetadata {
|
||||
readonly schemaVersion: 1;
|
||||
readonly sourceAdt: { readonly sha256: string; readonly path: string };
|
||||
readonly layerVariants: readonly string[];
|
||||
readonly weightVariants: readonly string[];
|
||||
readonly uv: {
|
||||
readonly attribute: "TEXCOORD_0";
|
||||
readonly basis: "mcnk-local-normalized";
|
||||
readonly diffuseScale: readonly [8, 8];
|
||||
readonly weightScale: readonly [1, 1];
|
||||
readonly vSign: 1;
|
||||
};
|
||||
readonly composition: { readonly rule: "wow-overlay-residual"; readonly normalize: false };
|
||||
}
|
||||
|
||||
const bindings = new WeakMap<Material, WowTerrainLayers>();
|
||||
const configuredLoaders = new WeakSet<object>();
|
||||
|
||||
/** Native detail repeats eight times per chunk; alpha uses normalized chunk UV. */
|
||||
export function bindWowTerrainMaterial(material: Material, data: WowTerrainLayers): void {
|
||||
if (!(material instanceof MeshStandardMaterial)) throw new Error("WoW terrain requires a standard lit material.");
|
||||
if (data.layers.length < 1 || data.layers.length > 4 || Boolean(data.weights) !== (data.layers.length > 1)) {
|
||||
throw new Error("Invalid native WoW terrain layer layout.");
|
||||
}
|
||||
if (data.layers.some(texture => texture.colorSpace !== SRGBColorSpace
|
||||
|| texture.wrapS !== RepeatWrapping || texture.wrapT !== RepeatWrapping)
|
||||
|| (data.weights && (data.weights.colorSpace !== NoColorSpace
|
||||
|| data.weights.wrapS !== ClampToEdgeWrapping || data.weights.wrapT !== ClampToEdgeWrapping))) {
|
||||
throw new Error("Native WoW terrain requires repeating color tiles and clamped linear blend weights.");
|
||||
}
|
||||
bindings.set(material, data);
|
||||
const uniforms: Record<string, { value: Texture }> = {};
|
||||
data.layers.forEach((value, index) => { uniforms[`wowTerrainLayer${index}`] = { value }; });
|
||||
if (data.weights) uniforms.wowTerrainWeights = { value: data.weights };
|
||||
// Shared filtering and disposal visit these auxiliary texture uniforms.
|
||||
Object.assign(material, { uniforms });
|
||||
material.onBeforeCompile = shader => {
|
||||
Object.assign(shader.uniforms, uniforms);
|
||||
shader.vertexShader = shader.vertexShader
|
||||
.replace("#include <uv_pars_vertex>", "#include <uv_pars_vertex>\nvarying vec2 wowTerrainUv;")
|
||||
.replace("#include <uv_vertex>", "#include <uv_vertex>\nwowTerrainUv = uv;");
|
||||
shader.fragmentShader = ["varying vec2 wowTerrainUv;",
|
||||
...Object.keys(uniforms).map(name => `uniform sampler2D ${name};`), shader.fragmentShader].join("\n");
|
||||
const channels = "rgb".slice(0, data.layers.length - 1).split("");
|
||||
const blend = channels.length ? [
|
||||
"vec3 wowOverlay = texture2D(wowTerrainWeights, wowTerrainUv).rgb;",
|
||||
`float wowBaseWeight = 1.0 - (${channels.map(channel => `wowOverlay.${channel}`).join(" + ")});`,
|
||||
"vec3 wowAlbedo = texture2D(wowTerrainLayer0, wowTerrainUv * 8.0).rgb * wowBaseWeight;",
|
||||
...channels.map((channel, index) => `wowAlbedo += texture2D(wowTerrainLayer${index + 1}, wowTerrainUv * 8.0).rgb * wowOverlay.${channel};`),
|
||||
] : ["vec3 wowAlbedo = texture2D(wowTerrainLayer0, wowTerrainUv * 8.0).rgb;"];
|
||||
// Native tile alpha is not surface opacity. Weight conversion belongs to
|
||||
// the source decoder; normalization here would change authored blending.
|
||||
blend.push("diffuseColor.rgb *= wowAlbedo;");
|
||||
shader.fragmentShader = shader.fragmentShader.replace("#include <map_fragment>", blend.join("\n"));
|
||||
};
|
||||
material.customProgramCacheKey = () => `wow-terrain-residual-v1:${data.layers.length}`;
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function copyWowTerrainMaterial(source: Material, target: Material): void {
|
||||
const data = bindings.get(source);
|
||||
if (data) bindWowTerrainMaterial(target, data);
|
||||
}
|
||||
|
||||
function metadataFor(material: Material): WowTerrainMetadata {
|
||||
const data = material.userData.wowTerrain as WowTerrainMetadata | undefined;
|
||||
if (!data || data.schemaVersion !== 1 || !/^[a-f0-9]{64}$/i.test(data.sourceAdt?.sha256 ?? "")
|
||||
|| typeof data.sourceAdt?.path !== "string" || !data.sourceAdt.path.toLowerCase().endsWith(".adt")
|
||||
|| !Array.isArray(data.layerVariants) || data.layerVariants.length < 1 || data.layerVariants.length > 4
|
||||
|| !data.layerVariants.every(name => typeof name === "string" && name.length > 0)
|
||||
|| !Array.isArray(data.weightVariants) || data.weightVariants.length !== (data.layerVariants.length > 1 ? 1 : 0)
|
||||
|| !data.weightVariants.every(name => typeof name === "string" && name.length > 0)
|
||||
|| data.uv?.attribute !== "TEXCOORD_0" || data.uv.basis !== "mcnk-local-normalized" || data.uv.vSign !== 1
|
||||
|| !Array.isArray(data.uv.diffuseScale) || data.uv.diffuseScale.length !== 2 || data.uv.diffuseScale.some(value => value !== 8)
|
||||
|| !Array.isArray(data.uv.weightScale) || data.uv.weightScale.length !== 2 || data.uv.weightScale.some(value => value !== 1)
|
||||
|| data.composition?.rule !== "wow-overlay-residual" || data.composition.normalize !== false) {
|
||||
throw new Error("Unsupported or unverified native WoW terrain metadata.");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Keep the contract compatible with both production three-stdlib and the
|
||||
// isolated Three.js example loader; their full GLTF parser types differ.
|
||||
export function createWowTerrainPlugin(parser: Pick<GLTFParser, "json" | "getDependency">): {
|
||||
name: string; afterRoot: (gltf: { scene: Object3D }) => Promise<void>;
|
||||
} {
|
||||
return {
|
||||
name: "HEALERMAN_wow_terrain",
|
||||
async afterRoot(gltf) {
|
||||
const materials = new Set<Material>();
|
||||
gltf.scene.traverse(node => {
|
||||
const mesh = node as Mesh;
|
||||
if (!mesh.isMesh) return;
|
||||
for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) {
|
||||
if (material.userData.wowTerrain) {
|
||||
if (!mesh.geometry.getAttribute("uv")) throw new Error("Native WoW terrain requires chunk UV coordinates.");
|
||||
materials.add(material);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!materials.size) return;
|
||||
const variants = parser.json.extensions?.KHR_materials_variants?.variants ?? [];
|
||||
const carriers = new Map<string, number>();
|
||||
for (const mesh of parser.json.meshes ?? []) {
|
||||
for (const primitive of mesh.primitives ?? []) {
|
||||
for (const mapping of primitive.extensions?.KHR_materials_variants?.mappings ?? []) {
|
||||
for (const index of mapping.variants) {
|
||||
const name = variants[index]?.name;
|
||||
if (typeof name !== "string" || (carriers.has(name) && carriers.get(name) !== mapping.material)) {
|
||||
throw new Error("Missing or ambiguous native WoW terrain carrier.");
|
||||
}
|
||||
carriers.set(name, mapping.material);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const texture = async (name: string, weight: boolean): Promise<Texture> => {
|
||||
const index = carriers.get(name);
|
||||
if (index === undefined) throw new Error(`Missing native WoW terrain carrier ${name}.`);
|
||||
const carrier: MeshStandardMaterial = await parser.getDependency("material", index);
|
||||
const image = weight ? carrier.aoMap : carrier.map;
|
||||
if (!image) throw new Error(`Missing native WoW terrain image ${name}.`);
|
||||
return image;
|
||||
};
|
||||
await Promise.all([...materials].map(async material => {
|
||||
const metadata = metadataFor(material);
|
||||
const [layers, weights] = await Promise.all([
|
||||
Promise.all(metadata.layerVariants.map(name => texture(name, false))),
|
||||
metadata.weightVariants.length ? texture(metadata.weightVariants[0], true) : undefined,
|
||||
]);
|
||||
bindWowTerrainMaterial(material, { layers, weights });
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function configureWowTerrainLoader(loader: { register: (factory: typeof createWowTerrainPlugin) => unknown }): void {
|
||||
if (configuredLoaders.has(loader)) return;
|
||||
loader.register(createWowTerrainPlugin);
|
||||
configuredLoaders.add(loader);
|
||||
}
|
||||
@@ -74,6 +74,14 @@ describe("combat visual mode option", () => {
|
||||
expect(markup).toContain("Party healing numbers");
|
||||
});
|
||||
|
||||
it("shows the enabled healer recovery option and its exact rate", () => {
|
||||
const markup = renderOptions(DEFAULT_COMBAT_PRESENTATION_SETTINGS.vfxMode);
|
||||
expect(markup).toContain("Healer mana recovery");
|
||||
expect(markup).toContain("15% of maximum mana per second");
|
||||
expect(markup).toMatch(/Healer mana recovery<\/strong>.*?aria-pressed="true".*?>On<\/button>/);
|
||||
expect(DEFAULT_GAMEPLAY_SETTINGS.healerManaRecovery).toBe(true);
|
||||
});
|
||||
|
||||
it("shows dungeon graphics controls with the saved defaults", () => {
|
||||
const markup = renderOptions(DEFAULT_COMBAT_PRESENTATION_SETTINGS.vfxMode);
|
||||
expect(markup).toContain("Render resolution");
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface OptionsPanelSettings extends GraphicsSettings {
|
||||
showOwnDamageText: boolean;
|
||||
showAllyDamageText: boolean;
|
||||
showIncomingDamageText: boolean;
|
||||
healerManaRecovery: boolean;
|
||||
autoTargetNearest: boolean;
|
||||
enableScreenShake: boolean;
|
||||
masterVolume: number;
|
||||
@@ -66,12 +67,13 @@ export interface OptionsPanelProps {
|
||||
|
||||
interface ToggleOption {
|
||||
id: keyof Pick<OptionsPanelSettings,
|
||||
"showMobHealthBars" | "showMobAggroRanges" | "showTargetFrame" | "showThreatMeter" | "showFloatingCombatText" | "showOwnDamageText" | "showAllyDamageText" | "showIncomingDamageText" | "autoTargetNearest" | "enableScreenShake">;
|
||||
"showMobHealthBars" | "showMobAggroRanges" | "showTargetFrame" | "showThreatMeter" | "showFloatingCombatText" | "showOwnDamageText" | "showAllyDamageText" | "showIncomingDamageText" | "healerManaRecovery" | "autoTargetNearest" | "enableScreenShake">;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const TOGGLES: readonly ToggleOption[] = [
|
||||
{ id: "healerManaRecovery", label: "Healer mana recovery", description: "A living healer restores 15% of maximum mana per second to the party while out of combat." },
|
||||
{ id: "showMobHealthBars", label: "Mob health bars", description: "Show health above nearby enemies." },
|
||||
{ id: "showMobAggroRanges", label: "Mob aggro ranges", description: "Show the boundary where idle dungeon enemies automatically attack your party." },
|
||||
{ id: "showTargetFrame", label: "Target frame", description: "Show detailed status for your current target." },
|
||||
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user