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/
|
||||
dist-android/
|
||||
.android-public/
|
||||
.web-public/
|
||||
__pycache__/
|
||||
*.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
|
||||
Android APK contains the React/Three.js runtime, native Android bridge, menus,
|
||||
local profile and save support, UI assets, and the Wailing Caverns starter
|
||||
pack. Other GLBs, textures, audio, character packs, dungeons, and Manastorm
|
||||
stages are published by TrueNAS as optional content.
|
||||
Healer Man separates the installed game client from large game content. Both
|
||||
the production browser build and Android APK contain a compact required set:
|
||||
the React/Three.js runtime, menus, local profile and save support, UI assets,
|
||||
starter equipment, Wailing Caverns starter content, and the basic Human
|
||||
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
|
||||
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
|
||||
will not activate incompatible content.
|
||||
|
||||
The browser build remains a full build served by TrueNAS. It receives client
|
||||
code when the app checkout is pulled, rebuilt, and restarted. The Android build
|
||||
is deliberately separate and compact so the APK does not inherit the complete
|
||||
multi-gigabyte browser asset catalog.
|
||||
|
||||
Measured on August 14, 2026, the production browser `dist` is 3.88 GiB across
|
||||
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.
|
||||
Production builds deliberately stage only required content. `npm run build`
|
||||
prepares `.web-public` and emits the compact browser `dist`; Android prepares
|
||||
`.android-public` and emits `dist-android`. The development server continues to
|
||||
use the source `public` tree so asset-pipeline work remains convenient. The
|
||||
complete source catalog and published immutable objects stay on TrueNAS, not in
|
||||
every player's initial browser or Android installation.
|
||||
|
||||
## 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
|
||||
storage. Filenames are therefore not used as version numbers and runtime code
|
||||
does not need to know the hash.
|
||||
storage or, in production browsers, to the app-managed content cache through a
|
||||
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
|
||||
|
||||
@@ -68,17 +67,34 @@ Publication order is intentionally atomic:
|
||||
3. Write the versioned manifest.
|
||||
4. Replace `manifest.json` only after all referenced objects exist.
|
||||
|
||||
On a device, the client fetches the small manifest and compares it with its
|
||||
installed manifest. Only missing or hash-mismatched objects are downloaded.
|
||||
Android streams each object to a `.part` file, supports HTTP range resume,
|
||||
verifies its size and SHA-256, then renames it into the object cache. The active
|
||||
logical-path map changes only after the complete pack verifies. An interrupted
|
||||
or corrupt update leaves the previous pack playable.
|
||||
The client fetches the small manifest and compares it with its bundled and
|
||||
installed file identities. Only missing or hash-mismatched objects are
|
||||
downloaded. Browsers download into a temporary Cache Storage staging area,
|
||||
verify every object's byte count and SHA-256, and promote the pack to the active
|
||||
cache only after the complete pack succeeds. Android streams each object to a
|
||||
`.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
|
||||
app-private files directory, outside the packaged web bundle, and survive an
|
||||
APK update. Players may remove optional packs through the content manager; save
|
||||
data is stored separately and is never removed with content.
|
||||
The Downloads screen uses the same lifecycle in browser and Android. A missing
|
||||
pack offers **Download**, an installed pack with changed files offers
|
||||
**Update**, and an installed optional pack offers **Remove**. Required packs are
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
- A failed update check is silent outside the content manager.
|
||||
- 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
|
||||
when connectivity returns.
|
||||
- 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
|
||||
```
|
||||
|
||||
The Node server exposes the content volume at `/content`. Manifests use
|
||||
`no-cache`; immutable hashed objects use long-lived cache headers and support
|
||||
byte ranges for resumed downloads.
|
||||
The Node server exposes the compact production browser client at `/` and the
|
||||
complete downloadable-content volume at `/content`. Manifests use `no-cache`;
|
||||
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:
|
||||
|
||||
@@ -121,15 +140,29 @@ discover a manifest whose files have not been published.
|
||||
## Pack policy
|
||||
|
||||
Packs are player-facing groups while files remain individually addressable.
|
||||
This permits a fast initial pack install and small later patches. Current pack
|
||||
IDs are derived predictably:
|
||||
This permits a compact initial install and small later patches. Required pack
|
||||
IDs include:
|
||||
|
||||
```text
|
||||
core:runtime
|
||||
core:ui
|
||||
core:equipment
|
||||
core:character-basic
|
||||
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
|
||||
characters:wow335a-v1
|
||||
characters:ascension-v1
|
||||
characters:runewaker-v1
|
||||
core:combat-media
|
||||
manastorm:<stage-id>
|
||||
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
|
||||
not the game database.
|
||||
|
||||
Large runtime assets are published separately under `/app/content`. Android
|
||||
clients download verified content objects from `/content` and retain them in
|
||||
Large runtime assets are published separately under `/app/content`. Production
|
||||
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,
|
||||
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
|
||||
with `cp`. The checkout contains several gigabytes of required GLB assets, so
|
||||
the initial clone and first build will take substantially longer than later
|
||||
fast-forward updates.
|
||||
with `cp`. The checkout contains several gigabytes of content-source GLB assets,
|
||||
so the initial clone and first content publication will take substantially
|
||||
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
|
||||
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
|
||||
`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
|
||||
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
|
||||
would hide the checkout's required `/app/data/loot/schema.sql`. The
|
||||
startup command installs locked dependencies, initializes the schema, publishes
|
||||
new or changed content objects, builds the full browser bundle, and starts the
|
||||
combined static/API/content server. Existing content objects are reused.
|
||||
new or changed content objects, builds the compact required-content browser
|
||||
bundle, and starts the combined static/API/content server. Existing content
|
||||
objects are reused.
|
||||
|
||||
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`.
|
||||
Do not commit a `.env` file, password, API token, keystore, APK, database,
|
||||
`content/`, `dist/`, `dist-android/`, or `.android-public/` output. The final
|
||||
command prints the exact deployed commit ID.
|
||||
`content/`, `dist/`, `dist-android/`, `.web-public/`, or `.android-public/`
|
||||
output. The final command prints the exact deployed commit ID.
|
||||
|
||||
Next, open the TrueNAS Apps UI and **stop** `healer-man`. Open a TrueNAS shell
|
||||
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
|
||||
`healer-man` in the TrueNAS Apps UI afterward. Container startup
|
||||
publishes immutable content objects and replaces `manifest.json` only after all
|
||||
objects exist, then rebuilds the browser client and starts the server. Android
|
||||
devices discover the small new manifest and download only missing or changed
|
||||
files. Asset-only updates do not require a new APK. A failed fast-forward pull
|
||||
deliberately leaves the running checkout unchanged; resolve diverged history on
|
||||
the development machine rather than forcing the TrueNAS checkout.
|
||||
objects exist, then rebuilds the browser client and starts the server. Browser
|
||||
and Android clients discover the small new manifest and download only missing
|
||||
or changed files for packs the player installed. Asset-only updates do not
|
||||
require a new APK. A failed fast-forward pull deliberately leaves the running
|
||||
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:
|
||||
|
||||
@@ -286,9 +291,10 @@ copies; it does not replace dataset-level protection.
|
||||
## APK releases and Obtainium
|
||||
|
||||
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
|
||||
content rather than the full multi-gigabyte browser catalog. Downloaded packs
|
||||
survive APK updates.
|
||||
which contains the game runtime, UI, starter equipment, basic Human character,
|
||||
and Wailing Caverns starter content. Optional character/skin packs, original
|
||||
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
|
||||
`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,
|
||||
the KTX2 audit found no fallback textures, and the release-candidate check
|
||||
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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
and install directly onto a connected device, use `npm run android:install`.
|
||||
|
||||
Android uses a separate compact `dist-android` build. It bundles the client,
|
||||
interface, equipment, and Wailing Caverns starter content while optional
|
||||
dungeons, character packs, and Manastorm stages are downloaded into app-private
|
||||
storage. Installed content and local saves remain playable without a server
|
||||
connection and survive APK updates. See `CONTENT_UPDATES.md` for the update
|
||||
contract and `DEPLOYMENT.md` for TrueNAS publishing and signed Gitea releases.
|
||||
Android uses a compact `dist-android` build with the same required-content
|
||||
boundary as the production browser build. Optional dungeons, character/skin
|
||||
packs, Manastorm stages, and original spell media are downloaded into
|
||||
app-private storage. Installed content and local saves remain playable without
|
||||
a server connection and survive APK updates. See `CONTENT_UPDATES.md` for the
|
||||
update contract and `DEPLOYMENT.md` for TrueNAS publishing and signed Gitea
|
||||
releases.
|
||||
|
||||
## 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.
|
||||
- Missing production GLBs activate a small cave proxy; malformed assets activate
|
||||
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
|
||||
face-button diamond, shoulders, and D-pad keep the physical controller layout
|
||||
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
|
||||
release.
|
||||
|
||||
Single-player remains offline-capable. A fresh APK includes the runtime and
|
||||
starter content. Additional content that a player has downloaded is retained
|
||||
in Android app-private storage and can be used offline after download.
|
||||
Single-player remains offline-capable. A fresh APK includes the runtime,
|
||||
interface, starter equipment, Wailing Caverns, and the basic Human character
|
||||
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
|
||||
|
||||
@@ -287,7 +290,8 @@ git diff --cached --check
|
||||
```
|
||||
|
||||
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
|
||||
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/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
|
||||
Ascension-specific instances—Tinkertech Showdown, Frozen Reach, and Forgotten
|
||||
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
|
||||
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
|
||||
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",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.14",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.14",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
@@ -10,6 +10,7 @@
|
||||
"db:backup": "node scripts/backup-game-database.mjs",
|
||||
"server:test": "node --test server/*.test.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",
|
||||
"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",
|
||||
@@ -73,6 +74,7 @@
|
||||
"runewaker:register:all": "node scripts/runewaker-pipeline/cli.mjs register-all",
|
||||
"runewaker:refresh:all": "node scripts/runewaker-pipeline/cli.mjs refresh-all",
|
||||
"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: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",
|
||||
@@ -96,7 +98,7 @@
|
||||
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
|
||||
"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",
|
||||
"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: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",
|
||||
|
||||
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",
|
||||
"versionCode": 1013
|
||||
"versionName": "0.1.14",
|
||||
"versionCode": 1014
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
BASIC_HUMAN_TEXTURE_PATHS,
|
||||
PROJECT_ROOT,
|
||||
createContentManifest,
|
||||
scanContentSources,
|
||||
@@ -12,8 +13,21 @@ if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirect
|
||||
}
|
||||
|
||||
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", "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",
|
||||
|
||||
@@ -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([
|
||||
"core:ui",
|
||||
"core:equipment",
|
||||
"core:character-basic",
|
||||
"core:runtime",
|
||||
"dungeon:wailing-caverns",
|
||||
]);
|
||||
|
||||
const RUNTIME_EXTENSIONS = new Set([
|
||||
".avif", ".glb", ".json", ".ktx2", ".mp3", ".ogg", ".png",
|
||||
".svg", ".wav", ".webm", ".webp",
|
||||
".avif", ".glb", ".js", ".json", ".ktx2", ".mp3", ".ogg", ".png",
|
||||
".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([
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets"),
|
||||
@@ -49,18 +69,33 @@ function packLabel(packId) {
|
||||
const [category, id = category] = packId.split(":", 2);
|
||||
const name = id.split("-").map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`).join(" ");
|
||||
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 (packId === "shared:creatures") return "Shared Creatures";
|
||||
if (packId === "core:ui") return "Interface";
|
||||
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;
|
||||
}
|
||||
|
||||
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[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]) {
|
||||
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]) {
|
||||
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] === "equipment") return "core:equipment";
|
||||
return "core:misc";
|
||||
}
|
||||
|
||||
async function walkFiles(directory) {
|
||||
const rootStats = await stat(directory);
|
||||
if (rootStats.isFile()) {
|
||||
return RUNTIME_EXTENSIONS.has(path.extname(directory).toLowerCase()) ? [directory] : [];
|
||||
}
|
||||
const files = [];
|
||||
async function visit(current) {
|
||||
const entries = await readdir(current, { withFileTypes: true });
|
||||
@@ -111,7 +151,9 @@ export async function scanContentSources(sources = DEFAULT_CONTENT_SOURCES) {
|
||||
const files = await walkFiles(sourceDirectory);
|
||||
for (const sourcePath of files) {
|
||||
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 sha256 = await sha256File(sourcePath);
|
||||
const prior = discovered.get(logicalPath);
|
||||
@@ -170,11 +212,17 @@ export function createContentManifest(files, options = {}) {
|
||||
|
||||
if (packs.has("shared:creatures")) {
|
||||
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"];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (packs.has("core:character-basic")) {
|
||||
for (const pack of packs.values()) {
|
||||
if (pack.id.startsWith("characters:")) pack.dependencies = ["core:character-basic"];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: CONTENT_SCHEMA_VERSION,
|
||||
|
||||
@@ -4,6 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
BUNDLED_ANDROID_PACKS,
|
||||
contentPackForPath,
|
||||
createContentManifest,
|
||||
publishContent,
|
||||
@@ -13,8 +14,19 @@ import {
|
||||
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/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/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 () => {
|
||||
|
||||
@@ -47,6 +47,14 @@ const azerothCoreReferenceFile = path.join(
|
||||
"generated",
|
||||
"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 sourceCatalogFile = path.join(projectRoot, "src", "game", "generated", "manastormCatalog.json");
|
||||
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 entry = Number(entity.entry);
|
||||
const basicCooldown = Math.max(1_250, Math.min(3_200, Number(entity.baseAttackTimeMs) || 1_900));
|
||||
const scriptDetails = (scriptCatalog?.scripts?.[entity.scriptName]?.spells ?? [])
|
||||
.filter((spell) => spell.combat);
|
||||
@@ -565,20 +759,27 @@ function combatFor(entity, boss, spells, scriptCatalog = undefined) {
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, boss ? 4 : 2)
|
||||
.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 level = levels.length
|
||||
? Math.max(1, Math.round(levels.reduce((sum, value) => sum + value, 0) / levels.length))
|
||||
: undefined;
|
||||
return {
|
||||
...(entry ? { serverEntry: entry } : {}),
|
||||
...(level ? { level } : {}),
|
||||
healthMultiplier: boss ? 1.15 + Math.min(1.5, Number(entity.rank || 0) * 0.18) : 1,
|
||||
damageMultiplier: boss ? 1.12 : 1,
|
||||
moveSpeed: Math.max(2.4, Math.min(5.2, 3.2 * (Number(entity.runSpeed) || 1))),
|
||||
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,
|
||||
moveSpeed: Math.max(2.4, Math.min(5.2, 3.2 * (Number(entity.runSpeed) || 1))),
|
||||
leashRange: boss ? 55 : 42,
|
||||
...(boss ? { mechanicIds: scripted.map((attack) => attack.id) } : {}),
|
||||
attacks: [{
|
||||
...basicAttack(basicCooldown),
|
||||
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 = {
|
||||
id,
|
||||
entry: Number(id.match(/\d+/)?.[0] ?? stableHash(id)),
|
||||
@@ -681,7 +883,7 @@ function syntheticEntity(id, name, boss, level) {
|
||||
name,
|
||||
...(boss ? { title: "Dungeon Boss", hasLoot: true } : { hasLoot: false }),
|
||||
combat: {
|
||||
...combatFor(entity, boss, new Map()),
|
||||
...combatFor(entity, boss, new Map(), undefined, undefined, bossMechanics),
|
||||
...(!boss ? { attacks: [basicAttack(), fallbackSpecial(id, false)] } : {}),
|
||||
},
|
||||
visual: visualFor(id, name, boss, entity.rank),
|
||||
@@ -764,6 +966,9 @@ async function compile() {
|
||||
const scriptSpellCatalog = await exists(scriptSpellCatalogFile)
|
||||
? await readJson(scriptSpellCatalogFile)
|
||||
: { scripts: {} };
|
||||
const azerothCoreWorldAi = await readJson(azerothCoreWorldAiFile);
|
||||
const bossMechanics = await readJson(bossMechanicsFile);
|
||||
validateBossMechanicsCatalog(bossMechanics);
|
||||
const azerothCoreReference = await readJson(azerothCoreReferenceFile);
|
||||
if (azerothCoreReference.wailingCaverns.unsupportedRequiredBossActions.length) {
|
||||
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 ?? []) {
|
||||
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);
|
||||
@@ -897,7 +1110,14 @@ async function compile() {
|
||||
...(boss ? { title: "Dungeon Boss", hasLoot: true } : {}),
|
||||
combat: recipe.slug === "wailing-caverns"
|
||||
? wailingServerCombatFor(source, boss, azerothCoreReference)
|
||||
: combatFor(source, boss, spells, scriptSpellCatalog),
|
||||
: combatFor(
|
||||
source,
|
||||
boss,
|
||||
spells,
|
||||
scriptSpellCatalog,
|
||||
azerothCoreWorldAi,
|
||||
bossMechanics,
|
||||
),
|
||||
visual: visualFor(
|
||||
source.id,
|
||||
source.name,
|
||||
@@ -920,7 +1140,14 @@ async function compile() {
|
||||
hasLoot: true,
|
||||
combat: recipe.slug === "wailing-caverns"
|
||||
? wailingServerCombatFor(source, true, azerothCoreReference)
|
||||
: combatFor(source, true, spells, scriptSpellCatalog),
|
||||
: combatFor(
|
||||
source,
|
||||
true,
|
||||
spells,
|
||||
scriptSpellCatalog,
|
||||
azerothCoreWorldAi,
|
||||
bossMechanics,
|
||||
),
|
||||
visual: visualFor(
|
||||
entityId,
|
||||
encounter.name,
|
||||
@@ -949,6 +1176,7 @@ async function compile() {
|
||||
encounter.name,
|
||||
true,
|
||||
recipe.difficultyVariants[0]?.levelRange?.[1] ?? 60,
|
||||
bossMechanics,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,14 +19,15 @@ const outputFile = path.join(
|
||||
"generated",
|
||||
"azerothCoreScriptSpells.json",
|
||||
);
|
||||
const epochSourceFile = path.join(
|
||||
const campaignSourceFile = path.join(
|
||||
projectRoot,
|
||||
"dungeon-pipeline",
|
||||
"epoch-five-player-instances.json",
|
||||
"dungeon-campaign.json",
|
||||
);
|
||||
const repository = "https://github.com/azerothcore/azerothcore-wotlk";
|
||||
const sourceRoots = [
|
||||
"src/server/scripts/Kalimdor/CavernsOfTime",
|
||||
"src/server/scripts/EasternKingdoms",
|
||||
"src/server/scripts/Kalimdor",
|
||||
"src/server/scripts/Northrend",
|
||||
"src/server/scripts/Outland",
|
||||
"src/server/scripts/World",
|
||||
@@ -45,7 +46,10 @@ const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
||||
|
||||
function runGit(argumentsList, cwd = projectRoot) {
|
||||
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,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
@@ -61,7 +65,10 @@ function runGit(argumentsList, cwd = projectRoot) {
|
||||
|
||||
function readGit(argumentsList, cwd = projectRoot) {
|
||||
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,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
@@ -203,8 +210,8 @@ if (checkedOutCommit !== DEFAULT_AZEROTHCORE_COMMIT) {
|
||||
`AzerothCore script checkout is ${checkedOutCommit}; rerun with --fetch to restore ${DEFAULT_AZEROTHCORE_COMMIT}.`,
|
||||
);
|
||||
}
|
||||
const epochSource = await readJson(epochSourceFile);
|
||||
const wantedSlugs = new Set(epochSource.dungeons.map((dungeon) => dungeon.slug));
|
||||
const campaignSource = await readJson(campaignSourceFile);
|
||||
const wantedSlugs = new Set(campaignSource.dungeons.map((dungeon) => dungeon.slug));
|
||||
const scriptNames = new Set();
|
||||
for (const slug of wantedSlugs) {
|
||||
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:
|
||||
|
||||
~~~powershell
|
||||
npm run runewaker:spells:refresh
|
||||
npm run runewaker:bosses:refresh
|
||||
npm run runewaker:build:all -- --phase population --force
|
||||
npm run runewaker:forsaken:population
|
||||
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
|
||||
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:
|
||||
|
||||
- verifies every executable attack.spellId against both its source template
|
||||
assignment and the portable spell catalog;
|
||||
- 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
|
||||
projected to the retained surface within 6m;
|
||||
- validates native animation families, with Rune Device's missing locomotion
|
||||
|
||||
@@ -8,11 +8,16 @@
|
||||
"runtimeSpawns": 4086,
|
||||
"runtimeEntities": 436,
|
||||
"bossObjectives": 61,
|
||||
"runtimeAttacks": 824,
|
||||
"runtimeAttacks": 1068,
|
||||
"genericDefaultAttacks": 436,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 388,
|
||||
"catalogBossMechanics": 279,
|
||||
"sourceBackedBossMechanics": 275,
|
||||
"sourceGapBossMechanics": 4,
|
||||
"telegraphedBossMechanics": 111,
|
||||
"healthTriggeredBossMechanics": 28,
|
||||
"sourceBackedDamageFamilyAttacks": 483,
|
||||
"sourceSpellTemplates": 293,
|
||||
"sourceSpellAssignments": 659,
|
||||
"scriptedTemplates": 357,
|
||||
@@ -27,9 +32,9 @@
|
||||
"dungeons": 30,
|
||||
"uniqueNativeModels": 321,
|
||||
"uniqueSourceSpellIds": 411,
|
||||
"portableSpellCatalogRecords": 411,
|
||||
"portableDamageFamilyRecords": 227,
|
||||
"portableEvidenceOnlyRecords": 184
|
||||
"portableSpellCatalogRecords": 3199,
|
||||
"portableDamageFamilyRecords": 1342,
|
||||
"portableEvidenceOnlyRecords": 1857
|
||||
},
|
||||
"mechanicsCategories": {
|
||||
"death": 75,
|
||||
@@ -53,7 +58,7 @@
|
||||
"boss spawn membership"
|
||||
],
|
||||
"genericRuntimeSystems": [
|
||||
"unmapped attacks",
|
||||
"source-gap attacks",
|
||||
"health/damage scaling",
|
||||
"movement/leash",
|
||||
"clustering/aggro",
|
||||
@@ -63,9 +68,9 @@
|
||||
"procedural animation"
|
||||
],
|
||||
"deferredMechanics": [
|
||||
"doors/gates",
|
||||
"summons/waves",
|
||||
"phases",
|
||||
"complex doors/gates",
|
||||
"unmapped multi-actor waves",
|
||||
"unmapped phases",
|
||||
"patrols",
|
||||
"interactions",
|
||||
"encounter resets",
|
||||
@@ -216,7 +221,10 @@
|
||||
},
|
||||
"sourceMechanics": {
|
||||
"status": "review-required",
|
||||
"executableMechanicsGenerated": false,
|
||||
"executableBossMechanicsGenerated": true,
|
||||
"catalogBossMechanics": 279,
|
||||
"sourceBackedBossMechanics": 275,
|
||||
"sourceGapBossMechanics": 4,
|
||||
"scriptedTemplates": 357,
|
||||
"evidenceCounts": {
|
||||
"death": 75,
|
||||
@@ -239,7 +247,7 @@
|
||||
"unknownEffectsExecute": 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": [
|
||||
{
|
||||
@@ -257,8 +265,7 @@
|
||||
"reviewNotes": [
|
||||
"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.",
|
||||
"436 executable attacks do not carry an audited source-spell mapping.",
|
||||
"Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.",
|
||||
"4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.",
|
||||
"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."
|
||||
],
|
||||
@@ -284,6 +291,11 @@
|
||||
"genericDefaultAttacks": 16,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 13,
|
||||
"nativeBindings": 16,
|
||||
"proceduralBindings": 0,
|
||||
@@ -333,10 +345,15 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 14,
|
||||
"runtimeAttacks": 15,
|
||||
"genericDefaultAttacks": 7,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 3,
|
||||
"sourceBackedBossMechanics": 3,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 3,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 7,
|
||||
"nativeBindings": 7,
|
||||
"proceduralBindings": 0,
|
||||
@@ -400,11 +417,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 9,
|
||||
"runtimeAttacks": 20,
|
||||
"genericDefaultAttacks": 7,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 2,
|
||||
"catalogBossMechanics": 11,
|
||||
"sourceBackedBossMechanics": 11,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 6,
|
||||
"healthTriggeredBossMechanics": 1,
|
||||
"sourceBackedDamageFamilyAttacks": 9,
|
||||
"nativeBindings": 7,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -453,11 +475,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 28,
|
||||
"runtimeAttacks": 32,
|
||||
"genericDefaultAttacks": 16,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 12,
|
||||
"catalogBossMechanics": 4,
|
||||
"sourceBackedBossMechanics": 4,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 1,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 14,
|
||||
"nativeBindings": 16,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -491,6 +518,11 @@
|
||||
"genericDefaultAttacks": 0,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 0,
|
||||
"nativeBindings": 0,
|
||||
"proceduralBindings": 0,
|
||||
@@ -525,6 +557,11 @@
|
||||
"genericDefaultAttacks": 17,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 5,
|
||||
"nativeBindings": 17,
|
||||
"proceduralBindings": 0,
|
||||
@@ -700,11 +737,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 15,
|
||||
"runtimeAttacks": 26,
|
||||
"genericDefaultAttacks": 15,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 0,
|
||||
"catalogBossMechanics": 11,
|
||||
"sourceBackedBossMechanics": 7,
|
||||
"sourceGapBossMechanics": 4,
|
||||
"telegraphedBossMechanics": 6,
|
||||
"healthTriggeredBossMechanics": 4,
|
||||
"sourceBackedDamageFamilyAttacks": 2,
|
||||
"nativeBindings": 15,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -738,6 +780,11 @@
|
||||
"genericDefaultAttacks": 11,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 10,
|
||||
"nativeBindings": 11,
|
||||
"proceduralBindings": 0,
|
||||
@@ -931,11 +978,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 74,
|
||||
"runtimeAttacks": 122,
|
||||
"genericDefaultAttacks": 40,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 34,
|
||||
"catalogBossMechanics": 49,
|
||||
"sourceBackedBossMechanics": 49,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 23,
|
||||
"healthTriggeredBossMechanics": 2,
|
||||
"sourceBackedDamageFamilyAttacks": 60,
|
||||
"nativeBindings": 39,
|
||||
"proceduralBindings": 1,
|
||||
"proxyBindings": 0,
|
||||
@@ -1052,11 +1104,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 54,
|
||||
"runtimeAttacks": 75,
|
||||
"genericDefaultAttacks": 26,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 28,
|
||||
"catalogBossMechanics": 25,
|
||||
"sourceBackedBossMechanics": 25,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 4,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 34,
|
||||
"nativeBindings": 26,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1173,11 +1230,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 68,
|
||||
"runtimeAttacks": 80,
|
||||
"genericDefaultAttacks": 32,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 36,
|
||||
"catalogBossMechanics": 16,
|
||||
"sourceBackedBossMechanics": 16,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 8,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 39,
|
||||
"nativeBindings": 32,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1211,6 +1273,11 @@
|
||||
"genericDefaultAttacks": 1,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 4,
|
||||
"nativeBindings": 1,
|
||||
"proceduralBindings": 0,
|
||||
@@ -1316,11 +1383,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 31,
|
||||
"runtimeAttacks": 48,
|
||||
"genericDefaultAttacks": 14,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 17,
|
||||
"catalogBossMechanics": 23,
|
||||
"sourceBackedBossMechanics": 23,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 8,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 20,
|
||||
"nativeBindings": 14,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1369,11 +1441,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 27,
|
||||
"runtimeAttacks": 33,
|
||||
"genericDefaultAttacks": 20,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 7,
|
||||
"catalogBossMechanics": 6,
|
||||
"sourceBackedBossMechanics": 6,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 4,
|
||||
"healthTriggeredBossMechanics": 1,
|
||||
"sourceBackedDamageFamilyAttacks": 11,
|
||||
"nativeBindings": 20,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1422,11 +1499,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 34,
|
||||
"runtimeAttacks": 40,
|
||||
"genericDefaultAttacks": 16,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 18,
|
||||
"catalogBossMechanics": 6,
|
||||
"sourceBackedBossMechanics": 6,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 2,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 22,
|
||||
"nativeBindings": 16,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1460,6 +1542,11 @@
|
||||
"genericDefaultAttacks": 14,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 9,
|
||||
"nativeBindings": 14,
|
||||
"proceduralBindings": 0,
|
||||
@@ -1545,10 +1632,15 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 38,
|
||||
"runtimeAttacks": 50,
|
||||
"genericDefaultAttacks": 12,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 23,
|
||||
"sourceBackedBossMechanics": 23,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 11,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 26,
|
||||
"nativeBindings": 12,
|
||||
"proceduralBindings": 0,
|
||||
@@ -1670,11 +1762,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 18,
|
||||
"runtimeAttacks": 52,
|
||||
"genericDefaultAttacks": 12,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 6,
|
||||
"catalogBossMechanics": 34,
|
||||
"sourceBackedBossMechanics": 34,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 9,
|
||||
"healthTriggeredBossMechanics": 16,
|
||||
"sourceBackedDamageFamilyAttacks": 13,
|
||||
"nativeBindings": 12,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1719,11 +1816,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 61,
|
||||
"runtimeAttacks": 65,
|
||||
"genericDefaultAttacks": 40,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 21,
|
||||
"catalogBossMechanics": 4,
|
||||
"sourceBackedBossMechanics": 4,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 3,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 25,
|
||||
"nativeBindings": 40,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1772,11 +1874,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 1,
|
||||
"runtimeAttacks": 5,
|
||||
"genericDefaultAttacks": 1,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 0,
|
||||
"catalogBossMechanics": 4,
|
||||
"sourceBackedBossMechanics": 4,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 3,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 2,
|
||||
"nativeBindings": 1,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1861,11 +1968,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 28,
|
||||
"runtimeAttacks": 31,
|
||||
"genericDefaultAttacks": 16,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 12,
|
||||
"catalogBossMechanics": 3,
|
||||
"sourceBackedBossMechanics": 3,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 2,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 14,
|
||||
"nativeBindings": 16,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1924,11 +2036,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 55,
|
||||
"runtimeAttacks": 64,
|
||||
"genericDefaultAttacks": 26,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 29,
|
||||
"catalogBossMechanics": 9,
|
||||
"sourceBackedBossMechanics": 9,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 1,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 34,
|
||||
"nativeBindings": 26,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1962,6 +2079,11 @@
|
||||
"genericDefaultAttacks": 25,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 32,
|
||||
"nativeBindings": 25,
|
||||
"proceduralBindings": 0,
|
||||
@@ -2035,11 +2157,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 55,
|
||||
"runtimeAttacks": 63,
|
||||
"genericDefaultAttacks": 21,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 34,
|
||||
"catalogBossMechanics": 12,
|
||||
"sourceBackedBossMechanics": 12,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 3,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 36,
|
||||
"nativeBindings": 21,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -2073,6 +2200,11 @@
|
||||
"genericDefaultAttacks": 1,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 0,
|
||||
"nativeBindings": 1,
|
||||
"proceduralBindings": 0,
|
||||
@@ -2150,11 +2282,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 5,
|
||||
"runtimeAttacks": 25,
|
||||
"genericDefaultAttacks": 3,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 2,
|
||||
"catalogBossMechanics": 22,
|
||||
"sourceBackedBossMechanics": 22,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 8,
|
||||
"healthTriggeredBossMechanics": 4,
|
||||
"sourceBackedDamageFamilyAttacks": 11,
|
||||
"nativeBindings": 3,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -2203,11 +2340,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 2,
|
||||
"runtimeAttacks": 8,
|
||||
"genericDefaultAttacks": 1,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 1,
|
||||
"catalogBossMechanics": 7,
|
||||
"sourceBackedBossMechanics": 7,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 4,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 5,
|
||||
"nativeBindings": 1,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -2256,11 +2398,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 1,
|
||||
"runtimeAttacks": 8,
|
||||
"genericDefaultAttacks": 1,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 0,
|
||||
"catalogBossMechanics": 7,
|
||||
"sourceBackedBossMechanics": 7,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 2,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 3,
|
||||
"nativeBindings": 1,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -2294,6 +2441,11 @@
|
||||
"genericDefaultAttacks": 25,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 23,
|
||||
"nativeBindings": 25,
|
||||
"proceduralBindings": 0,
|
||||
@@ -2328,6 +2480,11 @@
|
||||
"genericDefaultAttacks": 0,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 0,
|
||||
"nativeBindings": 0,
|
||||
"proceduralBindings": 0,
|
||||
|
||||
@@ -9,11 +9,13 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
|
||||
## Coverage
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -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.
|
||||
- Navmesh-projected placements derived from each packaged environment anchor.
|
||||
- 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
|
||||
|
||||
- 436 entities use generated Basic Attack/Corrosive Slam.
|
||||
- 0 attacks are builder exceptions without source mapping.
|
||||
- 388 executable attacks carry a conservative source-backed direct-damage family mapping.
|
||||
- 0 attacks are builder exceptions outside the modular boss catalog.
|
||||
- 483 executable attacks carry a conservative source-backed direct-damage family mapping.
|
||||
- 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.
|
||||
- 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,
|
||||
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 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| 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 |
|
||||
| cave-water-dragon | 144 | 97 | 7 | 2 | 9 | 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 |
|
||||
| barren-caves | 63 | 48 | 7 | 1 | 15 | 7 | 3 | 7/0/0 | 1/1 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 | 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 |
|
||||
| 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 |
|
||||
| hall-of-survivors | 363 | 162 | 40 | 9 | 74 | 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 |
|
||||
| ice-dwarf-kingdom | 651 | 599 | 32 | 5 | 68 | 25 | 11 | 32/0/0 | 3/5 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 | 75 | 19 | 15 | 26/0/0 | 2/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 |
|
||||
| kalins-shrine | 289 | 165 | 14 | 5 | 31 | 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 |
|
||||
| kulech-bones-nest | 287 | 154 | 16 | 1 | 34 | 14 | 20 | 16/0/0 | 1/1 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 | 33 | 13 | 24 | 20/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 |
|
||||
| mystic-altar | 398 | 251 | 12 | 3 | 38 | 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 |
|
||||
| raksha-temple | 487 | 123 | 40 | 1 | 61 | 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 |
|
||||
| sardo-castle | 247 | 71 | 16 | 3 | 28 | 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 |
|
||||
| mystic-altar | 398 | 251 | 12 | 3 | 50 | 11 | 15 | 12/0/0 | 3/3 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 | 65 | 29 | 29 | 40/0/0 | 0/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 | 31 | 7 | 24 | 16/0/0 | 3/3 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 |
|
||||
| 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 |
|
||||
| ystra-labyrinth-boss1 | 17 | 3 | 3 | 3 | 5 | 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-boss3 | 7 | 1 | 1 | 1 | 1 | 0 | 1 | 1/0/0 | 1/1 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 | 8 | 1 | 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-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: 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
|
||||
- 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.
|
||||
- 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.
|
||||
- Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.
|
||||
- 4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.
|
||||
- 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.
|
||||
@@ -194,6 +194,12 @@ function renderMarkdown(report) {
|
||||
"- " + summary.sourceSpellTemplates + " templates preserve source spell evidence ("
|
||||
+ summary.sourceSpellAssignments + " assignments; " + summary.uniqueSourceSpellIds
|
||||
+ " 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",
|
||||
"",
|
||||
@@ -201,18 +207,18 @@ function renderMarkdown(report) {
|
||||
" directions, model paths, source hashes, and boss spawn membership.",
|
||||
"- Navmesh-projected placements derived from each packaged environment anchor.",
|
||||
"- 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",
|
||||
"",
|
||||
"- " + 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
|
||||
+ " executable attacks carry a conservative source-backed direct-damage family mapping.",
|
||||
"- 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.",
|
||||
"- 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,",
|
||||
" attack, cast, wound, and death families.",
|
||||
"",
|
||||
@@ -243,8 +249,8 @@ function renderMarkdown(report) {
|
||||
...report.blockerGroups.navigation.derivedEntries.map((entry) => "- " + entry.dungeonId
|
||||
+ ": " + entry.from + " -> " + entry.to + " (" + entry.orderKind + ")"),
|
||||
"",
|
||||
"Unimplemented source mechanics: " + report.blockerGroups.sourceMechanics.scriptedTemplates
|
||||
+ " templates preserve AutoPlot/Lua evidence, with executable generation deliberately disabled.",
|
||||
"Source-mechanics review: " + report.blockerGroups.sourceMechanics.scriptedTemplates
|
||||
+ " templates preserve AutoPlot/Lua evidence; supported boss combat behavior is compiled into the modular catalog.",
|
||||
"",
|
||||
...Object.entries(report.blockerGroups.sourceMechanics.evidenceCounts)
|
||||
.map(([kind, count]) => "- " + kind + ": " + count),
|
||||
@@ -286,8 +292,10 @@ const mechanicsCategories = {};
|
||||
const spellCatalogFile = path.join(
|
||||
root, "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
);
|
||||
const bossMechanicsFile = path.join(root, "dungeon-pipeline/boss-mechanics.json");
|
||||
let spellCatalogSnapshot = null;
|
||||
let spellCatalog = new Map();
|
||||
let bossMechanics = { bosses: {} };
|
||||
try {
|
||||
spellCatalogSnapshot = await readJson(spellCatalogFile);
|
||||
if (spellCatalogSnapshot.status !== "portable"
|
||||
@@ -305,6 +313,14 @@ try {
|
||||
} catch (error) {
|
||||
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(
|
||||
path.join(root, "src/game/generated/runewakerDungeonDefinitions.generated.ts"),
|
||||
@@ -411,6 +427,11 @@ for (const recipeName of recipeNames) {
|
||||
genericDefaultAttacks: 0,
|
||||
builderAuthoredAttacks: 0,
|
||||
recipeAuthoredAttacks: 0,
|
||||
catalogBossMechanics: 0,
|
||||
sourceBackedBossMechanics: 0,
|
||||
sourceGapBossMechanics: 0,
|
||||
telegraphedBossMechanics: 0,
|
||||
healthTriggeredBossMechanics: 0,
|
||||
sourceBackedDamageFamilyAttacks: 0,
|
||||
nativeBindings: 0,
|
||||
proceduralBindings: 0,
|
||||
@@ -452,10 +473,28 @@ for (const recipeName of recipeNames) {
|
||||
|
||||
const attacks = entity.combat?.attacks ?? [];
|
||||
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 executableSourceSpellIds = 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) {
|
||||
counts.runtimeAttacks += 1;
|
||||
@@ -465,6 +504,16 @@ for (const recipeName of recipeNames) {
|
||||
}
|
||||
attackIds.add(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);
|
||||
if (executableSpellId > 0) {
|
||||
executableSourceSpellIds.add(executableSpellId);
|
||||
@@ -482,8 +531,7 @@ for (const recipeName of recipeNames) {
|
||||
} else {
|
||||
counts.sourceBackedDamageFamilyAttacks += 1;
|
||||
if (attack.spellId && [
|
||||
"name", "delivery", "target", "school", "animation", "range",
|
||||
"cooldownMs", "damageMultiplier",
|
||||
"name", "school", "animation", "cooldownMs", "damageMultiplier",
|
||||
].some((field) => attack[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 "
|
||||
+ sourceSpellId + ".");
|
||||
}
|
||||
} else if (attack.id.endsWith("-basic-attack")) {
|
||||
} else if (attack.id === "basic-attack" || attack.id.endsWith("-basic-attack")) {
|
||||
counts.genericDefaultAttacks += 1;
|
||||
} else if (!attack.spellId) {
|
||||
} else if (!catalogAuthored && !attack.spellId) {
|
||||
counts.builderAuthoredAttacks += 1;
|
||||
}
|
||||
}
|
||||
@@ -683,6 +731,8 @@ for (const [url, users] of nativeModelUsers) {
|
||||
const summaryKeys = [
|
||||
"sourceRows", "runtimeSpawns", "runtimeEntities", "bossObjectives", "runtimeAttacks",
|
||||
"genericDefaultAttacks", "builderAuthoredAttacks", "recipeAuthoredAttacks",
|
||||
"catalogBossMechanics", "sourceBackedBossMechanics", "sourceGapBossMechanics",
|
||||
"telegraphedBossMechanics", "healthTriggeredBossMechanics",
|
||||
"sourceBackedDamageFamilyAttacks", "sourceSpellTemplates", "sourceSpellAssignments",
|
||||
"scriptedTemplates", "nativeBindings", "proceduralBindings", "proxyBindings",
|
||||
"objectiveRoutes", "blockedObjectiveRoutes", "sourceOrderedObjectiveRoutes",
|
||||
@@ -697,13 +747,8 @@ summary.uniqueSourceSpellIds = sourceSpellIds.size;
|
||||
summary.portableSpellCatalogRecords = spellCatalog.size;
|
||||
summary.portableDamageFamilyRecords = spellCatalogSnapshot?.damageFamilyCount ?? 0;
|
||||
summary.portableEvidenceOnlyRecords = spellCatalogSnapshot?.evidenceOnlyCount ?? 0;
|
||||
if (summary.sourceBackedDamageFamilyAttacks < summary.runtimeAttacks) {
|
||||
reviewNotes.push((summary.runtimeAttacks - summary.sourceBackedDamageFamilyAttacks)
|
||||
+ " 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(summary.sourceGapBossMechanics
|
||||
+ " boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.");
|
||||
reviewNotes.push("Source-backed spell coefficients are HealerMan-generic; the original RuneWaker "
|
||||
+ "formula/stat scaling has not been reconstructed.");
|
||||
const navigationBlockerEntries = dungeons.flatMap((dungeon) => (
|
||||
@@ -751,11 +796,11 @@ const report = {
|
||||
"source positions/directions", "model paths", "source hashes", "boss spawn membership",
|
||||
],
|
||||
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",
|
||||
],
|
||||
deferredMechanics: [
|
||||
"doors/gates", "summons/waves", "phases", "patrols", "interactions",
|
||||
"complex doors/gates", "unmapped multi-actor waves", "unmapped phases", "patrols", "interactions",
|
||||
"encounter resets", "source loot",
|
||||
],
|
||||
blockerGroups: {
|
||||
@@ -769,7 +814,10 @@ const report = {
|
||||
},
|
||||
sourceMechanics: {
|
||||
status: "review-required",
|
||||
executableMechanicsGenerated: false,
|
||||
executableBossMechanicsGenerated: true,
|
||||
catalogBossMechanics: summary.catalogBossMechanics,
|
||||
sourceBackedBossMechanics: summary.sourceBackedBossMechanics,
|
||||
sourceGapBossMechanics: summary.sourceGapBossMechanics,
|
||||
scriptedTemplates: summary.scriptedTemplates,
|
||||
evidenceCounts: Object.fromEntries(Object.entries(mechanicsCategories).sort()),
|
||||
},
|
||||
@@ -779,7 +827,7 @@ const report = {
|
||||
status: spellCatalogSnapshot?.status ?? "missing",
|
||||
safety: spellCatalogSnapshot?.safety ?? null,
|
||||
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,
|
||||
documentedExceptions,
|
||||
|
||||
@@ -63,6 +63,7 @@ const khronosValidator = path.join(
|
||||
"manastorm-assets",
|
||||
"validate-package-glbs.cjs",
|
||||
);
|
||||
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
|
||||
|
||||
function run(executable, argumentsList, label) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -286,21 +287,8 @@ function distanceSquared(a, b) {
|
||||
}
|
||||
|
||||
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 {
|
||||
id: "rw-fa-basic-attack",
|
||||
id: "basic-attack",
|
||||
name: template.archetype === "ooze" ? "Corrosive Slam" : "Basic Attack",
|
||||
delivery: "melee",
|
||||
target: "primary",
|
||||
@@ -312,8 +300,9 @@ function attackFor(template) {
|
||||
};
|
||||
}
|
||||
|
||||
function entityFor(template, actor) {
|
||||
function entityFor(template, actor, bossMechanics) {
|
||||
const boss = template.classification === "boss";
|
||||
const mechanics = bossMechanics?.bosses?.["rw-fa-" + template.id]?.mechanics ?? [];
|
||||
return {
|
||||
id: "rw-fa-" + template.id,
|
||||
name: template.name,
|
||||
@@ -326,7 +315,8 @@ function entityFor(template, actor) {
|
||||
damageMultiplier: boss ? 1.3 : 1,
|
||||
moveSpeed: boss ? 2.25 : 2.15,
|
||||
leashRange: boss ? 32 : 24,
|
||||
attacks: [attackFor(template)],
|
||||
...(boss ? { mechanicIds: mechanics.map((attack) => attack.id) } : {}),
|
||||
attacks: [attackFor(template), ...mechanics],
|
||||
},
|
||||
visual: {
|
||||
primaryColor: template.primaryColor,
|
||||
@@ -384,11 +374,16 @@ async function generatePopulation() {
|
||||
manifestFile,
|
||||
"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(manifestFile, "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 actors = new Map(manifest.assets.map((actor) => [actor.id, actor]));
|
||||
@@ -438,7 +433,7 @@ async function generatePopulation() {
|
||||
|
||||
const entityId = "rw-fa-" + row.templateId;
|
||||
if (!entities[entityId]) {
|
||||
entities[entityId] = entityFor(template, actors.get(template.actor));
|
||||
entities[entityId] = entityFor(template, actors.get(template.actor), bossMechanics);
|
||||
}
|
||||
const spawn = {
|
||||
id: "rw-fa-spawn-" + row.spawnId,
|
||||
|
||||
@@ -42,6 +42,7 @@ const spellCatalogFile = path.join(
|
||||
"snapshots",
|
||||
"runewaker-npc-spell-catalog.json",
|
||||
);
|
||||
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
|
||||
const paperdollCatalogFile = path.join(
|
||||
pipelineDirectory,
|
||||
"snapshots",
|
||||
@@ -430,11 +431,10 @@ function yawFromDirection(direction) {
|
||||
return Number(((((direction % turn) + turn) % turn) * Math.PI * 2 / turn).toFixed(9));
|
||||
}
|
||||
|
||||
function attacksFor(template, sourceSpells, spellCatalog) {
|
||||
if (Array.isArray(template.attacks) && template.attacks.length) return template.attacks;
|
||||
function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
|
||||
const ooze = template.archetype === "ooze";
|
||||
const basicAttack = {
|
||||
id: runtimePrefix + "-basic-attack",
|
||||
id: "basic-attack",
|
||||
name: ooze ? "Corrosive Slam" : "Basic Attack",
|
||||
delivery: "melee",
|
||||
target: "primary",
|
||||
@@ -462,13 +462,24 @@ function attacksFor(template, sourceSpells, spellCatalog) {
|
||||
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 defaults = recipe.combatDefaults ?? {};
|
||||
const combat = template.combat ?? {};
|
||||
const attacks = attacksFor(template, sourceSpells, spellCatalog, bossMechanics);
|
||||
return {
|
||||
id: runtimePrefix + "-" + template.id,
|
||||
name: template.name,
|
||||
@@ -481,7 +492,8 @@ function entityFor(template, actor, sourceSpells, spellCatalog) {
|
||||
damageMultiplier: combat.damageMultiplier ?? (boss ? defaults.bossDamageMultiplier ?? 1.3 : defaults.damageMultiplier ?? 1),
|
||||
moveSpeed: combat.moveSpeed ?? (boss ? defaults.bossMoveSpeed ?? 2.25 : defaults.moveSpeed ?? 2.15),
|
||||
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: {
|
||||
primaryColor: template.primaryColor,
|
||||
@@ -537,12 +549,14 @@ async function generatePopulation() {
|
||||
requireFile(manifestFile, "Run build-population.mjs --actors-only 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(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(manifestFile, "utf8").then(JSON.parse),
|
||||
readFile(resolveProject(recipe.files.environmentMetadata), "utf8").then(JSON.parse),
|
||||
readFile(spellCatalogFile, "utf8").then(JSON.parse),
|
||||
readFile(bossMechanicsFile, "utf8").then(JSON.parse),
|
||||
]);
|
||||
if (spellCatalogSnapshot.status !== "portable"
|
||||
|| spellCatalogSnapshot.safety?.runtimeSqlDependency !== false
|
||||
@@ -619,6 +633,7 @@ async function generatePopulation() {
|
||||
actorForTemplate(template),
|
||||
[...(sourceSpellsByTemplate.get(template.id)?.values() ?? [])],
|
||||
spellCatalog,
|
||||
bossMechanics,
|
||||
);
|
||||
}
|
||||
const spawn = {
|
||||
|
||||
@@ -36,6 +36,7 @@ Usage:
|
||||
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 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 build --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);
|
||||
} else if (command === "mechanics") {
|
||||
await run("extract-mechanics.mjs", argv);
|
||||
} else if (command === "boss-mechanics") {
|
||||
await run("import-boss-mechanics.mjs", argv);
|
||||
} else if (command === "validate") {
|
||||
await run("validate-instance.mjs", argv);
|
||||
} else if (command === "prepare-all") {
|
||||
|
||||
@@ -165,6 +165,28 @@ for (const recipe of recipes) {
|
||||
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 = [];
|
||||
|
||||
@@ -70,7 +70,7 @@ for (const dungeon of dungeons) {
|
||||
String(actorManifest.proceduralFallbacks.length)
|
||||
+ " 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 = {
|
||||
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": [] },
|
||||
presentationSettingsByOwner: {
|
||||
"offline-roster": {
|
||||
vfxMode: "original",
|
||||
vfxQuality: "low",
|
||||
vfxEnabled: { player: true, party: false, enemy: true },
|
||||
audioEnabled: { player: true, party: true, enemy: false },
|
||||
@@ -172,12 +173,15 @@ describe("local profile repository", () => {
|
||||
});
|
||||
expect(JSON.parse(legacyV3Storage.getItem(PROFILE_STORAGE_KEYS.database)!)
|
||||
.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 firstOwner = (await createLocalAccount("EffectsOne", "eight-plus", storage)).session!.ownerId;
|
||||
const secondOwner = (await createLocalAccount("EffectsTwo", "eight-plus", storage)).session!.ownerId;
|
||||
const customized = updateCombatPresentationSettings(firstOwner, {
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
originalMediaEnabled: true,
|
||||
vfxMode: "original",
|
||||
vfxQuality: "low",
|
||||
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)
|
||||
? (settings as { vfxMode?: unknown }).vfxMode
|
||||
: 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];
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/aeternal-circle-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/barren-caves-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/cave-water-dragon-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold-125-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/dungeon-of-dalanis-population.json",
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"schemaVersion": 1,
|
||||
"dungeonId": "forsaken-abbey",
|
||||
"zoneId": 102,
|
||||
"sourceRowCount": 251,
|
||||
"importedSpawnCount": 150,
|
||||
"excludedRowCount": 101,
|
||||
"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",
|
||||
"sourceContentSha256": "13ef6edd3df6e71389aa92d01201889148c0794cf9ecaafdbaf90d3145d2bf7b",
|
||||
"sourceHashes": {
|
||||
@@ -26,7 +19,7 @@
|
||||
"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": {
|
||||
"maximumAllowedDistance": 2,
|
||||
"maximumObservedDistance": 2.5965,
|
||||
@@ -35,9 +28,6 @@
|
||||
},
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"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",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/forsaken-abbey-population.json",
|
||||
"actorManifest": "public/assets/creatures/forsaken-abbey/manifest.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/grafu-castle-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/hall-of-survivors-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/hoto-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/ice-dwarf-kingdom-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/kal-turok-hive-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/kalins-shrine-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/kawaks-tomb-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/kulech-bones-nest-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/lair-demon-dragon-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/mystic-altar-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/paspers-shrine-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/raksha-temple-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/sand-vortex-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/sardo-castle-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/the-origin-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/tomb-seven-heroes-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/treasure-trove-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/warnorken-arena-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss2-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss3-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold-124-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold-population.json",
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
} from "../app/characterCatalog";
|
||||
import type { InventoryItem } from "../game/lootTypes";
|
||||
import { resolveContentUrl } from "../content/contentManager";
|
||||
import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||
import {
|
||||
selectCharacterBaseClip,
|
||||
selectCharacterEventClip,
|
||||
@@ -144,6 +145,7 @@ function useManifestEntry(identity: AvatarIdentity | null | undefined): {
|
||||
manifest: AvatarManifest | null;
|
||||
entry: AvatarManifestEntry | null;
|
||||
} {
|
||||
const contentRevision = useContentManagerSnapshot().installedRevision;
|
||||
const [state, setState] = useState<{ manifest: AvatarManifest; entry: AvatarManifestEntry } | null>(null);
|
||||
const key = identity ? avatarManifestKey(identity.raceId, identity.gender) : "";
|
||||
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);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [gender, key, raceId]);
|
||||
}, [contentRevision, gender, key, raceId]);
|
||||
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 type { AvatarIdentity } from "./CharacterModel";
|
||||
import {
|
||||
availableAvatarManifestUrls,
|
||||
getAvatarEntry,
|
||||
loadAvatarManifest,
|
||||
} from "./characterManifest";
|
||||
@@ -16,6 +17,7 @@ const requestedModels = new Set<string>();
|
||||
export async function preloadAvatarModels(
|
||||
identities: readonly (AvatarIdentity | null | undefined)[],
|
||||
): Promise<void> {
|
||||
if (!availableAvatarManifestUrls().length) return;
|
||||
const manifest = await loadAvatarManifest();
|
||||
for (const identity of identities) {
|
||||
if (!identity) continue;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
availableAvatarManifestUrls,
|
||||
getAvatarEntry,
|
||||
loadAvatarManifestSources,
|
||||
type AvatarManifest,
|
||||
type AvatarManifestEntry,
|
||||
@@ -33,6 +35,32 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const base = manifest("3.3.5", {
|
||||
|
||||
+108
-20
@@ -1,5 +1,8 @@
|
||||
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 TextureRegion = readonly [number, number, number, number];
|
||||
@@ -73,6 +76,8 @@ export interface AvatarManifestEntry {
|
||||
featureOptions: readonly AvatarFeatureOption[];
|
||||
attachmentPoints?: Readonly<Record<string, AvatarAttachmentPoint>>;
|
||||
rendererStrategy?: RunewakerModularRendererStrategy;
|
||||
/** Runtime ownership; generated manifests may omit it. */
|
||||
contentPackId?: string;
|
||||
}
|
||||
|
||||
export interface AvatarManifest {
|
||||
@@ -97,12 +102,24 @@ export interface AvatarManifest {
|
||||
export type CharacterManifest = AvatarManifest;
|
||||
|
||||
export const AVATAR_MANIFEST_URL = "/assets/characters/wow335a-v1/manifest.json";
|
||||
export const AVATAR_MANIFEST_URLS = [
|
||||
AVATAR_MANIFEST_URL,
|
||||
"/assets/characters/ascension-v1/manifest.json",
|
||||
"/assets/characters/runewaker-v1/manifest.json",
|
||||
export const BASIC_CHARACTER_CONTENT_PACK_ID = "core:character-basic";
|
||||
export const WOW_CHARACTER_CONTENT_PACK_ID = "characters:wow335a-v1";
|
||||
export const AVATAR_MANIFEST_SOURCES = [
|
||||
{
|
||||
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;
|
||||
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 {
|
||||
readonly ok: boolean;
|
||||
@@ -114,12 +131,37 @@ export function avatarManifestKey(raceId: RaceId, gender: GenderId): string {
|
||||
return `${raceId}:${gender}`;
|
||||
}
|
||||
|
||||
function mergeAvatarManifests(manifests: readonly AvatarManifest[]): AvatarManifest {
|
||||
const [baseManifest, ...extensions] = manifests;
|
||||
export function availableAvatarManifestUrls(
|
||||
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 {
|
||||
...baseManifest,
|
||||
sourceBuild: manifests.map((manifest) => manifest.sourceBuild).join(" + "),
|
||||
entries: Object.assign({}, baseManifest.entries, ...extensions.map((manifest) => manifest.entries)),
|
||||
sourceBuild: sources.map(({ manifest }) => manifest.sourceBuild).join(" + "),
|
||||
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) {
|
||||
throw new Error(`Avatar manifest is malformed: ${url}`);
|
||||
}
|
||||
return manifest;
|
||||
return { url, manifest };
|
||||
}));
|
||||
const manifests = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
|
||||
if (!manifests.length) {
|
||||
const sources = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
|
||||
if (!sources.length) {
|
||||
const firstFailure = results.find((result) => result.status === "rejected");
|
||||
throw firstFailure?.status === "rejected"
|
||||
? firstFailure.reason
|
||||
@@ -152,27 +194,73 @@ export async function loadAvatarManifestSources(
|
||||
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> {
|
||||
if (!manifestPromise) {
|
||||
manifestPromise = loadAvatarManifestSources(
|
||||
AVATAR_MANIFEST_URLS,
|
||||
const urls = availableAvatarManifestUrls();
|
||||
if (!urls.length) throw new Error("No optional playable-character packs are installed");
|
||||
const key = urls.join("|");
|
||||
if (manifestRequest?.key !== key) {
|
||||
const promise = loadAvatarManifestSources(
|
||||
urls,
|
||||
(url) => fetch(resolveContentUrl(url), { cache: "force-cache" }),
|
||||
)
|
||||
.catch((error) => {
|
||||
manifestPromise = null;
|
||||
if (manifestRequest?.promise === promise) manifestRequest = null;
|
||||
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(
|
||||
manifest: AvatarManifest,
|
||||
raceId: RaceId,
|
||||
gender: GenderId,
|
||||
isUsable: (packId: string) => boolean = contentPackIsUsable,
|
||||
): 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 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 {
|
||||
readonly packId: string;
|
||||
@@ -29,6 +45,7 @@ interface PersistedContentState {
|
||||
|
||||
export interface ContentManagerSnapshot {
|
||||
readonly revision: number;
|
||||
readonly installedRevision: number;
|
||||
readonly checking: boolean;
|
||||
readonly manifest: ContentManifest | null;
|
||||
readonly bundledManifest: ContentManifest | null;
|
||||
@@ -42,6 +59,7 @@ const EMPTY_STATE: PersistedContentState = { version: 1, packVersions: {}, files
|
||||
let installedState = readInstalledState();
|
||||
let snapshot: ContentManagerSnapshot = {
|
||||
revision: 0,
|
||||
installedRevision: 0,
|
||||
checking: false,
|
||||
manifest: null,
|
||||
bundledManifest: null,
|
||||
@@ -87,13 +105,45 @@ function absoluteContentUrl(value: string): string {
|
||||
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 {
|
||||
const match = /(?:\.[A-Za-z0-9]+)(?:\?.*)?$/.exec(entry.url);
|
||||
return match?.[0].split("?")[0].toLowerCase() ?? ".bin";
|
||||
}
|
||||
|
||||
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 }>> {
|
||||
@@ -117,21 +167,31 @@ export function resolveContentUrl(logicalUrl: string): string {
|
||||
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 {
|
||||
if (!Capacitor.isNativePlatform()) return true;
|
||||
const installed = STATIC_ANDROID_BUNDLED_PACKS.has(packId)
|
||||
|| snapshot.bundledManifest?.packs.some((pack) => pack.id === packId)
|
||||
|| Boolean(installedState.packVersions[packId]);
|
||||
if (!installed) return false;
|
||||
if (contentPackIsBundled(packId)) return true;
|
||||
if (!installedState.packVersions[packId]) return false;
|
||||
const pack = snapshot.manifest?.packs.find((candidate) => candidate.id === packId);
|
||||
return pack?.dependencies.every((dependency) => contentPackIsUsable(dependency)) ?? true;
|
||||
}
|
||||
|
||||
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);
|
||||
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> {
|
||||
@@ -149,7 +209,7 @@ export async function checkForContentUpdates(): Promise<ContentManifest | null>
|
||||
}
|
||||
|
||||
async function loadBundledManifest(): Promise<void> {
|
||||
if (!Capacitor.isNativePlatform()) return;
|
||||
if (!Capacitor.isNativePlatform() && import.meta.env.VITE_CONTENT_MODE !== "downloadable") return;
|
||||
try {
|
||||
const response = await fetch("/content/bootstrap-manifest.json", { cache: "force-cache" });
|
||||
if (!response.ok) return;
|
||||
@@ -162,13 +222,102 @@ async function loadBundledManifest(): Promise<void> {
|
||||
export function initializeContentSystem(): Promise<void> {
|
||||
if (!initialization) {
|
||||
initialization = (async () => {
|
||||
await loadBundledManifest();
|
||||
await Promise.all([loadBundledManifest(), registerBrowserContentWorker()]);
|
||||
if (typeof navigator === "undefined" || navigator.onLine !== false) void checkForContentUpdates();
|
||||
})();
|
||||
}
|
||||
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> {
|
||||
const manifest = snapshot.manifest ?? await checkForContentUpdates();
|
||||
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);
|
||||
if (!pack) throw new Error(`Unknown content pack: ${packId}`);
|
||||
if (!Capacitor.isNativePlatform()) return;
|
||||
|
||||
for (const dependency of pack.dependencies) {
|
||||
if (!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)) {
|
||||
await installContentPack(dependency);
|
||||
if (!contentPackIsBundled(packId)) {
|
||||
for (const dependency of pack.dependencies) {
|
||||
if (!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)) {
|
||||
await installContentPack(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 storage = await NativeContentStorage.getFreeBytes();
|
||||
const safetyReserve = 128 * 1024 * 1024;
|
||||
@@ -232,7 +386,13 @@ export async function installContentPack(packId: string): Promise<void> {
|
||||
packVersions: { ...installedState.packVersions, [packId]: manifest.version },
|
||||
};
|
||||
persistInstalledState();
|
||||
publish({ installingPackId: null, downloadedBytes: totalBytes, totalBytes, error: "" });
|
||||
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;
|
||||
@@ -242,19 +402,29 @@ export async function installContentPack(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 targetFiles = Object.entries(remainingFiles).filter(([, entry]) => entry.packId === packId);
|
||||
for (const [logicalPath, entry] of targetFiles) {
|
||||
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 (Capacitor.isNativePlatform()) {
|
||||
for (const [logicalPath, entry] of targetFiles) {
|
||||
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];
|
||||
}
|
||||
} 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 };
|
||||
delete packVersions[packId];
|
||||
installedState = { version: 1, files: remainingFiles, packVersions };
|
||||
persistInstalledState();
|
||||
publish({ error: "" });
|
||||
publish({ error: "", installedRevision: snapshot.installedRevision + 1 });
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
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 {
|
||||
DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
type CombatPresentationEvent,
|
||||
} from "./combatPresentation";
|
||||
import {
|
||||
combatAudioOutputEnabled,
|
||||
authenticCombatAudioEnabled,
|
||||
combatMediaPreloadEnabled,
|
||||
configureCombatAudio,
|
||||
masterAudioOutputEnabled,
|
||||
playCombatAudio,
|
||||
@@ -28,7 +38,10 @@ const fixtureEvent: CombatPresentationEvent = {
|
||||
targetPosition: [1, 0, 0],
|
||||
};
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
afterEach(() => {
|
||||
combatMedia.ready = false;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("combat audio output policy", () => {
|
||||
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(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0)).toBe(false);
|
||||
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", () => {
|
||||
|
||||
+22
-2
@@ -6,6 +6,10 @@ import {
|
||||
type CombatPresentationSettings,
|
||||
type CombatPresentationSchool,
|
||||
} from "./combatPresentation";
|
||||
import {
|
||||
combatMediaPackReady,
|
||||
fetchOptionalMediaAsset,
|
||||
} from "./combatMediaPack";
|
||||
|
||||
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(
|
||||
settings: CombatPresentationSettings,
|
||||
masterVolume: number,
|
||||
@@ -185,6 +202,7 @@ function playSynthesizedCombatCue(event: CombatPresentationEvent): void {
|
||||
}
|
||||
|
||||
function weightedSound(event: CombatPresentationEvent): CombatEffectSoundAsset | null {
|
||||
if (!configuredSettings || !authenticCombatAudioEnabled(configuredSettings)) return null;
|
||||
const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? [];
|
||||
const playable = sounds.filter((sound) => sound.url);
|
||||
if (!playable.length) return null;
|
||||
@@ -201,7 +219,7 @@ async function decodeAudio(url: string): Promise<AudioBuffer | null> {
|
||||
const audioContext = ensureContext();
|
||||
if (!audioContext) return null;
|
||||
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((data) => audioContext.decodeAudioData(data))
|
||||
.catch(() => null));
|
||||
@@ -213,7 +231,9 @@ export async function playMasterAudioCue(
|
||||
url: string,
|
||||
volume = 1,
|
||||
): Promise<(() => void) | null> {
|
||||
if (!masterAudioOutputEnabled(configuredMasterVolume)) return null;
|
||||
if (!configuredSettings
|
||||
|| !authenticCombatAudioEnabled(configuredSettings)
|
||||
|| !masterAudioOutputEnabled(configuredMasterVolume)) return null;
|
||||
const audioContext = ensureContext();
|
||||
const buffer = await decodeAudio(url);
|
||||
if (
|
||||
|
||||
@@ -116,6 +116,9 @@ describe("named aura combat runtime", () => {
|
||||
school: "fire",
|
||||
interruptible: true,
|
||||
targetActorId: PLAYER_AGGRO_ID,
|
||||
sourcePosition: [0, 0, 0],
|
||||
targetPosition: [1, 0, 0],
|
||||
sourceYaw: Math.PI / 2,
|
||||
startedAt: 1_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 },
|
||||
audioVolume: { player: 2, party: -1, enemy: 0.35 },
|
||||
})).toEqual({
|
||||
originalMediaEnabled: false,
|
||||
vfxMode: "generated",
|
||||
vfxQuality: "low",
|
||||
vfxEnabled: { player: false, party: true, enemy: true },
|
||||
@@ -31,8 +32,13 @@ describe("combat presentation", () => {
|
||||
});
|
||||
expect(normalizeCombatPresentationSettings({
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
originalMediaEnabled: true,
|
||||
vfxMode: "original",
|
||||
}).vfxMode).toBe("original");
|
||||
expect(normalizeCombatPresentationSettings({
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
vfxMode: "original",
|
||||
}).vfxMode).toBe("generated");
|
||||
expect(normalizeCombatPresentationSettings({
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
vfxMode: "hybrid",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
import {
|
||||
COMBAT_MEDIA_MANIFEST_URL,
|
||||
combatMediaPackReady,
|
||||
fetchCombatMediaAsset,
|
||||
} from "./combatMediaPack";
|
||||
|
||||
export type CombatActorGroup = "player" | "party" | "enemy";
|
||||
export type CombatVfxMode = "generated" | "original";
|
||||
@@ -24,6 +29,7 @@ export type CombatPresentationSource = "wow335" | "ascension" | "runewaker" | "f
|
||||
export type CombatPresentationPosition = readonly [number, number, number];
|
||||
|
||||
export interface CombatPresentationSettings {
|
||||
readonly originalMediaEnabled: boolean;
|
||||
readonly vfxMode: CombatVfxMode;
|
||||
readonly vfxQuality: CombatVfxQuality;
|
||||
readonly vfxEnabled: Readonly<Record<CombatActorGroup, boolean>>;
|
||||
@@ -34,6 +40,7 @@ export interface CombatPresentationSettings {
|
||||
const ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
|
||||
|
||||
export const DEFAULT_COMBAT_PRESENTATION_SETTINGS: CombatPresentationSettings = Object.freeze({
|
||||
originalMediaEnabled: false,
|
||||
vfxMode: "generated",
|
||||
vfxQuality: "high",
|
||||
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)
|
||||
? 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
|
||||
: fallback.vfxMode;
|
||||
const vfxMode = requestedVfxMode === "original" && !originalMediaEnabled
|
||||
? "generated"
|
||||
: requestedVfxMode;
|
||||
const vfxQuality = record.vfxQuality === "off" || record.vfxQuality === "low" || record.vfxQuality === "high"
|
||||
? record.vfxQuality
|
||||
: fallback.vfxQuality;
|
||||
return Object.freeze({
|
||||
originalMediaEnabled,
|
||||
vfxMode,
|
||||
vfxQuality,
|
||||
vfxEnabled: booleanGroups(record.vfxEnabled, fallback.vfxEnabled),
|
||||
@@ -191,7 +205,6 @@ interface GeneratedCombatEffectManifest {
|
||||
readonly entries: readonly CombatEffectManifestEntry[];
|
||||
}
|
||||
|
||||
const COMBAT_EFFECT_MANIFEST_URL = "/assets/game/combat-effects/manifest.json";
|
||||
const manifestByKey = new Map<string, CombatEffectManifestEntry>();
|
||||
const manifestBySpellId = new Map<number, CombatEffectManifestEntry>();
|
||||
let manifestRequest: Promise<void> | null = null;
|
||||
@@ -207,13 +220,19 @@ function installCombatEffectManifest(manifest: GeneratedCombatEffectManifest): v
|
||||
export function preloadCombatEffectManifest(): Promise<void> {
|
||||
if (manifestRequest) return manifestRequest;
|
||||
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((manifest) => installCombatEffectManifest(manifest as GeneratedCombatEffectManifest))
|
||||
.catch(() => undefined);
|
||||
return manifestRequest;
|
||||
}
|
||||
|
||||
export function effectiveCombatVfxMode(settings: CombatPresentationSettings): CombatVfxMode {
|
||||
return settings.originalMediaEnabled && combatMediaPackReady()
|
||||
? settings.vfxMode
|
||||
: "generated";
|
||||
}
|
||||
|
||||
export function combatEffectManifestEntry(
|
||||
spellId: number | undefined,
|
||||
source?: CombatPresentationSource,
|
||||
|
||||
@@ -83,12 +83,14 @@ import {
|
||||
} from "./talentModifiers";
|
||||
import {
|
||||
getMobPosition,
|
||||
getMobRuntimeTransform,
|
||||
mobRuntimeDistanceSquared,
|
||||
nearbyMobRuntimeIds,
|
||||
removeMobPosition,
|
||||
setMobPosition,
|
||||
type CombatPosition,
|
||||
} from "./mobRuntimeRegistry";
|
||||
import { enemyTelegraphContainsPosition } from "./enemyTelegraphGeometry";
|
||||
import {
|
||||
getPartyRuntimePosition,
|
||||
partyRuntimeDistanceSquared,
|
||||
@@ -299,6 +301,9 @@ export interface EnemyActiveCast {
|
||||
readonly school: EnemyDamageSchool;
|
||||
readonly interruptible: boolean;
|
||||
readonly targetActorId: AggroActorId | null;
|
||||
readonly sourcePosition: CombatPosition;
|
||||
readonly targetPosition: CombatPosition;
|
||||
readonly sourceYaw: number;
|
||||
readonly startedAt: number;
|
||||
readonly completesAt: number;
|
||||
}
|
||||
@@ -6163,6 +6168,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
);
|
||||
if (selectedAttack && startingEnemyCast) {
|
||||
const attack = selectedAttack;
|
||||
const sourceTransform = getMobRuntimeTransform(id);
|
||||
enemyActiveCast = {
|
||||
attackId: attack.id,
|
||||
name: attack.name,
|
||||
@@ -6170,6 +6176,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
school: attack.school,
|
||||
interruptible: attack.interruptible !== false,
|
||||
targetActorId: resolution.targetActorId,
|
||||
sourcePosition: [...position],
|
||||
targetPosition: [...targetPosition],
|
||||
sourceYaw: sourceTransform?.yaw
|
||||
?? Math.atan2(targetPosition[0] - position[0], targetPosition[2] - position[2]),
|
||||
startedAt: now,
|
||||
completesAt: now + (attack.castTimeMs ?? 0),
|
||||
};
|
||||
@@ -6187,6 +6197,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
);
|
||||
} else if (selectedAttack && !cannotAttack) {
|
||||
const attack = selectedAttack!;
|
||||
const resolvedEnemyCast = enemyActiveCast;
|
||||
enemyActiveCast = null;
|
||||
const effect = attack.effect ?? {
|
||||
kind: "damage" as const,
|
||||
@@ -6198,10 +6209,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
stableCombatUnit(`${id}:${attack.id}:${attackRevision}:target`) * eligibleActors.length,
|
||||
)]
|
||||
: 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"
|
||||
? eligibleActors.filter((actorId) => {
|
||||
const candidate = actorPosition(runtimeState, actorId);
|
||||
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 targetDx = candidate[0] - position[0];
|
||||
const targetDy = candidate[1] - position[1];
|
||||
@@ -6298,6 +6323,21 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
targetId: id,
|
||||
now,
|
||||
}).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") {
|
||||
for (const actorId of targets) {
|
||||
if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue;
|
||||
|
||||
@@ -167,6 +167,7 @@ describe("generated dungeon campaign catalog", () => {
|
||||
"Fear",
|
||||
"Magic's Bane",
|
||||
"Corrupt Soul",
|
||||
"Soulstorm",
|
||||
]);
|
||||
expect(attackNames(culling!, "Mal'Ganis")).toEqual([
|
||||
"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", () => {
|
||||
const ids = new Set(generated.definitions.map((dungeon) => dungeon.id));
|
||||
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 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 =
|
||||
| { readonly kind: "damage"; readonly multiplier: number }
|
||||
| { readonly kind: "heal"; readonly multiplier: number }
|
||||
@@ -26,6 +41,11 @@ export type EnemyAbilityEffect =
|
||||
readonly durationMs: number;
|
||||
readonly magnitude?: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: "target-aura";
|
||||
readonly aura: string;
|
||||
readonly durationMs: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: "control";
|
||||
readonly mechanic: EnemyControlMechanic;
|
||||
@@ -83,6 +103,7 @@ export interface EnemyAttackDefinition {
|
||||
readonly maximumCasts?: number;
|
||||
readonly damageMultiplier: number;
|
||||
readonly radius?: number;
|
||||
readonly telegraph?: EnemyAttackTelegraph;
|
||||
readonly effect?: EnemyAbilityEffect;
|
||||
readonly condition?: EnemyAbilityCondition;
|
||||
readonly source?: EnemyAbilitySource;
|
||||
@@ -96,6 +117,8 @@ export interface EnemyCombatDefinition {
|
||||
readonly moveSpeed?: number;
|
||||
readonly aggroRange?: number;
|
||||
readonly leashRange?: number;
|
||||
/** Stable special-attack ids available for future encounter loadout randomization. */
|
||||
readonly mechanicIds?: readonly string[];
|
||||
readonly attacks: readonly EnemyAttackDefinition[];
|
||||
}
|
||||
|
||||
@@ -125,14 +148,94 @@ function normalizeCooldownRange(
|
||||
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(
|
||||
value: EnemyAttackDefinition,
|
||||
fallback: EnemyAttackDefinition = DEFAULT_ENEMY_ATTACK,
|
||||
): EnemyAttackDefinition {
|
||||
const cooldownMs = Math.round(finiteAtLeast(value.cooldownMs, fallback.cooldownMs, 1));
|
||||
const telegraph = value.telegraph ?? inferEnemyAttackTelegraph(value);
|
||||
const inferredCastTimeMs = value.castTimeMs === undefined && value.animation === "cast"
|
||||
? (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({
|
||||
id: value.id.trim() || fallback.id,
|
||||
name: value.name.trim() || fallback.name,
|
||||
@@ -143,7 +246,7 @@ export function normalizeEnemyAttack(
|
||||
target: value.target,
|
||||
school: value.school,
|
||||
animation: value.animation,
|
||||
castTimeMs: Math.round(finiteAtLeast(value.castTimeMs, inferredCastTimeMs, 0)),
|
||||
castTimeMs,
|
||||
interruptible: value.interruptible ?? fallback.interruptible ?? true,
|
||||
range: finiteAtLeast(value.range, fallback.range, 0),
|
||||
cooldownMs,
|
||||
@@ -160,6 +263,7 @@ export function normalizeEnemyAttack(
|
||||
...(value.radius !== undefined
|
||||
? { radius: finiteAtLeast(value.radius, 0, 0) }
|
||||
: {}),
|
||||
...(telegraph ? { telegraph: normalizeTelegraph(telegraph) } : {}),
|
||||
...(value.effect ? { effect: value.effect } : {}),
|
||||
...(value.condition ? { condition: value.condition } : {}),
|
||||
...(value.source ? { source: value.source } : {}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import {
|
||||
deterministicEnemyCooldown,
|
||||
useCombatStore,
|
||||
@@ -171,6 +172,42 @@ describe("server-backed enemy ability runtime", () => {
|
||||
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", () => {
|
||||
useWailingEncounterStore.getState().reset();
|
||||
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,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-aeternal-circle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -56,7 +56,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-aeternal-circle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -96,7 +96,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-aeternal-circle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -156,7 +156,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-aeternal-circle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -196,7 +196,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-aeternal-circle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -256,7 +256,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-aeternal-circle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -336,7 +336,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-aeternal-circle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -416,7 +416,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-aeternal-circle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -496,7 +496,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-aeternal-circle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -556,7 +556,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-aeternal-circle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -596,7 +596,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-aeternal-circle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -656,7 +656,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-aeternal-circle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -696,7 +696,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-aeternal-circle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -756,7 +756,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-aeternal-circle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -836,7 +836,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-aeternal-circle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -876,7 +876,7 @@ export const AETERNAL_CIRCLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-aeternal-circle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -2303,6 +2303,6 @@ export const AETERNAL_CIRCLE_POPULATION_PROVENANCE = {
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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."
|
||||
} as const;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -16,7 +16,7 @@ export const BARREN_CAVES_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-barren-caves-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -76,7 +76,7 @@ export const BARREN_CAVES_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-barren-caves-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -136,7 +136,7 @@ export const BARREN_CAVES_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-barren-caves-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -176,7 +176,7 @@ export const BARREN_CAVES_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-barren-caves-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -216,7 +216,7 @@ export const BARREN_CAVES_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-barren-caves-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -296,7 +296,7 @@ export const BARREN_CAVES_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-barren-caves-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -355,9 +355,14 @@ export const BARREN_CAVES_ENTITIES = {
|
||||
"damageMultiplier": 1.3,
|
||||
"moveSpeed": 2.25,
|
||||
"leashRange": 32,
|
||||
"mechanicIds": [
|
||||
"rw-barren-caves-101462-spell-492292",
|
||||
"rw-barren-caves-101462-spell-492293",
|
||||
"rw-barren-caves-101462-control-492294"
|
||||
],
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-barren-caves-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -368,13 +373,14 @@ export const BARREN_CAVES_ENTITIES = {
|
||||
"damageMultiplier": 1
|
||||
},
|
||||
{
|
||||
"id": "rw-barren-caves-spell-492292",
|
||||
"id": "rw-barren-caves-101462-spell-492292",
|
||||
"name": "Trample",
|
||||
"spellId": 492292,
|
||||
"delivery": "projectile",
|
||||
"target": "primary",
|
||||
"delivery": "area",
|
||||
"target": "nearby-party",
|
||||
"school": "physical",
|
||||
"animation": "cast",
|
||||
"castTimeMs": 1800,
|
||||
"range": 10,
|
||||
"cooldownMs": 20000,
|
||||
"initialCooldownMs": [
|
||||
@@ -385,16 +391,24 @@ export const BARREN_CAVES_ENTITIES = {
|
||||
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",
|
||||
"spellId": 492293,
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
"delivery": "area",
|
||||
"target": "nearby-party",
|
||||
"school": "physical",
|
||||
"animation": "attack",
|
||||
"castTimeMs": 1800,
|
||||
"range": 5,
|
||||
"cooldownMs": 20000,
|
||||
"initialCooldownMs": [
|
||||
@@ -405,7 +419,46 @@ export const BARREN_CAVES_ENTITIES = {
|
||||
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,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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."
|
||||
} as const;
|
||||
@@ -16,7 +16,7 @@ export const CAVE_WATER_DRAGON_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-cave-water-dragon-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -56,7 +56,7 @@ export const CAVE_WATER_DRAGON_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-cave-water-dragon-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -116,7 +116,7 @@ export const CAVE_WATER_DRAGON_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-cave-water-dragon-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -176,7 +176,7 @@ export const CAVE_WATER_DRAGON_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-cave-water-dragon-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -216,7 +216,7 @@ export const CAVE_WATER_DRAGON_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-cave-water-dragon-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -255,9 +255,18 @@ export const CAVE_WATER_DRAGON_ENTITIES = {
|
||||
"damageMultiplier": 1.3,
|
||||
"moveSpeed": 2.25,
|
||||
"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": [
|
||||
{
|
||||
"id": "rw-cave-water-dragon-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -266,6 +275,193 @@ export const CAVE_WATER_DRAGON_ENTITIES = {
|
||||
"range": 4.5,
|
||||
"cooldownMs": 1900,
|
||||
"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,
|
||||
"moveSpeed": 2.25,
|
||||
"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": [
|
||||
{
|
||||
"id": "rw-cave-water-dragon-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -307,6 +509,115 @@ export const CAVE_WATER_DRAGON_ENTITIES = {
|
||||
"range": 4.5,
|
||||
"cooldownMs": 1900,
|
||||
"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,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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."
|
||||
} as const;
|
||||
@@ -48,6 +48,6 @@ export const DEMON_STRONGHOLD_125_POPULATION_PROVENANCE = {
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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."
|
||||
} as const;
|
||||
@@ -16,7 +16,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-demon-stronghold-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -76,7 +76,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-demon-stronghold-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -116,7 +116,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-demon-stronghold-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -156,7 +156,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-demon-stronghold-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -236,7 +236,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-demon-stronghold-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -336,7 +336,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-demon-stronghold-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -396,7 +396,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-demon-stronghold-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -436,7 +436,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-demon-stronghold-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -536,7 +536,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-demon-stronghold-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -616,7 +616,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-demon-stronghold-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -656,7 +656,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-demon-stronghold-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -696,7 +696,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-demon-stronghold-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -736,7 +736,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-demon-stronghold-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -776,7 +776,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-demon-stronghold-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -816,7 +816,7 @@ export const DEMON_STRONGHOLD_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-demon-stronghold-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -855,9 +855,15 @@ export const DEMON_STRONGHOLD_ENTITIES = {
|
||||
"damageMultiplier": 1.3,
|
||||
"moveSpeed": 2.25,
|
||||
"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": [
|
||||
{
|
||||
"id": "rw-demon-stronghold-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -866,6 +872,105 @@ export const DEMON_STRONGHOLD_ENTITIES = {
|
||||
"range": 4.5,
|
||||
"cooldownMs": 1900,
|
||||
"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,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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."
|
||||
} as const;
|
||||
@@ -1,2 +1,2 @@
|
||||
// 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,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-dungeon-of-dalanis-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -56,7 +56,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-dungeon-of-dalanis-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -96,7 +96,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-dungeon-of-dalanis-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -136,7 +136,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-dungeon-of-dalanis-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -196,7 +196,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-dungeon-of-dalanis-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -236,7 +236,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-dungeon-of-dalanis-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Corrosive Slam",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -296,7 +296,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-dungeon-of-dalanis-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -336,7 +336,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-dungeon-of-dalanis-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -376,7 +376,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-dungeon-of-dalanis-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -416,7 +416,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-dungeon-of-dalanis-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -476,7 +476,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-dungeon-of-dalanis-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -536,7 +536,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-dungeon-of-dalanis-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -576,7 +576,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-dungeon-of-dalanis-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -636,7 +636,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-dungeon-of-dalanis-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -676,7 +676,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-dungeon-of-dalanis-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -716,7 +716,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-dungeon-of-dalanis-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -756,7 +756,7 @@ export const DUNGEON_OF_DALANIS_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-dungeon-of-dalanis-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -1567,6 +1567,6 @@ export const DUNGEON_OF_DALANIS_POPULATION_PROVENANCE = {
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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."
|
||||
} 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 { PopulationDefinitionMap, StaticMobSpawnDefinition } from "../mobPopulation";
|
||||
|
||||
@@ -16,7 +16,7 @@ export const FORSAKEN_ABBEY_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-fa-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Corrosive Slam",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -56,7 +56,7 @@ export const FORSAKEN_ABBEY_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-fa-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -96,7 +96,7 @@ export const FORSAKEN_ABBEY_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-fa-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -136,7 +136,7 @@ export const FORSAKEN_ABBEY_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-fa-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -175,9 +175,12 @@ export const FORSAKEN_ABBEY_ENTITIES = {
|
||||
"damageMultiplier": 1.3,
|
||||
"moveSpeed": 2.25,
|
||||
"leashRange": 32,
|
||||
"mechanicIds": [
|
||||
"rw-fa-100070-source-gap-burst"
|
||||
],
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-fa-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -186,6 +189,33 @@ export const FORSAKEN_ABBEY_ENTITIES = {
|
||||
"range": 4.5,
|
||||
"cooldownMs": 1900,
|
||||
"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,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-fa-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -257,7 +287,7 @@ export const FORSAKEN_ABBEY_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-fa-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -297,7 +327,7 @@ export const FORSAKEN_ABBEY_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-fa-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Corrosive Slam",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -336,9 +366,12 @@ export const FORSAKEN_ABBEY_ENTITIES = {
|
||||
"damageMultiplier": 1.3,
|
||||
"moveSpeed": 2.25,
|
||||
"leashRange": 32,
|
||||
"mechanicIds": [
|
||||
"rw-fa-100561-source-gap-sweep"
|
||||
],
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-fa-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Corrosive Slam",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -347,6 +380,33 @@ export const FORSAKEN_ABBEY_ENTITIES = {
|
||||
"range": 4.5,
|
||||
"cooldownMs": 1900,
|
||||
"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,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-fa-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -417,9 +477,12 @@ export const FORSAKEN_ABBEY_ENTITIES = {
|
||||
"damageMultiplier": 1.3,
|
||||
"moveSpeed": 2.25,
|
||||
"leashRange": 32,
|
||||
"mechanicIds": [
|
||||
"rw-fa-100562-source-gap-sweep"
|
||||
],
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-fa-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -428,6 +491,33 @@ export const FORSAKEN_ABBEY_ENTITIES = {
|
||||
"range": 4.5,
|
||||
"cooldownMs": 1900,
|
||||
"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,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-fa-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -498,9 +588,12 @@ export const FORSAKEN_ABBEY_ENTITIES = {
|
||||
"damageMultiplier": 1.3,
|
||||
"moveSpeed": 2.25,
|
||||
"leashRange": 32,
|
||||
"mechanicIds": [
|
||||
"rw-fa-100563-source-gap-sweep"
|
||||
],
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-fa-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -509,6 +602,33 @@ export const FORSAKEN_ABBEY_ENTITIES = {
|
||||
"range": 4.5,
|
||||
"cooldownMs": 1900,
|
||||
"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,
|
||||
"moveSpeed": 2.25,
|
||||
"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": [
|
||||
{
|
||||
"id": "rw-fa-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -550,6 +679,215 @@ export const FORSAKEN_ABBEY_ENTITIES = {
|
||||
"range": 4.5,
|
||||
"cooldownMs": 1900,
|
||||
"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,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-fa-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -2341,20 +2679,13 @@ export const FORSAKEN_ABBEY_BOSSES = [
|
||||
export const FORSAKEN_ABBEY_OBJECTIVE_ORDER = FORSAKEN_ABBEY_BOSSES;
|
||||
|
||||
export const FORSAKEN_ABBEY_POPULATION_PROVENANCE = {
|
||||
"schemaVersion": 2,
|
||||
"schemaVersion": 1,
|
||||
"dungeonId": "forsaken-abbey",
|
||||
"zoneId": 102,
|
||||
"sourceRowCount": 251,
|
||||
"importedSpawnCount": 150,
|
||||
"excludedRowCount": 101,
|
||||
"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",
|
||||
"sourceContentSha256": "13ef6edd3df6e71389aa92d01201889148c0794cf9ecaafdbaf90d3145d2bf7b",
|
||||
"sourceHashes": {
|
||||
@@ -2368,7 +2699,7 @@ export const FORSAKEN_ABBEY_POPULATION_PROVENANCE = {
|
||||
"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": {
|
||||
"maximumAllowedDistance": 2,
|
||||
"maximumObservedDistance": 2.5965,
|
||||
@@ -2376,8 +2707,5 @@ export const FORSAKEN_ABBEY_POPULATION_PROVENANCE = {
|
||||
"deferredRowsBeyondCombatLimit": 1
|
||||
},
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"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."
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry."
|
||||
} as const;
|
||||
@@ -16,7 +16,7 @@ export const GRAFU_CASTLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-grafu-castle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -56,7 +56,7 @@ export const GRAFU_CASTLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-grafu-castle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -96,7 +96,7 @@ export const GRAFU_CASTLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-grafu-castle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -176,7 +176,7 @@ export const GRAFU_CASTLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-grafu-castle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -256,7 +256,7 @@ export const GRAFU_CASTLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-grafu-castle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -336,7 +336,7 @@ export const GRAFU_CASTLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-grafu-castle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -416,7 +416,7 @@ export const GRAFU_CASTLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-grafu-castle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -456,7 +456,7 @@ export const GRAFU_CASTLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-grafu-castle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -536,7 +536,7 @@ export const GRAFU_CASTLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-grafu-castle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -576,7 +576,7 @@ export const GRAFU_CASTLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-grafu-castle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -616,7 +616,7 @@ export const GRAFU_CASTLE_ENTITIES = {
|
||||
"leashRange": 24,
|
||||
"attacks": [
|
||||
{
|
||||
"id": "rw-grafu-castle-basic-attack",
|
||||
"id": "basic-attack",
|
||||
"name": "Basic Attack",
|
||||
"delivery": "melee",
|
||||
"target": "primary",
|
||||
@@ -1306,6 +1306,6 @@ export const GRAFU_CASTLE_POPULATION_PROVENANCE = {
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"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."
|
||||
} 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