Release Healer Man 0.1.14
This commit is contained in:
1 parent
67d7a8ebe7
commit
7b41b102fa
147 files changed
+169892
-1342
No files matched your search
@@ -20,5 +20,6 @@ releases/
|
|||||||
/content/
|
/content/
|
||||||
dist-android/
|
dist-android/
|
||||||
.android-public/
|
.android-public/
|
||||||
|
.web-public/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
+66
-33
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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" },
|
||||||
|
});
|
||||||
|
})());
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}`);
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
@@ -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 },
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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 });
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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 (
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 [
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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 } : {}),
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
Reference in new issue
Block a user