Release Healer Man 0.1.14

This commit is contained in:
phenom committed 2026-09-05 11:40:50 -04:00
1 parent 67d7a8ebe7
commit 7b41b102fa
147 files changed
+169892 -1342

No files matched your search

+1
View File
@@ -20,5 +20,6 @@ releases/
/content/ /content/
dist-android/ dist-android/
.android-public/ .android-public/
.web-public/
__pycache__/ __pycache__/
*.pyc *.pyc
+66 -33
View File
@@ -1,10 +1,12 @@
# Local-first content updates and Android releases # Local-first content updates for browser and Android
Healer Man separates the installed game client from large game content. The Healer Man separates the installed game client from large game content. Both
Android APK contains the React/Three.js runtime, native Android bridge, menus, the production browser build and Android APK contain a compact required set:
local profile and save support, UI assets, and the Wailing Caverns starter the React/Three.js runtime, menus, local profile and save support, UI assets,
pack. Other GLBs, textures, audio, character packs, dungeons, and Manastorm starter equipment, Wailing Caverns starter content, and the basic Human
stages are published by TrueNAS as optional content. character model with its default male and female appearances. Android also
contains its native bridge. Other GLBs, textures, audio, character/skin packs,
dungeons, and Manastorm stages are published by TrueNAS as optional content.
Single-player is local-first. Starting the game, loading a character, entering Single-player is local-first. Starting the game, loading a character, entering
installed content, simulating combat, and saving progress never require the installed content, simulating combat, and saving progress never require the
@@ -27,16 +29,12 @@ UI behavior, shaders or loaders, native Android code, permissions, or the
content-manifest schema. A manifest can set `minimumAppVersion` so an older APK content-manifest schema. A manifest can set `minimumAppVersion` so an older APK
will not activate incompatible content. will not activate incompatible content.
The browser build remains a full build served by TrueNAS. It receives client Production builds deliberately stage only required content. `npm run build`
code when the app checkout is pulled, rebuilt, and restarted. The Android build prepares `.web-public` and emits the compact browser `dist`; Android prepares
is deliberately separate and compact so the APK does not inherit the complete `.android-public` and emits `dist-android`. The development server continues to
multi-gigabyte browser asset catalog. use the source `public` tree so asset-pipeline work remains convenient. The
complete source catalog and published immutable objects stay on TrueNAS, not in
Measured on August 14, 2026, the production browser `dist` is 3.88 GiB across every player's initial browser or Android installation.
18,895 files; GLBs account for 3.75 GiB. The compact Android `dist-android` is
148.0 MiB across 5,561 files, including 47.8 MiB of starter GLBs. The final
signed APK will be smaller because Android packages compress eligible files;
record its exact size after the first release build.
## Content manifest ## Content manifest
@@ -56,8 +54,9 @@ The runtime continues to request stable paths such as:
``` ```
The content resolver maps that path to a verified file in Android app-private The content resolver maps that path to a verified file in Android app-private
storage. Filenames are therefore not used as version numbers and runtime code storage or, in production browsers, to the app-managed content cache through a
does not need to know the hash. same-origin service worker. Filenames are therefore not used as version numbers
and runtime code does not need to know the hash.
## Safe publication and activation ## Safe publication and activation
@@ -68,17 +67,34 @@ Publication order is intentionally atomic:
3. Write the versioned manifest. 3. Write the versioned manifest.
4. Replace `manifest.json` only after all referenced objects exist. 4. Replace `manifest.json` only after all referenced objects exist.
On a device, the client fetches the small manifest and compares it with its The client fetches the small manifest and compares it with its bundled and
installed manifest. Only missing or hash-mismatched objects are downloaded. installed file identities. Only missing or hash-mismatched objects are
Android streams each object to a `.part` file, supports HTTP range resume, downloaded. Browsers download into a temporary Cache Storage staging area,
verifies its size and SHA-256, then renames it into the object cache. The active verify every object's byte count and SHA-256, and promote the pack to the active
logical-path map changes only after the complete pack verifies. An interrupted cache only after the complete pack succeeds. Android streams each object to a
or corrupt update leaves the previous pack playable. `.part` file, supports HTTP range resume, verifies its size and SHA-256, then
renames it into the object cache. An interrupted or corrupt update leaves the
previous pack playable on either platform.
The starter pack is bundled into the APK. Downloaded packs live under Android's The Downloads screen uses the same lifecycle in browser and Android. A missing
app-private files directory, outside the packaged web bundle, and survive an pack offers **Download**, an installed pack with changed files offers
APK update. Players may remove optional packs through the content manager; save **Update**, and an installed optional pack offers **Remove**. Required packs are
data is stored separately and is never removed with content. marked as included and cannot be removed. Browser packs live in the app-managed
cache; Android packs live in app-private storage and survive APK updates. Save
data and roster identities are stored separately and are never removed with
content.
Character packs are presentation-only. When a selected race's pack is absent,
removed, disabled, or fails to load, the shared character renderer uses the
matching-gender basic Human model and default appearance. The saved race, class,
equipment, progression, and appearance choices remain intact and become visible
again after the pack is installed.
Generated spell effects and synthesized combat cues are the default. The
`core:combat-media` pack containing packaged original spell visuals and sounds
is never part of required bootstrap content. Original mode becomes available
only after an explicit download and falls back to generated effects if the pack
is later removed.
## Offline behavior ## Offline behavior
@@ -87,6 +103,8 @@ data is stored separately and is never removed with content.
- The last verified content map is read synchronously from local storage. - The last verified content map is read synchronously from local storage.
- A failed update check is silent outside the content manager. - A failed update check is silent outside the content manager.
- A failed or interrupted download never replaces active content. - A failed or interrupted download never replaces active content.
- Missing optional character packs fall back to the basic Human model.
- Generated spell effects remain available without original spell media.
- Local saves are authoritative during play and cloud synchronization retries - Local saves are authoritative during play and cloud synchronization retries
when connectivity returns. when connectivity returns.
- Content that has never been downloaded is clearly marked as unavailable - Content that has never been downloaded is clearly marked as unavailable
@@ -104,9 +122,10 @@ Keeping the runtime in the APK is what makes offline startup reliable.
/mnt/usbssds/apps/healer-man/backups Database backups /mnt/usbssds/apps/healer-man/backups Database backups
``` ```
The Node server exposes the content volume at `/content`. Manifests use The Node server exposes the compact production browser client at `/` and the
`no-cache`; immutable hashed objects use long-lived cache headers and support complete downloadable-content volume at `/content`. Manifests use `no-cache`;
byte ranges for resumed downloads. immutable hashed objects use long-lived cache headers and support byte ranges
for resumed downloads.
After pulling source on TrueNAS, publish content before restarting the app: After pulling source on TrueNAS, publish content before restarting the app:
@@ -121,15 +140,29 @@ discover a manifest whose files have not been published.
## Pack policy ## Pack policy
Packs are player-facing groups while files remain individually addressable. Packs are player-facing groups while files remain individually addressable.
This permits a fast initial pack install and small later patches. Current pack This permits a compact initial install and small later patches. Required pack
IDs are derived predictably: IDs include:
```text ```text
core:runtime
core:ui
core:equipment
core:character-basic
dungeon:wailing-caverns dungeon:wailing-caverns
```
`core:character-basic` owns the two basic Human rigs, the character manifest,
and only the default texture selections needed by the fallback. The remaining
human customizations and every non-Human character model/skin are optional.
Downloadable pack IDs include:
```text
dungeon:forsaken-abbey dungeon:forsaken-abbey
characters:wow335a-v1 characters:wow335a-v1
characters:ascension-v1 characters:ascension-v1
characters:runewaker-v1 characters:runewaker-v1
core:combat-media
manastorm:<stage-id> manastorm:<stage-id>
shared:creatures shared:creatures
``` ```
+24 -18
View File
@@ -24,8 +24,9 @@ container, rebuilding `dist`, or pulling source must not replace the database.
TrueNAS Gitea stores the source repository and APK release attachments; it is TrueNAS Gitea stores the source repository and APK release attachments; it is
not the game database. not the game database.
Large runtime assets are published separately under `/app/content`. Android Large runtime assets are published separately under `/app/content`. Production
clients download verified content objects from `/content` and retain them in browser and Android clients download verified content objects from `/content`;
browsers retain them in an app-managed cache and Android retains them in
app-private storage for offline play. See `CONTENT_UPDATES.md` for the manifest, app-private storage for offline play. See `CONTENT_UPDATES.md` for the manifest,
atomic activation, compatibility, and rollback contract. atomic activation, compatibility, and rollback contract.
@@ -55,9 +56,10 @@ Container/host port:
``` ```
The Gitea bare repository is not runnable. Clone it with Git; never copy it The Gitea bare repository is not runnable. Clone it with Git; never copy it
with `cp`. The checkout contains several gigabytes of required GLB assets, so with `cp`. The checkout contains several gigabytes of content-source GLB assets,
the initial clone and first build will take substantially longer than later so the initial clone and first content publication will take substantially
fast-forward updates. longer than later fast-forward updates. Production browser and Android bundles
stage only their compact required-content subset.
Do not turn the GLBs into Git LFS pointers without also changing this deployment Do not turn the GLBs into Git LFS pointers without also changing this deployment
procedure. Gitea keeps LFS objects outside the bare repository, so a bare-path procedure. Gitea keeps LFS objects outside the bare repository, so a bare-path
@@ -80,7 +82,8 @@ git branch --show-current
If the repository slug differs, change both the remote URL here and every If the repository slug differs, change both the remote URL here and every
`healer-man.git` bare-repository path below. Do not commit `.env` files, `healer-man.git` bare-repository path below. Do not commit `.env` files,
keystores, API tokens, `content`, `dist`, `dist-android`, or `.android-public`. keystores, API tokens, `content`, `dist`, `dist-android`, `.web-public`, or
`.android-public`.
On TrueNAS, confirm the bare repository path. If the first command fails, use On TrueNAS, confirm the bare repository path. If the first command fails, use
the search command: the search command:
@@ -154,8 +157,9 @@ account/cloud-save data; `/app/content` owns downloadable manifests and
immutable content objects. Never mount a volume at `/app/data`, because that immutable content objects. Never mount a volume at `/app/data`, because that
would hide the checkout's required `/app/data/loot/schema.sql`. The would hide the checkout's required `/app/data/loot/schema.sql`. The
startup command installs locked dependencies, initializes the schema, publishes startup command installs locked dependencies, initializes the schema, publishes
new or changed content objects, builds the full browser bundle, and starts the new or changed content objects, builds the compact required-content browser
combined static/API/content server. Existing content objects are reused. bundle, and starts the combined static/API/content server. Existing content
objects are reused.
After startup, test on the LAN: After startup, test on the LAN:
@@ -220,8 +224,8 @@ git rev-parse HEAD
Before `git commit`, read the staged file list printed by `git status --short`. Before `git commit`, read the staged file list printed by `git status --short`.
Do not commit a `.env` file, password, API token, keystore, APK, database, Do not commit a `.env` file, password, API token, keystore, APK, database,
`content/`, `dist/`, `dist-android/`, or `.android-public/` output. The final `content/`, `dist/`, `dist-android/`, `.web-public/`, or `.android-public/`
command prints the exact deployed commit ID. output. The final command prints the exact deployed commit ID.
Next, open the TrueNAS Apps UI and **stop** `healer-man`. Open a TrueNAS shell Next, open the TrueNAS Apps UI and **stop** `healer-man`. Open a TrueNAS shell
and run these exact commands: and run these exact commands:
@@ -247,11 +251,12 @@ sudo git -C /mnt/usbssds/apps/healer-man/app rev-parse HEAD
The TrueNAS commit ID must match the one printed on the development PC. Start The TrueNAS commit ID must match the one printed on the development PC. Start
`healer-man` in the TrueNAS Apps UI afterward. Container startup `healer-man` in the TrueNAS Apps UI afterward. Container startup
publishes immutable content objects and replaces `manifest.json` only after all publishes immutable content objects and replaces `manifest.json` only after all
objects exist, then rebuilds the browser client and starts the server. Android objects exist, then rebuilds the browser client and starts the server. Browser
devices discover the small new manifest and download only missing or changed and Android clients discover the small new manifest and download only missing
files. Asset-only updates do not require a new APK. A failed fast-forward pull or changed files for packs the player installed. Asset-only updates do not
deliberately leaves the running checkout unchanged; resolve diverged history on require a new APK. A failed fast-forward pull deliberately leaves the running
the development machine rather than forcing the TrueNAS checkout. checkout unchanged; resolve diverged history on the development machine rather
than forcing the TrueNAS checkout.
After the logs say the server is listening on port `4173`, run on TrueNAS: After the logs say the server is listening on port `4173`, run on TrueNAS:
@@ -286,9 +291,10 @@ copies; it does not replace dataset-level protection.
## APK releases and Obtainium ## APK releases and Obtainium
Keep APK binaries out of the Git branch. The Android build uses `dist-android`, Keep APK binaries out of the Git branch. The Android build uses `dist-android`,
which contains the game runtime, UI, equipment, and Wailing Caverns starter which contains the game runtime, UI, starter equipment, basic Human character,
content rather than the full multi-gigabyte browser catalog. Downloaded packs and Wailing Caverns starter content. Optional character/skin packs, original
survive APK updates. spell media, dungeons, and other large content remain outside the APK.
Downloaded packs survive APK updates.
Install JDK 21 and Android SDK Platform 36 on the development PC and set Install JDK 21 and Android SDK Platform 36 on the development PC and set
`JAVA_HOME` plus `ANDROID_SDK_ROOT`. The build wrapper also recognizes the `JAVA_HOME` plus `ANDROID_SDK_ROOT`. The build wrapper also recognizes the
+82
View File
@@ -1572,3 +1572,85 @@ Vite's existing large-chunk advisory remains informational.
106 environment assets passed their performance audit with no regressions, 106 environment assets passed their performance audit with no regressions,
the KTX2 audit found no fallback textures, and the release-candidate check the KTX2 audit found no fallback textures, and the release-candidate check
passed. The existing large-chunk advisory remains informational. passed. The existing large-chunk advisory remains informational.
# 2026-09-04 — optional content offload and basic Human fallback
- Built and served the production browser output from a clean localhost origin.
A fresh Undead female Priest preview rendered with the real bundled Human
female rig and default textures while retaining the Undead/Priest identity in
the roster. No optional character pack had been installed.
- Entered Wailing Caverns as healer from a separate clean browser origin. The
Undead player, Undead tank, and Goblin party member all rendered through the
matching basic Human fallbacks while their saved race, role, class, equipment,
and party labels remained intact. The dungeon stayed playable and emitted no
new error; only the existing third-party initialization deprecation warning
appeared.
- Exercised the browser Downloads lifecycle with a small local two-revision
content fixture. Missing character and original-media packs showed Download;
a verified install showed Remove; the changed revision showed Update and
Remove together; and applying Update returned the pack to offline-ready state.
- Reloaded after service-worker activation. The application and lazy JavaScript
chunks loaded normally, while the content worker continued to reserve managed
character, creature, equipment, game, and UI paths for bundled or verified
cached content. The initial broad `/assets/` interception found during this
playtest was corrected before the final run.
- In-dungeon Options showed Generated as the active default, kept Original
disabled until its optional spell-effects-and-sounds pack is downloaded, and
exposed a Download action for that pack.
- Evidence:
`playtest-artifacts/2026-09-04-optional-content/roster-undead-priest-basic-human-fallback.png`,
`dungeon-optional-models-basic-human-fallback.png`,
`browser-downloads-optional-packs.png`, and
`browser-downloads-update-and-remove.png`.
- Verification: all 43 avatar tests passed; the complete Vitest suite passed all
896 cases across 164 files; production browser and Android builds completed;
both binary inventories contained only the two Human rigs, starter equipment,
and Wailing Caverns content; and neither output contained optional character
packs, original combat media, or WAV files. KTX2 and environment-performance
audits remained green, and the release-candidate check passed. The existing
large-chunk advisory remains informational.
# 2026-09-05 — Power Word: Shield live character anchoring
- Installed the optional Original Spell Effects & Sounds pack through the
production Downloads screen, enabled it, and selected Original spell visuals.
- Entered Wailing Caverns as a Human Priest and cast Power Word: Shield on both
the party tank and the player while exercising party movement and combat. The
persistent original aura stayed at the rendered character root instead of
following the throttled gameplay-position snapshot, with no inherited camera
or character rotation.
- Reviewed live dungeon screenshots before and during the movement test. The
browser emitted no new errors; only the existing third-party initialization
deprecation warning remained.
- Verification: all 899 Vitest cases passed across 165 files, including three
new visual-anchor registry cases. The production build completed with 920
modules transformed, and asset audits remained green. The existing
large-chunk advisory remains informational.
# 2026-09-05 — modular RuneWaker boss mechanics
- Built a temporary local production content feed for Forsaken Abbey and the
first two Ystra Labyrinth boss rooms, then installed each pack through the
browser Downloads workflow with checksum verification.
- Loaded the source-backed Specter Guardian Creator encounter. Its mechanic
panel appeared below the minimap and buff/debuff strips with Guard Bomb,
Transport Magic, and Meteor Shower countdowns. A live Meteor Shower cast
showed its one-second cast warning over the boss.
- The first pass exposed that four countdown rows could crowd the boss's
pending 80%, 60%, 40%, and 20% phase triggers out of the panel. The timeline
now limits only countdown rows and always retains every pending health
threshold. The corrected production build displayed all eight rows without
covering the central playfield.
- Loaded Rune Guardian Leader in Ystra Labyrinth Boss Room 2. Its source-backed
Manastorms Duo Lv5 mechanic displayed a large purple circular floor warning
while the matching panel row counted down at 0.6 seconds. The area indicator,
cast timing, party damage, and effect/debuff presentation all advanced in the
live encounter.
- Browser logs contained no uncaught application errors. The existing
third-party initialization deprecation warning remained; WebGL context-lost
messages corresponded to intentional production rebuild reloads and opening
replacement test tabs.
- Verification: the full suite passed all 913 cases across 169 files, and the
corrected production build completed with 923 modules transformed. KTX2 and
environment-performance audits remained green, and the release-candidate
check passed.
+31 -6
View File
@@ -41,6 +41,28 @@ npm test
npm run build npm run build
``` ```
## Content downloads
Production browser and Android builds ship a compact required set: the game
runtime, interface, starter equipment, Wailing Caverns starter content, and a
basic male/female Human character model with its default appearance. Large
character and skin libraries, additional dungeons, Manastorm stages, and the
original spell-effects and sound pack are optional downloads.
Open **Downloads** from the main menu to download, update, or remove optional
packs on either platform. Browser downloads live in an app-managed verified
cache; Android downloads live in app-private storage. Removing a pack never
removes a roster or save. If a character's model/skin pack is unavailable, the
shared character renderer displays the matching-gender basic Human while
preserving that character's identity and progress for a later reinstall.
Generated spell effects and synthesized combat cues are the default and need
no original-media download. Players who want the packaged original visuals and
sounds can download that pack explicitly, select **Original** effects, and
remove the pack again to reclaim its storage. See
[`CONTENT_UPDATES.md`](CONTENT_UPDATES.md) for the content lifecycle and release
contract.
## Conquest of Azeroth classes ## Conquest of Azeroth classes
All 21 CoA classes use a checked-in live Vol'Jin catalog generated from All 21 CoA classes use a checked-in live Vol'Jin catalog generated from
@@ -181,12 +203,13 @@ npm run android:apk
The debug APK is written under `android/app/build/outputs/apk/debug/`. To build The debug APK is written under `android/app/build/outputs/apk/debug/`. To build
and install directly onto a connected device, use `npm run android:install`. and install directly onto a connected device, use `npm run android:install`.
Android uses a separate compact `dist-android` build. It bundles the client, Android uses a compact `dist-android` build with the same required-content
interface, equipment, and Wailing Caverns starter content while optional boundary as the production browser build. Optional dungeons, character/skin
dungeons, character packs, and Manastorm stages are downloaded into app-private packs, Manastorm stages, and original spell media are downloaded into
storage. Installed content and local saves remain playable without a server app-private storage. Installed content and local saves remain playable without
connection and survive APK updates. See `CONTENT_UPDATES.md` for the update a server connection and survive APK updates. See `CONTENT_UPDATES.md` for the
contract and `DEPLOYMENT.md` for TrueNAS publishing and signed Gitea releases. update contract and `DEPLOYMENT.md` for TrueNAS publishing and signed Gitea
releases.
## Runtime details ## Runtime details
@@ -206,6 +229,8 @@ contract and `DEPLOYMENT.md` for TrueNAS publishing and signed Gitea releases.
compressed in GPU memory instead of expanding to uncompressed RGBA textures. compressed in GPU memory instead of expanding to uncompressed RGBA textures.
- Missing production GLBs activate a small cave proxy; malformed assets activate - Missing production GLBs activate a small cave proxy; malformed assets activate
the recoverable scene-error screen. the recoverable scene-error screen.
- Missing optional character or skin packs render the built-in basic Human
model instead of hiding the player, party member, or roster preview.
- The controller action HUD displays only the active eight-slot layer. Its - The controller action HUD displays only the active eight-slot layer. Its
face-button diamond, shoulders, and D-pad keep the physical controller layout face-button diamond, shoulders, and D-pad keep the physical controller layout
visible without showing all 24 bindings over the playfield. visible without showing all 24 bindings over the playfield.
+8 -4
View File
@@ -71,9 +71,12 @@ file, use the shorter procedure at the end. Those updates can be published by
the server and downloaded by an already-installed APK without an Obtainium the server and downloaded by an already-installed APK without an Obtainium
release. release.
Single-player remains offline-capable. A fresh APK includes the runtime and Single-player remains offline-capable. A fresh APK includes the runtime,
starter content. Additional content that a player has downloaded is retained interface, starter equipment, Wailing Caverns, and the basic Human character
in Android app-private storage and can be used offline after download. fallback. Additional content that a player has downloaded is retained in
Android app-private storage and can be used offline after download. Generated
spell effects are available without downloading the optional original-media
pack.
## Fixed locations ## Fixed locations
@@ -287,7 +290,8 @@ git diff --cached --check
``` ```
Do not continue if the staged list contains a keystore, APK, `.env` file, Do not continue if the staged list contains a keystore, APK, `.env` file,
database, `content/`, `dist/`, `dist-android/`, or `.android-public/` output. database, `content/`, `dist/`, `dist-android/`, `.web-public/`, or
`.android-public/` output.
Unstage any accidental file with `git restore --staged -- <path>` without Unstage any accidental file with `git restore --staged -- <path>` without
deleting the local file. deleting the local file.
+18 -1
View File
@@ -73,6 +73,23 @@ attaches native animated client models by display ID. It writes:
- `src/game/generated/dungeonCampaignCatalog.compressed.ts`, the bundled catalog - `src/game/generated/dungeonCampaignCatalog.compressed.ts`, the bundled catalog
- `src/game/generated/dungeonAvailability.json`, the generated availability list - `src/game/generated/dungeonAvailability.json`, the generated availability list
Boss specials are compiled as independent mechanic records. AzerothCore
SmartAI timers and health events are imported automatically; C++ encounter
spells are imported from the pinned script checkout. Add or override a boss in
`dungeon-pipeline/boss-mechanics.json` when an encounter needs hand-authored
targeting, summon behavior, timing, or telegraph geometry. Use `mode: "replace"`
to make that list authoritative or `mode: "append"` to layer entries ahead of
the imported mechanics, then run `npm run dungeon:catalog`. The generated
`combat.mechanicIds` list is the stable loadout boundary intended for later
encounter randomization.
The same catalog also stores Runewaker kits under
`rw-<dungeon>-<entity-id>` keys. Run `npm run runewaker:bosses:refresh` to
rebuild those records from preserved RuneWaker spell/Lua evidence, regenerate
the population packages, and run `npm run runewaker:audit:gameplay`. A record
whose preserved source has no recoverable combat ability is explicitly marked
`source.kind: "healer-man-source-gap"` rather than being claimed as authentic.
Stock AzerothCore population is used for the standard dungeons. The three Stock AzerothCore population is used for the standard dungeons. The three
Ascension-specific instances—Tinkertech Showdown, Frozen Reach, and Forgotten Ascension-specific instances—Tinkertech Showdown, Frozen Reach, and Forgotten
Mine—keep their client encounter identities and use an explicit procedural Mine—keep their client encounter identities and use an explicit procedural
@@ -229,4 +246,4 @@ Kresh is intentionally recorded as a source-faithful melee-only boss: entry
3653 has no spell or SmartAI row in the pinned AzerothCore snapshot. Trial of 3653 has no spell or SmartAI row in the pinned AzerothCore snapshot. Trial of
the Champion is likewise documented as a boss-arena population rather than a the Champion is likewise documented as a boss-arena population rather than a
trash route. These exceptions are asserted by exact dungeon/entity IDs; any trash route. These exceptions are asserted by exact dungeon/entity IDs; any
new basic-only boss or mob-less dungeon fails the batch. new basic-only boss or mob-less dungeon fails the batch.
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.13", "version": "0.1.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.13", "version": "0.1.14",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+4 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.13", "version": "0.1.14",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
@@ -10,6 +10,7 @@
"db:backup": "node scripts/backup-game-database.mjs", "db:backup": "node scripts/backup-game-database.mjs",
"server:test": "node --test server/*.test.mjs", "server:test": "node --test server/*.test.mjs",
"content:publish": "node scripts/content/publish-content.mjs", "content:publish": "node scripts/content/publish-content.mjs",
"web:prepare": "node scripts/content/prepare-web-bootstrap.mjs",
"android:prepare": "node scripts/content/prepare-android-bootstrap.mjs", "android:prepare": "node scripts/content/prepare-android-bootstrap.mjs",
"content:test": "node --test scripts/content/*.test.mjs", "content:test": "node --test scripts/content/*.test.mjs",
"effects:generate": "node scripts/combat-effects/generate-manifest.mjs --package-audio && node scripts/combat-effects/package-priest-original.mjs", "effects:generate": "node scripts/combat-effects/generate-manifest.mjs --package-audio && node scripts/combat-effects/package-priest-original.mjs",
@@ -73,6 +74,7 @@
"runewaker:register:all": "node scripts/runewaker-pipeline/cli.mjs register-all", "runewaker:register:all": "node scripts/runewaker-pipeline/cli.mjs register-all",
"runewaker:refresh:all": "node scripts/runewaker-pipeline/cli.mjs refresh-all", "runewaker:refresh:all": "node scripts/runewaker-pipeline/cli.mjs refresh-all",
"runewaker:spells:refresh": "node scripts/runewaker-pipeline/export-spell-catalog.mjs", "runewaker:spells:refresh": "node scripts/runewaker-pipeline/export-spell-catalog.mjs",
"runewaker:bosses:refresh": "npm run runewaker:spells:refresh && node scripts/runewaker-pipeline/import-boss-mechanics.mjs",
"runewaker:players:refresh": "node scripts/runewaker-pipeline/export-player-catalog.mjs && npm run runewaker:players:avatars", "runewaker:players:refresh": "node scripts/runewaker-pipeline/export-player-catalog.mjs && npm run runewaker:players:avatars",
"runewaker:players:avatars": "node scripts/runewaker-pipeline/build-population.mjs --recipe scripts/runewaker-pipeline/recipes/runewaker-player-avatars.json --actors-only && node scripts/runewaker-pipeline/finalize-player-avatar-manifest.mjs", "runewaker:players:avatars": "node scripts/runewaker-pipeline/build-population.mjs --recipe scripts/runewaker-pipeline/recipes/runewaker-player-avatars.json --actors-only && node scripts/runewaker-pipeline/finalize-player-avatar-manifest.mjs",
"runewaker:paperdolls:refresh": "node scripts/runewaker-pipeline/refresh-paperdoll-appearances.mjs", "runewaker:paperdolls:refresh": "node scripts/runewaker-pipeline/refresh-paperdoll-appearances.mjs",
@@ -96,7 +98,7 @@
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs", "assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
"balance:benchmark": "vite-node scripts/combat-benchmark/run.ts", "balance:benchmark": "vite-node scripts/combat-benchmark/run.ts",
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2", "balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
"prebuild": "npm run loot:generate && npm run assets:ktx2:audit && npm run assets:performance:audit", "prebuild": "npm run loot:generate && npm run web:prepare && npm run assets:ktx2:audit && npm run assets:performance:audit",
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build", "build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
"build:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android", "build:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android",
"android:sync": "npm run build:android:web && cap sync android", "android:sync": "npm run build:android:web && cap sync android",
+68
View File
@@ -0,0 +1,68 @@
const CONTENT_CACHE_NAME = "healer-man-content-v1";
const MANAGED_ASSET_PREFIXES = [
"/assets/characters/",
"/assets/creatures/",
"/assets/equipment/",
"/assets/game/",
"/assets/ui/",
];
const BUNDLED_ASSET_PREFIXES = [
"/assets/ui/",
"/assets/equipment/",
"/assets/characters/wow335a-v1/manifest.json",
"/assets/characters/wow335a-v1/human/",
"/assets/creatures/wailing-caverns/",
"/assets/game/dungeons/wailing-caverns/",
];
const BUNDLED_ASSET_PATHS = new Set([
"/assets/characters/wow335a-v1/textures/character/human/faciallowerhair00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/facialupperhair00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemalefacelower00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemalefaceupper00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedpelvisskin00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedtorsoskin00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemaleskin00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/hair00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/male/humanmalefacelower00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/male/humanmalefaceupper00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/male/humanmalenakedpelvisskin00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/scalplowerhair00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/scalpupperhair00_00.webp",
]);
self.addEventListener("install", (event) => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
const names = await caches.keys();
await Promise.all(names
.filter((name) => name.startsWith(`${CONTENT_CACHE_NAME}-staging-`))
.map((name) => caches.delete(name)));
await self.clients.claim();
})());
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin
|| !MANAGED_ASSET_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) return;
event.respondWith((async () => {
const cache = await caches.open(CONTENT_CACHE_NAME);
const cached = await cache.match(request, { ignoreSearch: true });
if (cached) return cached;
if (BUNDLED_ASSET_PATHS.has(url.pathname)
|| BUNDLED_ASSET_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) {
return fetch(request);
}
return new Response("Optional content is not installed.", {
status: 404,
headers: { "content-type": "text/plain; charset=utf-8" },
});
})());
});
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.13", "versionName": "0.1.14",
"versionCode": 1013 "versionCode": 1014
} }
@@ -1,6 +1,7 @@
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { import {
BASIC_HUMAN_TEXTURE_PATHS,
PROJECT_ROOT, PROJECT_ROOT,
createContentManifest, createContentManifest,
scanContentSources, scanContentSources,
@@ -12,8 +13,21 @@ if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirect
} }
const sources = [ const sources = [
{ directory: path.join(PROJECT_ROOT, "public", "basis"), logicalPrefix: "basis" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" }, { directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" }, { directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" },
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "manifest.json"),
logicalPrefix: "assets/characters/wow335a-v1/manifest.json",
},
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "human"),
logicalPrefix: "assets/characters/wow335a-v1/human",
},
...BASIC_HUMAN_TEXTURE_PATHS.map((logicalPath) => ({
directory: path.join(PROJECT_ROOT, "public", ...logicalPath.split("/")),
logicalPrefix: logicalPath,
})),
{ {
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"), directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
logicalPrefix: "assets/creatures/wailing-caverns", logicalPrefix: "assets/creatures/wailing-caverns",
+71
View File
@@ -0,0 +1,71 @@
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
BASIC_HUMAN_TEXTURE_PATHS,
PROJECT_ROOT,
createContentManifest,
scanContentSources,
} from "./publish-content.mjs";
const outputDirectory = path.join(PROJECT_ROOT, ".web-public");
if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirectory) !== ".web-public") {
throw new Error(`Refusing to replace unexpected browser staging directory: ${outputDirectory}`);
}
const sources = [
{ directory: path.join(PROJECT_ROOT, "public", "basis"), logicalPrefix: "basis" },
{ directory: path.join(PROJECT_ROOT, "public", "data"), logicalPrefix: "data" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" },
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "manifest.json"),
logicalPrefix: "assets/characters/wow335a-v1/manifest.json",
},
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "human"),
logicalPrefix: "assets/characters/wow335a-v1/human",
},
...BASIC_HUMAN_TEXTURE_PATHS.map((logicalPath) => ({
directory: path.join(PROJECT_ROOT, "public", ...logicalPath.split("/")),
logicalPrefix: logicalPath,
})),
{
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
logicalPrefix: "assets/creatures/wailing-caverns",
},
{
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
logicalPrefix: "assets/game/dungeons/wailing-caverns",
},
];
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
const files = await scanContentSources(sources);
for (const file of files) {
const destination = path.join(outputDirectory, ...file.logicalPath.split("/"));
await mkdir(path.dirname(destination), { recursive: true });
await copyFile(file.sourcePath, destination);
}
await copyFile(
path.join(PROJECT_ROOT, "public", "content-cache-sw.js"),
path.join(outputDirectory, "content-cache-sw.js"),
);
const generated = createContentManifest(files, { baseUrl: "/" });
const bootstrapManifest = {
...generated,
files: Object.fromEntries(Object.entries(generated.files).map(([logicalPath, entry]) => [
logicalPath,
{ ...entry, url: logicalPath },
])),
};
const manifestPath = path.join(outputDirectory, "content", "bootstrap-manifest.json");
await mkdir(path.dirname(manifestPath), { recursive: true });
await writeFile(manifestPath, `${JSON.stringify(bootstrapManifest, null, 2)}\n`, "utf8");
const bytes = files.reduce((total, file) => total + file.bytes, 0);
console.log(`Prepared browser bootstrap content: ${files.length} files (${bytes} bytes)`);
console.log(`Bootstrap version: ${bootstrapManifest.version}`);
+55 -7
View File
@@ -18,14 +18,34 @@ export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0";
export const BUNDLED_ANDROID_PACKS = Object.freeze([ export const BUNDLED_ANDROID_PACKS = Object.freeze([
"core:ui", "core:ui",
"core:equipment", "core:equipment",
"core:character-basic",
"core:runtime",
"dungeon:wailing-caverns", "dungeon:wailing-caverns",
]); ]);
const RUNTIME_EXTENSIONS = new Set([ const RUNTIME_EXTENSIONS = new Set([
".avif", ".glb", ".json", ".ktx2", ".mp3", ".ogg", ".png", ".avif", ".glb", ".js", ".json", ".ktx2", ".mp3", ".ogg", ".png",
".svg", ".wav", ".webm", ".webp", ".svg", ".wasm", ".wav", ".webm", ".webp",
]); ]);
export const BASIC_HUMAN_TEXTURE_PATHS = Object.freeze([
"assets/characters/wow335a-v1/textures/character/human/faciallowerhair00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/facialupperhair00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalefacelower00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalefaceupper00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedpelvisskin00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedtorsoskin00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/female/humanfemaleskin00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/hair00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/male/humanmalefacelower00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/male/humanmalefaceupper00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/male/humanmalenakedpelvisskin00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/scalplowerhair00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/scalpupperhair00_00.webp",
]);
const BASIC_HUMAN_TEXTURE_PATH_SET = new Set(BASIC_HUMAN_TEXTURE_PATHS);
export const DEFAULT_CONTENT_SOURCES = Object.freeze([ export const DEFAULT_CONTENT_SOURCES = Object.freeze([
{ {
directory: path.join(PROJECT_ROOT, "public", "assets"), directory: path.join(PROJECT_ROOT, "public", "assets"),
@@ -49,18 +69,33 @@ function packLabel(packId) {
const [category, id = category] = packId.split(":", 2); const [category, id = category] = packId.split(":", 2);
const name = id.split("-").map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`).join(" "); const name = id.split("-").map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`).join(" ");
if (category === "dungeon") return name; if (category === "dungeon") return name;
if (category === "characters") return `${name} Characters`; if (packId === "characters:wow335a-v1") return "Extra Wrath Character Models & Skins";
if (packId === "characters:ascension-v1") return "Ascension Character Models & Skins";
if (packId === "characters:runewaker-v1") return "RuneWaker Character Models & Skins";
if (category === "characters") return `${name} Character Models & Skins`;
if (category === "manastorm") return `${name} Manastorm`; if (category === "manastorm") return `${name} Manastorm`;
if (packId === "shared:creatures") return "Shared Creatures"; if (packId === "shared:creatures") return "Shared Creatures";
if (packId === "core:ui") return "Interface"; if (packId === "core:ui") return "Interface";
if (packId === "core:equipment") return "Starter Equipment"; if (packId === "core:equipment") return "Starter Equipment";
if (packId === "core:character-basic") return "Basic Human Character Model";
if (packId === "core:runtime") return "Game Runtime";
if (packId === "core:combat-media") return "Original Spell Effects & Sounds";
return name; return name;
} }
export function contentPackForPath(logicalPath) { export function contentPackForPath(logicalPath) {
const segments = normalizedRelativePath(logicalPath).split("/"); const normalizedPath = normalizedRelativePath(logicalPath);
const segments = normalizedPath.split("/");
if (segments[0] === "basis" || segments[0] === "data") return "core:runtime";
if (segments[0] !== "assets") return "core:misc"; if (segments[0] !== "assets") return "core:misc";
if (segments[1] === "characters" && segments[2]) return `characters:${segments[2]}`; if (segments[1] === "characters" && segments[2]) {
const basicWrathHuman = segments[2] === "wow335a-v1" && (
segments[3] === "manifest.json"
|| segments[3] === "human"
|| BASIC_HUMAN_TEXTURE_PATH_SET.has(normalizedPath)
);
return basicWrathHuman ? "core:character-basic" : `characters:${segments[2]}`;
}
if (segments[1] === "creatures" && segments[2]) { if (segments[1] === "creatures" && segments[2]) {
return segments[2] === "shared" ? "shared:creatures" : `dungeon:${segments[2]}`; return segments[2] === "shared" ? "shared:creatures" : `dungeon:${segments[2]}`;
} }
@@ -73,12 +108,17 @@ export function contentPackForPath(logicalPath) {
if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) { if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) {
return `manastorm:${segments[3]}`; return `manastorm:${segments[3]}`;
} }
if (segments[1] === "game" && segments[2] === "combat-effects") return "core:combat-media";
if (segments[1] === "ui") return "core:ui"; if (segments[1] === "ui") return "core:ui";
if (segments[1] === "equipment") return "core:equipment"; if (segments[1] === "equipment") return "core:equipment";
return "core:misc"; return "core:misc";
} }
async function walkFiles(directory) { async function walkFiles(directory) {
const rootStats = await stat(directory);
if (rootStats.isFile()) {
return RUNTIME_EXTENSIONS.has(path.extname(directory).toLowerCase()) ? [directory] : [];
}
const files = []; const files = [];
async function visit(current) { async function visit(current) {
const entries = await readdir(current, { withFileTypes: true }); const entries = await readdir(current, { withFileTypes: true });
@@ -111,7 +151,9 @@ export async function scanContentSources(sources = DEFAULT_CONTENT_SOURCES) {
const files = await walkFiles(sourceDirectory); const files = await walkFiles(sourceDirectory);
for (const sourcePath of files) { for (const sourcePath of files) {
const relative = normalizedRelativePath(path.relative(sourceDirectory, sourcePath)); const relative = normalizedRelativePath(path.relative(sourceDirectory, sourcePath));
const logicalPath = normalizedRelativePath(`${source.logicalPrefix}/${relative}`); const logicalPath = normalizedRelativePath(relative
? `${source.logicalPrefix}/${relative}`
: source.logicalPrefix);
const fileStats = await stat(sourcePath); const fileStats = await stat(sourcePath);
const sha256 = await sha256File(sourcePath); const sha256 = await sha256File(sourcePath);
const prior = discovered.get(logicalPath); const prior = discovered.get(logicalPath);
@@ -170,11 +212,17 @@ export function createContentManifest(files, options = {}) {
if (packs.has("shared:creatures")) { if (packs.has("shared:creatures")) {
for (const pack of packs.values()) { for (const pack of packs.values()) {
if (pack.id.startsWith("dungeon:") || pack.id.startsWith("manastorm:")) { if ((pack.id.startsWith("dungeon:") && pack.id !== "dungeon:wailing-caverns")
|| pack.id.startsWith("manastorm:")) {
pack.dependencies = ["shared:creatures"]; pack.dependencies = ["shared:creatures"];
} }
} }
} }
if (packs.has("core:character-basic")) {
for (const pack of packs.values()) {
if (pack.id.startsWith("characters:")) pack.dependencies = ["core:character-basic"];
}
}
return { return {
schemaVersion: CONTENT_SCHEMA_VERSION, schemaVersion: CONTENT_SCHEMA_VERSION,
+13 -1
View File
@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import test from "node:test"; import test from "node:test";
import { import {
BUNDLED_ANDROID_PACKS,
contentPackForPath, contentPackForPath,
createContentManifest, createContentManifest,
publishContent, publishContent,
@@ -13,8 +14,19 @@ import {
test("assigns stable player-facing packs from logical asset paths", () => { test("assigns stable player-facing packs from logical asset paths", () => {
assert.equal(contentPackForPath("assets/game/dungeons/wailing-caverns/visual.glb"), "dungeon:wailing-caverns"); assert.equal(contentPackForPath("assets/game/dungeons/wailing-caverns/visual.glb"), "dungeon:wailing-caverns");
assert.equal(contentPackForPath("assets/creatures/wailing-caverns/mutanus.glb"), "dungeon:wailing-caverns"); assert.equal(contentPackForPath("assets/creatures/wailing-caverns/mutanus.glb"), "dungeon:wailing-caverns");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/manifest.json"), "characters:wow335a-v1"); assert.equal(contentPackForPath("assets/characters/wow335a-v1/manifest.json"), "core:character-basic");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/human/male/rig.glb"), "core:character-basic");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp"), "core:character-basic");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_01.webp"), "characters:wow335a-v1");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/orc/male/rig.glb"), "characters:wow335a-v1");
assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1"); assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1");
assert.equal(contentPackForPath("assets/game/combat-effects/audio/smite.wav"), "core:combat-media");
assert.equal(
contentPackForPath("assets/game/combat-effects/audio/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"),
"core:combat-media",
);
assert.equal(BUNDLED_ANDROID_PACKS.includes("core:combat-media"), false);
assert.equal(contentPackForPath("basis/basis_transcoder.wasm"), "core:runtime");
}); });
test("publishes objects before a deterministic latest manifest", async () => { test("publishes objects before a deterministic latest manifest", async () => {
@@ -47,6 +47,14 @@ const azerothCoreReferenceFile = path.join(
"generated", "generated",
"azerothCoreReference.json", "azerothCoreReference.json",
); );
const azerothCoreWorldAiFile = path.join(
projectRoot,
"src",
"game",
"generated",
"azerothCoreWorldAi.json",
);
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
const encounterFile = path.join(projectRoot, "src", "game", "generated", "manastormRuntimeCatalog.json"); const encounterFile = path.join(projectRoot, "src", "game", "generated", "manastormRuntimeCatalog.json");
const sourceCatalogFile = path.join(projectRoot, "src", "game", "generated", "manastormCatalog.json"); const sourceCatalogFile = path.join(projectRoot, "src", "game", "generated", "manastormCatalog.json");
const epochCatalogFile = path.join( const epochCatalogFile = path.join(
@@ -533,8 +541,194 @@ function fallbackSpecial(id, boss) {
}; };
} }
function combatFor(entity, boss, spells, scriptCatalog = undefined) { function validateBossMechanicsCatalog(catalog) {
if (catalog.schemaVersion !== 1) {
throw new Error(`Unsupported boss mechanic schema ${catalog.schemaVersion}.`);
}
const deliveries = new Set(["melee", "projectile", "area"]);
const targets = new Set(["primary", "random-party", "nearby-party", "self", "lowest-health-friendly"]);
const schools = new Set(["physical", "arcane", "fire", "frost", "nature", "shadow", "holy"]);
const animations = new Set(["attack", "cast"]);
for (const [entry, boss] of Object.entries(catalog.bosses ?? {})) {
if (!/^\d+$/.test(entry) && !/^rw-[a-z0-9-]+-\d+$/.test(entry)) {
throw new Error(`Boss mechanic key ${entry} is not a creature entry or RuneWaker entity id.`);
}
if (!["append", "replace"].includes(boss.mode)) {
throw new Error(`${entry}: mechanic mode must be append or replace.`);
}
if (!Array.isArray(boss.mechanics)) throw new Error(`${entry}: mechanics must be an array.`);
const ids = new Set();
for (const mechanic of boss.mechanics) {
if (!mechanic.id || ids.has(mechanic.id)) {
throw new Error(`${entry}: mechanic ids must be non-empty and unique (${mechanic.id}).`);
}
ids.add(mechanic.id);
if (!mechanic.name || !deliveries.has(mechanic.delivery) || !targets.has(mechanic.target)
|| !schools.has(mechanic.school) || !animations.has(mechanic.animation)
|| !Number.isFinite(mechanic.cooldownMs) || mechanic.cooldownMs <= 0
|| !Number.isFinite(mechanic.range) || mechanic.range < 0) {
throw new Error(`${entry}:${mechanic.id}: mechanic fields are incomplete or invalid.`);
}
if (mechanic.telegraph && !["circle", "cone", "line"].includes(mechanic.telegraph.shape)) {
throw new Error(`${entry}:${mechanic.id}: unsupported telegraph shape.`);
}
}
}
}
function smartAiEventProperties(row) {
if (row.event_type === 0) {
const initial = [Number(row.event_param1) || 0, Number(row.event_param2) || Number(row.event_param1) || 0];
const repeat = [Number(row.event_param3) || 1_000, Number(row.event_param4) || Number(row.event_param3) || 1_000];
return {
initialCooldownMs: initial,
repeatCooldownMs: repeat,
cooldownMs: Math.max(1_000, Math.round((repeat[0] + repeat[1]) / 2)),
};
}
if (row.event_type === 2) {
return {
initialCooldownMs: [0, 0],
repeatCooldownMs: [1_000, 1_000],
maximumCasts: 1,
cooldownMs: 1_000,
condition: {
kind: "self-health-percent",
minimum: Math.max(0, Number(row.event_param1) || 0),
maximum: Math.min(100, Number(row.event_param2) || 100),
},
};
}
if (row.event_type === 4) {
return {
initialCooldownMs: [0, 0],
repeatCooldownMs: [1_000, 1_000],
maximumCasts: 1,
cooldownMs: 1_000,
};
}
if (row.event_type === 14) {
return {
initialCooldownMs: [0, 0],
repeatCooldownMs: [1_000, 1_000],
cooldownMs: 1_000,
condition: {
kind: "friendly-missing-health",
amount: Math.max(1, Number(row.event_param1) || 1),
range: Math.max(1, Number(row.event_param2) || 30),
},
};
}
return null;
}
function smartAiMechanicsFor(entity, boss, spells, worldAi) {
if (!boss) return [];
const entry = Number(entity.entry);
const rows = worldAi?.smartScriptsByEntry?.[String(entry)] ?? [];
const rowsById = new Map(rows.map((row) => [Number(row.id), row]));
const mechanics = [];
for (const trigger of rows.filter((row) => Number(row.event_type) !== 61)) {
const event = smartAiEventProperties(trigger);
if (!event) continue;
const chain = [];
let row = trigger;
const visited = new Set();
while (row && !visited.has(Number(row.id))) {
visited.add(Number(row.id));
chain.push(row);
row = Number(row.link) ? rowsById.get(Number(row.link)) : undefined;
}
for (const action of chain) {
const actionType = Number(action.action_type);
const source = {
entry,
sourceType: Number(action.source_type) || 0,
rowId: Number(action.id) || 0,
eventType: Number(trigger.event_type) || 0,
actionType,
targetType: Number(action.target_type) || 0,
comment: String(action.comment ?? trigger.comment ?? ""),
};
if (actionType === 11) {
const spellId = Number(action.action_param1);
const spell = spells.get(spellId);
if (!spell) continue;
const hash = stableHash(`${entry}:${spellId}:${action.id}`);
const attack = spellAttack(spell, hash, true);
mechanics.push({
...attack,
...event,
id: `smart-${entry}-${action.id}-${slugPart(spell.name)}`,
target: [5, 6].includes(Number(action.target_type))
? "random-party"
: attack.target,
source,
});
} else if (actionType === 12) {
const creatureEntry = Number(action.action_param1);
if (!creatureEntry) continue;
mechanics.push({
id: `smart-${entry}-${action.id}-summon-${creatureEntry}`,
name: String(action.comment ?? "Summon Reinforcements")
.replace(/^.*? - (?:In Combat|Health Between [^-]+) - /, ""),
delivery: "area",
target: "self",
school: "physical",
animation: "cast",
castTimeMs: 1_200,
interruptible: false,
range: 0,
damageMultiplier: 0,
effect: { kind: "summon", creatureEntry, count: 1 },
...event,
source,
});
} else if (actionType === 39) {
const radius = Math.max(1, Number(action.action_param1) || 30);
mechanics.push({
id: `smart-${entry}-${action.id}-call-for-help`,
name: "Call for Help",
delivery: "area",
target: "self",
school: "physical",
animation: "cast",
castTimeMs: 800,
interruptible: false,
range: 0,
damageMultiplier: 0,
effect: { kind: "call-for-help", radius },
...event,
source,
});
}
}
}
return mechanics;
}
function uniqueMechanics(mechanics) {
const seenIds = new Set();
const seenSpellIds = new Set();
return mechanics.filter((mechanic) => {
if (seenIds.has(mechanic.id)) return false;
if (mechanic.spellId && seenSpellIds.has(mechanic.spellId)) return false;
seenIds.add(mechanic.id);
if (mechanic.spellId) seenSpellIds.add(mechanic.spellId);
return true;
});
}
function combatFor(
entity,
boss,
spells,
scriptCatalog = undefined,
worldAi = undefined,
bossMechanics = undefined,
) {
const hash = stableHash(`${entity.id}:${entity.name}`); const hash = stableHash(`${entity.id}:${entity.name}`);
const entry = Number(entity.entry);
const basicCooldown = Math.max(1_250, Math.min(3_200, Number(entity.baseAttackTimeMs) || 1_900)); const basicCooldown = Math.max(1_250, Math.min(3_200, Number(entity.baseAttackTimeMs) || 1_900));
const scriptDetails = (scriptCatalog?.scripts?.[entity.scriptName]?.spells ?? []) const scriptDetails = (scriptCatalog?.scripts?.[entity.scriptName]?.spells ?? [])
.filter((spell) => spell.combat); .filter((spell) => spell.combat);
@@ -565,20 +759,27 @@ function combatFor(entity, boss, spells, scriptCatalog = undefined) {
: null; : null;
}) })
.filter(Boolean) .filter(Boolean)
.slice(0, boss ? 4 : 2)
.map((spell) => spellAttack(spell, hash, boss)); .map((spell) => spellAttack(spell, hash, boss));
if (!spellAttacks.length && boss) spellAttacks.push(fallbackSpecial(entity.id, true)); const importedMechanics = smartAiMechanicsFor(entity, boss, spells, worldAi);
const authored = boss ? bossMechanics?.bosses?.[String(entry)] : undefined;
let mechanics = authored?.mode === "replace"
? authored.mechanics ?? []
: [...(authored?.mechanics ?? []), ...importedMechanics, ...spellAttacks];
mechanics = uniqueMechanics(mechanics);
if (!mechanics.length && boss && !authored) mechanics.push(fallbackSpecial(entity.id, true));
const levels = (entity.level ?? []).map(Number).filter(Number.isFinite); const levels = (entity.level ?? []).map(Number).filter(Number.isFinite);
const level = levels.length const level = levels.length
? Math.max(1, Math.round(levels.reduce((sum, value) => sum + value, 0) / levels.length)) ? Math.max(1, Math.round(levels.reduce((sum, value) => sum + value, 0) / levels.length))
: undefined; : undefined;
return { return {
...(entry ? { serverEntry: entry } : {}),
...(level ? { level } : {}), ...(level ? { level } : {}),
healthMultiplier: boss ? 1.15 + Math.min(1.5, Number(entity.rank || 0) * 0.18) : 1, healthMultiplier: boss ? 1.15 + Math.min(1.5, Number(entity.rank || 0) * 0.18) : 1,
damageMultiplier: boss ? 1.12 : 1, damageMultiplier: boss ? 1.12 : 1,
moveSpeed: Math.max(2.4, Math.min(5.2, 3.2 * (Number(entity.runSpeed) || 1))), moveSpeed: Math.max(2.4, Math.min(5.2, 3.2 * (Number(entity.runSpeed) || 1))),
leashRange: boss ? 70 : 52, leashRange: boss ? 70 : 52,
attacks: [basicAttack(basicCooldown), ...spellAttacks], ...(boss ? { mechanicIds: mechanics.map((mechanic) => mechanic.id) } : {}),
attacks: [basicAttack(basicCooldown), ...mechanics],
}; };
} }
@@ -596,6 +797,7 @@ function wailingServerCombatFor(entity, boss, reference) {
damageMultiplier: 1, damageMultiplier: 1,
moveSpeed: Math.max(2.4, Math.min(5.2, 3.2 * (Number(entity.runSpeed) || 1))), moveSpeed: Math.max(2.4, Math.min(5.2, 3.2 * (Number(entity.runSpeed) || 1))),
leashRange: boss ? 55 : 42, leashRange: boss ? 55 : 42,
...(boss ? { mechanicIds: scripted.map((attack) => attack.id) } : {}),
attacks: [{ attacks: [{
...basicAttack(basicCooldown), ...basicAttack(basicCooldown),
initialCooldownMs: [450, 450], initialCooldownMs: [450, 450],
@@ -664,7 +866,7 @@ function offsetPosition(position, index, radius = 3.2) {
]; ];
} }
function syntheticEntity(id, name, boss, level) { function syntheticEntity(id, name, boss, level, bossMechanics = undefined) {
const entity = { const entity = {
id, id,
entry: Number(id.match(/\d+/)?.[0] ?? stableHash(id)), entry: Number(id.match(/\d+/)?.[0] ?? stableHash(id)),
@@ -681,7 +883,7 @@ function syntheticEntity(id, name, boss, level) {
name, name,
...(boss ? { title: "Dungeon Boss", hasLoot: true } : { hasLoot: false }), ...(boss ? { title: "Dungeon Boss", hasLoot: true } : { hasLoot: false }),
combat: { combat: {
...combatFor(entity, boss, new Map()), ...combatFor(entity, boss, new Map(), undefined, undefined, bossMechanics),
...(!boss ? { attacks: [basicAttack(), fallbackSpecial(id, false)] } : {}), ...(!boss ? { attacks: [basicAttack(), fallbackSpecial(id, false)] } : {}),
}, },
visual: visualFor(id, name, boss, entity.rank), visual: visualFor(id, name, boss, entity.rank),
@@ -764,6 +966,9 @@ async function compile() {
const scriptSpellCatalog = await exists(scriptSpellCatalogFile) const scriptSpellCatalog = await exists(scriptSpellCatalogFile)
? await readJson(scriptSpellCatalogFile) ? await readJson(scriptSpellCatalogFile)
: { scripts: {} }; : { scripts: {} };
const azerothCoreWorldAi = await readJson(azerothCoreWorldAiFile);
const bossMechanics = await readJson(bossMechanicsFile);
validateBossMechanicsCatalog(bossMechanics);
const azerothCoreReference = await readJson(azerothCoreReferenceFile); const azerothCoreReference = await readJson(azerothCoreReferenceFile);
if (azerothCoreReference.wailingCaverns.unsupportedRequiredBossActions.length) { if (azerothCoreReference.wailingCaverns.unsupportedRequiredBossActions.length) {
throw new Error("Wailing Caverns has unsupported required-boss SmartAI actions."); throw new Error("Wailing Caverns has unsupported required-boss SmartAI actions.");
@@ -798,6 +1003,14 @@ async function compile() {
for (const detail of scriptSpellCatalog.scripts?.[entity.scriptName]?.spells ?? []) { for (const detail of scriptSpellCatalog.scripts?.[entity.scriptName]?.spells ?? []) {
if (detail.combat) wantedSpellIds.add(Number(detail.spellId)); if (detail.combat) wantedSpellIds.add(Number(detail.spellId));
} }
for (const row of azerothCoreWorldAi.smartScriptsByEntry?.[String(entity.entry)] ?? []) {
if (Number(row.action_type) === 11) wantedSpellIds.add(Number(row.action_param1));
}
}
}
for (const boss of Object.values(bossMechanics.bosses ?? {})) {
for (const mechanic of boss.mechanics ?? []) {
if (mechanic.spellId) wantedSpellIds.add(Number(mechanic.spellId));
} }
} }
const spellDbc = path.resolve(process.env.ASCENSION_SPELL_DBC ?? defaultSpellDbc); const spellDbc = path.resolve(process.env.ASCENSION_SPELL_DBC ?? defaultSpellDbc);
@@ -897,7 +1110,14 @@ async function compile() {
...(boss ? { title: "Dungeon Boss", hasLoot: true } : {}), ...(boss ? { title: "Dungeon Boss", hasLoot: true } : {}),
combat: recipe.slug === "wailing-caverns" combat: recipe.slug === "wailing-caverns"
? wailingServerCombatFor(source, boss, azerothCoreReference) ? wailingServerCombatFor(source, boss, azerothCoreReference)
: combatFor(source, boss, spells, scriptSpellCatalog), : combatFor(
source,
boss,
spells,
scriptSpellCatalog,
azerothCoreWorldAi,
bossMechanics,
),
visual: visualFor( visual: visualFor(
source.id, source.id,
source.name, source.name,
@@ -920,7 +1140,14 @@ async function compile() {
hasLoot: true, hasLoot: true,
combat: recipe.slug === "wailing-caverns" combat: recipe.slug === "wailing-caverns"
? wailingServerCombatFor(source, true, azerothCoreReference) ? wailingServerCombatFor(source, true, azerothCoreReference)
: combatFor(source, true, spells, scriptSpellCatalog), : combatFor(
source,
true,
spells,
scriptSpellCatalog,
azerothCoreWorldAi,
bossMechanics,
),
visual: visualFor( visual: visualFor(
entityId, entityId,
encounter.name, encounter.name,
@@ -949,6 +1176,7 @@ async function compile() {
encounter.name, encounter.name,
true, true,
recipe.difficultyVariants[0]?.levelRange?.[1] ?? 60, recipe.difficultyVariants[0]?.levelRange?.[1] ?? 60,
bossMechanics,
); );
} }
} }
@@ -19,14 +19,15 @@ const outputFile = path.join(
"generated", "generated",
"azerothCoreScriptSpells.json", "azerothCoreScriptSpells.json",
); );
const epochSourceFile = path.join( const campaignSourceFile = path.join(
projectRoot, projectRoot,
"dungeon-pipeline", "dungeon-pipeline",
"epoch-five-player-instances.json", "dungeon-campaign.json",
); );
const repository = "https://github.com/azerothcore/azerothcore-wotlk"; const repository = "https://github.com/azerothcore/azerothcore-wotlk";
const sourceRoots = [ const sourceRoots = [
"src/server/scripts/Kalimdor/CavernsOfTime", "src/server/scripts/EasternKingdoms",
"src/server/scripts/Kalimdor",
"src/server/scripts/Northrend", "src/server/scripts/Northrend",
"src/server/scripts/Outland", "src/server/scripts/Outland",
"src/server/scripts/World", "src/server/scripts/World",
@@ -45,7 +46,10 @@ const sha256 = (value) => createHash("sha256").update(value).digest("hex");
function runGit(argumentsList, cwd = projectRoot) { function runGit(argumentsList, cwd = projectRoot) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawn("git", argumentsList, { const safeArguments = cwd === sourceRoot
? ["-c", `safe.directory=${sourceRoot.replace(/\\/g, "/")}`, ...argumentsList]
: argumentsList;
const child = spawn("git", safeArguments, {
cwd, cwd,
stdio: "inherit", stdio: "inherit",
windowsHide: true, windowsHide: true,
@@ -61,7 +65,10 @@ function runGit(argumentsList, cwd = projectRoot) {
function readGit(argumentsList, cwd = projectRoot) { function readGit(argumentsList, cwd = projectRoot) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawn("git", argumentsList, { const safeArguments = cwd === sourceRoot
? ["-c", `safe.directory=${sourceRoot.replace(/\\/g, "/")}`, ...argumentsList]
: argumentsList;
const child = spawn("git", safeArguments, {
cwd, cwd,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
windowsHide: true, windowsHide: true,
@@ -203,8 +210,8 @@ if (checkedOutCommit !== DEFAULT_AZEROTHCORE_COMMIT) {
`AzerothCore script checkout is ${checkedOutCommit}; rerun with --fetch to restore ${DEFAULT_AZEROTHCORE_COMMIT}.`, `AzerothCore script checkout is ${checkedOutCommit}; rerun with --fetch to restore ${DEFAULT_AZEROTHCORE_COMMIT}.`,
); );
} }
const epochSource = await readJson(epochSourceFile); const campaignSource = await readJson(campaignSourceFile);
const wantedSlugs = new Set(epochSource.dungeons.map((dungeon) => dungeon.slug)); const wantedSlugs = new Set(campaignSource.dungeons.map((dungeon) => dungeon.slug));
const scriptNames = new Set(); const scriptNames = new Set();
for (const slug of wantedSlugs) { for (const slug of wantedSlugs) {
const draftFile = path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons", slug, "runtime-draft.json"); const draftFile = path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons", slug, "runtime-draft.json");
+20 -1
View File
@@ -105,8 +105,9 @@ NPC spell evidence is exported from the preserved read-only RuneWaker client
catalogs into a checked-in portable snapshot: catalogs into a checked-in portable snapshot:
~~~powershell ~~~powershell
npm run runewaker:spells:refresh npm run runewaker:bosses:refresh
npm run runewaker:build:all -- --phase population --force npm run runewaker:build:all -- --phase population --force
npm run runewaker:forsaken:population
npm run runewaker:audit:gameplay npm run runewaker:audit:gameplay
~~~ ~~~
@@ -123,11 +124,29 @@ evidence-only. The portable mapping preserves source spell identity, name,
damage family, school, range, and cooldown. The current 0.85 damage coefficient damage family, school, range, and cooldown. The current 0.85 damage coefficient
is HealerMan-authored and is not represented as the original RuneWaker formula. is HealerMan-authored and is not represented as the original RuneWaker formula.
`runewaker:bosses:refresh` first rebuilds that portable spell snapshot, then
statically follows each packaged boss's preserved AutoPlot/template callbacks
and writes modular `rw-<dungeon>-<entity-id>` kits to
`dungeon-pipeline/boss-mechanics.json`. Each special is an independent record
with stable ID, trigger, target, effects, and explicit circle, cone, or line
telegraph geometry. Both population builders copy those IDs into
`combat.mechanicIds`, so the same runtime loadout boundary is shared by
Runewaker and WoW encounters and can later support reviewed randomization.
Source-backed records identify the RuneWaker Lua and spell evidence they came
from. If the preserved scripts expose only encounter-progression hooks, the
importer creates a conservative playable mechanic marked
`source.kind: "healer-man-source-gap"`; it never labels that fallback as an
original RuneWaker ability. Edit or append a boss kit in the shared catalog,
then rerun the population build and gameplay audit to update an encounter.
The gameplay audit also: The gameplay audit also:
- verifies every executable attack.spellId against both its source template - verifies every executable attack.spellId against both its source template
assignment and the portable spell catalog; assignment and the portable spell catalog;
- rejects evidence-only spell IDs if they enter runtime combat data; - rejects evidence-only spell IDs if they enter runtime combat data;
- requires every boss objective to resolve a modular catalog kit and validates
its source label, telegraph, timed trigger, and health trigger;
- reuses the live party-navigation graph to inspect boss approach points - reuses the live party-navigation graph to inspect boss approach points
projected to the retained surface within 6m; projected to the retained surface within 6m;
- validates native animation families, with Rune Device's missing locomotion - validates native animation families, with Rune Device's missing locomotion
@@ -8,11 +8,16 @@
"runtimeSpawns": 4086, "runtimeSpawns": 4086,
"runtimeEntities": 436, "runtimeEntities": 436,
"bossObjectives": 61, "bossObjectives": 61,
"runtimeAttacks": 824, "runtimeAttacks": 1068,
"genericDefaultAttacks": 436, "genericDefaultAttacks": 436,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 388, "catalogBossMechanics": 279,
"sourceBackedBossMechanics": 275,
"sourceGapBossMechanics": 4,
"telegraphedBossMechanics": 111,
"healthTriggeredBossMechanics": 28,
"sourceBackedDamageFamilyAttacks": 483,
"sourceSpellTemplates": 293, "sourceSpellTemplates": 293,
"sourceSpellAssignments": 659, "sourceSpellAssignments": 659,
"scriptedTemplates": 357, "scriptedTemplates": 357,
@@ -27,9 +32,9 @@
"dungeons": 30, "dungeons": 30,
"uniqueNativeModels": 321, "uniqueNativeModels": 321,
"uniqueSourceSpellIds": 411, "uniqueSourceSpellIds": 411,
"portableSpellCatalogRecords": 411, "portableSpellCatalogRecords": 3199,
"portableDamageFamilyRecords": 227, "portableDamageFamilyRecords": 1342,
"portableEvidenceOnlyRecords": 184 "portableEvidenceOnlyRecords": 1857
}, },
"mechanicsCategories": { "mechanicsCategories": {
"death": 75, "death": 75,
@@ -53,7 +58,7 @@
"boss spawn membership" "boss spawn membership"
], ],
"genericRuntimeSystems": [ "genericRuntimeSystems": [
"unmapped attacks", "source-gap attacks",
"health/damage scaling", "health/damage scaling",
"movement/leash", "movement/leash",
"clustering/aggro", "clustering/aggro",
@@ -63,9 +68,9 @@
"procedural animation" "procedural animation"
], ],
"deferredMechanics": [ "deferredMechanics": [
"doors/gates", "complex doors/gates",
"summons/waves", "unmapped multi-actor waves",
"phases", "unmapped phases",
"patrols", "patrols",
"interactions", "interactions",
"encounter resets", "encounter resets",
@@ -216,7 +221,10 @@
}, },
"sourceMechanics": { "sourceMechanics": {
"status": "review-required", "status": "review-required",
"executableMechanicsGenerated": false, "executableBossMechanicsGenerated": true,
"catalogBossMechanics": 279,
"sourceBackedBossMechanics": 275,
"sourceGapBossMechanics": 4,
"scriptedTemplates": 357, "scriptedTemplates": 357,
"evidenceCounts": { "evidenceCounts": {
"death": 75, "death": 75,
@@ -239,7 +247,7 @@
"unknownEffectsExecute": false, "unknownEffectsExecute": false,
"coefficientsAreSourceExact": false "coefficientsAreSourceExact": false
}, },
"mappingPolicy": "Only positively classified direct-HP damage families execute; unknown/buff/heal/summon/script effects remain evidence-only." "mappingPolicy": "Only positively classified direct-HP damage families execute; named source auras, controls, summons, and health gates use conservative modular effects; unknown semantics remain evidence-only."
}, },
"resolvedRuntimeObservations": [ "resolvedRuntimeObservations": [
{ {
@@ -257,8 +265,7 @@
"reviewNotes": [ "reviewNotes": [
"demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.", "demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.",
"zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.", "zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.",
"436 executable attacks do not carry an audited source-spell mapping.", "4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.",
"Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.",
"Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.", "Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.",
"61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence." "61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence."
], ],
@@ -284,6 +291,11 @@
"genericDefaultAttacks": 16, "genericDefaultAttacks": 16,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 13, "sourceBackedDamageFamilyAttacks": 13,
"nativeBindings": 16, "nativeBindings": 16,
"proceduralBindings": 0, "proceduralBindings": 0,
@@ -333,10 +345,15 @@
] ]
} }
], ],
"runtimeAttacks": 14, "runtimeAttacks": 15,
"genericDefaultAttacks": 7, "genericDefaultAttacks": 7,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"catalogBossMechanics": 3,
"sourceBackedBossMechanics": 3,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 3,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 7, "sourceBackedDamageFamilyAttacks": 7,
"nativeBindings": 7, "nativeBindings": 7,
"proceduralBindings": 0, "proceduralBindings": 0,
@@ -400,11 +417,16 @@
] ]
} }
], ],
"runtimeAttacks": 9, "runtimeAttacks": 20,
"genericDefaultAttacks": 7, "genericDefaultAttacks": 7,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 2, "catalogBossMechanics": 11,
"sourceBackedBossMechanics": 11,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 6,
"healthTriggeredBossMechanics": 1,
"sourceBackedDamageFamilyAttacks": 9,
"nativeBindings": 7, "nativeBindings": 7,
"proceduralBindings": 0, "proceduralBindings": 0,
"proxyBindings": 0, "proxyBindings": 0,
@@ -453,11 +475,16 @@
] ]
} }
], ],
"runtimeAttacks": 28, "runtimeAttacks": 32,
"genericDefaultAttacks": 16, "genericDefaultAttacks": 16,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 12, "catalogBossMechanics": 4,
"sourceBackedBossMechanics": 4,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 1,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 14,
"nativeBindings": 16, "nativeBindings": 16,
"proceduralBindings": 0, "proceduralBindings": 0,
"proxyBindings": 0, "proxyBindings": 0,
@@ -491,6 +518,11 @@
"genericDefaultAttacks": 0, "genericDefaultAttacks": 0,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 0, "sourceBackedDamageFamilyAttacks": 0,
"nativeBindings": 0, "nativeBindings": 0,
"proceduralBindings": 0, "proceduralBindings": 0,
@@ -525,6 +557,11 @@
"genericDefaultAttacks": 17, "genericDefaultAttacks": 17,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 5, "sourceBackedDamageFamilyAttacks": 5,
"nativeBindings": 17, "nativeBindings": 17,
"proceduralBindings": 0, "proceduralBindings": 0,
@@ -700,11 +737,16 @@
] ]
} }
], ],
"runtimeAttacks": 15, "runtimeAttacks": 26,
"genericDefaultAttacks": 15, "genericDefaultAttacks": 15,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 0, "catalogBossMechanics": 11,
"sourceBackedBossMechanics": 7,
"sourceGapBossMechanics": 4,
"telegraphedBossMechanics": 6,
"healthTriggeredBossMechanics": 4,
"sourceBackedDamageFamilyAttacks": 2,
"nativeBindings": 15, "nativeBindings": 15,
"proceduralBindings": 0, "proceduralBindings": 0,
"proxyBindings": 0, "proxyBindings": 0,
@@ -738,6 +780,11 @@
"genericDefaultAttacks": 11, "genericDefaultAttacks": 11,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 10, "sourceBackedDamageFamilyAttacks": 10,
"nativeBindings": 11, "nativeBindings": 11,
"proceduralBindings": 0, "proceduralBindings": 0,
@@ -931,11 +978,16 @@
] ]
} }
], ],
"runtimeAttacks": 74, "runtimeAttacks": 122,
"genericDefaultAttacks": 40, "genericDefaultAttacks": 40,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 34, "catalogBossMechanics": 49,
"sourceBackedBossMechanics": 49,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 23,
"healthTriggeredBossMechanics": 2,
"sourceBackedDamageFamilyAttacks": 60,
"nativeBindings": 39, "nativeBindings": 39,
"proceduralBindings": 1, "proceduralBindings": 1,
"proxyBindings": 0, "proxyBindings": 0,
@@ -1052,11 +1104,16 @@
] ]
} }
], ],
"runtimeAttacks": 54, "runtimeAttacks": 75,
"genericDefaultAttacks": 26, "genericDefaultAttacks": 26,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 28, "catalogBossMechanics": 25,
"sourceBackedBossMechanics": 25,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 4,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 34,
"nativeBindings": 26, "nativeBindings": 26,
"proceduralBindings": 0, "proceduralBindings": 0,
"proxyBindings": 0, "proxyBindings": 0,
@@ -1173,11 +1230,16 @@
] ]
} }
], ],
"runtimeAttacks": 68, "runtimeAttacks": 80,
"genericDefaultAttacks": 32, "genericDefaultAttacks": 32,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 36, "catalogBossMechanics": 16,
"sourceBackedBossMechanics": 16,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 8,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 39,
"nativeBindings": 32, "nativeBindings": 32,
"proceduralBindings": 0, "proceduralBindings": 0,
"proxyBindings": 0, "proxyBindings": 0,
@@ -1211,6 +1273,11 @@
"genericDefaultAttacks": 1, "genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 4, "sourceBackedDamageFamilyAttacks": 4,
"nativeBindings": 1, "nativeBindings": 1,
"proceduralBindings": 0, "proceduralBindings": 0,
@@ -1316,11 +1383,16 @@
] ]
} }
], ],
"runtimeAttacks": 31, "runtimeAttacks": 48,
"genericDefaultAttacks": 14, "genericDefaultAttacks": 14,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 17, "catalogBossMechanics": 23,
"sourceBackedBossMechanics": 23,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 8,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 20,
"nativeBindings": 14, "nativeBindings": 14,
"proceduralBindings": 0, "proceduralBindings": 0,
"proxyBindings": 0, "proxyBindings": 0,
@@ -1369,11 +1441,16 @@
] ]
} }
], ],
"runtimeAttacks": 27, "runtimeAttacks": 33,
"genericDefaultAttacks": 20, "genericDefaultAttacks": 20,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 7, "catalogBossMechanics": 6,
"sourceBackedBossMechanics": 6,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 4,
"healthTriggeredBossMechanics": 1,
"sourceBackedDamageFamilyAttacks": 11,
"nativeBindings": 20, "nativeBindings": 20,
"proceduralBindings": 0, "proceduralBindings": 0,
"proxyBindings": 0, "proxyBindings": 0,
@@ -1422,11 +1499,16 @@
] ]
} }
], ],
"runtimeAttacks": 34, "runtimeAttacks": 40,
"genericDefaultAttacks": 16, "genericDefaultAttacks": 16,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 18, "catalogBossMechanics": 6,
"sourceBackedBossMechanics": 6,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 2,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 22,
"nativeBindings": 16, "nativeBindings": 16,
"proceduralBindings": 0, "proceduralBindings": 0,
"proxyBindings": 0, "proxyBindings": 0,
@@ -1460,6 +1542,11 @@
"genericDefaultAttacks": 14, "genericDefaultAttacks": 14,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 9, "sourceBackedDamageFamilyAttacks": 9,
"nativeBindings": 14, "nativeBindings": 14,
"proceduralBindings": 0, "proceduralBindings": 0,
@@ -1545,10 +1632,15 @@
] ]
} }
], ],
"runtimeAttacks": 38, "runtimeAttacks": 50,
"genericDefaultAttacks": 12, "genericDefaultAttacks": 12,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"catalogBossMechanics": 23,
"sourceBackedBossMechanics": 23,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 11,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 26, "sourceBackedDamageFamilyAttacks": 26,
"nativeBindings": 12, "nativeBindings": 12,
"proceduralBindings": 0, "proceduralBindings": 0,
@@ -1670,11 +1762,16 @@
] ]
} }
], ],
"runtimeAttacks": 18, "runtimeAttacks": 52,
"genericDefaultAttacks": 12, "genericDefaultAttacks": 12,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 6, "catalogBossMechanics": 34,
"sourceBackedBossMechanics": 34,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 9,
"healthTriggeredBossMechanics": 16,
"sourceBackedDamageFamilyAttacks": 13,
"nativeBindings": 12, "nativeBindings": 12,
"proceduralBindings": 0, "proceduralBindings": 0,
"proxyBindings": 0, "proxyBindings": 0,
@@ -1719,11 +1816,16 @@
] ]
} }
], ],
"runtimeAttacks": 61, "runtimeAttacks": 65,
"genericDefaultAttacks": 40, "genericDefaultAttacks": 40,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 21, "catalogBossMechanics": 4,
"sourceBackedBossMechanics": 4,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 3,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 25,
"nativeBindings": 40, "nativeBindings": 40,
"proceduralBindings": 0, "proceduralBindings": 0,
"proxyBindings": 0, "proxyBindings": 0,
@@ -1772,11 +1874,16 @@
] ]
} }
], ],
"runtimeAttacks": 1, "runtimeAttacks": 5,
"genericDefaultAttacks": 1, "genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 0, "catalogBossMechanics": 4,
"sourceBackedBossMechanics": 4,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 3,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 2,
"nativeBindings": 1, "nativeBindings": 1,
"proceduralBindings": 0, "proceduralBindings": 0,
"proxyBindings": 0, "proxyBindings": 0,
@@ -1861,11 +1968,16 @@
] ]
} }
], ],
"runtimeAttacks": 28, "runtimeAttacks": 31,
"genericDefaultAttacks": 16, "genericDefaultAttacks": 16,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 12, "catalogBossMechanics": 3,
"sourceBackedBossMechanics": 3,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 2,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 14,
"nativeBindings": 16, "nativeBindings": 16,
"proceduralBindings": 0, "proceduralBindings": 0,
"proxyBindings": 0, "proxyBindings": 0,
@@ -1924,11 +2036,16 @@
] ]
} }
], ],
"runtimeAttacks": 55, "runtimeAttacks": 64,
"genericDefaultAttacks": 26, "genericDefaultAttacks": 26,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 29, "catalogBossMechanics": 9,
"sourceBackedBossMechanics": 9,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 1,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 34,
"nativeBindings": 26, "nativeBindings": 26,
"proceduralBindings": 0, "proceduralBindings": 0,
"proxyBindings": 0, "proxyBindings": 0,
@@ -1962,6 +2079,11 @@
"genericDefaultAttacks": 25, "genericDefaultAttacks": 25,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 32, "sourceBackedDamageFamilyAttacks": 32,
"nativeBindings": 25, "nativeBindings": 25,
"proceduralBindings": 0, "proceduralBindings": 0,
@@ -2035,11 +2157,16 @@
] ]
} }
], ],
"runtimeAttacks": 55, "runtimeAttacks": 63,
"genericDefaultAttacks": 21, "genericDefaultAttacks": 21,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 34, "catalogBossMechanics": 12,
"sourceBackedBossMechanics": 12,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 3,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 36,
"nativeBindings": 21, "nativeBindings": 21,
"proceduralBindings": 0, "proceduralBindings": 0,
"proxyBindings": 0, "proxyBindings": 0,
@@ -2073,6 +2200,11 @@
"genericDefaultAttacks": 1, "genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 0, "sourceBackedDamageFamilyAttacks": 0,
"nativeBindings": 1, "nativeBindings": 1,
"proceduralBindings": 0, "proceduralBindings": 0,
@@ -2150,11 +2282,16 @@
] ]
} }
], ],
"runtimeAttacks": 5, "runtimeAttacks": 25,
"genericDefaultAttacks": 3, "genericDefaultAttacks": 3,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 2, "catalogBossMechanics": 22,
"sourceBackedBossMechanics": 22,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 8,
"healthTriggeredBossMechanics": 4,
"sourceBackedDamageFamilyAttacks": 11,
"nativeBindings": 3, "nativeBindings": 3,
"proceduralBindings": 0, "proceduralBindings": 0,
"proxyBindings": 0, "proxyBindings": 0,
@@ -2203,11 +2340,16 @@
] ]
} }
], ],
"runtimeAttacks": 2, "runtimeAttacks": 8,
"genericDefaultAttacks": 1, "genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 1, "catalogBossMechanics": 7,
"sourceBackedBossMechanics": 7,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 4,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 5,
"nativeBindings": 1, "nativeBindings": 1,
"proceduralBindings": 0, "proceduralBindings": 0,
"proxyBindings": 0, "proxyBindings": 0,
@@ -2256,11 +2398,16 @@
] ]
} }
], ],
"runtimeAttacks": 1, "runtimeAttacks": 8,
"genericDefaultAttacks": 1, "genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 0, "catalogBossMechanics": 7,
"sourceBackedBossMechanics": 7,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 2,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 3,
"nativeBindings": 1, "nativeBindings": 1,
"proceduralBindings": 0, "proceduralBindings": 0,
"proxyBindings": 0, "proxyBindings": 0,
@@ -2294,6 +2441,11 @@
"genericDefaultAttacks": 25, "genericDefaultAttacks": 25,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 23, "sourceBackedDamageFamilyAttacks": 23,
"nativeBindings": 25, "nativeBindings": 25,
"proceduralBindings": 0, "proceduralBindings": 0,
@@ -2328,6 +2480,11 @@
"genericDefaultAttacks": 0, "genericDefaultAttacks": 0,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 0, "sourceBackedDamageFamilyAttacks": 0,
"nativeBindings": 0, "nativeBindings": 0,
"proceduralBindings": 0, "proceduralBindings": 0,
@@ -9,11 +9,13 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
## Coverage ## Coverage
- 30 dungeons, 8199 source rows, 4086 static spawns, and 436 combat entities. - 30 dungeons, 8199 source rows, 4086 static spawns, and 436 combat entities.
- 61 boss objectives and 824 executable attacks. - 61 boss objectives and 1068 executable attacks.
- Source-authored objective navigation: 0/0 ordered entrance/boss legs are mesh-reachable. - Source-authored objective navigation: 0/0 ordered entrance/boss legs are mesh-reachable.
- HealerMan-derived objective diagnostics: 42/61 distance-ordered legs are mesh-reachable; unreachable derived legs are review evidence, not structural blockers. - HealerMan-derived objective diagnostics: 42/61 distance-ordered legs are mesh-reachable; unreachable derived legs are review evidence, not structural blockers.
- Animation entity bindings: 435 native, 1 static procedural, and 0 proxy. - Animation entity bindings: 435 native, 1 static procedural, and 0 proxy.
- 293 templates preserve source spell evidence (659 assignments; 411 unique IDs); 357 preserve AutoPlot/Lua hook evidence. - 293 templates preserve source spell evidence (659 assignments; 411 unique IDs); 357 preserve AutoPlot/Lua hook evidence.
- 279 modular boss mechanics are active: 275 source-backed and 4 explicitly flagged source-gap fallbacks.
- Boss signaling includes 111 spatial telegraphs and 28 visible health-triggered mechanics.
## Authentic source data currently used ## Authentic source data currently used
@@ -21,17 +23,17 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
directions, model paths, source hashes, and boss spawn membership. directions, model paths, source hashes, and boss spawn membership.
- Navmesh-projected placements derived from each packaged environment anchor. - Navmesh-projected placements derived from each packaged environment anchor.
- Original static meshes; original GR2 clips only where the manifest and GLB prove they exist. - Original static meshes; original GR2 clips only where the manifest and GLB prove they exist.
- Spell IDs, Lua hooks, AutoPlot names, and Lua signals are retained as evidence, not guessed. - Spell IDs, Lua hooks, AutoPlot names, and Lua signals feed the modular boss-mechanic catalog.
## HealerMan-generic or deferred ## HealerMan-generic or deferred
- 436 entities use generated Basic Attack/Corrosive Slam. - 436 entities use generated Basic Attack/Corrosive Slam.
- 0 attacks are builder exceptions without source mapping. - 0 attacks are builder exceptions outside the modular boss catalog.
- 388 executable attacks carry a conservative source-backed direct-damage family mapping. - 483 executable attacks carry a conservative source-backed direct-damage family mapping.
- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown; - Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;
HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact. HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.
- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic. - Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.
- Doors, phases, summons, patrols, resets, interactions, and source loot remain review-required. - Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.
- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move, - Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,
attack, cast, wound, and death families. attack, cast, wound, and death families.
@@ -42,33 +44,33 @@ Animation columns are native/procedural/proxy entity bindings.
| Dungeon | Source | Spawns | Entities | Bosses | Attacks | Spell templates | Scripted templates | N/P/X | Routes | Mechanics | | Dungeon | Source | Spawns | Entities | Bosses | Attacks | Spell templates | Scripted templates | N/P/X | Routes | Mechanics |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| aeternal-circle | 514 | 123 | 16 | 0 | 29 | 11 | 20 | 16/0/0 | 0/0 derived | review-required | | aeternal-circle | 514 | 123 | 16 | 0 | 29 | 11 | 20 | 16/0/0 | 0/0 derived | review-required |
| barren-caves | 63 | 48 | 7 | 1 | 14 | 7 | 3 | 7/0/0 | 1/1 derived | review-required | | barren-caves | 63 | 48 | 7 | 1 | 15 | 7 | 3 | 7/0/0 | 1/1 derived | review-required |
| cave-water-dragon | 144 | 97 | 7 | 2 | 9 | 4 | 5 | 7/0/0 | 1/2 derived | review-required | | cave-water-dragon | 144 | 97 | 7 | 2 | 20 | 4 | 5 | 7/0/0 | 1/2 derived | review-required |
| demon-stronghold | 439 | 183 | 16 | 1 | 28 | 7 | 14 | 16/0/0 | 1/1 derived | review-required | | demon-stronghold | 439 | 183 | 16 | 1 | 32 | 7 | 14 | 16/0/0 | 1/1 derived | review-required |
| demon-stronghold-125 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0/0/0 | 0/0 derived | review-required | | demon-stronghold-125 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0/0/0 | 0/0 derived | review-required |
| dungeon-of-dalanis | 229 | 67 | 17 | 0 | 22 | 9 | 13 | 17/0/0 | 0/0 derived | review-required | | dungeon-of-dalanis | 229 | 67 | 17 | 0 | 22 | 9 | 13 | 17/0/0 | 0/0 derived | review-required |
| forsaken-abbey | 251 | 150 | 15 | 8 | 15 | 0 | 11 | 15/0/0 | 8/8 derived | review-required | | forsaken-abbey | 251 | 150 | 15 | 8 | 26 | 0 | 11 | 15/0/0 | 8/8 derived | review-required |
| grafu-castle | 249 | 56 | 11 | 0 | 21 | 6 | 15 | 11/0/0 | 0/0 derived | review-required | | grafu-castle | 249 | 56 | 11 | 0 | 21 | 6 | 15 | 11/0/0 | 0/0 derived | review-required |
| hall-of-survivors | 363 | 162 | 40 | 9 | 74 | 23 | 25 | 39/1/0 | 9/9 derived | review-required | | hall-of-survivors | 363 | 162 | 40 | 9 | 122 | 23 | 25 | 39/1/0 | 9/9 derived | review-required |
| hoto | 401 | 307 | 26 | 5 | 54 | 19 | 15 | 26/0/0 | 2/5 derived | review-required | | hoto | 401 | 307 | 26 | 5 | 75 | 19 | 15 | 26/0/0 | 2/5 derived | review-required |
| ice-dwarf-kingdom | 651 | 599 | 32 | 5 | 68 | 25 | 11 | 32/0/0 | 3/5 derived | review-required | | ice-dwarf-kingdom | 651 | 599 | 32 | 5 | 80 | 25 | 11 | 32/0/0 | 3/5 derived | review-required |
| kal-turok-hive | 4 | 1 | 1 | 0 | 5 | 1 | 3 | 1/0/0 | 0/0 derived | review-required | | kal-turok-hive | 4 | 1 | 1 | 0 | 5 | 1 | 3 | 1/0/0 | 0/0 derived | review-required |
| kalins-shrine | 289 | 165 | 14 | 5 | 31 | 13 | 17 | 14/0/0 | 0/5 derived | review-required | | kalins-shrine | 289 | 165 | 14 | 5 | 48 | 13 | 17 | 14/0/0 | 0/5 derived | review-required |
| kawaks-tomb | 293 | 117 | 20 | 1 | 27 | 13 | 24 | 20/0/0 | 1/1 derived | review-required | | kawaks-tomb | 293 | 117 | 20 | 1 | 33 | 13 | 24 | 20/0/0 | 1/1 derived | review-required |
| kulech-bones-nest | 287 | 154 | 16 | 1 | 34 | 14 | 20 | 16/0/0 | 1/1 derived | review-required | | kulech-bones-nest | 287 | 154 | 16 | 1 | 40 | 14 | 20 | 16/0/0 | 1/1 derived | review-required |
| lair-demon-dragon | 532 | 103 | 14 | 0 | 23 | 9 | 10 | 14/0/0 | 0/0 derived | review-required | | lair-demon-dragon | 532 | 103 | 14 | 0 | 23 | 9 | 10 | 14/0/0 | 0/0 derived | review-required |
| mystic-altar | 398 | 251 | 12 | 3 | 38 | 11 | 15 | 12/0/0 | 3/3 derived | review-required | | mystic-altar | 398 | 251 | 12 | 3 | 50 | 11 | 15 | 12/0/0 | 3/3 derived | review-required |
| paspers-shrine | 255 | 224 | 12 | 5 | 18 | 7 | 6 | 12/0/0 | 5/5 derived | review-required | | paspers-shrine | 255 | 224 | 12 | 5 | 52 | 7 | 6 | 12/0/0 | 5/5 derived | review-required |
| raksha-temple | 487 | 123 | 40 | 1 | 61 | 29 | 29 | 40/0/0 | 0/1 derived | review-required | | raksha-temple | 487 | 123 | 40 | 1 | 65 | 29 | 29 | 40/0/0 | 0/1 derived | review-required |
| sand-vortex | 2 | 1 | 1 | 1 | 1 | 1 | 1 | 1/0/0 | 1/1 derived | review-required | | sand-vortex | 2 | 1 | 1 | 1 | 5 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
| sardo-castle | 247 | 71 | 16 | 3 | 28 | 7 | 24 | 16/0/0 | 3/3 derived | review-required | | sardo-castle | 247 | 71 | 16 | 3 | 31 | 7 | 24 | 16/0/0 | 3/3 derived | review-required |
| the-origin | 510 | 188 | 26 | 2 | 55 | 21 | 10 | 26/0/0 | 0/2 derived | review-required | | the-origin | 510 | 188 | 26 | 2 | 64 | 21 | 10 | 26/0/0 | 0/2 derived | review-required |
| tomb-seven-heroes | 439 | 240 | 25 | 0 | 57 | 18 | 24 | 25/0/0 | 0/0 derived | review-required | | tomb-seven-heroes | 439 | 240 | 25 | 0 | 57 | 18 | 24 | 25/0/0 | 0/0 derived | review-required |
| treasure-trove | 685 | 497 | 21 | 3 | 55 | 20 | 17 | 21/0/0 | 0/3 derived | review-required | | treasure-trove | 685 | 497 | 21 | 3 | 63 | 20 | 17 | 21/0/0 | 0/3 derived | review-required |
| warnorken-arena | 40 | 1 | 1 | 0 | 1 | 0 | 2 | 1/0/0 | 0/0 derived | review-required | | warnorken-arena | 40 | 1 | 1 | 0 | 1 | 0 | 2 | 1/0/0 | 0/0 derived | review-required |
| ystra-labyrinth-boss1 | 17 | 3 | 3 | 3 | 5 | 2 | 4 | 3/0/0 | 1/3 derived | review-required | | ystra-labyrinth-boss1 | 17 | 3 | 3 | 3 | 25 | 2 | 4 | 3/0/0 | 1/3 derived | review-required |
| ystra-labyrinth-boss2 | 3 | 1 | 1 | 1 | 2 | 1 | 1 | 1/0/0 | 1/1 derived | review-required | | ystra-labyrinth-boss2 | 3 | 1 | 1 | 1 | 8 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
| ystra-labyrinth-boss3 | 7 | 1 | 1 | 1 | 1 | 0 | 1 | 1/0/0 | 1/1 derived | review-required | | ystra-labyrinth-boss3 | 7 | 1 | 1 | 1 | 8 | 0 | 1 | 1/0/0 | 1/1 derived | review-required |
| zurhidon-stronghold | 400 | 153 | 25 | 0 | 48 | 15 | 17 | 25/0/0 | 0/0 derived | review-required | | zurhidon-stronghold | 400 | 153 | 25 | 0 | 48 | 15 | 17 | 25/0/0 | 0/0 derived | review-required |
| zurhidon-stronghold-124 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0/0/0 | 0/0 derived | review-required | | zurhidon-stronghold-124 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0/0/0 | 0/0 derived | review-required |
@@ -111,7 +113,7 @@ Derived-order navigation review: 19 unreachable heuristic legs across 8 dungeons
- ystra-labyrinth-boss1: Specter Guardian Creator -> Raichika the Destroyer (healerMan-distance-derived) - ystra-labyrinth-boss1: Specter Guardian Creator -> Raichika the Destroyer (healerMan-distance-derived)
- ystra-labyrinth-boss1: Raichika the Destroyer -> Rune Guardian Leader (healerMan-distance-derived) - ystra-labyrinth-boss1: Raichika the Destroyer -> Rune Guardian Leader (healerMan-distance-derived)
Unimplemented source mechanics: 357 templates preserve AutoPlot/Lua evidence, with executable generation deliberately disabled. Source-mechanics review: 357 templates preserve AutoPlot/Lua evidence; supported boss combat behavior is compiled into the modular catalog.
- death: 75 - death: 75
- door: 22 - door: 22
@@ -139,7 +141,6 @@ Unimplemented source mechanics: 357 templates preserve AutoPlot/Lua evidence, wi
- demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population. - demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.
- zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population. - zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.
- 436 executable attacks do not carry an audited source-spell mapping. - 4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.
- Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.
- Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed. - Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.
- 61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence. - 61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence.
@@ -194,6 +194,12 @@ function renderMarkdown(report) {
"- " + summary.sourceSpellTemplates + " templates preserve source spell evidence (" "- " + summary.sourceSpellTemplates + " templates preserve source spell evidence ("
+ summary.sourceSpellAssignments + " assignments; " + summary.uniqueSourceSpellIds + summary.sourceSpellAssignments + " assignments; " + summary.uniqueSourceSpellIds
+ " unique IDs); " + summary.scriptedTemplates + " preserve AutoPlot/Lua hook evidence.", + " unique IDs); " + summary.scriptedTemplates + " preserve AutoPlot/Lua hook evidence.",
"- " + summary.catalogBossMechanics + " modular boss mechanics are active: "
+ summary.sourceBackedBossMechanics + " source-backed and "
+ summary.sourceGapBossMechanics + " explicitly flagged source-gap fallbacks.",
"- Boss signaling includes " + summary.telegraphedBossMechanics
+ " spatial telegraphs and " + summary.healthTriggeredBossMechanics
+ " visible health-triggered mechanics.",
"", "",
"## Authentic source data currently used", "## Authentic source data currently used",
"", "",
@@ -201,18 +207,18 @@ function renderMarkdown(report) {
" directions, model paths, source hashes, and boss spawn membership.", " directions, model paths, source hashes, and boss spawn membership.",
"- Navmesh-projected placements derived from each packaged environment anchor.", "- Navmesh-projected placements derived from each packaged environment anchor.",
"- Original static meshes; original GR2 clips only where the manifest and GLB prove they exist.", "- Original static meshes; original GR2 clips only where the manifest and GLB prove they exist.",
"- Spell IDs, Lua hooks, AutoPlot names, and Lua signals are retained as evidence, not guessed.", "- Spell IDs, Lua hooks, AutoPlot names, and Lua signals feed the modular boss-mechanic catalog.",
"", "",
"## HealerMan-generic or deferred", "## HealerMan-generic or deferred",
"", "",
"- " + summary.genericDefaultAttacks + " entities use generated Basic Attack/Corrosive Slam.", "- " + summary.genericDefaultAttacks + " entities use generated Basic Attack/Corrosive Slam.",
"- " + summary.builderAuthoredAttacks + " attacks are builder exceptions without source mapping.", "- " + summary.builderAuthoredAttacks + " attacks are builder exceptions outside the modular boss catalog.",
"- " + summary.sourceBackedDamageFamilyAttacks "- " + summary.sourceBackedDamageFamilyAttacks
+ " executable attacks carry a conservative source-backed direct-damage family mapping.", + " executable attacks carry a conservative source-backed direct-damage family mapping.",
"- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;", "- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;",
" HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.", " HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.",
"- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.", "- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.",
"- Doors, phases, summons, patrols, resets, interactions, and source loot remain review-required.", "- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.",
"- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,", "- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,",
" attack, cast, wound, and death families.", " attack, cast, wound, and death families.",
"", "",
@@ -243,8 +249,8 @@ function renderMarkdown(report) {
...report.blockerGroups.navigation.derivedEntries.map((entry) => "- " + entry.dungeonId ...report.blockerGroups.navigation.derivedEntries.map((entry) => "- " + entry.dungeonId
+ ": " + entry.from + " -> " + entry.to + " (" + entry.orderKind + ")"), + ": " + entry.from + " -> " + entry.to + " (" + entry.orderKind + ")"),
"", "",
"Unimplemented source mechanics: " + report.blockerGroups.sourceMechanics.scriptedTemplates "Source-mechanics review: " + report.blockerGroups.sourceMechanics.scriptedTemplates
+ " templates preserve AutoPlot/Lua evidence, with executable generation deliberately disabled.", + " templates preserve AutoPlot/Lua evidence; supported boss combat behavior is compiled into the modular catalog.",
"", "",
...Object.entries(report.blockerGroups.sourceMechanics.evidenceCounts) ...Object.entries(report.blockerGroups.sourceMechanics.evidenceCounts)
.map(([kind, count]) => "- " + kind + ": " + count), .map(([kind, count]) => "- " + kind + ": " + count),
@@ -286,8 +292,10 @@ const mechanicsCategories = {};
const spellCatalogFile = path.join( const spellCatalogFile = path.join(
root, "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", root, "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
); );
const bossMechanicsFile = path.join(root, "dungeon-pipeline/boss-mechanics.json");
let spellCatalogSnapshot = null; let spellCatalogSnapshot = null;
let spellCatalog = new Map(); let spellCatalog = new Map();
let bossMechanics = { bosses: {} };
try { try {
spellCatalogSnapshot = await readJson(spellCatalogFile); spellCatalogSnapshot = await readJson(spellCatalogFile);
if (spellCatalogSnapshot.status !== "portable" if (spellCatalogSnapshot.status !== "portable"
@@ -305,6 +313,14 @@ try {
} catch (error) { } catch (error) {
issues.push("Could not load the portable RuneWaker spell catalog: " + error.message); issues.push("Could not load the portable RuneWaker spell catalog: " + error.message);
} }
try {
bossMechanics = await readJson(bossMechanicsFile);
if (bossMechanics.schemaVersion !== 1) {
issues.push("RuneWaker boss mechanics use an unsupported catalog schema.");
}
} catch (error) {
issues.push("Could not load the modular boss mechanic catalog: " + error.message);
}
const registrySource = await readFile( const registrySource = await readFile(
path.join(root, "src/game/generated/runewakerDungeonDefinitions.generated.ts"), path.join(root, "src/game/generated/runewakerDungeonDefinitions.generated.ts"),
@@ -411,6 +427,11 @@ for (const recipeName of recipeNames) {
genericDefaultAttacks: 0, genericDefaultAttacks: 0,
builderAuthoredAttacks: 0, builderAuthoredAttacks: 0,
recipeAuthoredAttacks: 0, recipeAuthoredAttacks: 0,
catalogBossMechanics: 0,
sourceBackedBossMechanics: 0,
sourceGapBossMechanics: 0,
telegraphedBossMechanics: 0,
healthTriggeredBossMechanics: 0,
sourceBackedDamageFamilyAttacks: 0, sourceBackedDamageFamilyAttacks: 0,
nativeBindings: 0, nativeBindings: 0,
proceduralBindings: 0, proceduralBindings: 0,
@@ -452,10 +473,28 @@ for (const recipeName of recipeNames) {
const attacks = entity.combat?.attacks ?? []; const attacks = entity.combat?.attacks ?? [];
if (!attacks.length) issue(issues, dungeonId, entityId + " has no combat family."); if (!attacks.length) issue(issues, dungeonId, entityId + " has no combat family.");
const authoredBoss = bossMechanics.bosses?.[entityId];
const authoredMechanicIds = new Set(
(authoredBoss?.mechanics ?? []).map((mechanic) => mechanic.id),
);
const authoredSourceSpellIds = new Set(
(authoredBoss?.source?.spellIds ?? []).map(Number),
);
if (entity.kind === "boss") {
if (!authoredBoss) issue(issues, dungeonId, entityId + " has no modular boss mechanic kit.");
const runtimeMechanicIds = entity.combat?.mechanicIds ?? [];
if (runtimeMechanicIds.length !== authoredMechanicIds.size
|| runtimeMechanicIds.some((id) => !authoredMechanicIds.has(id))) {
issue(issues, dungeonId, entityId + " mechanic id registry drifted from the modular catalog.");
}
}
const attackIds = new Set(); const attackIds = new Set();
const executableSourceSpellIds = new Set(); const executableSourceSpellIds = new Set();
const candidateSpellIds = new Set( const candidateSpellIds = new Set(
(candidateById.get(templateId)?.spells ?? []).map((spell) => Number(spell.id)), [
...(candidateById.get(templateId)?.spells ?? []).map((spell) => Number(spell.id)),
...authoredSourceSpellIds,
],
); );
for (const attack of attacks) { for (const attack of attacks) {
counts.runtimeAttacks += 1; counts.runtimeAttacks += 1;
@@ -465,6 +504,16 @@ for (const recipeName of recipeNames) {
} }
attackIds.add(attack.id); attackIds.add(attack.id);
const recipeAttack = template?.attacks?.find((entry) => entry.id === attack.id); const recipeAttack = template?.attacks?.find((entry) => entry.id === attack.id);
const catalogAuthored = authoredMechanicIds.has(attack.id);
if (catalogAuthored) {
counts.catalogBossMechanics += 1;
if (authoredBoss.source?.kind === "runewaker") counts.sourceBackedBossMechanics += 1;
else counts.sourceGapBossMechanics += 1;
if (attack.telegraph) counts.telegraphedBossMechanics += 1;
if (attack.condition?.kind === "self-health-percent") {
counts.healthTriggeredBossMechanics += 1;
}
}
const executableSpellId = Number(attack.spellId ?? attack.source?.spellId ?? 0); const executableSpellId = Number(attack.spellId ?? attack.source?.spellId ?? 0);
if (executableSpellId > 0) { if (executableSpellId > 0) {
executableSourceSpellIds.add(executableSpellId); executableSourceSpellIds.add(executableSpellId);
@@ -482,8 +531,7 @@ for (const recipeName of recipeNames) {
} else { } else {
counts.sourceBackedDamageFamilyAttacks += 1; counts.sourceBackedDamageFamilyAttacks += 1;
if (attack.spellId && [ if (attack.spellId && [
"name", "delivery", "target", "school", "animation", "range", "name", "school", "animation", "cooldownMs", "damageMultiplier",
"cooldownMs", "damageMultiplier",
].some((field) => attack[field] !== ( ].some((field) => attack[field] !== (
field === "name" ? catalogSpell.name : catalogSpell.runtime[field] field === "name" ? catalogSpell.name : catalogSpell.runtime[field]
))) { ))) {
@@ -499,9 +547,9 @@ for (const recipeName of recipeNames) {
issue(issues, dungeonId, entityId + " recipe attack cites unassigned source spell " issue(issues, dungeonId, entityId + " recipe attack cites unassigned source spell "
+ sourceSpellId + "."); + sourceSpellId + ".");
} }
} else if (attack.id.endsWith("-basic-attack")) { } else if (attack.id === "basic-attack" || attack.id.endsWith("-basic-attack")) {
counts.genericDefaultAttacks += 1; counts.genericDefaultAttacks += 1;
} else if (!attack.spellId) { } else if (!catalogAuthored && !attack.spellId) {
counts.builderAuthoredAttacks += 1; counts.builderAuthoredAttacks += 1;
} }
} }
@@ -683,6 +731,8 @@ for (const [url, users] of nativeModelUsers) {
const summaryKeys = [ const summaryKeys = [
"sourceRows", "runtimeSpawns", "runtimeEntities", "bossObjectives", "runtimeAttacks", "sourceRows", "runtimeSpawns", "runtimeEntities", "bossObjectives", "runtimeAttacks",
"genericDefaultAttacks", "builderAuthoredAttacks", "recipeAuthoredAttacks", "genericDefaultAttacks", "builderAuthoredAttacks", "recipeAuthoredAttacks",
"catalogBossMechanics", "sourceBackedBossMechanics", "sourceGapBossMechanics",
"telegraphedBossMechanics", "healthTriggeredBossMechanics",
"sourceBackedDamageFamilyAttacks", "sourceSpellTemplates", "sourceSpellAssignments", "sourceBackedDamageFamilyAttacks", "sourceSpellTemplates", "sourceSpellAssignments",
"scriptedTemplates", "nativeBindings", "proceduralBindings", "proxyBindings", "scriptedTemplates", "nativeBindings", "proceduralBindings", "proxyBindings",
"objectiveRoutes", "blockedObjectiveRoutes", "sourceOrderedObjectiveRoutes", "objectiveRoutes", "blockedObjectiveRoutes", "sourceOrderedObjectiveRoutes",
@@ -697,13 +747,8 @@ summary.uniqueSourceSpellIds = sourceSpellIds.size;
summary.portableSpellCatalogRecords = spellCatalog.size; summary.portableSpellCatalogRecords = spellCatalog.size;
summary.portableDamageFamilyRecords = spellCatalogSnapshot?.damageFamilyCount ?? 0; summary.portableDamageFamilyRecords = spellCatalogSnapshot?.damageFamilyCount ?? 0;
summary.portableEvidenceOnlyRecords = spellCatalogSnapshot?.evidenceOnlyCount ?? 0; summary.portableEvidenceOnlyRecords = spellCatalogSnapshot?.evidenceOnlyCount ?? 0;
if (summary.sourceBackedDamageFamilyAttacks < summary.runtimeAttacks) { reviewNotes.push(summary.sourceGapBossMechanics
reviewNotes.push((summary.runtimeAttacks - summary.sourceBackedDamageFamilyAttacks) + " boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.");
+ " executable attacks do not carry an audited source-spell mapping.");
}
if (dungeons.some((dungeon) => dungeon.bossObjectives && !dungeon.sourceBackedDamageFamilyAttacks)) {
reviewNotes.push("Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.");
}
reviewNotes.push("Source-backed spell coefficients are HealerMan-generic; the original RuneWaker " reviewNotes.push("Source-backed spell coefficients are HealerMan-generic; the original RuneWaker "
+ "formula/stat scaling has not been reconstructed."); + "formula/stat scaling has not been reconstructed.");
const navigationBlockerEntries = dungeons.flatMap((dungeon) => ( const navigationBlockerEntries = dungeons.flatMap((dungeon) => (
@@ -751,11 +796,11 @@ const report = {
"source positions/directions", "model paths", "source hashes", "boss spawn membership", "source positions/directions", "model paths", "source hashes", "boss spawn membership",
], ],
genericRuntimeSystems: [ genericRuntimeSystems: [
"unmapped attacks", "health/damage scaling", "movement/leash", "clustering/aggro", "source-gap attacks", "health/damage scaling", "movement/leash", "clustering/aggro",
"combat/death state", "loot policy", "objective ordering", "procedural animation", "combat/death state", "loot policy", "objective ordering", "procedural animation",
], ],
deferredMechanics: [ deferredMechanics: [
"doors/gates", "summons/waves", "phases", "patrols", "interactions", "complex doors/gates", "unmapped multi-actor waves", "unmapped phases", "patrols", "interactions",
"encounter resets", "source loot", "encounter resets", "source loot",
], ],
blockerGroups: { blockerGroups: {
@@ -769,7 +814,10 @@ const report = {
}, },
sourceMechanics: { sourceMechanics: {
status: "review-required", status: "review-required",
executableMechanicsGenerated: false, executableBossMechanicsGenerated: true,
catalogBossMechanics: summary.catalogBossMechanics,
sourceBackedBossMechanics: summary.sourceBackedBossMechanics,
sourceGapBossMechanics: summary.sourceGapBossMechanics,
scriptedTemplates: summary.scriptedTemplates, scriptedTemplates: summary.scriptedTemplates,
evidenceCounts: Object.fromEntries(Object.entries(mechanicsCategories).sort()), evidenceCounts: Object.fromEntries(Object.entries(mechanicsCategories).sort()),
}, },
@@ -779,7 +827,7 @@ const report = {
status: spellCatalogSnapshot?.status ?? "missing", status: spellCatalogSnapshot?.status ?? "missing",
safety: spellCatalogSnapshot?.safety ?? null, safety: spellCatalogSnapshot?.safety ?? null,
mappingPolicy: "Only positively classified direct-HP damage families execute; " mappingPolicy: "Only positively classified direct-HP damage families execute; "
+ "unknown/buff/heal/summon/script effects remain evidence-only.", + "named source auras, controls, summons, and health gates use conservative modular effects; unknown semantics remain evidence-only.",
}, },
resolvedRuntimeObservations, resolvedRuntimeObservations,
documentedExceptions, documentedExceptions,
@@ -63,6 +63,7 @@ const khronosValidator = path.join(
"manastorm-assets", "manastorm-assets",
"validate-package-glbs.cjs", "validate-package-glbs.cjs",
); );
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
function run(executable, argumentsList, label) { function run(executable, argumentsList, label) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -286,21 +287,8 @@ function distanceSquared(a, b) {
} }
function attackFor(template) { function attackFor(template) {
if (template.id === 100074 || template.id === 100070) {
return {
id: "rw-fa-shadow-bolt",
name: "Shadow Bolt",
delivery: "projectile",
target: "primary",
school: "shadow",
animation: "cast",
range: 18,
cooldownMs: 4200,
damageMultiplier: 1.08,
};
}
return { return {
id: "rw-fa-basic-attack", id: "basic-attack",
name: template.archetype === "ooze" ? "Corrosive Slam" : "Basic Attack", name: template.archetype === "ooze" ? "Corrosive Slam" : "Basic Attack",
delivery: "melee", delivery: "melee",
target: "primary", target: "primary",
@@ -312,8 +300,9 @@ function attackFor(template) {
}; };
} }
function entityFor(template, actor) { function entityFor(template, actor, bossMechanics) {
const boss = template.classification === "boss"; const boss = template.classification === "boss";
const mechanics = bossMechanics?.bosses?.["rw-fa-" + template.id]?.mechanics ?? [];
return { return {
id: "rw-fa-" + template.id, id: "rw-fa-" + template.id,
name: template.name, name: template.name,
@@ -326,7 +315,8 @@ function entityFor(template, actor) {
damageMultiplier: boss ? 1.3 : 1, damageMultiplier: boss ? 1.3 : 1,
moveSpeed: boss ? 2.25 : 2.15, moveSpeed: boss ? 2.25 : 2.15,
leashRange: boss ? 32 : 24, leashRange: boss ? 32 : 24,
attacks: [attackFor(template)], ...(boss ? { mechanicIds: mechanics.map((attack) => attack.id) } : {}),
attacks: [attackFor(template), ...mechanics],
}, },
visual: { visual: {
primaryColor: template.primaryColor, primaryColor: template.primaryColor,
@@ -384,11 +374,16 @@ async function generatePopulation() {
manifestFile, manifestFile,
"Run node scripts/runewaker-pipeline/build-forsaken-population.mjs --actors-only to package the creature models.", "Run node scripts/runewaker-pipeline/build-forsaken-population.mjs --actors-only to package the creature models.",
), ),
requireFile(
bossMechanicsFile,
"Run npm run runewaker:bosses:refresh to create the boss mechanic catalog.",
),
]); ]);
const [snapshot, manifest, environment] = await Promise.all([ const [snapshot, manifest, environment, bossMechanics] = await Promise.all([
readFile(snapshotFile, "utf8").then(JSON.parse), readFile(snapshotFile, "utf8").then(JSON.parse),
readFile(manifestFile, "utf8").then(JSON.parse), readFile(manifestFile, "utf8").then(JSON.parse),
readFile(resolveProject(recipe.files.environmentMetadata), "utf8").then(JSON.parse), readFile(resolveProject(recipe.files.environmentMetadata), "utf8").then(JSON.parse),
readFile(bossMechanicsFile, "utf8").then(JSON.parse),
]); ]);
const templates = new Map(recipe.templates.map((template) => [template.id, template])); const templates = new Map(recipe.templates.map((template) => [template.id, template]));
const actors = new Map(manifest.assets.map((actor) => [actor.id, actor])); const actors = new Map(manifest.assets.map((actor) => [actor.id, actor]));
@@ -438,7 +433,7 @@ async function generatePopulation() {
const entityId = "rw-fa-" + row.templateId; const entityId = "rw-fa-" + row.templateId;
if (!entities[entityId]) { if (!entities[entityId]) {
entities[entityId] = entityFor(template, actors.get(template.actor)); entities[entityId] = entityFor(template, actors.get(template.actor), bossMechanics);
} }
const spawn = { const spawn = {
id: "rw-fa-spawn-" + row.spawnId, id: "rw-fa-spawn-" + row.spawnId,
@@ -42,6 +42,7 @@ const spellCatalogFile = path.join(
"snapshots", "snapshots",
"runewaker-npc-spell-catalog.json", "runewaker-npc-spell-catalog.json",
); );
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
const paperdollCatalogFile = path.join( const paperdollCatalogFile = path.join(
pipelineDirectory, pipelineDirectory,
"snapshots", "snapshots",
@@ -430,11 +431,10 @@ function yawFromDirection(direction) {
return Number(((((direction % turn) + turn) % turn) * Math.PI * 2 / turn).toFixed(9)); return Number(((((direction % turn) + turn) % turn) * Math.PI * 2 / turn).toFixed(9));
} }
function attacksFor(template, sourceSpells, spellCatalog) { function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
if (Array.isArray(template.attacks) && template.attacks.length) return template.attacks;
const ooze = template.archetype === "ooze"; const ooze = template.archetype === "ooze";
const basicAttack = { const basicAttack = {
id: runtimePrefix + "-basic-attack", id: "basic-attack",
name: ooze ? "Corrosive Slam" : "Basic Attack", name: ooze ? "Corrosive Slam" : "Basic Attack",
delivery: "melee", delivery: "melee",
target: "primary", target: "primary",
@@ -462,13 +462,24 @@ function attacksFor(template, sourceSpells, spellCatalog) {
damageMultiplier: spell.runtime.damageMultiplier, damageMultiplier: spell.runtime.damageMultiplier,
}]; }];
}); });
return [basicAttack, ...sourceAttacks]; const recipeAttacks = Array.isArray(template.attacks)
? template.attacks.filter((attack) => attack.id !== "basic-attack")
: [];
const authored = template.classification === "boss"
? bossMechanics?.bosses?.[runtimePrefix + "-" + template.id]
: null;
const mechanics = authored?.mode === "replace"
? authored.mechanics ?? []
: [...(authored?.mechanics ?? []), ...recipeAttacks, ...sourceAttacks];
const unique = [...new Map(mechanics.map((attack) => [attack.id, attack])).values()];
return [basicAttack, ...unique];
} }
function entityFor(template, actor, sourceSpells, spellCatalog) { function entityFor(template, actor, sourceSpells, spellCatalog, bossMechanics) {
const boss = template.classification === "boss"; const boss = template.classification === "boss";
const defaults = recipe.combatDefaults ?? {}; const defaults = recipe.combatDefaults ?? {};
const combat = template.combat ?? {}; const combat = template.combat ?? {};
const attacks = attacksFor(template, sourceSpells, spellCatalog, bossMechanics);
return { return {
id: runtimePrefix + "-" + template.id, id: runtimePrefix + "-" + template.id,
name: template.name, name: template.name,
@@ -481,7 +492,8 @@ function entityFor(template, actor, sourceSpells, spellCatalog) {
damageMultiplier: combat.damageMultiplier ?? (boss ? defaults.bossDamageMultiplier ?? 1.3 : defaults.damageMultiplier ?? 1), damageMultiplier: combat.damageMultiplier ?? (boss ? defaults.bossDamageMultiplier ?? 1.3 : defaults.damageMultiplier ?? 1),
moveSpeed: combat.moveSpeed ?? (boss ? defaults.bossMoveSpeed ?? 2.25 : defaults.moveSpeed ?? 2.15), moveSpeed: combat.moveSpeed ?? (boss ? defaults.bossMoveSpeed ?? 2.25 : defaults.moveSpeed ?? 2.15),
leashRange: combat.leashRange ?? (boss ? defaults.bossLeashRange ?? 32 : defaults.leashRange ?? 24), leashRange: combat.leashRange ?? (boss ? defaults.bossLeashRange ?? 32 : defaults.leashRange ?? 24),
attacks: attacksFor(template, sourceSpells, spellCatalog), ...(boss ? { mechanicIds: attacks.slice(1).map((attack) => attack.id) } : {}),
attacks,
}, },
visual: { visual: {
primaryColor: template.primaryColor, primaryColor: template.primaryColor,
@@ -537,12 +549,14 @@ async function generatePopulation() {
requireFile(manifestFile, "Run build-population.mjs --actors-only first."), requireFile(manifestFile, "Run build-population.mjs --actors-only first."),
requireFile(resolveProject(recipe.files.environmentMetadata), "Build the environment first."), requireFile(resolveProject(recipe.files.environmentMetadata), "Build the environment first."),
requireFile(spellCatalogFile, "Run npm run runewaker:spells:refresh once to create the portable spell catalog."), requireFile(spellCatalogFile, "Run npm run runewaker:spells:refresh once to create the portable spell catalog."),
requireFile(bossMechanicsFile, "Run npm run runewaker:bosses:refresh to create the boss mechanic catalog."),
]); ]);
const [snapshot, manifest, environment, spellCatalogSnapshot] = await Promise.all([ const [snapshot, manifest, environment, spellCatalogSnapshot, bossMechanics] = await Promise.all([
readFile(snapshotFile, "utf8").then(JSON.parse), readFile(snapshotFile, "utf8").then(JSON.parse),
readFile(manifestFile, "utf8").then(JSON.parse), readFile(manifestFile, "utf8").then(JSON.parse),
readFile(resolveProject(recipe.files.environmentMetadata), "utf8").then(JSON.parse), readFile(resolveProject(recipe.files.environmentMetadata), "utf8").then(JSON.parse),
readFile(spellCatalogFile, "utf8").then(JSON.parse), readFile(spellCatalogFile, "utf8").then(JSON.parse),
readFile(bossMechanicsFile, "utf8").then(JSON.parse),
]); ]);
if (spellCatalogSnapshot.status !== "portable" if (spellCatalogSnapshot.status !== "portable"
|| spellCatalogSnapshot.safety?.runtimeSqlDependency !== false || spellCatalogSnapshot.safety?.runtimeSqlDependency !== false
@@ -619,6 +633,7 @@ async function generatePopulation() {
actorForTemplate(template), actorForTemplate(template),
[...(sourceSpellsByTemplate.get(template.id)?.values() ?? [])], [...(sourceSpellsByTemplate.get(template.id)?.values() ?? [])],
spellCatalog, spellCatalog,
bossMechanics,
); );
} }
const spawn = { const spawn = {
+3
View File
@@ -36,6 +36,7 @@ Usage:
node scripts/runewaker-pipeline/cli.mjs actors --recipe <population.json> node scripts/runewaker-pipeline/cli.mjs actors --recipe <population.json>
node scripts/runewaker-pipeline/cli.mjs population --recipe <population.json> node scripts/runewaker-pipeline/cli.mjs population --recipe <population.json>
node scripts/runewaker-pipeline/cli.mjs mechanics --recipe <population.json> node scripts/runewaker-pipeline/cli.mjs mechanics --recipe <population.json>
node scripts/runewaker-pipeline/cli.mjs boss-mechanics
node scripts/runewaker-pipeline/cli.mjs offline --recipe <population.json> node scripts/runewaker-pipeline/cli.mjs offline --recipe <population.json>
node scripts/runewaker-pipeline/cli.mjs build --recipe <population.json> node scripts/runewaker-pipeline/cli.mjs build --recipe <population.json>
node scripts/runewaker-pipeline/cli.mjs validate --recipe <population.json> node scripts/runewaker-pipeline/cli.mjs validate --recipe <population.json>
@@ -73,6 +74,8 @@ if (command === "help" || command === "--help" || command === "-h") {
await run("build-population.mjs", argv); await run("build-population.mjs", argv);
} else if (command === "mechanics") { } else if (command === "mechanics") {
await run("extract-mechanics.mjs", argv); await run("extract-mechanics.mjs", argv);
} else if (command === "boss-mechanics") {
await run("import-boss-mechanics.mjs", argv);
} else if (command === "validate") { } else if (command === "validate") {
await run("validate-instance.mjs", argv); await run("validate-instance.mjs", argv);
} else if (command === "prepare-all") { } else if (command === "prepare-all") {
@@ -165,6 +165,28 @@ for (const recipe of recipes) {
assignmentsBySpell.set(Number(sourceSpell.id), assignments); assignmentsBySpell.set(Number(sourceSpell.id), assignments);
} }
} }
}
const luaRoot = path.join(sourceRoot, recipes[0].source.luaRoot ?? "Resource/luascript");
const luaNames = (await readdir(luaRoot)).filter((name) => name.endsWith(".lua")).sort();
for (const fileName of luaNames) {
const source = await readFile(path.join(luaRoot, fileName), "utf8");
const executableSource = source
.replace(/--\[\[[\s\S]*?\]\]/g, "")
.split(/\r?\n/)
.map((line) => line.replace(/--.*$/, ""))
.join("\n");
for (const match of executableSource.matchAll(/\b49\d{4}\b/g)) {
const spellId = Number(match[0]);
const assignments = assignmentsBySpell.get(spellId) ?? new Map();
const key = "lua:" + fileName + ":" + spellId;
assignments.set(key, {
sourceKind: "lua-reference",
sourceFile: fileName,
});
assignmentsBySpell.set(spellId, assignments);
}
} }
const spells = []; const spells = [];
@@ -70,7 +70,7 @@ for (const dungeon of dungeons) {
String(actorManifest.proceduralFallbacks.length) String(actorManifest.proceduralFallbacks.length)
+ " runtime/dynamic actor containers use HealerMan procedural visuals because their ROS files expose no static triangles.", + " runtime/dynamic actor containers use HealerMan procedural visuals because their ROS files expose no static triangles.",
] : []), ] : []),
"Mechanics candidates are source-audited; complex doors, summons, phases, patrol scripts, and loot remain review-required.", "Boss combat kits are compiled from RuneWaker spell/script evidence; complex doors, patrol scripts, and loot remain review-required.",
]; ];
const definition = { const definition = {
schemaVersion: "__SCHEMA__", schemaVersion: "__SCHEMA__",
@@ -0,0 +1,509 @@
#!/usr/bin/env node
import { readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import {
pipelineDirectory,
projectPath,
projectRoot,
readJson,
sourceRootFor,
} from "./lib/recipe.mjs";
const recipesDirectory = path.join(pipelineDirectory, "recipes");
const mechanicCatalogFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
const spellCatalogFile = path.join(
pipelineDirectory,
"snapshots",
"runewaker-npc-spell-catalog.json",
);
const recipeNames = (await readdir(recipesDirectory))
.filter((name) => name.endsWith("-population.json"))
.sort();
function slugify(value) {
return String(value ?? "")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
}
function cleanLua(source) {
return source
.replace(/--\[\[[\s\S]*?\]\]/g, "")
.split(/\r?\n/)
.map((line) => line.replace(/--.*$/, ""))
.join("\n");
}
function functionIndex(luaSources) {
const functions = new Map();
for (const source of luaSources) {
const cleaned = cleanLua(source.text);
const matches = [...cleaned.matchAll(/^\s*function\s+([A-Za-z_][A-Za-z0-9_:.]*)\s*\(/gm)];
for (let index = 0; index < matches.length; index += 1) {
const match = matches[index];
const next = matches[index + 1];
functions.set(match[1], {
file: source.file,
body: cleaned.slice(match.index, next?.index ?? cleaned.length),
});
}
}
return functions;
}
function rootName(value) {
return String(value ?? "").trim().match(/^([A-Za-z_][A-Za-z0-9_:.]*)/)?.[1] ?? null;
}
function reachableFunctions(index, roots) {
const queue = roots.filter(Boolean);
const visited = new Set();
const result = [];
while (queue.length) {
const name = queue.shift();
if (!name || visited.has(name)) continue;
visited.add(name);
const definition = index.get(name);
if (!definition) continue;
result.push({ name, ...definition });
for (const match of definition.body.matchAll(/\b([A-Za-z_][A-Za-z0-9_:.]*)\s*\(/g)) {
if (index.has(match[1]) && !visited.has(match[1])) queue.push(match[1]);
}
for (const callbackCall of definition.body.matchAll(/\b(?:BeginPlot|SetPlot)\s*\([^\n]*/g)) {
for (const match of callbackCall[0].matchAll(/["']([A-Za-z_][A-Za-z0-9_:.]*)["']/g)) {
if (index.has(match[1]) && !visited.has(match[1])) queue.push(match[1]);
}
}
}
return result;
}
function telegraphFor(spell, delivery, target, range) {
const name = spell.name ?? "";
const effectRadius = Math.max(3, Math.min(15, Number(spell.source?.effectRange ?? 0) * 0.1));
if (/\b(?:breath|cleave|frontal|sweep|fan)\b/i.test(name)) {
return {
delivery: "area",
target: "nearby-party",
radius: undefined,
telegraph: {
shape: "cone",
anchor: "source",
warningMs: 1800,
length: Math.max(6, range),
angleDegrees: /breath/i.test(name) ? 70 : 95,
},
};
}
if (/\b(?:beam|charge|line|lance)\b/i.test(name)) {
return {
delivery: "area",
target: "nearby-party",
radius: undefined,
telegraph: {
shape: "line",
anchor: "source",
warningMs: 1800,
length: Math.max(7, range),
width: Math.max(2, effectRadius * 0.45),
},
};
}
const area = Number(spell.source?.rangeType ?? 0) !== 0
|| Number(spell.source?.effectRange ?? 0) >= 30
|| /\b(?:area|group|storm|rain|nova|vortex|wave|burst|explosion|whirl|spin|quake|shock)\b/i.test(name);
if (!area) return { delivery, target, radius: undefined, telegraph: undefined };
const targetAnchored = /\b(?:rain|meteor|eruption|ground|barrage)\b/i.test(name)
&& Number(spell.source?.targetType ?? 0) !== 0;
return {
delivery: "area",
target: "nearby-party",
radius: effectRadius,
telegraph: {
shape: "circle",
anchor: targetAnchored ? "target" : "source",
warningMs: 1800,
radius: effectRadius,
},
};
}
function damageMechanic(entityId, spell) {
const runtime = spell.runtime;
const spatial = telegraphFor(spell, runtime.delivery, runtime.target, runtime.range);
const castTimeMs = Math.max(
spatial.telegraph?.warningMs ?? 0,
Math.round(Math.max(0, Number(spell.source?.spellTimeSeconds ?? 0)) * 1000),
);
return {
id: entityId + "-spell-" + spell.spellId,
name: spell.name,
spellId: spell.spellId,
delivery: spatial.delivery,
target: spatial.target,
school: runtime.school,
animation: runtime.animation,
castTimeMs,
range: Math.max(runtime.range, spatial.radius ?? 0),
cooldownMs: runtime.cooldownMs,
initialCooldownMs: [runtime.cooldownMs, runtime.cooldownMs],
repeatCooldownMs: [runtime.cooldownMs, runtime.cooldownMs],
damageMultiplier: runtime.damageMultiplier,
...(spatial.radius === undefined ? {} : { radius: spatial.radius }),
...(spatial.telegraph ? { telegraph: spatial.telegraph } : {}),
};
}
function controlMechanic(entityId, spell) {
const identity = [spell.name, ...(spell.components ?? []).map((component) => component.name)]
.filter(Boolean)
.join(" ");
const mechanic = /fear|terr(?:or|if)/i.test(identity)
? "fear"
: /sleep/i.test(identity)
? "sleep"
: /root|catch|prison|immobili/i.test(identity)
? "root"
: /silence/i.test(identity)
? "silence"
: /stun|paraly/i.test(identity)
? "stun"
: null;
if (!mechanic) return null;
const cooldownMs = Math.max(4_000, Math.min(
25_000,
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 12_000,
));
const range = Math.max(4.5, Math.min(
30,
Number(spell.source?.attackDistance ?? 0) * 0.1 || 18,
));
const spatial = telegraphFor(spell, "projectile", "primary", range);
return {
id: entityId + "-control-" + spell.spellId,
name: spell.name,
delivery: spatial.delivery,
target: spatial.target,
school: "shadow",
animation: "cast",
castTimeMs: Math.max(1800, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
range,
cooldownMs,
initialCooldownMs: [cooldownMs, cooldownMs],
repeatCooldownMs: [cooldownMs, cooldownMs],
damageMultiplier: 0,
...(spatial.radius === undefined ? {} : { radius: spatial.radius }),
...(spatial.telegraph ? { telegraph: spatial.telegraph } : {}),
effect: { kind: "control", mechanic, durationMs: 3_000 },
};
}
function auraMechanic(entityId, spell) {
if (Number(spell.source?.targetType ?? -1) !== 0) return null;
const namedComponent = (spell.components ?? []).find((component) => (
component.name && Number(component.magicFunc) === 1
));
if (!namedComponent && /^RuneWaker Spell /i.test(spell.name ?? "")) return null;
const cooldownMs = Math.max(6_000, Math.min(
45_000,
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 15_000,
));
return {
id: entityId + "-aura-" + spell.spellId,
name: spell.name,
delivery: "area",
target: "self",
school: "arcane",
animation: "cast",
castTimeMs: Math.max(1_200, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
range: 0,
cooldownMs,
initialCooldownMs: [cooldownMs, cooldownMs],
repeatCooldownMs: [cooldownMs, cooldownMs],
damageMultiplier: 0,
effect: {
kind: "aura",
aura: slugify(namedComponent?.name ?? spell.name),
durationMs: Math.min(12_000, Math.max(4_000, Math.round(cooldownMs * 0.5))),
},
};
}
function targetAuraMechanic(entityId, spell) {
if (Number(spell.source?.targetType ?? 0) === 0) return null;
const namedComponent = (spell.components ?? []).find((component) => (
component.name && Number(component.magicFunc) === 1
));
if (!namedComponent) return null;
const cooldownMs = Math.max(6_000, Math.min(
30_000,
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 12_000,
));
const range = Math.max(4.5, Math.min(
30,
Number(spell.source?.attackDistance ?? 0) * 0.1 || 18,
));
const spatial = telegraphFor(spell, "projectile", "primary", range);
return {
id: entityId + "-debuff-" + spell.spellId,
name: spell.name,
delivery: spatial.delivery,
target: spatial.target,
school: "arcane",
animation: "cast",
castTimeMs: Math.max(1_800, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
range,
cooldownMs,
initialCooldownMs: [cooldownMs, cooldownMs],
repeatCooldownMs: [cooldownMs, cooldownMs],
damageMultiplier: 0,
...(spatial.radius === undefined ? {} : { radius: spatial.radius }),
...(spatial.telegraph ? { telegraph: spatial.telegraph } : {}),
effect: {
kind: "target-aura",
aura: slugify(namedComponent.name),
durationMs: Math.min(10_000, Math.max(4_000, Math.round(cooldownMs * 0.5))),
},
};
}
function scriptPhaseMechanics(entityId, bodies) {
const thresholds = [...new Set(bodies.flatMap((body) => (
[...body.matchAll(/\bHPPercent\s*<=\s*(\d+(?:\.\d+)?)\b/g)].map((match) => {
const raw = Number(match[1]);
return Math.round(raw <= 1 ? raw * 100 : raw);
})
)).filter((value) => value > 0 && value < 100))].sort((left, right) => right - left);
return thresholds.map((threshold) => ({
id: entityId + "-phase-" + threshold,
name: "Phase Change",
delivery: "area",
target: "self",
school: "arcane",
animation: "cast",
castTimeMs: 1_200,
interruptible: false,
range: 0,
cooldownMs: 1_000,
initialCooldownMs: [0, 0],
repeatCooldownMs: [1_000, 1_000],
maximumCasts: 1,
damageMultiplier: 0,
effect: { kind: "aura", aura: "phase-" + threshold, durationMs: 5_000 },
condition: { kind: "self-health-percent", minimum: 0, maximum: threshold },
}));
}
function summonMechanic(entityId, spell) {
const summon = (spell.components ?? []).find((component) => Number(component.summonCreatureId) > 0);
if (!summon) return null;
const cooldownMs = Math.max(8_000, Math.min(
40_000,
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 20_000,
));
return {
id: entityId + "-summon-" + spell.spellId,
name: spell.name,
delivery: "area",
target: "self",
school: "arcane",
animation: "cast",
castTimeMs: Math.max(1_800, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
interruptible: false,
range: 0,
cooldownMs,
initialCooldownMs: [cooldownMs, cooldownMs],
repeatCooldownMs: [cooldownMs, cooldownMs],
damageMultiplier: 0,
effect: { kind: "summon", creatureEntry: summon.summonCreatureId, count: 1 },
};
}
function scriptSummons(entityId, bodies) {
const joined = bodies.join("\n");
if (!/\b(?:CallMonster|CreateNPC|CreateMonster)/i.test(joined)) return [];
const constants = new Map([...joined.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(1\d{5})\b/g)]
.map((match) => [match[1], Number(match[2])]));
const creatureEntry = [...constants.entries()]
.find(([name]) => /monster|mob|summon/i.test(name))?.[1];
if (!creatureEntry) return [];
const count = Math.max(1, Math.min(6, Number(
[...joined.matchAll(/\b(?:MonsterNumber|MobNumber|SummonNumber)\w*\s*=\s*(\d+)\b/gi)][0]?.[1] ?? 1,
)));
const thresholds = [...new Set([...joined.matchAll(/\bHPPercent\s*<=\s*(0?\.\d+|1(?:\.0+)?)\b/g)]
.map((match) => Math.round(Number(match[1]) * 100))
.filter((value) => value > 0 && value < 100))].sort((left, right) => right - left);
const events = thresholds.length ? thresholds : [null];
return events.map((threshold, index) => ({
id: entityId + "-summon-" + creatureEntry + (threshold ? "-at-" + threshold : ""),
name: threshold ? "Reinforcements at " + threshold + "%" : "Call Reinforcements",
delivery: "area",
target: "self",
school: "physical",
animation: "cast",
castTimeMs: 1_800,
interruptible: false,
range: 0,
cooldownMs: threshold ? 1_000 : 20_000,
initialCooldownMs: threshold ? [0, 0] : [8_000, 8_000],
repeatCooldownMs: threshold ? [1_000, 1_000] : [20_000, 20_000],
...(threshold ? { maximumCasts: 1 } : {}),
damageMultiplier: 0,
effect: { kind: "summon", creatureEntry, count },
...(threshold ? {
condition: { kind: "self-health-percent", minimum: 0, maximum: threshold },
} : {}),
_sourceOrder: index,
})).map(({ _sourceOrder, ...mechanic }) => mechanic);
}
function sourceGapMechanic(entityId, name, archetype) {
const caster = /mage|witch|goddess|disciple|tree|seeker|guardian/i.test(name);
if (caster) {
return {
id: entityId + "-source-gap-burst",
name: "Arcane Burst",
delivery: "area",
target: "nearby-party",
school: "arcane",
animation: "cast",
castTimeMs: 2_000,
range: 12,
cooldownMs: 12_000,
initialCooldownMs: [8_000, 8_000],
repeatCooldownMs: [12_000, 12_000],
damageMultiplier: 0.82,
radius: 8,
telegraph: { shape: "circle", anchor: "source", warningMs: 2_000, radius: 8 },
};
}
return {
id: entityId + "-source-gap-sweep",
name: archetype === "ooze" ? "Corrosive Sweep" : "Heavy Sweep",
delivery: "area",
target: "nearby-party",
school: archetype === "ooze" ? "nature" : "physical",
animation: "attack",
castTimeMs: 1_800,
range: 7,
cooldownMs: 10_000,
initialCooldownMs: [7_000, 7_000],
repeatCooldownMs: [10_000, 10_000],
damageMultiplier: 1.1,
telegraph: {
shape: "cone",
anchor: "source",
warningMs: 1_800,
length: 7,
angleDegrees: 100,
},
};
}
const spellCatalog = await readJson(spellCatalogFile);
const spellsById = new Map(spellCatalog.spells.map((spell) => [Number(spell.spellId), spell]));
const catalog = await readJson(mechanicCatalogFile);
const runewakerBosses = {};
let sourceBacked = 0;
let sourceGap = 0;
const firstRecipe = await readJson(path.join(recipesDirectory, recipeNames[0]));
const sharedSourceRoot = sourceRootFor(firstRecipe);
const sharedLuaRoot = path.join(
sharedSourceRoot,
firstRecipe.source.luaRoot ?? "Resource/luascript",
);
const sharedLuaSources = [];
for (const file of (await readdir(sharedLuaRoot)).filter((name) => name.endsWith(".lua")).sort()) {
sharedLuaSources.push({ file, text: await readFile(path.join(sharedLuaRoot, file), "utf8") });
}
const sharedFunctions = functionIndex(sharedLuaSources);
for (const recipeName of recipeNames) {
const recipe = await readJson(path.join(recipesDirectory, recipeName));
const snapshot = await readJson(path.resolve(projectRoot, recipe.files.snapshot));
const rowsByTemplate = new Map();
for (const row of snapshot.rows ?? []) {
if (row.classification !== "boss") continue;
const rows = rowsByTemplate.get(Number(row.templateId)) ?? [];
rows.push(row);
rowsByTemplate.set(Number(row.templateId), rows);
}
for (const template of recipe.templates.filter((entry) => entry.classification === "boss")) {
const rows = rowsByTemplate.get(Number(template.id)) ?? [];
if (!rows.length) continue;
const roots = [...new Set(rows.flatMap((row) => [
rootName(row.source?.autoPlot),
rootName(row.source?.plotClassName),
...Object.values(row.source?.templateScripts ?? {}).map(rootName),
]).filter(Boolean))];
const reachable = reachableFunctions(sharedFunctions, roots);
const bodies = reachable.map((entry) => entry.body);
const scriptSpellIds = [...new Set(bodies.flatMap((body) => (
[...body.matchAll(/\b49\d{4}\b/g)].map((match) => Number(match[0]))
)))];
const templateSpellIds = [...new Set(rows.flatMap((row) => (
(row.source?.spells ?? []).map((spell) => Number(spell.id)).filter((id) => id > 0)
)))];
const spellIds = [...new Set([...templateSpellIds, ...scriptSpellIds])].sort((a, b) => a - b);
const entityId = (recipe.runtimeIdPrefix ?? "rw-" + recipe.dungeonId) + "-" + template.id;
const mechanics = [];
for (const spellId of spellIds) {
const spell = spellsById.get(spellId);
if (!spell) continue;
if (spell.mappingStatus === "damage-family" && spell.runtime) {
mechanics.push(damageMechanic(entityId, spell));
continue;
}
const summon = summonMechanic(entityId, spell);
if (summon) {
mechanics.push(summon);
continue;
}
const control = controlMechanic(entityId, spell);
if (control) {
mechanics.push(control);
continue;
}
const aura = auraMechanic(entityId, spell);
if (aura) {
mechanics.push(aura);
continue;
}
const targetAura = targetAuraMechanic(entityId, spell);
if (targetAura) mechanics.push(targetAura);
}
mechanics.push(...scriptSummons(entityId, bodies));
mechanics.push(...scriptPhaseMechanics(entityId, bodies));
const uniqueMechanics = [...new Map(mechanics.map((mechanic) => [mechanic.id, mechanic])).values()];
const fallback = uniqueMechanics.length === 0;
if (fallback) uniqueMechanics.push(sourceGapMechanic(entityId, template.name, template.archetype));
if (fallback) sourceGap += 1;
else sourceBacked += 1;
runewakerBosses[entityId] = {
name: template.name,
mode: "replace",
source: {
kind: fallback ? "healer-man-source-gap" : "runewaker",
dungeonId: recipe.dungeonId,
templateId: template.id,
roots,
functions: reachable.map((entry) => entry.name),
spellIds,
},
mechanics: uniqueMechanics,
};
}
}
catalog.bosses = Object.fromEntries([
...Object.entries(catalog.bosses).filter(([key]) => !key.startsWith("rw-")),
...Object.entries(runewakerBosses).sort(([left], [right]) => left.localeCompare(right)),
]);
await writeFile(mechanicCatalogFile, JSON.stringify(catalog, null, 2) + "\n", "utf8");
console.log("[runewaker-boss-mechanics] wrote " + Object.keys(runewakerBosses).length + " boss kits.");
console.log("[runewaker-boss-mechanics] source-backed: " + sourceBacked + "; source-gap fallback: " + sourceGap + ".");
console.log("[runewaker-boss-mechanics] " + projectPath(mechanicCatalogFile));
File diff suppressed because it is too large. Load diff
+4
View File
@@ -159,6 +159,7 @@ describe("local profile repository", () => {
rosters: { "offline-roster": [] }, rosters: { "offline-roster": [] },
presentationSettingsByOwner: { presentationSettingsByOwner: {
"offline-roster": { "offline-roster": {
vfxMode: "original",
vfxQuality: "low", vfxQuality: "low",
vfxEnabled: { player: true, party: false, enemy: true }, vfxEnabled: { player: true, party: false, enemy: true },
audioEnabled: { player: true, party: true, enemy: false }, audioEnabled: { player: true, party: true, enemy: false },
@@ -172,12 +173,15 @@ describe("local profile repository", () => {
}); });
expect(JSON.parse(legacyV3Storage.getItem(PROFILE_STORAGE_KEYS.database)!) expect(JSON.parse(legacyV3Storage.getItem(PROFILE_STORAGE_KEYS.database)!)
.presentationSettingsByOwner["offline-roster"].vfxMode).toBe("generated"); .presentationSettingsByOwner["offline-roster"].vfxMode).toBe("generated");
expect(JSON.parse(legacyV3Storage.getItem(PROFILE_STORAGE_KEYS.database)!)
.presentationSettingsByOwner["offline-roster"].originalMediaEnabled).toBe(false);
const storage = new MemoryStorage(); const storage = new MemoryStorage();
const firstOwner = (await createLocalAccount("EffectsOne", "eight-plus", storage)).session!.ownerId; const firstOwner = (await createLocalAccount("EffectsOne", "eight-plus", storage)).session!.ownerId;
const secondOwner = (await createLocalAccount("EffectsTwo", "eight-plus", storage)).session!.ownerId; const secondOwner = (await createLocalAccount("EffectsTwo", "eight-plus", storage)).session!.ownerId;
const customized = updateCombatPresentationSettings(firstOwner, { const customized = updateCombatPresentationSettings(firstOwner, {
...DEFAULT_COMBAT_PRESENTATION_SETTINGS, ...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
originalMediaEnabled: true,
vfxMode: "original", vfxMode: "original",
vfxQuality: "low", vfxQuality: "low",
vfxEnabled: { player: true, party: false, enemy: true }, vfxEnabled: { player: true, party: false, enemy: true },
+7 -1
View File
@@ -216,7 +216,13 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
const storedMode = settings && typeof settings === "object" && !Array.isArray(settings) const storedMode = settings && typeof settings === "object" && !Array.isArray(settings)
? (settings as { vfxMode?: unknown }).vfxMode ? (settings as { vfxMode?: unknown }).vfxMode
: undefined; : undefined;
if (storedMode !== normalized.vfxMode) presentationSettingsMigrated = true; const storedOriginalMediaEnabled = settings && typeof settings === "object" && !Array.isArray(settings)
? (settings as { originalMediaEnabled?: unknown }).originalMediaEnabled
: undefined;
if (
storedMode !== normalized.vfxMode
|| storedOriginalMediaEnabled !== normalized.originalMediaEnabled
) presentationSettingsMigrated = true;
return [ownerId, normalized]; return [ownerId, normalized];
}), }),
); );
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/aeternal-circle-population.json", "recipe": "scripts/runewaker-pipeline/recipes/aeternal-circle-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/barren-caves-population.json", "recipe": "scripts/runewaker-pipeline/recipes/barren-caves-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/cave-water-dragon-population.json", "recipe": "scripts/runewaker-pipeline/recipes/cave-water-dragon-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold-125-population.json", "recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold-125-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold-population.json", "recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/dungeon-of-dalanis-population.json", "recipe": "scripts/runewaker-pipeline/recipes/dungeon-of-dalanis-population.json",
@@ -1,18 +1,11 @@
{ {
"schemaVersion": 2, "schemaVersion": 1,
"dungeonId": "forsaken-abbey", "dungeonId": "forsaken-abbey",
"zoneId": 102, "zoneId": 102,
"sourceRowCount": 251, "sourceRowCount": 251,
"importedSpawnCount": 150, "importedSpawnCount": 150,
"excludedRowCount": 101, "excludedRowCount": 101,
"bossSpawnCount": 8, "bossSpawnCount": 8,
"objectiveOrder": {
"authoritative": false,
"kind": "healerMan-distance-derived",
"evidence": [
"scripts/runewaker-pipeline/build-population.mjs"
]
},
"sourceSnapshot": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json", "sourceSnapshot": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json",
"sourceContentSha256": "13ef6edd3df6e71389aa92d01201889148c0794cf9ecaafdbaf90d3145d2bf7b", "sourceContentSha256": "13ef6edd3df6e71389aa92d01201889148c0794cf9ecaafdbaf90d3145d2bf7b",
"sourceHashes": { "sourceHashes": {
@@ -26,7 +19,7 @@
"Resource/data/string_enus.db": "4fce021ba9ed110a213e08d3228b2de57cec66ddbbc8cdcf5753204874d9ab75" "Resource/data/string_enus.db": "4fce021ba9ed110a213e08d3228b2de57cec66ddbbc8cdcf5753204874d9ab75"
} }
}, },
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(meters,meters,-meters)", "coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"navmeshProjection": { "navmeshProjection": {
"maximumAllowedDistance": 2, "maximumAllowedDistance": 2,
"maximumObservedDistance": 2.5965, "maximumObservedDistance": 2.5965,
@@ -35,9 +28,6 @@
}, },
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/forsaken-abbey-population.json", "recipe": "scripts/runewaker-pipeline/recipes/forsaken-abbey-population.json",
"actorManifest": "public/assets/creatures/forsaken-abbey/manifest.json", "actorManifest": "public/assets/creatures/forsaken-abbey/manifest.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/grafu-castle-population.json", "recipe": "scripts/runewaker-pipeline/recipes/grafu-castle-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/hall-of-survivors-population.json", "recipe": "scripts/runewaker-pipeline/recipes/hall-of-survivors-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/hoto-population.json", "recipe": "scripts/runewaker-pipeline/recipes/hoto-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/ice-dwarf-kingdom-population.json", "recipe": "scripts/runewaker-pipeline/recipes/ice-dwarf-kingdom-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/kal-turok-hive-population.json", "recipe": "scripts/runewaker-pipeline/recipes/kal-turok-hive-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/kalins-shrine-population.json", "recipe": "scripts/runewaker-pipeline/recipes/kalins-shrine-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/kawaks-tomb-population.json", "recipe": "scripts/runewaker-pipeline/recipes/kawaks-tomb-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/kulech-bones-nest-population.json", "recipe": "scripts/runewaker-pipeline/recipes/kulech-bones-nest-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/lair-demon-dragon-population.json", "recipe": "scripts/runewaker-pipeline/recipes/lair-demon-dragon-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/mystic-altar-population.json", "recipe": "scripts/runewaker-pipeline/recipes/mystic-altar-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/paspers-shrine-population.json", "recipe": "scripts/runewaker-pipeline/recipes/paspers-shrine-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/raksha-temple-population.json", "recipe": "scripts/runewaker-pipeline/recipes/raksha-temple-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/sand-vortex-population.json", "recipe": "scripts/runewaker-pipeline/recipes/sand-vortex-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/sardo-castle-population.json", "recipe": "scripts/runewaker-pipeline/recipes/sardo-castle-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/the-origin-population.json", "recipe": "scripts/runewaker-pipeline/recipes/the-origin-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/tomb-seven-heroes-population.json", "recipe": "scripts/runewaker-pipeline/recipes/tomb-seven-heroes-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/treasure-trove-population.json", "recipe": "scripts/runewaker-pipeline/recipes/treasure-trove-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/warnorken-arena-population.json", "recipe": "scripts/runewaker-pipeline/recipes/warnorken-arena-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1-population.json", "recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss2-population.json", "recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss2-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss3-population.json", "recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss3-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold-124-population.json", "recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold-124-population.json",
@@ -36,7 +36,7 @@
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.", "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"status": "green", "status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold-population.json", "recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold-population.json",
+3 -1
View File
@@ -36,6 +36,7 @@ import type {
} from "../app/characterCatalog"; } from "../app/characterCatalog";
import type { InventoryItem } from "../game/lootTypes"; import type { InventoryItem } from "../game/lootTypes";
import { resolveContentUrl } from "../content/contentManager"; import { resolveContentUrl } from "../content/contentManager";
import { useContentManagerSnapshot } from "../content/useContentManager";
import { import {
selectCharacterBaseClip, selectCharacterBaseClip,
selectCharacterEventClip, selectCharacterEventClip,
@@ -144,6 +145,7 @@ function useManifestEntry(identity: AvatarIdentity | null | undefined): {
manifest: AvatarManifest | null; manifest: AvatarManifest | null;
entry: AvatarManifestEntry | null; entry: AvatarManifestEntry | null;
} { } {
const contentRevision = useContentManagerSnapshot().installedRevision;
const [state, setState] = useState<{ manifest: AvatarManifest; entry: AvatarManifestEntry } | null>(null); const [state, setState] = useState<{ manifest: AvatarManifest; entry: AvatarManifestEntry } | null>(null);
const key = identity ? avatarManifestKey(identity.raceId, identity.gender) : ""; const key = identity ? avatarManifestKey(identity.raceId, identity.gender) : "";
const raceId = identity?.raceId; const raceId = identity?.raceId;
@@ -162,7 +164,7 @@ function useManifestEntry(identity: AvatarIdentity | null | undefined): {
if (!cancelled) console.warn("Playable-character manifest could not be loaded", error); if (!cancelled) console.warn("Playable-character manifest could not be loaded", error);
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [gender, key, raceId]); }, [contentRevision, gender, key, raceId]);
return { manifest: state?.manifest ?? null, entry: state?.entry ?? null }; return { manifest: state?.manifest ?? null, entry: state?.entry ?? null };
} }
+2
View File
@@ -2,6 +2,7 @@ import { useGLTF } from "@react-three/drei";
import { resolveContentUrl } from "../content/contentManager"; import { resolveContentUrl } from "../content/contentManager";
import type { AvatarIdentity } from "./CharacterModel"; import type { AvatarIdentity } from "./CharacterModel";
import { import {
availableAvatarManifestUrls,
getAvatarEntry, getAvatarEntry,
loadAvatarManifest, loadAvatarManifest,
} from "./characterManifest"; } from "./characterManifest";
@@ -16,6 +17,7 @@ const requestedModels = new Set<string>();
export async function preloadAvatarModels( export async function preloadAvatarModels(
identities: readonly (AvatarIdentity | null | undefined)[], identities: readonly (AvatarIdentity | null | undefined)[],
): Promise<void> { ): Promise<void> {
if (!availableAvatarManifestUrls().length) return;
const manifest = await loadAvatarManifest(); const manifest = await loadAvatarManifest();
for (const identity of identities) { for (const identity of identities) {
if (!identity) continue; if (!identity) continue;
+28
View File
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
availableAvatarManifestUrls,
getAvatarEntry,
loadAvatarManifestSources, loadAvatarManifestSources,
type AvatarManifest, type AvatarManifest,
type AvatarManifestEntry, type AvatarManifestEntry,
@@ -33,6 +35,32 @@ afterEach(() => {
}); });
describe("playable-character manifest loading", () => { describe("playable-character manifest loading", () => {
it("does not expose optional character manifests until their packs are usable", () => {
expect(availableAvatarManifestUrls(() => false)).toEqual([]);
expect(availableAvatarManifestUrls((packId) => packId === "core:character-basic")).toEqual([
"/assets/characters/wow335a-v1/manifest.json",
]);
});
it("uses the matching basic human when an extra race pack is unavailable", async () => {
const loaded = await loadAvatarManifestSources(
["/assets/characters/wow335a-v1/manifest.json"],
async () => ({
ok: true,
status: 200,
json: async () => manifest("3.3.5", {
"human:male": { raceId: "human", gender: "male", model: "/base-human.glb" },
"orc:male": { raceId: "orc", gender: "male", model: "/orc.glb" },
}),
}),
);
const fallback = getAvatarEntry(loaded, "orc", "male", (packId) => packId === "core:character-basic");
expect(fallback?.model).toBe("/base-human.glb");
expect(fallback?.counts).toEqual({ skinColor: 1, face: 1, hairStyle: 1, hairColor: 1, feature: 1 });
expect(getAvatarEntry(loaded, "orc", "male", () => true)?.model).toBe("/orc.glb");
});
it("keeps valid base characters available when an optional pack fails", async () => { it("keeps valid base characters available when an optional pack fails", async () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(console, "warn").mockImplementation(() => undefined);
const base = manifest("3.3.5", { const base = manifest("3.3.5", {
+108 -20
View File
@@ -1,5 +1,8 @@
import type { AppearanceCounts, GenderId, RaceId } from "../app/characterCatalog"; import type { AppearanceCounts, GenderId, RaceId } from "../app/characterCatalog";
import { resolveContentUrl } from "../content/contentManager"; import {
contentPackIsUsable,
resolveContentUrl,
} from "../content/contentManager";
export type TextureTriplet = readonly [string, string, string]; export type TextureTriplet = readonly [string, string, string];
export type TextureRegion = readonly [number, number, number, number]; export type TextureRegion = readonly [number, number, number, number];
@@ -73,6 +76,8 @@ export interface AvatarManifestEntry {
featureOptions: readonly AvatarFeatureOption[]; featureOptions: readonly AvatarFeatureOption[];
attachmentPoints?: Readonly<Record<string, AvatarAttachmentPoint>>; attachmentPoints?: Readonly<Record<string, AvatarAttachmentPoint>>;
rendererStrategy?: RunewakerModularRendererStrategy; rendererStrategy?: RunewakerModularRendererStrategy;
/** Runtime ownership; generated manifests may omit it. */
contentPackId?: string;
} }
export interface AvatarManifest { export interface AvatarManifest {
@@ -97,12 +102,24 @@ export interface AvatarManifest {
export type CharacterManifest = AvatarManifest; export type CharacterManifest = AvatarManifest;
export const AVATAR_MANIFEST_URL = "/assets/characters/wow335a-v1/manifest.json"; export const AVATAR_MANIFEST_URL = "/assets/characters/wow335a-v1/manifest.json";
export const AVATAR_MANIFEST_URLS = [ export const BASIC_CHARACTER_CONTENT_PACK_ID = "core:character-basic";
AVATAR_MANIFEST_URL, export const WOW_CHARACTER_CONTENT_PACK_ID = "characters:wow335a-v1";
"/assets/characters/ascension-v1/manifest.json", export const AVATAR_MANIFEST_SOURCES = [
"/assets/characters/runewaker-v1/manifest.json", {
packIds: [BASIC_CHARACTER_CONTENT_PACK_ID, WOW_CHARACTER_CONTENT_PACK_ID],
url: AVATAR_MANIFEST_URL,
},
{
packIds: ["characters:ascension-v1"],
url: "/assets/characters/ascension-v1/manifest.json",
},
{
packIds: ["characters:runewaker-v1"],
url: "/assets/characters/runewaker-v1/manifest.json",
},
] as const; ] as const;
let manifestPromise: Promise<AvatarManifest> | null = null; export const AVATAR_MANIFEST_URLS = AVATAR_MANIFEST_SOURCES.map((source) => source.url);
let manifestRequest: { readonly key: string; readonly promise: Promise<AvatarManifest> } | null = null;
interface AvatarManifestResponse { interface AvatarManifestResponse {
readonly ok: boolean; readonly ok: boolean;
@@ -114,12 +131,37 @@ export function avatarManifestKey(raceId: RaceId, gender: GenderId): string {
return `${raceId}:${gender}`; return `${raceId}:${gender}`;
} }
function mergeAvatarManifests(manifests: readonly AvatarManifest[]): AvatarManifest { export function availableAvatarManifestUrls(
const [baseManifest, ...extensions] = manifests; isUsable: (packId: string) => boolean = contentPackIsUsable,
): readonly string[] {
return AVATAR_MANIFEST_SOURCES
.filter((source) => source.packIds.some((packId) => isUsable(packId)))
.map((source) => source.url);
}
function contentPackForAvatarEntry(url: string, entry: AvatarManifestEntry): string {
if (url === AVATAR_MANIFEST_URL) {
return entry.raceId === "human"
? BASIC_CHARACTER_CONTENT_PACK_ID
: WOW_CHARACTER_CONTENT_PACK_ID;
}
return AVATAR_MANIFEST_SOURCES.find((source) => source.url === url)?.packIds[0]
?? WOW_CHARACTER_CONTENT_PACK_ID;
}
function mergeAvatarManifests(
sources: readonly { readonly url: string; readonly manifest: AvatarManifest }[],
): AvatarManifest {
const [{ manifest: baseManifest }, ...extensions] = sources;
return { return {
...baseManifest, ...baseManifest,
sourceBuild: manifests.map((manifest) => manifest.sourceBuild).join(" + "), sourceBuild: sources.map(({ manifest }) => manifest.sourceBuild).join(" + "),
entries: Object.assign({}, baseManifest.entries, ...extensions.map((manifest) => manifest.entries)), entries: Object.assign({}, ...sources.map(({ manifest, url }) => Object.fromEntries(
Object.entries(manifest.entries).map(([key, entry]) => [
key,
{ ...entry, contentPackId: contentPackForAvatarEntry(url, entry) },
]),
))),
}; };
} }
@@ -138,10 +180,10 @@ export async function loadAvatarManifestSources(
if (manifest.version !== 1 || !manifest.entries) { if (manifest.version !== 1 || !manifest.entries) {
throw new Error(`Avatar manifest is malformed: ${url}`); throw new Error(`Avatar manifest is malformed: ${url}`);
} }
return manifest; return { url, manifest };
})); }));
const manifests = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []); const sources = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
if (!manifests.length) { if (!sources.length) {
const firstFailure = results.find((result) => result.status === "rejected"); const firstFailure = results.find((result) => result.status === "rejected");
throw firstFailure?.status === "rejected" throw firstFailure?.status === "rejected"
? firstFailure.reason ? firstFailure.reason
@@ -152,27 +194,73 @@ export async function loadAvatarManifestSources(
console.warn(`Optional playable-character pack could not be loaded: ${urls[index]}`, result.reason); console.warn(`Optional playable-character pack could not be loaded: ${urls[index]}`, result.reason);
} }
}); });
return mergeAvatarManifests(manifests); return mergeAvatarManifests(sources);
} }
export async function loadAvatarManifest(): Promise<AvatarManifest> { export async function loadAvatarManifest(): Promise<AvatarManifest> {
if (!manifestPromise) { const urls = availableAvatarManifestUrls();
manifestPromise = loadAvatarManifestSources( if (!urls.length) throw new Error("No optional playable-character packs are installed");
AVATAR_MANIFEST_URLS, const key = urls.join("|");
if (manifestRequest?.key !== key) {
const promise = loadAvatarManifestSources(
urls,
(url) => fetch(resolveContentUrl(url), { cache: "force-cache" }), (url) => fetch(resolveContentUrl(url), { cache: "force-cache" }),
) )
.catch((error) => { .catch((error) => {
manifestPromise = null; if (manifestRequest?.promise === promise) manifestRequest = null;
throw error; throw error;
}); });
manifestRequest = { key, promise };
} }
return manifestPromise; return manifestRequest.promise;
}
function firstOrEmpty<T>(values: readonly T[]): readonly T[] {
return values.length ? [values[0]] : [];
}
function basicHumanEntry(entry: AvatarManifestEntry): AvatarManifestEntry {
const skin = entry.skinOptions?.[0];
const face = entry.faceOptions?.[0];
const hair = entry.hairOptions?.[0];
const feature = entry.featureOptions?.[0];
return {
...entry,
contentPackId: BASIC_CHARACTER_CONTENT_PACK_ID,
counts: { skinColor: 1, face: 1, hairStyle: 1, hairColor: 1, feature: 1 },
dbcIndices: {
skinColor: firstOrEmpty(entry.dbcIndices?.skinColor ?? []),
face: firstOrEmpty(entry.dbcIndices?.face ?? []),
hairStyle: firstOrEmpty(entry.dbcIndices?.hairStyle ?? []),
hairColor: firstOrEmpty(entry.dbcIndices?.hairColor ?? []),
feature: firstOrEmpty(entry.dbcIndices?.feature ?? []),
},
skinOptions: skin ? [skin] : [],
faceOptions: face ? [{ ...face, bySkin: firstOrEmpty(face.bySkin) }] : [],
hairOptions: hair ? [{ ...hair, byColor: firstOrEmpty(hair.byColor) }] : [],
featureOptions: feature ? [{ ...feature, byColor: firstOrEmpty(feature.byColor) }] : [],
};
} }
export function getAvatarEntry( export function getAvatarEntry(
manifest: AvatarManifest, manifest: AvatarManifest,
raceId: RaceId, raceId: RaceId,
gender: GenderId, gender: GenderId,
isUsable: (packId: string) => boolean = contentPackIsUsable,
): AvatarManifestEntry | null { ): AvatarManifestEntry | null {
return manifest.entries[avatarManifestKey(raceId, gender)] ?? null; const requested = manifest.entries[avatarManifestKey(raceId, gender)] ?? null;
if (requested) {
const requestedPackUsable = !requested.contentPackId || isUsable(requested.contentPackId);
if (requested.raceId === "human") {
if (requestedPackUsable && isUsable(WOW_CHARACTER_CONTENT_PACK_ID)) return requested;
} else if (requestedPackUsable) {
return requested;
}
}
const basicHuman = manifest.entries[avatarManifestKey("human", gender)]
?? manifest.entries[avatarManifestKey("human", "male")]
?? null;
return basicHuman && (!basicHuman.contentPackId || isUsable(basicHuman.contentPackId))
? basicHumanEntry(basicHuman)
: null;
} }
+24
View File
@@ -0,0 +1,24 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { BROWSER_CONTENT_CACHE_NAME } from "./contentManager";
const workerSource = readFileSync(
new URL("../../public/content-cache-sw.js", import.meta.url),
"utf8",
);
describe("browser content cache worker", () => {
it("serves only cached same-origin asset requests from the active content cache", () => {
expect(workerSource).toContain(`const CONTENT_CACHE_NAME = "${BROWSER_CONTENT_CACHE_NAME}";`);
expect(workerSource).toContain('request.method !== "GET"');
expect(workerSource).toContain("url.origin !== self.location.origin");
expect(workerSource).toContain("MANAGED_ASSET_PREFIXES.some");
expect(workerSource).toContain('"/assets/game/"');
expect(workerSource).toContain("if (cached) return cached");
expect(workerSource).toContain("BUNDLED_ASSET_PATHS.has(url.pathname)");
expect(workerSource).toContain("BUNDLED_ASSET_PREFIXES.some");
expect(workerSource).not.toContain('"/assets/characters/wow335a-v1/textures/character/human/",');
expect(workerSource).toContain('status: 404');
expect(workerSource).not.toContain("cached ?? fetch(request)");
});
});
+150
View File
@@ -0,0 +1,150 @@
import { createHash } from "node:crypto";
import { Capacitor } from "@capacitor/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
BROWSER_CONTENT_CACHE_NAME,
checkForContentUpdates,
contentPackHasUpdate,
contentPackIsBundled,
contentPackIsInstalled,
contentPackIsUsable,
getContentManagerSnapshot,
installContentPack,
removeContentPack,
} from "./contentManager";
import type { ContentManifest } from "./contentManifest";
class MemoryCache {
readonly responses = new Map<string, Response>();
async match(request: RequestInfo | URL): Promise<Response | undefined> {
return this.responses.get(this.key(request))?.clone();
}
async put(request: RequestInfo | URL, response: Response): Promise<void> {
this.responses.set(this.key(request), response.clone());
}
async delete(request: RequestInfo | URL): Promise<boolean> {
return this.responses.delete(this.key(request));
}
private key(request: RequestInfo | URL): string {
if (request instanceof Request) return request.url;
return new URL(String(request), "http://localhost").toString();
}
}
class MemoryCacheStorage {
readonly stores = new Map<string, MemoryCache>();
async open(name: string): Promise<Cache> {
const cache = this.stores.get(name) ?? new MemoryCache();
this.stores.set(name, cache);
return cache as unknown as Cache;
}
async delete(name: string): Promise<boolean> {
return this.stores.delete(name);
}
}
function sha256(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
function contentManifest(version: string, objectName: string, bytes: Uint8Array): ContentManifest {
return {
schemaVersion: 1,
version,
createdAt: "2026-09-04T00:00:00.000Z",
minimumAppVersion: "0.1.0",
packs: [{
id: "characters:test",
label: "Test characters",
bytes: bytes.byteLength,
fileCount: 1,
bundledOnAndroid: false,
dependencies: [],
}],
files: {
"/assets/characters/test.glb": {
packId: "characters:test",
url: `/content/objects/${objectName}`,
sha256: sha256(bytes),
bytes: bytes.byteLength,
},
},
};
}
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("browser content packs", () => {
it("downloads, updates, and removes verified optional content", async () => {
const firstBytes = new TextEncoder().encode("first-model");
const updatedBytes = new TextEncoder().encode("updated-model");
let manifest = contentManifest("content-one", "first.glb", firstBytes);
let corruptUpdate = false;
const storage = new MemoryCacheStorage();
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
vi.stubGlobal("caches", storage as unknown as CacheStorage);
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/content/manifest.json")) return Response.json(manifest);
if (url.endsWith("/first.glb")) return new Response(firstBytes);
if (url.endsWith("/updated.glb")) {
return new Response(corruptUpdate ? new Uint8Array(updatedBytes.byteLength).fill(7) : updatedBytes);
}
return new Response(null, { status: 404 });
}));
await checkForContentUpdates();
expect(contentPackIsInstalled("characters:test")).toBe(false);
expect(contentPackIsUsable("characters:test")).toBe(false);
expect(contentPackHasUpdate("characters:test")).toBe(false);
await installContentPack("characters:test");
expect(contentPackIsInstalled("characters:test")).toBe(true);
expect(contentPackIsUsable("characters:test")).toBe(true);
expect(contentPackHasUpdate("characters:test")).toBe(false);
expect(getContentManagerSnapshot()).toMatchObject({
installingPackId: null,
downloadedBytes: firstBytes.byteLength,
totalBytes: firstBytes.byteLength,
error: "",
});
const active = storage.stores.get(BROWSER_CONTENT_CACHE_NAME);
expect(await (await active?.match("http://localhost/assets/characters/test.glb"))?.text()).toBe("first-model");
manifest = contentManifest("content-two", "updated.glb", updatedBytes);
await checkForContentUpdates();
expect(contentPackHasUpdate("characters:test")).toBe(true);
corruptUpdate = true;
await expect(installContentPack("characters:test")).rejects.toThrow(/verification/i);
expect(await (await active?.match("http://localhost/assets/characters/test.glb"))?.text()).toBe("first-model");
corruptUpdate = false;
await installContentPack("characters:test");
expect(contentPackHasUpdate("characters:test")).toBe(false);
expect(await (await active?.match("http://localhost/assets/characters/test.glb"))?.text()).toBe("updated-model");
await removeContentPack("characters:test");
expect(contentPackIsInstalled("characters:test")).toBe(false);
expect(contentPackIsUsable("characters:test")).toBe(false);
expect(await active?.match("http://localhost/assets/characters/test.glb")).toBeUndefined();
});
it("keeps default browser content installed and non-removable", async () => {
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
expect(contentPackIsBundled("core:ui")).toBe(true);
expect(contentPackIsUsable("core:ui")).toBe(true);
await expect(removeContentPack("core:ui")).resolves.toBeUndefined();
expect(contentPackIsUsable("core:ui")).toBe(true);
});
});
+194 -24
View File
@@ -11,7 +11,23 @@ import { NativeContentStorage } from "./nativeContentStorage";
const CONTENT_STATE_KEY = "healer-man.content-state.v1"; const CONTENT_STATE_KEY = "healer-man.content-state.v1";
const PRODUCTION_ORIGIN = "https://iwanttoheal.phenomrom.com"; const PRODUCTION_ORIGIN = "https://iwanttoheal.phenomrom.com";
const STATIC_ANDROID_BUNDLED_PACKS = new Set(["core:ui", "core:equipment", "dungeon:wailing-caverns"]); const STATIC_ANDROID_BUNDLED_PACKS = new Set([
"core:ui",
"core:equipment",
"core:character-basic",
"core:runtime",
"dungeon:wailing-caverns",
]);
const STATIC_BROWSER_BUNDLED_PACKS = new Set([
"core:ui",
"core:equipment",
"core:character-basic",
"core:runtime",
"dungeon:wailing-caverns",
]);
export const BROWSER_CONTENT_CACHE_NAME = "healer-man-content-v1";
export const BROWSER_CONTENT_WORKER_URL = "/content-cache-sw.js";
export interface InstalledContentFile { export interface InstalledContentFile {
readonly packId: string; readonly packId: string;
@@ -29,6 +45,7 @@ interface PersistedContentState {
export interface ContentManagerSnapshot { export interface ContentManagerSnapshot {
readonly revision: number; readonly revision: number;
readonly installedRevision: number;
readonly checking: boolean; readonly checking: boolean;
readonly manifest: ContentManifest | null; readonly manifest: ContentManifest | null;
readonly bundledManifest: ContentManifest | null; readonly bundledManifest: ContentManifest | null;
@@ -42,6 +59,7 @@ const EMPTY_STATE: PersistedContentState = { version: 1, packVersions: {}, files
let installedState = readInstalledState(); let installedState = readInstalledState();
let snapshot: ContentManagerSnapshot = { let snapshot: ContentManagerSnapshot = {
revision: 0, revision: 0,
installedRevision: 0,
checking: false, checking: false,
manifest: null, manifest: null,
bundledManifest: null, bundledManifest: null,
@@ -87,13 +105,45 @@ function absoluteContentUrl(value: string): string {
return new URL(value, `${contentOrigin() || "http://localhost"}/`).toString(); return new URL(value, `${contentOrigin() || "http://localhost"}/`).toString();
} }
function browserCacheRequest(logicalPath: string): Request {
const appOrigin = typeof window === "undefined" ? "http://localhost" : window.location.origin;
return new Request(new URL(logicalPath, `${appOrigin}/`).toString(), { method: "GET" });
}
function browserCacheStorage(): CacheStorage | null {
return typeof caches === "undefined" ? null : caches;
}
async function registerBrowserContentWorker(): Promise<void> {
if (Capacitor.isNativePlatform()
|| import.meta.env.DEV
|| typeof navigator === "undefined"
|| !("serviceWorker" in navigator)) return;
try {
await navigator.serviceWorker.register(BROWSER_CONTENT_WORKER_URL, {
scope: "/",
updateViaCache: "none",
});
await navigator.serviceWorker.ready;
} catch (error) {
console.warn("Browser content cache worker could not be registered.", error);
}
}
function extensionForEntry(entry: ContentManifestFile): string { function extensionForEntry(entry: ContentManifestFile): string {
const match = /(?:\.[A-Za-z0-9]+)(?:\?.*)?$/.exec(entry.url); const match = /(?:\.[A-Za-z0-9]+)(?:\?.*)?$/.exec(entry.url);
return match?.[0].split("?")[0].toLowerCase() ?? ".bin"; return match?.[0].split("?")[0].toLowerCase() ?? ".bin";
} }
function bundledFiles(): Readonly<Record<string, { readonly sha256: string }>> { function bundledFiles(): Readonly<Record<string, { readonly sha256: string }>> {
return snapshot.bundledManifest?.files ?? {}; if (snapshot.bundledManifest) return snapshot.bundledManifest.files;
if (!snapshot.manifest) return {};
const bundledPacks = Capacitor.isNativePlatform()
? STATIC_ANDROID_BUNDLED_PACKS
: STATIC_BROWSER_BUNDLED_PACKS;
return Object.fromEntries(Object.entries(snapshot.manifest.files).filter(([, entry]) => (
bundledPacks.has(entry.packId)
)));
} }
function activeFileIdentities(): Readonly<Record<string, { readonly sha256: string }>> { function activeFileIdentities(): Readonly<Record<string, { readonly sha256: string }>> {
@@ -117,21 +167,31 @@ export function resolveContentUrl(logicalUrl: string): string {
return installed?.fileUri ? Capacitor.convertFileSrc(installed.fileUri) : logicalUrl; return installed?.fileUri ? Capacitor.convertFileSrc(installed.fileUri) : logicalUrl;
} }
export function contentPackIsBundled(packId: string): boolean {
if (!Capacitor.isNativePlatform()) return STATIC_BROWSER_BUNDLED_PACKS.has(packId);
return STATIC_ANDROID_BUNDLED_PACKS.has(packId)
|| Boolean(snapshot.bundledManifest?.packs.some((pack) => pack.id === packId));
}
export function contentPackIsInstalled(packId: string): boolean {
return contentPackIsBundled(packId) || Boolean(installedState.packVersions[packId]);
}
export function contentPackIsUsable(packId: string): boolean { export function contentPackIsUsable(packId: string): boolean {
if (!Capacitor.isNativePlatform()) return true; if (contentPackIsBundled(packId)) return true;
const installed = STATIC_ANDROID_BUNDLED_PACKS.has(packId) if (!installedState.packVersions[packId]) return false;
|| snapshot.bundledManifest?.packs.some((pack) => pack.id === packId)
|| Boolean(installedState.packVersions[packId]);
if (!installed) return false;
const pack = snapshot.manifest?.packs.find((candidate) => candidate.id === packId); const pack = snapshot.manifest?.packs.find((candidate) => candidate.id === packId);
return pack?.dependencies.every((dependency) => contentPackIsUsable(dependency)) ?? true; return pack?.dependencies.every((dependency) => contentPackIsUsable(dependency)) ?? true;
} }
export function contentPackHasUpdate(packId: string): boolean { export function contentPackHasUpdate(packId: string): boolean {
if (!snapshot.manifest) return false; if (!snapshot.manifest || !contentPackIsInstalled(packId)) return false;
const pack = snapshot.manifest.packs.find((candidate) => candidate.id === packId); const pack = snapshot.manifest.packs.find((candidate) => candidate.id === packId);
return changedFilesForPack(snapshot.manifest, packId, activeFileIdentities()).length > 0 return changedFilesForPack(snapshot.manifest, packId, activeFileIdentities()).length > 0
|| Boolean(pack?.dependencies.some((dependency) => contentPackHasUpdate(dependency))); || Boolean(!contentPackIsBundled(packId)
&& pack?.dependencies.some((dependency) => (
!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)
)));
} }
export async function checkForContentUpdates(): Promise<ContentManifest | null> { export async function checkForContentUpdates(): Promise<ContentManifest | null> {
@@ -149,7 +209,7 @@ export async function checkForContentUpdates(): Promise<ContentManifest | null>
} }
async function loadBundledManifest(): Promise<void> { async function loadBundledManifest(): Promise<void> {
if (!Capacitor.isNativePlatform()) return; if (!Capacitor.isNativePlatform() && import.meta.env.VITE_CONTENT_MODE !== "downloadable") return;
try { try {
const response = await fetch("/content/bootstrap-manifest.json", { cache: "force-cache" }); const response = await fetch("/content/bootstrap-manifest.json", { cache: "force-cache" });
if (!response.ok) return; if (!response.ok) return;
@@ -162,13 +222,102 @@ async function loadBundledManifest(): Promise<void> {
export function initializeContentSystem(): Promise<void> { export function initializeContentSystem(): Promise<void> {
if (!initialization) { if (!initialization) {
initialization = (async () => { initialization = (async () => {
await loadBundledManifest(); await Promise.all([loadBundledManifest(), registerBrowserContentWorker()]);
if (typeof navigator === "undefined" || navigator.onLine !== false) void checkForContentUpdates(); if (typeof navigator === "undefined" || navigator.onLine !== false) void checkForContentUpdates();
})(); })();
} }
return initialization; return initialization;
} }
async function sha256Bytes(bytes: ArrayBuffer): Promise<string> {
if (!globalThis.crypto?.subtle) {
throw new Error("This browser cannot verify downloaded content.");
}
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
}
async function installBrowserContentPack(
packId: string,
manifest: ContentManifest,
changes: ReturnType<typeof changedFilesForPack>,
): Promise<void> {
const storage = browserCacheStorage();
if (!storage) throw new Error("Offline content storage is unavailable in this browser.");
const totalBytes = changes.reduce((total, [, entry]) => total + entry.bytes, 0);
const stagingName = `${BROWSER_CONTENT_CACHE_NAME}-staging-${encodeURIComponent(packId)}`;
await storage.delete(stagingName);
const staging = await storage.open(stagingName);
publish({ installingPackId: packId, downloadedBytes: 0, totalBytes, error: "" });
let completedBytes = 0;
try {
for (const [logicalPath, entry] of changes) {
const response = await fetch(absoluteContentUrl(entry.url), { cache: "no-store" });
if (!response.ok) throw new Error(`Content request failed (${response.status}): ${logicalPath}`);
const bytes = await response.arrayBuffer();
if (bytes.byteLength !== entry.bytes) {
throw new Error(`Downloaded content has the wrong size: ${logicalPath}`);
}
if (await sha256Bytes(bytes) !== entry.sha256) {
throw new Error(`Downloaded content failed verification: ${logicalPath}`);
}
const headers = new Headers();
const contentType = response.headers.get("content-type");
if (contentType) headers.set("content-type", contentType);
await staging.put(browserCacheRequest(logicalPath), new Response(bytes, {
headers,
status: 200,
}));
completedBytes += entry.bytes;
publish({ downloadedBytes: completedBytes });
}
const active = await storage.open(BROWSER_CONTENT_CACHE_NAME);
for (const [logicalPath] of changes) {
const cached = await staging.match(browserCacheRequest(logicalPath));
if (!cached) throw new Error(`Verified content was not staged: ${logicalPath}`);
await active.put(browserCacheRequest(logicalPath), cached);
}
const pendingFiles = { ...installedState.files };
const latestPaths = new Set(filesForContentPack(manifest, packId).map(([logicalPath]) => logicalPath));
for (const [logicalPath, installed] of Object.entries(pendingFiles)) {
if (installed.packId !== packId || latestPaths.has(logicalPath)) continue;
await active.delete(browserCacheRequest(logicalPath));
delete pendingFiles[logicalPath];
}
for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) {
pendingFiles[logicalPath] = {
packId,
sha256: entry.sha256,
bytes: entry.bytes,
extension: extensionForEntry(entry),
fileUri: absoluteContentUrl(logicalPath),
};
}
installedState = {
version: 1,
files: pendingFiles,
packVersions: { ...installedState.packVersions, [packId]: manifest.version },
};
persistInstalledState();
publish({
installingPackId: null,
downloadedBytes: totalBytes,
totalBytes,
error: "",
installedRevision: snapshot.installedRevision + 1,
});
} catch (error) {
publish({ installingPackId: null, error: error instanceof Error ? error.message : "Content download failed." });
throw error;
} finally {
await storage.delete(stagingName).catch(() => false);
}
}
export async function installContentPack(packId: string): Promise<void> { export async function installContentPack(packId: string): Promise<void> {
const manifest = snapshot.manifest ?? await checkForContentUpdates(); const manifest = snapshot.manifest ?? await checkForContentUpdates();
if (!manifest) throw new Error(snapshot.error || "Content server is unavailable."); if (!manifest) throw new Error(snapshot.error || "Content server is unavailable.");
@@ -177,15 +326,20 @@ export async function installContentPack(packId: string): Promise<void> {
} }
const pack = manifest.packs.find((candidate) => candidate.id === packId); const pack = manifest.packs.find((candidate) => candidate.id === packId);
if (!pack) throw new Error(`Unknown content pack: ${packId}`); if (!pack) throw new Error(`Unknown content pack: ${packId}`);
if (!Capacitor.isNativePlatform()) return;
for (const dependency of pack.dependencies) { if (!contentPackIsBundled(packId)) {
if (!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)) { for (const dependency of pack.dependencies) {
await installContentPack(dependency); if (!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)) {
await installContentPack(dependency);
}
} }
} }
const changes = changedFilesForPack(manifest, packId, activeFileIdentities()); const changes = changedFilesForPack(manifest, packId, activeFileIdentities());
if (!Capacitor.isNativePlatform()) {
await installBrowserContentPack(packId, manifest, changes);
return;
}
const totalBytes = changes.reduce((total, [, entry]) => total + entry.bytes, 0); const totalBytes = changes.reduce((total, [, entry]) => total + entry.bytes, 0);
const storage = await NativeContentStorage.getFreeBytes(); const storage = await NativeContentStorage.getFreeBytes();
const safetyReserve = 128 * 1024 * 1024; const safetyReserve = 128 * 1024 * 1024;
@@ -232,7 +386,13 @@ export async function installContentPack(packId: string): Promise<void> {
packVersions: { ...installedState.packVersions, [packId]: manifest.version }, packVersions: { ...installedState.packVersions, [packId]: manifest.version },
}; };
persistInstalledState(); persistInstalledState();
publish({ installingPackId: null, downloadedBytes: totalBytes, totalBytes, error: "" }); publish({
installingPackId: null,
downloadedBytes: totalBytes,
totalBytes,
error: "",
installedRevision: snapshot.installedRevision + 1,
});
} catch (error) { } catch (error) {
publish({ installingPackId: null, error: error instanceof Error ? error.message : "Content download failed." }); publish({ installingPackId: null, error: error instanceof Error ? error.message : "Content download failed." });
throw error; throw error;
@@ -242,19 +402,29 @@ export async function installContentPack(packId: string): Promise<void> {
} }
export async function removeContentPack(packId: string): Promise<void> { export async function removeContentPack(packId: string): Promise<void> {
if (!Capacitor.isNativePlatform()) return; if (contentPackIsBundled(packId)) return;
const remainingFiles = { ...installedState.files }; const remainingFiles = { ...installedState.files };
const targetFiles = Object.entries(remainingFiles).filter(([, entry]) => entry.packId === packId); const targetFiles = Object.entries(remainingFiles).filter(([, entry]) => entry.packId === packId);
for (const [logicalPath, entry] of targetFiles) { if (Capacitor.isNativePlatform()) {
const shared = Object.entries(remainingFiles).some(([otherPath, other]) => ( for (const [logicalPath, entry] of targetFiles) {
otherPath !== logicalPath && other.sha256 === entry.sha256 && other.extension === entry.extension && other.packId !== packId const shared = Object.entries(remainingFiles).some(([otherPath, other]) => (
)); otherPath !== logicalPath && other.sha256 === entry.sha256 && other.extension === entry.extension && other.packId !== packId
if (!shared) await NativeContentStorage.remove({ sha256: entry.sha256, extension: entry.extension }); ));
delete remainingFiles[logicalPath]; if (!shared) await NativeContentStorage.remove({ sha256: entry.sha256, extension: entry.extension });
delete remainingFiles[logicalPath];
}
} else {
const storage = browserCacheStorage();
if (!storage) throw new Error("Offline content storage is unavailable in this browser.");
const active = await storage.open(BROWSER_CONTENT_CACHE_NAME);
for (const [logicalPath] of targetFiles) {
await active.delete(browserCacheRequest(logicalPath));
delete remainingFiles[logicalPath];
}
} }
const packVersions = { ...installedState.packVersions }; const packVersions = { ...installedState.packVersions };
delete packVersions[packId]; delete packVersions[packId];
installedState = { version: 1, files: remainingFiles, packVersions }; installedState = { version: 1, files: remainingFiles, packVersions };
persistInstalledState(); persistInstalledState();
publish({ error: "" }); publish({ error: "", installedRevision: snapshot.installedRevision + 1 });
} }
+38 -1
View File
@@ -1,10 +1,20 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
const combatMedia = vi.hoisted(() => ({ ready: false }));
vi.mock("./combatMediaPack", async (importOriginal) => ({
...(await importOriginal<typeof import("./combatMediaPack")>()),
combatMediaPackReady: () => combatMedia.ready,
}));
import { import {
DEFAULT_COMBAT_PRESENTATION_SETTINGS, DEFAULT_COMBAT_PRESENTATION_SETTINGS,
type CombatPresentationEvent, type CombatPresentationEvent,
} from "./combatPresentation"; } from "./combatPresentation";
import { import {
combatAudioOutputEnabled, combatAudioOutputEnabled,
authenticCombatAudioEnabled,
combatMediaPreloadEnabled,
configureCombatAudio, configureCombatAudio,
masterAudioOutputEnabled, masterAudioOutputEnabled,
playCombatAudio, playCombatAudio,
@@ -28,7 +38,10 @@ const fixtureEvent: CombatPresentationEvent = {
targetPosition: [1, 0, 0], targetPosition: [1, 0, 0],
}; };
afterEach(() => vi.unstubAllGlobals()); afterEach(() => {
combatMedia.ready = false;
vi.unstubAllGlobals();
});
describe("combat audio output policy", () => { describe("combat audio output policy", () => {
it("does not create or fetch audio while the master volume is disabled", async () => { it("does not create or fetch audio while the master volume is disabled", async () => {
@@ -50,6 +63,30 @@ describe("combat audio output policy", () => {
expect(masterAudioOutputEnabled(0.6)).toBe(true); expect(masterAudioOutputEnabled(0.6)).toBe(true);
expect(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0)).toBe(false); expect(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0)).toBe(false);
expect(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(true); expect(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(true);
expect(authenticCombatAudioEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS)).toBe(false);
combatMedia.ready = true;
expect(authenticCombatAudioEnabled({
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
originalMediaEnabled: true,
})).toBe(true);
expect(combatMediaPreloadEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(false);
expect(combatMediaPreloadEnabled({
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
originalMediaEnabled: true,
}, 0.6)).toBe(true);
});
it("does not fetch packaged sound effects before the player downloads them", async () => {
const AudioContextConstructor = vi.fn();
const fetch = vi.fn();
vi.stubGlobal("window", { AudioContext: AudioContextConstructor });
vi.stubGlobal("fetch", fetch);
configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
await expect(playMasterAudioCue("/assets/game/combat-effects/audio/fixture.wav")).resolves.toBeNull();
expect(AudioContextConstructor).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
}); });
it("does not initialize audio for a zero-volume actor group", () => { it("does not initialize audio for a zero-volume actor group", () => {
+22 -2
View File
@@ -6,6 +6,10 @@ import {
type CombatPresentationSettings, type CombatPresentationSettings,
type CombatPresentationSchool, type CombatPresentationSchool,
} from "./combatPresentation"; } from "./combatPresentation";
import {
combatMediaPackReady,
fetchOptionalMediaAsset,
} from "./combatMediaPack";
const MAX_COMBAT_VOICES = 32; const MAX_COMBAT_VOICES = 32;
@@ -37,6 +41,19 @@ export function combatAudioOutputEnabled(
)); ));
} }
export function authenticCombatAudioEnabled(settings: CombatPresentationSettings): boolean {
return settings.originalMediaEnabled && combatMediaPackReady();
}
export function combatMediaPreloadEnabled(
settings: CombatPresentationSettings,
masterVolume: number,
): boolean {
return authenticCombatAudioEnabled(settings) && (
settings.vfxQuality !== "off" || combatAudioOutputEnabled(settings, masterVolume)
);
}
function combatAudioGroupOutputEnabled( function combatAudioGroupOutputEnabled(
settings: CombatPresentationSettings, settings: CombatPresentationSettings,
masterVolume: number, masterVolume: number,
@@ -185,6 +202,7 @@ function playSynthesizedCombatCue(event: CombatPresentationEvent): void {
} }
function weightedSound(event: CombatPresentationEvent): CombatEffectSoundAsset | null { function weightedSound(event: CombatPresentationEvent): CombatEffectSoundAsset | null {
if (!configuredSettings || !authenticCombatAudioEnabled(configuredSettings)) return null;
const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? []; const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? [];
const playable = sounds.filter((sound) => sound.url); const playable = sounds.filter((sound) => sound.url);
if (!playable.length) return null; if (!playable.length) return null;
@@ -201,7 +219,7 @@ async function decodeAudio(url: string): Promise<AudioBuffer | null> {
const audioContext = ensureContext(); const audioContext = ensureContext();
if (!audioContext) return null; if (!audioContext) return null;
if (!decodedBuffers.has(url)) { if (!decodedBuffers.has(url)) {
decodedBuffers.set(url, fetch(url) decodedBuffers.set(url, fetchOptionalMediaAsset(url)
.then((response) => response.ok ? response.arrayBuffer() : Promise.reject(new Error(String(response.status)))) .then((response) => response.ok ? response.arrayBuffer() : Promise.reject(new Error(String(response.status))))
.then((data) => audioContext.decodeAudioData(data)) .then((data) => audioContext.decodeAudioData(data))
.catch(() => null)); .catch(() => null));
@@ -213,7 +231,9 @@ export async function playMasterAudioCue(
url: string, url: string,
volume = 1, volume = 1,
): Promise<(() => void) | null> { ): Promise<(() => void) | null> {
if (!masterAudioOutputEnabled(configuredMasterVolume)) return null; if (!configuredSettings
|| !authenticCombatAudioEnabled(configuredSettings)
|| !masterAudioOutputEnabled(configuredMasterVolume)) return null;
const audioContext = ensureContext(); const audioContext = ensureContext();
const buffer = await decodeAudio(url); const buffer = await decodeAudio(url);
if ( if (
+3
View File
@@ -116,6 +116,9 @@ describe("named aura combat runtime", () => {
school: "fire", school: "fire",
interruptible: true, interruptible: true,
targetActorId: PLAYER_AGGRO_ID, targetActorId: PLAYER_AGGRO_ID,
sourcePosition: [0, 0, 0],
targetPosition: [1, 0, 0],
sourceYaw: Math.PI / 2,
startedAt: 1_000, startedAt: 1_000,
completesAt: 6_000, completesAt: 6_000,
}, },
+77
View File
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const content = vi.hoisted(() => ({
usable: false,
install: vi.fn(async () => undefined),
remove: vi.fn(async () => undefined),
listener: null as (() => void) | null,
snapshot: {
downloadedBytes: 4,
totalBytes: 8,
manifest: {
packs: [{ id: "core:combat-media", fileCount: 12 }],
},
},
}));
vi.mock("../content/contentManager", () => ({
contentPackIsUsable: () => content.usable,
getContentManagerSnapshot: () => content.snapshot,
installContentPack: content.install,
removeContentPack: content.remove,
resolveContentUrl: (url: string) => `resolved:${url}`,
subscribeContentManager: (listener: () => void) => {
content.listener = listener;
return () => { content.listener = null; };
},
}));
import {
combatMediaPackReady,
downloadCombatMediaPack,
fetchCombatMediaAsset,
removeCombatMediaPack,
} from "./combatMediaPack";
beforeEach(() => {
content.usable = false;
content.install.mockClear();
content.remove.mockClear();
vi.unstubAllGlobals();
});
describe("optional combat media pack", () => {
it("uses the shared verified content-pack installer only after an explicit request", async () => {
const progress = vi.fn();
await expect(downloadCombatMediaPack(progress)).resolves.toEqual({ fileCount: 12 });
expect(content.install).toHaveBeenCalledWith("core:combat-media");
expect(progress).toHaveBeenLastCalledWith({ completed: 4, total: 8 });
});
it("does not fetch source media while its pack is offloaded", async () => {
const fetch = vi.fn();
vi.stubGlobal("fetch", fetch);
expect(combatMediaPackReady()).toBe(false);
await expect(fetchCombatMediaAsset("/assets/game/combat-effects/audio/shield.wav"))
.rejects.toThrow("not installed");
expect(fetch).not.toHaveBeenCalled();
});
it("resolves installed media through the shared content router and removes the same pack", async () => {
content.usable = true;
const response = new Response(new Uint8Array([1, 2, 3]));
const fetch = vi.fn(async () => response);
vi.stubGlobal("fetch", fetch);
await expect(fetchCombatMediaAsset("/assets/game/combat-effects/audio/shield.wav"))
.resolves.toBe(response);
expect(fetch).toHaveBeenCalledWith(
"resolved:/assets/game/combat-effects/audio/shield.wav",
{ cache: "no-store" },
);
await removeCombatMediaPack();
expect(content.remove).toHaveBeenCalledWith("core:combat-media");
});
});
+70
View File
@@ -0,0 +1,70 @@
import {
contentPackIsUsable,
getContentManagerSnapshot,
installContentPack,
removeContentPack,
resolveContentUrl,
subscribeContentManager,
} from "../content/contentManager";
export const COMBAT_MEDIA_MANIFEST_URL = "/assets/game/combat-effects/manifest.json";
export const COMBAT_MEDIA_CONTENT_PACK_ID = "core:combat-media";
export interface CombatMediaDownloadProgress {
readonly completed: number;
readonly total: number;
}
export interface CombatMediaDownloadResult {
readonly fileCount: number;
}
type ProgressListener = (progress: CombatMediaDownloadProgress) => void;
function isCombatMediaUrl(value: unknown): value is string {
return typeof value === "string" && value.startsWith("/assets/game/combat-effects/");
}
/** Callers must enforce the player's original-media opt-in before fetching. */
export async function fetchCombatMediaAsset(url: string): Promise<Response> {
if (!isCombatMediaUrl(url)) throw new Error(`Unsupported combat media URL: ${url}`);
return fetchOptionalMediaAsset(url);
}
/** Fetch source media only after its shared optional pack has been enabled. */
export async function fetchOptionalMediaAsset(url: string): Promise<Response> {
if (!combatMediaPackReady()) throw new Error("Original spell media is not installed.");
return fetch(resolveContentUrl(url), { cache: "no-store" });
}
/** Download and verify every file owned by the original combat-media pack. */
export async function downloadCombatMediaPack(
onProgress?: ProgressListener,
): Promise<CombatMediaDownloadResult> {
const publishProgress = () => {
const state = getContentManagerSnapshot();
onProgress?.({
completed: state.downloadedBytes,
total: Math.max(1, state.totalBytes),
});
};
const unsubscribe = subscribeContentManager(publishProgress);
try {
await installContentPack(COMBAT_MEDIA_CONTENT_PACK_ID);
publishProgress();
const pack = getContentManagerSnapshot().manifest?.packs.find((candidate) => (
candidate.id === COMBAT_MEDIA_CONTENT_PACK_ID
));
return { fileCount: pack?.fileCount ?? 0 };
} finally {
unsubscribe();
}
}
export async function removeCombatMediaPack(): Promise<void> {
await removeContentPack(COMBAT_MEDIA_CONTENT_PACK_ID);
}
export function combatMediaPackReady(): boolean {
return contentPackIsUsable(COMBAT_MEDIA_CONTENT_PACK_ID);
}
+6
View File
@@ -23,6 +23,7 @@ describe("combat presentation", () => {
audioEnabled: { enemy: false }, audioEnabled: { enemy: false },
audioVolume: { player: 2, party: -1, enemy: 0.35 }, audioVolume: { player: 2, party: -1, enemy: 0.35 },
})).toEqual({ })).toEqual({
originalMediaEnabled: false,
vfxMode: "generated", vfxMode: "generated",
vfxQuality: "low", vfxQuality: "low",
vfxEnabled: { player: false, party: true, enemy: true }, vfxEnabled: { player: false, party: true, enemy: true },
@@ -31,8 +32,13 @@ describe("combat presentation", () => {
}); });
expect(normalizeCombatPresentationSettings({ expect(normalizeCombatPresentationSettings({
...DEFAULT_COMBAT_PRESENTATION_SETTINGS, ...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
originalMediaEnabled: true,
vfxMode: "original", vfxMode: "original",
}).vfxMode).toBe("original"); }).vfxMode).toBe("original");
expect(normalizeCombatPresentationSettings({
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
vfxMode: "original",
}).vfxMode).toBe("generated");
expect(normalizeCombatPresentationSettings({ expect(normalizeCombatPresentationSettings({
...DEFAULT_COMBAT_PRESENTATION_SETTINGS, ...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
vfxMode: "hybrid", vfxMode: "hybrid",
+22 -3
View File
@@ -1,4 +1,9 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import {
COMBAT_MEDIA_MANIFEST_URL,
combatMediaPackReady,
fetchCombatMediaAsset,
} from "./combatMediaPack";
export type CombatActorGroup = "player" | "party" | "enemy"; export type CombatActorGroup = "player" | "party" | "enemy";
export type CombatVfxMode = "generated" | "original"; export type CombatVfxMode = "generated" | "original";
@@ -24,6 +29,7 @@ export type CombatPresentationSource = "wow335" | "ascension" | "runewaker" | "f
export type CombatPresentationPosition = readonly [number, number, number]; export type CombatPresentationPosition = readonly [number, number, number];
export interface CombatPresentationSettings { export interface CombatPresentationSettings {
readonly originalMediaEnabled: boolean;
readonly vfxMode: CombatVfxMode; readonly vfxMode: CombatVfxMode;
readonly vfxQuality: CombatVfxQuality; readonly vfxQuality: CombatVfxQuality;
readonly vfxEnabled: Readonly<Record<CombatActorGroup, boolean>>; readonly vfxEnabled: Readonly<Record<CombatActorGroup, boolean>>;
@@ -34,6 +40,7 @@ export interface CombatPresentationSettings {
const ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"]; const ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
export const DEFAULT_COMBAT_PRESENTATION_SETTINGS: CombatPresentationSettings = Object.freeze({ export const DEFAULT_COMBAT_PRESENTATION_SETTINGS: CombatPresentationSettings = Object.freeze({
originalMediaEnabled: false,
vfxMode: "generated", vfxMode: "generated",
vfxQuality: "high", vfxQuality: "high",
vfxEnabled: Object.freeze({ player: true, party: true, enemy: true }), vfxEnabled: Object.freeze({ player: true, party: true, enemy: true }),
@@ -80,13 +87,20 @@ export function normalizeCombatPresentationSettings(
const record = value && typeof value === "object" && !Array.isArray(value) const record = value && typeof value === "object" && !Array.isArray(value)
? value as Partial<Record<keyof CombatPresentationSettings, unknown>> ? value as Partial<Record<keyof CombatPresentationSettings, unknown>>
: {}; : {};
const vfxMode = record.vfxMode === "generated" || record.vfxMode === "original" const originalMediaEnabled = typeof record.originalMediaEnabled === "boolean"
? record.originalMediaEnabled
: fallback.originalMediaEnabled;
const requestedVfxMode = record.vfxMode === "generated" || record.vfxMode === "original"
? record.vfxMode ? record.vfxMode
: fallback.vfxMode; : fallback.vfxMode;
const vfxMode = requestedVfxMode === "original" && !originalMediaEnabled
? "generated"
: requestedVfxMode;
const vfxQuality = record.vfxQuality === "off" || record.vfxQuality === "low" || record.vfxQuality === "high" const vfxQuality = record.vfxQuality === "off" || record.vfxQuality === "low" || record.vfxQuality === "high"
? record.vfxQuality ? record.vfxQuality
: fallback.vfxQuality; : fallback.vfxQuality;
return Object.freeze({ return Object.freeze({
originalMediaEnabled,
vfxMode, vfxMode,
vfxQuality, vfxQuality,
vfxEnabled: booleanGroups(record.vfxEnabled, fallback.vfxEnabled), vfxEnabled: booleanGroups(record.vfxEnabled, fallback.vfxEnabled),
@@ -191,7 +205,6 @@ interface GeneratedCombatEffectManifest {
readonly entries: readonly CombatEffectManifestEntry[]; readonly entries: readonly CombatEffectManifestEntry[];
} }
const COMBAT_EFFECT_MANIFEST_URL = "/assets/game/combat-effects/manifest.json";
const manifestByKey = new Map<string, CombatEffectManifestEntry>(); const manifestByKey = new Map<string, CombatEffectManifestEntry>();
const manifestBySpellId = new Map<number, CombatEffectManifestEntry>(); const manifestBySpellId = new Map<number, CombatEffectManifestEntry>();
let manifestRequest: Promise<void> | null = null; let manifestRequest: Promise<void> | null = null;
@@ -207,13 +220,19 @@ function installCombatEffectManifest(manifest: GeneratedCombatEffectManifest): v
export function preloadCombatEffectManifest(): Promise<void> { export function preloadCombatEffectManifest(): Promise<void> {
if (manifestRequest) return manifestRequest; if (manifestRequest) return manifestRequest;
if (typeof fetch === "undefined") return Promise.resolve(); if (typeof fetch === "undefined") return Promise.resolve();
manifestRequest = fetch(COMBAT_EFFECT_MANIFEST_URL) manifestRequest = fetchCombatMediaAsset(COMBAT_MEDIA_MANIFEST_URL)
.then((response) => response.ok ? response.json() : Promise.reject(new Error(String(response.status)))) .then((response) => response.ok ? response.json() : Promise.reject(new Error(String(response.status))))
.then((manifest) => installCombatEffectManifest(manifest as GeneratedCombatEffectManifest)) .then((manifest) => installCombatEffectManifest(manifest as GeneratedCombatEffectManifest))
.catch(() => undefined); .catch(() => undefined);
return manifestRequest; return manifestRequest;
} }
export function effectiveCombatVfxMode(settings: CombatPresentationSettings): CombatVfxMode {
return settings.originalMediaEnabled && combatMediaPackReady()
? settings.vfxMode
: "generated";
}
export function combatEffectManifestEntry( export function combatEffectManifestEntry(
spellId: number | undefined, spellId: number | undefined,
source?: CombatPresentationSource, source?: CombatPresentationSource,
+40
View File
@@ -83,12 +83,14 @@ import {
} from "./talentModifiers"; } from "./talentModifiers";
import { import {
getMobPosition, getMobPosition,
getMobRuntimeTransform,
mobRuntimeDistanceSquared, mobRuntimeDistanceSquared,
nearbyMobRuntimeIds, nearbyMobRuntimeIds,
removeMobPosition, removeMobPosition,
setMobPosition, setMobPosition,
type CombatPosition, type CombatPosition,
} from "./mobRuntimeRegistry"; } from "./mobRuntimeRegistry";
import { enemyTelegraphContainsPosition } from "./enemyTelegraphGeometry";
import { import {
getPartyRuntimePosition, getPartyRuntimePosition,
partyRuntimeDistanceSquared, partyRuntimeDistanceSquared,
@@ -299,6 +301,9 @@ export interface EnemyActiveCast {
readonly school: EnemyDamageSchool; readonly school: EnemyDamageSchool;
readonly interruptible: boolean; readonly interruptible: boolean;
readonly targetActorId: AggroActorId | null; readonly targetActorId: AggroActorId | null;
readonly sourcePosition: CombatPosition;
readonly targetPosition: CombatPosition;
readonly sourceYaw: number;
readonly startedAt: number; readonly startedAt: number;
readonly completesAt: number; readonly completesAt: number;
} }
@@ -6163,6 +6168,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
); );
if (selectedAttack && startingEnemyCast) { if (selectedAttack && startingEnemyCast) {
const attack = selectedAttack; const attack = selectedAttack;
const sourceTransform = getMobRuntimeTransform(id);
enemyActiveCast = { enemyActiveCast = {
attackId: attack.id, attackId: attack.id,
name: attack.name, name: attack.name,
@@ -6170,6 +6176,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
school: attack.school, school: attack.school,
interruptible: attack.interruptible !== false, interruptible: attack.interruptible !== false,
targetActorId: resolution.targetActorId, targetActorId: resolution.targetActorId,
sourcePosition: [...position],
targetPosition: [...targetPosition],
sourceYaw: sourceTransform?.yaw
?? Math.atan2(targetPosition[0] - position[0], targetPosition[2] - position[2]),
startedAt: now, startedAt: now,
completesAt: now + (attack.castTimeMs ?? 0), completesAt: now + (attack.castTimeMs ?? 0),
}; };
@@ -6187,6 +6197,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
); );
} else if (selectedAttack && !cannotAttack) { } else if (selectedAttack && !cannotAttack) {
const attack = selectedAttack!; const attack = selectedAttack!;
const resolvedEnemyCast = enemyActiveCast;
enemyActiveCast = null; enemyActiveCast = null;
const effect = attack.effect ?? { const effect = attack.effect ?? {
kind: "damage" as const, kind: "damage" as const,
@@ -6198,10 +6209,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
stableCombatUnit(`${id}:${attack.id}:${attackRevision}:target`) * eligibleActors.length, stableCombatUnit(`${id}:${attack.id}:${attackRevision}:target`) * eligibleActors.length,
)] )]
: resolution.targetActorId; : resolution.targetActorId;
const telegraphPlacement = resolvedEnemyCast
? {
sourcePosition: resolvedEnemyCast.sourcePosition,
targetPosition: resolvedEnemyCast.targetPosition,
sourceYaw: resolvedEnemyCast.sourceYaw,
}
: {
sourcePosition: position,
targetPosition,
sourceYaw: Math.atan2(targetPosition[0] - position[0], targetPosition[2] - position[2]),
};
const unvalidatedTargets = attack.target === "nearby-party" const unvalidatedTargets = attack.target === "nearby-party"
? eligibleActors.filter((actorId) => { ? eligibleActors.filter((actorId) => {
const candidate = actorPosition(runtimeState, actorId); const candidate = actorPosition(runtimeState, actorId);
if (!candidate) return false; if (!candidate) return false;
if (attack.telegraph) {
return enemyTelegraphContainsPosition(attack.telegraph, telegraphPlacement, candidate);
}
const radius = attack.radius ?? Math.max(1, attack.range * 0.35); const radius = attack.radius ?? Math.max(1, attack.range * 0.35);
const targetDx = candidate[0] - position[0]; const targetDx = candidate[0] - position[0];
const targetDy = candidate[1] - position[1]; const targetDy = candidate[1] - position[1];
@@ -6298,6 +6323,21 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetId: id, targetId: id,
now, now,
}).auras]; }).auras];
} else if (effect.kind === "target-aura") {
for (const actorId of targets) {
namedAuras = [...applyCombatAura(namedAuras, {
definition: {
id: `enemy-debuff:${id}:${attack.id}:${effect.aura}`,
name: `${attack.name}: ${effect.aura}`,
disposition: "debuff",
durationMs: effect.durationMs > 0 ? effect.durationMs : null,
dispelCategory: "magic",
},
sourceId: id,
targetId: actorId,
now,
}).auras];
}
} else if (effect.kind === "control") { } else if (effect.kind === "control") {
for (const actorId of targets) { for (const actorId of targets) {
if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue; if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue;
+44
View File
@@ -167,6 +167,7 @@ describe("generated dungeon campaign catalog", () => {
"Fear", "Fear",
"Magic's Bane", "Magic's Bane",
"Corrupt Soul", "Corrupt Soul",
"Soulstorm",
]); ]);
expect(attackNames(culling!, "Mal'Ganis")).toEqual([ expect(attackNames(culling!, "Mal'Ganis")).toEqual([
"Basic Attack", "Basic Attack",
@@ -204,6 +205,49 @@ describe("generated dungeon campaign catalog", () => {
}); });
}); });
it("compiles Archaedas's timed and health-based encounter mechanics", () => {
const uldaman = generated.definitions.find((dungeon) => dungeon.id === "uldaman")!;
const archaedas = Object.values(uldaman.entities).find(
(entity) => entity.name === "Archaedas",
) as unknown as {
readonly combat: {
readonly mechanicIds: readonly string[];
readonly attacks: ReadonlyArray<{
readonly id: string;
readonly initialCooldownMs?: readonly [number, number];
readonly repeatCooldownMs?: readonly [number, number];
readonly condition?: { readonly kind: string; readonly maximum?: number };
readonly effect?: { readonly kind: string; readonly creatureEntry?: number; readonly count?: number };
readonly telegraph?: { readonly shape: string; readonly anchor: string; readonly radius?: number };
}>;
};
};
expect(archaedas.combat.mechanicIds).toEqual([
"archaedas-awaken-earthen-custodian",
"archaedas-ground-tremor",
"archaedas-awaken-earthen-guardians",
"archaedas-awaken-vault-warders",
]);
expect(archaedas.combat.attacks.find((attack) => attack.id === "archaedas-ground-tremor"))
.toMatchObject({
initialCooldownMs: [0, 10_000],
repeatCooldownMs: [14_000, 20_000],
telegraph: { shape: "circle", anchor: "source", radius: 18 },
});
expect(archaedas.combat.attacks.find(
(attack) => attack.id === "archaedas-awaken-earthen-guardians",
)).toMatchObject({
condition: { kind: "self-health-percent", maximum: 70 },
effect: { kind: "summon", creatureEntry: 7076, count: 6 },
});
expect(archaedas.combat.attacks.find(
(attack) => attack.id === "archaedas-awaken-vault-warders",
)).toMatchObject({
condition: { kind: "self-health-percent", maximum: 40 },
effect: { kind: "summon", creatureEntry: 10120, count: 2 },
});
});
it("does not leak outdoor zones, arenas, or raids into the dungeon browser", () => { it("does not leak outdoor zones, arenas, or raids into the dungeon browser", () => {
const ids = new Set(generated.definitions.map((dungeon) => dungeon.id)); const ids = new Set(generated.definitions.map((dungeon) => dungeon.id));
for (const excluded of [ for (const excluded of [
+114
View File
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it } from "vitest";
import { encounterTimeline } from "./encounterTimeline";
import { useCombatStore } from "./combatStore";
describe("encounter mechanic timeline", () => {
beforeEach(() => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 40 });
useCombatStore.getState().registerMob("boss", {
name: "Timeline Boss",
boss: true,
maxHealth: 1_000,
attacks: [{
id: "timed-quake",
name: "Timed Quake",
delivery: "area",
target: "nearby-party",
school: "nature",
animation: "cast",
range: 20,
radius: 10,
cooldownMs: 15_000,
repeatCooldownMs: [12_000, 18_000],
damageMultiplier: 1,
}, {
id: "health-adds",
name: "Awaken Adds",
delivery: "area",
target: "self",
school: "physical",
animation: "cast",
range: 0,
cooldownMs: 1_000,
maximumCasts: 1,
damageMultiplier: 0,
effect: { kind: "summon", creatureEntry: 1, count: 2 },
condition: { kind: "self-health-percent", minimum: 0, maximum: 70 },
}],
});
useCombatStore.setState((state) => ({
mobs: {
...state.mobs,
boss: {
...state.mobs.boss,
engaged: true,
abilityReadyAt: { "timed-quake": 16_000, "health-adds": 1_000 },
},
},
}));
});
it("shows both timer countdowns and pending health thresholds", () => {
const model = encounterTimeline(useCombatStore.getState().mobs, 10_000);
expect(model?.bossName).toBe("Timeline Boss");
expect(model?.rows.map((row) => [row.name, row.kind, row.value])).toEqual([
["Timed Quake", "timer", "6.0s"],
["Awaken Adds", "health", "70% HP"],
]);
});
it("promotes an active cast and hides a completed one-shot threshold", () => {
useCombatStore.setState((state) => ({
mobs: {
...state.mobs,
boss: {
...state.mobs.boss,
abilityCastCounts: { "health-adds": 1 },
activeCast: {
attackId: "timed-quake",
name: "Timed Quake",
school: "nature",
interruptible: true,
targetActorId: "__player__",
sourcePosition: [0, 0, 0],
targetPosition: [0, 0, 5],
sourceYaw: 0,
startedAt: 10_000,
completesAt: 12_000,
},
},
},
}));
expect(encounterTimeline(useCombatStore.getState().mobs, 11_250)?.rows).toMatchObject([{
name: "Timed Quake",
kind: "casting",
value: "0.8s",
}]);
});
it("keeps every pending health threshold visible beyond the countdown row limit", () => {
const boss = useCombatStore.getState().mobs.boss;
const extraTimers = Array.from({ length: 5 }, (_, index) => ({
...boss.attacks[0],
id: `extra-timer-${index}`,
name: `Extra Timer ${index}`,
}));
useCombatStore.setState((state) => ({
mobs: {
...state.mobs,
boss: {
...state.mobs.boss,
attacks: [...state.mobs.boss.attacks, ...extraTimers],
abilityReadyAt: {
...state.mobs.boss.abilityReadyAt,
...Object.fromEntries(extraTimers.map((attack, index) => [attack.id, 11_000 + index * 1_000])),
},
},
},
}));
const rows = encounterTimeline(useCombatStore.getState().mobs, 10_000, 4)?.rows ?? [];
expect(rows.filter((row) => row.kind === "timer")).toHaveLength(4);
expect(rows.filter((row) => row.kind === "health").map((row) => row.value)).toEqual(["70% HP"]);
});
});
+99
View File
@@ -0,0 +1,99 @@
import type { MobCombatState } from "./combatStore";
import type { EnemyDamageSchool, EnemyTelegraphShape } from "./enemyAttacks";
export type EncounterTimelineKind = "casting" | "timer" | "health";
export interface EncounterTimelineRow {
readonly id: string;
readonly name: string;
readonly kind: EncounterTimelineKind;
readonly value: string;
readonly progress: number;
readonly sortAt: number;
readonly school: EnemyDamageSchool;
readonly telegraphShape?: EnemyTelegraphShape;
}
export interface EncounterTimelineModel {
readonly bossId: string;
readonly bossName: string;
readonly rows: readonly EncounterTimelineRow[];
}
function clampedProgress(value: number): number {
return Math.min(1, Math.max(0, value));
}
export function encounterTimeline(
mobs: Readonly<Record<string, MobCombatState>>,
now: number,
limit = 4,
): EncounterTimelineModel | null {
const boss = Object.values(mobs)
.filter((mob) => mob.boss && mob.engaged && !mob.dead && !mob.despawned)
.sort((left, right) => (
Number(Boolean(right.activeCast)) - Number(Boolean(left.activeCast))
|| left.id.localeCompare(right.id)
))[0];
if (!boss) return null;
const healthPercent = (boss.health / Math.max(1, boss.maxHealth)) * 100;
const rows = boss.attacks.flatMap<EncounterTimelineRow>((attack) => {
if (attack.id === "basic-attack") return [];
const castCount = boss.abilityCastCounts[attack.id] ?? 0;
if (attack.maximumCasts && castCount >= attack.maximumCasts) return [];
const common = {
id: attack.id,
name: attack.name,
school: attack.school,
...(attack.telegraph ? { telegraphShape: attack.telegraph.shape } : {}),
};
if (boss.activeCast?.attackId === attack.id) {
const duration = Math.max(1, boss.activeCast.completesAt - boss.activeCast.startedAt);
const remaining = Math.max(0, boss.activeCast.completesAt - now);
return [{
...common,
kind: "casting",
value: `${(remaining / 1_000).toFixed(1)}s`,
progress: clampedProgress(remaining / duration),
sortAt: -1,
}];
}
if (attack.condition?.kind === "self-health-percent") {
const threshold = attack.condition.maximum;
return [{
...common,
kind: "health",
value: `${Math.round(threshold)}% HP`,
progress: clampedProgress(healthPercent / Math.max(1, threshold)),
sortAt: 1_000_000 + (100 - threshold) * 1_000,
}];
}
const readyAt = boss.abilityReadyAt[attack.id];
if (!Number.isFinite(readyAt)) return [];
const remaining = Math.max(0, (readyAt ?? now) - now);
const cooldown = Math.max(
1,
attack.repeatCooldownMs?.[1] ?? attack.initialCooldownMs?.[1] ?? attack.cooldownMs,
);
return [{
...common,
kind: "timer",
value: `${(remaining / 1_000).toFixed(1)}s`,
progress: clampedProgress(remaining / cooldown),
sortAt: remaining,
}];
}).sort((left, right) => left.sortAt - right.sortAt || left.id.localeCompare(right.id));
const alwaysVisibleHealthRows = rows.filter((row) => row.kind === "health");
const countdownRows = rows
.filter((row) => row.kind !== "health")
.slice(0, Math.max(1, limit));
const visibleRows = [...countdownRows, ...alwaysVisibleHealthRows];
return visibleRows.length ? {
bossId: boss.id,
bossName: boss.name,
rows: visibleRows,
} : null;
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { normalizeEnemyAttack, type EnemyAttackDefinition } from "./enemyAttacks";
function attack(overrides: Partial<EnemyAttackDefinition>): EnemyAttackDefinition {
return {
id: "test-special",
name: "Test Special",
delivery: "area",
target: "nearby-party",
school: "physical",
animation: "attack",
range: 12,
cooldownMs: 10_000,
damageMultiplier: 1,
...overrides,
};
}
describe("enemy attack telegraphs", () => {
it("infers a warned cone for legacy frontal mechanics", () => {
const normalized = normalizeEnemyAttack(attack({ name: "Stone Cleave" }));
expect(normalized.telegraph).toMatchObject({
shape: "cone",
anchor: "source",
warningMs: 1_650,
});
expect(normalized.castTimeMs).toBe(1_650);
});
it("preserves authored geometry and extends too-short cast warnings", () => {
const normalized = normalizeEnemyAttack(attack({
name: "Falling Ruin",
castTimeMs: 500,
telegraph: {
shape: "circle",
anchor: "target",
warningMs: 2_200,
radius: 7,
},
}));
expect(normalized.castTimeMs).toBe(2_200);
expect(normalized.telegraph).toEqual({
shape: "circle",
anchor: "target",
warningMs: 2_200,
radius: 7,
});
});
it("does not telegraph basic attacks or beneficial mechanics", () => {
expect(normalizeEnemyAttack(attack({ id: "basic-attack" })).telegraph).toBeUndefined();
expect(normalizeEnemyAttack(attack({
effect: { kind: "heal", multiplier: 1 },
})).telegraph).toBeUndefined();
});
});
+106 -2
View File
@@ -17,6 +17,21 @@ export type EnemyDamageSchool =
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun" | "silence"; export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun" | "silence";
export type EnemyTelegraphShape = "circle" | "cone" | "line";
export type EnemyTelegraphAnchor = "source" | "target";
/** World-space warning geometry shared by simulation and Three presentation. */
export interface EnemyAttackTelegraph {
readonly shape: EnemyTelegraphShape;
readonly anchor: EnemyTelegraphAnchor;
/** Minimum warning time before the mechanic resolves. */
readonly warningMs: number;
readonly radius?: number;
readonly length?: number;
readonly width?: number;
readonly angleDegrees?: number;
}
export type EnemyAbilityEffect = export type EnemyAbilityEffect =
| { readonly kind: "damage"; readonly multiplier: number } | { readonly kind: "damage"; readonly multiplier: number }
| { readonly kind: "heal"; readonly multiplier: number } | { readonly kind: "heal"; readonly multiplier: number }
@@ -26,6 +41,11 @@ export type EnemyAbilityEffect =
readonly durationMs: number; readonly durationMs: number;
readonly magnitude?: number; readonly magnitude?: number;
} }
| {
readonly kind: "target-aura";
readonly aura: string;
readonly durationMs: number;
}
| { | {
readonly kind: "control"; readonly kind: "control";
readonly mechanic: EnemyControlMechanic; readonly mechanic: EnemyControlMechanic;
@@ -83,6 +103,7 @@ export interface EnemyAttackDefinition {
readonly maximumCasts?: number; readonly maximumCasts?: number;
readonly damageMultiplier: number; readonly damageMultiplier: number;
readonly radius?: number; readonly radius?: number;
readonly telegraph?: EnemyAttackTelegraph;
readonly effect?: EnemyAbilityEffect; readonly effect?: EnemyAbilityEffect;
readonly condition?: EnemyAbilityCondition; readonly condition?: EnemyAbilityCondition;
readonly source?: EnemyAbilitySource; readonly source?: EnemyAbilitySource;
@@ -96,6 +117,8 @@ export interface EnemyCombatDefinition {
readonly moveSpeed?: number; readonly moveSpeed?: number;
readonly aggroRange?: number; readonly aggroRange?: number;
readonly leashRange?: number; readonly leashRange?: number;
/** Stable special-attack ids available for future encounter loadout randomization. */
readonly mechanicIds?: readonly string[];
readonly attacks: readonly EnemyAttackDefinition[]; readonly attacks: readonly EnemyAttackDefinition[];
} }
@@ -125,14 +148,94 @@ function normalizeCooldownRange(
return Object.freeze([Math.round(lower), Math.round(upper)]); return Object.freeze([Math.round(lower), Math.round(upper)]);
} }
function harmfulEnemyAttack(value: EnemyAttackDefinition): boolean {
return !value.effect
|| value.effect.kind === "damage"
|| value.effect.kind === "control"
|| value.effect.kind === "target-aura";
}
/**
* Legacy/imported encounters receive safe defaults while authored mechanics can
* override every dimension in the modular mechanic catalog.
*/
export function inferEnemyAttackTelegraph(
value: EnemyAttackDefinition,
): EnemyAttackTelegraph | undefined {
if (value.id === "basic-attack" || !harmfulEnemyAttack(value)) return undefined;
const radius = value.radius ?? Math.max(3, value.range * 0.35);
if (/\b(?:breath|cleave|frontal|sweep|cone)\b/i.test(value.name)) {
return {
shape: "cone",
anchor: "source",
warningMs: 1_650,
length: Math.max(5.5, value.range),
angleDegrees: /breath/i.test(value.name) ? 70 : 95,
};
}
if (/\b(?:beam|charge|line|lance)\b/i.test(value.name)) {
return {
shape: "line",
anchor: "source",
warningMs: 1_650,
length: Math.max(7, value.range),
width: Math.max(2, radius * 0.45),
};
}
if (value.delivery === "area" || value.radius !== undefined) {
const targetAnchored = /\b(?:blizzard|rain|barrage|volley|eruption|ground|meteor)\b/i
.test(value.name) && value.target !== "self";
return {
shape: "circle",
anchor: targetAnchored ? "target" : "source",
warningMs: 1_650,
radius,
};
}
return undefined;
}
function normalizeTelegraph(value: EnemyAttackTelegraph): EnemyAttackTelegraph {
const warningMs = Math.round(finiteAtLeast(value.warningMs, 1_650, 250));
if (value.shape === "circle") {
return Object.freeze({
shape: value.shape,
anchor: value.anchor,
warningMs,
radius: finiteAtLeast(value.radius, 4, 0.5),
});
}
if (value.shape === "cone") {
return Object.freeze({
shape: value.shape,
anchor: "source",
warningMs,
length: finiteAtLeast(value.length, 8, 1),
angleDegrees: Math.min(180, finiteAtLeast(value.angleDegrees, 90, 5)),
});
}
return Object.freeze({
shape: value.shape,
anchor: "source",
warningMs,
length: finiteAtLeast(value.length, 8, 1),
width: finiteAtLeast(value.width, 3, 0.5),
});
}
export function normalizeEnemyAttack( export function normalizeEnemyAttack(
value: EnemyAttackDefinition, value: EnemyAttackDefinition,
fallback: EnemyAttackDefinition = DEFAULT_ENEMY_ATTACK, fallback: EnemyAttackDefinition = DEFAULT_ENEMY_ATTACK,
): EnemyAttackDefinition { ): EnemyAttackDefinition {
const cooldownMs = Math.round(finiteAtLeast(value.cooldownMs, fallback.cooldownMs, 1)); const cooldownMs = Math.round(finiteAtLeast(value.cooldownMs, fallback.cooldownMs, 1));
const telegraph = value.telegraph ?? inferEnemyAttackTelegraph(value);
const inferredCastTimeMs = value.castTimeMs === undefined && value.animation === "cast" const inferredCastTimeMs = value.castTimeMs === undefined && value.animation === "cast"
? (fallback.castTimeMs ?? 1_500) ? (fallback.castTimeMs ?? 1_500)
: (fallback.castTimeMs ?? 0); : telegraph?.warningMs ?? fallback.castTimeMs ?? 0;
const castTimeMs = Math.max(
Math.round(finiteAtLeast(value.castTimeMs, inferredCastTimeMs, 0)),
telegraph?.warningMs ?? 0,
);
return Object.freeze({ return Object.freeze({
id: value.id.trim() || fallback.id, id: value.id.trim() || fallback.id,
name: value.name.trim() || fallback.name, name: value.name.trim() || fallback.name,
@@ -143,7 +246,7 @@ export function normalizeEnemyAttack(
target: value.target, target: value.target,
school: value.school, school: value.school,
animation: value.animation, animation: value.animation,
castTimeMs: Math.round(finiteAtLeast(value.castTimeMs, inferredCastTimeMs, 0)), castTimeMs,
interruptible: value.interruptible ?? fallback.interruptible ?? true, interruptible: value.interruptible ?? fallback.interruptible ?? true,
range: finiteAtLeast(value.range, fallback.range, 0), range: finiteAtLeast(value.range, fallback.range, 0),
cooldownMs, cooldownMs,
@@ -160,6 +263,7 @@ export function normalizeEnemyAttack(
...(value.radius !== undefined ...(value.radius !== undefined
? { radius: finiteAtLeast(value.radius, 0, 0) } ? { radius: finiteAtLeast(value.radius, 0, 0) }
: {}), : {}),
...(telegraph ? { telegraph: normalizeTelegraph(telegraph) } : {}),
...(value.effect ? { effect: value.effect } : {}), ...(value.effect ? { effect: value.effect } : {}),
...(value.condition ? { condition: value.condition } : {}), ...(value.condition ? { condition: value.condition } : {}),
...(value.source ? { source: value.source } : {}), ...(value.source ? { source: value.source } : {}),
+37
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { import {
deterministicEnemyCooldown, deterministicEnemyCooldown,
useCombatStore, useCombatStore,
@@ -171,6 +172,42 @@ describe("server-backed enemy ability runtime", () => {
expect(summonIds.every((id) => getMobPosition(id) === null)).toBe(true); expect(summonIds.every((id) => getMobPosition(id) === null)).toBe(true);
}); });
it("applies a modular target aura as a dispellable player debuff", () => {
useCombatStore.getState().registerMob("runewaker-debuffer", {
name: "RuneWaker Debuffer",
maxHealth: 1_000,
attacks: [{
id: "conductivity",
name: "Conductivity",
delivery: "projectile",
target: "primary",
school: "arcane",
animation: "cast",
castTimeMs: 1_000,
range: 20,
cooldownMs: 10_000,
initialCooldownMs: [0, 0],
repeatCooldownMs: [10_000, 10_000],
damageMultiplier: 0,
effect: { kind: "target-aura", aura: "conductivity", durationMs: 6_000 },
}],
});
setMobPosition("runewaker-debuffer", [2, 0, 0]);
useCombatStore.getState().damageMob("runewaker-debuffer", 1, 1_000);
useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0]);
useCombatStore.getState().advanceMobCombat(2_000, [0, 0, 0]);
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
sourceId: "runewaker-debuffer",
targetId: PLAYER_AGGRO_ID,
expiresAt: 8_000,
definition: expect.objectContaining({
disposition: "debuff",
dispelCategory: "magic",
}),
}));
});
it("honors encounter-state conditions before selecting an ability", () => { it("honors encounter-state conditions before selecting an ability", () => {
useWailingEncounterStore.getState().reset(); useWailingEncounterStore.getState().reset();
useCombatStore.getState().registerMob("event-caster", { useCombatStore.getState().registerMob("event-caster", {
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { enemyTelegraphContainsPosition } from "./enemyTelegraphGeometry";
const placement = {
sourcePosition: [0, 0, 0] as const,
targetPosition: [0, 0, 10] as const,
sourceYaw: 0,
};
describe("enemy telegraph gameplay geometry", () => {
it("matches source and target anchored circles", () => {
expect(enemyTelegraphContainsPosition({
shape: "circle", anchor: "source", warningMs: 1_000, radius: 4,
}, placement, [3, 0, 0])).toBe(true);
expect(enemyTelegraphContainsPosition({
shape: "circle", anchor: "target", warningMs: 1_000, radius: 3,
}, placement, [0, 0, 8])).toBe(true);
expect(enemyTelegraphContainsPosition({
shape: "circle", anchor: "target", warningMs: 1_000, radius: 3,
}, placement, [0, 0, 4])).toBe(false);
});
it("keeps cone hits in front and within the visible angle", () => {
const cone = {
shape: "cone" as const,
anchor: "source" as const,
warningMs: 1_000,
length: 10,
angleDegrees: 90,
};
expect(enemyTelegraphContainsPosition(cone, placement, [2, 0, 7])).toBe(true);
expect(enemyTelegraphContainsPosition(cone, placement, [8, 0, 2])).toBe(false);
expect(enemyTelegraphContainsPosition(cone, placement, [0, 0, -2])).toBe(false);
});
it("uses the visible line width for collision", () => {
const line = {
shape: "line" as const,
anchor: "source" as const,
warningMs: 1_000,
length: 12,
width: 4,
};
expect(enemyTelegraphContainsPosition(line, placement, [1.9, 0, 8])).toBe(true);
expect(enemyTelegraphContainsPosition(line, placement, [2.1, 0, 8])).toBe(false);
});
});
+51
View File
@@ -0,0 +1,51 @@
import type { EnemyAttackTelegraph } from "./enemyAttacks";
import type { CombatPosition } from "./mobRuntimeRegistry";
export interface EnemyTelegraphPlacement {
readonly sourcePosition: CombatPosition;
readonly targetPosition: CombatPosition;
readonly sourceYaw: number;
}
export function enemyTelegraphYaw(placement: EnemyTelegraphPlacement): number {
const dx = placement.targetPosition[0] - placement.sourcePosition[0];
const dz = placement.targetPosition[2] - placement.sourcePosition[2];
return dx * dx + dz * dz > 0.0001 ? Math.atan2(dx, dz) : placement.sourceYaw;
}
export function enemyTelegraphOrigin(
telegraph: EnemyAttackTelegraph,
placement: EnemyTelegraphPlacement,
): CombatPosition {
return telegraph.anchor === "target"
? placement.targetPosition
: placement.sourcePosition;
}
/** Matches the visible warning mesh in the X/Z combat plane. */
export function enemyTelegraphContainsPosition(
telegraph: EnemyAttackTelegraph,
placement: EnemyTelegraphPlacement,
candidate: CombatPosition,
): boolean {
const origin = enemyTelegraphOrigin(telegraph, placement);
const dx = candidate[0] - origin[0];
const dz = candidate[2] - origin[2];
if (telegraph.shape === "circle") {
const radius = telegraph.radius ?? 0;
return dx * dx + dz * dz <= radius * radius;
}
const yaw = enemyTelegraphYaw(placement);
const forwardX = Math.sin(yaw);
const forwardZ = Math.cos(yaw);
const forward = dx * forwardX + dz * forwardZ;
const side = Math.abs(dx * forwardZ - dz * forwardX);
const length = telegraph.length ?? 0;
if (forward < 0 || forward > length) return false;
if (telegraph.shape === "line") return side <= (telegraph.width ?? 0) / 2;
const distance = Math.hypot(dx, dz);
if (distance <= 0.0001) return true;
const halfAngle = ((telegraph.angleDegrees ?? 0) * Math.PI) / 360;
return Math.acos(Math.min(1, Math.max(-1, forward / distance))) <= halfAngle;
}
@@ -16,7 +16,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-aeternal-circle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -56,7 +56,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-aeternal-circle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -96,7 +96,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-aeternal-circle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -156,7 +156,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-aeternal-circle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -196,7 +196,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-aeternal-circle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -256,7 +256,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-aeternal-circle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -336,7 +336,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-aeternal-circle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -416,7 +416,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-aeternal-circle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -496,7 +496,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-aeternal-circle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -556,7 +556,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-aeternal-circle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -596,7 +596,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-aeternal-circle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -656,7 +656,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-aeternal-circle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -696,7 +696,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-aeternal-circle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -756,7 +756,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-aeternal-circle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -836,7 +836,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-aeternal-circle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -876,7 +876,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-aeternal-circle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -2303,6 +2303,6 @@ export const AETERNAL_CIRCLE_POPULATION_PROVENANCE = {
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values." "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values."
} as const; } as const;
File diff suppressed because it is too large. Load diff
@@ -16,7 +16,7 @@ export const BARREN_CAVES_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-barren-caves-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -76,7 +76,7 @@ export const BARREN_CAVES_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-barren-caves-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -136,7 +136,7 @@ export const BARREN_CAVES_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-barren-caves-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -176,7 +176,7 @@ export const BARREN_CAVES_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-barren-caves-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -216,7 +216,7 @@ export const BARREN_CAVES_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-barren-caves-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -296,7 +296,7 @@ export const BARREN_CAVES_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-barren-caves-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -355,9 +355,14 @@ export const BARREN_CAVES_ENTITIES = {
"damageMultiplier": 1.3, "damageMultiplier": 1.3,
"moveSpeed": 2.25, "moveSpeed": 2.25,
"leashRange": 32, "leashRange": 32,
"mechanicIds": [
"rw-barren-caves-101462-spell-492292",
"rw-barren-caves-101462-spell-492293",
"rw-barren-caves-101462-control-492294"
],
"attacks": [ "attacks": [
{ {
"id": "rw-barren-caves-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -368,13 +373,14 @@ export const BARREN_CAVES_ENTITIES = {
"damageMultiplier": 1 "damageMultiplier": 1
}, },
{ {
"id": "rw-barren-caves-spell-492292", "id": "rw-barren-caves-101462-spell-492292",
"name": "Trample", "name": "Trample",
"spellId": 492292, "spellId": 492292,
"delivery": "projectile", "delivery": "area",
"target": "primary", "target": "nearby-party",
"school": "physical", "school": "physical",
"animation": "cast", "animation": "cast",
"castTimeMs": 1800,
"range": 10, "range": 10,
"cooldownMs": 20000, "cooldownMs": 20000,
"initialCooldownMs": [ "initialCooldownMs": [
@@ -385,16 +391,24 @@ export const BARREN_CAVES_ENTITIES = {
20000, 20000,
20000 20000
], ],
"damageMultiplier": 0.85 "damageMultiplier": 0.85,
"radius": 5,
"telegraph": {
"shape": "circle",
"anchor": "source",
"warningMs": 1800,
"radius": 5
}
}, },
{ {
"id": "rw-barren-caves-spell-492293", "id": "rw-barren-caves-101462-spell-492293",
"name": "Bash", "name": "Bash",
"spellId": 492293, "spellId": 492293,
"delivery": "melee", "delivery": "area",
"target": "primary", "target": "nearby-party",
"school": "physical", "school": "physical",
"animation": "attack", "animation": "attack",
"castTimeMs": 1800,
"range": 5, "range": 5,
"cooldownMs": 20000, "cooldownMs": 20000,
"initialCooldownMs": [ "initialCooldownMs": [
@@ -405,7 +419,46 @@ export const BARREN_CAVES_ENTITIES = {
20000, 20000,
20000 20000
], ],
"damageMultiplier": 0.85 "damageMultiplier": 0.85,
"radius": 5,
"telegraph": {
"shape": "circle",
"anchor": "source",
"warningMs": 1800,
"radius": 5
}
},
{
"id": "rw-barren-caves-101462-control-492294",
"name": "Gulp",
"delivery": "area",
"target": "nearby-party",
"school": "shadow",
"animation": "cast",
"castTimeMs": 1800,
"range": 15,
"cooldownMs": 20000,
"initialCooldownMs": [
20000,
20000
],
"repeatCooldownMs": [
20000,
20000
],
"damageMultiplier": 0,
"radius": 5,
"telegraph": {
"shape": "circle",
"anchor": "source",
"warningMs": 1800,
"radius": 5
},
"effect": {
"kind": "control",
"mechanic": "stun",
"durationMs": 3000
}
} }
] ]
}, },
@@ -1009,6 +1062,6 @@ export const BARREN_CAVES_POPULATION_PROVENANCE = {
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values." "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values."
} as const; } as const;
@@ -16,7 +16,7 @@ export const CAVE_WATER_DRAGON_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-cave-water-dragon-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -56,7 +56,7 @@ export const CAVE_WATER_DRAGON_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-cave-water-dragon-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -116,7 +116,7 @@ export const CAVE_WATER_DRAGON_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-cave-water-dragon-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -176,7 +176,7 @@ export const CAVE_WATER_DRAGON_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-cave-water-dragon-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -216,7 +216,7 @@ export const CAVE_WATER_DRAGON_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-cave-water-dragon-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -255,9 +255,18 @@ export const CAVE_WATER_DRAGON_ENTITIES = {
"damageMultiplier": 1.3, "damageMultiplier": 1.3,
"moveSpeed": 2.25, "moveSpeed": 2.25,
"leashRange": 32, "leashRange": 32,
"mechanicIds": [
"rw-cave-water-dragon-102623-spell-494249",
"rw-cave-water-dragon-102623-spell-494250",
"rw-cave-water-dragon-102623-spell-494253",
"rw-cave-water-dragon-102623-spell-494254",
"rw-cave-water-dragon-102623-debuff-494297",
"rw-cave-water-dragon-102623-debuff-494298",
"rw-cave-water-dragon-102623-phase-45"
],
"attacks": [ "attacks": [
{ {
"id": "rw-cave-water-dragon-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -266,6 +275,193 @@ export const CAVE_WATER_DRAGON_ENTITIES = {
"range": 4.5, "range": 4.5,
"cooldownMs": 1900, "cooldownMs": 1900,
"damageMultiplier": 1 "damageMultiplier": 1
},
{
"id": "rw-cave-water-dragon-102623-spell-494249",
"name": "Manastorms Duo Lv5",
"spellId": 494249,
"delivery": "projectile",
"target": "primary",
"school": "arcane",
"animation": "cast",
"castTimeMs": 0,
"range": 30,
"cooldownMs": 6500,
"initialCooldownMs": [
6500,
6500
],
"repeatCooldownMs": [
6500,
6500
],
"damageMultiplier": 0.85
},
{
"id": "rw-cave-water-dragon-102623-spell-494250",
"name": "Manastorms Duo Lv5",
"spellId": 494250,
"delivery": "projectile",
"target": "primary",
"school": "arcane",
"animation": "cast",
"castTimeMs": 0,
"range": 30,
"cooldownMs": 6500,
"initialCooldownMs": [
6500,
6500
],
"repeatCooldownMs": [
6500,
6500
],
"damageMultiplier": 0.85
},
{
"id": "rw-cave-water-dragon-102623-spell-494253",
"name": "Water Dragon Bomb",
"spellId": 494253,
"delivery": "projectile",
"target": "primary",
"school": "frost",
"animation": "cast",
"castTimeMs": 2000,
"range": 30,
"cooldownMs": 6500,
"initialCooldownMs": [
6500,
6500
],
"repeatCooldownMs": [
6500,
6500
],
"damageMultiplier": 0.85
},
{
"id": "rw-cave-water-dragon-102623-spell-494254",
"name": "Dragon Attack",
"spellId": 494254,
"delivery": "area",
"target": "nearby-party",
"school": "physical",
"animation": "cast",
"castTimeMs": 1800,
"range": 15,
"cooldownMs": 6500,
"initialCooldownMs": [
6500,
6500
],
"repeatCooldownMs": [
6500,
6500
],
"damageMultiplier": 0.85,
"radius": 10,
"telegraph": {
"shape": "circle",
"anchor": "source",
"warningMs": 1800,
"radius": 10
}
},
{
"id": "rw-cave-water-dragon-102623-debuff-494297",
"name": "Law of the Dragons",
"delivery": "area",
"target": "nearby-party",
"school": "arcane",
"animation": "cast",
"castTimeMs": 2000,
"range": 30,
"cooldownMs": 12000,
"initialCooldownMs": [
12000,
12000
],
"repeatCooldownMs": [
12000,
12000
],
"damageMultiplier": 0,
"radius": 15,
"telegraph": {
"shape": "circle",
"anchor": "source",
"warningMs": 1800,
"radius": 15
},
"effect": {
"kind": "target-aura",
"aura": "confusion",
"durationMs": 6000
}
},
{
"id": "rw-cave-water-dragon-102623-debuff-494298",
"name": "Law of the Dragons",
"delivery": "area",
"target": "nearby-party",
"school": "arcane",
"animation": "cast",
"castTimeMs": 2000,
"range": 30,
"cooldownMs": 12000,
"initialCooldownMs": [
12000,
12000
],
"repeatCooldownMs": [
12000,
12000
],
"damageMultiplier": 0,
"radius": 15,
"telegraph": {
"shape": "circle",
"anchor": "source",
"warningMs": 1800,
"radius": 15
},
"effect": {
"kind": "target-aura",
"aura": "confusion",
"durationMs": 6000
}
},
{
"id": "rw-cave-water-dragon-102623-phase-45",
"name": "Phase Change",
"delivery": "area",
"target": "self",
"school": "arcane",
"animation": "cast",
"castTimeMs": 1200,
"interruptible": false,
"range": 0,
"cooldownMs": 1000,
"initialCooldownMs": [
0,
0
],
"repeatCooldownMs": [
1000,
1000
],
"maximumCasts": 1,
"damageMultiplier": 0,
"effect": {
"kind": "aura",
"aura": "phase-45",
"durationMs": 5000
},
"condition": {
"kind": "self-health-percent",
"minimum": 0,
"maximum": 45
}
} }
] ]
}, },
@@ -296,9 +492,15 @@ export const CAVE_WATER_DRAGON_ENTITIES = {
"damageMultiplier": 1.3, "damageMultiplier": 1.3,
"moveSpeed": 2.25, "moveSpeed": 2.25,
"leashRange": 32, "leashRange": 32,
"mechanicIds": [
"rw-cave-water-dragon-104161-spell-493996",
"rw-cave-water-dragon-104161-spell-493999",
"rw-cave-water-dragon-104161-spell-494000",
"rw-cave-water-dragon-104161-aura-494386"
],
"attacks": [ "attacks": [
{ {
"id": "rw-cave-water-dragon-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -307,6 +509,115 @@ export const CAVE_WATER_DRAGON_ENTITIES = {
"range": 4.5, "range": 4.5,
"cooldownMs": 1900, "cooldownMs": 1900,
"damageMultiplier": 1 "damageMultiplier": 1
},
{
"id": "rw-cave-water-dragon-104161-spell-493996",
"name": "Water Spirit Attack",
"spellId": 493996,
"delivery": "area",
"target": "nearby-party",
"school": "frost",
"animation": "cast",
"castTimeMs": 2000,
"range": 15,
"cooldownMs": 6500,
"initialCooldownMs": [
6500,
6500
],
"repeatCooldownMs": [
6500,
6500
],
"damageMultiplier": 0.85,
"radius": 15,
"telegraph": {
"shape": "circle",
"anchor": "source",
"warningMs": 1800,
"radius": 15
}
},
{
"id": "rw-cave-water-dragon-104161-spell-493999",
"name": "Wave Bomb",
"spellId": 493999,
"delivery": "area",
"target": "nearby-party",
"school": "frost",
"animation": "cast",
"castTimeMs": 2000,
"range": 20,
"cooldownMs": 6500,
"initialCooldownMs": [
6500,
6500
],
"repeatCooldownMs": [
6500,
6500
],
"damageMultiplier": 0.85,
"radius": 15,
"telegraph": {
"shape": "circle",
"anchor": "source",
"warningMs": 1800,
"radius": 15
}
},
{
"id": "rw-cave-water-dragon-104161-spell-494000",
"name": "Wave Strike",
"spellId": 494000,
"delivery": "area",
"target": "nearby-party",
"school": "frost",
"animation": "cast",
"castTimeMs": 2000,
"range": 20,
"cooldownMs": 6500,
"initialCooldownMs": [
6500,
6500
],
"repeatCooldownMs": [
6500,
6500
],
"damageMultiplier": 0.85,
"radius": 15,
"telegraph": {
"shape": "circle",
"anchor": "source",
"warningMs": 1800,
"radius": 15
}
},
{
"id": "rw-cave-water-dragon-104161-aura-494386",
"name": "Soul of the Water Dragon",
"delivery": "area",
"target": "self",
"school": "arcane",
"animation": "cast",
"castTimeMs": 1200,
"range": 0,
"cooldownMs": 15000,
"initialCooldownMs": [
15000,
15000
],
"repeatCooldownMs": [
15000,
15000
],
"damageMultiplier": 0,
"effect": {
"kind": "aura",
"aura": "soul-of-the-water-dragon",
"durationMs": 7500
}
} }
] ]
}, },
@@ -1458,6 +1769,6 @@ export const CAVE_WATER_DRAGON_POPULATION_PROVENANCE = {
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values." "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values."
} as const; } as const;
@@ -48,6 +48,6 @@ export const DEMON_STRONGHOLD_125_POPULATION_PROVENANCE = {
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values." "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values."
} as const; } as const;
@@ -16,7 +16,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-demon-stronghold-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -76,7 +76,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-demon-stronghold-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -116,7 +116,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-demon-stronghold-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -156,7 +156,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-demon-stronghold-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -236,7 +236,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-demon-stronghold-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -336,7 +336,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-demon-stronghold-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -396,7 +396,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-demon-stronghold-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -436,7 +436,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-demon-stronghold-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -536,7 +536,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-demon-stronghold-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -616,7 +616,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-demon-stronghold-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -656,7 +656,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-demon-stronghold-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -696,7 +696,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-demon-stronghold-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -736,7 +736,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-demon-stronghold-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -776,7 +776,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-demon-stronghold-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -816,7 +816,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-demon-stronghold-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -855,9 +855,15 @@ export const DEMON_STRONGHOLD_ENTITIES = {
"damageMultiplier": 1.3, "damageMultiplier": 1.3,
"moveSpeed": 2.25, "moveSpeed": 2.25,
"leashRange": 32, "leashRange": 32,
"mechanicIds": [
"rw-demon-stronghold-102701-aura-494871",
"rw-demon-stronghold-102701-aura-494873",
"rw-demon-stronghold-102701-spell-494875",
"rw-demon-stronghold-102701-spell-494876"
],
"attacks": [ "attacks": [
{ {
"id": "rw-demon-stronghold-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -866,6 +872,105 @@ export const DEMON_STRONGHOLD_ENTITIES = {
"range": 4.5, "range": 4.5,
"cooldownMs": 1900, "cooldownMs": 1900,
"damageMultiplier": 1 "damageMultiplier": 1
},
{
"id": "rw-demon-stronghold-102701-aura-494871",
"name": "Hurricane",
"delivery": "area",
"target": "self",
"school": "arcane",
"animation": "cast",
"castTimeMs": 2000,
"range": 0,
"cooldownMs": 15000,
"initialCooldownMs": [
15000,
15000
],
"repeatCooldownMs": [
15000,
15000
],
"damageMultiplier": 0,
"effect": {
"kind": "aura",
"aura": "hurricane",
"durationMs": 7500
}
},
{
"id": "rw-demon-stronghold-102701-aura-494873",
"name": "Struggle",
"delivery": "area",
"target": "self",
"school": "arcane",
"animation": "cast",
"castTimeMs": 10000,
"range": 0,
"cooldownMs": 15000,
"initialCooldownMs": [
15000,
15000
],
"repeatCooldownMs": [
15000,
15000
],
"damageMultiplier": 0,
"effect": {
"kind": "aura",
"aura": "struggle",
"durationMs": 7500
}
},
{
"id": "rw-demon-stronghold-102701-spell-494875",
"name": "Double Cyclone",
"spellId": 494875,
"delivery": "area",
"target": "nearby-party",
"school": "arcane",
"animation": "cast",
"castTimeMs": 3000,
"range": 15,
"cooldownMs": 6500,
"initialCooldownMs": [
6500,
6500
],
"repeatCooldownMs": [
6500,
6500
],
"damageMultiplier": 0.85,
"radius": 15,
"telegraph": {
"shape": "circle",
"anchor": "source",
"warningMs": 1800,
"radius": 15
}
},
{
"id": "rw-demon-stronghold-102701-spell-494876",
"name": "Titanic Attack",
"spellId": 494876,
"delivery": "projectile",
"target": "primary",
"school": "physical",
"animation": "cast",
"castTimeMs": 2000,
"range": 15,
"cooldownMs": 6500,
"initialCooldownMs": [
6500,
6500
],
"repeatCooldownMs": [
6500,
6500
],
"damageMultiplier": 0.85
} }
] ]
}, },
@@ -2954,6 +3059,6 @@ export const DEMON_STRONGHOLD_POPULATION_PROVENANCE = {
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values." "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values."
} as const; } as const;
@@ -1,2 +1,2 @@
// Generated by npm run dungeon:catalog. Do not edit. // Generated by npm run dungeon:catalog. Do not edit.
export default "H4sIAAAAAAACCuy9bY/jOJIu+lcMnwOcWcBmMYLvBZwP/bKzM/dO7/Tt2jODi0FhQUlUpqecVl7ZWdXVjf7vF0FKtmzJaTntrsrKqf0w22WmRDIUEYyXJ4K/Ttf5bbjzfwv1elGtpq9hNl1XD3Uepq9/neb+7t4vblY/+NWiDOvN9PW0eFjdhGo1v1/ch+ViFV61P7R/y/65rlbT2TSs3i/qanUXVpsfff7O34T19PU/pus6f3Xj78Krm7AKtd+E4lW5eB9+XPqPof4+vevPd/dVvVm3bxp45M6v/HpT1XffrNdh+/74wOQPm0DP+/rjpPTLZebzd/82fUsLyquH1SbU3/mNX1Y309ePvvmnh9VmcReaP27X4n8JdbW5/a6qw3fV3d2CSCILV0AhM2VQBAsuV7lwvNS5BzAGrSiyMrNB7D/+JlL5L1X+bnghg3+6Jcl9WC63+/h16td5WKUPOGXs1V98sXi3/uHH/+ffi8Wmql/ly0VYbeb5Q12H1eaVr4OfK3x17zf57fz7V99/+8fFMqy/i3/16g29mxVZTh/xvspv0zv/FPwy1D/41fzNpqr9TXjVssD8Q1W/a/lg/So+M09THn31b7/Npnn1PtCLpq//8WvLV38uiBq3vqg+lH51M38Xwv10Nr3z9zQiROczrqevDf1zs9gsiLcEse7Gbxb5m3v/YbWevgbuZtO68neL1Q3xyHr6GuWOv9uvkVd1mP42O1gD8cCHxaqYrzdV/s4XobMMub8M1V0G9JbhxOEq+MhFFMEXd4tVWHfm1o+QALFHAtMngRg5+we/WC5WN/Pcvw/1qrMGefAZ7N4a+OEaUOHhGszIJdT+l6ouw2r+rvYPy84KzGOMYEdQAXDkErKlz9+VfnNb3c2LEO67dLD7q9B7dDA9OnDorWIsJzwsC3/nV7u5DT+LAoLjkylws6ruQh1uutO7g+nl45IoTG/rAsZK4sPqXVjNSasvO0IYhfv4EkSP+lIdLkGey4VF9aErB4DuMQZQPTZ0svcRxgrjOvf1MmzmdxWdT6H+2FmGdY/oI9HjQ0Fcd7AMPXIZvzwsS1/X/t1ueuSPTY8wQh2APEcY6yp/N1/fL+oON+Dhp4C9VZieQCite4oRz15FEe43tx2OQHEgFyC6y9ADR5Tsfww1miduq2V151d5lxKH3GBPaAZSVE9dwKYmxbi86x6Oh1/CdedXvdNBip5qoDeMmv/O1/6hqFbdo/nR3fe0gsKeVkA+Wi3chHJRh3l+69d3nTXYxzRT3z6A/hcYKw8FTX+3fzT2JAH3JKGnlYzqSYIa+wHWt36zCXUo5rd+uewIgpIHgrAnB6Jvpzj7ZNWYLauqmJcP9cp3BUFJfGQJ0FOLINyTl3AblsvICjV5QfVmjxIH1tLe54AeJax68kG93gR/994/LDfd6dV5VBhQB2OttYdVEeqsuunOrh8RhQFzVcOTzdX10r8P8/uw2iO+eWT3OCCKvbNJjKW+r3O/qf0vndkVPrZ7d/rTj5XDrNr41SL33bnFY6ey65vI8GTuvwv5rV/5uju7PIvrrHyySZCcxfnSZx/rxWpz212EOo/57NMVwDpsbsO7dz01qPRjwq8HzIEne6wUuZhvqrtsb/7H+D/SeH9+cE82BvxDfhtWfjHP64/3expQ2ceIIE/7y8KMpsHNgqzj9XwT6nr/OLCPKsK+zyh7ilCfYx/O76rar7u2oXaPLGDgOHqyVRDWub8P87Iml/WhXt1Wy274QunHzua+ahC2/0HGLuVhc+PrIhzEcZR5TD0MSCbg0w+lZgX3i9XK510PUpnH9MMYJTWWCpvbMF+Fnx+6QmH0Y75jTzOgeLqKpOmr/GG5P799bPNjPsHYgzF/WMY4UlXOhxwG5R5V0/1wEjw5nha1c1pHtdpbgjuLE4x5spVS1P7d5tbX1epAJjTnZ51Wzj3Zife//LNazlehfsi688Nj+rHnORn+VH7YfoXl4uZ2s1qsbrqrwLOYYYgtxwaWbh5WxV4kQ/PHArv9ya15cjzv/aKiaA5p5u789pFP0HPc3FPFwN+u3vnbsJlXy2L+brG6KaqO+6rh0XhO32YQ4sk2Q1nVNyEKZPXQtZu0wMdMhh4zyp7xasfqxnrhl7QCUpL5rb+7X3TjCVrxR4jR+yTqqZ/kfrGJZCDF0J39MYYQfWnQ9snO41Yq61AuQ77ZJ4N+1ITrL+TJKnqzWL0L9Sbkt/P1bfWBIq67ZVg80FHQXUafM/Vjq7ivqzwUD7Vfzts8YZ8/6+qXsJrXwee33eC3/cTLqOqbarMJqzllgzqRX2N/b4K8nU2LUC5WC2KIdUzV9TPGi+Gs3WaxWdKL32xHJv93L5+3LG/atG9B77eUqv353rfpzHzp1+tFvp9R/qEqIiMVm/ntx6xekBItFmW5yB+Wm49/8/XCrzZptXFtq6q+88u9P+r+uib6/ODX7+JuluF9WP7kVzEzCXYm+NuGDBRa+X77hj/vvdm/94ulzxbLRXx3889ob97XYR1W9CVoR79Ol1Xe/PcAaTa34Y729tf7sKLM6rKYhJ/vQ7FoJHL9cHfna5rij3R8Tja3dfVwczs5eNNsUoQy+M1ksVlPthwyqStyj2YTvyom9JUmm9swoYjVx4lfLt4HNp1Nl5UvFqubH8J6HbOz0x/rcO/rxermcBLG6O8fVvmtrzeh+KYO/j99XP3/aX+b9HdIjHVTVw8rot//4JYXnD5vmVLZebWs6jjgAYFPZ9NV8HVKfJb0H6j5b7Opv8sopfwXosHeY95m0pFds1htKCdOX4MzxN9m09twt1jf34Y6bB9bv/v4XfukNS63FEJJi9v+zh3PuDt8o5a/zWL8NapKv+yvpMDcuGL/OZhNq7Jchw2xFs6Qz+bm7W8kEJtAJ9E6vuFhUz1sfoyh1Shz0//+Fv4nYRjSCNGYfn47m2bLsCr2/xLjX8aBzh/6gjjofdj/Wxn/th3b/nlc0D0t5cOi2NxSkJ4T+dIWBVmq9/TQ6iYZBHm12lQPdXzpDygnyNXkO6snAHYCEicozQS5nYCByRsh5MTARHI9AdTT2fQHYSco3OQ74G4CVkzA4AS1naDk8bE3QsqJ0xPhzMSY6VsCD3gCfkRqvV+sH/xyJ+vNv2fTcrEMDTum39jNkhjjoV5OX09fpTck2MUW9fFKiPlOkb3aey6/Dfm79cPd9PVU5DJg4YKzgcAd4AnlEQQEoQCd1VwFb2VWWCEQweXccyO8B2kzV3pjSI4XvwSylJw1UstZNERWN8vwHUnq9LV0yIGT3smr5XKRlGG7xe1Pc875/k73hs7f8NDjnX1zV+Y65zygB1eW0uZQZLLIrAtZcByU4FnhLBgshTOZlgFcYYTnipeF0byzb5BO93atlRKaTr/eNuH4NuGybUJ/m1qDNaC8F76wXmPmjFXcAXijQTulNDfCclGgsKhECcCzHHOEkmORO9XdpqZoy+ht4vFt4mXbxP42jQKFslQ2A6N9gCxTWYG68EaZQuSGF2iUyApjjc2dUtzLzCuvyjwIrnzW2SZytGdsUxzfprhsm6K/zUyA17JEiSrnRa5UaQBQIqIw3JWuKJQP3ilecq28VCZ4m5fATebKkPuw9zXtOduUx7cpL9um7G8TMpCldzJDmxtZWs4DbYWjtNKWyktjpeCG+zLLi8y5LMtDKDOdO5tnKNS+bOIZ21THt6ku26YaYFpwTmbeGuXLzOSIFkvMNfhMSSu4A64CWu+VK2xWZAGzEoo8U7r0PofgOtuUTp/zNfXxberLtqn728SyhBJzg4XgptAauFZZ8KYseY5K0OIBICAP0mnIJGRCYGm5QZlpB7KzTWXlOds0x7dpLtum6W+zyJQ3nKPPvcid92BLMDxoHoQrNYpcWlAh1y5ojTKoUqIJeeaKQgjnMtvZppZcnrFNe3yb9rJt2oEDpfBZoYWQvNCobaa1R+O9DwY5VyorA2RYhKC9U4BBYs6tLrEUpdXS2e42BVfnyKY7vk132TZdf5tWccsDlmDyXFuygXyweVkI64PI8pBnRikRQDqjdCh5UFxaZ70zUhhfdg8UpfCMcxOOW0FwmRUEA1aQ45gJWwSUIstKEXwZiiCdLbISTZlxLaCQ4HhRClMi5JzzQkJJpgFCZnbbFE5Z3pdNo4Vzg9s8bgXBZVYQDFlBaHNhM2VlrvPCegxo0YFzygZrJHhe6kJlpQ82aCkNF7km9HPBQwiye6A4C+dYQXDcCoLLrCAYsIJUrp1x5Cy7TOvcGG4hZCYYHbDMJGgMMoCWPCAvueAoMGAuM8+d1KZzoCAIMaRptbFycJvHrSC4zAqCASvIQR5MnjtTGIVaZcgLcFoorRVA4bh1VqEDL/Lcl4UN0ijNy5znWgReio4KkgIk9G130lzDn/O4GQSXmUEwYAZpsmezTJKBIHRRZii01nnhvRWl5WC1Ba+dFegKlXsM2kmhfamx5NYZv/ucaMSAcGojrBrc5nEzCC4zg2DADOKZsJgJ8kc0Wh8ynkuvcy68kmB9yDEIIzELRYGZxcDzzJXCFybzJS9N1wwCZ844UeC4GQSXmUEwYAbZEHJeuMBLrpzTBhVmVsjcaVUKMhtUyCDjZaZtFlyOHp1KZqFUDsRum0ILPbBNIx0fFs7jZhBcZgbBgBmkbK6tDUpYyLzKMVjaKLfKF9rkQVqUhROIeemVlJrnWkEITkqJGUiDO6bVVg6pWmtxeJvHzSC4zAyCATPI2MJa7V1hHSg0PM8kyCC58JkSufUSkOcuK02JLkcltXdaYcBCC5VDKbbbVJw72bf2nAEcPjiPm0FwmRkEA2aQKAoLkMtCS+Ho0MyMFVmeq6wwDrhDKYALb3JbFNZTHCUrlC/KAnNuSreLDgmrxcDXNJoLMbRNPG4G4WVmEA6YQTLjvvDKKY5C0ukitPFGidLnqlDKW2O9w1BwoZwurc5kIaSCwpRZiZZ3XBRjnTzDPsDjZhBeZgbhgBlUeFdiWeRLine truncated export default "H4sIAAAAAAACCuy9a5PbyJUo+FcY3N24MxFkKs/J10lF3A/9GI+96x57W147NhwdN0AgWcURH7UgS2p1h/77xskESJAASbBISaWy/EHuQhLIzJPn/crfh+v8Piyyv4dyPVsth69hNFyvHss8DF//PsyzxUM2u1v+lC1n07DeDF8Pi8flXVgtxw+zhzCfLcOr+kH9W/Hf69VyOBqG5btZuVouwnLz1yx/m92F9fD1P4frMn91ly3Cq7uwDGW2CcWr6exd+Os8+xDKH9O3/rR4WJWbdf2ljlcW2TJbb1bl4rv1Omy/H18Y/Nsm8PtZ+WEwzebzSZa//ffhL7ygfPW43ITyh2yTzVd3w9cnv/zz43IzW4Tqx/Vast9Cudrc/7Aqww+rxWLGINGFL6DQE+NQBQKfm1x5ObV5BuAckiom0wkFtf/6mwjlP6/yt90L6fzpFiQPYT7f7uP3YbbOwzId4FCIV3/Oitnb9U9//b//o5htVuWrfD4Ly804fyzLsNy8ysqQjQ2+esg2+f34x1c/fv+H2Tysf4i/evWGvy2KSc6H+LDK79M3/xiyeSh/ypbjN5tVmd2FVzUKjN+vyrc1HqxfxXfGacqjn/74cTTMV+8Cf2j4+p+/13j1p4KhcZ8Vq/fTbHk3fhvCw3A0XGQPPKJU4xjXw9eO/9zMNjPGLcWou8k2s/zNQ/Z+uR6+BulHw3KVLWbLO8aR9fA16h1+16eRr8ow/Dg6WAPjwPvZshivN6v8bVaExjL0/jJMcxnQWoZXh6uQPRdRhKxYzJZh3ZjbngABYgsErg0C1XP299lsPlvejfPsXSiXjTXog2OgvTXIwzWgwcM1uJ5LKLPfVuU0LMdvy+xx3liBO4UI1AMKgD2XMJln+dtptrlfLcZFCA9NOND+KuweHFwLDhJaq+iLCY/zIltky93cTl4EASXxyRC4W64WoQx3zen9wfT6NCUq19q6gr6U+Lh8G5Zj5urzBhFG4j6+BNWCvjaHS9CXYmGxet+kA0B/CgFMCw29bh1CX2Jc51k5D5vxYsXyKZQfGssgf4IfqRYeKsa6g2XYnsv47XE+zcoye7ubHuWp6RF6sAPQlxBjucrfjtcPs7KBDXh4FLC3CtciCGNtizHixasowsPmvoERqA7oAlRzGbZDROn2YZjeOHG/mq8W2TJvQuIQG+gMZ2BG9dQFbEpmjPNFUzgenoRvzm9a0kGrFmvgL/Saf5GV2WOxWjZF88ndt7iCwRZXQNmbLdyF6awM4/w+Wy8aa6BTnKmtH0D7BPrSQ8HTL/ZFY4sScI8SWlzJmRYlmL4HsL7PNptQhmJ8n83nDUIw+oAQ9uhAtfUUT09mjZP5alWMp4/lMmsSgtF4YgnQYoug/JOXcB/m84gKJVtB5WYPEgfa0t5xQAsSZJ4sqNebkC3eZY/zTXN6cxkUOthBX23tcVmEcrK6a85uT5BCh7pq4cnq6nqevQvjh7DcA747sXvsIMWWbFJ9oZ+VebYps98asxs8tXt//uj70uFktcmWszxrzq1OSWXfVpHhydi/CPl9tszK5uz6Iqwj/WSVIBmL43k2+VDOlpv75iLMZchHT2cA67C5D2/fttigsaeI33aoA0+2WNlzMd6sFpO9+U/hf4Tx/vzgn6wMZI/5fVhms3FefnjY44CGTgFBn7eXlesNg7sZa8fr8SaU5b44oJOMsG0z6hYjtJfoh+PFqszWTd3Q+hML6BBHT9YKwjrPHsJ4WrLJ+lgu71fzpvvC2FOyuc0aFLUPpO9SHjd3WVmEAz+OcafYQwdlAj5dKFUreJgtl1netCCNO8Uf+jCpvlDY3IfxMvz62CQKZ0/Zji3OgOrpLJKnX+WP8/356dTm+xxBX8GYP86jH2k1HXcZDMafZNNtdxI82Z8WuXNax2q5twR/ESY492QtpSizt5v7rFwtD2jCSnmRtPL+yUZ89tt/r+bjZSgfJ8354RR/bFlOTj4VH7anMJ/d3W+Ws+VdcxV4ETJ0oWVfx9Ld47LY82RYecqx256c3JP9ee9mK/bmMGduzk8njqBluPmnkkF2v3yb3YfNeDUvxm9ny7ti1TBfLZz057R1BqWerDNMV+VdiAS5emzqTVbhKZWhhYy6pbxSX95YzrI5r4CZZH6fLR5mTX+CNfIEMFpHYp56JA+zTQQDM4bm7KcQQrWpwdKTjcctVZZhOg/5Zh8M9qQK117Ik1n0ZrZ8G8pNyO/H6/vVe/a47pZBeMCjoLmMNmbaU6t4KFd5KB7LbD6u44Rt/CxXv4XluAxZft90ftNnXsaqvFttNmE55mhQw/Pr6FMD5JfRsAjT2XLGCLGOobp2xHjWHbXbzDZz/vCb7cjg/2rF8+bTuzrsW/D3iUO1vz5kdTgzn2fr9Szfjyj/tCoiIhWb8f2HSTljJlrMptNZ/jjffPh7Vs6y5SatNq5tuSoX2XzvR82na4bPT9n6bdzNPLwL85+zZYxMAo2U/KUCA7tWftx+4U97X87eZbN5NpnNZ/Hb1Z9R33wowzos+SR4R78P56u8+u8O0Gzuw4L39peHsOTI6rwYhF8fQjGrKHL9uFhkJU/xBxafg819uXq8ux8cfGk0KMI0ZJvBbLMebDFkUK7YPBoNsmUx4FMabO7DgD1WHwbZfPYuiOFoOF9lxWx591NYr2N0dvjXMjxk5Wx5dziJEPz7x2V+n5WbUHxXhuy/srj6/6d+NmjvkBHrrlw9Lhl+/5skWUg+3mkKZeer+aqMAxkgyOFouAxZmQKfU/4PtPLjaJgtJhxS/jPDYO+1jCbas14zW244Js6nIQXix9HwPixm64f7UIbta+u3H36o3yTnc2IXSlrc9rn0ciL94Ret/jiK/tfIKrN5eyUF5s4X++/BaLiaTtdhw6iFI5SjsfvlIxPEJrAkWscvPG5Wj5u/RtdqpLnh//oe/nfOYUgjDGN+/MtoOJmHZbH/S4y/jAONH2YFY9C7sP9bHX9bj21/Hhf0wEt5Pys29+yklwy+tEXFmuoDv7S8SwpBvlpuVo9l/OhPqAcozeAHsgMAGoDGAWo3QEkDcDB4o5QeOBhoaQeAdjga/qRogMoPfgDpB0BqAA4HaGmAWsbX3iitB94OlHcD54a/cPJAxokfEVrvZuvHbL6j9erv0XA6m4cKHdMzcTdnxHgs58PXw1fpCyntYpv18Uqp8Y6Rvdp7L78P+dv142L4eqhyHbDwwVPg5A7IOMsjKAjKAHqy0oSM9KQgpRDB5zKTTmUZaJr4aeYc0/Hst8CakienrR5FRWR5Nw8/MKUOX2uPEiTznXw1n88SM6y3uH00llLu73Rv6PINd73e2Lf009zmUgbMwE+nmnIoJrqYkA+T4CUYJSeFJ3A4Vd5NrA7gC6cyaeS0cFY29g3a29aurTHKsvRrbROObxOu2ya0t2ktkAOTZSorKLM48Y6M9ACZs2C9MVY6RVIVqAiNmgLISY45wlRikXvT3KZlb0vvbeLxbeJ128T2Np0Bg3pqaALOZgEmEzMp0BaZM65QuZMFOqMmhSNHuTdGZnqSmcxM86CkySaNbaJEumCb6vg21XXbVO1tThRkVk9Ro8llkRszdQCoEVE56ae+KEwWMm/kVFqTaeNCRvkUpJv4acizsHeadMk29fFt6uu2qdvbhAnoaeb1BCl3ekpSBt6KRE2apibTjrSSTmbTSV5MvJ9M8hCmE5t7yieozD5t4gXbNMe3aa7bpulAWvBeTzJyJptOXI5IOMXcQjYxmpT0IE1AyjLjC5oUk4CTKRT5xNhpluUQfGOb2ttLTtMe36a9bpu2vU2cTmGKucNCSVdYC9KaScjcdCpzNIoXDwABZdDewkTDRCmcknSoJ9aDbmzTkL5km+74Nt1123TtbRYTkzkpMcszlfssA5qCk8HKoPzUoso1gQm59cFa1MFMNbqQT3xRKOX9hBrbtFrqC7ZJx7dJ122TOgRKkU0Kq5SWhUVLE2szdFmWBYdSGjOZBphgEYLNvAEMGnNJdopTNSWrPTW3qaS5hDb98W3667bp29skI0kGnILLc0usA2WB8mmhKAtqkod84oxRAbR3xoapDEZq8pR5p5XLpk2BYgxeIDfhuBYE12lB0KEFeYkTRUVArSaTqQrZNBRBeyomU3TTibQKCg1eFlPlpgi5lLLQMGXVAGHidttU3pBs06azyvvObR7XguA6LQi6tCCkXNHEkM5tXlCGAQk9eG8okNOQyaktzGSaBQpWaydVbjn7uZAhBN0UKJ7gEi0IjmtBcJ0WBB1akMmtd56NZT+xNndOEoSJC84GnE40WAw6gNUyoJxKJVFhwFxPMum1dQ2BgqBUF6e1jnTnNo9rQXCdFgQdWpCHPLg8965wBq2ZoCzAW2WsNQCFl+TJoIdM5Xk2LShoZ6yc5jK3KsiparAgrUBDW3dnztV9nMfVILhODYIONciyPjuZaFYQlC2mE1TW2rzIMlJTkkCWILOeFPrC5BkG67Wy2dTiVJJ32e440akO4rROkenc5nE1CK5Tg6BDDZITRThRbI9YpCxMZK4zm0uVGQ2UhRyDchonoShwQhhkPvFTlRVukk3l1DXVIPDuAokCx9UguE4Ngg41iELIZeGDnErjvXVocEJK596aqWK1wYQJTOR0YmkSfI4ZepPUQm08qN02lVW2Y5tOe9lNnMfVILhODYIONchQbomCUQSTzOQYiDcqyWSFdXnQhLrwCjGfZkZrK3NrIASvtcYJaIc7pLWku1gtEXZv87gaBNepQdChBjkqiGzmC/Jg0Ml8okEHLVU2MSqnTAPK3E+mboo+R6Nt5q3BgIVVJoep2m7TSOl1W9vzDrBbcB5Xg+A6NQg61CBVFASQ68Jq5VloThypSZ6bSeE8SI9agVSZy6koKGM/yqQwWTEtMJdu6nfeIUVWdZyms1Kprm3icTUIr1ODsEMN0hOZFZnxRqLSLF2UdZkzaprlpjAmI0eZx1BIZbydkp3oQmkDhZtOpkiyYaI 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
@@ -16,7 +16,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-dungeon-of-dalanis-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -56,7 +56,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-dungeon-of-dalanis-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -96,7 +96,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-dungeon-of-dalanis-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -136,7 +136,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-dungeon-of-dalanis-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -196,7 +196,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-dungeon-of-dalanis-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -236,7 +236,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-dungeon-of-dalanis-basic-attack", "id": "basic-attack",
"name": "Corrosive Slam", "name": "Corrosive Slam",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -296,7 +296,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-dungeon-of-dalanis-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -336,7 +336,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-dungeon-of-dalanis-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -376,7 +376,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-dungeon-of-dalanis-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -416,7 +416,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-dungeon-of-dalanis-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -476,7 +476,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-dungeon-of-dalanis-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -536,7 +536,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-dungeon-of-dalanis-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -576,7 +576,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-dungeon-of-dalanis-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -636,7 +636,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-dungeon-of-dalanis-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -676,7 +676,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-dungeon-of-dalanis-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -716,7 +716,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-dungeon-of-dalanis-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -756,7 +756,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-dungeon-of-dalanis-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -1567,6 +1567,6 @@ export const DUNGEON_OF_DALANIS_POPULATION_PROVENANCE = {
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values." "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values."
} as const; } as const;
@@ -1,4 +1,4 @@
/* Generated by scripts/runewaker-pipeline/build-population.mjs. */ /* This file is generated by scripts/runewaker-pipeline/build-forsaken-population.mjs. */
import type { DungeonBossObjective } from "../dungeonTypes"; import type { DungeonBossObjective } from "../dungeonTypes";
import type { PopulationDefinitionMap, StaticMobSpawnDefinition } from "../mobPopulation"; import type { PopulationDefinitionMap, StaticMobSpawnDefinition } from "../mobPopulation";
@@ -16,7 +16,7 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-fa-basic-attack", "id": "basic-attack",
"name": "Corrosive Slam", "name": "Corrosive Slam",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -56,7 +56,7 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-fa-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -96,7 +96,7 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-fa-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -136,7 +136,7 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-fa-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -175,9 +175,12 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"damageMultiplier": 1.3, "damageMultiplier": 1.3,
"moveSpeed": 2.25, "moveSpeed": 2.25,
"leashRange": 32, "leashRange": 32,
"mechanicIds": [
"rw-fa-100070-source-gap-burst"
],
"attacks": [ "attacks": [
{ {
"id": "rw-fa-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -186,6 +189,33 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"range": 4.5, "range": 4.5,
"cooldownMs": 1900, "cooldownMs": 1900,
"damageMultiplier": 1 "damageMultiplier": 1
},
{
"id": "rw-fa-100070-source-gap-burst",
"name": "Arcane Burst",
"delivery": "area",
"target": "nearby-party",
"school": "arcane",
"animation": "cast",
"castTimeMs": 2000,
"range": 12,
"cooldownMs": 12000,
"initialCooldownMs": [
8000,
8000
],
"repeatCooldownMs": [
12000,
12000
],
"damageMultiplier": 0.82,
"radius": 8,
"telegraph": {
"shape": "circle",
"anchor": "source",
"warningMs": 2000,
"radius": 8
}
} }
] ]
}, },
@@ -217,7 +247,7 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-fa-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -257,7 +287,7 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-fa-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -297,7 +327,7 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-fa-basic-attack", "id": "basic-attack",
"name": "Corrosive Slam", "name": "Corrosive Slam",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -336,9 +366,12 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"damageMultiplier": 1.3, "damageMultiplier": 1.3,
"moveSpeed": 2.25, "moveSpeed": 2.25,
"leashRange": 32, "leashRange": 32,
"mechanicIds": [
"rw-fa-100561-source-gap-sweep"
],
"attacks": [ "attacks": [
{ {
"id": "rw-fa-basic-attack", "id": "basic-attack",
"name": "Corrosive Slam", "name": "Corrosive Slam",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -347,6 +380,33 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"range": 4.5, "range": 4.5,
"cooldownMs": 1900, "cooldownMs": 1900,
"damageMultiplier": 1 "damageMultiplier": 1
},
{
"id": "rw-fa-100561-source-gap-sweep",
"name": "Corrosive Sweep",
"delivery": "area",
"target": "nearby-party",
"school": "nature",
"animation": "attack",
"castTimeMs": 1800,
"range": 7,
"cooldownMs": 10000,
"initialCooldownMs": [
7000,
7000
],
"repeatCooldownMs": [
10000,
10000
],
"damageMultiplier": 1.1,
"telegraph": {
"shape": "cone",
"anchor": "source",
"warningMs": 1800,
"length": 7,
"angleDegrees": 100
}
} }
] ]
}, },
@@ -378,7 +438,7 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-fa-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -417,9 +477,12 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"damageMultiplier": 1.3, "damageMultiplier": 1.3,
"moveSpeed": 2.25, "moveSpeed": 2.25,
"leashRange": 32, "leashRange": 32,
"mechanicIds": [
"rw-fa-100562-source-gap-sweep"
],
"attacks": [ "attacks": [
{ {
"id": "rw-fa-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -428,6 +491,33 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"range": 4.5, "range": 4.5,
"cooldownMs": 1900, "cooldownMs": 1900,
"damageMultiplier": 1 "damageMultiplier": 1
},
{
"id": "rw-fa-100562-source-gap-sweep",
"name": "Heavy Sweep",
"delivery": "area",
"target": "nearby-party",
"school": "physical",
"animation": "attack",
"castTimeMs": 1800,
"range": 7,
"cooldownMs": 10000,
"initialCooldownMs": [
7000,
7000
],
"repeatCooldownMs": [
10000,
10000
],
"damageMultiplier": 1.1,
"telegraph": {
"shape": "cone",
"anchor": "source",
"warningMs": 1800,
"length": 7,
"angleDegrees": 100
}
} }
] ]
}, },
@@ -459,7 +549,7 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-fa-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -498,9 +588,12 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"damageMultiplier": 1.3, "damageMultiplier": 1.3,
"moveSpeed": 2.25, "moveSpeed": 2.25,
"leashRange": 32, "leashRange": 32,
"mechanicIds": [
"rw-fa-100563-source-gap-sweep"
],
"attacks": [ "attacks": [
{ {
"id": "rw-fa-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -509,6 +602,33 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"range": 4.5, "range": 4.5,
"cooldownMs": 1900, "cooldownMs": 1900,
"damageMultiplier": 1 "damageMultiplier": 1
},
{
"id": "rw-fa-100563-source-gap-sweep",
"name": "Heavy Sweep",
"delivery": "area",
"target": "nearby-party",
"school": "physical",
"animation": "attack",
"castTimeMs": 1800,
"range": 7,
"cooldownMs": 10000,
"initialCooldownMs": [
7000,
7000
],
"repeatCooldownMs": [
10000,
10000
],
"damageMultiplier": 1.1,
"telegraph": {
"shape": "cone",
"anchor": "source",
"warningMs": 1800,
"length": 7,
"angleDegrees": 100
}
} }
] ]
}, },
@@ -539,9 +659,18 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"damageMultiplier": 1.3, "damageMultiplier": 1.3,
"moveSpeed": 2.25, "moveSpeed": 2.25,
"leashRange": 32, "leashRange": 32,
"mechanicIds": [
"rw-fa-100074-spell-490471",
"rw-fa-100074-spell-490489",
"rw-fa-100074-control-490636",
"rw-fa-100074-summon-100070-at-50",
"rw-fa-100074-summon-100070-at-25",
"rw-fa-100074-phase-50",
"rw-fa-100074-phase-25"
],
"attacks": [ "attacks": [
{ {
"id": "rw-fa-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -550,6 +679,215 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"range": 4.5, "range": 4.5,
"cooldownMs": 1900, "cooldownMs": 1900,
"damageMultiplier": 1 "damageMultiplier": 1
},
{
"id": "rw-fa-100074-spell-490471",
"name": "Fireball",
"spellId": 490471,
"delivery": "projectile",
"target": "primary",
"school": "fire",
"animation": "cast",
"castTimeMs": 2000,
"range": 22.5,
"cooldownMs": 3000,
"initialCooldownMs": [
3000,
3000
],
"repeatCooldownMs": [
3000,
3000
],
"damageMultiplier": 0.85
},
{
"id": "rw-fa-100074-spell-490489",
"name": "Dark Power Release",
"spellId": 490489,
"delivery": "area",
"target": "nearby-party",
"school": "fire",
"animation": "cast",
"castTimeMs": 5000,
"range": 15,
"cooldownMs": 20000,
"initialCooldownMs": [
20000,
20000
],
"repeatCooldownMs": [
20000,
20000
],
"damageMultiplier": 0.85,
"radius": 15,
"telegraph": {
"shape": "circle",
"anchor": "source",
"warningMs": 1800,
"radius": 15
}
},
{
"id": "rw-fa-100074-control-490636",
"name": "Group Catch",
"delivery": "area",
"target": "nearby-party",
"school": "shadow",
"animation": "cast",
"castTimeMs": 1800,
"range": 10,
"cooldownMs": 15000,
"initialCooldownMs": [
15000,
15000
],
"repeatCooldownMs": [
15000,
15000
],
"damageMultiplier": 0,
"radius": 5,
"telegraph": {
"shape": "circle",
"anchor": "source",
"warningMs": 1800,
"radius": 5
},
"effect": {
"kind": "control",
"mechanic": "root",
"durationMs": 3000
}
},
{
"id": "rw-fa-100074-summon-100070-at-50",
"name": "Reinforcements at 50%",
"delivery": "area",
"target": "self",
"school": "physical",
"animation": "cast",
"castTimeMs": 1800,
"interruptible": false,
"range": 0,
"cooldownMs": 1000,
"initialCooldownMs": [
0,
0
],
"repeatCooldownMs": [
1000,
1000
],
"maximumCasts": 1,
"damageMultiplier": 0,
"effect": {
"kind": "summon",
"creatureEntry": 100070,
"count": 2
},
"condition": {
"kind": "self-health-percent",
"minimum": 0,
"maximum": 50
}
},
{
"id": "rw-fa-100074-summon-100070-at-25",
"name": "Reinforcements at 25%",
"delivery": "area",
"target": "self",
"school": "physical",
"animation": "cast",
"castTimeMs": 1800,
"interruptible": false,
"range": 0,
"cooldownMs": 1000,
"initialCooldownMs": [
0,
0
],
"repeatCooldownMs": [
1000,
1000
],
"maximumCasts": 1,
"damageMultiplier": 0,
"effect": {
"kind": "summon",
"creatureEntry": 100070,
"count": 2
},
"condition": {
"kind": "self-health-percent",
"minimum": 0,
"maximum": 25
}
},
{
"id": "rw-fa-100074-phase-50",
"name": "Phase Change",
"delivery": "area",
"target": "self",
"school": "arcane",
"animation": "cast",
"castTimeMs": 1200,
"interruptible": false,
"range": 0,
"cooldownMs": 1000,
"initialCooldownMs": [
0,
0
],
"repeatCooldownMs": [
1000,
1000
],
"maximumCasts": 1,
"damageMultiplier": 0,
"effect": {
"kind": "aura",
"aura": "phase-50",
"durationMs": 5000
},
"condition": {
"kind": "self-health-percent",
"minimum": 0,
"maximum": 50
}
},
{
"id": "rw-fa-100074-phase-25",
"name": "Phase Change",
"delivery": "area",
"target": "self",
"school": "arcane",
"animation": "cast",
"castTimeMs": 1200,
"interruptible": false,
"range": 0,
"cooldownMs": 1000,
"initialCooldownMs": [
0,
0
],
"repeatCooldownMs": [
1000,
1000
],
"maximumCasts": 1,
"damageMultiplier": 0,
"effect": {
"kind": "aura",
"aura": "phase-25",
"durationMs": 5000
},
"condition": {
"kind": "self-health-percent",
"minimum": 0,
"maximum": 25
}
} }
] ]
}, },
@@ -581,7 +919,7 @@ export const FORSAKEN_ABBEY_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-fa-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -2341,20 +2679,13 @@ export const FORSAKEN_ABBEY_BOSSES = [
export const FORSAKEN_ABBEY_OBJECTIVE_ORDER = FORSAKEN_ABBEY_BOSSES; export const FORSAKEN_ABBEY_OBJECTIVE_ORDER = FORSAKEN_ABBEY_BOSSES;
export const FORSAKEN_ABBEY_POPULATION_PROVENANCE = { export const FORSAKEN_ABBEY_POPULATION_PROVENANCE = {
"schemaVersion": 2, "schemaVersion": 1,
"dungeonId": "forsaken-abbey", "dungeonId": "forsaken-abbey",
"zoneId": 102, "zoneId": 102,
"sourceRowCount": 251, "sourceRowCount": 251,
"importedSpawnCount": 150, "importedSpawnCount": 150,
"excludedRowCount": 101, "excludedRowCount": 101,
"bossSpawnCount": 8, "bossSpawnCount": 8,
"objectiveOrder": {
"authoritative": false,
"kind": "healerMan-distance-derived",
"evidence": [
"scripts/runewaker-pipeline/build-population.mjs"
]
},
"sourceSnapshot": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json", "sourceSnapshot": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json",
"sourceContentSha256": "13ef6edd3df6e71389aa92d01201889148c0794cf9ecaafdbaf90d3145d2bf7b", "sourceContentSha256": "13ef6edd3df6e71389aa92d01201889148c0794cf9ecaafdbaf90d3145d2bf7b",
"sourceHashes": { "sourceHashes": {
@@ -2368,7 +2699,7 @@ export const FORSAKEN_ABBEY_POPULATION_PROVENANCE = {
"Resource/data/string_enus.db": "4fce021ba9ed110a213e08d3228b2de57cec66ddbbc8cdcf5753204874d9ab75" "Resource/data/string_enus.db": "4fce021ba9ed110a213e08d3228b2de57cec66ddbbc8cdcf5753204874d9ab75"
} }
}, },
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(meters,meters,-meters)", "coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"navmeshProjection": { "navmeshProjection": {
"maximumAllowedDistance": 2, "maximumAllowedDistance": 2,
"maximumObservedDistance": 2.5965, "maximumObservedDistance": 2.5965,
@@ -2376,8 +2707,5 @@ export const FORSAKEN_ABBEY_POPULATION_PROVENANCE = {
"deferredRowsBeyondCombatLimit": 1 "deferredRowsBeyondCombatLimit": 1
}, },
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry."
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values."
} as const; } as const;
@@ -16,7 +16,7 @@ export const GRAFU_CASTLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-grafu-castle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -56,7 +56,7 @@ export const GRAFU_CASTLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-grafu-castle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -96,7 +96,7 @@ export const GRAFU_CASTLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-grafu-castle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -176,7 +176,7 @@ export const GRAFU_CASTLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-grafu-castle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -256,7 +256,7 @@ export const GRAFU_CASTLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-grafu-castle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -336,7 +336,7 @@ export const GRAFU_CASTLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-grafu-castle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -416,7 +416,7 @@ export const GRAFU_CASTLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-grafu-castle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -456,7 +456,7 @@ export const GRAFU_CASTLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-grafu-castle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -536,7 +536,7 @@ export const GRAFU_CASTLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-grafu-castle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -576,7 +576,7 @@ export const GRAFU_CASTLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-grafu-castle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -616,7 +616,7 @@ export const GRAFU_CASTLE_ENTITIES = {
"leashRange": 24, "leashRange": 24,
"attacks": [ "attacks": [
{ {
"id": "rw-grafu-castle-basic-attack", "id": "basic-attack",
"name": "Basic Attack", "name": "Basic Attack",
"delivery": "melee", "delivery": "melee",
"target": "primary", "target": "primary",
@@ -1306,6 +1306,6 @@ export const GRAFU_CASTLE_POPULATION_PROVENANCE = {
"runeWakerIdsAreNamespaced": true, "runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.", "serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json", "sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c", "sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values." "sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values."
} as const; } as const;
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more