Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f49e54f35f | ||
|
|
7b41b102fa |
No files matched your search
@@ -20,5 +20,6 @@ releases/
|
|||||||
/content/
|
/content/
|
||||||
dist-android/
|
dist-android/
|
||||||
.android-public/
|
.android-public/
|
||||||
|
.web-public/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
+66
-33
@@ -1,10 +1,12 @@
|
|||||||
# Local-first content updates and Android releases
|
# Local-first content updates for browser and Android
|
||||||
|
|
||||||
Healer Man separates the installed game client from large game content. The
|
Healer Man separates the installed game client from large game content. Both
|
||||||
Android APK contains the React/Three.js runtime, native Android bridge, menus,
|
the production browser build and Android APK contain a compact required set:
|
||||||
local profile and save support, UI assets, and the Wailing Caverns starter
|
the React/Three.js runtime, menus, local profile and save support, UI assets,
|
||||||
pack. Other GLBs, textures, audio, character packs, dungeons, and Manastorm
|
starter equipment, Wailing Caverns starter content, and the basic Human
|
||||||
stages are published by TrueNAS as optional content.
|
character model with its default male and female appearances. Android also
|
||||||
|
contains its native bridge. Other GLBs, textures, audio, character/skin packs,
|
||||||
|
dungeons, and Manastorm stages are published by TrueNAS as optional content.
|
||||||
|
|
||||||
Single-player is local-first. Starting the game, loading a character, entering
|
Single-player is local-first. Starting the game, loading a character, entering
|
||||||
installed content, simulating combat, and saving progress never require the
|
installed content, simulating combat, and saving progress never require the
|
||||||
@@ -27,16 +29,12 @@ UI behavior, shaders or loaders, native Android code, permissions, or the
|
|||||||
content-manifest schema. A manifest can set `minimumAppVersion` so an older APK
|
content-manifest schema. A manifest can set `minimumAppVersion` so an older APK
|
||||||
will not activate incompatible content.
|
will not activate incompatible content.
|
||||||
|
|
||||||
The browser build remains a full build served by TrueNAS. It receives client
|
Production builds deliberately stage only required content. `npm run build`
|
||||||
code when the app checkout is pulled, rebuilt, and restarted. The Android build
|
prepares `.web-public` and emits the compact browser `dist`; Android prepares
|
||||||
is deliberately separate and compact so the APK does not inherit the complete
|
`.android-public` and emits `dist-android`. The development server continues to
|
||||||
multi-gigabyte browser asset catalog.
|
use the source `public` tree so asset-pipeline work remains convenient. The
|
||||||
|
complete source catalog and published immutable objects stay on TrueNAS, not in
|
||||||
Measured on August 14, 2026, the production browser `dist` is 3.88 GiB across
|
every player's initial browser or Android installation.
|
||||||
18,895 files; GLBs account for 3.75 GiB. The compact Android `dist-android` is
|
|
||||||
148.0 MiB across 5,561 files, including 47.8 MiB of starter GLBs. The final
|
|
||||||
signed APK will be smaller because Android packages compress eligible files;
|
|
||||||
record its exact size after the first release build.
|
|
||||||
|
|
||||||
## Content manifest
|
## Content manifest
|
||||||
|
|
||||||
@@ -56,8 +54,9 @@ The runtime continues to request stable paths such as:
|
|||||||
```
|
```
|
||||||
|
|
||||||
The content resolver maps that path to a verified file in Android app-private
|
The content resolver maps that path to a verified file in Android app-private
|
||||||
storage. Filenames are therefore not used as version numbers and runtime code
|
storage or, in production browsers, to the app-managed content cache through a
|
||||||
does not need to know the hash.
|
same-origin service worker. Filenames are therefore not used as version numbers
|
||||||
|
and runtime code does not need to know the hash.
|
||||||
|
|
||||||
## Safe publication and activation
|
## Safe publication and activation
|
||||||
|
|
||||||
@@ -68,17 +67,34 @@ Publication order is intentionally atomic:
|
|||||||
3. Write the versioned manifest.
|
3. Write the versioned manifest.
|
||||||
4. Replace `manifest.json` only after all referenced objects exist.
|
4. Replace `manifest.json` only after all referenced objects exist.
|
||||||
|
|
||||||
On a device, the client fetches the small manifest and compares it with its
|
The client fetches the small manifest and compares it with its bundled and
|
||||||
installed manifest. Only missing or hash-mismatched objects are downloaded.
|
installed file identities. Only missing or hash-mismatched objects are
|
||||||
Android streams each object to a `.part` file, supports HTTP range resume,
|
downloaded. Browsers download into a temporary Cache Storage staging area,
|
||||||
verifies its size and SHA-256, then renames it into the object cache. The active
|
verify every object's byte count and SHA-256, and promote the pack to the active
|
||||||
logical-path map changes only after the complete pack verifies. An interrupted
|
cache only after the complete pack succeeds. Android streams each object to a
|
||||||
or corrupt update leaves the previous pack playable.
|
`.part` file, supports HTTP range resume, verifies its size and SHA-256, then
|
||||||
|
renames it into the object cache. An interrupted or corrupt update leaves the
|
||||||
|
previous pack playable on either platform.
|
||||||
|
|
||||||
The starter pack is bundled into the APK. Downloaded packs live under Android's
|
The Downloads screen uses the same lifecycle in browser and Android. A missing
|
||||||
app-private files directory, outside the packaged web bundle, and survive an
|
pack offers **Download**, an installed pack with changed files offers
|
||||||
APK update. Players may remove optional packs through the content manager; save
|
**Update**, and an installed optional pack offers **Remove**. Required packs are
|
||||||
data is stored separately and is never removed with content.
|
marked as included and cannot be removed. Browser packs live in the app-managed
|
||||||
|
cache; Android packs live in app-private storage and survive APK updates. Save
|
||||||
|
data and roster identities are stored separately and are never removed with
|
||||||
|
content.
|
||||||
|
|
||||||
|
Character packs are presentation-only. When a selected race's pack is absent,
|
||||||
|
removed, disabled, or fails to load, the shared character renderer uses the
|
||||||
|
matching-gender basic Human model and default appearance. The saved race, class,
|
||||||
|
equipment, progression, and appearance choices remain intact and become visible
|
||||||
|
again after the pack is installed.
|
||||||
|
|
||||||
|
Generated spell effects and synthesized combat cues are the default. The
|
||||||
|
`core:combat-media` pack containing packaged original spell visuals and sounds
|
||||||
|
is never part of required bootstrap content. Original mode becomes available
|
||||||
|
only after an explicit download and falls back to generated effects if the pack
|
||||||
|
is later removed.
|
||||||
|
|
||||||
## Offline behavior
|
## Offline behavior
|
||||||
|
|
||||||
@@ -87,6 +103,8 @@ data is stored separately and is never removed with content.
|
|||||||
- The last verified content map is read synchronously from local storage.
|
- The last verified content map is read synchronously from local storage.
|
||||||
- A failed update check is silent outside the content manager.
|
- A failed update check is silent outside the content manager.
|
||||||
- A failed or interrupted download never replaces active content.
|
- A failed or interrupted download never replaces active content.
|
||||||
|
- Missing optional character packs fall back to the basic Human model.
|
||||||
|
- Generated spell effects remain available without original spell media.
|
||||||
- Local saves are authoritative during play and cloud synchronization retries
|
- Local saves are authoritative during play and cloud synchronization retries
|
||||||
when connectivity returns.
|
when connectivity returns.
|
||||||
- Content that has never been downloaded is clearly marked as unavailable
|
- Content that has never been downloaded is clearly marked as unavailable
|
||||||
@@ -104,9 +122,10 @@ Keeping the runtime in the APK is what makes offline startup reliable.
|
|||||||
/mnt/usbssds/apps/healer-man/backups Database backups
|
/mnt/usbssds/apps/healer-man/backups Database backups
|
||||||
```
|
```
|
||||||
|
|
||||||
The Node server exposes the content volume at `/content`. Manifests use
|
The Node server exposes the compact production browser client at `/` and the
|
||||||
`no-cache`; immutable hashed objects use long-lived cache headers and support
|
complete downloadable-content volume at `/content`. Manifests use `no-cache`;
|
||||||
byte ranges for resumed downloads.
|
immutable hashed objects use long-lived cache headers and support byte ranges
|
||||||
|
for resumed downloads.
|
||||||
|
|
||||||
After pulling source on TrueNAS, publish content before restarting the app:
|
After pulling source on TrueNAS, publish content before restarting the app:
|
||||||
|
|
||||||
@@ -121,15 +140,29 @@ discover a manifest whose files have not been published.
|
|||||||
## Pack policy
|
## Pack policy
|
||||||
|
|
||||||
Packs are player-facing groups while files remain individually addressable.
|
Packs are player-facing groups while files remain individually addressable.
|
||||||
This permits a fast initial pack install and small later patches. Current pack
|
This permits a compact initial install and small later patches. Required pack
|
||||||
IDs are derived predictably:
|
IDs include:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
core:runtime
|
||||||
|
core:ui
|
||||||
|
core:equipment
|
||||||
|
core:character-basic
|
||||||
dungeon:wailing-caverns
|
dungeon:wailing-caverns
|
||||||
|
```
|
||||||
|
|
||||||
|
`core:character-basic` owns the two basic Human rigs, the character manifest,
|
||||||
|
and only the default texture selections needed by the fallback. The remaining
|
||||||
|
human customizations and every non-Human character model/skin are optional.
|
||||||
|
|
||||||
|
Downloadable pack IDs include:
|
||||||
|
|
||||||
|
```text
|
||||||
dungeon:forsaken-abbey
|
dungeon:forsaken-abbey
|
||||||
characters:wow335a-v1
|
characters:wow335a-v1
|
||||||
characters:ascension-v1
|
characters:ascension-v1
|
||||||
characters:runewaker-v1
|
characters:runewaker-v1
|
||||||
|
core:combat-media
|
||||||
manastorm:<stage-id>
|
manastorm:<stage-id>
|
||||||
shared:creatures
|
shared:creatures
|
||||||
```
|
```
|
||||||
|
|||||||
+24
-18
@@ -24,8 +24,9 @@ container, rebuilding `dist`, or pulling source must not replace the database.
|
|||||||
TrueNAS Gitea stores the source repository and APK release attachments; it is
|
TrueNAS Gitea stores the source repository and APK release attachments; it is
|
||||||
not the game database.
|
not the game database.
|
||||||
|
|
||||||
Large runtime assets are published separately under `/app/content`. Android
|
Large runtime assets are published separately under `/app/content`. Production
|
||||||
clients download verified content objects from `/content` and retain them in
|
browser and Android clients download verified content objects from `/content`;
|
||||||
|
browsers retain them in an app-managed cache and Android retains them in
|
||||||
app-private storage for offline play. See `CONTENT_UPDATES.md` for the manifest,
|
app-private storage for offline play. See `CONTENT_UPDATES.md` for the manifest,
|
||||||
atomic activation, compatibility, and rollback contract.
|
atomic activation, compatibility, and rollback contract.
|
||||||
|
|
||||||
@@ -55,9 +56,10 @@ Container/host port:
|
|||||||
```
|
```
|
||||||
|
|
||||||
The Gitea bare repository is not runnable. Clone it with Git; never copy it
|
The Gitea bare repository is not runnable. Clone it with Git; never copy it
|
||||||
with `cp`. The checkout contains several gigabytes of required GLB assets, so
|
with `cp`. The checkout contains several gigabytes of content-source GLB assets,
|
||||||
the initial clone and first build will take substantially longer than later
|
so the initial clone and first content publication will take substantially
|
||||||
fast-forward updates.
|
longer than later fast-forward updates. Production browser and Android bundles
|
||||||
|
stage only their compact required-content subset.
|
||||||
|
|
||||||
Do not turn the GLBs into Git LFS pointers without also changing this deployment
|
Do not turn the GLBs into Git LFS pointers without also changing this deployment
|
||||||
procedure. Gitea keeps LFS objects outside the bare repository, so a bare-path
|
procedure. Gitea keeps LFS objects outside the bare repository, so a bare-path
|
||||||
@@ -80,7 +82,8 @@ git branch --show-current
|
|||||||
|
|
||||||
If the repository slug differs, change both the remote URL here and every
|
If the repository slug differs, change both the remote URL here and every
|
||||||
`healer-man.git` bare-repository path below. Do not commit `.env` files,
|
`healer-man.git` bare-repository path below. Do not commit `.env` files,
|
||||||
keystores, API tokens, `content`, `dist`, `dist-android`, or `.android-public`.
|
keystores, API tokens, `content`, `dist`, `dist-android`, `.web-public`, or
|
||||||
|
`.android-public`.
|
||||||
|
|
||||||
On TrueNAS, confirm the bare repository path. If the first command fails, use
|
On TrueNAS, confirm the bare repository path. If the first command fails, use
|
||||||
the search command:
|
the search command:
|
||||||
@@ -154,8 +157,9 @@ account/cloud-save data; `/app/content` owns downloadable manifests and
|
|||||||
immutable content objects. Never mount a volume at `/app/data`, because that
|
immutable content objects. Never mount a volume at `/app/data`, because that
|
||||||
would hide the checkout's required `/app/data/loot/schema.sql`. The
|
would hide the checkout's required `/app/data/loot/schema.sql`. The
|
||||||
startup command installs locked dependencies, initializes the schema, publishes
|
startup command installs locked dependencies, initializes the schema, publishes
|
||||||
new or changed content objects, builds the full browser bundle, and starts the
|
new or changed content objects, builds the compact required-content browser
|
||||||
combined static/API/content server. Existing content objects are reused.
|
bundle, and starts the combined static/API/content server. Existing content
|
||||||
|
objects are reused.
|
||||||
|
|
||||||
After startup, test on the LAN:
|
After startup, test on the LAN:
|
||||||
|
|
||||||
@@ -220,8 +224,8 @@ git rev-parse HEAD
|
|||||||
|
|
||||||
Before `git commit`, read the staged file list printed by `git status --short`.
|
Before `git commit`, read the staged file list printed by `git status --short`.
|
||||||
Do not commit a `.env` file, password, API token, keystore, APK, database,
|
Do not commit a `.env` file, password, API token, keystore, APK, database,
|
||||||
`content/`, `dist/`, `dist-android/`, or `.android-public/` output. The final
|
`content/`, `dist/`, `dist-android/`, `.web-public/`, or `.android-public/`
|
||||||
command prints the exact deployed commit ID.
|
output. The final command prints the exact deployed commit ID.
|
||||||
|
|
||||||
Next, open the TrueNAS Apps UI and **stop** `healer-man`. Open a TrueNAS shell
|
Next, open the TrueNAS Apps UI and **stop** `healer-man`. Open a TrueNAS shell
|
||||||
and run these exact commands:
|
and run these exact commands:
|
||||||
@@ -247,11 +251,12 @@ sudo git -C /mnt/usbssds/apps/healer-man/app rev-parse HEAD
|
|||||||
The TrueNAS commit ID must match the one printed on the development PC. Start
|
The TrueNAS commit ID must match the one printed on the development PC. Start
|
||||||
`healer-man` in the TrueNAS Apps UI afterward. Container startup
|
`healer-man` in the TrueNAS Apps UI afterward. Container startup
|
||||||
publishes immutable content objects and replaces `manifest.json` only after all
|
publishes immutable content objects and replaces `manifest.json` only after all
|
||||||
objects exist, then rebuilds the browser client and starts the server. Android
|
objects exist, then rebuilds the browser client and starts the server. Browser
|
||||||
devices discover the small new manifest and download only missing or changed
|
and Android clients discover the small new manifest and download only missing
|
||||||
files. Asset-only updates do not require a new APK. A failed fast-forward pull
|
or changed files for packs the player installed. Asset-only updates do not
|
||||||
deliberately leaves the running checkout unchanged; resolve diverged history on
|
require a new APK. A failed fast-forward pull deliberately leaves the running
|
||||||
the development machine rather than forcing the TrueNAS checkout.
|
checkout unchanged; resolve diverged history on the development machine rather
|
||||||
|
than forcing the TrueNAS checkout.
|
||||||
|
|
||||||
After the logs say the server is listening on port `4173`, run on TrueNAS:
|
After the logs say the server is listening on port `4173`, run on TrueNAS:
|
||||||
|
|
||||||
@@ -286,9 +291,10 @@ copies; it does not replace dataset-level protection.
|
|||||||
## APK releases and Obtainium
|
## APK releases and Obtainium
|
||||||
|
|
||||||
Keep APK binaries out of the Git branch. The Android build uses `dist-android`,
|
Keep APK binaries out of the Git branch. The Android build uses `dist-android`,
|
||||||
which contains the game runtime, UI, equipment, and Wailing Caverns starter
|
which contains the game runtime, UI, starter equipment, basic Human character,
|
||||||
content rather than the full multi-gigabyte browser catalog. Downloaded packs
|
and Wailing Caverns starter content. Optional character/skin packs, original
|
||||||
survive APK updates.
|
spell media, dungeons, and other large content remain outside the APK.
|
||||||
|
Downloaded packs survive APK updates.
|
||||||
|
|
||||||
Install JDK 21 and Android SDK Platform 36 on the development PC and set
|
Install JDK 21 and Android SDK Platform 36 on the development PC and set
|
||||||
`JAVA_HOME` plus `ANDROID_SDK_ROOT`. The build wrapper also recognizes the
|
`JAVA_HOME` plus `ANDROID_SDK_ROOT`. The build wrapper also recognizes the
|
||||||
|
|||||||
@@ -1572,3 +1572,116 @@ Vite's existing large-chunk advisory remains informational.
|
|||||||
106 environment assets passed their performance audit with no regressions,
|
106 environment assets passed their performance audit with no regressions,
|
||||||
the KTX2 audit found no fallback textures, and the release-candidate check
|
the KTX2 audit found no fallback textures, and the release-candidate check
|
||||||
passed. The existing large-chunk advisory remains informational.
|
passed. The existing large-chunk advisory remains informational.
|
||||||
|
|
||||||
|
# 2026-09-04 — optional content offload and basic Human fallback
|
||||||
|
|
||||||
|
- Built and served the production browser output from a clean localhost origin.
|
||||||
|
A fresh Undead female Priest preview rendered with the real bundled Human
|
||||||
|
female rig and default textures while retaining the Undead/Priest identity in
|
||||||
|
the roster. No optional character pack had been installed.
|
||||||
|
- Entered Wailing Caverns as healer from a separate clean browser origin. The
|
||||||
|
Undead player, Undead tank, and Goblin party member all rendered through the
|
||||||
|
matching basic Human fallbacks while their saved race, role, class, equipment,
|
||||||
|
and party labels remained intact. The dungeon stayed playable and emitted no
|
||||||
|
new error; only the existing third-party initialization deprecation warning
|
||||||
|
appeared.
|
||||||
|
- Exercised the browser Downloads lifecycle with a small local two-revision
|
||||||
|
content fixture. Missing character and original-media packs showed Download;
|
||||||
|
a verified install showed Remove; the changed revision showed Update and
|
||||||
|
Remove together; and applying Update returned the pack to offline-ready state.
|
||||||
|
- Reloaded after service-worker activation. The application and lazy JavaScript
|
||||||
|
chunks loaded normally, while the content worker continued to reserve managed
|
||||||
|
character, creature, equipment, game, and UI paths for bundled or verified
|
||||||
|
cached content. The initial broad `/assets/` interception found during this
|
||||||
|
playtest was corrected before the final run.
|
||||||
|
- In-dungeon Options showed Generated as the active default, kept Original
|
||||||
|
disabled until its optional spell-effects-and-sounds pack is downloaded, and
|
||||||
|
exposed a Download action for that pack.
|
||||||
|
- Evidence:
|
||||||
|
`playtest-artifacts/2026-09-04-optional-content/roster-undead-priest-basic-human-fallback.png`,
|
||||||
|
`dungeon-optional-models-basic-human-fallback.png`,
|
||||||
|
`browser-downloads-optional-packs.png`, and
|
||||||
|
`browser-downloads-update-and-remove.png`.
|
||||||
|
- Verification: all 43 avatar tests passed; the complete Vitest suite passed all
|
||||||
|
896 cases across 164 files; production browser and Android builds completed;
|
||||||
|
both binary inventories contained only the two Human rigs, starter equipment,
|
||||||
|
and Wailing Caverns content; and neither output contained optional character
|
||||||
|
packs, original combat media, or WAV files. KTX2 and environment-performance
|
||||||
|
audits remained green, and the release-candidate check passed. The existing
|
||||||
|
large-chunk advisory remains informational.
|
||||||
|
|
||||||
|
# 2026-09-05 — Power Word: Shield live character anchoring
|
||||||
|
|
||||||
|
- Installed the optional Original Spell Effects & Sounds pack through the
|
||||||
|
production Downloads screen, enabled it, and selected Original spell visuals.
|
||||||
|
- Entered Wailing Caverns as a Human Priest and cast Power Word: Shield on both
|
||||||
|
the party tank and the player while exercising party movement and combat. The
|
||||||
|
persistent original aura stayed at the rendered character root instead of
|
||||||
|
following the throttled gameplay-position snapshot, with no inherited camera
|
||||||
|
or character rotation.
|
||||||
|
- Reviewed live dungeon screenshots before and during the movement test. The
|
||||||
|
browser emitted no new errors; only the existing third-party initialization
|
||||||
|
deprecation warning remained.
|
||||||
|
- Verification: all 899 Vitest cases passed across 165 files, including three
|
||||||
|
new visual-anchor registry cases. The production build completed with 920
|
||||||
|
modules transformed, and asset audits remained green. The existing
|
||||||
|
large-chunk advisory remains informational.
|
||||||
|
|
||||||
|
# 2026-09-05 — modular RuneWaker boss mechanics
|
||||||
|
|
||||||
|
- Built a temporary local production content feed for Forsaken Abbey and the
|
||||||
|
first two Ystra Labyrinth boss rooms, then installed each pack through the
|
||||||
|
browser Downloads workflow with checksum verification.
|
||||||
|
- Loaded the source-backed Specter Guardian Creator encounter. Its mechanic
|
||||||
|
panel appeared below the minimap and buff/debuff strips with Guard Bomb,
|
||||||
|
Transport Magic, and Meteor Shower countdowns. A live Meteor Shower cast
|
||||||
|
showed its one-second cast warning over the boss.
|
||||||
|
- The first pass exposed that four countdown rows could crowd the boss's
|
||||||
|
pending 80%, 60%, 40%, and 20% phase triggers out of the panel. The timeline
|
||||||
|
now limits only countdown rows and always retains every pending health
|
||||||
|
threshold. The corrected production build displayed all eight rows without
|
||||||
|
covering the central playfield.
|
||||||
|
- Loaded Rune Guardian Leader in Ystra Labyrinth Boss Room 2. Its source-backed
|
||||||
|
Manastorms Duo Lv5 mechanic displayed a large purple circular floor warning
|
||||||
|
while the matching panel row counted down at 0.6 seconds. The area indicator,
|
||||||
|
cast timing, party damage, and effect/debuff presentation all advanced in the
|
||||||
|
live encounter.
|
||||||
|
- Browser logs contained no uncaught application errors. The existing
|
||||||
|
third-party initialization deprecation warning remained; WebGL context-lost
|
||||||
|
messages corresponded to intentional production rebuild reloads and opening
|
||||||
|
replacement test tabs.
|
||||||
|
- Verification: the full suite passed all 913 cases across 169 files, and the
|
||||||
|
corrected production build completed with 923 modules transformed. KTX2 and
|
||||||
|
environment-performance audits remained green, and the release-candidate
|
||||||
|
check passed.
|
||||||
|
|
||||||
|
# 2026-09-05 — specialization-aware heirlooms and endgame loot
|
||||||
|
|
||||||
|
- Activated the legacy level-10 Priest `Rolecheck`. The mandatory Class Hall
|
||||||
|
picker appeared before the main menu, offered Discipline, Holy, and Shadow,
|
||||||
|
and presented Cancel, Keep talents, and Reset talents after choosing Holy.
|
||||||
|
Keeping talents returned to the main menu with the specialization saved.
|
||||||
|
- Opened Wailing Caverns from Dungeon Finder. Role selection was restricted to
|
||||||
|
Healer, matching the saved Holy specialization; no Tank or DPS override was
|
||||||
|
available.
|
||||||
|
- Reviewed the roster preview and a live Wailing Caverns combat scene. The
|
||||||
|
player body, staff, and all four AI party bodies/equipment silhouettes were
|
||||||
|
visible and correctly anchored without obscuring the HUD or playfield.
|
||||||
|
- Opened Inventory & Equipment in the dungeon. The player had zero earned
|
||||||
|
items but every applicable slot resolved to a level-10 Priest healer
|
||||||
|
heirloom and reported average item level 10. Morvane, the Blood Death Knight
|
||||||
|
companion, showed a complete tank heirloom loadout with the intended
|
||||||
|
two-handed weapon layout and empty conflicting slots.
|
||||||
|
- Browser logs contained no uncaught application errors. Only the existing
|
||||||
|
third-party initialization deprecation warning appeared.
|
||||||
|
- Automated coverage validates every classic and CoA specialization, Feral
|
||||||
|
Bear/Cat separation, RoM-derived profiles, player and party scaling caps,
|
||||||
|
same-level catalog dominance at levels 1/40/79, real item-level-85 upgrade
|
||||||
|
availability for every gear archetype, two-hand replacement scoring,
|
||||||
|
fallback reappearance, percentage-preserving stat refreshes, specialization
|
||||||
|
persistence and talent reset behavior, and the item-level-255 save limit.
|
||||||
|
- Verification: the complete Vitest suite passed all 928 cases across 171
|
||||||
|
files, including all 43 avatar cases. All 15 server persistence/API tests
|
||||||
|
passed. The production build completed with 925 modules transformed, KTX2
|
||||||
|
and environment-performance audits remained green, and the release-candidate
|
||||||
|
check passed. The existing large-chunk advisory remains informational.
|
||||||
@@ -41,6 +41,28 @@ npm test
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Content downloads
|
||||||
|
|
||||||
|
Production browser and Android builds ship a compact required set: the game
|
||||||
|
runtime, interface, starter equipment, Wailing Caverns starter content, and a
|
||||||
|
basic male/female Human character model with its default appearance. Large
|
||||||
|
character and skin libraries, additional dungeons, Manastorm stages, and the
|
||||||
|
original spell-effects and sound pack are optional downloads.
|
||||||
|
|
||||||
|
Open **Downloads** from the main menu to download, update, or remove optional
|
||||||
|
packs on either platform. Browser downloads live in an app-managed verified
|
||||||
|
cache; Android downloads live in app-private storage. Removing a pack never
|
||||||
|
removes a roster or save. If a character's model/skin pack is unavailable, the
|
||||||
|
shared character renderer displays the matching-gender basic Human while
|
||||||
|
preserving that character's identity and progress for a later reinstall.
|
||||||
|
|
||||||
|
Generated spell effects and synthesized combat cues are the default and need
|
||||||
|
no original-media download. Players who want the packaged original visuals and
|
||||||
|
sounds can download that pack explicitly, select **Original** effects, and
|
||||||
|
remove the pack again to reclaim its storage. See
|
||||||
|
[`CONTENT_UPDATES.md`](CONTENT_UPDATES.md) for the content lifecycle and release
|
||||||
|
contract.
|
||||||
|
|
||||||
## Conquest of Azeroth classes
|
## Conquest of Azeroth classes
|
||||||
|
|
||||||
All 21 CoA classes use a checked-in live Vol'Jin catalog generated from
|
All 21 CoA classes use a checked-in live Vol'Jin catalog generated from
|
||||||
@@ -81,12 +103,23 @@ validates the relational data and ships both a browser-safe JSON catalog and
|
|||||||
for a future loot-browser screen.
|
for a future loot-browser screen.
|
||||||
|
|
||||||
Every Wailing Caverns boss rolls one item when it dies. The item instance
|
Every Wailing Caverns boss rolls one item when it dies. The item instance
|
||||||
captures the player's level at the killing blow, remains on the corpse until
|
captures its item level at the killing blow, remains on the corpse until
|
||||||
looted, and is then saved in that character's inventory. The SQL loot source
|
looted, and is then saved in that character's inventory. Drops use character
|
||||||
contains the original 3.3.5a attributes, ratings, armor, block, weapon damage,
|
level through 79; level-80 drops begin the first endgame tier at item level 85.
|
||||||
weapon speed, regeneration, and spell-power values for each item. A scaled
|
The SQL loot source contains the original 3.3.5a attributes, ratings, armor,
|
||||||
drop preserves that original stat distribution instead of replacing it with a
|
block, weapon damage, weapon speed, regeneration, and spell-power values for
|
||||||
generic item-power budget.
|
each item. A scaled drop preserves that original stat distribution instead of
|
||||||
|
replacing it with a generic item-power budget.
|
||||||
|
|
||||||
|
Every WoW and Conquest of Azeroth character has a saved specialization that
|
||||||
|
locks its activity role and selects a complete specialization-aware heirloom
|
||||||
|
loadout. Runes of Magic derives the same profile from its active primary class
|
||||||
|
and selected role. Virtual heirlooms fill every applicable unoccupied slot,
|
||||||
|
rescale through item level 79 for players and 80 for AI party members, never
|
||||||
|
enter inventory, and automatically return when earned equipment is removed.
|
||||||
|
During leveling their score is normalized above compatible same-level catalog
|
||||||
|
drops. At level 80 the item-level-85 boss tier contains real upgrades, which
|
||||||
|
the equipment and loot interfaces highlight without equipping automatically.
|
||||||
|
|
||||||
The equipment runtime uses the Wrath primary attributes and itemizable combat
|
The equipment runtime uses the Wrath primary attributes and itemizable combat
|
||||||
stats. Strength, Agility, Stamina, Intellect, Spirit, Attack Power, Spell
|
stats. Strength, Agility, Stamina, Intellect, Spirit, Attack Power, Spell
|
||||||
@@ -181,12 +214,13 @@ npm run android:apk
|
|||||||
The debug APK is written under `android/app/build/outputs/apk/debug/`. To build
|
The debug APK is written under `android/app/build/outputs/apk/debug/`. To build
|
||||||
and install directly onto a connected device, use `npm run android:install`.
|
and install directly onto a connected device, use `npm run android:install`.
|
||||||
|
|
||||||
Android uses a separate compact `dist-android` build. It bundles the client,
|
Android uses a compact `dist-android` build with the same required-content
|
||||||
interface, equipment, and Wailing Caverns starter content while optional
|
boundary as the production browser build. Optional dungeons, character/skin
|
||||||
dungeons, character packs, and Manastorm stages are downloaded into app-private
|
packs, Manastorm stages, and original spell media are downloaded into
|
||||||
storage. Installed content and local saves remain playable without a server
|
app-private storage. Installed content and local saves remain playable without
|
||||||
connection and survive APK updates. See `CONTENT_UPDATES.md` for the update
|
a server connection and survive APK updates. See `CONTENT_UPDATES.md` for the
|
||||||
contract and `DEPLOYMENT.md` for TrueNAS publishing and signed Gitea releases.
|
update contract and `DEPLOYMENT.md` for TrueNAS publishing and signed Gitea
|
||||||
|
releases.
|
||||||
|
|
||||||
## Runtime details
|
## Runtime details
|
||||||
|
|
||||||
@@ -206,6 +240,8 @@ contract and `DEPLOYMENT.md` for TrueNAS publishing and signed Gitea releases.
|
|||||||
compressed in GPU memory instead of expanding to uncompressed RGBA textures.
|
compressed in GPU memory instead of expanding to uncompressed RGBA textures.
|
||||||
- Missing production GLBs activate a small cave proxy; malformed assets activate
|
- Missing production GLBs activate a small cave proxy; malformed assets activate
|
||||||
the recoverable scene-error screen.
|
the recoverable scene-error screen.
|
||||||
|
- Missing optional character or skin packs render the built-in basic Human
|
||||||
|
model instead of hiding the player, party member, or roster preview.
|
||||||
- The controller action HUD displays only the active eight-slot layer. Its
|
- The controller action HUD displays only the active eight-slot layer. Its
|
||||||
face-button diamond, shoulders, and D-pad keep the physical controller layout
|
face-button diamond, shoulders, and D-pad keep the physical controller layout
|
||||||
visible without showing all 24 bindings over the playfield.
|
visible without showing all 24 bindings over the playfield.
|
||||||
|
|||||||
+8
-4
@@ -71,9 +71,12 @@ file, use the shorter procedure at the end. Those updates can be published by
|
|||||||
the server and downloaded by an already-installed APK without an Obtainium
|
the server and downloaded by an already-installed APK without an Obtainium
|
||||||
release.
|
release.
|
||||||
|
|
||||||
Single-player remains offline-capable. A fresh APK includes the runtime and
|
Single-player remains offline-capable. A fresh APK includes the runtime,
|
||||||
starter content. Additional content that a player has downloaded is retained
|
interface, starter equipment, Wailing Caverns, and the basic Human character
|
||||||
in Android app-private storage and can be used offline after download.
|
fallback. Additional content that a player has downloaded is retained in
|
||||||
|
Android app-private storage and can be used offline after download. Generated
|
||||||
|
spell effects are available without downloading the optional original-media
|
||||||
|
pack.
|
||||||
|
|
||||||
## Fixed locations
|
## Fixed locations
|
||||||
|
|
||||||
@@ -287,7 +290,8 @@ git diff --cached --check
|
|||||||
```
|
```
|
||||||
|
|
||||||
Do not continue if the staged list contains a keystore, APK, `.env` file,
|
Do not continue if the staged list contains a keystore, APK, `.env` file,
|
||||||
database, `content/`, `dist/`, `dist-android/`, or `.android-public/` output.
|
database, `content/`, `dist/`, `dist-android/`, `.web-public/`, or
|
||||||
|
`.android-public/` output.
|
||||||
Unstage any accidental file with `git restore --staged -- <path>` without
|
Unstage any accidental file with `git restore --staged -- <path>` without
|
||||||
deleting the local file.
|
deleting the local file.
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,23 @@ attaches native animated client models by display ID. It writes:
|
|||||||
- `src/game/generated/dungeonCampaignCatalog.compressed.ts`, the bundled catalog
|
- `src/game/generated/dungeonCampaignCatalog.compressed.ts`, the bundled catalog
|
||||||
- `src/game/generated/dungeonAvailability.json`, the generated availability list
|
- `src/game/generated/dungeonAvailability.json`, the generated availability list
|
||||||
|
|
||||||
|
Boss specials are compiled as independent mechanic records. AzerothCore
|
||||||
|
SmartAI timers and health events are imported automatically; C++ encounter
|
||||||
|
spells are imported from the pinned script checkout. Add or override a boss in
|
||||||
|
`dungeon-pipeline/boss-mechanics.json` when an encounter needs hand-authored
|
||||||
|
targeting, summon behavior, timing, or telegraph geometry. Use `mode: "replace"`
|
||||||
|
to make that list authoritative or `mode: "append"` to layer entries ahead of
|
||||||
|
the imported mechanics, then run `npm run dungeon:catalog`. The generated
|
||||||
|
`combat.mechanicIds` list is the stable loadout boundary intended for later
|
||||||
|
encounter randomization.
|
||||||
|
|
||||||
|
The same catalog also stores Runewaker kits under
|
||||||
|
`rw-<dungeon>-<entity-id>` keys. Run `npm run runewaker:bosses:refresh` to
|
||||||
|
rebuild those records from preserved RuneWaker spell/Lua evidence, regenerate
|
||||||
|
the population packages, and run `npm run runewaker:audit:gameplay`. A record
|
||||||
|
whose preserved source has no recoverable combat ability is explicitly marked
|
||||||
|
`source.kind: "healer-man-source-gap"` rather than being claimed as authentic.
|
||||||
|
|
||||||
Stock AzerothCore population is used for the standard dungeons. The three
|
Stock AzerothCore population is used for the standard dungeons. The three
|
||||||
Ascension-specific instances—Tinkertech Showdown, Frozen Reach, and Forgotten
|
Ascension-specific instances—Tinkertech Showdown, Frozen Reach, and Forgotten
|
||||||
Mine—keep their client encounter identities and use an explicit procedural
|
Mine—keep their client encounter identities and use an explicit procedural
|
||||||
@@ -229,4 +246,4 @@ Kresh is intentionally recorded as a source-faithful melee-only boss: entry
|
|||||||
3653 has no spell or SmartAI row in the pinned AzerothCore snapshot. Trial of
|
3653 has no spell or SmartAI row in the pinned AzerothCore snapshot. Trial of
|
||||||
the Champion is likewise documented as a boss-arena population rather than a
|
the Champion is likewise documented as a boss-arena population rather than a
|
||||||
trash route. These exceptions are asserted by exact dungeon/entity IDs; any
|
trash route. These exceptions are asserted by exact dungeon/entity IDs; any
|
||||||
new basic-only boss or mob-less dungeon fails the batch.
|
new basic-only boss or mob-less dungeon fails the batch.
|
||||||
File diff suppressed because it is too large.
Load diff
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.13",
|
"version": "0.1.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.13",
|
"version": "0.1.15",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
|
|||||||
+4
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.13",
|
"version": "0.1.15",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"db:backup": "node scripts/backup-game-database.mjs",
|
"db:backup": "node scripts/backup-game-database.mjs",
|
||||||
"server:test": "node --test server/*.test.mjs",
|
"server:test": "node --test server/*.test.mjs",
|
||||||
"content:publish": "node scripts/content/publish-content.mjs",
|
"content:publish": "node scripts/content/publish-content.mjs",
|
||||||
|
"web:prepare": "node scripts/content/prepare-web-bootstrap.mjs",
|
||||||
"android:prepare": "node scripts/content/prepare-android-bootstrap.mjs",
|
"android:prepare": "node scripts/content/prepare-android-bootstrap.mjs",
|
||||||
"content:test": "node --test scripts/content/*.test.mjs",
|
"content:test": "node --test scripts/content/*.test.mjs",
|
||||||
"effects:generate": "node scripts/combat-effects/generate-manifest.mjs --package-audio && node scripts/combat-effects/package-priest-original.mjs",
|
"effects:generate": "node scripts/combat-effects/generate-manifest.mjs --package-audio && node scripts/combat-effects/package-priest-original.mjs",
|
||||||
@@ -73,6 +74,7 @@
|
|||||||
"runewaker:register:all": "node scripts/runewaker-pipeline/cli.mjs register-all",
|
"runewaker:register:all": "node scripts/runewaker-pipeline/cli.mjs register-all",
|
||||||
"runewaker:refresh:all": "node scripts/runewaker-pipeline/cli.mjs refresh-all",
|
"runewaker:refresh:all": "node scripts/runewaker-pipeline/cli.mjs refresh-all",
|
||||||
"runewaker:spells:refresh": "node scripts/runewaker-pipeline/export-spell-catalog.mjs",
|
"runewaker:spells:refresh": "node scripts/runewaker-pipeline/export-spell-catalog.mjs",
|
||||||
|
"runewaker:bosses:refresh": "npm run runewaker:spells:refresh && node scripts/runewaker-pipeline/import-boss-mechanics.mjs",
|
||||||
"runewaker:players:refresh": "node scripts/runewaker-pipeline/export-player-catalog.mjs && npm run runewaker:players:avatars",
|
"runewaker:players:refresh": "node scripts/runewaker-pipeline/export-player-catalog.mjs && npm run runewaker:players:avatars",
|
||||||
"runewaker:players:avatars": "node scripts/runewaker-pipeline/build-population.mjs --recipe scripts/runewaker-pipeline/recipes/runewaker-player-avatars.json --actors-only && node scripts/runewaker-pipeline/finalize-player-avatar-manifest.mjs",
|
"runewaker:players:avatars": "node scripts/runewaker-pipeline/build-population.mjs --recipe scripts/runewaker-pipeline/recipes/runewaker-player-avatars.json --actors-only && node scripts/runewaker-pipeline/finalize-player-avatar-manifest.mjs",
|
||||||
"runewaker:paperdolls:refresh": "node scripts/runewaker-pipeline/refresh-paperdoll-appearances.mjs",
|
"runewaker:paperdolls:refresh": "node scripts/runewaker-pipeline/refresh-paperdoll-appearances.mjs",
|
||||||
@@ -96,7 +98,7 @@
|
|||||||
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
|
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
|
||||||
"balance:benchmark": "vite-node scripts/combat-benchmark/run.ts",
|
"balance:benchmark": "vite-node scripts/combat-benchmark/run.ts",
|
||||||
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
|
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
|
||||||
"prebuild": "npm run loot:generate && npm run assets:ktx2:audit && npm run assets:performance:audit",
|
"prebuild": "npm run loot:generate && npm run web:prepare && npm run assets:ktx2:audit && npm run assets:performance:audit",
|
||||||
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
|
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
|
||||||
"build:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android",
|
"build:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android",
|
||||||
"android:sync": "npm run build:android:web && cap sync android",
|
"android:sync": "npm run build:android:web && cap sync android",
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,68 @@
|
|||||||
|
const CONTENT_CACHE_NAME = "healer-man-content-v1";
|
||||||
|
const MANAGED_ASSET_PREFIXES = [
|
||||||
|
"/assets/characters/",
|
||||||
|
"/assets/creatures/",
|
||||||
|
"/assets/equipment/",
|
||||||
|
"/assets/game/",
|
||||||
|
"/assets/ui/",
|
||||||
|
];
|
||||||
|
const BUNDLED_ASSET_PREFIXES = [
|
||||||
|
"/assets/ui/",
|
||||||
|
"/assets/equipment/",
|
||||||
|
"/assets/characters/wow335a-v1/manifest.json",
|
||||||
|
"/assets/characters/wow335a-v1/human/",
|
||||||
|
"/assets/creatures/wailing-caverns/",
|
||||||
|
"/assets/game/dungeons/wailing-caverns/",
|
||||||
|
];
|
||||||
|
const BUNDLED_ASSET_PATHS = new Set([
|
||||||
|
"/assets/characters/wow335a-v1/textures/character/human/faciallowerhair00_00.webp",
|
||||||
|
"/assets/characters/wow335a-v1/textures/character/human/facialupperhair00_00.webp",
|
||||||
|
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemalefacelower00_00.webp",
|
||||||
|
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemalefaceupper00_00.webp",
|
||||||
|
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedpelvisskin00_00.webp",
|
||||||
|
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedtorsoskin00_00.webp",
|
||||||
|
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemaleskin00_00.webp",
|
||||||
|
"/assets/characters/wow335a-v1/textures/character/human/hair00_00.webp",
|
||||||
|
"/assets/characters/wow335a-v1/textures/character/human/male/humanmalefacelower00_00.webp",
|
||||||
|
"/assets/characters/wow335a-v1/textures/character/human/male/humanmalefaceupper00_00.webp",
|
||||||
|
"/assets/characters/wow335a-v1/textures/character/human/male/humanmalenakedpelvisskin00_00.webp",
|
||||||
|
"/assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp",
|
||||||
|
"/assets/characters/wow335a-v1/textures/character/human/scalplowerhair00_00.webp",
|
||||||
|
"/assets/characters/wow335a-v1/textures/character/human/scalpupperhair00_00.webp",
|
||||||
|
]);
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(self.skipWaiting());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil((async () => {
|
||||||
|
const names = await caches.keys();
|
||||||
|
await Promise.all(names
|
||||||
|
.filter((name) => name.startsWith(`${CONTENT_CACHE_NAME}-staging-`))
|
||||||
|
.map((name) => caches.delete(name)));
|
||||||
|
await self.clients.claim();
|
||||||
|
})());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
if (request.method !== "GET") return;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin
|
||||||
|
|| !MANAGED_ASSET_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) return;
|
||||||
|
|
||||||
|
event.respondWith((async () => {
|
||||||
|
const cache = await caches.open(CONTENT_CACHE_NAME);
|
||||||
|
const cached = await cache.match(request, { ignoreSearch: true });
|
||||||
|
if (cached) return cached;
|
||||||
|
if (BUNDLED_ASSET_PATHS.has(url.pathname)
|
||||||
|
|| BUNDLED_ASSET_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) {
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
return new Response("Optional content is not installed.", {
|
||||||
|
status: 404,
|
||||||
|
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||||
|
});
|
||||||
|
})());
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.13",
|
"versionName": "0.1.15",
|
||||||
"versionCode": 1013
|
"versionCode": 1015
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
|
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
|
BASIC_HUMAN_TEXTURE_PATHS,
|
||||||
PROJECT_ROOT,
|
PROJECT_ROOT,
|
||||||
createContentManifest,
|
createContentManifest,
|
||||||
scanContentSources,
|
scanContentSources,
|
||||||
@@ -12,8 +13,21 @@ if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirect
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sources = [
|
const sources = [
|
||||||
|
{ directory: path.join(PROJECT_ROOT, "public", "basis"), logicalPrefix: "basis" },
|
||||||
{ directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" },
|
{ directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" },
|
||||||
{ directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" },
|
{ directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" },
|
||||||
|
{
|
||||||
|
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "manifest.json"),
|
||||||
|
logicalPrefix: "assets/characters/wow335a-v1/manifest.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "human"),
|
||||||
|
logicalPrefix: "assets/characters/wow335a-v1/human",
|
||||||
|
},
|
||||||
|
...BASIC_HUMAN_TEXTURE_PATHS.map((logicalPath) => ({
|
||||||
|
directory: path.join(PROJECT_ROOT, "public", ...logicalPath.split("/")),
|
||||||
|
logicalPrefix: logicalPath,
|
||||||
|
})),
|
||||||
{
|
{
|
||||||
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
|
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
|
||||||
logicalPrefix: "assets/creatures/wailing-caverns",
|
logicalPrefix: "assets/creatures/wailing-caverns",
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
BASIC_HUMAN_TEXTURE_PATHS,
|
||||||
|
PROJECT_ROOT,
|
||||||
|
createContentManifest,
|
||||||
|
scanContentSources,
|
||||||
|
} from "./publish-content.mjs";
|
||||||
|
|
||||||
|
const outputDirectory = path.join(PROJECT_ROOT, ".web-public");
|
||||||
|
if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirectory) !== ".web-public") {
|
||||||
|
throw new Error(`Refusing to replace unexpected browser staging directory: ${outputDirectory}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources = [
|
||||||
|
{ directory: path.join(PROJECT_ROOT, "public", "basis"), logicalPrefix: "basis" },
|
||||||
|
{ directory: path.join(PROJECT_ROOT, "public", "data"), logicalPrefix: "data" },
|
||||||
|
{ directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" },
|
||||||
|
{ directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" },
|
||||||
|
{
|
||||||
|
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "manifest.json"),
|
||||||
|
logicalPrefix: "assets/characters/wow335a-v1/manifest.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "human"),
|
||||||
|
logicalPrefix: "assets/characters/wow335a-v1/human",
|
||||||
|
},
|
||||||
|
...BASIC_HUMAN_TEXTURE_PATHS.map((logicalPath) => ({
|
||||||
|
directory: path.join(PROJECT_ROOT, "public", ...logicalPath.split("/")),
|
||||||
|
logicalPrefix: logicalPath,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
|
||||||
|
logicalPrefix: "assets/creatures/wailing-caverns",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
|
||||||
|
logicalPrefix: "assets/game/dungeons/wailing-caverns",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await rm(outputDirectory, { recursive: true, force: true });
|
||||||
|
await mkdir(outputDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const files = await scanContentSources(sources);
|
||||||
|
for (const file of files) {
|
||||||
|
const destination = path.join(outputDirectory, ...file.logicalPath.split("/"));
|
||||||
|
await mkdir(path.dirname(destination), { recursive: true });
|
||||||
|
await copyFile(file.sourcePath, destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
await copyFile(
|
||||||
|
path.join(PROJECT_ROOT, "public", "content-cache-sw.js"),
|
||||||
|
path.join(outputDirectory, "content-cache-sw.js"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const generated = createContentManifest(files, { baseUrl: "/" });
|
||||||
|
const bootstrapManifest = {
|
||||||
|
...generated,
|
||||||
|
files: Object.fromEntries(Object.entries(generated.files).map(([logicalPath, entry]) => [
|
||||||
|
logicalPath,
|
||||||
|
{ ...entry, url: logicalPath },
|
||||||
|
])),
|
||||||
|
};
|
||||||
|
const manifestPath = path.join(outputDirectory, "content", "bootstrap-manifest.json");
|
||||||
|
await mkdir(path.dirname(manifestPath), { recursive: true });
|
||||||
|
await writeFile(manifestPath, `${JSON.stringify(bootstrapManifest, null, 2)}\n`, "utf8");
|
||||||
|
|
||||||
|
const bytes = files.reduce((total, file) => total + file.bytes, 0);
|
||||||
|
console.log(`Prepared browser bootstrap content: ${files.length} files (${bytes} bytes)`);
|
||||||
|
console.log(`Bootstrap version: ${bootstrapManifest.version}`);
|
||||||
@@ -18,14 +18,34 @@ export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0";
|
|||||||
export const BUNDLED_ANDROID_PACKS = Object.freeze([
|
export const BUNDLED_ANDROID_PACKS = Object.freeze([
|
||||||
"core:ui",
|
"core:ui",
|
||||||
"core:equipment",
|
"core:equipment",
|
||||||
|
"core:character-basic",
|
||||||
|
"core:runtime",
|
||||||
"dungeon:wailing-caverns",
|
"dungeon:wailing-caverns",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const RUNTIME_EXTENSIONS = new Set([
|
const RUNTIME_EXTENSIONS = new Set([
|
||||||
".avif", ".glb", ".json", ".ktx2", ".mp3", ".ogg", ".png",
|
".avif", ".glb", ".js", ".json", ".ktx2", ".mp3", ".ogg", ".png",
|
||||||
".svg", ".wav", ".webm", ".webp",
|
".svg", ".wasm", ".wav", ".webm", ".webp",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const BASIC_HUMAN_TEXTURE_PATHS = Object.freeze([
|
||||||
|
"assets/characters/wow335a-v1/textures/character/human/faciallowerhair00_00.webp",
|
||||||
|
"assets/characters/wow335a-v1/textures/character/human/facialupperhair00_00.webp",
|
||||||
|
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalefacelower00_00.webp",
|
||||||
|
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalefaceupper00_00.webp",
|
||||||
|
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedpelvisskin00_00.webp",
|
||||||
|
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedtorsoskin00_00.webp",
|
||||||
|
"assets/characters/wow335a-v1/textures/character/human/female/humanfemaleskin00_00.webp",
|
||||||
|
"assets/characters/wow335a-v1/textures/character/human/hair00_00.webp",
|
||||||
|
"assets/characters/wow335a-v1/textures/character/human/male/humanmalefacelower00_00.webp",
|
||||||
|
"assets/characters/wow335a-v1/textures/character/human/male/humanmalefaceupper00_00.webp",
|
||||||
|
"assets/characters/wow335a-v1/textures/character/human/male/humanmalenakedpelvisskin00_00.webp",
|
||||||
|
"assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp",
|
||||||
|
"assets/characters/wow335a-v1/textures/character/human/scalplowerhair00_00.webp",
|
||||||
|
"assets/characters/wow335a-v1/textures/character/human/scalpupperhair00_00.webp",
|
||||||
|
]);
|
||||||
|
const BASIC_HUMAN_TEXTURE_PATH_SET = new Set(BASIC_HUMAN_TEXTURE_PATHS);
|
||||||
|
|
||||||
export const DEFAULT_CONTENT_SOURCES = Object.freeze([
|
export const DEFAULT_CONTENT_SOURCES = Object.freeze([
|
||||||
{
|
{
|
||||||
directory: path.join(PROJECT_ROOT, "public", "assets"),
|
directory: path.join(PROJECT_ROOT, "public", "assets"),
|
||||||
@@ -49,18 +69,33 @@ function packLabel(packId) {
|
|||||||
const [category, id = category] = packId.split(":", 2);
|
const [category, id = category] = packId.split(":", 2);
|
||||||
const name = id.split("-").map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`).join(" ");
|
const name = id.split("-").map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`).join(" ");
|
||||||
if (category === "dungeon") return name;
|
if (category === "dungeon") return name;
|
||||||
if (category === "characters") return `${name} Characters`;
|
if (packId === "characters:wow335a-v1") return "Extra Wrath Character Models & Skins";
|
||||||
|
if (packId === "characters:ascension-v1") return "Ascension Character Models & Skins";
|
||||||
|
if (packId === "characters:runewaker-v1") return "RuneWaker Character Models & Skins";
|
||||||
|
if (category === "characters") return `${name} Character Models & Skins`;
|
||||||
if (category === "manastorm") return `${name} Manastorm`;
|
if (category === "manastorm") return `${name} Manastorm`;
|
||||||
if (packId === "shared:creatures") return "Shared Creatures";
|
if (packId === "shared:creatures") return "Shared Creatures";
|
||||||
if (packId === "core:ui") return "Interface";
|
if (packId === "core:ui") return "Interface";
|
||||||
if (packId === "core:equipment") return "Starter Equipment";
|
if (packId === "core:equipment") return "Starter Equipment";
|
||||||
|
if (packId === "core:character-basic") return "Basic Human Character Model";
|
||||||
|
if (packId === "core:runtime") return "Game Runtime";
|
||||||
|
if (packId === "core:combat-media") return "Original Spell Effects & Sounds";
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function contentPackForPath(logicalPath) {
|
export function contentPackForPath(logicalPath) {
|
||||||
const segments = normalizedRelativePath(logicalPath).split("/");
|
const normalizedPath = normalizedRelativePath(logicalPath);
|
||||||
|
const segments = normalizedPath.split("/");
|
||||||
|
if (segments[0] === "basis" || segments[0] === "data") return "core:runtime";
|
||||||
if (segments[0] !== "assets") return "core:misc";
|
if (segments[0] !== "assets") return "core:misc";
|
||||||
if (segments[1] === "characters" && segments[2]) return `characters:${segments[2]}`;
|
if (segments[1] === "characters" && segments[2]) {
|
||||||
|
const basicWrathHuman = segments[2] === "wow335a-v1" && (
|
||||||
|
segments[3] === "manifest.json"
|
||||||
|
|| segments[3] === "human"
|
||||||
|
|| BASIC_HUMAN_TEXTURE_PATH_SET.has(normalizedPath)
|
||||||
|
);
|
||||||
|
return basicWrathHuman ? "core:character-basic" : `characters:${segments[2]}`;
|
||||||
|
}
|
||||||
if (segments[1] === "creatures" && segments[2]) {
|
if (segments[1] === "creatures" && segments[2]) {
|
||||||
return segments[2] === "shared" ? "shared:creatures" : `dungeon:${segments[2]}`;
|
return segments[2] === "shared" ? "shared:creatures" : `dungeon:${segments[2]}`;
|
||||||
}
|
}
|
||||||
@@ -73,12 +108,17 @@ export function contentPackForPath(logicalPath) {
|
|||||||
if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) {
|
if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) {
|
||||||
return `manastorm:${segments[3]}`;
|
return `manastorm:${segments[3]}`;
|
||||||
}
|
}
|
||||||
|
if (segments[1] === "game" && segments[2] === "combat-effects") return "core:combat-media";
|
||||||
if (segments[1] === "ui") return "core:ui";
|
if (segments[1] === "ui") return "core:ui";
|
||||||
if (segments[1] === "equipment") return "core:equipment";
|
if (segments[1] === "equipment") return "core:equipment";
|
||||||
return "core:misc";
|
return "core:misc";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function walkFiles(directory) {
|
async function walkFiles(directory) {
|
||||||
|
const rootStats = await stat(directory);
|
||||||
|
if (rootStats.isFile()) {
|
||||||
|
return RUNTIME_EXTENSIONS.has(path.extname(directory).toLowerCase()) ? [directory] : [];
|
||||||
|
}
|
||||||
const files = [];
|
const files = [];
|
||||||
async function visit(current) {
|
async function visit(current) {
|
||||||
const entries = await readdir(current, { withFileTypes: true });
|
const entries = await readdir(current, { withFileTypes: true });
|
||||||
@@ -111,7 +151,9 @@ export async function scanContentSources(sources = DEFAULT_CONTENT_SOURCES) {
|
|||||||
const files = await walkFiles(sourceDirectory);
|
const files = await walkFiles(sourceDirectory);
|
||||||
for (const sourcePath of files) {
|
for (const sourcePath of files) {
|
||||||
const relative = normalizedRelativePath(path.relative(sourceDirectory, sourcePath));
|
const relative = normalizedRelativePath(path.relative(sourceDirectory, sourcePath));
|
||||||
const logicalPath = normalizedRelativePath(`${source.logicalPrefix}/${relative}`);
|
const logicalPath = normalizedRelativePath(relative
|
||||||
|
? `${source.logicalPrefix}/${relative}`
|
||||||
|
: source.logicalPrefix);
|
||||||
const fileStats = await stat(sourcePath);
|
const fileStats = await stat(sourcePath);
|
||||||
const sha256 = await sha256File(sourcePath);
|
const sha256 = await sha256File(sourcePath);
|
||||||
const prior = discovered.get(logicalPath);
|
const prior = discovered.get(logicalPath);
|
||||||
@@ -170,11 +212,17 @@ export function createContentManifest(files, options = {}) {
|
|||||||
|
|
||||||
if (packs.has("shared:creatures")) {
|
if (packs.has("shared:creatures")) {
|
||||||
for (const pack of packs.values()) {
|
for (const pack of packs.values()) {
|
||||||
if (pack.id.startsWith("dungeon:") || pack.id.startsWith("manastorm:")) {
|
if ((pack.id.startsWith("dungeon:") && pack.id !== "dungeon:wailing-caverns")
|
||||||
|
|| pack.id.startsWith("manastorm:")) {
|
||||||
pack.dependencies = ["shared:creatures"];
|
pack.dependencies = ["shared:creatures"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (packs.has("core:character-basic")) {
|
||||||
|
for (const pack of packs.values()) {
|
||||||
|
if (pack.id.startsWith("characters:")) pack.dependencies = ["core:character-basic"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
schemaVersion: CONTENT_SCHEMA_VERSION,
|
schemaVersion: CONTENT_SCHEMA_VERSION,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import {
|
import {
|
||||||
|
BUNDLED_ANDROID_PACKS,
|
||||||
contentPackForPath,
|
contentPackForPath,
|
||||||
createContentManifest,
|
createContentManifest,
|
||||||
publishContent,
|
publishContent,
|
||||||
@@ -13,8 +14,19 @@ import {
|
|||||||
test("assigns stable player-facing packs from logical asset paths", () => {
|
test("assigns stable player-facing packs from logical asset paths", () => {
|
||||||
assert.equal(contentPackForPath("assets/game/dungeons/wailing-caverns/visual.glb"), "dungeon:wailing-caverns");
|
assert.equal(contentPackForPath("assets/game/dungeons/wailing-caverns/visual.glb"), "dungeon:wailing-caverns");
|
||||||
assert.equal(contentPackForPath("assets/creatures/wailing-caverns/mutanus.glb"), "dungeon:wailing-caverns");
|
assert.equal(contentPackForPath("assets/creatures/wailing-caverns/mutanus.glb"), "dungeon:wailing-caverns");
|
||||||
assert.equal(contentPackForPath("assets/characters/wow335a-v1/manifest.json"), "characters:wow335a-v1");
|
assert.equal(contentPackForPath("assets/characters/wow335a-v1/manifest.json"), "core:character-basic");
|
||||||
|
assert.equal(contentPackForPath("assets/characters/wow335a-v1/human/male/rig.glb"), "core:character-basic");
|
||||||
|
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp"), "core:character-basic");
|
||||||
|
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_01.webp"), "characters:wow335a-v1");
|
||||||
|
assert.equal(contentPackForPath("assets/characters/wow335a-v1/orc/male/rig.glb"), "characters:wow335a-v1");
|
||||||
assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1");
|
assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1");
|
||||||
|
assert.equal(contentPackForPath("assets/game/combat-effects/audio/smite.wav"), "core:combat-media");
|
||||||
|
assert.equal(
|
||||||
|
contentPackForPath("assets/game/combat-effects/audio/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"),
|
||||||
|
"core:combat-media",
|
||||||
|
);
|
||||||
|
assert.equal(BUNDLED_ANDROID_PACKS.includes("core:combat-media"), false);
|
||||||
|
assert.equal(contentPackForPath("basis/basis_transcoder.wasm"), "core:runtime");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("publishes objects before a deterministic latest manifest", async () => {
|
test("publishes objects before a deterministic latest manifest", async () => {
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ const azerothCoreReferenceFile = path.join(
|
|||||||
"generated",
|
"generated",
|
||||||
"azerothCoreReference.json",
|
"azerothCoreReference.json",
|
||||||
);
|
);
|
||||||
|
const azerothCoreWorldAiFile = path.join(
|
||||||
|
projectRoot,
|
||||||
|
"src",
|
||||||
|
"game",
|
||||||
|
"generated",
|
||||||
|
"azerothCoreWorldAi.json",
|
||||||
|
);
|
||||||
|
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
|
||||||
const encounterFile = path.join(projectRoot, "src", "game", "generated", "manastormRuntimeCatalog.json");
|
const encounterFile = path.join(projectRoot, "src", "game", "generated", "manastormRuntimeCatalog.json");
|
||||||
const sourceCatalogFile = path.join(projectRoot, "src", "game", "generated", "manastormCatalog.json");
|
const sourceCatalogFile = path.join(projectRoot, "src", "game", "generated", "manastormCatalog.json");
|
||||||
const epochCatalogFile = path.join(
|
const epochCatalogFile = path.join(
|
||||||
@@ -533,8 +541,194 @@ function fallbackSpecial(id, boss) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function combatFor(entity, boss, spells, scriptCatalog = undefined) {
|
function validateBossMechanicsCatalog(catalog) {
|
||||||
|
if (catalog.schemaVersion !== 1) {
|
||||||
|
throw new Error(`Unsupported boss mechanic schema ${catalog.schemaVersion}.`);
|
||||||
|
}
|
||||||
|
const deliveries = new Set(["melee", "projectile", "area"]);
|
||||||
|
const targets = new Set(["primary", "random-party", "nearby-party", "self", "lowest-health-friendly"]);
|
||||||
|
const schools = new Set(["physical", "arcane", "fire", "frost", "nature", "shadow", "holy"]);
|
||||||
|
const animations = new Set(["attack", "cast"]);
|
||||||
|
for (const [entry, boss] of Object.entries(catalog.bosses ?? {})) {
|
||||||
|
if (!/^\d+$/.test(entry) && !/^rw-[a-z0-9-]+-\d+$/.test(entry)) {
|
||||||
|
throw new Error(`Boss mechanic key ${entry} is not a creature entry or RuneWaker entity id.`);
|
||||||
|
}
|
||||||
|
if (!["append", "replace"].includes(boss.mode)) {
|
||||||
|
throw new Error(`${entry}: mechanic mode must be append or replace.`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(boss.mechanics)) throw new Error(`${entry}: mechanics must be an array.`);
|
||||||
|
const ids = new Set();
|
||||||
|
for (const mechanic of boss.mechanics) {
|
||||||
|
if (!mechanic.id || ids.has(mechanic.id)) {
|
||||||
|
throw new Error(`${entry}: mechanic ids must be non-empty and unique (${mechanic.id}).`);
|
||||||
|
}
|
||||||
|
ids.add(mechanic.id);
|
||||||
|
if (!mechanic.name || !deliveries.has(mechanic.delivery) || !targets.has(mechanic.target)
|
||||||
|
|| !schools.has(mechanic.school) || !animations.has(mechanic.animation)
|
||||||
|
|| !Number.isFinite(mechanic.cooldownMs) || mechanic.cooldownMs <= 0
|
||||||
|
|| !Number.isFinite(mechanic.range) || mechanic.range < 0) {
|
||||||
|
throw new Error(`${entry}:${mechanic.id}: mechanic fields are incomplete or invalid.`);
|
||||||
|
}
|
||||||
|
if (mechanic.telegraph && !["circle", "cone", "line"].includes(mechanic.telegraph.shape)) {
|
||||||
|
throw new Error(`${entry}:${mechanic.id}: unsupported telegraph shape.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function smartAiEventProperties(row) {
|
||||||
|
if (row.event_type === 0) {
|
||||||
|
const initial = [Number(row.event_param1) || 0, Number(row.event_param2) || Number(row.event_param1) || 0];
|
||||||
|
const repeat = [Number(row.event_param3) || 1_000, Number(row.event_param4) || Number(row.event_param3) || 1_000];
|
||||||
|
return {
|
||||||
|
initialCooldownMs: initial,
|
||||||
|
repeatCooldownMs: repeat,
|
||||||
|
cooldownMs: Math.max(1_000, Math.round((repeat[0] + repeat[1]) / 2)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (row.event_type === 2) {
|
||||||
|
return {
|
||||||
|
initialCooldownMs: [0, 0],
|
||||||
|
repeatCooldownMs: [1_000, 1_000],
|
||||||
|
maximumCasts: 1,
|
||||||
|
cooldownMs: 1_000,
|
||||||
|
condition: {
|
||||||
|
kind: "self-health-percent",
|
||||||
|
minimum: Math.max(0, Number(row.event_param1) || 0),
|
||||||
|
maximum: Math.min(100, Number(row.event_param2) || 100),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (row.event_type === 4) {
|
||||||
|
return {
|
||||||
|
initialCooldownMs: [0, 0],
|
||||||
|
repeatCooldownMs: [1_000, 1_000],
|
||||||
|
maximumCasts: 1,
|
||||||
|
cooldownMs: 1_000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (row.event_type === 14) {
|
||||||
|
return {
|
||||||
|
initialCooldownMs: [0, 0],
|
||||||
|
repeatCooldownMs: [1_000, 1_000],
|
||||||
|
cooldownMs: 1_000,
|
||||||
|
condition: {
|
||||||
|
kind: "friendly-missing-health",
|
||||||
|
amount: Math.max(1, Number(row.event_param1) || 1),
|
||||||
|
range: Math.max(1, Number(row.event_param2) || 30),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function smartAiMechanicsFor(entity, boss, spells, worldAi) {
|
||||||
|
if (!boss) return [];
|
||||||
|
const entry = Number(entity.entry);
|
||||||
|
const rows = worldAi?.smartScriptsByEntry?.[String(entry)] ?? [];
|
||||||
|
const rowsById = new Map(rows.map((row) => [Number(row.id), row]));
|
||||||
|
const mechanics = [];
|
||||||
|
for (const trigger of rows.filter((row) => Number(row.event_type) !== 61)) {
|
||||||
|
const event = smartAiEventProperties(trigger);
|
||||||
|
if (!event) continue;
|
||||||
|
const chain = [];
|
||||||
|
let row = trigger;
|
||||||
|
const visited = new Set();
|
||||||
|
while (row && !visited.has(Number(row.id))) {
|
||||||
|
visited.add(Number(row.id));
|
||||||
|
chain.push(row);
|
||||||
|
row = Number(row.link) ? rowsById.get(Number(row.link)) : undefined;
|
||||||
|
}
|
||||||
|
for (const action of chain) {
|
||||||
|
const actionType = Number(action.action_type);
|
||||||
|
const source = {
|
||||||
|
entry,
|
||||||
|
sourceType: Number(action.source_type) || 0,
|
||||||
|
rowId: Number(action.id) || 0,
|
||||||
|
eventType: Number(trigger.event_type) || 0,
|
||||||
|
actionType,
|
||||||
|
targetType: Number(action.target_type) || 0,
|
||||||
|
comment: String(action.comment ?? trigger.comment ?? ""),
|
||||||
|
};
|
||||||
|
if (actionType === 11) {
|
||||||
|
const spellId = Number(action.action_param1);
|
||||||
|
const spell = spells.get(spellId);
|
||||||
|
if (!spell) continue;
|
||||||
|
const hash = stableHash(`${entry}:${spellId}:${action.id}`);
|
||||||
|
const attack = spellAttack(spell, hash, true);
|
||||||
|
mechanics.push({
|
||||||
|
...attack,
|
||||||
|
...event,
|
||||||
|
id: `smart-${entry}-${action.id}-${slugPart(spell.name)}`,
|
||||||
|
target: [5, 6].includes(Number(action.target_type))
|
||||||
|
? "random-party"
|
||||||
|
: attack.target,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
} else if (actionType === 12) {
|
||||||
|
const creatureEntry = Number(action.action_param1);
|
||||||
|
if (!creatureEntry) continue;
|
||||||
|
mechanics.push({
|
||||||
|
id: `smart-${entry}-${action.id}-summon-${creatureEntry}`,
|
||||||
|
name: String(action.comment ?? "Summon Reinforcements")
|
||||||
|
.replace(/^.*? - (?:In Combat|Health Between [^-]+) - /, ""),
|
||||||
|
delivery: "area",
|
||||||
|
target: "self",
|
||||||
|
school: "physical",
|
||||||
|
animation: "cast",
|
||||||
|
castTimeMs: 1_200,
|
||||||
|
interruptible: false,
|
||||||
|
range: 0,
|
||||||
|
damageMultiplier: 0,
|
||||||
|
effect: { kind: "summon", creatureEntry, count: 1 },
|
||||||
|
...event,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
} else if (actionType === 39) {
|
||||||
|
const radius = Math.max(1, Number(action.action_param1) || 30);
|
||||||
|
mechanics.push({
|
||||||
|
id: `smart-${entry}-${action.id}-call-for-help`,
|
||||||
|
name: "Call for Help",
|
||||||
|
delivery: "area",
|
||||||
|
target: "self",
|
||||||
|
school: "physical",
|
||||||
|
animation: "cast",
|
||||||
|
castTimeMs: 800,
|
||||||
|
interruptible: false,
|
||||||
|
range: 0,
|
||||||
|
damageMultiplier: 0,
|
||||||
|
effect: { kind: "call-for-help", radius },
|
||||||
|
...event,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mechanics;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueMechanics(mechanics) {
|
||||||
|
const seenIds = new Set();
|
||||||
|
const seenSpellIds = new Set();
|
||||||
|
return mechanics.filter((mechanic) => {
|
||||||
|
if (seenIds.has(mechanic.id)) return false;
|
||||||
|
if (mechanic.spellId && seenSpellIds.has(mechanic.spellId)) return false;
|
||||||
|
seenIds.add(mechanic.id);
|
||||||
|
if (mechanic.spellId) seenSpellIds.add(mechanic.spellId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function combatFor(
|
||||||
|
entity,
|
||||||
|
boss,
|
||||||
|
spells,
|
||||||
|
scriptCatalog = undefined,
|
||||||
|
worldAi = undefined,
|
||||||
|
bossMechanics = undefined,
|
||||||
|
) {
|
||||||
const hash = stableHash(`${entity.id}:${entity.name}`);
|
const hash = stableHash(`${entity.id}:${entity.name}`);
|
||||||
|
const entry = Number(entity.entry);
|
||||||
const basicCooldown = Math.max(1_250, Math.min(3_200, Number(entity.baseAttackTimeMs) || 1_900));
|
const basicCooldown = Math.max(1_250, Math.min(3_200, Number(entity.baseAttackTimeMs) || 1_900));
|
||||||
const scriptDetails = (scriptCatalog?.scripts?.[entity.scriptName]?.spells ?? [])
|
const scriptDetails = (scriptCatalog?.scripts?.[entity.scriptName]?.spells ?? [])
|
||||||
.filter((spell) => spell.combat);
|
.filter((spell) => spell.combat);
|
||||||
@@ -565,20 +759,27 @@ function combatFor(entity, boss, spells, scriptCatalog = undefined) {
|
|||||||
: null;
|
: null;
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, boss ? 4 : 2)
|
|
||||||
.map((spell) => spellAttack(spell, hash, boss));
|
.map((spell) => spellAttack(spell, hash, boss));
|
||||||
if (!spellAttacks.length && boss) spellAttacks.push(fallbackSpecial(entity.id, true));
|
const importedMechanics = smartAiMechanicsFor(entity, boss, spells, worldAi);
|
||||||
|
const authored = boss ? bossMechanics?.bosses?.[String(entry)] : undefined;
|
||||||
|
let mechanics = authored?.mode === "replace"
|
||||||
|
? authored.mechanics ?? []
|
||||||
|
: [...(authored?.mechanics ?? []), ...importedMechanics, ...spellAttacks];
|
||||||
|
mechanics = uniqueMechanics(mechanics);
|
||||||
|
if (!mechanics.length && boss && !authored) mechanics.push(fallbackSpecial(entity.id, true));
|
||||||
const levels = (entity.level ?? []).map(Number).filter(Number.isFinite);
|
const levels = (entity.level ?? []).map(Number).filter(Number.isFinite);
|
||||||
const level = levels.length
|
const level = levels.length
|
||||||
? Math.max(1, Math.round(levels.reduce((sum, value) => sum + value, 0) / levels.length))
|
? Math.max(1, Math.round(levels.reduce((sum, value) => sum + value, 0) / levels.length))
|
||||||
: undefined;
|
: undefined;
|
||||||
return {
|
return {
|
||||||
|
...(entry ? { serverEntry: entry } : {}),
|
||||||
...(level ? { level } : {}),
|
...(level ? { level } : {}),
|
||||||
healthMultiplier: boss ? 1.15 + Math.min(1.5, Number(entity.rank || 0) * 0.18) : 1,
|
healthMultiplier: boss ? 1.15 + Math.min(1.5, Number(entity.rank || 0) * 0.18) : 1,
|
||||||
damageMultiplier: boss ? 1.12 : 1,
|
damageMultiplier: boss ? 1.12 : 1,
|
||||||
moveSpeed: Math.max(2.4, Math.min(5.2, 3.2 * (Number(entity.runSpeed) || 1))),
|
moveSpeed: Math.max(2.4, Math.min(5.2, 3.2 * (Number(entity.runSpeed) || 1))),
|
||||||
leashRange: boss ? 70 : 52,
|
leashRange: boss ? 70 : 52,
|
||||||
attacks: [basicAttack(basicCooldown), ...spellAttacks],
|
...(boss ? { mechanicIds: mechanics.map((mechanic) => mechanic.id) } : {}),
|
||||||
|
attacks: [basicAttack(basicCooldown), ...mechanics],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,6 +797,7 @@ function wailingServerCombatFor(entity, boss, reference) {
|
|||||||
damageMultiplier: 1,
|
damageMultiplier: 1,
|
||||||
moveSpeed: Math.max(2.4, Math.min(5.2, 3.2 * (Number(entity.runSpeed) || 1))),
|
moveSpeed: Math.max(2.4, Math.min(5.2, 3.2 * (Number(entity.runSpeed) || 1))),
|
||||||
leashRange: boss ? 55 : 42,
|
leashRange: boss ? 55 : 42,
|
||||||
|
...(boss ? { mechanicIds: scripted.map((attack) => attack.id) } : {}),
|
||||||
attacks: [{
|
attacks: [{
|
||||||
...basicAttack(basicCooldown),
|
...basicAttack(basicCooldown),
|
||||||
initialCooldownMs: [450, 450],
|
initialCooldownMs: [450, 450],
|
||||||
@@ -664,7 +866,7 @@ function offsetPosition(position, index, radius = 3.2) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function syntheticEntity(id, name, boss, level) {
|
function syntheticEntity(id, name, boss, level, bossMechanics = undefined) {
|
||||||
const entity = {
|
const entity = {
|
||||||
id,
|
id,
|
||||||
entry: Number(id.match(/\d+/)?.[0] ?? stableHash(id)),
|
entry: Number(id.match(/\d+/)?.[0] ?? stableHash(id)),
|
||||||
@@ -681,7 +883,7 @@ function syntheticEntity(id, name, boss, level) {
|
|||||||
name,
|
name,
|
||||||
...(boss ? { title: "Dungeon Boss", hasLoot: true } : { hasLoot: false }),
|
...(boss ? { title: "Dungeon Boss", hasLoot: true } : { hasLoot: false }),
|
||||||
combat: {
|
combat: {
|
||||||
...combatFor(entity, boss, new Map()),
|
...combatFor(entity, boss, new Map(), undefined, undefined, bossMechanics),
|
||||||
...(!boss ? { attacks: [basicAttack(), fallbackSpecial(id, false)] } : {}),
|
...(!boss ? { attacks: [basicAttack(), fallbackSpecial(id, false)] } : {}),
|
||||||
},
|
},
|
||||||
visual: visualFor(id, name, boss, entity.rank),
|
visual: visualFor(id, name, boss, entity.rank),
|
||||||
@@ -764,6 +966,9 @@ async function compile() {
|
|||||||
const scriptSpellCatalog = await exists(scriptSpellCatalogFile)
|
const scriptSpellCatalog = await exists(scriptSpellCatalogFile)
|
||||||
? await readJson(scriptSpellCatalogFile)
|
? await readJson(scriptSpellCatalogFile)
|
||||||
: { scripts: {} };
|
: { scripts: {} };
|
||||||
|
const azerothCoreWorldAi = await readJson(azerothCoreWorldAiFile);
|
||||||
|
const bossMechanics = await readJson(bossMechanicsFile);
|
||||||
|
validateBossMechanicsCatalog(bossMechanics);
|
||||||
const azerothCoreReference = await readJson(azerothCoreReferenceFile);
|
const azerothCoreReference = await readJson(azerothCoreReferenceFile);
|
||||||
if (azerothCoreReference.wailingCaverns.unsupportedRequiredBossActions.length) {
|
if (azerothCoreReference.wailingCaverns.unsupportedRequiredBossActions.length) {
|
||||||
throw new Error("Wailing Caverns has unsupported required-boss SmartAI actions.");
|
throw new Error("Wailing Caverns has unsupported required-boss SmartAI actions.");
|
||||||
@@ -798,6 +1003,14 @@ async function compile() {
|
|||||||
for (const detail of scriptSpellCatalog.scripts?.[entity.scriptName]?.spells ?? []) {
|
for (const detail of scriptSpellCatalog.scripts?.[entity.scriptName]?.spells ?? []) {
|
||||||
if (detail.combat) wantedSpellIds.add(Number(detail.spellId));
|
if (detail.combat) wantedSpellIds.add(Number(detail.spellId));
|
||||||
}
|
}
|
||||||
|
for (const row of azerothCoreWorldAi.smartScriptsByEntry?.[String(entity.entry)] ?? []) {
|
||||||
|
if (Number(row.action_type) === 11) wantedSpellIds.add(Number(row.action_param1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const boss of Object.values(bossMechanics.bosses ?? {})) {
|
||||||
|
for (const mechanic of boss.mechanics ?? []) {
|
||||||
|
if (mechanic.spellId) wantedSpellIds.add(Number(mechanic.spellId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const spellDbc = path.resolve(process.env.ASCENSION_SPELL_DBC ?? defaultSpellDbc);
|
const spellDbc = path.resolve(process.env.ASCENSION_SPELL_DBC ?? defaultSpellDbc);
|
||||||
@@ -897,7 +1110,14 @@ async function compile() {
|
|||||||
...(boss ? { title: "Dungeon Boss", hasLoot: true } : {}),
|
...(boss ? { title: "Dungeon Boss", hasLoot: true } : {}),
|
||||||
combat: recipe.slug === "wailing-caverns"
|
combat: recipe.slug === "wailing-caverns"
|
||||||
? wailingServerCombatFor(source, boss, azerothCoreReference)
|
? wailingServerCombatFor(source, boss, azerothCoreReference)
|
||||||
: combatFor(source, boss, spells, scriptSpellCatalog),
|
: combatFor(
|
||||||
|
source,
|
||||||
|
boss,
|
||||||
|
spells,
|
||||||
|
scriptSpellCatalog,
|
||||||
|
azerothCoreWorldAi,
|
||||||
|
bossMechanics,
|
||||||
|
),
|
||||||
visual: visualFor(
|
visual: visualFor(
|
||||||
source.id,
|
source.id,
|
||||||
source.name,
|
source.name,
|
||||||
@@ -920,7 +1140,14 @@ async function compile() {
|
|||||||
hasLoot: true,
|
hasLoot: true,
|
||||||
combat: recipe.slug === "wailing-caverns"
|
combat: recipe.slug === "wailing-caverns"
|
||||||
? wailingServerCombatFor(source, true, azerothCoreReference)
|
? wailingServerCombatFor(source, true, azerothCoreReference)
|
||||||
: combatFor(source, true, spells, scriptSpellCatalog),
|
: combatFor(
|
||||||
|
source,
|
||||||
|
true,
|
||||||
|
spells,
|
||||||
|
scriptSpellCatalog,
|
||||||
|
azerothCoreWorldAi,
|
||||||
|
bossMechanics,
|
||||||
|
),
|
||||||
visual: visualFor(
|
visual: visualFor(
|
||||||
entityId,
|
entityId,
|
||||||
encounter.name,
|
encounter.name,
|
||||||
@@ -949,6 +1176,7 @@ async function compile() {
|
|||||||
encounter.name,
|
encounter.name,
|
||||||
true,
|
true,
|
||||||
recipe.difficultyVariants[0]?.levelRange?.[1] ?? 60,
|
recipe.difficultyVariants[0]?.levelRange?.[1] ?? 60,
|
||||||
|
bossMechanics,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,14 +19,15 @@ const outputFile = path.join(
|
|||||||
"generated",
|
"generated",
|
||||||
"azerothCoreScriptSpells.json",
|
"azerothCoreScriptSpells.json",
|
||||||
);
|
);
|
||||||
const epochSourceFile = path.join(
|
const campaignSourceFile = path.join(
|
||||||
projectRoot,
|
projectRoot,
|
||||||
"dungeon-pipeline",
|
"dungeon-pipeline",
|
||||||
"epoch-five-player-instances.json",
|
"dungeon-campaign.json",
|
||||||
);
|
);
|
||||||
const repository = "https://github.com/azerothcore/azerothcore-wotlk";
|
const repository = "https://github.com/azerothcore/azerothcore-wotlk";
|
||||||
const sourceRoots = [
|
const sourceRoots = [
|
||||||
"src/server/scripts/Kalimdor/CavernsOfTime",
|
"src/server/scripts/EasternKingdoms",
|
||||||
|
"src/server/scripts/Kalimdor",
|
||||||
"src/server/scripts/Northrend",
|
"src/server/scripts/Northrend",
|
||||||
"src/server/scripts/Outland",
|
"src/server/scripts/Outland",
|
||||||
"src/server/scripts/World",
|
"src/server/scripts/World",
|
||||||
@@ -45,7 +46,10 @@ const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|||||||
|
|
||||||
function runGit(argumentsList, cwd = projectRoot) {
|
function runGit(argumentsList, cwd = projectRoot) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn("git", argumentsList, {
|
const safeArguments = cwd === sourceRoot
|
||||||
|
? ["-c", `safe.directory=${sourceRoot.replace(/\\/g, "/")}`, ...argumentsList]
|
||||||
|
: argumentsList;
|
||||||
|
const child = spawn("git", safeArguments, {
|
||||||
cwd,
|
cwd,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
@@ -61,7 +65,10 @@ function runGit(argumentsList, cwd = projectRoot) {
|
|||||||
|
|
||||||
function readGit(argumentsList, cwd = projectRoot) {
|
function readGit(argumentsList, cwd = projectRoot) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn("git", argumentsList, {
|
const safeArguments = cwd === sourceRoot
|
||||||
|
? ["-c", `safe.directory=${sourceRoot.replace(/\\/g, "/")}`, ...argumentsList]
|
||||||
|
: argumentsList;
|
||||||
|
const child = spawn("git", safeArguments, {
|
||||||
cwd,
|
cwd,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
@@ -203,8 +210,8 @@ if (checkedOutCommit !== DEFAULT_AZEROTHCORE_COMMIT) {
|
|||||||
`AzerothCore script checkout is ${checkedOutCommit}; rerun with --fetch to restore ${DEFAULT_AZEROTHCORE_COMMIT}.`,
|
`AzerothCore script checkout is ${checkedOutCommit}; rerun with --fetch to restore ${DEFAULT_AZEROTHCORE_COMMIT}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const epochSource = await readJson(epochSourceFile);
|
const campaignSource = await readJson(campaignSourceFile);
|
||||||
const wantedSlugs = new Set(epochSource.dungeons.map((dungeon) => dungeon.slug));
|
const wantedSlugs = new Set(campaignSource.dungeons.map((dungeon) => dungeon.slug));
|
||||||
const scriptNames = new Set();
|
const scriptNames = new Set();
|
||||||
for (const slug of wantedSlugs) {
|
for (const slug of wantedSlugs) {
|
||||||
const draftFile = path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons", slug, "runtime-draft.json");
|
const draftFile = path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons", slug, "runtime-draft.json");
|
||||||
|
|||||||
@@ -105,8 +105,9 @@ NPC spell evidence is exported from the preserved read-only RuneWaker client
|
|||||||
catalogs into a checked-in portable snapshot:
|
catalogs into a checked-in portable snapshot:
|
||||||
|
|
||||||
~~~powershell
|
~~~powershell
|
||||||
npm run runewaker:spells:refresh
|
npm run runewaker:bosses:refresh
|
||||||
npm run runewaker:build:all -- --phase population --force
|
npm run runewaker:build:all -- --phase population --force
|
||||||
|
npm run runewaker:forsaken:population
|
||||||
npm run runewaker:audit:gameplay
|
npm run runewaker:audit:gameplay
|
||||||
~~~
|
~~~
|
||||||
|
|
||||||
@@ -123,11 +124,29 @@ evidence-only. The portable mapping preserves source spell identity, name,
|
|||||||
damage family, school, range, and cooldown. The current 0.85 damage coefficient
|
damage family, school, range, and cooldown. The current 0.85 damage coefficient
|
||||||
is HealerMan-authored and is not represented as the original RuneWaker formula.
|
is HealerMan-authored and is not represented as the original RuneWaker formula.
|
||||||
|
|
||||||
|
`runewaker:bosses:refresh` first rebuilds that portable spell snapshot, then
|
||||||
|
statically follows each packaged boss's preserved AutoPlot/template callbacks
|
||||||
|
and writes modular `rw-<dungeon>-<entity-id>` kits to
|
||||||
|
`dungeon-pipeline/boss-mechanics.json`. Each special is an independent record
|
||||||
|
with stable ID, trigger, target, effects, and explicit circle, cone, or line
|
||||||
|
telegraph geometry. Both population builders copy those IDs into
|
||||||
|
`combat.mechanicIds`, so the same runtime loadout boundary is shared by
|
||||||
|
Runewaker and WoW encounters and can later support reviewed randomization.
|
||||||
|
|
||||||
|
Source-backed records identify the RuneWaker Lua and spell evidence they came
|
||||||
|
from. If the preserved scripts expose only encounter-progression hooks, the
|
||||||
|
importer creates a conservative playable mechanic marked
|
||||||
|
`source.kind: "healer-man-source-gap"`; it never labels that fallback as an
|
||||||
|
original RuneWaker ability. Edit or append a boss kit in the shared catalog,
|
||||||
|
then rerun the population build and gameplay audit to update an encounter.
|
||||||
|
|
||||||
The gameplay audit also:
|
The gameplay audit also:
|
||||||
|
|
||||||
- verifies every executable attack.spellId against both its source template
|
- verifies every executable attack.spellId against both its source template
|
||||||
assignment and the portable spell catalog;
|
assignment and the portable spell catalog;
|
||||||
- rejects evidence-only spell IDs if they enter runtime combat data;
|
- rejects evidence-only spell IDs if they enter runtime combat data;
|
||||||
|
- requires every boss objective to resolve a modular catalog kit and validates
|
||||||
|
its source label, telegraph, timed trigger, and health trigger;
|
||||||
- reuses the live party-navigation graph to inspect boss approach points
|
- reuses the live party-navigation graph to inspect boss approach points
|
||||||
projected to the retained surface within 6m;
|
projected to the retained surface within 6m;
|
||||||
- validates native animation families, with Rune Device's missing locomotion
|
- validates native animation families, with Rune Device's missing locomotion
|
||||||
|
|||||||
@@ -8,11 +8,16 @@
|
|||||||
"runtimeSpawns": 4086,
|
"runtimeSpawns": 4086,
|
||||||
"runtimeEntities": 436,
|
"runtimeEntities": 436,
|
||||||
"bossObjectives": 61,
|
"bossObjectives": 61,
|
||||||
"runtimeAttacks": 824,
|
"runtimeAttacks": 1068,
|
||||||
"genericDefaultAttacks": 436,
|
"genericDefaultAttacks": 436,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 388,
|
"catalogBossMechanics": 279,
|
||||||
|
"sourceBackedBossMechanics": 275,
|
||||||
|
"sourceGapBossMechanics": 4,
|
||||||
|
"telegraphedBossMechanics": 111,
|
||||||
|
"healthTriggeredBossMechanics": 28,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 483,
|
||||||
"sourceSpellTemplates": 293,
|
"sourceSpellTemplates": 293,
|
||||||
"sourceSpellAssignments": 659,
|
"sourceSpellAssignments": 659,
|
||||||
"scriptedTemplates": 357,
|
"scriptedTemplates": 357,
|
||||||
@@ -27,9 +32,9 @@
|
|||||||
"dungeons": 30,
|
"dungeons": 30,
|
||||||
"uniqueNativeModels": 321,
|
"uniqueNativeModels": 321,
|
||||||
"uniqueSourceSpellIds": 411,
|
"uniqueSourceSpellIds": 411,
|
||||||
"portableSpellCatalogRecords": 411,
|
"portableSpellCatalogRecords": 3199,
|
||||||
"portableDamageFamilyRecords": 227,
|
"portableDamageFamilyRecords": 1342,
|
||||||
"portableEvidenceOnlyRecords": 184
|
"portableEvidenceOnlyRecords": 1857
|
||||||
},
|
},
|
||||||
"mechanicsCategories": {
|
"mechanicsCategories": {
|
||||||
"death": 75,
|
"death": 75,
|
||||||
@@ -53,7 +58,7 @@
|
|||||||
"boss spawn membership"
|
"boss spawn membership"
|
||||||
],
|
],
|
||||||
"genericRuntimeSystems": [
|
"genericRuntimeSystems": [
|
||||||
"unmapped attacks",
|
"source-gap attacks",
|
||||||
"health/damage scaling",
|
"health/damage scaling",
|
||||||
"movement/leash",
|
"movement/leash",
|
||||||
"clustering/aggro",
|
"clustering/aggro",
|
||||||
@@ -63,9 +68,9 @@
|
|||||||
"procedural animation"
|
"procedural animation"
|
||||||
],
|
],
|
||||||
"deferredMechanics": [
|
"deferredMechanics": [
|
||||||
"doors/gates",
|
"complex doors/gates",
|
||||||
"summons/waves",
|
"unmapped multi-actor waves",
|
||||||
"phases",
|
"unmapped phases",
|
||||||
"patrols",
|
"patrols",
|
||||||
"interactions",
|
"interactions",
|
||||||
"encounter resets",
|
"encounter resets",
|
||||||
@@ -216,7 +221,10 @@
|
|||||||
},
|
},
|
||||||
"sourceMechanics": {
|
"sourceMechanics": {
|
||||||
"status": "review-required",
|
"status": "review-required",
|
||||||
"executableMechanicsGenerated": false,
|
"executableBossMechanicsGenerated": true,
|
||||||
|
"catalogBossMechanics": 279,
|
||||||
|
"sourceBackedBossMechanics": 275,
|
||||||
|
"sourceGapBossMechanics": 4,
|
||||||
"scriptedTemplates": 357,
|
"scriptedTemplates": 357,
|
||||||
"evidenceCounts": {
|
"evidenceCounts": {
|
||||||
"death": 75,
|
"death": 75,
|
||||||
@@ -239,7 +247,7 @@
|
|||||||
"unknownEffectsExecute": false,
|
"unknownEffectsExecute": false,
|
||||||
"coefficientsAreSourceExact": false
|
"coefficientsAreSourceExact": false
|
||||||
},
|
},
|
||||||
"mappingPolicy": "Only positively classified direct-HP damage families execute; unknown/buff/heal/summon/script effects remain evidence-only."
|
"mappingPolicy": "Only positively classified direct-HP damage families execute; named source auras, controls, summons, and health gates use conservative modular effects; unknown semantics remain evidence-only."
|
||||||
},
|
},
|
||||||
"resolvedRuntimeObservations": [
|
"resolvedRuntimeObservations": [
|
||||||
{
|
{
|
||||||
@@ -257,8 +265,7 @@
|
|||||||
"reviewNotes": [
|
"reviewNotes": [
|
||||||
"demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.",
|
"demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.",
|
||||||
"zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.",
|
"zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.",
|
||||||
"436 executable attacks do not carry an audited source-spell mapping.",
|
"4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.",
|
||||||
"Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.",
|
|
||||||
"Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.",
|
"Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.",
|
||||||
"61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence."
|
"61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence."
|
||||||
],
|
],
|
||||||
@@ -284,6 +291,11 @@
|
|||||||
"genericDefaultAttacks": 16,
|
"genericDefaultAttacks": 16,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
|
"catalogBossMechanics": 0,
|
||||||
|
"sourceBackedBossMechanics": 0,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 0,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 13,
|
"sourceBackedDamageFamilyAttacks": 13,
|
||||||
"nativeBindings": 16,
|
"nativeBindings": 16,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
@@ -333,10 +345,15 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 14,
|
"runtimeAttacks": 15,
|
||||||
"genericDefaultAttacks": 7,
|
"genericDefaultAttacks": 7,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
|
"catalogBossMechanics": 3,
|
||||||
|
"sourceBackedBossMechanics": 3,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 3,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 7,
|
"sourceBackedDamageFamilyAttacks": 7,
|
||||||
"nativeBindings": 7,
|
"nativeBindings": 7,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
@@ -400,11 +417,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 9,
|
"runtimeAttacks": 20,
|
||||||
"genericDefaultAttacks": 7,
|
"genericDefaultAttacks": 7,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 2,
|
"catalogBossMechanics": 11,
|
||||||
|
"sourceBackedBossMechanics": 11,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 6,
|
||||||
|
"healthTriggeredBossMechanics": 1,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 9,
|
||||||
"nativeBindings": 7,
|
"nativeBindings": 7,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
"proxyBindings": 0,
|
"proxyBindings": 0,
|
||||||
@@ -453,11 +475,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 28,
|
"runtimeAttacks": 32,
|
||||||
"genericDefaultAttacks": 16,
|
"genericDefaultAttacks": 16,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 12,
|
"catalogBossMechanics": 4,
|
||||||
|
"sourceBackedBossMechanics": 4,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 1,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 14,
|
||||||
"nativeBindings": 16,
|
"nativeBindings": 16,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
"proxyBindings": 0,
|
"proxyBindings": 0,
|
||||||
@@ -491,6 +518,11 @@
|
|||||||
"genericDefaultAttacks": 0,
|
"genericDefaultAttacks": 0,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
|
"catalogBossMechanics": 0,
|
||||||
|
"sourceBackedBossMechanics": 0,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 0,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 0,
|
"sourceBackedDamageFamilyAttacks": 0,
|
||||||
"nativeBindings": 0,
|
"nativeBindings": 0,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
@@ -525,6 +557,11 @@
|
|||||||
"genericDefaultAttacks": 17,
|
"genericDefaultAttacks": 17,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
|
"catalogBossMechanics": 0,
|
||||||
|
"sourceBackedBossMechanics": 0,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 0,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 5,
|
"sourceBackedDamageFamilyAttacks": 5,
|
||||||
"nativeBindings": 17,
|
"nativeBindings": 17,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
@@ -700,11 +737,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 15,
|
"runtimeAttacks": 26,
|
||||||
"genericDefaultAttacks": 15,
|
"genericDefaultAttacks": 15,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 0,
|
"catalogBossMechanics": 11,
|
||||||
|
"sourceBackedBossMechanics": 7,
|
||||||
|
"sourceGapBossMechanics": 4,
|
||||||
|
"telegraphedBossMechanics": 6,
|
||||||
|
"healthTriggeredBossMechanics": 4,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 2,
|
||||||
"nativeBindings": 15,
|
"nativeBindings": 15,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
"proxyBindings": 0,
|
"proxyBindings": 0,
|
||||||
@@ -738,6 +780,11 @@
|
|||||||
"genericDefaultAttacks": 11,
|
"genericDefaultAttacks": 11,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
|
"catalogBossMechanics": 0,
|
||||||
|
"sourceBackedBossMechanics": 0,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 0,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 10,
|
"sourceBackedDamageFamilyAttacks": 10,
|
||||||
"nativeBindings": 11,
|
"nativeBindings": 11,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
@@ -931,11 +978,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 74,
|
"runtimeAttacks": 122,
|
||||||
"genericDefaultAttacks": 40,
|
"genericDefaultAttacks": 40,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 34,
|
"catalogBossMechanics": 49,
|
||||||
|
"sourceBackedBossMechanics": 49,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 23,
|
||||||
|
"healthTriggeredBossMechanics": 2,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 60,
|
||||||
"nativeBindings": 39,
|
"nativeBindings": 39,
|
||||||
"proceduralBindings": 1,
|
"proceduralBindings": 1,
|
||||||
"proxyBindings": 0,
|
"proxyBindings": 0,
|
||||||
@@ -1052,11 +1104,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 54,
|
"runtimeAttacks": 75,
|
||||||
"genericDefaultAttacks": 26,
|
"genericDefaultAttacks": 26,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 28,
|
"catalogBossMechanics": 25,
|
||||||
|
"sourceBackedBossMechanics": 25,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 4,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 34,
|
||||||
"nativeBindings": 26,
|
"nativeBindings": 26,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
"proxyBindings": 0,
|
"proxyBindings": 0,
|
||||||
@@ -1173,11 +1230,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 68,
|
"runtimeAttacks": 80,
|
||||||
"genericDefaultAttacks": 32,
|
"genericDefaultAttacks": 32,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 36,
|
"catalogBossMechanics": 16,
|
||||||
|
"sourceBackedBossMechanics": 16,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 8,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 39,
|
||||||
"nativeBindings": 32,
|
"nativeBindings": 32,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
"proxyBindings": 0,
|
"proxyBindings": 0,
|
||||||
@@ -1211,6 +1273,11 @@
|
|||||||
"genericDefaultAttacks": 1,
|
"genericDefaultAttacks": 1,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
|
"catalogBossMechanics": 0,
|
||||||
|
"sourceBackedBossMechanics": 0,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 0,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 4,
|
"sourceBackedDamageFamilyAttacks": 4,
|
||||||
"nativeBindings": 1,
|
"nativeBindings": 1,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
@@ -1316,11 +1383,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 31,
|
"runtimeAttacks": 48,
|
||||||
"genericDefaultAttacks": 14,
|
"genericDefaultAttacks": 14,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 17,
|
"catalogBossMechanics": 23,
|
||||||
|
"sourceBackedBossMechanics": 23,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 8,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 20,
|
||||||
"nativeBindings": 14,
|
"nativeBindings": 14,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
"proxyBindings": 0,
|
"proxyBindings": 0,
|
||||||
@@ -1369,11 +1441,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 27,
|
"runtimeAttacks": 33,
|
||||||
"genericDefaultAttacks": 20,
|
"genericDefaultAttacks": 20,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 7,
|
"catalogBossMechanics": 6,
|
||||||
|
"sourceBackedBossMechanics": 6,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 4,
|
||||||
|
"healthTriggeredBossMechanics": 1,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 11,
|
||||||
"nativeBindings": 20,
|
"nativeBindings": 20,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
"proxyBindings": 0,
|
"proxyBindings": 0,
|
||||||
@@ -1422,11 +1499,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 34,
|
"runtimeAttacks": 40,
|
||||||
"genericDefaultAttacks": 16,
|
"genericDefaultAttacks": 16,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 18,
|
"catalogBossMechanics": 6,
|
||||||
|
"sourceBackedBossMechanics": 6,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 2,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 22,
|
||||||
"nativeBindings": 16,
|
"nativeBindings": 16,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
"proxyBindings": 0,
|
"proxyBindings": 0,
|
||||||
@@ -1460,6 +1542,11 @@
|
|||||||
"genericDefaultAttacks": 14,
|
"genericDefaultAttacks": 14,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
|
"catalogBossMechanics": 0,
|
||||||
|
"sourceBackedBossMechanics": 0,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 0,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 9,
|
"sourceBackedDamageFamilyAttacks": 9,
|
||||||
"nativeBindings": 14,
|
"nativeBindings": 14,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
@@ -1545,10 +1632,15 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 38,
|
"runtimeAttacks": 50,
|
||||||
"genericDefaultAttacks": 12,
|
"genericDefaultAttacks": 12,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
|
"catalogBossMechanics": 23,
|
||||||
|
"sourceBackedBossMechanics": 23,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 11,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 26,
|
"sourceBackedDamageFamilyAttacks": 26,
|
||||||
"nativeBindings": 12,
|
"nativeBindings": 12,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
@@ -1670,11 +1762,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 18,
|
"runtimeAttacks": 52,
|
||||||
"genericDefaultAttacks": 12,
|
"genericDefaultAttacks": 12,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 6,
|
"catalogBossMechanics": 34,
|
||||||
|
"sourceBackedBossMechanics": 34,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 9,
|
||||||
|
"healthTriggeredBossMechanics": 16,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 13,
|
||||||
"nativeBindings": 12,
|
"nativeBindings": 12,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
"proxyBindings": 0,
|
"proxyBindings": 0,
|
||||||
@@ -1719,11 +1816,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 61,
|
"runtimeAttacks": 65,
|
||||||
"genericDefaultAttacks": 40,
|
"genericDefaultAttacks": 40,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 21,
|
"catalogBossMechanics": 4,
|
||||||
|
"sourceBackedBossMechanics": 4,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 3,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 25,
|
||||||
"nativeBindings": 40,
|
"nativeBindings": 40,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
"proxyBindings": 0,
|
"proxyBindings": 0,
|
||||||
@@ -1772,11 +1874,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 1,
|
"runtimeAttacks": 5,
|
||||||
"genericDefaultAttacks": 1,
|
"genericDefaultAttacks": 1,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 0,
|
"catalogBossMechanics": 4,
|
||||||
|
"sourceBackedBossMechanics": 4,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 3,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 2,
|
||||||
"nativeBindings": 1,
|
"nativeBindings": 1,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
"proxyBindings": 0,
|
"proxyBindings": 0,
|
||||||
@@ -1861,11 +1968,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 28,
|
"runtimeAttacks": 31,
|
||||||
"genericDefaultAttacks": 16,
|
"genericDefaultAttacks": 16,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 12,
|
"catalogBossMechanics": 3,
|
||||||
|
"sourceBackedBossMechanics": 3,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 2,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 14,
|
||||||
"nativeBindings": 16,
|
"nativeBindings": 16,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
"proxyBindings": 0,
|
"proxyBindings": 0,
|
||||||
@@ -1924,11 +2036,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 55,
|
"runtimeAttacks": 64,
|
||||||
"genericDefaultAttacks": 26,
|
"genericDefaultAttacks": 26,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 29,
|
"catalogBossMechanics": 9,
|
||||||
|
"sourceBackedBossMechanics": 9,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 1,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 34,
|
||||||
"nativeBindings": 26,
|
"nativeBindings": 26,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
"proxyBindings": 0,
|
"proxyBindings": 0,
|
||||||
@@ -1962,6 +2079,11 @@
|
|||||||
"genericDefaultAttacks": 25,
|
"genericDefaultAttacks": 25,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
|
"catalogBossMechanics": 0,
|
||||||
|
"sourceBackedBossMechanics": 0,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 0,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 32,
|
"sourceBackedDamageFamilyAttacks": 32,
|
||||||
"nativeBindings": 25,
|
"nativeBindings": 25,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
@@ -2035,11 +2157,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 55,
|
"runtimeAttacks": 63,
|
||||||
"genericDefaultAttacks": 21,
|
"genericDefaultAttacks": 21,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 34,
|
"catalogBossMechanics": 12,
|
||||||
|
"sourceBackedBossMechanics": 12,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 3,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 36,
|
||||||
"nativeBindings": 21,
|
"nativeBindings": 21,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
"proxyBindings": 0,
|
"proxyBindings": 0,
|
||||||
@@ -2073,6 +2200,11 @@
|
|||||||
"genericDefaultAttacks": 1,
|
"genericDefaultAttacks": 1,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
|
"catalogBossMechanics": 0,
|
||||||
|
"sourceBackedBossMechanics": 0,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 0,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 0,
|
"sourceBackedDamageFamilyAttacks": 0,
|
||||||
"nativeBindings": 1,
|
"nativeBindings": 1,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
@@ -2150,11 +2282,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 5,
|
"runtimeAttacks": 25,
|
||||||
"genericDefaultAttacks": 3,
|
"genericDefaultAttacks": 3,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 2,
|
"catalogBossMechanics": 22,
|
||||||
|
"sourceBackedBossMechanics": 22,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 8,
|
||||||
|
"healthTriggeredBossMechanics": 4,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 11,
|
||||||
"nativeBindings": 3,
|
"nativeBindings": 3,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
"proxyBindings": 0,
|
"proxyBindings": 0,
|
||||||
@@ -2203,11 +2340,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 2,
|
"runtimeAttacks": 8,
|
||||||
"genericDefaultAttacks": 1,
|
"genericDefaultAttacks": 1,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 1,
|
"catalogBossMechanics": 7,
|
||||||
|
"sourceBackedBossMechanics": 7,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 4,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 5,
|
||||||
"nativeBindings": 1,
|
"nativeBindings": 1,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
"proxyBindings": 0,
|
"proxyBindings": 0,
|
||||||
@@ -2256,11 +2398,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtimeAttacks": 1,
|
"runtimeAttacks": 8,
|
||||||
"genericDefaultAttacks": 1,
|
"genericDefaultAttacks": 1,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 0,
|
"catalogBossMechanics": 7,
|
||||||
|
"sourceBackedBossMechanics": 7,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 2,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
|
"sourceBackedDamageFamilyAttacks": 3,
|
||||||
"nativeBindings": 1,
|
"nativeBindings": 1,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
"proxyBindings": 0,
|
"proxyBindings": 0,
|
||||||
@@ -2294,6 +2441,11 @@
|
|||||||
"genericDefaultAttacks": 25,
|
"genericDefaultAttacks": 25,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
|
"catalogBossMechanics": 0,
|
||||||
|
"sourceBackedBossMechanics": 0,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 0,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 23,
|
"sourceBackedDamageFamilyAttacks": 23,
|
||||||
"nativeBindings": 25,
|
"nativeBindings": 25,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
@@ -2328,6 +2480,11 @@
|
|||||||
"genericDefaultAttacks": 0,
|
"genericDefaultAttacks": 0,
|
||||||
"builderAuthoredAttacks": 0,
|
"builderAuthoredAttacks": 0,
|
||||||
"recipeAuthoredAttacks": 0,
|
"recipeAuthoredAttacks": 0,
|
||||||
|
"catalogBossMechanics": 0,
|
||||||
|
"sourceBackedBossMechanics": 0,
|
||||||
|
"sourceGapBossMechanics": 0,
|
||||||
|
"telegraphedBossMechanics": 0,
|
||||||
|
"healthTriggeredBossMechanics": 0,
|
||||||
"sourceBackedDamageFamilyAttacks": 0,
|
"sourceBackedDamageFamilyAttacks": 0,
|
||||||
"nativeBindings": 0,
|
"nativeBindings": 0,
|
||||||
"proceduralBindings": 0,
|
"proceduralBindings": 0,
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
|
|||||||
## Coverage
|
## Coverage
|
||||||
|
|
||||||
- 30 dungeons, 8199 source rows, 4086 static spawns, and 436 combat entities.
|
- 30 dungeons, 8199 source rows, 4086 static spawns, and 436 combat entities.
|
||||||
- 61 boss objectives and 824 executable attacks.
|
- 61 boss objectives and 1068 executable attacks.
|
||||||
- Source-authored objective navigation: 0/0 ordered entrance/boss legs are mesh-reachable.
|
- Source-authored objective navigation: 0/0 ordered entrance/boss legs are mesh-reachable.
|
||||||
- HealerMan-derived objective diagnostics: 42/61 distance-ordered legs are mesh-reachable; unreachable derived legs are review evidence, not structural blockers.
|
- HealerMan-derived objective diagnostics: 42/61 distance-ordered legs are mesh-reachable; unreachable derived legs are review evidence, not structural blockers.
|
||||||
- Animation entity bindings: 435 native, 1 static procedural, and 0 proxy.
|
- Animation entity bindings: 435 native, 1 static procedural, and 0 proxy.
|
||||||
- 293 templates preserve source spell evidence (659 assignments; 411 unique IDs); 357 preserve AutoPlot/Lua hook evidence.
|
- 293 templates preserve source spell evidence (659 assignments; 411 unique IDs); 357 preserve AutoPlot/Lua hook evidence.
|
||||||
|
- 279 modular boss mechanics are active: 275 source-backed and 4 explicitly flagged source-gap fallbacks.
|
||||||
|
- Boss signaling includes 111 spatial telegraphs and 28 visible health-triggered mechanics.
|
||||||
|
|
||||||
## Authentic source data currently used
|
## Authentic source data currently used
|
||||||
|
|
||||||
@@ -21,17 +23,17 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
|
|||||||
directions, model paths, source hashes, and boss spawn membership.
|
directions, model paths, source hashes, and boss spawn membership.
|
||||||
- Navmesh-projected placements derived from each packaged environment anchor.
|
- Navmesh-projected placements derived from each packaged environment anchor.
|
||||||
- Original static meshes; original GR2 clips only where the manifest and GLB prove they exist.
|
- Original static meshes; original GR2 clips only where the manifest and GLB prove they exist.
|
||||||
- Spell IDs, Lua hooks, AutoPlot names, and Lua signals are retained as evidence, not guessed.
|
- Spell IDs, Lua hooks, AutoPlot names, and Lua signals feed the modular boss-mechanic catalog.
|
||||||
|
|
||||||
## HealerMan-generic or deferred
|
## HealerMan-generic or deferred
|
||||||
|
|
||||||
- 436 entities use generated Basic Attack/Corrosive Slam.
|
- 436 entities use generated Basic Attack/Corrosive Slam.
|
||||||
- 0 attacks are builder exceptions without source mapping.
|
- 0 attacks are builder exceptions outside the modular boss catalog.
|
||||||
- 388 executable attacks carry a conservative source-backed direct-damage family mapping.
|
- 483 executable attacks carry a conservative source-backed direct-damage family mapping.
|
||||||
- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;
|
- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;
|
||||||
HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.
|
HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.
|
||||||
- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.
|
- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.
|
||||||
- Doors, phases, summons, patrols, resets, interactions, and source loot remain review-required.
|
- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.
|
||||||
- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,
|
- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,
|
||||||
attack, cast, wound, and death families.
|
attack, cast, wound, and death families.
|
||||||
|
|
||||||
@@ -42,33 +44,33 @@ Animation columns are native/procedural/proxy entity bindings.
|
|||||||
| Dungeon | Source | Spawns | Entities | Bosses | Attacks | Spell templates | Scripted templates | N/P/X | Routes | Mechanics |
|
| Dungeon | Source | Spawns | Entities | Bosses | Attacks | Spell templates | Scripted templates | N/P/X | Routes | Mechanics |
|
||||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
||||||
| aeternal-circle | 514 | 123 | 16 | 0 | 29 | 11 | 20 | 16/0/0 | 0/0 derived | review-required |
|
| aeternal-circle | 514 | 123 | 16 | 0 | 29 | 11 | 20 | 16/0/0 | 0/0 derived | review-required |
|
||||||
| barren-caves | 63 | 48 | 7 | 1 | 14 | 7 | 3 | 7/0/0 | 1/1 derived | review-required |
|
| barren-caves | 63 | 48 | 7 | 1 | 15 | 7 | 3 | 7/0/0 | 1/1 derived | review-required |
|
||||||
| cave-water-dragon | 144 | 97 | 7 | 2 | 9 | 4 | 5 | 7/0/0 | 1/2 derived | review-required |
|
| cave-water-dragon | 144 | 97 | 7 | 2 | 20 | 4 | 5 | 7/0/0 | 1/2 derived | review-required |
|
||||||
| demon-stronghold | 439 | 183 | 16 | 1 | 28 | 7 | 14 | 16/0/0 | 1/1 derived | review-required |
|
| demon-stronghold | 439 | 183 | 16 | 1 | 32 | 7 | 14 | 16/0/0 | 1/1 derived | review-required |
|
||||||
| demon-stronghold-125 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0/0/0 | 0/0 derived | review-required |
|
| demon-stronghold-125 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0/0/0 | 0/0 derived | review-required |
|
||||||
| dungeon-of-dalanis | 229 | 67 | 17 | 0 | 22 | 9 | 13 | 17/0/0 | 0/0 derived | review-required |
|
| dungeon-of-dalanis | 229 | 67 | 17 | 0 | 22 | 9 | 13 | 17/0/0 | 0/0 derived | review-required |
|
||||||
| forsaken-abbey | 251 | 150 | 15 | 8 | 15 | 0 | 11 | 15/0/0 | 8/8 derived | review-required |
|
| forsaken-abbey | 251 | 150 | 15 | 8 | 26 | 0 | 11 | 15/0/0 | 8/8 derived | review-required |
|
||||||
| grafu-castle | 249 | 56 | 11 | 0 | 21 | 6 | 15 | 11/0/0 | 0/0 derived | review-required |
|
| grafu-castle | 249 | 56 | 11 | 0 | 21 | 6 | 15 | 11/0/0 | 0/0 derived | review-required |
|
||||||
| hall-of-survivors | 363 | 162 | 40 | 9 | 74 | 23 | 25 | 39/1/0 | 9/9 derived | review-required |
|
| hall-of-survivors | 363 | 162 | 40 | 9 | 122 | 23 | 25 | 39/1/0 | 9/9 derived | review-required |
|
||||||
| hoto | 401 | 307 | 26 | 5 | 54 | 19 | 15 | 26/0/0 | 2/5 derived | review-required |
|
| hoto | 401 | 307 | 26 | 5 | 75 | 19 | 15 | 26/0/0 | 2/5 derived | review-required |
|
||||||
| ice-dwarf-kingdom | 651 | 599 | 32 | 5 | 68 | 25 | 11 | 32/0/0 | 3/5 derived | review-required |
|
| ice-dwarf-kingdom | 651 | 599 | 32 | 5 | 80 | 25 | 11 | 32/0/0 | 3/5 derived | review-required |
|
||||||
| kal-turok-hive | 4 | 1 | 1 | 0 | 5 | 1 | 3 | 1/0/0 | 0/0 derived | review-required |
|
| kal-turok-hive | 4 | 1 | 1 | 0 | 5 | 1 | 3 | 1/0/0 | 0/0 derived | review-required |
|
||||||
| kalins-shrine | 289 | 165 | 14 | 5 | 31 | 13 | 17 | 14/0/0 | 0/5 derived | review-required |
|
| kalins-shrine | 289 | 165 | 14 | 5 | 48 | 13 | 17 | 14/0/0 | 0/5 derived | review-required |
|
||||||
| kawaks-tomb | 293 | 117 | 20 | 1 | 27 | 13 | 24 | 20/0/0 | 1/1 derived | review-required |
|
| kawaks-tomb | 293 | 117 | 20 | 1 | 33 | 13 | 24 | 20/0/0 | 1/1 derived | review-required |
|
||||||
| kulech-bones-nest | 287 | 154 | 16 | 1 | 34 | 14 | 20 | 16/0/0 | 1/1 derived | review-required |
|
| kulech-bones-nest | 287 | 154 | 16 | 1 | 40 | 14 | 20 | 16/0/0 | 1/1 derived | review-required |
|
||||||
| lair-demon-dragon | 532 | 103 | 14 | 0 | 23 | 9 | 10 | 14/0/0 | 0/0 derived | review-required |
|
| lair-demon-dragon | 532 | 103 | 14 | 0 | 23 | 9 | 10 | 14/0/0 | 0/0 derived | review-required |
|
||||||
| mystic-altar | 398 | 251 | 12 | 3 | 38 | 11 | 15 | 12/0/0 | 3/3 derived | review-required |
|
| mystic-altar | 398 | 251 | 12 | 3 | 50 | 11 | 15 | 12/0/0 | 3/3 derived | review-required |
|
||||||
| paspers-shrine | 255 | 224 | 12 | 5 | 18 | 7 | 6 | 12/0/0 | 5/5 derived | review-required |
|
| paspers-shrine | 255 | 224 | 12 | 5 | 52 | 7 | 6 | 12/0/0 | 5/5 derived | review-required |
|
||||||
| raksha-temple | 487 | 123 | 40 | 1 | 61 | 29 | 29 | 40/0/0 | 0/1 derived | review-required |
|
| raksha-temple | 487 | 123 | 40 | 1 | 65 | 29 | 29 | 40/0/0 | 0/1 derived | review-required |
|
||||||
| sand-vortex | 2 | 1 | 1 | 1 | 1 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
|
| sand-vortex | 2 | 1 | 1 | 1 | 5 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
|
||||||
| sardo-castle | 247 | 71 | 16 | 3 | 28 | 7 | 24 | 16/0/0 | 3/3 derived | review-required |
|
| sardo-castle | 247 | 71 | 16 | 3 | 31 | 7 | 24 | 16/0/0 | 3/3 derived | review-required |
|
||||||
| the-origin | 510 | 188 | 26 | 2 | 55 | 21 | 10 | 26/0/0 | 0/2 derived | review-required |
|
| the-origin | 510 | 188 | 26 | 2 | 64 | 21 | 10 | 26/0/0 | 0/2 derived | review-required |
|
||||||
| tomb-seven-heroes | 439 | 240 | 25 | 0 | 57 | 18 | 24 | 25/0/0 | 0/0 derived | review-required |
|
| tomb-seven-heroes | 439 | 240 | 25 | 0 | 57 | 18 | 24 | 25/0/0 | 0/0 derived | review-required |
|
||||||
| treasure-trove | 685 | 497 | 21 | 3 | 55 | 20 | 17 | 21/0/0 | 0/3 derived | review-required |
|
| treasure-trove | 685 | 497 | 21 | 3 | 63 | 20 | 17 | 21/0/0 | 0/3 derived | review-required |
|
||||||
| warnorken-arena | 40 | 1 | 1 | 0 | 1 | 0 | 2 | 1/0/0 | 0/0 derived | review-required |
|
| warnorken-arena | 40 | 1 | 1 | 0 | 1 | 0 | 2 | 1/0/0 | 0/0 derived | review-required |
|
||||||
| ystra-labyrinth-boss1 | 17 | 3 | 3 | 3 | 5 | 2 | 4 | 3/0/0 | 1/3 derived | review-required |
|
| ystra-labyrinth-boss1 | 17 | 3 | 3 | 3 | 25 | 2 | 4 | 3/0/0 | 1/3 derived | review-required |
|
||||||
| ystra-labyrinth-boss2 | 3 | 1 | 1 | 1 | 2 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
|
| ystra-labyrinth-boss2 | 3 | 1 | 1 | 1 | 8 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
|
||||||
| ystra-labyrinth-boss3 | 7 | 1 | 1 | 1 | 1 | 0 | 1 | 1/0/0 | 1/1 derived | review-required |
|
| ystra-labyrinth-boss3 | 7 | 1 | 1 | 1 | 8 | 0 | 1 | 1/0/0 | 1/1 derived | review-required |
|
||||||
| zurhidon-stronghold | 400 | 153 | 25 | 0 | 48 | 15 | 17 | 25/0/0 | 0/0 derived | review-required |
|
| zurhidon-stronghold | 400 | 153 | 25 | 0 | 48 | 15 | 17 | 25/0/0 | 0/0 derived | review-required |
|
||||||
| zurhidon-stronghold-124 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0/0/0 | 0/0 derived | review-required |
|
| zurhidon-stronghold-124 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0/0/0 | 0/0 derived | review-required |
|
||||||
|
|
||||||
@@ -111,7 +113,7 @@ Derived-order navigation review: 19 unreachable heuristic legs across 8 dungeons
|
|||||||
- ystra-labyrinth-boss1: Specter Guardian Creator -> Raichika the Destroyer (healerMan-distance-derived)
|
- ystra-labyrinth-boss1: Specter Guardian Creator -> Raichika the Destroyer (healerMan-distance-derived)
|
||||||
- ystra-labyrinth-boss1: Raichika the Destroyer -> Rune Guardian Leader (healerMan-distance-derived)
|
- ystra-labyrinth-boss1: Raichika the Destroyer -> Rune Guardian Leader (healerMan-distance-derived)
|
||||||
|
|
||||||
Unimplemented source mechanics: 357 templates preserve AutoPlot/Lua evidence, with executable generation deliberately disabled.
|
Source-mechanics review: 357 templates preserve AutoPlot/Lua evidence; supported boss combat behavior is compiled into the modular catalog.
|
||||||
|
|
||||||
- death: 75
|
- death: 75
|
||||||
- door: 22
|
- door: 22
|
||||||
@@ -139,7 +141,6 @@ Unimplemented source mechanics: 357 templates preserve AutoPlot/Lua evidence, wi
|
|||||||
|
|
||||||
- demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.
|
- demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.
|
||||||
- zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.
|
- zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.
|
||||||
- 436 executable attacks do not carry an audited source-spell mapping.
|
- 4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.
|
||||||
- Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.
|
|
||||||
- Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.
|
- Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.
|
||||||
- 61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence.
|
- 61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence.
|
||||||
@@ -194,6 +194,12 @@ function renderMarkdown(report) {
|
|||||||
"- " + summary.sourceSpellTemplates + " templates preserve source spell evidence ("
|
"- " + summary.sourceSpellTemplates + " templates preserve source spell evidence ("
|
||||||
+ summary.sourceSpellAssignments + " assignments; " + summary.uniqueSourceSpellIds
|
+ summary.sourceSpellAssignments + " assignments; " + summary.uniqueSourceSpellIds
|
||||||
+ " unique IDs); " + summary.scriptedTemplates + " preserve AutoPlot/Lua hook evidence.",
|
+ " unique IDs); " + summary.scriptedTemplates + " preserve AutoPlot/Lua hook evidence.",
|
||||||
|
"- " + summary.catalogBossMechanics + " modular boss mechanics are active: "
|
||||||
|
+ summary.sourceBackedBossMechanics + " source-backed and "
|
||||||
|
+ summary.sourceGapBossMechanics + " explicitly flagged source-gap fallbacks.",
|
||||||
|
"- Boss signaling includes " + summary.telegraphedBossMechanics
|
||||||
|
+ " spatial telegraphs and " + summary.healthTriggeredBossMechanics
|
||||||
|
+ " visible health-triggered mechanics.",
|
||||||
"",
|
"",
|
||||||
"## Authentic source data currently used",
|
"## Authentic source data currently used",
|
||||||
"",
|
"",
|
||||||
@@ -201,18 +207,18 @@ function renderMarkdown(report) {
|
|||||||
" directions, model paths, source hashes, and boss spawn membership.",
|
" directions, model paths, source hashes, and boss spawn membership.",
|
||||||
"- Navmesh-projected placements derived from each packaged environment anchor.",
|
"- Navmesh-projected placements derived from each packaged environment anchor.",
|
||||||
"- Original static meshes; original GR2 clips only where the manifest and GLB prove they exist.",
|
"- Original static meshes; original GR2 clips only where the manifest and GLB prove they exist.",
|
||||||
"- Spell IDs, Lua hooks, AutoPlot names, and Lua signals are retained as evidence, not guessed.",
|
"- Spell IDs, Lua hooks, AutoPlot names, and Lua signals feed the modular boss-mechanic catalog.",
|
||||||
"",
|
"",
|
||||||
"## HealerMan-generic or deferred",
|
"## HealerMan-generic or deferred",
|
||||||
"",
|
"",
|
||||||
"- " + summary.genericDefaultAttacks + " entities use generated Basic Attack/Corrosive Slam.",
|
"- " + summary.genericDefaultAttacks + " entities use generated Basic Attack/Corrosive Slam.",
|
||||||
"- " + summary.builderAuthoredAttacks + " attacks are builder exceptions without source mapping.",
|
"- " + summary.builderAuthoredAttacks + " attacks are builder exceptions outside the modular boss catalog.",
|
||||||
"- " + summary.sourceBackedDamageFamilyAttacks
|
"- " + summary.sourceBackedDamageFamilyAttacks
|
||||||
+ " executable attacks carry a conservative source-backed direct-damage family mapping.",
|
+ " executable attacks carry a conservative source-backed direct-damage family mapping.",
|
||||||
"- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;",
|
"- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;",
|
||||||
" HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.",
|
" HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.",
|
||||||
"- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.",
|
"- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.",
|
||||||
"- Doors, phases, summons, patrols, resets, interactions, and source loot remain review-required.",
|
"- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.",
|
||||||
"- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,",
|
"- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,",
|
||||||
" attack, cast, wound, and death families.",
|
" attack, cast, wound, and death families.",
|
||||||
"",
|
"",
|
||||||
@@ -243,8 +249,8 @@ function renderMarkdown(report) {
|
|||||||
...report.blockerGroups.navigation.derivedEntries.map((entry) => "- " + entry.dungeonId
|
...report.blockerGroups.navigation.derivedEntries.map((entry) => "- " + entry.dungeonId
|
||||||
+ ": " + entry.from + " -> " + entry.to + " (" + entry.orderKind + ")"),
|
+ ": " + entry.from + " -> " + entry.to + " (" + entry.orderKind + ")"),
|
||||||
"",
|
"",
|
||||||
"Unimplemented source mechanics: " + report.blockerGroups.sourceMechanics.scriptedTemplates
|
"Source-mechanics review: " + report.blockerGroups.sourceMechanics.scriptedTemplates
|
||||||
+ " templates preserve AutoPlot/Lua evidence, with executable generation deliberately disabled.",
|
+ " templates preserve AutoPlot/Lua evidence; supported boss combat behavior is compiled into the modular catalog.",
|
||||||
"",
|
"",
|
||||||
...Object.entries(report.blockerGroups.sourceMechanics.evidenceCounts)
|
...Object.entries(report.blockerGroups.sourceMechanics.evidenceCounts)
|
||||||
.map(([kind, count]) => "- " + kind + ": " + count),
|
.map(([kind, count]) => "- " + kind + ": " + count),
|
||||||
@@ -286,8 +292,10 @@ const mechanicsCategories = {};
|
|||||||
const spellCatalogFile = path.join(
|
const spellCatalogFile = path.join(
|
||||||
root, "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
root, "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
);
|
);
|
||||||
|
const bossMechanicsFile = path.join(root, "dungeon-pipeline/boss-mechanics.json");
|
||||||
let spellCatalogSnapshot = null;
|
let spellCatalogSnapshot = null;
|
||||||
let spellCatalog = new Map();
|
let spellCatalog = new Map();
|
||||||
|
let bossMechanics = { bosses: {} };
|
||||||
try {
|
try {
|
||||||
spellCatalogSnapshot = await readJson(spellCatalogFile);
|
spellCatalogSnapshot = await readJson(spellCatalogFile);
|
||||||
if (spellCatalogSnapshot.status !== "portable"
|
if (spellCatalogSnapshot.status !== "portable"
|
||||||
@@ -305,6 +313,14 @@ try {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
issues.push("Could not load the portable RuneWaker spell catalog: " + error.message);
|
issues.push("Could not load the portable RuneWaker spell catalog: " + error.message);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
bossMechanics = await readJson(bossMechanicsFile);
|
||||||
|
if (bossMechanics.schemaVersion !== 1) {
|
||||||
|
issues.push("RuneWaker boss mechanics use an unsupported catalog schema.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
issues.push("Could not load the modular boss mechanic catalog: " + error.message);
|
||||||
|
}
|
||||||
|
|
||||||
const registrySource = await readFile(
|
const registrySource = await readFile(
|
||||||
path.join(root, "src/game/generated/runewakerDungeonDefinitions.generated.ts"),
|
path.join(root, "src/game/generated/runewakerDungeonDefinitions.generated.ts"),
|
||||||
@@ -411,6 +427,11 @@ for (const recipeName of recipeNames) {
|
|||||||
genericDefaultAttacks: 0,
|
genericDefaultAttacks: 0,
|
||||||
builderAuthoredAttacks: 0,
|
builderAuthoredAttacks: 0,
|
||||||
recipeAuthoredAttacks: 0,
|
recipeAuthoredAttacks: 0,
|
||||||
|
catalogBossMechanics: 0,
|
||||||
|
sourceBackedBossMechanics: 0,
|
||||||
|
sourceGapBossMechanics: 0,
|
||||||
|
telegraphedBossMechanics: 0,
|
||||||
|
healthTriggeredBossMechanics: 0,
|
||||||
sourceBackedDamageFamilyAttacks: 0,
|
sourceBackedDamageFamilyAttacks: 0,
|
||||||
nativeBindings: 0,
|
nativeBindings: 0,
|
||||||
proceduralBindings: 0,
|
proceduralBindings: 0,
|
||||||
@@ -452,10 +473,28 @@ for (const recipeName of recipeNames) {
|
|||||||
|
|
||||||
const attacks = entity.combat?.attacks ?? [];
|
const attacks = entity.combat?.attacks ?? [];
|
||||||
if (!attacks.length) issue(issues, dungeonId, entityId + " has no combat family.");
|
if (!attacks.length) issue(issues, dungeonId, entityId + " has no combat family.");
|
||||||
|
const authoredBoss = bossMechanics.bosses?.[entityId];
|
||||||
|
const authoredMechanicIds = new Set(
|
||||||
|
(authoredBoss?.mechanics ?? []).map((mechanic) => mechanic.id),
|
||||||
|
);
|
||||||
|
const authoredSourceSpellIds = new Set(
|
||||||
|
(authoredBoss?.source?.spellIds ?? []).map(Number),
|
||||||
|
);
|
||||||
|
if (entity.kind === "boss") {
|
||||||
|
if (!authoredBoss) issue(issues, dungeonId, entityId + " has no modular boss mechanic kit.");
|
||||||
|
const runtimeMechanicIds = entity.combat?.mechanicIds ?? [];
|
||||||
|
if (runtimeMechanicIds.length !== authoredMechanicIds.size
|
||||||
|
|| runtimeMechanicIds.some((id) => !authoredMechanicIds.has(id))) {
|
||||||
|
issue(issues, dungeonId, entityId + " mechanic id registry drifted from the modular catalog.");
|
||||||
|
}
|
||||||
|
}
|
||||||
const attackIds = new Set();
|
const attackIds = new Set();
|
||||||
const executableSourceSpellIds = new Set();
|
const executableSourceSpellIds = new Set();
|
||||||
const candidateSpellIds = new Set(
|
const candidateSpellIds = new Set(
|
||||||
(candidateById.get(templateId)?.spells ?? []).map((spell) => Number(spell.id)),
|
[
|
||||||
|
...(candidateById.get(templateId)?.spells ?? []).map((spell) => Number(spell.id)),
|
||||||
|
...authoredSourceSpellIds,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
for (const attack of attacks) {
|
for (const attack of attacks) {
|
||||||
counts.runtimeAttacks += 1;
|
counts.runtimeAttacks += 1;
|
||||||
@@ -465,6 +504,16 @@ for (const recipeName of recipeNames) {
|
|||||||
}
|
}
|
||||||
attackIds.add(attack.id);
|
attackIds.add(attack.id);
|
||||||
const recipeAttack = template?.attacks?.find((entry) => entry.id === attack.id);
|
const recipeAttack = template?.attacks?.find((entry) => entry.id === attack.id);
|
||||||
|
const catalogAuthored = authoredMechanicIds.has(attack.id);
|
||||||
|
if (catalogAuthored) {
|
||||||
|
counts.catalogBossMechanics += 1;
|
||||||
|
if (authoredBoss.source?.kind === "runewaker") counts.sourceBackedBossMechanics += 1;
|
||||||
|
else counts.sourceGapBossMechanics += 1;
|
||||||
|
if (attack.telegraph) counts.telegraphedBossMechanics += 1;
|
||||||
|
if (attack.condition?.kind === "self-health-percent") {
|
||||||
|
counts.healthTriggeredBossMechanics += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
const executableSpellId = Number(attack.spellId ?? attack.source?.spellId ?? 0);
|
const executableSpellId = Number(attack.spellId ?? attack.source?.spellId ?? 0);
|
||||||
if (executableSpellId > 0) {
|
if (executableSpellId > 0) {
|
||||||
executableSourceSpellIds.add(executableSpellId);
|
executableSourceSpellIds.add(executableSpellId);
|
||||||
@@ -482,8 +531,7 @@ for (const recipeName of recipeNames) {
|
|||||||
} else {
|
} else {
|
||||||
counts.sourceBackedDamageFamilyAttacks += 1;
|
counts.sourceBackedDamageFamilyAttacks += 1;
|
||||||
if (attack.spellId && [
|
if (attack.spellId && [
|
||||||
"name", "delivery", "target", "school", "animation", "range",
|
"name", "school", "animation", "cooldownMs", "damageMultiplier",
|
||||||
"cooldownMs", "damageMultiplier",
|
|
||||||
].some((field) => attack[field] !== (
|
].some((field) => attack[field] !== (
|
||||||
field === "name" ? catalogSpell.name : catalogSpell.runtime[field]
|
field === "name" ? catalogSpell.name : catalogSpell.runtime[field]
|
||||||
))) {
|
))) {
|
||||||
@@ -499,9 +547,9 @@ for (const recipeName of recipeNames) {
|
|||||||
issue(issues, dungeonId, entityId + " recipe attack cites unassigned source spell "
|
issue(issues, dungeonId, entityId + " recipe attack cites unassigned source spell "
|
||||||
+ sourceSpellId + ".");
|
+ sourceSpellId + ".");
|
||||||
}
|
}
|
||||||
} else if (attack.id.endsWith("-basic-attack")) {
|
} else if (attack.id === "basic-attack" || attack.id.endsWith("-basic-attack")) {
|
||||||
counts.genericDefaultAttacks += 1;
|
counts.genericDefaultAttacks += 1;
|
||||||
} else if (!attack.spellId) {
|
} else if (!catalogAuthored && !attack.spellId) {
|
||||||
counts.builderAuthoredAttacks += 1;
|
counts.builderAuthoredAttacks += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -683,6 +731,8 @@ for (const [url, users] of nativeModelUsers) {
|
|||||||
const summaryKeys = [
|
const summaryKeys = [
|
||||||
"sourceRows", "runtimeSpawns", "runtimeEntities", "bossObjectives", "runtimeAttacks",
|
"sourceRows", "runtimeSpawns", "runtimeEntities", "bossObjectives", "runtimeAttacks",
|
||||||
"genericDefaultAttacks", "builderAuthoredAttacks", "recipeAuthoredAttacks",
|
"genericDefaultAttacks", "builderAuthoredAttacks", "recipeAuthoredAttacks",
|
||||||
|
"catalogBossMechanics", "sourceBackedBossMechanics", "sourceGapBossMechanics",
|
||||||
|
"telegraphedBossMechanics", "healthTriggeredBossMechanics",
|
||||||
"sourceBackedDamageFamilyAttacks", "sourceSpellTemplates", "sourceSpellAssignments",
|
"sourceBackedDamageFamilyAttacks", "sourceSpellTemplates", "sourceSpellAssignments",
|
||||||
"scriptedTemplates", "nativeBindings", "proceduralBindings", "proxyBindings",
|
"scriptedTemplates", "nativeBindings", "proceduralBindings", "proxyBindings",
|
||||||
"objectiveRoutes", "blockedObjectiveRoutes", "sourceOrderedObjectiveRoutes",
|
"objectiveRoutes", "blockedObjectiveRoutes", "sourceOrderedObjectiveRoutes",
|
||||||
@@ -697,13 +747,8 @@ summary.uniqueSourceSpellIds = sourceSpellIds.size;
|
|||||||
summary.portableSpellCatalogRecords = spellCatalog.size;
|
summary.portableSpellCatalogRecords = spellCatalog.size;
|
||||||
summary.portableDamageFamilyRecords = spellCatalogSnapshot?.damageFamilyCount ?? 0;
|
summary.portableDamageFamilyRecords = spellCatalogSnapshot?.damageFamilyCount ?? 0;
|
||||||
summary.portableEvidenceOnlyRecords = spellCatalogSnapshot?.evidenceOnlyCount ?? 0;
|
summary.portableEvidenceOnlyRecords = spellCatalogSnapshot?.evidenceOnlyCount ?? 0;
|
||||||
if (summary.sourceBackedDamageFamilyAttacks < summary.runtimeAttacks) {
|
reviewNotes.push(summary.sourceGapBossMechanics
|
||||||
reviewNotes.push((summary.runtimeAttacks - summary.sourceBackedDamageFamilyAttacks)
|
+ " boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.");
|
||||||
+ " executable attacks do not carry an audited source-spell mapping.");
|
|
||||||
}
|
|
||||||
if (dungeons.some((dungeon) => dungeon.bossObjectives && !dungeon.sourceBackedDamageFamilyAttacks)) {
|
|
||||||
reviewNotes.push("Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.");
|
|
||||||
}
|
|
||||||
reviewNotes.push("Source-backed spell coefficients are HealerMan-generic; the original RuneWaker "
|
reviewNotes.push("Source-backed spell coefficients are HealerMan-generic; the original RuneWaker "
|
||||||
+ "formula/stat scaling has not been reconstructed.");
|
+ "formula/stat scaling has not been reconstructed.");
|
||||||
const navigationBlockerEntries = dungeons.flatMap((dungeon) => (
|
const navigationBlockerEntries = dungeons.flatMap((dungeon) => (
|
||||||
@@ -751,11 +796,11 @@ const report = {
|
|||||||
"source positions/directions", "model paths", "source hashes", "boss spawn membership",
|
"source positions/directions", "model paths", "source hashes", "boss spawn membership",
|
||||||
],
|
],
|
||||||
genericRuntimeSystems: [
|
genericRuntimeSystems: [
|
||||||
"unmapped attacks", "health/damage scaling", "movement/leash", "clustering/aggro",
|
"source-gap attacks", "health/damage scaling", "movement/leash", "clustering/aggro",
|
||||||
"combat/death state", "loot policy", "objective ordering", "procedural animation",
|
"combat/death state", "loot policy", "objective ordering", "procedural animation",
|
||||||
],
|
],
|
||||||
deferredMechanics: [
|
deferredMechanics: [
|
||||||
"doors/gates", "summons/waves", "phases", "patrols", "interactions",
|
"complex doors/gates", "unmapped multi-actor waves", "unmapped phases", "patrols", "interactions",
|
||||||
"encounter resets", "source loot",
|
"encounter resets", "source loot",
|
||||||
],
|
],
|
||||||
blockerGroups: {
|
blockerGroups: {
|
||||||
@@ -769,7 +814,10 @@ const report = {
|
|||||||
},
|
},
|
||||||
sourceMechanics: {
|
sourceMechanics: {
|
||||||
status: "review-required",
|
status: "review-required",
|
||||||
executableMechanicsGenerated: false,
|
executableBossMechanicsGenerated: true,
|
||||||
|
catalogBossMechanics: summary.catalogBossMechanics,
|
||||||
|
sourceBackedBossMechanics: summary.sourceBackedBossMechanics,
|
||||||
|
sourceGapBossMechanics: summary.sourceGapBossMechanics,
|
||||||
scriptedTemplates: summary.scriptedTemplates,
|
scriptedTemplates: summary.scriptedTemplates,
|
||||||
evidenceCounts: Object.fromEntries(Object.entries(mechanicsCategories).sort()),
|
evidenceCounts: Object.fromEntries(Object.entries(mechanicsCategories).sort()),
|
||||||
},
|
},
|
||||||
@@ -779,7 +827,7 @@ const report = {
|
|||||||
status: spellCatalogSnapshot?.status ?? "missing",
|
status: spellCatalogSnapshot?.status ?? "missing",
|
||||||
safety: spellCatalogSnapshot?.safety ?? null,
|
safety: spellCatalogSnapshot?.safety ?? null,
|
||||||
mappingPolicy: "Only positively classified direct-HP damage families execute; "
|
mappingPolicy: "Only positively classified direct-HP damage families execute; "
|
||||||
+ "unknown/buff/heal/summon/script effects remain evidence-only.",
|
+ "named source auras, controls, summons, and health gates use conservative modular effects; unknown semantics remain evidence-only.",
|
||||||
},
|
},
|
||||||
resolvedRuntimeObservations,
|
resolvedRuntimeObservations,
|
||||||
documentedExceptions,
|
documentedExceptions,
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const khronosValidator = path.join(
|
|||||||
"manastorm-assets",
|
"manastorm-assets",
|
||||||
"validate-package-glbs.cjs",
|
"validate-package-glbs.cjs",
|
||||||
);
|
);
|
||||||
|
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
|
||||||
|
|
||||||
function run(executable, argumentsList, label) {
|
function run(executable, argumentsList, label) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -286,21 +287,8 @@ function distanceSquared(a, b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function attackFor(template) {
|
function attackFor(template) {
|
||||||
if (template.id === 100074 || template.id === 100070) {
|
|
||||||
return {
|
|
||||||
id: "rw-fa-shadow-bolt",
|
|
||||||
name: "Shadow Bolt",
|
|
||||||
delivery: "projectile",
|
|
||||||
target: "primary",
|
|
||||||
school: "shadow",
|
|
||||||
animation: "cast",
|
|
||||||
range: 18,
|
|
||||||
cooldownMs: 4200,
|
|
||||||
damageMultiplier: 1.08,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
id: "rw-fa-basic-attack",
|
id: "basic-attack",
|
||||||
name: template.archetype === "ooze" ? "Corrosive Slam" : "Basic Attack",
|
name: template.archetype === "ooze" ? "Corrosive Slam" : "Basic Attack",
|
||||||
delivery: "melee",
|
delivery: "melee",
|
||||||
target: "primary",
|
target: "primary",
|
||||||
@@ -312,8 +300,9 @@ function attackFor(template) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function entityFor(template, actor) {
|
function entityFor(template, actor, bossMechanics) {
|
||||||
const boss = template.classification === "boss";
|
const boss = template.classification === "boss";
|
||||||
|
const mechanics = bossMechanics?.bosses?.["rw-fa-" + template.id]?.mechanics ?? [];
|
||||||
return {
|
return {
|
||||||
id: "rw-fa-" + template.id,
|
id: "rw-fa-" + template.id,
|
||||||
name: template.name,
|
name: template.name,
|
||||||
@@ -326,7 +315,8 @@ function entityFor(template, actor) {
|
|||||||
damageMultiplier: boss ? 1.3 : 1,
|
damageMultiplier: boss ? 1.3 : 1,
|
||||||
moveSpeed: boss ? 2.25 : 2.15,
|
moveSpeed: boss ? 2.25 : 2.15,
|
||||||
leashRange: boss ? 32 : 24,
|
leashRange: boss ? 32 : 24,
|
||||||
attacks: [attackFor(template)],
|
...(boss ? { mechanicIds: mechanics.map((attack) => attack.id) } : {}),
|
||||||
|
attacks: [attackFor(template), ...mechanics],
|
||||||
},
|
},
|
||||||
visual: {
|
visual: {
|
||||||
primaryColor: template.primaryColor,
|
primaryColor: template.primaryColor,
|
||||||
@@ -384,11 +374,16 @@ async function generatePopulation() {
|
|||||||
manifestFile,
|
manifestFile,
|
||||||
"Run node scripts/runewaker-pipeline/build-forsaken-population.mjs --actors-only to package the creature models.",
|
"Run node scripts/runewaker-pipeline/build-forsaken-population.mjs --actors-only to package the creature models.",
|
||||||
),
|
),
|
||||||
|
requireFile(
|
||||||
|
bossMechanicsFile,
|
||||||
|
"Run npm run runewaker:bosses:refresh to create the boss mechanic catalog.",
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
const [snapshot, manifest, environment] = await Promise.all([
|
const [snapshot, manifest, environment, bossMechanics] = await Promise.all([
|
||||||
readFile(snapshotFile, "utf8").then(JSON.parse),
|
readFile(snapshotFile, "utf8").then(JSON.parse),
|
||||||
readFile(manifestFile, "utf8").then(JSON.parse),
|
readFile(manifestFile, "utf8").then(JSON.parse),
|
||||||
readFile(resolveProject(recipe.files.environmentMetadata), "utf8").then(JSON.parse),
|
readFile(resolveProject(recipe.files.environmentMetadata), "utf8").then(JSON.parse),
|
||||||
|
readFile(bossMechanicsFile, "utf8").then(JSON.parse),
|
||||||
]);
|
]);
|
||||||
const templates = new Map(recipe.templates.map((template) => [template.id, template]));
|
const templates = new Map(recipe.templates.map((template) => [template.id, template]));
|
||||||
const actors = new Map(manifest.assets.map((actor) => [actor.id, actor]));
|
const actors = new Map(manifest.assets.map((actor) => [actor.id, actor]));
|
||||||
@@ -438,7 +433,7 @@ async function generatePopulation() {
|
|||||||
|
|
||||||
const entityId = "rw-fa-" + row.templateId;
|
const entityId = "rw-fa-" + row.templateId;
|
||||||
if (!entities[entityId]) {
|
if (!entities[entityId]) {
|
||||||
entities[entityId] = entityFor(template, actors.get(template.actor));
|
entities[entityId] = entityFor(template, actors.get(template.actor), bossMechanics);
|
||||||
}
|
}
|
||||||
const spawn = {
|
const spawn = {
|
||||||
id: "rw-fa-spawn-" + row.spawnId,
|
id: "rw-fa-spawn-" + row.spawnId,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const spellCatalogFile = path.join(
|
|||||||
"snapshots",
|
"snapshots",
|
||||||
"runewaker-npc-spell-catalog.json",
|
"runewaker-npc-spell-catalog.json",
|
||||||
);
|
);
|
||||||
|
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
|
||||||
const paperdollCatalogFile = path.join(
|
const paperdollCatalogFile = path.join(
|
||||||
pipelineDirectory,
|
pipelineDirectory,
|
||||||
"snapshots",
|
"snapshots",
|
||||||
@@ -430,11 +431,10 @@ function yawFromDirection(direction) {
|
|||||||
return Number(((((direction % turn) + turn) % turn) * Math.PI * 2 / turn).toFixed(9));
|
return Number(((((direction % turn) + turn) % turn) * Math.PI * 2 / turn).toFixed(9));
|
||||||
}
|
}
|
||||||
|
|
||||||
function attacksFor(template, sourceSpells, spellCatalog) {
|
function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
|
||||||
if (Array.isArray(template.attacks) && template.attacks.length) return template.attacks;
|
|
||||||
const ooze = template.archetype === "ooze";
|
const ooze = template.archetype === "ooze";
|
||||||
const basicAttack = {
|
const basicAttack = {
|
||||||
id: runtimePrefix + "-basic-attack",
|
id: "basic-attack",
|
||||||
name: ooze ? "Corrosive Slam" : "Basic Attack",
|
name: ooze ? "Corrosive Slam" : "Basic Attack",
|
||||||
delivery: "melee",
|
delivery: "melee",
|
||||||
target: "primary",
|
target: "primary",
|
||||||
@@ -462,13 +462,24 @@ function attacksFor(template, sourceSpells, spellCatalog) {
|
|||||||
damageMultiplier: spell.runtime.damageMultiplier,
|
damageMultiplier: spell.runtime.damageMultiplier,
|
||||||
}];
|
}];
|
||||||
});
|
});
|
||||||
return [basicAttack, ...sourceAttacks];
|
const recipeAttacks = Array.isArray(template.attacks)
|
||||||
|
? template.attacks.filter((attack) => attack.id !== "basic-attack")
|
||||||
|
: [];
|
||||||
|
const authored = template.classification === "boss"
|
||||||
|
? bossMechanics?.bosses?.[runtimePrefix + "-" + template.id]
|
||||||
|
: null;
|
||||||
|
const mechanics = authored?.mode === "replace"
|
||||||
|
? authored.mechanics ?? []
|
||||||
|
: [...(authored?.mechanics ?? []), ...recipeAttacks, ...sourceAttacks];
|
||||||
|
const unique = [...new Map(mechanics.map((attack) => [attack.id, attack])).values()];
|
||||||
|
return [basicAttack, ...unique];
|
||||||
}
|
}
|
||||||
|
|
||||||
function entityFor(template, actor, sourceSpells, spellCatalog) {
|
function entityFor(template, actor, sourceSpells, spellCatalog, bossMechanics) {
|
||||||
const boss = template.classification === "boss";
|
const boss = template.classification === "boss";
|
||||||
const defaults = recipe.combatDefaults ?? {};
|
const defaults = recipe.combatDefaults ?? {};
|
||||||
const combat = template.combat ?? {};
|
const combat = template.combat ?? {};
|
||||||
|
const attacks = attacksFor(template, sourceSpells, spellCatalog, bossMechanics);
|
||||||
return {
|
return {
|
||||||
id: runtimePrefix + "-" + template.id,
|
id: runtimePrefix + "-" + template.id,
|
||||||
name: template.name,
|
name: template.name,
|
||||||
@@ -481,7 +492,8 @@ function entityFor(template, actor, sourceSpells, spellCatalog) {
|
|||||||
damageMultiplier: combat.damageMultiplier ?? (boss ? defaults.bossDamageMultiplier ?? 1.3 : defaults.damageMultiplier ?? 1),
|
damageMultiplier: combat.damageMultiplier ?? (boss ? defaults.bossDamageMultiplier ?? 1.3 : defaults.damageMultiplier ?? 1),
|
||||||
moveSpeed: combat.moveSpeed ?? (boss ? defaults.bossMoveSpeed ?? 2.25 : defaults.moveSpeed ?? 2.15),
|
moveSpeed: combat.moveSpeed ?? (boss ? defaults.bossMoveSpeed ?? 2.25 : defaults.moveSpeed ?? 2.15),
|
||||||
leashRange: combat.leashRange ?? (boss ? defaults.bossLeashRange ?? 32 : defaults.leashRange ?? 24),
|
leashRange: combat.leashRange ?? (boss ? defaults.bossLeashRange ?? 32 : defaults.leashRange ?? 24),
|
||||||
attacks: attacksFor(template, sourceSpells, spellCatalog),
|
...(boss ? { mechanicIds: attacks.slice(1).map((attack) => attack.id) } : {}),
|
||||||
|
attacks,
|
||||||
},
|
},
|
||||||
visual: {
|
visual: {
|
||||||
primaryColor: template.primaryColor,
|
primaryColor: template.primaryColor,
|
||||||
@@ -537,12 +549,14 @@ async function generatePopulation() {
|
|||||||
requireFile(manifestFile, "Run build-population.mjs --actors-only first."),
|
requireFile(manifestFile, "Run build-population.mjs --actors-only first."),
|
||||||
requireFile(resolveProject(recipe.files.environmentMetadata), "Build the environment first."),
|
requireFile(resolveProject(recipe.files.environmentMetadata), "Build the environment first."),
|
||||||
requireFile(spellCatalogFile, "Run npm run runewaker:spells:refresh once to create the portable spell catalog."),
|
requireFile(spellCatalogFile, "Run npm run runewaker:spells:refresh once to create the portable spell catalog."),
|
||||||
|
requireFile(bossMechanicsFile, "Run npm run runewaker:bosses:refresh to create the boss mechanic catalog."),
|
||||||
]);
|
]);
|
||||||
const [snapshot, manifest, environment, spellCatalogSnapshot] = await Promise.all([
|
const [snapshot, manifest, environment, spellCatalogSnapshot, bossMechanics] = await Promise.all([
|
||||||
readFile(snapshotFile, "utf8").then(JSON.parse),
|
readFile(snapshotFile, "utf8").then(JSON.parse),
|
||||||
readFile(manifestFile, "utf8").then(JSON.parse),
|
readFile(manifestFile, "utf8").then(JSON.parse),
|
||||||
readFile(resolveProject(recipe.files.environmentMetadata), "utf8").then(JSON.parse),
|
readFile(resolveProject(recipe.files.environmentMetadata), "utf8").then(JSON.parse),
|
||||||
readFile(spellCatalogFile, "utf8").then(JSON.parse),
|
readFile(spellCatalogFile, "utf8").then(JSON.parse),
|
||||||
|
readFile(bossMechanicsFile, "utf8").then(JSON.parse),
|
||||||
]);
|
]);
|
||||||
if (spellCatalogSnapshot.status !== "portable"
|
if (spellCatalogSnapshot.status !== "portable"
|
||||||
|| spellCatalogSnapshot.safety?.runtimeSqlDependency !== false
|
|| spellCatalogSnapshot.safety?.runtimeSqlDependency !== false
|
||||||
@@ -619,6 +633,7 @@ async function generatePopulation() {
|
|||||||
actorForTemplate(template),
|
actorForTemplate(template),
|
||||||
[...(sourceSpellsByTemplate.get(template.id)?.values() ?? [])],
|
[...(sourceSpellsByTemplate.get(template.id)?.values() ?? [])],
|
||||||
spellCatalog,
|
spellCatalog,
|
||||||
|
bossMechanics,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const spawn = {
|
const spawn = {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ Usage:
|
|||||||
node scripts/runewaker-pipeline/cli.mjs actors --recipe <population.json>
|
node scripts/runewaker-pipeline/cli.mjs actors --recipe <population.json>
|
||||||
node scripts/runewaker-pipeline/cli.mjs population --recipe <population.json>
|
node scripts/runewaker-pipeline/cli.mjs population --recipe <population.json>
|
||||||
node scripts/runewaker-pipeline/cli.mjs mechanics --recipe <population.json>
|
node scripts/runewaker-pipeline/cli.mjs mechanics --recipe <population.json>
|
||||||
|
node scripts/runewaker-pipeline/cli.mjs boss-mechanics
|
||||||
node scripts/runewaker-pipeline/cli.mjs offline --recipe <population.json>
|
node scripts/runewaker-pipeline/cli.mjs offline --recipe <population.json>
|
||||||
node scripts/runewaker-pipeline/cli.mjs build --recipe <population.json>
|
node scripts/runewaker-pipeline/cli.mjs build --recipe <population.json>
|
||||||
node scripts/runewaker-pipeline/cli.mjs validate --recipe <population.json>
|
node scripts/runewaker-pipeline/cli.mjs validate --recipe <population.json>
|
||||||
@@ -73,6 +74,8 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|||||||
await run("build-population.mjs", argv);
|
await run("build-population.mjs", argv);
|
||||||
} else if (command === "mechanics") {
|
} else if (command === "mechanics") {
|
||||||
await run("extract-mechanics.mjs", argv);
|
await run("extract-mechanics.mjs", argv);
|
||||||
|
} else if (command === "boss-mechanics") {
|
||||||
|
await run("import-boss-mechanics.mjs", argv);
|
||||||
} else if (command === "validate") {
|
} else if (command === "validate") {
|
||||||
await run("validate-instance.mjs", argv);
|
await run("validate-instance.mjs", argv);
|
||||||
} else if (command === "prepare-all") {
|
} else if (command === "prepare-all") {
|
||||||
|
|||||||
@@ -165,6 +165,28 @@ for (const recipe of recipes) {
|
|||||||
assignmentsBySpell.set(Number(sourceSpell.id), assignments);
|
assignmentsBySpell.set(Number(sourceSpell.id), assignments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const luaRoot = path.join(sourceRoot, recipes[0].source.luaRoot ?? "Resource/luascript");
|
||||||
|
const luaNames = (await readdir(luaRoot)).filter((name) => name.endsWith(".lua")).sort();
|
||||||
|
for (const fileName of luaNames) {
|
||||||
|
const source = await readFile(path.join(luaRoot, fileName), "utf8");
|
||||||
|
const executableSource = source
|
||||||
|
.replace(/--\[\[[\s\S]*?\]\]/g, "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.replace(/--.*$/, ""))
|
||||||
|
.join("\n");
|
||||||
|
for (const match of executableSource.matchAll(/\b49\d{4}\b/g)) {
|
||||||
|
const spellId = Number(match[0]);
|
||||||
|
const assignments = assignmentsBySpell.get(spellId) ?? new Map();
|
||||||
|
const key = "lua:" + fileName + ":" + spellId;
|
||||||
|
assignments.set(key, {
|
||||||
|
sourceKind: "lua-reference",
|
||||||
|
sourceFile: fileName,
|
||||||
|
});
|
||||||
|
assignmentsBySpell.set(spellId, assignments);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const spells = [];
|
const spells = [];
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ for (const dungeon of dungeons) {
|
|||||||
String(actorManifest.proceduralFallbacks.length)
|
String(actorManifest.proceduralFallbacks.length)
|
||||||
+ " runtime/dynamic actor containers use HealerMan procedural visuals because their ROS files expose no static triangles.",
|
+ " runtime/dynamic actor containers use HealerMan procedural visuals because their ROS files expose no static triangles.",
|
||||||
] : []),
|
] : []),
|
||||||
"Mechanics candidates are source-audited; complex doors, summons, phases, patrol scripts, and loot remain review-required.",
|
"Boss combat kits are compiled from RuneWaker spell/script evidence; complex doors, patrol scripts, and loot remain review-required.",
|
||||||
];
|
];
|
||||||
const definition = {
|
const definition = {
|
||||||
schemaVersion: "__SCHEMA__",
|
schemaVersion: "__SCHEMA__",
|
||||||
|
|||||||
@@ -0,0 +1,509 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { readdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
pipelineDirectory,
|
||||||
|
projectPath,
|
||||||
|
projectRoot,
|
||||||
|
readJson,
|
||||||
|
sourceRootFor,
|
||||||
|
} from "./lib/recipe.mjs";
|
||||||
|
|
||||||
|
const recipesDirectory = path.join(pipelineDirectory, "recipes");
|
||||||
|
const mechanicCatalogFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
|
||||||
|
const spellCatalogFile = path.join(
|
||||||
|
pipelineDirectory,
|
||||||
|
"snapshots",
|
||||||
|
"runewaker-npc-spell-catalog.json",
|
||||||
|
);
|
||||||
|
const recipeNames = (await readdir(recipesDirectory))
|
||||||
|
.filter((name) => name.endsWith("-population.json"))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
function slugify(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-zA-Z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanLua(source) {
|
||||||
|
return source
|
||||||
|
.replace(/--\[\[[\s\S]*?\]\]/g, "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.replace(/--.*$/, ""))
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function functionIndex(luaSources) {
|
||||||
|
const functions = new Map();
|
||||||
|
for (const source of luaSources) {
|
||||||
|
const cleaned = cleanLua(source.text);
|
||||||
|
const matches = [...cleaned.matchAll(/^\s*function\s+([A-Za-z_][A-Za-z0-9_:.]*)\s*\(/gm)];
|
||||||
|
for (let index = 0; index < matches.length; index += 1) {
|
||||||
|
const match = matches[index];
|
||||||
|
const next = matches[index + 1];
|
||||||
|
functions.set(match[1], {
|
||||||
|
file: source.file,
|
||||||
|
body: cleaned.slice(match.index, next?.index ?? cleaned.length),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return functions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rootName(value) {
|
||||||
|
return String(value ?? "").trim().match(/^([A-Za-z_][A-Za-z0-9_:.]*)/)?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reachableFunctions(index, roots) {
|
||||||
|
const queue = roots.filter(Boolean);
|
||||||
|
const visited = new Set();
|
||||||
|
const result = [];
|
||||||
|
while (queue.length) {
|
||||||
|
const name = queue.shift();
|
||||||
|
if (!name || visited.has(name)) continue;
|
||||||
|
visited.add(name);
|
||||||
|
const definition = index.get(name);
|
||||||
|
if (!definition) continue;
|
||||||
|
result.push({ name, ...definition });
|
||||||
|
for (const match of definition.body.matchAll(/\b([A-Za-z_][A-Za-z0-9_:.]*)\s*\(/g)) {
|
||||||
|
if (index.has(match[1]) && !visited.has(match[1])) queue.push(match[1]);
|
||||||
|
}
|
||||||
|
for (const callbackCall of definition.body.matchAll(/\b(?:BeginPlot|SetPlot)\s*\([^\n]*/g)) {
|
||||||
|
for (const match of callbackCall[0].matchAll(/["']([A-Za-z_][A-Za-z0-9_:.]*)["']/g)) {
|
||||||
|
if (index.has(match[1]) && !visited.has(match[1])) queue.push(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function telegraphFor(spell, delivery, target, range) {
|
||||||
|
const name = spell.name ?? "";
|
||||||
|
const effectRadius = Math.max(3, Math.min(15, Number(spell.source?.effectRange ?? 0) * 0.1));
|
||||||
|
if (/\b(?:breath|cleave|frontal|sweep|fan)\b/i.test(name)) {
|
||||||
|
return {
|
||||||
|
delivery: "area",
|
||||||
|
target: "nearby-party",
|
||||||
|
radius: undefined,
|
||||||
|
telegraph: {
|
||||||
|
shape: "cone",
|
||||||
|
anchor: "source",
|
||||||
|
warningMs: 1800,
|
||||||
|
length: Math.max(6, range),
|
||||||
|
angleDegrees: /breath/i.test(name) ? 70 : 95,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (/\b(?:beam|charge|line|lance)\b/i.test(name)) {
|
||||||
|
return {
|
||||||
|
delivery: "area",
|
||||||
|
target: "nearby-party",
|
||||||
|
radius: undefined,
|
||||||
|
telegraph: {
|
||||||
|
shape: "line",
|
||||||
|
anchor: "source",
|
||||||
|
warningMs: 1800,
|
||||||
|
length: Math.max(7, range),
|
||||||
|
width: Math.max(2, effectRadius * 0.45),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const area = Number(spell.source?.rangeType ?? 0) !== 0
|
||||||
|
|| Number(spell.source?.effectRange ?? 0) >= 30
|
||||||
|
|| /\b(?:area|group|storm|rain|nova|vortex|wave|burst|explosion|whirl|spin|quake|shock)\b/i.test(name);
|
||||||
|
if (!area) return { delivery, target, radius: undefined, telegraph: undefined };
|
||||||
|
const targetAnchored = /\b(?:rain|meteor|eruption|ground|barrage)\b/i.test(name)
|
||||||
|
&& Number(spell.source?.targetType ?? 0) !== 0;
|
||||||
|
return {
|
||||||
|
delivery: "area",
|
||||||
|
target: "nearby-party",
|
||||||
|
radius: effectRadius,
|
||||||
|
telegraph: {
|
||||||
|
shape: "circle",
|
||||||
|
anchor: targetAnchored ? "target" : "source",
|
||||||
|
warningMs: 1800,
|
||||||
|
radius: effectRadius,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function damageMechanic(entityId, spell) {
|
||||||
|
const runtime = spell.runtime;
|
||||||
|
const spatial = telegraphFor(spell, runtime.delivery, runtime.target, runtime.range);
|
||||||
|
const castTimeMs = Math.max(
|
||||||
|
spatial.telegraph?.warningMs ?? 0,
|
||||||
|
Math.round(Math.max(0, Number(spell.source?.spellTimeSeconds ?? 0)) * 1000),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: entityId + "-spell-" + spell.spellId,
|
||||||
|
name: spell.name,
|
||||||
|
spellId: spell.spellId,
|
||||||
|
delivery: spatial.delivery,
|
||||||
|
target: spatial.target,
|
||||||
|
school: runtime.school,
|
||||||
|
animation: runtime.animation,
|
||||||
|
castTimeMs,
|
||||||
|
range: Math.max(runtime.range, spatial.radius ?? 0),
|
||||||
|
cooldownMs: runtime.cooldownMs,
|
||||||
|
initialCooldownMs: [runtime.cooldownMs, runtime.cooldownMs],
|
||||||
|
repeatCooldownMs: [runtime.cooldownMs, runtime.cooldownMs],
|
||||||
|
damageMultiplier: runtime.damageMultiplier,
|
||||||
|
...(spatial.radius === undefined ? {} : { radius: spatial.radius }),
|
||||||
|
...(spatial.telegraph ? { telegraph: spatial.telegraph } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function controlMechanic(entityId, spell) {
|
||||||
|
const identity = [spell.name, ...(spell.components ?? []).map((component) => component.name)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
const mechanic = /fear|terr(?:or|if)/i.test(identity)
|
||||||
|
? "fear"
|
||||||
|
: /sleep/i.test(identity)
|
||||||
|
? "sleep"
|
||||||
|
: /root|catch|prison|immobili/i.test(identity)
|
||||||
|
? "root"
|
||||||
|
: /silence/i.test(identity)
|
||||||
|
? "silence"
|
||||||
|
: /stun|paraly/i.test(identity)
|
||||||
|
? "stun"
|
||||||
|
: null;
|
||||||
|
if (!mechanic) return null;
|
||||||
|
const cooldownMs = Math.max(4_000, Math.min(
|
||||||
|
25_000,
|
||||||
|
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 12_000,
|
||||||
|
));
|
||||||
|
const range = Math.max(4.5, Math.min(
|
||||||
|
30,
|
||||||
|
Number(spell.source?.attackDistance ?? 0) * 0.1 || 18,
|
||||||
|
));
|
||||||
|
const spatial = telegraphFor(spell, "projectile", "primary", range);
|
||||||
|
return {
|
||||||
|
id: entityId + "-control-" + spell.spellId,
|
||||||
|
name: spell.name,
|
||||||
|
delivery: spatial.delivery,
|
||||||
|
target: spatial.target,
|
||||||
|
school: "shadow",
|
||||||
|
animation: "cast",
|
||||||
|
castTimeMs: Math.max(1800, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
|
||||||
|
range,
|
||||||
|
cooldownMs,
|
||||||
|
initialCooldownMs: [cooldownMs, cooldownMs],
|
||||||
|
repeatCooldownMs: [cooldownMs, cooldownMs],
|
||||||
|
damageMultiplier: 0,
|
||||||
|
...(spatial.radius === undefined ? {} : { radius: spatial.radius }),
|
||||||
|
...(spatial.telegraph ? { telegraph: spatial.telegraph } : {}),
|
||||||
|
effect: { kind: "control", mechanic, durationMs: 3_000 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function auraMechanic(entityId, spell) {
|
||||||
|
if (Number(spell.source?.targetType ?? -1) !== 0) return null;
|
||||||
|
const namedComponent = (spell.components ?? []).find((component) => (
|
||||||
|
component.name && Number(component.magicFunc) === 1
|
||||||
|
));
|
||||||
|
if (!namedComponent && /^RuneWaker Spell /i.test(spell.name ?? "")) return null;
|
||||||
|
const cooldownMs = Math.max(6_000, Math.min(
|
||||||
|
45_000,
|
||||||
|
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 15_000,
|
||||||
|
));
|
||||||
|
return {
|
||||||
|
id: entityId + "-aura-" + spell.spellId,
|
||||||
|
name: spell.name,
|
||||||
|
delivery: "area",
|
||||||
|
target: "self",
|
||||||
|
school: "arcane",
|
||||||
|
animation: "cast",
|
||||||
|
castTimeMs: Math.max(1_200, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
|
||||||
|
range: 0,
|
||||||
|
cooldownMs,
|
||||||
|
initialCooldownMs: [cooldownMs, cooldownMs],
|
||||||
|
repeatCooldownMs: [cooldownMs, cooldownMs],
|
||||||
|
damageMultiplier: 0,
|
||||||
|
effect: {
|
||||||
|
kind: "aura",
|
||||||
|
aura: slugify(namedComponent?.name ?? spell.name),
|
||||||
|
durationMs: Math.min(12_000, Math.max(4_000, Math.round(cooldownMs * 0.5))),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetAuraMechanic(entityId, spell) {
|
||||||
|
if (Number(spell.source?.targetType ?? 0) === 0) return null;
|
||||||
|
const namedComponent = (spell.components ?? []).find((component) => (
|
||||||
|
component.name && Number(component.magicFunc) === 1
|
||||||
|
));
|
||||||
|
if (!namedComponent) return null;
|
||||||
|
const cooldownMs = Math.max(6_000, Math.min(
|
||||||
|
30_000,
|
||||||
|
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 12_000,
|
||||||
|
));
|
||||||
|
const range = Math.max(4.5, Math.min(
|
||||||
|
30,
|
||||||
|
Number(spell.source?.attackDistance ?? 0) * 0.1 || 18,
|
||||||
|
));
|
||||||
|
const spatial = telegraphFor(spell, "projectile", "primary", range);
|
||||||
|
return {
|
||||||
|
id: entityId + "-debuff-" + spell.spellId,
|
||||||
|
name: spell.name,
|
||||||
|
delivery: spatial.delivery,
|
||||||
|
target: spatial.target,
|
||||||
|
school: "arcane",
|
||||||
|
animation: "cast",
|
||||||
|
castTimeMs: Math.max(1_800, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
|
||||||
|
range,
|
||||||
|
cooldownMs,
|
||||||
|
initialCooldownMs: [cooldownMs, cooldownMs],
|
||||||
|
repeatCooldownMs: [cooldownMs, cooldownMs],
|
||||||
|
damageMultiplier: 0,
|
||||||
|
...(spatial.radius === undefined ? {} : { radius: spatial.radius }),
|
||||||
|
...(spatial.telegraph ? { telegraph: spatial.telegraph } : {}),
|
||||||
|
effect: {
|
||||||
|
kind: "target-aura",
|
||||||
|
aura: slugify(namedComponent.name),
|
||||||
|
durationMs: Math.min(10_000, Math.max(4_000, Math.round(cooldownMs * 0.5))),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scriptPhaseMechanics(entityId, bodies) {
|
||||||
|
const thresholds = [...new Set(bodies.flatMap((body) => (
|
||||||
|
[...body.matchAll(/\bHPPercent\s*<=\s*(\d+(?:\.\d+)?)\b/g)].map((match) => {
|
||||||
|
const raw = Number(match[1]);
|
||||||
|
return Math.round(raw <= 1 ? raw * 100 : raw);
|
||||||
|
})
|
||||||
|
)).filter((value) => value > 0 && value < 100))].sort((left, right) => right - left);
|
||||||
|
return thresholds.map((threshold) => ({
|
||||||
|
id: entityId + "-phase-" + threshold,
|
||||||
|
name: "Phase Change",
|
||||||
|
delivery: "area",
|
||||||
|
target: "self",
|
||||||
|
school: "arcane",
|
||||||
|
animation: "cast",
|
||||||
|
castTimeMs: 1_200,
|
||||||
|
interruptible: false,
|
||||||
|
range: 0,
|
||||||
|
cooldownMs: 1_000,
|
||||||
|
initialCooldownMs: [0, 0],
|
||||||
|
repeatCooldownMs: [1_000, 1_000],
|
||||||
|
maximumCasts: 1,
|
||||||
|
damageMultiplier: 0,
|
||||||
|
effect: { kind: "aura", aura: "phase-" + threshold, durationMs: 5_000 },
|
||||||
|
condition: { kind: "self-health-percent", minimum: 0, maximum: threshold },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function summonMechanic(entityId, spell) {
|
||||||
|
const summon = (spell.components ?? []).find((component) => Number(component.summonCreatureId) > 0);
|
||||||
|
if (!summon) return null;
|
||||||
|
const cooldownMs = Math.max(8_000, Math.min(
|
||||||
|
40_000,
|
||||||
|
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 20_000,
|
||||||
|
));
|
||||||
|
return {
|
||||||
|
id: entityId + "-summon-" + spell.spellId,
|
||||||
|
name: spell.name,
|
||||||
|
delivery: "area",
|
||||||
|
target: "self",
|
||||||
|
school: "arcane",
|
||||||
|
animation: "cast",
|
||||||
|
castTimeMs: Math.max(1_800, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
|
||||||
|
interruptible: false,
|
||||||
|
range: 0,
|
||||||
|
cooldownMs,
|
||||||
|
initialCooldownMs: [cooldownMs, cooldownMs],
|
||||||
|
repeatCooldownMs: [cooldownMs, cooldownMs],
|
||||||
|
damageMultiplier: 0,
|
||||||
|
effect: { kind: "summon", creatureEntry: summon.summonCreatureId, count: 1 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scriptSummons(entityId, bodies) {
|
||||||
|
const joined = bodies.join("\n");
|
||||||
|
if (!/\b(?:CallMonster|CreateNPC|CreateMonster)/i.test(joined)) return [];
|
||||||
|
const constants = new Map([...joined.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(1\d{5})\b/g)]
|
||||||
|
.map((match) => [match[1], Number(match[2])]));
|
||||||
|
const creatureEntry = [...constants.entries()]
|
||||||
|
.find(([name]) => /monster|mob|summon/i.test(name))?.[1];
|
||||||
|
if (!creatureEntry) return [];
|
||||||
|
const count = Math.max(1, Math.min(6, Number(
|
||||||
|
[...joined.matchAll(/\b(?:MonsterNumber|MobNumber|SummonNumber)\w*\s*=\s*(\d+)\b/gi)][0]?.[1] ?? 1,
|
||||||
|
)));
|
||||||
|
const thresholds = [...new Set([...joined.matchAll(/\bHPPercent\s*<=\s*(0?\.\d+|1(?:\.0+)?)\b/g)]
|
||||||
|
.map((match) => Math.round(Number(match[1]) * 100))
|
||||||
|
.filter((value) => value > 0 && value < 100))].sort((left, right) => right - left);
|
||||||
|
const events = thresholds.length ? thresholds : [null];
|
||||||
|
return events.map((threshold, index) => ({
|
||||||
|
id: entityId + "-summon-" + creatureEntry + (threshold ? "-at-" + threshold : ""),
|
||||||
|
name: threshold ? "Reinforcements at " + threshold + "%" : "Call Reinforcements",
|
||||||
|
delivery: "area",
|
||||||
|
target: "self",
|
||||||
|
school: "physical",
|
||||||
|
animation: "cast",
|
||||||
|
castTimeMs: 1_800,
|
||||||
|
interruptible: false,
|
||||||
|
range: 0,
|
||||||
|
cooldownMs: threshold ? 1_000 : 20_000,
|
||||||
|
initialCooldownMs: threshold ? [0, 0] : [8_000, 8_000],
|
||||||
|
repeatCooldownMs: threshold ? [1_000, 1_000] : [20_000, 20_000],
|
||||||
|
...(threshold ? { maximumCasts: 1 } : {}),
|
||||||
|
damageMultiplier: 0,
|
||||||
|
effect: { kind: "summon", creatureEntry, count },
|
||||||
|
...(threshold ? {
|
||||||
|
condition: { kind: "self-health-percent", minimum: 0, maximum: threshold },
|
||||||
|
} : {}),
|
||||||
|
_sourceOrder: index,
|
||||||
|
})).map(({ _sourceOrder, ...mechanic }) => mechanic);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceGapMechanic(entityId, name, archetype) {
|
||||||
|
const caster = /mage|witch|goddess|disciple|tree|seeker|guardian/i.test(name);
|
||||||
|
if (caster) {
|
||||||
|
return {
|
||||||
|
id: entityId + "-source-gap-burst",
|
||||||
|
name: "Arcane Burst",
|
||||||
|
delivery: "area",
|
||||||
|
target: "nearby-party",
|
||||||
|
school: "arcane",
|
||||||
|
animation: "cast",
|
||||||
|
castTimeMs: 2_000,
|
||||||
|
range: 12,
|
||||||
|
cooldownMs: 12_000,
|
||||||
|
initialCooldownMs: [8_000, 8_000],
|
||||||
|
repeatCooldownMs: [12_000, 12_000],
|
||||||
|
damageMultiplier: 0.82,
|
||||||
|
radius: 8,
|
||||||
|
telegraph: { shape: "circle", anchor: "source", warningMs: 2_000, radius: 8 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: entityId + "-source-gap-sweep",
|
||||||
|
name: archetype === "ooze" ? "Corrosive Sweep" : "Heavy Sweep",
|
||||||
|
delivery: "area",
|
||||||
|
target: "nearby-party",
|
||||||
|
school: archetype === "ooze" ? "nature" : "physical",
|
||||||
|
animation: "attack",
|
||||||
|
castTimeMs: 1_800,
|
||||||
|
range: 7,
|
||||||
|
cooldownMs: 10_000,
|
||||||
|
initialCooldownMs: [7_000, 7_000],
|
||||||
|
repeatCooldownMs: [10_000, 10_000],
|
||||||
|
damageMultiplier: 1.1,
|
||||||
|
telegraph: {
|
||||||
|
shape: "cone",
|
||||||
|
anchor: "source",
|
||||||
|
warningMs: 1_800,
|
||||||
|
length: 7,
|
||||||
|
angleDegrees: 100,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const spellCatalog = await readJson(spellCatalogFile);
|
||||||
|
const spellsById = new Map(spellCatalog.spells.map((spell) => [Number(spell.spellId), spell]));
|
||||||
|
const catalog = await readJson(mechanicCatalogFile);
|
||||||
|
const runewakerBosses = {};
|
||||||
|
let sourceBacked = 0;
|
||||||
|
let sourceGap = 0;
|
||||||
|
const firstRecipe = await readJson(path.join(recipesDirectory, recipeNames[0]));
|
||||||
|
const sharedSourceRoot = sourceRootFor(firstRecipe);
|
||||||
|
const sharedLuaRoot = path.join(
|
||||||
|
sharedSourceRoot,
|
||||||
|
firstRecipe.source.luaRoot ?? "Resource/luascript",
|
||||||
|
);
|
||||||
|
const sharedLuaSources = [];
|
||||||
|
for (const file of (await readdir(sharedLuaRoot)).filter((name) => name.endsWith(".lua")).sort()) {
|
||||||
|
sharedLuaSources.push({ file, text: await readFile(path.join(sharedLuaRoot, file), "utf8") });
|
||||||
|
}
|
||||||
|
const sharedFunctions = functionIndex(sharedLuaSources);
|
||||||
|
|
||||||
|
for (const recipeName of recipeNames) {
|
||||||
|
const recipe = await readJson(path.join(recipesDirectory, recipeName));
|
||||||
|
const snapshot = await readJson(path.resolve(projectRoot, recipe.files.snapshot));
|
||||||
|
const rowsByTemplate = new Map();
|
||||||
|
for (const row of snapshot.rows ?? []) {
|
||||||
|
if (row.classification !== "boss") continue;
|
||||||
|
const rows = rowsByTemplate.get(Number(row.templateId)) ?? [];
|
||||||
|
rows.push(row);
|
||||||
|
rowsByTemplate.set(Number(row.templateId), rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const template of recipe.templates.filter((entry) => entry.classification === "boss")) {
|
||||||
|
const rows = rowsByTemplate.get(Number(template.id)) ?? [];
|
||||||
|
if (!rows.length) continue;
|
||||||
|
const roots = [...new Set(rows.flatMap((row) => [
|
||||||
|
rootName(row.source?.autoPlot),
|
||||||
|
rootName(row.source?.plotClassName),
|
||||||
|
...Object.values(row.source?.templateScripts ?? {}).map(rootName),
|
||||||
|
]).filter(Boolean))];
|
||||||
|
const reachable = reachableFunctions(sharedFunctions, roots);
|
||||||
|
const bodies = reachable.map((entry) => entry.body);
|
||||||
|
const scriptSpellIds = [...new Set(bodies.flatMap((body) => (
|
||||||
|
[...body.matchAll(/\b49\d{4}\b/g)].map((match) => Number(match[0]))
|
||||||
|
)))];
|
||||||
|
const templateSpellIds = [...new Set(rows.flatMap((row) => (
|
||||||
|
(row.source?.spells ?? []).map((spell) => Number(spell.id)).filter((id) => id > 0)
|
||||||
|
)))];
|
||||||
|
const spellIds = [...new Set([...templateSpellIds, ...scriptSpellIds])].sort((a, b) => a - b);
|
||||||
|
const entityId = (recipe.runtimeIdPrefix ?? "rw-" + recipe.dungeonId) + "-" + template.id;
|
||||||
|
const mechanics = [];
|
||||||
|
for (const spellId of spellIds) {
|
||||||
|
const spell = spellsById.get(spellId);
|
||||||
|
if (!spell) continue;
|
||||||
|
if (spell.mappingStatus === "damage-family" && spell.runtime) {
|
||||||
|
mechanics.push(damageMechanic(entityId, spell));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const summon = summonMechanic(entityId, spell);
|
||||||
|
if (summon) {
|
||||||
|
mechanics.push(summon);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const control = controlMechanic(entityId, spell);
|
||||||
|
if (control) {
|
||||||
|
mechanics.push(control);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const aura = auraMechanic(entityId, spell);
|
||||||
|
if (aura) {
|
||||||
|
mechanics.push(aura);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const targetAura = targetAuraMechanic(entityId, spell);
|
||||||
|
if (targetAura) mechanics.push(targetAura);
|
||||||
|
}
|
||||||
|
mechanics.push(...scriptSummons(entityId, bodies));
|
||||||
|
mechanics.push(...scriptPhaseMechanics(entityId, bodies));
|
||||||
|
const uniqueMechanics = [...new Map(mechanics.map((mechanic) => [mechanic.id, mechanic])).values()];
|
||||||
|
const fallback = uniqueMechanics.length === 0;
|
||||||
|
if (fallback) uniqueMechanics.push(sourceGapMechanic(entityId, template.name, template.archetype));
|
||||||
|
if (fallback) sourceGap += 1;
|
||||||
|
else sourceBacked += 1;
|
||||||
|
runewakerBosses[entityId] = {
|
||||||
|
name: template.name,
|
||||||
|
mode: "replace",
|
||||||
|
source: {
|
||||||
|
kind: fallback ? "healer-man-source-gap" : "runewaker",
|
||||||
|
dungeonId: recipe.dungeonId,
|
||||||
|
templateId: template.id,
|
||||||
|
roots,
|
||||||
|
functions: reachable.map((entry) => entry.name),
|
||||||
|
spellIds,
|
||||||
|
},
|
||||||
|
mechanics: uniqueMechanics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
catalog.bosses = Object.fromEntries([
|
||||||
|
...Object.entries(catalog.bosses).filter(([key]) => !key.startsWith("rw-")),
|
||||||
|
...Object.entries(runewakerBosses).sort(([left], [right]) => left.localeCompare(right)),
|
||||||
|
]);
|
||||||
|
await writeFile(mechanicCatalogFile, JSON.stringify(catalog, null, 2) + "\n", "utf8");
|
||||||
|
console.log("[runewaker-boss-mechanics] wrote " + Object.keys(runewakerBosses).length + " boss kits.");
|
||||||
|
console.log("[runewaker-boss-mechanics] source-backed: " + sourceBacked + "; source-gap fallback: " + sourceGap + ".");
|
||||||
|
console.log("[runewaker-boss-mechanics] " + projectPath(mechanicCatalogFile));
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -488,6 +488,9 @@ export function openGameDatabase(options = {}) {
|
|||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
classId,
|
classId,
|
||||||
|
specializationId: typeof character.specializationId === "string"
|
||||||
|
? character.specializationId.slice(0, 100)
|
||||||
|
: null,
|
||||||
categoryId: String(character.categoryId ?? "wow").slice(0, 20),
|
categoryId: String(character.categoryId ?? "wow").slice(0, 20),
|
||||||
raceId: String(character.raceId ?? "human").slice(0, 50),
|
raceId: String(character.raceId ?? "human").slice(0, 50),
|
||||||
gender: character.gender === "female" ? "female" : "male",
|
gender: character.gender === "female" ? "female" : "male",
|
||||||
|
|||||||
@@ -169,14 +169,15 @@ test("launches players-only and AI-filled group activities only when every membe
|
|||||||
database.respondToOnlineGroupInvite(member.id, invite.id, true);
|
database.respondToOnlineGroupInvite(member.id, invite.id, true);
|
||||||
|
|
||||||
database.updateOnlineGroupMember(leader.id, {
|
database.updateOnlineGroupMember(leader.id, {
|
||||||
id: "leader-character", name: "Leadwell", classId: "warrior", raceId: "human", gender: "male", level: 20,
|
id: "leader-character", name: "Leadwell", classId: "warrior", specializationId: "warrior:protection", raceId: "human", gender: "male", level: 20,
|
||||||
}, "tank");
|
}, "tank");
|
||||||
|
assert.equal(database.onlineGroupState(leader.id).group.members[0].character.specializationId, "warrior:protection");
|
||||||
assert.throws(() => database.startOnlineGroupActivity(leader.id, {
|
assert.throws(() => database.startOnlineGroupActivity(leader.id, {
|
||||||
type: "dungeon", contentId: "wailing-caverns", fillWithAi: true, partySize: 5,
|
type: "dungeon", contentId: "wailing-caverns", fillWithAi: true, partySize: 5,
|
||||||
}), (error) => error instanceof GameDatabaseError && error.code === "group_not_ready");
|
}), (error) => error instanceof GameDatabaseError && error.code === "group_not_ready");
|
||||||
|
|
||||||
database.updateOnlineGroupMember(member.id, {
|
database.updateOnlineGroupMember(member.id, {
|
||||||
id: "member-character", name: "Mendwell", classId: "priest", raceId: "human", gender: "female", level: 20,
|
id: "member-character", name: "Mendwell", classId: "priest", specializationId: "priest:holy", raceId: "human", gender: "female", level: 20,
|
||||||
}, "healer");
|
}, "healer");
|
||||||
const playersOnly = database.startOnlineGroupActivity(leader.id, {
|
const playersOnly = database.startOnlineGroupActivity(leader.id, {
|
||||||
type: "dungeon", contentId: "wailing-caverns", fillWithAi: false, partySize: 5,
|
type: "dungeon", contentId: "wailing-caverns", fillWithAi: false, partySize: 5,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
updateCharacterManastormProgress,
|
updateCharacterManastormProgress,
|
||||||
updateCharacterProgression,
|
updateCharacterProgression,
|
||||||
updateCharacterSettings,
|
updateCharacterSettings,
|
||||||
|
updateCharacterSpecialization,
|
||||||
updateCombatPresentationSettings,
|
updateCombatPresentationSettings,
|
||||||
type StorageLike,
|
type StorageLike,
|
||||||
} from "./accountRepository";
|
} from "./accountRepository";
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
recordManastormFailure,
|
recordManastormFailure,
|
||||||
setManastormLoadout,
|
setManastormLoadout,
|
||||||
} from "../game/manastormProgress";
|
} from "../game/manastormProgress";
|
||||||
|
import { abilitiesForClass } from "../game/abilityCatalog";
|
||||||
|
|
||||||
class MemoryStorage implements StorageLike {
|
class MemoryStorage implements StorageLike {
|
||||||
private values = new Map<string, string>();
|
private values = new Map<string, string>();
|
||||||
@@ -38,6 +40,37 @@ class MemoryStorage implements StorageLike {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("local profile repository", () => {
|
describe("local profile repository", () => {
|
||||||
|
it("persists specializations and supports Class Hall keep/reset choices", () => {
|
||||||
|
const storage = new MemoryStorage();
|
||||||
|
const talentAbility = abilitiesForClass("stormbringer").find((ability) => ability.talentEntryId !== undefined)!;
|
||||||
|
const created = createCharacter({
|
||||||
|
ownerId: "offline-roster",
|
||||||
|
name: "Tempest",
|
||||||
|
categoryId: "coa",
|
||||||
|
raceId: "human",
|
||||||
|
classId: "stormbringer",
|
||||||
|
specializationId: "stormbringer:lightning",
|
||||||
|
gender: "female",
|
||||||
|
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
|
||||||
|
}, storage).character!;
|
||||||
|
updateCharacterProgression("offline-roster", created.id, {
|
||||||
|
level: 20,
|
||||||
|
experience: 0,
|
||||||
|
talentRanks: { [`coa-entry-${talentAbility.talentEntryId}`]: 1 },
|
||||||
|
}, storage);
|
||||||
|
updateCharacterActionBindings("offline-roster", created.id, { "primary:face-top": talentAbility.id }, storage);
|
||||||
|
|
||||||
|
const kept = updateCharacterSpecialization("offline-roster", created.id, "stormbringer:wind", false, storage).character!;
|
||||||
|
expect(kept.specializationId).toBe("stormbringer:wind");
|
||||||
|
expect(kept.talentRanks).not.toEqual({});
|
||||||
|
expect(kept.actionBindings["primary:face-top"]).toBe(talentAbility.id);
|
||||||
|
|
||||||
|
const reset = updateCharacterSpecialization("offline-roster", created.id, "stormbringer:maelstrom", true, storage).character!;
|
||||||
|
expect(reset.specializationId).toBe("stormbringer:maelstrom");
|
||||||
|
expect(reset.talentRanks).toEqual({});
|
||||||
|
expect(reset.actionBindings["primary:face-top"]).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("creates and verifies a local account without storing its raw password", async () => {
|
it("creates and verifies a local account without storing its raw password", async () => {
|
||||||
const storage = new MemoryStorage();
|
const storage = new MemoryStorage();
|
||||||
const created = await createLocalAccount("CaveHealer", "serpent-pass", storage);
|
const created = await createLocalAccount("CaveHealer", "serpent-pass", storage);
|
||||||
@@ -149,7 +182,7 @@ describe("local profile repository", () => {
|
|||||||
}));
|
}));
|
||||||
expect(loadCombatPresentationSettings("offline-roster", storage))
|
expect(loadCombatPresentationSettings("offline-roster", storage))
|
||||||
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
|
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
|
||||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(3);
|
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(4);
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyV3Storage = new MemoryStorage();
|
const legacyV3Storage = new MemoryStorage();
|
||||||
@@ -159,6 +192,7 @@ describe("local profile repository", () => {
|
|||||||
rosters: { "offline-roster": [] },
|
rosters: { "offline-roster": [] },
|
||||||
presentationSettingsByOwner: {
|
presentationSettingsByOwner: {
|
||||||
"offline-roster": {
|
"offline-roster": {
|
||||||
|
vfxMode: "original",
|
||||||
vfxQuality: "low",
|
vfxQuality: "low",
|
||||||
vfxEnabled: { player: true, party: false, enemy: true },
|
vfxEnabled: { player: true, party: false, enemy: true },
|
||||||
audioEnabled: { player: true, party: true, enemy: false },
|
audioEnabled: { player: true, party: true, enemy: false },
|
||||||
@@ -172,12 +206,15 @@ describe("local profile repository", () => {
|
|||||||
});
|
});
|
||||||
expect(JSON.parse(legacyV3Storage.getItem(PROFILE_STORAGE_KEYS.database)!)
|
expect(JSON.parse(legacyV3Storage.getItem(PROFILE_STORAGE_KEYS.database)!)
|
||||||
.presentationSettingsByOwner["offline-roster"].vfxMode).toBe("generated");
|
.presentationSettingsByOwner["offline-roster"].vfxMode).toBe("generated");
|
||||||
|
expect(JSON.parse(legacyV3Storage.getItem(PROFILE_STORAGE_KEYS.database)!)
|
||||||
|
.presentationSettingsByOwner["offline-roster"].originalMediaEnabled).toBe(false);
|
||||||
|
|
||||||
const storage = new MemoryStorage();
|
const storage = new MemoryStorage();
|
||||||
const firstOwner = (await createLocalAccount("EffectsOne", "eight-plus", storage)).session!.ownerId;
|
const firstOwner = (await createLocalAccount("EffectsOne", "eight-plus", storage)).session!.ownerId;
|
||||||
const secondOwner = (await createLocalAccount("EffectsTwo", "eight-plus", storage)).session!.ownerId;
|
const secondOwner = (await createLocalAccount("EffectsTwo", "eight-plus", storage)).session!.ownerId;
|
||||||
const customized = updateCombatPresentationSettings(firstOwner, {
|
const customized = updateCombatPresentationSettings(firstOwner, {
|
||||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||||
|
originalMediaEnabled: true,
|
||||||
vfxMode: "original",
|
vfxMode: "original",
|
||||||
vfxQuality: "low",
|
vfxQuality: "low",
|
||||||
vfxEnabled: { player: true, party: false, enemy: true },
|
vfxEnabled: { player: true, party: false, enemy: true },
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ import {
|
|||||||
normalizeManastormProgress,
|
normalizeManastormProgress,
|
||||||
type ManastormProgress,
|
type ManastormProgress,
|
||||||
} from "../game/manastormProgress";
|
} from "../game/manastormProgress";
|
||||||
|
import { abilitiesForClass } from "../game/abilityCatalog";
|
||||||
|
import {
|
||||||
|
normalizeSpecializationId,
|
||||||
|
specializationById,
|
||||||
|
} from "../game/specializationCatalog";
|
||||||
|
|
||||||
const DATABASE_KEY = "healer-man.profile-database.v1";
|
const DATABASE_KEY = "healer-man.profile-database.v1";
|
||||||
const SESSION_KEY = "healer-man.session.v1";
|
const SESSION_KEY = "healer-man.session.v1";
|
||||||
@@ -51,7 +56,7 @@ interface LocalAccount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ProfileDatabase {
|
interface ProfileDatabase {
|
||||||
version: 3;
|
version: 4;
|
||||||
accounts: LocalAccount[];
|
accounts: LocalAccount[];
|
||||||
rosters: Record<string, CharacterProfile[]>;
|
rosters: Record<string, CharacterProfile[]>;
|
||||||
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
|
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
|
||||||
@@ -93,7 +98,7 @@ function browserSessionStorage(): StorageLike | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emptyDatabase(): ProfileDatabase {
|
function emptyDatabase(): ProfileDatabase {
|
||||||
return { version: 3, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
|
return { version: 4, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCharacterProfile(value: unknown): value is CharacterProfile {
|
function isCharacterProfile(value: unknown): value is CharacterProfile {
|
||||||
@@ -120,6 +125,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
|||||||
settings?: unknown;
|
settings?: unknown;
|
||||||
secondaryClassId?: unknown;
|
secondaryClassId?: unknown;
|
||||||
actionLoadouts?: unknown;
|
actionLoadouts?: unknown;
|
||||||
|
specializationId?: unknown;
|
||||||
};
|
};
|
||||||
const level = typeof legacy.level === "number" && Number.isFinite(legacy.level)
|
const level = typeof legacy.level === "number" && Number.isFinite(legacy.level)
|
||||||
? Math.max(1, Math.min(80, Math.floor(legacy.level)))
|
? Math.max(1, Math.min(80, Math.floor(legacy.level)))
|
||||||
@@ -138,6 +144,9 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
|||||||
.map(([bindingId, abilityId]) => [bindingId, abilityId as string | null]))
|
.map(([bindingId, abilityId]) => [bindingId, abilityId as string | null]))
|
||||||
: {};
|
: {};
|
||||||
const categoryId = contentCategoryForClass(value.classId);
|
const categoryId = contentCategoryForClass(value.classId);
|
||||||
|
const specializationId = categoryId === "rom"
|
||||||
|
? null
|
||||||
|
: normalizeSpecializationId(value.classId, legacy.specializationId);
|
||||||
const secondaryClassId = categoryId === "rom"
|
const secondaryClassId = categoryId === "rom"
|
||||||
&& isRomClassId(value.classId)
|
&& isRomClassId(value.classId)
|
||||||
&& typeof legacy.secondaryClassId === "string"
|
&& typeof legacy.secondaryClassId === "string"
|
||||||
@@ -168,6 +177,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
|||||||
talentRanks,
|
talentRanks,
|
||||||
actionBindings,
|
actionBindings,
|
||||||
categoryId,
|
categoryId,
|
||||||
|
specializationId,
|
||||||
secondaryClassId,
|
secondaryClassId,
|
||||||
actionLoadouts,
|
actionLoadouts,
|
||||||
inventory,
|
inventory,
|
||||||
@@ -186,7 +196,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
|||||||
rosters?: unknown;
|
rosters?: unknown;
|
||||||
presentationSettingsByOwner?: unknown;
|
presentationSettingsByOwner?: unknown;
|
||||||
}) | null;
|
}) | null;
|
||||||
if (!parsed || ![1, 2, 3].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
if (!parsed || ![1, 2, 3, 4].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||||
return emptyDatabase();
|
return emptyDatabase();
|
||||||
}
|
}
|
||||||
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
|
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
|
||||||
@@ -216,12 +226,18 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
|||||||
const storedMode = settings && typeof settings === "object" && !Array.isArray(settings)
|
const storedMode = settings && typeof settings === "object" && !Array.isArray(settings)
|
||||||
? (settings as { vfxMode?: unknown }).vfxMode
|
? (settings as { vfxMode?: unknown }).vfxMode
|
||||||
: undefined;
|
: undefined;
|
||||||
if (storedMode !== normalized.vfxMode) presentationSettingsMigrated = true;
|
const storedOriginalMediaEnabled = settings && typeof settings === "object" && !Array.isArray(settings)
|
||||||
|
? (settings as { originalMediaEnabled?: unknown }).originalMediaEnabled
|
||||||
|
: undefined;
|
||||||
|
if (
|
||||||
|
storedMode !== normalized.vfxMode
|
||||||
|
|| storedOriginalMediaEnabled !== normalized.originalMediaEnabled
|
||||||
|
) presentationSettingsMigrated = true;
|
||||||
return [ownerId, normalized];
|
return [ownerId, normalized];
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const database: ProfileDatabase = { version: 3, accounts, rosters, presentationSettingsByOwner };
|
const database: ProfileDatabase = { version: 4, accounts, rosters, presentationSettingsByOwner };
|
||||||
if (parsed.version !== 3 || presentationSettingsMigrated) writeDatabase(database, storage);
|
if (parsed.version !== 4 || presentationSettingsMigrated) writeDatabase(database, storage);
|
||||||
return database;
|
return database;
|
||||||
} catch {
|
} catch {
|
||||||
return emptyDatabase();
|
return emptyDatabase();
|
||||||
@@ -415,7 +431,9 @@ export function replaceCharactersForOwner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createCharacter(
|
export function createCharacter(
|
||||||
profile: Omit<CharacterProfile, "id" | "createdAt" | "lastPlayedAt" | "level" | "experience" | "talentRanks" | "actionBindings" | "inventory" | "equipment" | "manastormProgress" | "location">,
|
profile: Omit<CharacterProfile, "id" | "createdAt" | "lastPlayedAt" | "level" | "experience" | "talentRanks" | "actionBindings" | "inventory" | "equipment" | "manastormProgress" | "location" | "specializationId"> & {
|
||||||
|
specializationId?: string | null;
|
||||||
|
},
|
||||||
storage: StorageLike | null = browserLocalStorage(),
|
storage: StorageLike | null = browserLocalStorage(),
|
||||||
): CharacterResult {
|
): CharacterResult {
|
||||||
if (!storage) return { ok: false, error: "Local storage is unavailable on this device." };
|
if (!storage) return { ok: false, error: "Local storage is unavailable on this device." };
|
||||||
@@ -485,6 +503,54 @@ export function updateCharacterProgression(
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateCharacterSpecialization(
|
||||||
|
ownerId: string,
|
||||||
|
characterId: string,
|
||||||
|
specializationId: string,
|
||||||
|
resetTalents: boolean,
|
||||||
|
storage: StorageLike | null = browserLocalStorage(),
|
||||||
|
): CharacterResult {
|
||||||
|
if (!storage) return { ok: false, error: "Local storage is unavailable on this device." };
|
||||||
|
const database = readDatabase(storage);
|
||||||
|
let result: CharacterResult = { ok: false, error: "That character is no longer available." };
|
||||||
|
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
|
||||||
|
if (character.id !== characterId) return character;
|
||||||
|
if (character.categoryId === "rom") {
|
||||||
|
result = { ok: false, error: "RoM gear follows the active primary class and activity role." };
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
const specialization = specializationById(specializationId);
|
||||||
|
if (!specialization || specialization.classId !== character.classId) {
|
||||||
|
result = { ok: false, error: "That specialization is not available to this class." };
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
const talentAbilityIds = resetTalents
|
||||||
|
? new Set(abilitiesForClass(character.classId)
|
||||||
|
.filter((ability) => ability.talentEntryId !== undefined || ability.source === "coa-tree")
|
||||||
|
.map((ability) => ability.id))
|
||||||
|
: new Set<string>();
|
||||||
|
const actionBindings = resetTalents
|
||||||
|
? Object.fromEntries(Object.entries(character.actionBindings)
|
||||||
|
.map(([bindingId, abilityId]) => [bindingId, abilityId && talentAbilityIds.has(abilityId) ? null : abilityId]))
|
||||||
|
: character.actionBindings;
|
||||||
|
const loadoutKey = orderedClassLoadoutKey(character.classId, character.secondaryClassId);
|
||||||
|
const updated = normalizeCharacterProfile({
|
||||||
|
...character,
|
||||||
|
specializationId: specialization.id,
|
||||||
|
talentRanks: resetTalents ? {} : character.talentRanks,
|
||||||
|
actionBindings,
|
||||||
|
actionLoadouts: {
|
||||||
|
...(character.actionLoadouts ?? {}),
|
||||||
|
[loadoutKey]: { ...actionBindings },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
result = { ok: true, character: updated };
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
if (result.ok) writeDatabase(database, storage);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function updateCharacterActionBindings(
|
export function updateCharacterActionBindings(
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
characterId: string,
|
characterId: string,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { useShellStore } from "./shellStore";
|
import { useShellStore } from "./shellStore";
|
||||||
import { registerDungeonShellRuntime } from "./dungeonShellRuntimeBridge";
|
import { registerDungeonShellRuntime } from "./dungeonShellRuntimeBridge";
|
||||||
import { touchCharacter } from "./accountRepository";
|
import { touchCharacter } from "./accountRepository";
|
||||||
|
import { specializationById } from "../game/specializationCatalog";
|
||||||
|
|
||||||
export function installDungeonShellRuntime(): void {
|
export function installDungeonShellRuntime(): void {
|
||||||
registerDungeonShellRuntime({ enterDungeon });
|
registerDungeonShellRuntime({ enterDungeon });
|
||||||
@@ -33,7 +34,16 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
|
|||||||
useShellStore.setState({ notice: "That character is no longer available." });
|
useShellStore.setState({ notice: "That character is no longer available." });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const playerRole = requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
|
const savedSpecialization = specializationById(activeCharacter.specializationId);
|
||||||
|
if (activeCharacter.categoryId !== "rom" && !savedSpecialization) {
|
||||||
|
useShellStore.setState({ phase: "class-hall", notice: "Choose a specialization before entering activities." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const playerRole = savedSpecialization?.role ?? requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
|
||||||
|
if (savedSpecialization && requestedRole && requestedRole !== savedSpecialization.role) {
|
||||||
|
useShellStore.setState({ notice: `${savedSpecialization.displayName} is locked to ${PARTY_ROLE_LABELS[savedSpecialization.role]}.` });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!classCanFillPartyRole(activeCharacter.classId, playerRole)) {
|
if (!classCanFillPartyRole(activeCharacter.classId, playerRole)) {
|
||||||
useShellStore.setState({
|
useShellStore.setState({
|
||||||
notice: `${activeCharacter.name}'s class cannot enter as ${PARTY_ROLE_LABELS[playerRole]}.`,
|
notice: `${activeCharacter.name}'s class cannot enter as ${PARTY_ROLE_LABELS[playerRole]}.`,
|
||||||
@@ -56,4 +66,3 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
type ManastormPartySize,
|
type ManastormPartySize,
|
||||||
} from "../game/manastormProgress";
|
} from "../game/manastormProgress";
|
||||||
import { activateManastormStage, releaseActiveManastormStage } from "../game/manastormStageLoader";
|
import { activateManastormStage, releaseActiveManastormStage } from "../game/manastormStageLoader";
|
||||||
|
import { specializationById } from "../game/specializationCatalog";
|
||||||
import {
|
import {
|
||||||
manastormActiveSpellLoadoutModel,
|
manastormActiveSpellLoadoutModel,
|
||||||
validateManastormActiveSpellLoadout,
|
validateManastormActiveSpellLoadout,
|
||||||
@@ -63,7 +64,16 @@ function enterManastorm(
|
|||||||
useShellStore.setState({ notice: "That character is no longer available." });
|
useShellStore.setState({ notice: "That character is no longer available." });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const playerRole = requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
|
const savedSpecialization = specializationById(activeCharacter.specializationId);
|
||||||
|
if (activeCharacter.categoryId !== "rom" && !savedSpecialization) {
|
||||||
|
useShellStore.setState({ phase: "class-hall", notice: "Choose a specialization before entering activities." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const playerRole = savedSpecialization?.role ?? requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
|
||||||
|
if (savedSpecialization && requestedRole && requestedRole !== savedSpecialization.role) {
|
||||||
|
useShellStore.setState({ notice: `${savedSpecialization.displayName} is locked to ${PARTY_ROLE_LABELS[savedSpecialization.role]}.` });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!classCanFillPartyRole(activeCharacter.classId, playerRole)) {
|
if (!classCanFillPartyRole(activeCharacter.classId, playerRole)) {
|
||||||
useShellStore.setState({
|
useShellStore.setState({
|
||||||
notice: `${activeCharacter.name}'s class cannot enter as ${PARTY_ROLE_LABELS[playerRole]}.`,
|
notice: `${activeCharacter.name}'s class cannot enter as ${PARTY_ROLE_LABELS[playerRole]}.`,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface OnlineGroupCharacter {
|
|||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly classId: string;
|
readonly classId: string;
|
||||||
|
readonly specializationId?: string | null;
|
||||||
readonly categoryId: string;
|
readonly categoryId: string;
|
||||||
readonly raceId: string;
|
readonly raceId: string;
|
||||||
readonly gender: "female" | "male";
|
readonly gender: "female" | "male";
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
|
|||||||
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
|
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(3);
|
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
|
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const character = {
|
|||||||
name: "Remedy",
|
name: "Remedy",
|
||||||
raceId: "human" as const,
|
raceId: "human" as const,
|
||||||
classId: "priest" as const,
|
classId: "priest" as const,
|
||||||
|
specializationId: "priest:holy",
|
||||||
gender: "female" as const,
|
gender: "female" as const,
|
||||||
appearance: createDefaultAppearance(),
|
appearance: createDefaultAppearance(),
|
||||||
level: 1,
|
level: 1,
|
||||||
@@ -100,6 +101,15 @@ describe("shell store", () => {
|
|||||||
expect(useShellStore.getState().notice).toMatch(/select or create/i);
|
expect(useShellStore.getState().notice).toMatch(/select or create/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes a legacy WoW character through the mandatory specialization picker", () => {
|
||||||
|
prepareSelectedCharacter();
|
||||||
|
useShellStore.setState({ characters: [{ ...character, specializationId: null }] });
|
||||||
|
expect(useShellStore.getState().openMainMenu()).toBe(true);
|
||||||
|
expect(useShellStore.getState().phase).toBe("class-hall");
|
||||||
|
expect(useShellStore.getState().openDungeons()).toBe(false);
|
||||||
|
expect(useShellStore.getState().notice).toMatch(/choose a specialization/i);
|
||||||
|
});
|
||||||
|
|
||||||
it("opens the main menu and dungeon list without resetting gameplay", () => {
|
it("opens the main menu and dungeon list without resetting gameplay", () => {
|
||||||
prepareSelectedCharacter();
|
prepareSelectedCharacter();
|
||||||
useGameStore.setState({ paused: true, mapOpen: true, companionOpen: true, distanceTravelled: 42 });
|
useGameStore.setState({ paused: true, mapOpen: true, companionOpen: true, distanceTravelled: 42 });
|
||||||
@@ -124,11 +134,11 @@ describe("shell store", () => {
|
|||||||
useShellStore.getState().openDungeons();
|
useShellStore.getState().openDungeons();
|
||||||
useGameStore.setState({ paused: true, mapOpen: true, companionOpen: true, distanceTravelled: 42 });
|
useGameStore.setState({ paused: true, mapOpen: true, companionOpen: true, distanceTravelled: 42 });
|
||||||
|
|
||||||
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "damage")).toBe(true);
|
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "healer")).toBe(true);
|
||||||
expect(useShellStore.getState()).toMatchObject({
|
expect(useShellStore.getState()).toMatchObject({
|
||||||
phase: "game",
|
phase: "game",
|
||||||
activeCharacter: character,
|
activeCharacter: character,
|
||||||
activeDungeonRole: "damage",
|
activeDungeonRole: "healer",
|
||||||
});
|
});
|
||||||
expect(useGameStore.getState()).toMatchObject({
|
expect(useGameStore.getState()).toMatchObject({
|
||||||
paused: false,
|
paused: false,
|
||||||
@@ -142,7 +152,7 @@ describe("shell store", () => {
|
|||||||
prepareSelectedCharacter();
|
prepareSelectedCharacter();
|
||||||
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "tank")).toBe(false);
|
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "tank")).toBe(false);
|
||||||
expect(useShellStore.getState().phase).toBe("characters");
|
expect(useShellStore.getState().phase).toBe("characters");
|
||||||
expect(useShellStore.getState().notice).toMatch(/cannot enter as tank/i);
|
expect(useShellStore.getState().notice).toMatch(/locked to healer/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an unknown dungeon without resetting or leaving the list", () => {
|
it("rejects an unknown dungeon without resetting or leaving the list", () => {
|
||||||
@@ -234,7 +244,7 @@ describe("shell store", () => {
|
|||||||
expect(useShellStore.getState().openManastorms()).toBe(true);
|
expect(useShellStore.getState().openManastorms()).toBe(true);
|
||||||
expect(useShellStore.getState().phase).toBe("manastorms");
|
expect(useShellStore.getState().phase).toBe("manastorms");
|
||||||
expect(useShellStore.getState().enterManastorm("tank")).toBe(false);
|
expect(useShellStore.getState().enterManastorm("tank")).toBe(false);
|
||||||
expect(useShellStore.getState().notice).toMatch(/cannot enter as tank/i);
|
expect(useShellStore.getState().notice).toMatch(/locked to healer/i);
|
||||||
|
|
||||||
expect(useShellStore.getState().enterManastorm("healer")).toBe(true);
|
expect(useShellStore.getState().enterManastorm("healer")).toBe(true);
|
||||||
expect(useShellStore.getState()).toMatchObject({
|
expect(useShellStore.getState()).toMatchObject({
|
||||||
|
|||||||
+64
-3
@@ -14,6 +14,7 @@ import {
|
|||||||
updateCharacterManastormProgress,
|
updateCharacterManastormProgress,
|
||||||
updateCharacterProgression,
|
updateCharacterProgression,
|
||||||
updateCharacterSettings,
|
updateCharacterSettings,
|
||||||
|
updateCharacterSpecialization,
|
||||||
updateCombatPresentationSettings as persistCombatPresentationSettings,
|
updateCombatPresentationSettings as persistCombatPresentationSettings,
|
||||||
swapRomClasses,
|
swapRomClasses,
|
||||||
} from "./accountRepository";
|
} from "./accountRepository";
|
||||||
@@ -55,6 +56,11 @@ import {
|
|||||||
} from "./onlineAccountClient";
|
} from "./onlineAccountClient";
|
||||||
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
||||||
import { useGameStore } from "../game/store";
|
import { useGameStore } from "../game/store";
|
||||||
|
import {
|
||||||
|
normalizeSpecializationId,
|
||||||
|
specializationById,
|
||||||
|
specializationsForClass,
|
||||||
|
} from "../game/specializationCatalog";
|
||||||
|
|
||||||
export interface ShellState {
|
export interface ShellState {
|
||||||
phase: ShellPhase;
|
phase: ShellPhase;
|
||||||
@@ -82,6 +88,7 @@ export interface ShellState {
|
|||||||
openManastorms: () => boolean;
|
openManastorms: () => boolean;
|
||||||
openGmAdmin: () => boolean;
|
openGmAdmin: () => boolean;
|
||||||
openClassHall: () => boolean;
|
openClassHall: () => boolean;
|
||||||
|
changeSelectedSpecialization: (specializationId: string, resetTalents: boolean) => boolean;
|
||||||
chooseRomSecondaryClass: (classId: RomClassId) => boolean;
|
chooseRomSecondaryClass: (classId: RomClassId) => boolean;
|
||||||
swapSelectedRomClasses: () => boolean;
|
swapSelectedRomClasses: () => boolean;
|
||||||
returnToMainMenu: () => void;
|
returnToMainMenu: () => void;
|
||||||
@@ -110,11 +117,13 @@ export interface ShellState {
|
|||||||
|
|
||||||
function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
|
function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
|
||||||
const raceId: RaceId = categoryId === "rom" ? "rom-human" : "human";
|
const raceId: RaceId = categoryId === "rom" ? "rom-human" : "human";
|
||||||
|
const classId = categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest";
|
||||||
return {
|
return {
|
||||||
name: "",
|
name: "",
|
||||||
categoryId,
|
categoryId,
|
||||||
raceId,
|
raceId,
|
||||||
classId: categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest",
|
classId,
|
||||||
|
specializationId: specializationsForClass(classId)[0]?.id ?? null,
|
||||||
gender: "female",
|
gender: "female",
|
||||||
appearance: createDefaultAppearance(),
|
appearance: createDefaultAppearance(),
|
||||||
rotation: -12,
|
rotation: -12,
|
||||||
@@ -202,6 +211,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
|||||||
if (!availableClasses.some((definition) => definition.id === next.classId)) {
|
if (!availableClasses.some((definition) => definition.id === next.classId)) {
|
||||||
next.classId = availableClasses[0].id;
|
next.classId = availableClasses[0].id;
|
||||||
}
|
}
|
||||||
|
next.specializationId = next.categoryId === "rom"
|
||||||
|
? null
|
||||||
|
: normalizeSpecializationId(next.classId, next.specializationId)
|
||||||
|
?? specializationsForClass(next.classId)[0]?.id
|
||||||
|
?? null;
|
||||||
next.appearance = clampAppearance(next.raceId, next.gender, next.appearance);
|
next.appearance = clampAppearance(next.raceId, next.gender, next.appearance);
|
||||||
return { draft: next, notice: "" };
|
return { draft: next, notice: "" };
|
||||||
}),
|
}),
|
||||||
@@ -233,12 +247,17 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
|||||||
set({ notice: "That gender model is not available for the selected race." });
|
set({ notice: "That gender model is not available for the selected race." });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (draft.categoryId !== "rom" && !normalizeSpecializationId(draft.classId, draft.specializationId)) {
|
||||||
|
set({ notice: "Choose a specialization before creating this character." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const result = persistCharacter({
|
const result = persistCharacter({
|
||||||
ownerId: session.ownerId,
|
ownerId: session.ownerId,
|
||||||
name: normalizeCharacterName(draft.name),
|
name: normalizeCharacterName(draft.name),
|
||||||
categoryId: draft.categoryId,
|
categoryId: draft.categoryId,
|
||||||
raceId: draft.raceId,
|
raceId: draft.raceId,
|
||||||
classId: draft.classId,
|
classId: draft.classId,
|
||||||
|
specializationId: draft.categoryId === "rom" ? null : draft.specializationId,
|
||||||
gender: draft.gender,
|
gender: draft.gender,
|
||||||
appearance: clampAppearance(draft.raceId, draft.gender, draft.appearance),
|
appearance: clampAppearance(draft.raceId, draft.gender, draft.appearance),
|
||||||
});
|
});
|
||||||
@@ -278,6 +297,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
|||||||
set({ notice: "That character is no longer available." });
|
set({ notice: "That character is no longer available." });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
|
||||||
|
if (character.categoryId !== "rom" && !character.specializationId) {
|
||||||
|
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before continuing." });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
set({ phase: "main-menu", activeCharacter: null, notice: "" });
|
set({ phase: "main-menu", activeCharacter: null, notice: "" });
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -287,6 +311,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
|||||||
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
|
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
|
||||||
|
if (character.categoryId !== "rom" && !character.specializationId) {
|
||||||
|
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
set({ phase: "dungeons", activeCharacter: null, notice: "" });
|
set({ phase: "dungeons", activeCharacter: null, notice: "" });
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -296,6 +325,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
|||||||
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
|
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
|
||||||
|
if (character.categoryId !== "rom" && !character.specializationId) {
|
||||||
|
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
set({ phase: "manastorms", activeCharacter: null, notice: "" });
|
set({ phase: "manastorms", activeCharacter: null, notice: "" });
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -315,13 +349,40 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
|||||||
openClassHall: () => {
|
openClassHall: () => {
|
||||||
const { session, selectedCharacterId, characters } = get();
|
const { session, selectedCharacterId, characters } = get();
|
||||||
const character = characters.find((entry) => entry.id === selectedCharacterId);
|
const character = characters.find((entry) => entry.id === selectedCharacterId);
|
||||||
if (!session || !character || character.categoryId !== "rom") {
|
if (!session || !character) {
|
||||||
set({ notice: "The Class Hall is available to Runes of Magic characters." });
|
set({ notice: "Select a character before opening the Class Hall." });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
set({ phase: "class-hall", activeCharacter: null, notice: "" });
|
set({ phase: "class-hall", activeCharacter: null, notice: "" });
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
changeSelectedSpecialization: (specializationId, resetTalents) => {
|
||||||
|
const { session, selectedCharacterId, phase } = get();
|
||||||
|
if (!session || !selectedCharacterId || phase !== "class-hall") {
|
||||||
|
set({ notice: "Specializations may only be changed inside the Class Hall." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const definition = specializationById(specializationId);
|
||||||
|
if (!definition) {
|
||||||
|
set({ notice: "That specialization is unavailable." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const result = updateCharacterSpecialization(
|
||||||
|
session.ownerId,
|
||||||
|
selectedCharacterId,
|
||||||
|
definition.id,
|
||||||
|
resetTalents,
|
||||||
|
);
|
||||||
|
if (!result.ok || !result.character) {
|
||||||
|
set({ notice: result.error ?? "The specialization could not be changed." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
set((state) => ({
|
||||||
|
characters: state.characters.map((entry) => entry.id === result.character!.id ? result.character! : entry),
|
||||||
|
notice: `${definition.displayName} selected. ${resetTalents ? "Talents reset." : "Talents kept."}`,
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
chooseRomSecondaryClass: (classId) => {
|
chooseRomSecondaryClass: (classId) => {
|
||||||
const { session, selectedCharacterId } = get();
|
const { session, selectedCharacterId } = get();
|
||||||
if (!session || !selectedCharacterId) return false;
|
if (!session || !selectedCharacterId) return false;
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface CharacterProfile {
|
|||||||
name: string;
|
name: string;
|
||||||
raceId: RaceId;
|
raceId: RaceId;
|
||||||
classId: ClassId;
|
classId: ClassId;
|
||||||
|
/** Stable specialization registry ID. Normalized profiles always include it; optional for legacy callers. */
|
||||||
|
specializationId?: string | null;
|
||||||
/** Always present after repository normalization; optional for v1 callers. */
|
/** Always present after repository normalization; optional for v1 callers. */
|
||||||
categoryId?: ContentCategoryId;
|
categoryId?: ContentCategoryId;
|
||||||
/** RoM's inactive class. The active primary remains `classId`. */
|
/** RoM's inactive class. The active primary remains `classId`. */
|
||||||
@@ -51,6 +53,7 @@ export interface CharacterDraft {
|
|||||||
categoryId: ContentCategoryId;
|
categoryId: ContentCategoryId;
|
||||||
raceId: RaceId;
|
raceId: RaceId;
|
||||||
classId: ClassId;
|
classId: ClassId;
|
||||||
|
specializationId: string | null;
|
||||||
gender: GenderId;
|
gender: GenderId;
|
||||||
appearance: CharacterAppearance;
|
appearance: CharacterAppearance;
|
||||||
rotation: number;
|
rotation: number;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/aeternal-circle-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/aeternal-circle-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/barren-caves-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/barren-caves-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/cave-water-dragon-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/cave-water-dragon-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold-125-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold-125-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/dungeon-of-dalanis-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/dungeon-of-dalanis-population.json",
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 2,
|
"schemaVersion": 1,
|
||||||
"dungeonId": "forsaken-abbey",
|
"dungeonId": "forsaken-abbey",
|
||||||
"zoneId": 102,
|
"zoneId": 102,
|
||||||
"sourceRowCount": 251,
|
"sourceRowCount": 251,
|
||||||
"importedSpawnCount": 150,
|
"importedSpawnCount": 150,
|
||||||
"excludedRowCount": 101,
|
"excludedRowCount": 101,
|
||||||
"bossSpawnCount": 8,
|
"bossSpawnCount": 8,
|
||||||
"objectiveOrder": {
|
|
||||||
"authoritative": false,
|
|
||||||
"kind": "healerMan-distance-derived",
|
|
||||||
"evidence": [
|
|
||||||
"scripts/runewaker-pipeline/build-population.mjs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"sourceSnapshot": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json",
|
"sourceSnapshot": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json",
|
||||||
"sourceContentSha256": "13ef6edd3df6e71389aa92d01201889148c0794cf9ecaafdbaf90d3145d2bf7b",
|
"sourceContentSha256": "13ef6edd3df6e71389aa92d01201889148c0794cf9ecaafdbaf90d3145d2bf7b",
|
||||||
"sourceHashes": {
|
"sourceHashes": {
|
||||||
@@ -26,7 +19,7 @@
|
|||||||
"Resource/data/string_enus.db": "4fce021ba9ed110a213e08d3228b2de57cec66ddbbc8cdcf5753204874d9ab75"
|
"Resource/data/string_enus.db": "4fce021ba9ed110a213e08d3228b2de57cec66ddbbc8cdcf5753204874d9ab75"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(meters,meters,-meters)",
|
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
|
||||||
"navmeshProjection": {
|
"navmeshProjection": {
|
||||||
"maximumAllowedDistance": 2,
|
"maximumAllowedDistance": 2,
|
||||||
"maximumObservedDistance": 2.5965,
|
"maximumObservedDistance": 2.5965,
|
||||||
@@ -35,9 +28,6 @@
|
|||||||
},
|
},
|
||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/forsaken-abbey-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/forsaken-abbey-population.json",
|
||||||
"actorManifest": "public/assets/creatures/forsaken-abbey/manifest.json",
|
"actorManifest": "public/assets/creatures/forsaken-abbey/manifest.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/grafu-castle-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/grafu-castle-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/hall-of-survivors-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/hall-of-survivors-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/hoto-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/hoto-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/ice-dwarf-kingdom-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/ice-dwarf-kingdom-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/kal-turok-hive-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/kal-turok-hive-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/kalins-shrine-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/kalins-shrine-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/kawaks-tomb-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/kawaks-tomb-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/kulech-bones-nest-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/kulech-bones-nest-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/lair-demon-dragon-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/lair-demon-dragon-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/mystic-altar-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/mystic-altar-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/paspers-shrine-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/paspers-shrine-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/raksha-temple-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/raksha-temple-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/sand-vortex-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/sand-vortex-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/sardo-castle-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/sardo-castle-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/the-origin-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/the-origin-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/tomb-seven-heroes-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/tomb-seven-heroes-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/treasure-trove-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/treasure-trove-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/warnorken-arena-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/warnorken-arena-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss2-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss2-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss3-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss3-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold-124-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold-124-population.json",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"runeWakerIdsAreNamespaced": true,
|
"runeWakerIdsAreNamespaced": true,
|
||||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold-population.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold-population.json",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import type {
|
|||||||
} from "../app/characterCatalog";
|
} from "../app/characterCatalog";
|
||||||
import type { InventoryItem } from "../game/lootTypes";
|
import type { InventoryItem } from "../game/lootTypes";
|
||||||
import { resolveContentUrl } from "../content/contentManager";
|
import { resolveContentUrl } from "../content/contentManager";
|
||||||
|
import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||||
import {
|
import {
|
||||||
selectCharacterBaseClip,
|
selectCharacterBaseClip,
|
||||||
selectCharacterEventClip,
|
selectCharacterEventClip,
|
||||||
@@ -144,6 +145,7 @@ function useManifestEntry(identity: AvatarIdentity | null | undefined): {
|
|||||||
manifest: AvatarManifest | null;
|
manifest: AvatarManifest | null;
|
||||||
entry: AvatarManifestEntry | null;
|
entry: AvatarManifestEntry | null;
|
||||||
} {
|
} {
|
||||||
|
const contentRevision = useContentManagerSnapshot().installedRevision;
|
||||||
const [state, setState] = useState<{ manifest: AvatarManifest; entry: AvatarManifestEntry } | null>(null);
|
const [state, setState] = useState<{ manifest: AvatarManifest; entry: AvatarManifestEntry } | null>(null);
|
||||||
const key = identity ? avatarManifestKey(identity.raceId, identity.gender) : "";
|
const key = identity ? avatarManifestKey(identity.raceId, identity.gender) : "";
|
||||||
const raceId = identity?.raceId;
|
const raceId = identity?.raceId;
|
||||||
@@ -162,7 +164,7 @@ function useManifestEntry(identity: AvatarIdentity | null | undefined): {
|
|||||||
if (!cancelled) console.warn("Playable-character manifest could not be loaded", error);
|
if (!cancelled) console.warn("Playable-character manifest could not be loaded", error);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [gender, key, raceId]);
|
}, [contentRevision, gender, key, raceId]);
|
||||||
return { manifest: state?.manifest ?? null, entry: state?.entry ?? null };
|
return { manifest: state?.manifest ?? null, entry: state?.entry ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useGLTF } from "@react-three/drei";
|
|||||||
import { resolveContentUrl } from "../content/contentManager";
|
import { resolveContentUrl } from "../content/contentManager";
|
||||||
import type { AvatarIdentity } from "./CharacterModel";
|
import type { AvatarIdentity } from "./CharacterModel";
|
||||||
import {
|
import {
|
||||||
|
availableAvatarManifestUrls,
|
||||||
getAvatarEntry,
|
getAvatarEntry,
|
||||||
loadAvatarManifest,
|
loadAvatarManifest,
|
||||||
} from "./characterManifest";
|
} from "./characterManifest";
|
||||||
@@ -16,6 +17,7 @@ const requestedModels = new Set<string>();
|
|||||||
export async function preloadAvatarModels(
|
export async function preloadAvatarModels(
|
||||||
identities: readonly (AvatarIdentity | null | undefined)[],
|
identities: readonly (AvatarIdentity | null | undefined)[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (!availableAvatarManifestUrls().length) return;
|
||||||
const manifest = await loadAvatarManifest();
|
const manifest = await loadAvatarManifest();
|
||||||
for (const identity of identities) {
|
for (const identity of identities) {
|
||||||
if (!identity) continue;
|
if (!identity) continue;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
availableAvatarManifestUrls,
|
||||||
|
getAvatarEntry,
|
||||||
loadAvatarManifestSources,
|
loadAvatarManifestSources,
|
||||||
type AvatarManifest,
|
type AvatarManifest,
|
||||||
type AvatarManifestEntry,
|
type AvatarManifestEntry,
|
||||||
@@ -33,6 +35,32 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("playable-character manifest loading", () => {
|
describe("playable-character manifest loading", () => {
|
||||||
|
it("does not expose optional character manifests until their packs are usable", () => {
|
||||||
|
expect(availableAvatarManifestUrls(() => false)).toEqual([]);
|
||||||
|
expect(availableAvatarManifestUrls((packId) => packId === "core:character-basic")).toEqual([
|
||||||
|
"/assets/characters/wow335a-v1/manifest.json",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the matching basic human when an extra race pack is unavailable", async () => {
|
||||||
|
const loaded = await loadAvatarManifestSources(
|
||||||
|
["/assets/characters/wow335a-v1/manifest.json"],
|
||||||
|
async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => manifest("3.3.5", {
|
||||||
|
"human:male": { raceId: "human", gender: "male", model: "/base-human.glb" },
|
||||||
|
"orc:male": { raceId: "orc", gender: "male", model: "/orc.glb" },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fallback = getAvatarEntry(loaded, "orc", "male", (packId) => packId === "core:character-basic");
|
||||||
|
expect(fallback?.model).toBe("/base-human.glb");
|
||||||
|
expect(fallback?.counts).toEqual({ skinColor: 1, face: 1, hairStyle: 1, hairColor: 1, feature: 1 });
|
||||||
|
expect(getAvatarEntry(loaded, "orc", "male", () => true)?.model).toBe("/orc.glb");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps valid base characters available when an optional pack fails", async () => {
|
it("keeps valid base characters available when an optional pack fails", async () => {
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
const base = manifest("3.3.5", {
|
const base = manifest("3.3.5", {
|
||||||
|
|||||||
+108
-20
@@ -1,5 +1,8 @@
|
|||||||
import type { AppearanceCounts, GenderId, RaceId } from "../app/characterCatalog";
|
import type { AppearanceCounts, GenderId, RaceId } from "../app/characterCatalog";
|
||||||
import { resolveContentUrl } from "../content/contentManager";
|
import {
|
||||||
|
contentPackIsUsable,
|
||||||
|
resolveContentUrl,
|
||||||
|
} from "../content/contentManager";
|
||||||
|
|
||||||
export type TextureTriplet = readonly [string, string, string];
|
export type TextureTriplet = readonly [string, string, string];
|
||||||
export type TextureRegion = readonly [number, number, number, number];
|
export type TextureRegion = readonly [number, number, number, number];
|
||||||
@@ -73,6 +76,8 @@ export interface AvatarManifestEntry {
|
|||||||
featureOptions: readonly AvatarFeatureOption[];
|
featureOptions: readonly AvatarFeatureOption[];
|
||||||
attachmentPoints?: Readonly<Record<string, AvatarAttachmentPoint>>;
|
attachmentPoints?: Readonly<Record<string, AvatarAttachmentPoint>>;
|
||||||
rendererStrategy?: RunewakerModularRendererStrategy;
|
rendererStrategy?: RunewakerModularRendererStrategy;
|
||||||
|
/** Runtime ownership; generated manifests may omit it. */
|
||||||
|
contentPackId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AvatarManifest {
|
export interface AvatarManifest {
|
||||||
@@ -97,12 +102,24 @@ export interface AvatarManifest {
|
|||||||
export type CharacterManifest = AvatarManifest;
|
export type CharacterManifest = AvatarManifest;
|
||||||
|
|
||||||
export const AVATAR_MANIFEST_URL = "/assets/characters/wow335a-v1/manifest.json";
|
export const AVATAR_MANIFEST_URL = "/assets/characters/wow335a-v1/manifest.json";
|
||||||
export const AVATAR_MANIFEST_URLS = [
|
export const BASIC_CHARACTER_CONTENT_PACK_ID = "core:character-basic";
|
||||||
AVATAR_MANIFEST_URL,
|
export const WOW_CHARACTER_CONTENT_PACK_ID = "characters:wow335a-v1";
|
||||||
"/assets/characters/ascension-v1/manifest.json",
|
export const AVATAR_MANIFEST_SOURCES = [
|
||||||
"/assets/characters/runewaker-v1/manifest.json",
|
{
|
||||||
|
packIds: [BASIC_CHARACTER_CONTENT_PACK_ID, WOW_CHARACTER_CONTENT_PACK_ID],
|
||||||
|
url: AVATAR_MANIFEST_URL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packIds: ["characters:ascension-v1"],
|
||||||
|
url: "/assets/characters/ascension-v1/manifest.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packIds: ["characters:runewaker-v1"],
|
||||||
|
url: "/assets/characters/runewaker-v1/manifest.json",
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
let manifestPromise: Promise<AvatarManifest> | null = null;
|
export const AVATAR_MANIFEST_URLS = AVATAR_MANIFEST_SOURCES.map((source) => source.url);
|
||||||
|
let manifestRequest: { readonly key: string; readonly promise: Promise<AvatarManifest> } | null = null;
|
||||||
|
|
||||||
interface AvatarManifestResponse {
|
interface AvatarManifestResponse {
|
||||||
readonly ok: boolean;
|
readonly ok: boolean;
|
||||||
@@ -114,12 +131,37 @@ export function avatarManifestKey(raceId: RaceId, gender: GenderId): string {
|
|||||||
return `${raceId}:${gender}`;
|
return `${raceId}:${gender}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeAvatarManifests(manifests: readonly AvatarManifest[]): AvatarManifest {
|
export function availableAvatarManifestUrls(
|
||||||
const [baseManifest, ...extensions] = manifests;
|
isUsable: (packId: string) => boolean = contentPackIsUsable,
|
||||||
|
): readonly string[] {
|
||||||
|
return AVATAR_MANIFEST_SOURCES
|
||||||
|
.filter((source) => source.packIds.some((packId) => isUsable(packId)))
|
||||||
|
.map((source) => source.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentPackForAvatarEntry(url: string, entry: AvatarManifestEntry): string {
|
||||||
|
if (url === AVATAR_MANIFEST_URL) {
|
||||||
|
return entry.raceId === "human"
|
||||||
|
? BASIC_CHARACTER_CONTENT_PACK_ID
|
||||||
|
: WOW_CHARACTER_CONTENT_PACK_ID;
|
||||||
|
}
|
||||||
|
return AVATAR_MANIFEST_SOURCES.find((source) => source.url === url)?.packIds[0]
|
||||||
|
?? WOW_CHARACTER_CONTENT_PACK_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeAvatarManifests(
|
||||||
|
sources: readonly { readonly url: string; readonly manifest: AvatarManifest }[],
|
||||||
|
): AvatarManifest {
|
||||||
|
const [{ manifest: baseManifest }, ...extensions] = sources;
|
||||||
return {
|
return {
|
||||||
...baseManifest,
|
...baseManifest,
|
||||||
sourceBuild: manifests.map((manifest) => manifest.sourceBuild).join(" + "),
|
sourceBuild: sources.map(({ manifest }) => manifest.sourceBuild).join(" + "),
|
||||||
entries: Object.assign({}, baseManifest.entries, ...extensions.map((manifest) => manifest.entries)),
|
entries: Object.assign({}, ...sources.map(({ manifest, url }) => Object.fromEntries(
|
||||||
|
Object.entries(manifest.entries).map(([key, entry]) => [
|
||||||
|
key,
|
||||||
|
{ ...entry, contentPackId: contentPackForAvatarEntry(url, entry) },
|
||||||
|
]),
|
||||||
|
))),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,10 +180,10 @@ export async function loadAvatarManifestSources(
|
|||||||
if (manifest.version !== 1 || !manifest.entries) {
|
if (manifest.version !== 1 || !manifest.entries) {
|
||||||
throw new Error(`Avatar manifest is malformed: ${url}`);
|
throw new Error(`Avatar manifest is malformed: ${url}`);
|
||||||
}
|
}
|
||||||
return manifest;
|
return { url, manifest };
|
||||||
}));
|
}));
|
||||||
const manifests = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
|
const sources = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
|
||||||
if (!manifests.length) {
|
if (!sources.length) {
|
||||||
const firstFailure = results.find((result) => result.status === "rejected");
|
const firstFailure = results.find((result) => result.status === "rejected");
|
||||||
throw firstFailure?.status === "rejected"
|
throw firstFailure?.status === "rejected"
|
||||||
? firstFailure.reason
|
? firstFailure.reason
|
||||||
@@ -152,27 +194,73 @@ export async function loadAvatarManifestSources(
|
|||||||
console.warn(`Optional playable-character pack could not be loaded: ${urls[index]}`, result.reason);
|
console.warn(`Optional playable-character pack could not be loaded: ${urls[index]}`, result.reason);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return mergeAvatarManifests(manifests);
|
return mergeAvatarManifests(sources);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadAvatarManifest(): Promise<AvatarManifest> {
|
export async function loadAvatarManifest(): Promise<AvatarManifest> {
|
||||||
if (!manifestPromise) {
|
const urls = availableAvatarManifestUrls();
|
||||||
manifestPromise = loadAvatarManifestSources(
|
if (!urls.length) throw new Error("No optional playable-character packs are installed");
|
||||||
AVATAR_MANIFEST_URLS,
|
const key = urls.join("|");
|
||||||
|
if (manifestRequest?.key !== key) {
|
||||||
|
const promise = loadAvatarManifestSources(
|
||||||
|
urls,
|
||||||
(url) => fetch(resolveContentUrl(url), { cache: "force-cache" }),
|
(url) => fetch(resolveContentUrl(url), { cache: "force-cache" }),
|
||||||
)
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
manifestPromise = null;
|
if (manifestRequest?.promise === promise) manifestRequest = null;
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
manifestRequest = { key, promise };
|
||||||
}
|
}
|
||||||
return manifestPromise;
|
return manifestRequest.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstOrEmpty<T>(values: readonly T[]): readonly T[] {
|
||||||
|
return values.length ? [values[0]] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function basicHumanEntry(entry: AvatarManifestEntry): AvatarManifestEntry {
|
||||||
|
const skin = entry.skinOptions?.[0];
|
||||||
|
const face = entry.faceOptions?.[0];
|
||||||
|
const hair = entry.hairOptions?.[0];
|
||||||
|
const feature = entry.featureOptions?.[0];
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
contentPackId: BASIC_CHARACTER_CONTENT_PACK_ID,
|
||||||
|
counts: { skinColor: 1, face: 1, hairStyle: 1, hairColor: 1, feature: 1 },
|
||||||
|
dbcIndices: {
|
||||||
|
skinColor: firstOrEmpty(entry.dbcIndices?.skinColor ?? []),
|
||||||
|
face: firstOrEmpty(entry.dbcIndices?.face ?? []),
|
||||||
|
hairStyle: firstOrEmpty(entry.dbcIndices?.hairStyle ?? []),
|
||||||
|
hairColor: firstOrEmpty(entry.dbcIndices?.hairColor ?? []),
|
||||||
|
feature: firstOrEmpty(entry.dbcIndices?.feature ?? []),
|
||||||
|
},
|
||||||
|
skinOptions: skin ? [skin] : [],
|
||||||
|
faceOptions: face ? [{ ...face, bySkin: firstOrEmpty(face.bySkin) }] : [],
|
||||||
|
hairOptions: hair ? [{ ...hair, byColor: firstOrEmpty(hair.byColor) }] : [],
|
||||||
|
featureOptions: feature ? [{ ...feature, byColor: firstOrEmpty(feature.byColor) }] : [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAvatarEntry(
|
export function getAvatarEntry(
|
||||||
manifest: AvatarManifest,
|
manifest: AvatarManifest,
|
||||||
raceId: RaceId,
|
raceId: RaceId,
|
||||||
gender: GenderId,
|
gender: GenderId,
|
||||||
|
isUsable: (packId: string) => boolean = contentPackIsUsable,
|
||||||
): AvatarManifestEntry | null {
|
): AvatarManifestEntry | null {
|
||||||
return manifest.entries[avatarManifestKey(raceId, gender)] ?? null;
|
const requested = manifest.entries[avatarManifestKey(raceId, gender)] ?? null;
|
||||||
|
if (requested) {
|
||||||
|
const requestedPackUsable = !requested.contentPackId || isUsable(requested.contentPackId);
|
||||||
|
if (requested.raceId === "human") {
|
||||||
|
if (requestedPackUsable && isUsable(WOW_CHARACTER_CONTENT_PACK_ID)) return requested;
|
||||||
|
} else if (requestedPackUsable) {
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const basicHuman = manifest.entries[avatarManifestKey("human", gender)]
|
||||||
|
?? manifest.entries[avatarManifestKey("human", "male")]
|
||||||
|
?? null;
|
||||||
|
return basicHuman && (!basicHuman.contentPackId || isUsable(basicHuman.contentPackId))
|
||||||
|
? basicHumanEntry(basicHuman)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { BROWSER_CONTENT_CACHE_NAME } from "./contentManager";
|
||||||
|
|
||||||
|
const workerSource = readFileSync(
|
||||||
|
new URL("../../public/content-cache-sw.js", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("browser content cache worker", () => {
|
||||||
|
it("serves only cached same-origin asset requests from the active content cache", () => {
|
||||||
|
expect(workerSource).toContain(`const CONTENT_CACHE_NAME = "${BROWSER_CONTENT_CACHE_NAME}";`);
|
||||||
|
expect(workerSource).toContain('request.method !== "GET"');
|
||||||
|
expect(workerSource).toContain("url.origin !== self.location.origin");
|
||||||
|
expect(workerSource).toContain("MANAGED_ASSET_PREFIXES.some");
|
||||||
|
expect(workerSource).toContain('"/assets/game/"');
|
||||||
|
expect(workerSource).toContain("if (cached) return cached");
|
||||||
|
expect(workerSource).toContain("BUNDLED_ASSET_PATHS.has(url.pathname)");
|
||||||
|
expect(workerSource).toContain("BUNDLED_ASSET_PREFIXES.some");
|
||||||
|
expect(workerSource).not.toContain('"/assets/characters/wow335a-v1/textures/character/human/",');
|
||||||
|
expect(workerSource).toContain('status: 404');
|
||||||
|
expect(workerSource).not.toContain("cached ?? fetch(request)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { Capacitor } from "@capacitor/core";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
BROWSER_CONTENT_CACHE_NAME,
|
||||||
|
checkForContentUpdates,
|
||||||
|
contentPackHasUpdate,
|
||||||
|
contentPackIsBundled,
|
||||||
|
contentPackIsInstalled,
|
||||||
|
contentPackIsUsable,
|
||||||
|
getContentManagerSnapshot,
|
||||||
|
installContentPack,
|
||||||
|
removeContentPack,
|
||||||
|
} from "./contentManager";
|
||||||
|
import type { ContentManifest } from "./contentManifest";
|
||||||
|
|
||||||
|
class MemoryCache {
|
||||||
|
readonly responses = new Map<string, Response>();
|
||||||
|
|
||||||
|
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||||
|
return this.responses.get(this.key(request))?.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||||
|
this.responses.set(this.key(request), response.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(request: RequestInfo | URL): Promise<boolean> {
|
||||||
|
return this.responses.delete(this.key(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
private key(request: RequestInfo | URL): string {
|
||||||
|
if (request instanceof Request) return request.url;
|
||||||
|
return new URL(String(request), "http://localhost").toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryCacheStorage {
|
||||||
|
readonly stores = new Map<string, MemoryCache>();
|
||||||
|
|
||||||
|
async open(name: string): Promise<Cache> {
|
||||||
|
const cache = this.stores.get(name) ?? new MemoryCache();
|
||||||
|
this.stores.set(name, cache);
|
||||||
|
return cache as unknown as Cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(name: string): Promise<boolean> {
|
||||||
|
return this.stores.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(bytes: Uint8Array): string {
|
||||||
|
return createHash("sha256").update(bytes).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentManifest(version: string, objectName: string, bytes: Uint8Array): ContentManifest {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
version,
|
||||||
|
createdAt: "2026-09-04T00:00:00.000Z",
|
||||||
|
minimumAppVersion: "0.1.0",
|
||||||
|
packs: [{
|
||||||
|
id: "characters:test",
|
||||||
|
label: "Test characters",
|
||||||
|
bytes: bytes.byteLength,
|
||||||
|
fileCount: 1,
|
||||||
|
bundledOnAndroid: false,
|
||||||
|
dependencies: [],
|
||||||
|
}],
|
||||||
|
files: {
|
||||||
|
"/assets/characters/test.glb": {
|
||||||
|
packId: "characters:test",
|
||||||
|
url: `/content/objects/${objectName}`,
|
||||||
|
sha256: sha256(bytes),
|
||||||
|
bytes: bytes.byteLength,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("browser content packs", () => {
|
||||||
|
it("downloads, updates, and removes verified optional content", async () => {
|
||||||
|
const firstBytes = new TextEncoder().encode("first-model");
|
||||||
|
const updatedBytes = new TextEncoder().encode("updated-model");
|
||||||
|
let manifest = contentManifest("content-one", "first.glb", firstBytes);
|
||||||
|
let corruptUpdate = false;
|
||||||
|
const storage = new MemoryCacheStorage();
|
||||||
|
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
|
||||||
|
vi.stubGlobal("caches", storage as unknown as CacheStorage);
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.endsWith("/content/manifest.json")) return Response.json(manifest);
|
||||||
|
if (url.endsWith("/first.glb")) return new Response(firstBytes);
|
||||||
|
if (url.endsWith("/updated.glb")) {
|
||||||
|
return new Response(corruptUpdate ? new Uint8Array(updatedBytes.byteLength).fill(7) : updatedBytes);
|
||||||
|
}
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
}));
|
||||||
|
|
||||||
|
await checkForContentUpdates();
|
||||||
|
expect(contentPackIsInstalled("characters:test")).toBe(false);
|
||||||
|
expect(contentPackIsUsable("characters:test")).toBe(false);
|
||||||
|
expect(contentPackHasUpdate("characters:test")).toBe(false);
|
||||||
|
|
||||||
|
await installContentPack("characters:test");
|
||||||
|
expect(contentPackIsInstalled("characters:test")).toBe(true);
|
||||||
|
expect(contentPackIsUsable("characters:test")).toBe(true);
|
||||||
|
expect(contentPackHasUpdate("characters:test")).toBe(false);
|
||||||
|
expect(getContentManagerSnapshot()).toMatchObject({
|
||||||
|
installingPackId: null,
|
||||||
|
downloadedBytes: firstBytes.byteLength,
|
||||||
|
totalBytes: firstBytes.byteLength,
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
const active = storage.stores.get(BROWSER_CONTENT_CACHE_NAME);
|
||||||
|
expect(await (await active?.match("http://localhost/assets/characters/test.glb"))?.text()).toBe("first-model");
|
||||||
|
|
||||||
|
manifest = contentManifest("content-two", "updated.glb", updatedBytes);
|
||||||
|
await checkForContentUpdates();
|
||||||
|
expect(contentPackHasUpdate("characters:test")).toBe(true);
|
||||||
|
|
||||||
|
corruptUpdate = true;
|
||||||
|
await expect(installContentPack("characters:test")).rejects.toThrow(/verification/i);
|
||||||
|
expect(await (await active?.match("http://localhost/assets/characters/test.glb"))?.text()).toBe("first-model");
|
||||||
|
|
||||||
|
corruptUpdate = false;
|
||||||
|
await installContentPack("characters:test");
|
||||||
|
expect(contentPackHasUpdate("characters:test")).toBe(false);
|
||||||
|
expect(await (await active?.match("http://localhost/assets/characters/test.glb"))?.text()).toBe("updated-model");
|
||||||
|
|
||||||
|
await removeContentPack("characters:test");
|
||||||
|
expect(contentPackIsInstalled("characters:test")).toBe(false);
|
||||||
|
expect(contentPackIsUsable("characters:test")).toBe(false);
|
||||||
|
expect(await active?.match("http://localhost/assets/characters/test.glb")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps default browser content installed and non-removable", async () => {
|
||||||
|
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
|
||||||
|
|
||||||
|
expect(contentPackIsBundled("core:ui")).toBe(true);
|
||||||
|
expect(contentPackIsUsable("core:ui")).toBe(true);
|
||||||
|
await expect(removeContentPack("core:ui")).resolves.toBeUndefined();
|
||||||
|
expect(contentPackIsUsable("core:ui")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
+194
-24
@@ -11,7 +11,23 @@ import { NativeContentStorage } from "./nativeContentStorage";
|
|||||||
|
|
||||||
const CONTENT_STATE_KEY = "healer-man.content-state.v1";
|
const CONTENT_STATE_KEY = "healer-man.content-state.v1";
|
||||||
const PRODUCTION_ORIGIN = "https://iwanttoheal.phenomrom.com";
|
const PRODUCTION_ORIGIN = "https://iwanttoheal.phenomrom.com";
|
||||||
const STATIC_ANDROID_BUNDLED_PACKS = new Set(["core:ui", "core:equipment", "dungeon:wailing-caverns"]);
|
const STATIC_ANDROID_BUNDLED_PACKS = new Set([
|
||||||
|
"core:ui",
|
||||||
|
"core:equipment",
|
||||||
|
"core:character-basic",
|
||||||
|
"core:runtime",
|
||||||
|
"dungeon:wailing-caverns",
|
||||||
|
]);
|
||||||
|
const STATIC_BROWSER_BUNDLED_PACKS = new Set([
|
||||||
|
"core:ui",
|
||||||
|
"core:equipment",
|
||||||
|
"core:character-basic",
|
||||||
|
"core:runtime",
|
||||||
|
"dungeon:wailing-caverns",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const BROWSER_CONTENT_CACHE_NAME = "healer-man-content-v1";
|
||||||
|
export const BROWSER_CONTENT_WORKER_URL = "/content-cache-sw.js";
|
||||||
|
|
||||||
export interface InstalledContentFile {
|
export interface InstalledContentFile {
|
||||||
readonly packId: string;
|
readonly packId: string;
|
||||||
@@ -29,6 +45,7 @@ interface PersistedContentState {
|
|||||||
|
|
||||||
export interface ContentManagerSnapshot {
|
export interface ContentManagerSnapshot {
|
||||||
readonly revision: number;
|
readonly revision: number;
|
||||||
|
readonly installedRevision: number;
|
||||||
readonly checking: boolean;
|
readonly checking: boolean;
|
||||||
readonly manifest: ContentManifest | null;
|
readonly manifest: ContentManifest | null;
|
||||||
readonly bundledManifest: ContentManifest | null;
|
readonly bundledManifest: ContentManifest | null;
|
||||||
@@ -42,6 +59,7 @@ const EMPTY_STATE: PersistedContentState = { version: 1, packVersions: {}, files
|
|||||||
let installedState = readInstalledState();
|
let installedState = readInstalledState();
|
||||||
let snapshot: ContentManagerSnapshot = {
|
let snapshot: ContentManagerSnapshot = {
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
installedRevision: 0,
|
||||||
checking: false,
|
checking: false,
|
||||||
manifest: null,
|
manifest: null,
|
||||||
bundledManifest: null,
|
bundledManifest: null,
|
||||||
@@ -87,13 +105,45 @@ function absoluteContentUrl(value: string): string {
|
|||||||
return new URL(value, `${contentOrigin() || "http://localhost"}/`).toString();
|
return new URL(value, `${contentOrigin() || "http://localhost"}/`).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function browserCacheRequest(logicalPath: string): Request {
|
||||||
|
const appOrigin = typeof window === "undefined" ? "http://localhost" : window.location.origin;
|
||||||
|
return new Request(new URL(logicalPath, `${appOrigin}/`).toString(), { method: "GET" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserCacheStorage(): CacheStorage | null {
|
||||||
|
return typeof caches === "undefined" ? null : caches;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerBrowserContentWorker(): Promise<void> {
|
||||||
|
if (Capacitor.isNativePlatform()
|
||||||
|
|| import.meta.env.DEV
|
||||||
|
|| typeof navigator === "undefined"
|
||||||
|
|| !("serviceWorker" in navigator)) return;
|
||||||
|
try {
|
||||||
|
await navigator.serviceWorker.register(BROWSER_CONTENT_WORKER_URL, {
|
||||||
|
scope: "/",
|
||||||
|
updateViaCache: "none",
|
||||||
|
});
|
||||||
|
await navigator.serviceWorker.ready;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Browser content cache worker could not be registered.", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function extensionForEntry(entry: ContentManifestFile): string {
|
function extensionForEntry(entry: ContentManifestFile): string {
|
||||||
const match = /(?:\.[A-Za-z0-9]+)(?:\?.*)?$/.exec(entry.url);
|
const match = /(?:\.[A-Za-z0-9]+)(?:\?.*)?$/.exec(entry.url);
|
||||||
return match?.[0].split("?")[0].toLowerCase() ?? ".bin";
|
return match?.[0].split("?")[0].toLowerCase() ?? ".bin";
|
||||||
}
|
}
|
||||||
|
|
||||||
function bundledFiles(): Readonly<Record<string, { readonly sha256: string }>> {
|
function bundledFiles(): Readonly<Record<string, { readonly sha256: string }>> {
|
||||||
return snapshot.bundledManifest?.files ?? {};
|
if (snapshot.bundledManifest) return snapshot.bundledManifest.files;
|
||||||
|
if (!snapshot.manifest) return {};
|
||||||
|
const bundledPacks = Capacitor.isNativePlatform()
|
||||||
|
? STATIC_ANDROID_BUNDLED_PACKS
|
||||||
|
: STATIC_BROWSER_BUNDLED_PACKS;
|
||||||
|
return Object.fromEntries(Object.entries(snapshot.manifest.files).filter(([, entry]) => (
|
||||||
|
bundledPacks.has(entry.packId)
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeFileIdentities(): Readonly<Record<string, { readonly sha256: string }>> {
|
function activeFileIdentities(): Readonly<Record<string, { readonly sha256: string }>> {
|
||||||
@@ -117,21 +167,31 @@ export function resolveContentUrl(logicalUrl: string): string {
|
|||||||
return installed?.fileUri ? Capacitor.convertFileSrc(installed.fileUri) : logicalUrl;
|
return installed?.fileUri ? Capacitor.convertFileSrc(installed.fileUri) : logicalUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function contentPackIsBundled(packId: string): boolean {
|
||||||
|
if (!Capacitor.isNativePlatform()) return STATIC_BROWSER_BUNDLED_PACKS.has(packId);
|
||||||
|
return STATIC_ANDROID_BUNDLED_PACKS.has(packId)
|
||||||
|
|| Boolean(snapshot.bundledManifest?.packs.some((pack) => pack.id === packId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contentPackIsInstalled(packId: string): boolean {
|
||||||
|
return contentPackIsBundled(packId) || Boolean(installedState.packVersions[packId]);
|
||||||
|
}
|
||||||
|
|
||||||
export function contentPackIsUsable(packId: string): boolean {
|
export function contentPackIsUsable(packId: string): boolean {
|
||||||
if (!Capacitor.isNativePlatform()) return true;
|
if (contentPackIsBundled(packId)) return true;
|
||||||
const installed = STATIC_ANDROID_BUNDLED_PACKS.has(packId)
|
if (!installedState.packVersions[packId]) return false;
|
||||||
|| snapshot.bundledManifest?.packs.some((pack) => pack.id === packId)
|
|
||||||
|| Boolean(installedState.packVersions[packId]);
|
|
||||||
if (!installed) return false;
|
|
||||||
const pack = snapshot.manifest?.packs.find((candidate) => candidate.id === packId);
|
const pack = snapshot.manifest?.packs.find((candidate) => candidate.id === packId);
|
||||||
return pack?.dependencies.every((dependency) => contentPackIsUsable(dependency)) ?? true;
|
return pack?.dependencies.every((dependency) => contentPackIsUsable(dependency)) ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function contentPackHasUpdate(packId: string): boolean {
|
export function contentPackHasUpdate(packId: string): boolean {
|
||||||
if (!snapshot.manifest) return false;
|
if (!snapshot.manifest || !contentPackIsInstalled(packId)) return false;
|
||||||
const pack = snapshot.manifest.packs.find((candidate) => candidate.id === packId);
|
const pack = snapshot.manifest.packs.find((candidate) => candidate.id === packId);
|
||||||
return changedFilesForPack(snapshot.manifest, packId, activeFileIdentities()).length > 0
|
return changedFilesForPack(snapshot.manifest, packId, activeFileIdentities()).length > 0
|
||||||
|| Boolean(pack?.dependencies.some((dependency) => contentPackHasUpdate(dependency)));
|
|| Boolean(!contentPackIsBundled(packId)
|
||||||
|
&& pack?.dependencies.some((dependency) => (
|
||||||
|
!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkForContentUpdates(): Promise<ContentManifest | null> {
|
export async function checkForContentUpdates(): Promise<ContentManifest | null> {
|
||||||
@@ -149,7 +209,7 @@ export async function checkForContentUpdates(): Promise<ContentManifest | null>
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadBundledManifest(): Promise<void> {
|
async function loadBundledManifest(): Promise<void> {
|
||||||
if (!Capacitor.isNativePlatform()) return;
|
if (!Capacitor.isNativePlatform() && import.meta.env.VITE_CONTENT_MODE !== "downloadable") return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/content/bootstrap-manifest.json", { cache: "force-cache" });
|
const response = await fetch("/content/bootstrap-manifest.json", { cache: "force-cache" });
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
@@ -162,13 +222,102 @@ async function loadBundledManifest(): Promise<void> {
|
|||||||
export function initializeContentSystem(): Promise<void> {
|
export function initializeContentSystem(): Promise<void> {
|
||||||
if (!initialization) {
|
if (!initialization) {
|
||||||
initialization = (async () => {
|
initialization = (async () => {
|
||||||
await loadBundledManifest();
|
await Promise.all([loadBundledManifest(), registerBrowserContentWorker()]);
|
||||||
if (typeof navigator === "undefined" || navigator.onLine !== false) void checkForContentUpdates();
|
if (typeof navigator === "undefined" || navigator.onLine !== false) void checkForContentUpdates();
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
return initialization;
|
return initialization;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sha256Bytes(bytes: ArrayBuffer): Promise<string> {
|
||||||
|
if (!globalThis.crypto?.subtle) {
|
||||||
|
throw new Error("This browser cannot verify downloaded content.");
|
||||||
|
}
|
||||||
|
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
||||||
|
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installBrowserContentPack(
|
||||||
|
packId: string,
|
||||||
|
manifest: ContentManifest,
|
||||||
|
changes: ReturnType<typeof changedFilesForPack>,
|
||||||
|
): Promise<void> {
|
||||||
|
const storage = browserCacheStorage();
|
||||||
|
if (!storage) throw new Error("Offline content storage is unavailable in this browser.");
|
||||||
|
|
||||||
|
const totalBytes = changes.reduce((total, [, entry]) => total + entry.bytes, 0);
|
||||||
|
const stagingName = `${BROWSER_CONTENT_CACHE_NAME}-staging-${encodeURIComponent(packId)}`;
|
||||||
|
await storage.delete(stagingName);
|
||||||
|
const staging = await storage.open(stagingName);
|
||||||
|
publish({ installingPackId: packId, downloadedBytes: 0, totalBytes, error: "" });
|
||||||
|
|
||||||
|
let completedBytes = 0;
|
||||||
|
try {
|
||||||
|
for (const [logicalPath, entry] of changes) {
|
||||||
|
const response = await fetch(absoluteContentUrl(entry.url), { cache: "no-store" });
|
||||||
|
if (!response.ok) throw new Error(`Content request failed (${response.status}): ${logicalPath}`);
|
||||||
|
const bytes = await response.arrayBuffer();
|
||||||
|
if (bytes.byteLength !== entry.bytes) {
|
||||||
|
throw new Error(`Downloaded content has the wrong size: ${logicalPath}`);
|
||||||
|
}
|
||||||
|
if (await sha256Bytes(bytes) !== entry.sha256) {
|
||||||
|
throw new Error(`Downloaded content failed verification: ${logicalPath}`);
|
||||||
|
}
|
||||||
|
const headers = new Headers();
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
if (contentType) headers.set("content-type", contentType);
|
||||||
|
await staging.put(browserCacheRequest(logicalPath), new Response(bytes, {
|
||||||
|
headers,
|
||||||
|
status: 200,
|
||||||
|
}));
|
||||||
|
completedBytes += entry.bytes;
|
||||||
|
publish({ downloadedBytes: completedBytes });
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = await storage.open(BROWSER_CONTENT_CACHE_NAME);
|
||||||
|
for (const [logicalPath] of changes) {
|
||||||
|
const cached = await staging.match(browserCacheRequest(logicalPath));
|
||||||
|
if (!cached) throw new Error(`Verified content was not staged: ${logicalPath}`);
|
||||||
|
await active.put(browserCacheRequest(logicalPath), cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingFiles = { ...installedState.files };
|
||||||
|
const latestPaths = new Set(filesForContentPack(manifest, packId).map(([logicalPath]) => logicalPath));
|
||||||
|
for (const [logicalPath, installed] of Object.entries(pendingFiles)) {
|
||||||
|
if (installed.packId !== packId || latestPaths.has(logicalPath)) continue;
|
||||||
|
await active.delete(browserCacheRequest(logicalPath));
|
||||||
|
delete pendingFiles[logicalPath];
|
||||||
|
}
|
||||||
|
for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) {
|
||||||
|
pendingFiles[logicalPath] = {
|
||||||
|
packId,
|
||||||
|
sha256: entry.sha256,
|
||||||
|
bytes: entry.bytes,
|
||||||
|
extension: extensionForEntry(entry),
|
||||||
|
fileUri: absoluteContentUrl(logicalPath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
installedState = {
|
||||||
|
version: 1,
|
||||||
|
files: pendingFiles,
|
||||||
|
packVersions: { ...installedState.packVersions, [packId]: manifest.version },
|
||||||
|
};
|
||||||
|
persistInstalledState();
|
||||||
|
publish({
|
||||||
|
installingPackId: null,
|
||||||
|
downloadedBytes: totalBytes,
|
||||||
|
totalBytes,
|
||||||
|
error: "",
|
||||||
|
installedRevision: snapshot.installedRevision + 1,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
publish({ installingPackId: null, error: error instanceof Error ? error.message : "Content download failed." });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await storage.delete(stagingName).catch(() => false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function installContentPack(packId: string): Promise<void> {
|
export async function installContentPack(packId: string): Promise<void> {
|
||||||
const manifest = snapshot.manifest ?? await checkForContentUpdates();
|
const manifest = snapshot.manifest ?? await checkForContentUpdates();
|
||||||
if (!manifest) throw new Error(snapshot.error || "Content server is unavailable.");
|
if (!manifest) throw new Error(snapshot.error || "Content server is unavailable.");
|
||||||
@@ -177,15 +326,20 @@ export async function installContentPack(packId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
const pack = manifest.packs.find((candidate) => candidate.id === packId);
|
const pack = manifest.packs.find((candidate) => candidate.id === packId);
|
||||||
if (!pack) throw new Error(`Unknown content pack: ${packId}`);
|
if (!pack) throw new Error(`Unknown content pack: ${packId}`);
|
||||||
if (!Capacitor.isNativePlatform()) return;
|
|
||||||
|
|
||||||
for (const dependency of pack.dependencies) {
|
if (!contentPackIsBundled(packId)) {
|
||||||
if (!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)) {
|
for (const dependency of pack.dependencies) {
|
||||||
await installContentPack(dependency);
|
if (!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)) {
|
||||||
|
await installContentPack(dependency);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const changes = changedFilesForPack(manifest, packId, activeFileIdentities());
|
const changes = changedFilesForPack(manifest, packId, activeFileIdentities());
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
await installBrowserContentPack(packId, manifest, changes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const totalBytes = changes.reduce((total, [, entry]) => total + entry.bytes, 0);
|
const totalBytes = changes.reduce((total, [, entry]) => total + entry.bytes, 0);
|
||||||
const storage = await NativeContentStorage.getFreeBytes();
|
const storage = await NativeContentStorage.getFreeBytes();
|
||||||
const safetyReserve = 128 * 1024 * 1024;
|
const safetyReserve = 128 * 1024 * 1024;
|
||||||
@@ -232,7 +386,13 @@ export async function installContentPack(packId: string): Promise<void> {
|
|||||||
packVersions: { ...installedState.packVersions, [packId]: manifest.version },
|
packVersions: { ...installedState.packVersions, [packId]: manifest.version },
|
||||||
};
|
};
|
||||||
persistInstalledState();
|
persistInstalledState();
|
||||||
publish({ installingPackId: null, downloadedBytes: totalBytes, totalBytes, error: "" });
|
publish({
|
||||||
|
installingPackId: null,
|
||||||
|
downloadedBytes: totalBytes,
|
||||||
|
totalBytes,
|
||||||
|
error: "",
|
||||||
|
installedRevision: snapshot.installedRevision + 1,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
publish({ installingPackId: null, error: error instanceof Error ? error.message : "Content download failed." });
|
publish({ installingPackId: null, error: error instanceof Error ? error.message : "Content download failed." });
|
||||||
throw error;
|
throw error;
|
||||||
@@ -242,19 +402,29 @@ export async function installContentPack(packId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function removeContentPack(packId: string): Promise<void> {
|
export async function removeContentPack(packId: string): Promise<void> {
|
||||||
if (!Capacitor.isNativePlatform()) return;
|
if (contentPackIsBundled(packId)) return;
|
||||||
const remainingFiles = { ...installedState.files };
|
const remainingFiles = { ...installedState.files };
|
||||||
const targetFiles = Object.entries(remainingFiles).filter(([, entry]) => entry.packId === packId);
|
const targetFiles = Object.entries(remainingFiles).filter(([, entry]) => entry.packId === packId);
|
||||||
for (const [logicalPath, entry] of targetFiles) {
|
if (Capacitor.isNativePlatform()) {
|
||||||
const shared = Object.entries(remainingFiles).some(([otherPath, other]) => (
|
for (const [logicalPath, entry] of targetFiles) {
|
||||||
otherPath !== logicalPath && other.sha256 === entry.sha256 && other.extension === entry.extension && other.packId !== packId
|
const shared = Object.entries(remainingFiles).some(([otherPath, other]) => (
|
||||||
));
|
otherPath !== logicalPath && other.sha256 === entry.sha256 && other.extension === entry.extension && other.packId !== packId
|
||||||
if (!shared) await NativeContentStorage.remove({ sha256: entry.sha256, extension: entry.extension });
|
));
|
||||||
delete remainingFiles[logicalPath];
|
if (!shared) await NativeContentStorage.remove({ sha256: entry.sha256, extension: entry.extension });
|
||||||
|
delete remainingFiles[logicalPath];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const storage = browserCacheStorage();
|
||||||
|
if (!storage) throw new Error("Offline content storage is unavailable in this browser.");
|
||||||
|
const active = await storage.open(BROWSER_CONTENT_CACHE_NAME);
|
||||||
|
for (const [logicalPath] of targetFiles) {
|
||||||
|
await active.delete(browserCacheRequest(logicalPath));
|
||||||
|
delete remainingFiles[logicalPath];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const packVersions = { ...installedState.packVersions };
|
const packVersions = { ...installedState.packVersions };
|
||||||
delete packVersions[packId];
|
delete packVersions[packId];
|
||||||
installedState = { version: 1, files: remainingFiles, packVersions };
|
installedState = { version: 1, files: remainingFiles, packVersions };
|
||||||
persistInstalledState();
|
persistInstalledState();
|
||||||
publish({ error: "" });
|
publish({ error: "", installedRevision: snapshot.installedRevision + 1 });
|
||||||
}
|
}
|
||||||
@@ -21,19 +21,20 @@ import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
|
|||||||
export function CombatBridge() {
|
export function CombatBridge() {
|
||||||
const character = useShellStore((state) => state.activeCharacter);
|
const character = useShellStore((state) => state.activeCharacter);
|
||||||
const gameMode = useGameStore((state) => state.gameMode);
|
const gameMode = useGameStore((state) => state.gameMode);
|
||||||
|
const activeRole = useShellStore((state) => state.activeDungeonRole);
|
||||||
const resetRevision = useGameStore((state) => state.resetRevision);
|
const resetRevision = useGameStore((state) => state.resetRevision);
|
||||||
const mountedResetRevision = useRef(resetRevision);
|
const mountedResetRevision = useRef(resetRevision);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!character) return;
|
if (!character) return;
|
||||||
useCombatStore.getState().initializeCharacter(character);
|
useCombatStore.getState().initializeCharacter({ ...character, role: activeRole });
|
||||||
useCombatStore.getState().synchronizeActors();
|
useCombatStore.getState().synchronizeActors();
|
||||||
if (gameMode === "manastorm") {
|
if (gameMode === "manastorm") {
|
||||||
activateManastormActiveSpellLoadout(
|
activateManastormActiveSpellLoadout(
|
||||||
useManastormStore.getState().activeSpellLoadout,
|
useManastormStore.getState().activeSpellLoadout,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [character?.id, gameMode]);
|
}, [activeRole, character?.id, gameMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mountedResetRevision.current === resetRevision) return;
|
if (mountedResetRevision.current === resetRevision) return;
|
||||||
@@ -65,6 +66,7 @@ export function CombatBridge() {
|
|||||||
// also guarantees a fast return to Character Select cannot outrun a
|
// also guarantees a fast return to Character Select cannot outrun a
|
||||||
// deferred save and lose the last level or talent point.
|
// deferred save and lose the last level or talent point.
|
||||||
shell.saveActiveProgression(state.level, state.xp, state.talentRanks);
|
shell.saveActiveProgression(state.level, state.xp, state.talentRanks);
|
||||||
|
if (state.level !== previousState.level) useCombatStore.getState().refreshResolvedGear();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.actionBindings !== previousState.actionBindings) {
|
if (state.actionBindings !== previousState.actionBindings) {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import type {
|
|||||||
import { useShellStore } from "../app/shellStore";
|
import { useShellStore } from "../app/shellStore";
|
||||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
import { useCombatStore } from "./combatStore";
|
import { useCombatStore } from "./combatStore";
|
||||||
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
|
import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
|
||||||
|
import { resolvedEquipmentItems } from "./heirloomEquipment";
|
||||||
import { getMobRuntimeTransform } from "./mobRuntimeRegistry";
|
import { getMobRuntimeTransform } from "./mobRuntimeRegistry";
|
||||||
import {
|
import {
|
||||||
configureOnlineSessionRuntime,
|
configureOnlineSessionRuntime,
|
||||||
@@ -266,10 +267,17 @@ export function OnlineSessionBridge() {
|
|||||||
selectedTargetId: combat.selectedTargetId,
|
selectedTargetId: combat.selectedTargetId,
|
||||||
activeCast: combat.activeCast,
|
activeCast: combat.activeCast,
|
||||||
animationEvent: combat.playerAnimationEvent,
|
animationEvent: combat.playerAnimationEvent,
|
||||||
equipment: equipmentItemsForOwner(
|
equipment: resolvedEquipmentItems(
|
||||||
combat.equipment,
|
combat.equipment,
|
||||||
combat.inventory,
|
combat.inventory,
|
||||||
PLAYER_EQUIPMENT_OWNER_ID,
|
{
|
||||||
|
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||||
|
ownerKind: "player",
|
||||||
|
classId: combat.classId,
|
||||||
|
specializationId: combat.specializationId,
|
||||||
|
role: combat.gearRole,
|
||||||
|
level: combat.level,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(publishWorld ? { world: sharedWorld() } : {}),
|
...(publishWorld ? { world: sharedWorld() } : {}),
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ export function PartyBridge() {
|
|||||||
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
|
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
usePartyStore.getState().syncLevel(level);
|
const party = usePartyStore.getState();
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
party.syncLevel(level);
|
||||||
|
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||||
}, [level]);
|
}, [level]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -48,6 +51,7 @@ export function PartyBridge() {
|
|||||||
mountedResetRevision.current = resetRevision;
|
mountedResetRevision.current = resetRevision;
|
||||||
const combat = useCombatStore.getState();
|
const combat = useCombatStore.getState();
|
||||||
usePartyStore.getState().resetCombat(combat.level, combat.health);
|
usePartyStore.getState().resetCombat(combat.level, combat.health);
|
||||||
|
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||||
resetPartyRuntimeClock();
|
resetPartyRuntimeClock();
|
||||||
}, [resetRevision]);
|
}, [resetRevision]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const combatMedia = vi.hoisted(() => ({ ready: false }));
|
||||||
|
|
||||||
|
vi.mock("./combatMediaPack", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("./combatMediaPack")>()),
|
||||||
|
combatMediaPackReady: () => combatMedia.ready,
|
||||||
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||||
type CombatPresentationEvent,
|
type CombatPresentationEvent,
|
||||||
} from "./combatPresentation";
|
} from "./combatPresentation";
|
||||||
import {
|
import {
|
||||||
combatAudioOutputEnabled,
|
combatAudioOutputEnabled,
|
||||||
|
authenticCombatAudioEnabled,
|
||||||
|
combatMediaPreloadEnabled,
|
||||||
configureCombatAudio,
|
configureCombatAudio,
|
||||||
masterAudioOutputEnabled,
|
masterAudioOutputEnabled,
|
||||||
playCombatAudio,
|
playCombatAudio,
|
||||||
@@ -28,7 +38,10 @@ const fixtureEvent: CombatPresentationEvent = {
|
|||||||
targetPosition: [1, 0, 0],
|
targetPosition: [1, 0, 0],
|
||||||
};
|
};
|
||||||
|
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => {
|
||||||
|
combatMedia.ready = false;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
describe("combat audio output policy", () => {
|
describe("combat audio output policy", () => {
|
||||||
it("does not create or fetch audio while the master volume is disabled", async () => {
|
it("does not create or fetch audio while the master volume is disabled", async () => {
|
||||||
@@ -50,6 +63,30 @@ describe("combat audio output policy", () => {
|
|||||||
expect(masterAudioOutputEnabled(0.6)).toBe(true);
|
expect(masterAudioOutputEnabled(0.6)).toBe(true);
|
||||||
expect(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0)).toBe(false);
|
expect(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0)).toBe(false);
|
||||||
expect(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(true);
|
expect(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(true);
|
||||||
|
expect(authenticCombatAudioEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS)).toBe(false);
|
||||||
|
combatMedia.ready = true;
|
||||||
|
expect(authenticCombatAudioEnabled({
|
||||||
|
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||||
|
originalMediaEnabled: true,
|
||||||
|
})).toBe(true);
|
||||||
|
expect(combatMediaPreloadEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(false);
|
||||||
|
expect(combatMediaPreloadEnabled({
|
||||||
|
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||||
|
originalMediaEnabled: true,
|
||||||
|
}, 0.6)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fetch packaged sound effects before the player downloads them", async () => {
|
||||||
|
const AudioContextConstructor = vi.fn();
|
||||||
|
const fetch = vi.fn();
|
||||||
|
vi.stubGlobal("window", { AudioContext: AudioContextConstructor });
|
||||||
|
vi.stubGlobal("fetch", fetch);
|
||||||
|
configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
|
||||||
|
|
||||||
|
await expect(playMasterAudioCue("/assets/game/combat-effects/audio/fixture.wav")).resolves.toBeNull();
|
||||||
|
|
||||||
|
expect(AudioContextConstructor).not.toHaveBeenCalled();
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not initialize audio for a zero-volume actor group", () => {
|
it("does not initialize audio for a zero-volume actor group", () => {
|
||||||
|
|||||||
+22
-2
@@ -6,6 +6,10 @@ import {
|
|||||||
type CombatPresentationSettings,
|
type CombatPresentationSettings,
|
||||||
type CombatPresentationSchool,
|
type CombatPresentationSchool,
|
||||||
} from "./combatPresentation";
|
} from "./combatPresentation";
|
||||||
|
import {
|
||||||
|
combatMediaPackReady,
|
||||||
|
fetchOptionalMediaAsset,
|
||||||
|
} from "./combatMediaPack";
|
||||||
|
|
||||||
const MAX_COMBAT_VOICES = 32;
|
const MAX_COMBAT_VOICES = 32;
|
||||||
|
|
||||||
@@ -37,6 +41,19 @@ export function combatAudioOutputEnabled(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function authenticCombatAudioEnabled(settings: CombatPresentationSettings): boolean {
|
||||||
|
return settings.originalMediaEnabled && combatMediaPackReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function combatMediaPreloadEnabled(
|
||||||
|
settings: CombatPresentationSettings,
|
||||||
|
masterVolume: number,
|
||||||
|
): boolean {
|
||||||
|
return authenticCombatAudioEnabled(settings) && (
|
||||||
|
settings.vfxQuality !== "off" || combatAudioOutputEnabled(settings, masterVolume)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function combatAudioGroupOutputEnabled(
|
function combatAudioGroupOutputEnabled(
|
||||||
settings: CombatPresentationSettings,
|
settings: CombatPresentationSettings,
|
||||||
masterVolume: number,
|
masterVolume: number,
|
||||||
@@ -185,6 +202,7 @@ function playSynthesizedCombatCue(event: CombatPresentationEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function weightedSound(event: CombatPresentationEvent): CombatEffectSoundAsset | null {
|
function weightedSound(event: CombatPresentationEvent): CombatEffectSoundAsset | null {
|
||||||
|
if (!configuredSettings || !authenticCombatAudioEnabled(configuredSettings)) return null;
|
||||||
const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? [];
|
const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? [];
|
||||||
const playable = sounds.filter((sound) => sound.url);
|
const playable = sounds.filter((sound) => sound.url);
|
||||||
if (!playable.length) return null;
|
if (!playable.length) return null;
|
||||||
@@ -201,7 +219,7 @@ async function decodeAudio(url: string): Promise<AudioBuffer | null> {
|
|||||||
const audioContext = ensureContext();
|
const audioContext = ensureContext();
|
||||||
if (!audioContext) return null;
|
if (!audioContext) return null;
|
||||||
if (!decodedBuffers.has(url)) {
|
if (!decodedBuffers.has(url)) {
|
||||||
decodedBuffers.set(url, fetch(url)
|
decodedBuffers.set(url, fetchOptionalMediaAsset(url)
|
||||||
.then((response) => response.ok ? response.arrayBuffer() : Promise.reject(new Error(String(response.status))))
|
.then((response) => response.ok ? response.arrayBuffer() : Promise.reject(new Error(String(response.status))))
|
||||||
.then((data) => audioContext.decodeAudioData(data))
|
.then((data) => audioContext.decodeAudioData(data))
|
||||||
.catch(() => null));
|
.catch(() => null));
|
||||||
@@ -213,7 +231,9 @@ export async function playMasterAudioCue(
|
|||||||
url: string,
|
url: string,
|
||||||
volume = 1,
|
volume = 1,
|
||||||
): Promise<(() => void) | null> {
|
): Promise<(() => void) | null> {
|
||||||
if (!masterAudioOutputEnabled(configuredMasterVolume)) return null;
|
if (!configuredSettings
|
||||||
|
|| !authenticCombatAudioEnabled(configuredSettings)
|
||||||
|
|| !masterAudioOutputEnabled(configuredMasterVolume)) return null;
|
||||||
const audioContext = ensureContext();
|
const audioContext = ensureContext();
|
||||||
const buffer = await decodeAudio(url);
|
const buffer = await decodeAudio(url);
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ describe("named aura combat runtime", () => {
|
|||||||
school: "fire",
|
school: "fire",
|
||||||
interruptible: true,
|
interruptible: true,
|
||||||
targetActorId: PLAYER_AGGRO_ID,
|
targetActorId: PLAYER_AGGRO_ID,
|
||||||
|
sourcePosition: [0, 0, 0],
|
||||||
|
targetPosition: [1, 0, 0],
|
||||||
|
sourceYaw: Math.PI / 2,
|
||||||
startedAt: 1_000,
|
startedAt: 1_000,
|
||||||
completesAt: 6_000,
|
completesAt: 6_000,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const content = vi.hoisted(() => ({
|
||||||
|
usable: false,
|
||||||
|
install: vi.fn(async () => undefined),
|
||||||
|
remove: vi.fn(async () => undefined),
|
||||||
|
listener: null as (() => void) | null,
|
||||||
|
snapshot: {
|
||||||
|
downloadedBytes: 4,
|
||||||
|
totalBytes: 8,
|
||||||
|
manifest: {
|
||||||
|
packs: [{ id: "core:combat-media", fileCount: 12 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../content/contentManager", () => ({
|
||||||
|
contentPackIsUsable: () => content.usable,
|
||||||
|
getContentManagerSnapshot: () => content.snapshot,
|
||||||
|
installContentPack: content.install,
|
||||||
|
removeContentPack: content.remove,
|
||||||
|
resolveContentUrl: (url: string) => `resolved:${url}`,
|
||||||
|
subscribeContentManager: (listener: () => void) => {
|
||||||
|
content.listener = listener;
|
||||||
|
return () => { content.listener = null; };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
combatMediaPackReady,
|
||||||
|
downloadCombatMediaPack,
|
||||||
|
fetchCombatMediaAsset,
|
||||||
|
removeCombatMediaPack,
|
||||||
|
} from "./combatMediaPack";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
content.usable = false;
|
||||||
|
content.install.mockClear();
|
||||||
|
content.remove.mockClear();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("optional combat media pack", () => {
|
||||||
|
it("uses the shared verified content-pack installer only after an explicit request", async () => {
|
||||||
|
const progress = vi.fn();
|
||||||
|
await expect(downloadCombatMediaPack(progress)).resolves.toEqual({ fileCount: 12 });
|
||||||
|
expect(content.install).toHaveBeenCalledWith("core:combat-media");
|
||||||
|
expect(progress).toHaveBeenLastCalledWith({ completed: 4, total: 8 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fetch source media while its pack is offloaded", async () => {
|
||||||
|
const fetch = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetch);
|
||||||
|
|
||||||
|
expect(combatMediaPackReady()).toBe(false);
|
||||||
|
await expect(fetchCombatMediaAsset("/assets/game/combat-effects/audio/shield.wav"))
|
||||||
|
.rejects.toThrow("not installed");
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves installed media through the shared content router and removes the same pack", async () => {
|
||||||
|
content.usable = true;
|
||||||
|
const response = new Response(new Uint8Array([1, 2, 3]));
|
||||||
|
const fetch = vi.fn(async () => response);
|
||||||
|
vi.stubGlobal("fetch", fetch);
|
||||||
|
|
||||||
|
await expect(fetchCombatMediaAsset("/assets/game/combat-effects/audio/shield.wav"))
|
||||||
|
.resolves.toBe(response);
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
"resolved:/assets/game/combat-effects/audio/shield.wav",
|
||||||
|
{ cache: "no-store" },
|
||||||
|
);
|
||||||
|
|
||||||
|
await removeCombatMediaPack();
|
||||||
|
expect(content.remove).toHaveBeenCalledWith("core:combat-media");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
contentPackIsUsable,
|
||||||
|
getContentManagerSnapshot,
|
||||||
|
installContentPack,
|
||||||
|
removeContentPack,
|
||||||
|
resolveContentUrl,
|
||||||
|
subscribeContentManager,
|
||||||
|
} from "../content/contentManager";
|
||||||
|
|
||||||
|
export const COMBAT_MEDIA_MANIFEST_URL = "/assets/game/combat-effects/manifest.json";
|
||||||
|
export const COMBAT_MEDIA_CONTENT_PACK_ID = "core:combat-media";
|
||||||
|
|
||||||
|
export interface CombatMediaDownloadProgress {
|
||||||
|
readonly completed: number;
|
||||||
|
readonly total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatMediaDownloadResult {
|
||||||
|
readonly fileCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressListener = (progress: CombatMediaDownloadProgress) => void;
|
||||||
|
|
||||||
|
function isCombatMediaUrl(value: unknown): value is string {
|
||||||
|
return typeof value === "string" && value.startsWith("/assets/game/combat-effects/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Callers must enforce the player's original-media opt-in before fetching. */
|
||||||
|
export async function fetchCombatMediaAsset(url: string): Promise<Response> {
|
||||||
|
if (!isCombatMediaUrl(url)) throw new Error(`Unsupported combat media URL: ${url}`);
|
||||||
|
return fetchOptionalMediaAsset(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch source media only after its shared optional pack has been enabled. */
|
||||||
|
export async function fetchOptionalMediaAsset(url: string): Promise<Response> {
|
||||||
|
if (!combatMediaPackReady()) throw new Error("Original spell media is not installed.");
|
||||||
|
return fetch(resolveContentUrl(url), { cache: "no-store" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download and verify every file owned by the original combat-media pack. */
|
||||||
|
export async function downloadCombatMediaPack(
|
||||||
|
onProgress?: ProgressListener,
|
||||||
|
): Promise<CombatMediaDownloadResult> {
|
||||||
|
const publishProgress = () => {
|
||||||
|
const state = getContentManagerSnapshot();
|
||||||
|
onProgress?.({
|
||||||
|
completed: state.downloadedBytes,
|
||||||
|
total: Math.max(1, state.totalBytes),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const unsubscribe = subscribeContentManager(publishProgress);
|
||||||
|
try {
|
||||||
|
await installContentPack(COMBAT_MEDIA_CONTENT_PACK_ID);
|
||||||
|
publishProgress();
|
||||||
|
const pack = getContentManagerSnapshot().manifest?.packs.find((candidate) => (
|
||||||
|
candidate.id === COMBAT_MEDIA_CONTENT_PACK_ID
|
||||||
|
));
|
||||||
|
return { fileCount: pack?.fileCount ?? 0 };
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeCombatMediaPack(): Promise<void> {
|
||||||
|
await removeContentPack(COMBAT_MEDIA_CONTENT_PACK_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function combatMediaPackReady(): boolean {
|
||||||
|
return contentPackIsUsable(COMBAT_MEDIA_CONTENT_PACK_ID);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ describe("combat presentation", () => {
|
|||||||
audioEnabled: { enemy: false },
|
audioEnabled: { enemy: false },
|
||||||
audioVolume: { player: 2, party: -1, enemy: 0.35 },
|
audioVolume: { player: 2, party: -1, enemy: 0.35 },
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
|
originalMediaEnabled: false,
|
||||||
vfxMode: "generated",
|
vfxMode: "generated",
|
||||||
vfxQuality: "low",
|
vfxQuality: "low",
|
||||||
vfxEnabled: { player: false, party: true, enemy: true },
|
vfxEnabled: { player: false, party: true, enemy: true },
|
||||||
@@ -31,8 +32,13 @@ describe("combat presentation", () => {
|
|||||||
});
|
});
|
||||||
expect(normalizeCombatPresentationSettings({
|
expect(normalizeCombatPresentationSettings({
|
||||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||||
|
originalMediaEnabled: true,
|
||||||
vfxMode: "original",
|
vfxMode: "original",
|
||||||
}).vfxMode).toBe("original");
|
}).vfxMode).toBe("original");
|
||||||
|
expect(normalizeCombatPresentationSettings({
|
||||||
|
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||||
|
vfxMode: "original",
|
||||||
|
}).vfxMode).toBe("generated");
|
||||||
expect(normalizeCombatPresentationSettings({
|
expect(normalizeCombatPresentationSettings({
|
||||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||||
vfxMode: "hybrid",
|
vfxMode: "hybrid",
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||||
|
import {
|
||||||
|
COMBAT_MEDIA_MANIFEST_URL,
|
||||||
|
combatMediaPackReady,
|
||||||
|
fetchCombatMediaAsset,
|
||||||
|
} from "./combatMediaPack";
|
||||||
|
|
||||||
export type CombatActorGroup = "player" | "party" | "enemy";
|
export type CombatActorGroup = "player" | "party" | "enemy";
|
||||||
export type CombatVfxMode = "generated" | "original";
|
export type CombatVfxMode = "generated" | "original";
|
||||||
@@ -24,6 +29,7 @@ export type CombatPresentationSource = "wow335" | "ascension" | "runewaker" | "f
|
|||||||
export type CombatPresentationPosition = readonly [number, number, number];
|
export type CombatPresentationPosition = readonly [number, number, number];
|
||||||
|
|
||||||
export interface CombatPresentationSettings {
|
export interface CombatPresentationSettings {
|
||||||
|
readonly originalMediaEnabled: boolean;
|
||||||
readonly vfxMode: CombatVfxMode;
|
readonly vfxMode: CombatVfxMode;
|
||||||
readonly vfxQuality: CombatVfxQuality;
|
readonly vfxQuality: CombatVfxQuality;
|
||||||
readonly vfxEnabled: Readonly<Record<CombatActorGroup, boolean>>;
|
readonly vfxEnabled: Readonly<Record<CombatActorGroup, boolean>>;
|
||||||
@@ -34,6 +40,7 @@ export interface CombatPresentationSettings {
|
|||||||
const ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
|
const ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
|
||||||
|
|
||||||
export const DEFAULT_COMBAT_PRESENTATION_SETTINGS: CombatPresentationSettings = Object.freeze({
|
export const DEFAULT_COMBAT_PRESENTATION_SETTINGS: CombatPresentationSettings = Object.freeze({
|
||||||
|
originalMediaEnabled: false,
|
||||||
vfxMode: "generated",
|
vfxMode: "generated",
|
||||||
vfxQuality: "high",
|
vfxQuality: "high",
|
||||||
vfxEnabled: Object.freeze({ player: true, party: true, enemy: true }),
|
vfxEnabled: Object.freeze({ player: true, party: true, enemy: true }),
|
||||||
@@ -80,13 +87,20 @@ export function normalizeCombatPresentationSettings(
|
|||||||
const record = value && typeof value === "object" && !Array.isArray(value)
|
const record = value && typeof value === "object" && !Array.isArray(value)
|
||||||
? value as Partial<Record<keyof CombatPresentationSettings, unknown>>
|
? value as Partial<Record<keyof CombatPresentationSettings, unknown>>
|
||||||
: {};
|
: {};
|
||||||
const vfxMode = record.vfxMode === "generated" || record.vfxMode === "original"
|
const originalMediaEnabled = typeof record.originalMediaEnabled === "boolean"
|
||||||
|
? record.originalMediaEnabled
|
||||||
|
: fallback.originalMediaEnabled;
|
||||||
|
const requestedVfxMode = record.vfxMode === "generated" || record.vfxMode === "original"
|
||||||
? record.vfxMode
|
? record.vfxMode
|
||||||
: fallback.vfxMode;
|
: fallback.vfxMode;
|
||||||
|
const vfxMode = requestedVfxMode === "original" && !originalMediaEnabled
|
||||||
|
? "generated"
|
||||||
|
: requestedVfxMode;
|
||||||
const vfxQuality = record.vfxQuality === "off" || record.vfxQuality === "low" || record.vfxQuality === "high"
|
const vfxQuality = record.vfxQuality === "off" || record.vfxQuality === "low" || record.vfxQuality === "high"
|
||||||
? record.vfxQuality
|
? record.vfxQuality
|
||||||
: fallback.vfxQuality;
|
: fallback.vfxQuality;
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
originalMediaEnabled,
|
||||||
vfxMode,
|
vfxMode,
|
||||||
vfxQuality,
|
vfxQuality,
|
||||||
vfxEnabled: booleanGroups(record.vfxEnabled, fallback.vfxEnabled),
|
vfxEnabled: booleanGroups(record.vfxEnabled, fallback.vfxEnabled),
|
||||||
@@ -191,7 +205,6 @@ interface GeneratedCombatEffectManifest {
|
|||||||
readonly entries: readonly CombatEffectManifestEntry[];
|
readonly entries: readonly CombatEffectManifestEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMBAT_EFFECT_MANIFEST_URL = "/assets/game/combat-effects/manifest.json";
|
|
||||||
const manifestByKey = new Map<string, CombatEffectManifestEntry>();
|
const manifestByKey = new Map<string, CombatEffectManifestEntry>();
|
||||||
const manifestBySpellId = new Map<number, CombatEffectManifestEntry>();
|
const manifestBySpellId = new Map<number, CombatEffectManifestEntry>();
|
||||||
let manifestRequest: Promise<void> | null = null;
|
let manifestRequest: Promise<void> | null = null;
|
||||||
@@ -207,13 +220,19 @@ function installCombatEffectManifest(manifest: GeneratedCombatEffectManifest): v
|
|||||||
export function preloadCombatEffectManifest(): Promise<void> {
|
export function preloadCombatEffectManifest(): Promise<void> {
|
||||||
if (manifestRequest) return manifestRequest;
|
if (manifestRequest) return manifestRequest;
|
||||||
if (typeof fetch === "undefined") return Promise.resolve();
|
if (typeof fetch === "undefined") return Promise.resolve();
|
||||||
manifestRequest = fetch(COMBAT_EFFECT_MANIFEST_URL)
|
manifestRequest = fetchCombatMediaAsset(COMBAT_MEDIA_MANIFEST_URL)
|
||||||
.then((response) => response.ok ? response.json() : Promise.reject(new Error(String(response.status))))
|
.then((response) => response.ok ? response.json() : Promise.reject(new Error(String(response.status))))
|
||||||
.then((manifest) => installCombatEffectManifest(manifest as GeneratedCombatEffectManifest))
|
.then((manifest) => installCombatEffectManifest(manifest as GeneratedCombatEffectManifest))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
return manifestRequest;
|
return manifestRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function effectiveCombatVfxMode(settings: CombatPresentationSettings): CombatVfxMode {
|
||||||
|
return settings.originalMediaEnabled && combatMediaPackReady()
|
||||||
|
? settings.vfxMode
|
||||||
|
: "generated";
|
||||||
|
}
|
||||||
|
|
||||||
export function combatEffectManifestEntry(
|
export function combatEffectManifestEntry(
|
||||||
spellId: number | undefined,
|
spellId: number | undefined,
|
||||||
source?: CombatPresentationSource,
|
source?: CombatPresentationSource,
|
||||||
|
|||||||
+118
-9
@@ -83,12 +83,14 @@ import {
|
|||||||
} from "./talentModifiers";
|
} from "./talentModifiers";
|
||||||
import {
|
import {
|
||||||
getMobPosition,
|
getMobPosition,
|
||||||
|
getMobRuntimeTransform,
|
||||||
mobRuntimeDistanceSquared,
|
mobRuntimeDistanceSquared,
|
||||||
nearbyMobRuntimeIds,
|
nearbyMobRuntimeIds,
|
||||||
removeMobPosition,
|
removeMobPosition,
|
||||||
setMobPosition,
|
setMobPosition,
|
||||||
type CombatPosition,
|
type CombatPosition,
|
||||||
} from "./mobRuntimeRegistry";
|
} from "./mobRuntimeRegistry";
|
||||||
|
import { enemyTelegraphContainsPosition } from "./enemyTelegraphGeometry";
|
||||||
import {
|
import {
|
||||||
getPartyRuntimePosition,
|
getPartyRuntimePosition,
|
||||||
partyRuntimeDistanceSquared,
|
partyRuntimeDistanceSquared,
|
||||||
@@ -129,7 +131,6 @@ import { rollBossLoot } from "./lootCatalog";
|
|||||||
import { normalizeInventory, type InventoryItem } from "./lootTypes";
|
import { normalizeInventory, type InventoryItem } from "./lootTypes";
|
||||||
import {
|
import {
|
||||||
PLAYER_EQUIPMENT_OWNER_ID,
|
PLAYER_EQUIPMENT_OWNER_ID,
|
||||||
equipmentStats,
|
|
||||||
activeEquipmentStatsForClass,
|
activeEquipmentStatsForClass,
|
||||||
equipInventoryItem,
|
equipInventoryItem,
|
||||||
normalizeEquipment,
|
normalizeEquipment,
|
||||||
@@ -137,6 +138,12 @@ import {
|
|||||||
type EquipmentAssignments,
|
type EquipmentAssignments,
|
||||||
type EquipmentSlot,
|
type EquipmentSlot,
|
||||||
} from "./equipment";
|
} from "./equipment";
|
||||||
|
import {
|
||||||
|
resolvedEquipmentStats,
|
||||||
|
type HeirloomContext,
|
||||||
|
} from "./heirloomEquipment";
|
||||||
|
import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles";
|
||||||
|
import { specializationById } from "./specializationCatalog";
|
||||||
import {
|
import {
|
||||||
EMPTY_ITEM_STATS,
|
EMPTY_ITEM_STATS,
|
||||||
itemStatSummary,
|
itemStatSummary,
|
||||||
@@ -299,6 +306,9 @@ export interface EnemyActiveCast {
|
|||||||
readonly school: EnemyDamageSchool;
|
readonly school: EnemyDamageSchool;
|
||||||
readonly interruptible: boolean;
|
readonly interruptible: boolean;
|
||||||
readonly targetActorId: AggroActorId | null;
|
readonly targetActorId: AggroActorId | null;
|
||||||
|
readonly sourcePosition: CombatPosition;
|
||||||
|
readonly targetPosition: CombatPosition;
|
||||||
|
readonly sourceYaw: number;
|
||||||
readonly startedAt: number;
|
readonly startedAt: number;
|
||||||
readonly completesAt: number;
|
readonly completesAt: number;
|
||||||
}
|
}
|
||||||
@@ -486,6 +496,8 @@ export interface ActorHealingRequest {
|
|||||||
|
|
||||||
export interface CharacterCombatSeed {
|
export interface CharacterCombatSeed {
|
||||||
readonly classId: ClassId;
|
readonly classId: ClassId;
|
||||||
|
readonly specializationId?: string | null;
|
||||||
|
readonly role?: PartyRole;
|
||||||
readonly secondaryClassId?: ClassId | null;
|
readonly secondaryClassId?: ClassId | null;
|
||||||
readonly raceId?: RaceId;
|
readonly raceId?: RaceId;
|
||||||
readonly level?: number;
|
readonly level?: number;
|
||||||
@@ -588,6 +600,8 @@ export interface PendingGroundTarget {
|
|||||||
|
|
||||||
export interface CombatState {
|
export interface CombatState {
|
||||||
classId: ClassId;
|
classId: ClassId;
|
||||||
|
specializationId: string | null;
|
||||||
|
gearRole: PartyRole;
|
||||||
secondaryClassId: ClassId | null;
|
secondaryClassId: ClassId | null;
|
||||||
raceId: RaceId;
|
raceId: RaceId;
|
||||||
level: number;
|
level: number;
|
||||||
@@ -640,6 +654,7 @@ export interface CombatState {
|
|||||||
settings: GameplaySettings;
|
settings: GameplaySettings;
|
||||||
playerPosition: CombatPosition;
|
playerPosition: CombatPosition;
|
||||||
initializeCharacter: (profile: CharacterCombatSeed) => void;
|
initializeCharacter: (profile: CharacterCombatSeed) => void;
|
||||||
|
refreshResolvedGear: () => void;
|
||||||
registerMob: (id: string, registration: MobRegistration) => void;
|
registerMob: (id: string, registration: MobRegistration) => void;
|
||||||
removeMob: (id: string) => void;
|
removeMob: (id: string) => void;
|
||||||
lootMob: (id: string, now?: number) => boolean;
|
lootMob: (id: string, now?: number) => boolean;
|
||||||
@@ -3112,6 +3127,8 @@ function initialState() {
|
|||||||
const initialPools = initialResourcePools(classId, abilities, maxResource);
|
const initialPools = initialResourcePools(classId, abilities, maxResource);
|
||||||
return {
|
return {
|
||||||
classId,
|
classId,
|
||||||
|
specializationId: null,
|
||||||
|
gearRole: "healer" as PartyRole,
|
||||||
secondaryClassId: null,
|
secondaryClassId: null,
|
||||||
raceId,
|
raceId,
|
||||||
level,
|
level,
|
||||||
@@ -3170,6 +3187,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
initializeCharacter: (profile) => {
|
initializeCharacter: (profile) => {
|
||||||
resetCombatPresentationSession();
|
resetCombatPresentationSession();
|
||||||
const classId = profile.classId;
|
const classId = profile.classId;
|
||||||
|
const specialization = specializationById(profile.specializationId);
|
||||||
|
const specializationId = specialization?.classId === classId ? specialization.id : null;
|
||||||
|
const gearRole = specialization?.role ?? profile.role ?? defaultPartyRoleForClass(classId);
|
||||||
const secondaryClassId = profile.secondaryClassId ?? null;
|
const secondaryClassId = profile.secondaryClassId ?? null;
|
||||||
const raceId = profile.raceId ?? "human";
|
const raceId = profile.raceId ?? "human";
|
||||||
const progression = normalizeProgression(profile.level ?? 1, profile.xp ?? profile.experience ?? 0);
|
const progression = normalizeProgression(profile.level ?? 1, profile.xp ?? profile.experience ?? 0);
|
||||||
@@ -3179,7 +3199,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const abilities = abilitiesForCharacter(classId, secondaryClassId);
|
const abilities = abilitiesForCharacter(classId, secondaryClassId);
|
||||||
const inventory = normalizeInventory(profile.inventory);
|
const inventory = normalizeInventory(profile.inventory);
|
||||||
const equipment = normalizeEquipment(profile.equipment, inventory);
|
const equipment = normalizeEquipment(profile.equipment, inventory);
|
||||||
const gearStats = activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId);
|
const gearStats = specializationId !== null || classId.startsWith("rom-") ? resolvedEquipmentStats(equipment, inventory, {
|
||||||
|
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||||
|
ownerKind: "player",
|
||||||
|
classId,
|
||||||
|
specializationId,
|
||||||
|
role: gearRole,
|
||||||
|
level: progression.level,
|
||||||
|
}) : activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId);
|
||||||
const maxHealth = talentAdjustedMaxHealth(
|
const maxHealth = talentAdjustedMaxHealth(
|
||||||
classId,
|
classId,
|
||||||
progression.level,
|
progression.level,
|
||||||
@@ -3226,6 +3253,44 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
refreshResolvedGear: () => set((state) => {
|
||||||
|
const context: HeirloomContext = {
|
||||||
|
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||||
|
ownerKind: "player",
|
||||||
|
classId: state.classId,
|
||||||
|
specializationId: state.specializationId,
|
||||||
|
role: state.gearRole,
|
||||||
|
level: state.level,
|
||||||
|
};
|
||||||
|
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-")
|
||||||
|
? resolvedEquipmentStats(state.equipment, state.inventory, context)
|
||||||
|
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||||
|
const maxHealth = talentAdjustedMaxHealth(
|
||||||
|
state.classId,
|
||||||
|
state.level,
|
||||||
|
state.talentModifiers,
|
||||||
|
gearStats,
|
||||||
|
state.raceId,
|
||||||
|
state.secondaryClassId,
|
||||||
|
);
|
||||||
|
const maxResource = talentAdjustedMaxResource(
|
||||||
|
state.classId,
|
||||||
|
state.level,
|
||||||
|
state.talentModifiers,
|
||||||
|
gearStats,
|
||||||
|
state.raceId,
|
||||||
|
state.secondaryClassId,
|
||||||
|
);
|
||||||
|
if (gearStats === state.gearStats && maxHealth === state.maxHealth && maxResource === state.maxResource) return state;
|
||||||
|
return {
|
||||||
|
gearStats,
|
||||||
|
maxHealth,
|
||||||
|
maxResource,
|
||||||
|
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
||||||
|
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : maxResource, 0, maxResource),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
registerMob: (id, registration) => set((state) => {
|
registerMob: (id, registration) => set((state) => {
|
||||||
if (!id) return state;
|
if (!id) return state;
|
||||||
const existing = state.mobs[id];
|
const existing = state.mobs[id];
|
||||||
@@ -3466,12 +3531,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const ownerName = ownerId === PLAYER_EQUIPMENT_OWNER_ID
|
const ownerName = ownerId === PLAYER_EQUIPMENT_OWNER_ID
|
||||||
? "You"
|
? "You"
|
||||||
: usePartyStore.getState().members.find((member) => member.id === ownerId)?.name ?? "Companion";
|
: usePartyStore.getState().members.find((member) => member.id === ownerId)?.name ?? "Companion";
|
||||||
const gearStats = activeEquipmentStatsForClass(
|
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, state.inventory, {
|
||||||
change.assignments,
|
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||||
state.inventory,
|
ownerKind: "player",
|
||||||
PLAYER_EQUIPMENT_OWNER_ID,
|
classId: state.classId,
|
||||||
state.classId,
|
specializationId: state.specializationId,
|
||||||
);
|
role: state.gearRole,
|
||||||
|
level: state.level,
|
||||||
|
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||||
const maxHealth = talentAdjustedMaxHealth(
|
const maxHealth = talentAdjustedMaxHealth(
|
||||||
state.classId,
|
state.classId,
|
||||||
state.level,
|
state.level,
|
||||||
@@ -3513,7 +3580,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const state = get();
|
const state = get();
|
||||||
const next = unequipInventorySlot(state.equipment, ownerId, slot);
|
const next = unequipInventorySlot(state.equipment, ownerId, slot);
|
||||||
if (next === state.equipment) return false;
|
if (next === state.equipment) return false;
|
||||||
const gearStats = activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(next, state.inventory, {
|
||||||
|
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||||
|
ownerKind: "player",
|
||||||
|
classId: state.classId,
|
||||||
|
specializationId: state.specializationId,
|
||||||
|
role: state.gearRole,
|
||||||
|
level: state.level,
|
||||||
|
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||||
const maxHealth = talentAdjustedMaxHealth(
|
const maxHealth = talentAdjustedMaxHealth(
|
||||||
state.classId,
|
state.classId,
|
||||||
state.level,
|
state.level,
|
||||||
@@ -6163,6 +6237,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
);
|
);
|
||||||
if (selectedAttack && startingEnemyCast) {
|
if (selectedAttack && startingEnemyCast) {
|
||||||
const attack = selectedAttack;
|
const attack = selectedAttack;
|
||||||
|
const sourceTransform = getMobRuntimeTransform(id);
|
||||||
enemyActiveCast = {
|
enemyActiveCast = {
|
||||||
attackId: attack.id,
|
attackId: attack.id,
|
||||||
name: attack.name,
|
name: attack.name,
|
||||||
@@ -6170,6 +6245,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
school: attack.school,
|
school: attack.school,
|
||||||
interruptible: attack.interruptible !== false,
|
interruptible: attack.interruptible !== false,
|
||||||
targetActorId: resolution.targetActorId,
|
targetActorId: resolution.targetActorId,
|
||||||
|
sourcePosition: [...position],
|
||||||
|
targetPosition: [...targetPosition],
|
||||||
|
sourceYaw: sourceTransform?.yaw
|
||||||
|
?? Math.atan2(targetPosition[0] - position[0], targetPosition[2] - position[2]),
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
completesAt: now + (attack.castTimeMs ?? 0),
|
completesAt: now + (attack.castTimeMs ?? 0),
|
||||||
};
|
};
|
||||||
@@ -6187,6 +6266,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
);
|
);
|
||||||
} else if (selectedAttack && !cannotAttack) {
|
} else if (selectedAttack && !cannotAttack) {
|
||||||
const attack = selectedAttack!;
|
const attack = selectedAttack!;
|
||||||
|
const resolvedEnemyCast = enemyActiveCast;
|
||||||
enemyActiveCast = null;
|
enemyActiveCast = null;
|
||||||
const effect = attack.effect ?? {
|
const effect = attack.effect ?? {
|
||||||
kind: "damage" as const,
|
kind: "damage" as const,
|
||||||
@@ -6198,10 +6278,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
stableCombatUnit(`${id}:${attack.id}:${attackRevision}:target`) * eligibleActors.length,
|
stableCombatUnit(`${id}:${attack.id}:${attackRevision}:target`) * eligibleActors.length,
|
||||||
)]
|
)]
|
||||||
: resolution.targetActorId;
|
: resolution.targetActorId;
|
||||||
|
const telegraphPlacement = resolvedEnemyCast
|
||||||
|
? {
|
||||||
|
sourcePosition: resolvedEnemyCast.sourcePosition,
|
||||||
|
targetPosition: resolvedEnemyCast.targetPosition,
|
||||||
|
sourceYaw: resolvedEnemyCast.sourceYaw,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
sourcePosition: position,
|
||||||
|
targetPosition,
|
||||||
|
sourceYaw: Math.atan2(targetPosition[0] - position[0], targetPosition[2] - position[2]),
|
||||||
|
};
|
||||||
const unvalidatedTargets = attack.target === "nearby-party"
|
const unvalidatedTargets = attack.target === "nearby-party"
|
||||||
? eligibleActors.filter((actorId) => {
|
? eligibleActors.filter((actorId) => {
|
||||||
const candidate = actorPosition(runtimeState, actorId);
|
const candidate = actorPosition(runtimeState, actorId);
|
||||||
if (!candidate) return false;
|
if (!candidate) return false;
|
||||||
|
if (attack.telegraph) {
|
||||||
|
return enemyTelegraphContainsPosition(attack.telegraph, telegraphPlacement, candidate);
|
||||||
|
}
|
||||||
const radius = attack.radius ?? Math.max(1, attack.range * 0.35);
|
const radius = attack.radius ?? Math.max(1, attack.range * 0.35);
|
||||||
const targetDx = candidate[0] - position[0];
|
const targetDx = candidate[0] - position[0];
|
||||||
const targetDy = candidate[1] - position[1];
|
const targetDy = candidate[1] - position[1];
|
||||||
@@ -6298,6 +6392,21 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
targetId: id,
|
targetId: id,
|
||||||
now,
|
now,
|
||||||
}).auras];
|
}).auras];
|
||||||
|
} else if (effect.kind === "target-aura") {
|
||||||
|
for (const actorId of targets) {
|
||||||
|
namedAuras = [...applyCombatAura(namedAuras, {
|
||||||
|
definition: {
|
||||||
|
id: `enemy-debuff:${id}:${attack.id}:${effect.aura}`,
|
||||||
|
name: `${attack.name}: ${effect.aura}`,
|
||||||
|
disposition: "debuff",
|
||||||
|
durationMs: effect.durationMs > 0 ? effect.durationMs : null,
|
||||||
|
dispelCategory: "magic",
|
||||||
|
},
|
||||||
|
sourceId: id,
|
||||||
|
targetId: actorId,
|
||||||
|
now,
|
||||||
|
}).auras];
|
||||||
|
}
|
||||||
} else if (effect.kind === "control") {
|
} else if (effect.kind === "control") {
|
||||||
for (const actorId of targets) {
|
for (const actorId of targets) {
|
||||||
if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue;
|
if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue;
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ describe("generated dungeon campaign catalog", () => {
|
|||||||
"Fear",
|
"Fear",
|
||||||
"Magic's Bane",
|
"Magic's Bane",
|
||||||
"Corrupt Soul",
|
"Corrupt Soul",
|
||||||
|
"Soulstorm",
|
||||||
]);
|
]);
|
||||||
expect(attackNames(culling!, "Mal'Ganis")).toEqual([
|
expect(attackNames(culling!, "Mal'Ganis")).toEqual([
|
||||||
"Basic Attack",
|
"Basic Attack",
|
||||||
@@ -204,6 +205,49 @@ describe("generated dungeon campaign catalog", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("compiles Archaedas's timed and health-based encounter mechanics", () => {
|
||||||
|
const uldaman = generated.definitions.find((dungeon) => dungeon.id === "uldaman")!;
|
||||||
|
const archaedas = Object.values(uldaman.entities).find(
|
||||||
|
(entity) => entity.name === "Archaedas",
|
||||||
|
) as unknown as {
|
||||||
|
readonly combat: {
|
||||||
|
readonly mechanicIds: readonly string[];
|
||||||
|
readonly attacks: ReadonlyArray<{
|
||||||
|
readonly id: string;
|
||||||
|
readonly initialCooldownMs?: readonly [number, number];
|
||||||
|
readonly repeatCooldownMs?: readonly [number, number];
|
||||||
|
readonly condition?: { readonly kind: string; readonly maximum?: number };
|
||||||
|
readonly effect?: { readonly kind: string; readonly creatureEntry?: number; readonly count?: number };
|
||||||
|
readonly telegraph?: { readonly shape: string; readonly anchor: string; readonly radius?: number };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(archaedas.combat.mechanicIds).toEqual([
|
||||||
|
"archaedas-awaken-earthen-custodian",
|
||||||
|
"archaedas-ground-tremor",
|
||||||
|
"archaedas-awaken-earthen-guardians",
|
||||||
|
"archaedas-awaken-vault-warders",
|
||||||
|
]);
|
||||||
|
expect(archaedas.combat.attacks.find((attack) => attack.id === "archaedas-ground-tremor"))
|
||||||
|
.toMatchObject({
|
||||||
|
initialCooldownMs: [0, 10_000],
|
||||||
|
repeatCooldownMs: [14_000, 20_000],
|
||||||
|
telegraph: { shape: "circle", anchor: "source", radius: 18 },
|
||||||
|
});
|
||||||
|
expect(archaedas.combat.attacks.find(
|
||||||
|
(attack) => attack.id === "archaedas-awaken-earthen-guardians",
|
||||||
|
)).toMatchObject({
|
||||||
|
condition: { kind: "self-health-percent", maximum: 70 },
|
||||||
|
effect: { kind: "summon", creatureEntry: 7076, count: 6 },
|
||||||
|
});
|
||||||
|
expect(archaedas.combat.attacks.find(
|
||||||
|
(attack) => attack.id === "archaedas-awaken-vault-warders",
|
||||||
|
)).toMatchObject({
|
||||||
|
condition: { kind: "self-health-percent", maximum: 40 },
|
||||||
|
effect: { kind: "summon", creatureEntry: 10120, count: 2 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("does not leak outdoor zones, arenas, or raids into the dungeon browser", () => {
|
it("does not leak outdoor zones, arenas, or raids into the dungeon browser", () => {
|
||||||
const ids = new Set(generated.definitions.map((dungeon) => dungeon.id));
|
const ids = new Set(generated.definitions.map((dungeon) => dungeon.id));
|
||||||
for (const excluded of [
|
for (const excluded of [
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { encounterTimeline } from "./encounterTimeline";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
|
||||||
|
describe("encounter mechanic timeline", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 40 });
|
||||||
|
useCombatStore.getState().registerMob("boss", {
|
||||||
|
name: "Timeline Boss",
|
||||||
|
boss: true,
|
||||||
|
maxHealth: 1_000,
|
||||||
|
attacks: [{
|
||||||
|
id: "timed-quake",
|
||||||
|
name: "Timed Quake",
|
||||||
|
delivery: "area",
|
||||||
|
target: "nearby-party",
|
||||||
|
school: "nature",
|
||||||
|
animation: "cast",
|
||||||
|
range: 20,
|
||||||
|
radius: 10,
|
||||||
|
cooldownMs: 15_000,
|
||||||
|
repeatCooldownMs: [12_000, 18_000],
|
||||||
|
damageMultiplier: 1,
|
||||||
|
}, {
|
||||||
|
id: "health-adds",
|
||||||
|
name: "Awaken Adds",
|
||||||
|
delivery: "area",
|
||||||
|
target: "self",
|
||||||
|
school: "physical",
|
||||||
|
animation: "cast",
|
||||||
|
range: 0,
|
||||||
|
cooldownMs: 1_000,
|
||||||
|
maximumCasts: 1,
|
||||||
|
damageMultiplier: 0,
|
||||||
|
effect: { kind: "summon", creatureEntry: 1, count: 2 },
|
||||||
|
condition: { kind: "self-health-percent", minimum: 0, maximum: 70 },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
useCombatStore.setState((state) => ({
|
||||||
|
mobs: {
|
||||||
|
...state.mobs,
|
||||||
|
boss: {
|
||||||
|
...state.mobs.boss,
|
||||||
|
engaged: true,
|
||||||
|
abilityReadyAt: { "timed-quake": 16_000, "health-adds": 1_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows both timer countdowns and pending health thresholds", () => {
|
||||||
|
const model = encounterTimeline(useCombatStore.getState().mobs, 10_000);
|
||||||
|
expect(model?.bossName).toBe("Timeline Boss");
|
||||||
|
expect(model?.rows.map((row) => [row.name, row.kind, row.value])).toEqual([
|
||||||
|
["Timed Quake", "timer", "6.0s"],
|
||||||
|
["Awaken Adds", "health", "70% HP"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("promotes an active cast and hides a completed one-shot threshold", () => {
|
||||||
|
useCombatStore.setState((state) => ({
|
||||||
|
mobs: {
|
||||||
|
...state.mobs,
|
||||||
|
boss: {
|
||||||
|
...state.mobs.boss,
|
||||||
|
abilityCastCounts: { "health-adds": 1 },
|
||||||
|
activeCast: {
|
||||||
|
attackId: "timed-quake",
|
||||||
|
name: "Timed Quake",
|
||||||
|
school: "nature",
|
||||||
|
interruptible: true,
|
||||||
|
targetActorId: "__player__",
|
||||||
|
sourcePosition: [0, 0, 0],
|
||||||
|
targetPosition: [0, 0, 5],
|
||||||
|
sourceYaw: 0,
|
||||||
|
startedAt: 10_000,
|
||||||
|
completesAt: 12_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
expect(encounterTimeline(useCombatStore.getState().mobs, 11_250)?.rows).toMatchObject([{
|
||||||
|
name: "Timed Quake",
|
||||||
|
kind: "casting",
|
||||||
|
value: "0.8s",
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every pending health threshold visible beyond the countdown row limit", () => {
|
||||||
|
const boss = useCombatStore.getState().mobs.boss;
|
||||||
|
const extraTimers = Array.from({ length: 5 }, (_, index) => ({
|
||||||
|
...boss.attacks[0],
|
||||||
|
id: `extra-timer-${index}`,
|
||||||
|
name: `Extra Timer ${index}`,
|
||||||
|
}));
|
||||||
|
useCombatStore.setState((state) => ({
|
||||||
|
mobs: {
|
||||||
|
...state.mobs,
|
||||||
|
boss: {
|
||||||
|
...state.mobs.boss,
|
||||||
|
attacks: [...state.mobs.boss.attacks, ...extraTimers],
|
||||||
|
abilityReadyAt: {
|
||||||
|
...state.mobs.boss.abilityReadyAt,
|
||||||
|
...Object.fromEntries(extraTimers.map((attack, index) => [attack.id, 11_000 + index * 1_000])),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rows = encounterTimeline(useCombatStore.getState().mobs, 10_000, 4)?.rows ?? [];
|
||||||
|
expect(rows.filter((row) => row.kind === "timer")).toHaveLength(4);
|
||||||
|
expect(rows.filter((row) => row.kind === "health").map((row) => row.value)).toEqual(["70% HP"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { MobCombatState } from "./combatStore";
|
||||||
|
import type { EnemyDamageSchool, EnemyTelegraphShape } from "./enemyAttacks";
|
||||||
|
|
||||||
|
export type EncounterTimelineKind = "casting" | "timer" | "health";
|
||||||
|
|
||||||
|
export interface EncounterTimelineRow {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly kind: EncounterTimelineKind;
|
||||||
|
readonly value: string;
|
||||||
|
readonly progress: number;
|
||||||
|
readonly sortAt: number;
|
||||||
|
readonly school: EnemyDamageSchool;
|
||||||
|
readonly telegraphShape?: EnemyTelegraphShape;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncounterTimelineModel {
|
||||||
|
readonly bossId: string;
|
||||||
|
readonly bossName: string;
|
||||||
|
readonly rows: readonly EncounterTimelineRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampedProgress(value: number): number {
|
||||||
|
return Math.min(1, Math.max(0, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encounterTimeline(
|
||||||
|
mobs: Readonly<Record<string, MobCombatState>>,
|
||||||
|
now: number,
|
||||||
|
limit = 4,
|
||||||
|
): EncounterTimelineModel | null {
|
||||||
|
const boss = Object.values(mobs)
|
||||||
|
.filter((mob) => mob.boss && mob.engaged && !mob.dead && !mob.despawned)
|
||||||
|
.sort((left, right) => (
|
||||||
|
Number(Boolean(right.activeCast)) - Number(Boolean(left.activeCast))
|
||||||
|
|| left.id.localeCompare(right.id)
|
||||||
|
))[0];
|
||||||
|
if (!boss) return null;
|
||||||
|
|
||||||
|
const healthPercent = (boss.health / Math.max(1, boss.maxHealth)) * 100;
|
||||||
|
const rows = boss.attacks.flatMap<EncounterTimelineRow>((attack) => {
|
||||||
|
if (attack.id === "basic-attack") return [];
|
||||||
|
const castCount = boss.abilityCastCounts[attack.id] ?? 0;
|
||||||
|
if (attack.maximumCasts && castCount >= attack.maximumCasts) return [];
|
||||||
|
const common = {
|
||||||
|
id: attack.id,
|
||||||
|
name: attack.name,
|
||||||
|
school: attack.school,
|
||||||
|
...(attack.telegraph ? { telegraphShape: attack.telegraph.shape } : {}),
|
||||||
|
};
|
||||||
|
if (boss.activeCast?.attackId === attack.id) {
|
||||||
|
const duration = Math.max(1, boss.activeCast.completesAt - boss.activeCast.startedAt);
|
||||||
|
const remaining = Math.max(0, boss.activeCast.completesAt - now);
|
||||||
|
return [{
|
||||||
|
...common,
|
||||||
|
kind: "casting",
|
||||||
|
value: `${(remaining / 1_000).toFixed(1)}s`,
|
||||||
|
progress: clampedProgress(remaining / duration),
|
||||||
|
sortAt: -1,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
if (attack.condition?.kind === "self-health-percent") {
|
||||||
|
const threshold = attack.condition.maximum;
|
||||||
|
return [{
|
||||||
|
...common,
|
||||||
|
kind: "health",
|
||||||
|
value: `${Math.round(threshold)}% HP`,
|
||||||
|
progress: clampedProgress(healthPercent / Math.max(1, threshold)),
|
||||||
|
sortAt: 1_000_000 + (100 - threshold) * 1_000,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
const readyAt = boss.abilityReadyAt[attack.id];
|
||||||
|
if (!Number.isFinite(readyAt)) return [];
|
||||||
|
const remaining = Math.max(0, (readyAt ?? now) - now);
|
||||||
|
const cooldown = Math.max(
|
||||||
|
1,
|
||||||
|
attack.repeatCooldownMs?.[1] ?? attack.initialCooldownMs?.[1] ?? attack.cooldownMs,
|
||||||
|
);
|
||||||
|
return [{
|
||||||
|
...common,
|
||||||
|
kind: "timer",
|
||||||
|
value: `${(remaining / 1_000).toFixed(1)}s`,
|
||||||
|
progress: clampedProgress(remaining / cooldown),
|
||||||
|
sortAt: remaining,
|
||||||
|
}];
|
||||||
|
}).sort((left, right) => left.sortAt - right.sortAt || left.id.localeCompare(right.id));
|
||||||
|
|
||||||
|
const alwaysVisibleHealthRows = rows.filter((row) => row.kind === "health");
|
||||||
|
const countdownRows = rows
|
||||||
|
.filter((row) => row.kind !== "health")
|
||||||
|
.slice(0, Math.max(1, limit));
|
||||||
|
const visibleRows = [...countdownRows, ...alwaysVisibleHealthRows];
|
||||||
|
|
||||||
|
return visibleRows.length ? {
|
||||||
|
bossId: boss.id,
|
||||||
|
bossName: boss.name,
|
||||||
|
rows: visibleRows,
|
||||||
|
} : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { normalizeEnemyAttack, type EnemyAttackDefinition } from "./enemyAttacks";
|
||||||
|
|
||||||
|
function attack(overrides: Partial<EnemyAttackDefinition>): EnemyAttackDefinition {
|
||||||
|
return {
|
||||||
|
id: "test-special",
|
||||||
|
name: "Test Special",
|
||||||
|
delivery: "area",
|
||||||
|
target: "nearby-party",
|
||||||
|
school: "physical",
|
||||||
|
animation: "attack",
|
||||||
|
range: 12,
|
||||||
|
cooldownMs: 10_000,
|
||||||
|
damageMultiplier: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("enemy attack telegraphs", () => {
|
||||||
|
it("infers a warned cone for legacy frontal mechanics", () => {
|
||||||
|
const normalized = normalizeEnemyAttack(attack({ name: "Stone Cleave" }));
|
||||||
|
expect(normalized.telegraph).toMatchObject({
|
||||||
|
shape: "cone",
|
||||||
|
anchor: "source",
|
||||||
|
warningMs: 1_650,
|
||||||
|
});
|
||||||
|
expect(normalized.castTimeMs).toBe(1_650);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves authored geometry and extends too-short cast warnings", () => {
|
||||||
|
const normalized = normalizeEnemyAttack(attack({
|
||||||
|
name: "Falling Ruin",
|
||||||
|
castTimeMs: 500,
|
||||||
|
telegraph: {
|
||||||
|
shape: "circle",
|
||||||
|
anchor: "target",
|
||||||
|
warningMs: 2_200,
|
||||||
|
radius: 7,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
expect(normalized.castTimeMs).toBe(2_200);
|
||||||
|
expect(normalized.telegraph).toEqual({
|
||||||
|
shape: "circle",
|
||||||
|
anchor: "target",
|
||||||
|
warningMs: 2_200,
|
||||||
|
radius: 7,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not telegraph basic attacks or beneficial mechanics", () => {
|
||||||
|
expect(normalizeEnemyAttack(attack({ id: "basic-attack" })).telegraph).toBeUndefined();
|
||||||
|
expect(normalizeEnemyAttack(attack({
|
||||||
|
effect: { kind: "heal", multiplier: 1 },
|
||||||
|
})).telegraph).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
+106
-2
@@ -17,6 +17,21 @@ export type EnemyDamageSchool =
|
|||||||
|
|
||||||
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun" | "silence";
|
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun" | "silence";
|
||||||
|
|
||||||
|
export type EnemyTelegraphShape = "circle" | "cone" | "line";
|
||||||
|
export type EnemyTelegraphAnchor = "source" | "target";
|
||||||
|
|
||||||
|
/** World-space warning geometry shared by simulation and Three presentation. */
|
||||||
|
export interface EnemyAttackTelegraph {
|
||||||
|
readonly shape: EnemyTelegraphShape;
|
||||||
|
readonly anchor: EnemyTelegraphAnchor;
|
||||||
|
/** Minimum warning time before the mechanic resolves. */
|
||||||
|
readonly warningMs: number;
|
||||||
|
readonly radius?: number;
|
||||||
|
readonly length?: number;
|
||||||
|
readonly width?: number;
|
||||||
|
readonly angleDegrees?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type EnemyAbilityEffect =
|
export type EnemyAbilityEffect =
|
||||||
| { readonly kind: "damage"; readonly multiplier: number }
|
| { readonly kind: "damage"; readonly multiplier: number }
|
||||||
| { readonly kind: "heal"; readonly multiplier: number }
|
| { readonly kind: "heal"; readonly multiplier: number }
|
||||||
@@ -26,6 +41,11 @@ export type EnemyAbilityEffect =
|
|||||||
readonly durationMs: number;
|
readonly durationMs: number;
|
||||||
readonly magnitude?: number;
|
readonly magnitude?: number;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
readonly kind: "target-aura";
|
||||||
|
readonly aura: string;
|
||||||
|
readonly durationMs: number;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
readonly kind: "control";
|
readonly kind: "control";
|
||||||
readonly mechanic: EnemyControlMechanic;
|
readonly mechanic: EnemyControlMechanic;
|
||||||
@@ -83,6 +103,7 @@ export interface EnemyAttackDefinition {
|
|||||||
readonly maximumCasts?: number;
|
readonly maximumCasts?: number;
|
||||||
readonly damageMultiplier: number;
|
readonly damageMultiplier: number;
|
||||||
readonly radius?: number;
|
readonly radius?: number;
|
||||||
|
readonly telegraph?: EnemyAttackTelegraph;
|
||||||
readonly effect?: EnemyAbilityEffect;
|
readonly effect?: EnemyAbilityEffect;
|
||||||
readonly condition?: EnemyAbilityCondition;
|
readonly condition?: EnemyAbilityCondition;
|
||||||
readonly source?: EnemyAbilitySource;
|
readonly source?: EnemyAbilitySource;
|
||||||
@@ -96,6 +117,8 @@ export interface EnemyCombatDefinition {
|
|||||||
readonly moveSpeed?: number;
|
readonly moveSpeed?: number;
|
||||||
readonly aggroRange?: number;
|
readonly aggroRange?: number;
|
||||||
readonly leashRange?: number;
|
readonly leashRange?: number;
|
||||||
|
/** Stable special-attack ids available for future encounter loadout randomization. */
|
||||||
|
readonly mechanicIds?: readonly string[];
|
||||||
readonly attacks: readonly EnemyAttackDefinition[];
|
readonly attacks: readonly EnemyAttackDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,14 +148,94 @@ function normalizeCooldownRange(
|
|||||||
return Object.freeze([Math.round(lower), Math.round(upper)]);
|
return Object.freeze([Math.round(lower), Math.round(upper)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function harmfulEnemyAttack(value: EnemyAttackDefinition): boolean {
|
||||||
|
return !value.effect
|
||||||
|
|| value.effect.kind === "damage"
|
||||||
|
|| value.effect.kind === "control"
|
||||||
|
|| value.effect.kind === "target-aura";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy/imported encounters receive safe defaults while authored mechanics can
|
||||||
|
* override every dimension in the modular mechanic catalog.
|
||||||
|
*/
|
||||||
|
export function inferEnemyAttackTelegraph(
|
||||||
|
value: EnemyAttackDefinition,
|
||||||
|
): EnemyAttackTelegraph | undefined {
|
||||||
|
if (value.id === "basic-attack" || !harmfulEnemyAttack(value)) return undefined;
|
||||||
|
const radius = value.radius ?? Math.max(3, value.range * 0.35);
|
||||||
|
if (/\b(?:breath|cleave|frontal|sweep|cone)\b/i.test(value.name)) {
|
||||||
|
return {
|
||||||
|
shape: "cone",
|
||||||
|
anchor: "source",
|
||||||
|
warningMs: 1_650,
|
||||||
|
length: Math.max(5.5, value.range),
|
||||||
|
angleDegrees: /breath/i.test(value.name) ? 70 : 95,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (/\b(?:beam|charge|line|lance)\b/i.test(value.name)) {
|
||||||
|
return {
|
||||||
|
shape: "line",
|
||||||
|
anchor: "source",
|
||||||
|
warningMs: 1_650,
|
||||||
|
length: Math.max(7, value.range),
|
||||||
|
width: Math.max(2, radius * 0.45),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (value.delivery === "area" || value.radius !== undefined) {
|
||||||
|
const targetAnchored = /\b(?:blizzard|rain|barrage|volley|eruption|ground|meteor)\b/i
|
||||||
|
.test(value.name) && value.target !== "self";
|
||||||
|
return {
|
||||||
|
shape: "circle",
|
||||||
|
anchor: targetAnchored ? "target" : "source",
|
||||||
|
warningMs: 1_650,
|
||||||
|
radius,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTelegraph(value: EnemyAttackTelegraph): EnemyAttackTelegraph {
|
||||||
|
const warningMs = Math.round(finiteAtLeast(value.warningMs, 1_650, 250));
|
||||||
|
if (value.shape === "circle") {
|
||||||
|
return Object.freeze({
|
||||||
|
shape: value.shape,
|
||||||
|
anchor: value.anchor,
|
||||||
|
warningMs,
|
||||||
|
radius: finiteAtLeast(value.radius, 4, 0.5),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (value.shape === "cone") {
|
||||||
|
return Object.freeze({
|
||||||
|
shape: value.shape,
|
||||||
|
anchor: "source",
|
||||||
|
warningMs,
|
||||||
|
length: finiteAtLeast(value.length, 8, 1),
|
||||||
|
angleDegrees: Math.min(180, finiteAtLeast(value.angleDegrees, 90, 5)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
shape: value.shape,
|
||||||
|
anchor: "source",
|
||||||
|
warningMs,
|
||||||
|
length: finiteAtLeast(value.length, 8, 1),
|
||||||
|
width: finiteAtLeast(value.width, 3, 0.5),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeEnemyAttack(
|
export function normalizeEnemyAttack(
|
||||||
value: EnemyAttackDefinition,
|
value: EnemyAttackDefinition,
|
||||||
fallback: EnemyAttackDefinition = DEFAULT_ENEMY_ATTACK,
|
fallback: EnemyAttackDefinition = DEFAULT_ENEMY_ATTACK,
|
||||||
): EnemyAttackDefinition {
|
): EnemyAttackDefinition {
|
||||||
const cooldownMs = Math.round(finiteAtLeast(value.cooldownMs, fallback.cooldownMs, 1));
|
const cooldownMs = Math.round(finiteAtLeast(value.cooldownMs, fallback.cooldownMs, 1));
|
||||||
|
const telegraph = value.telegraph ?? inferEnemyAttackTelegraph(value);
|
||||||
const inferredCastTimeMs = value.castTimeMs === undefined && value.animation === "cast"
|
const inferredCastTimeMs = value.castTimeMs === undefined && value.animation === "cast"
|
||||||
? (fallback.castTimeMs ?? 1_500)
|
? (fallback.castTimeMs ?? 1_500)
|
||||||
: (fallback.castTimeMs ?? 0);
|
: telegraph?.warningMs ?? fallback.castTimeMs ?? 0;
|
||||||
|
const castTimeMs = Math.max(
|
||||||
|
Math.round(finiteAtLeast(value.castTimeMs, inferredCastTimeMs, 0)),
|
||||||
|
telegraph?.warningMs ?? 0,
|
||||||
|
);
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
id: value.id.trim() || fallback.id,
|
id: value.id.trim() || fallback.id,
|
||||||
name: value.name.trim() || fallback.name,
|
name: value.name.trim() || fallback.name,
|
||||||
@@ -143,7 +246,7 @@ export function normalizeEnemyAttack(
|
|||||||
target: value.target,
|
target: value.target,
|
||||||
school: value.school,
|
school: value.school,
|
||||||
animation: value.animation,
|
animation: value.animation,
|
||||||
castTimeMs: Math.round(finiteAtLeast(value.castTimeMs, inferredCastTimeMs, 0)),
|
castTimeMs,
|
||||||
interruptible: value.interruptible ?? fallback.interruptible ?? true,
|
interruptible: value.interruptible ?? fallback.interruptible ?? true,
|
||||||
range: finiteAtLeast(value.range, fallback.range, 0),
|
range: finiteAtLeast(value.range, fallback.range, 0),
|
||||||
cooldownMs,
|
cooldownMs,
|
||||||
@@ -160,6 +263,7 @@ export function normalizeEnemyAttack(
|
|||||||
...(value.radius !== undefined
|
...(value.radius !== undefined
|
||||||
? { radius: finiteAtLeast(value.radius, 0, 0) }
|
? { radius: finiteAtLeast(value.radius, 0, 0) }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(telegraph ? { telegraph: normalizeTelegraph(telegraph) } : {}),
|
||||||
...(value.effect ? { effect: value.effect } : {}),
|
...(value.effect ? { effect: value.effect } : {}),
|
||||||
...(value.condition ? { condition: value.condition } : {}),
|
...(value.condition ? { condition: value.condition } : {}),
|
||||||
...(value.source ? { source: value.source } : {}),
|
...(value.source ? { source: value.source } : {}),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
import {
|
import {
|
||||||
deterministicEnemyCooldown,
|
deterministicEnemyCooldown,
|
||||||
useCombatStore,
|
useCombatStore,
|
||||||
@@ -171,6 +172,42 @@ describe("server-backed enemy ability runtime", () => {
|
|||||||
expect(summonIds.every((id) => getMobPosition(id) === null)).toBe(true);
|
expect(summonIds.every((id) => getMobPosition(id) === null)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies a modular target aura as a dispellable player debuff", () => {
|
||||||
|
useCombatStore.getState().registerMob("runewaker-debuffer", {
|
||||||
|
name: "RuneWaker Debuffer",
|
||||||
|
maxHealth: 1_000,
|
||||||
|
attacks: [{
|
||||||
|
id: "conductivity",
|
||||||
|
name: "Conductivity",
|
||||||
|
delivery: "projectile",
|
||||||
|
target: "primary",
|
||||||
|
school: "arcane",
|
||||||
|
animation: "cast",
|
||||||
|
castTimeMs: 1_000,
|
||||||
|
range: 20,
|
||||||
|
cooldownMs: 10_000,
|
||||||
|
initialCooldownMs: [0, 0],
|
||||||
|
repeatCooldownMs: [10_000, 10_000],
|
||||||
|
damageMultiplier: 0,
|
||||||
|
effect: { kind: "target-aura", aura: "conductivity", durationMs: 6_000 },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
setMobPosition("runewaker-debuffer", [2, 0, 0]);
|
||||||
|
useCombatStore.getState().damageMob("runewaker-debuffer", 1, 1_000);
|
||||||
|
useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0]);
|
||||||
|
useCombatStore.getState().advanceMobCombat(2_000, [0, 0, 0]);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
||||||
|
sourceId: "runewaker-debuffer",
|
||||||
|
targetId: PLAYER_AGGRO_ID,
|
||||||
|
expiresAt: 8_000,
|
||||||
|
definition: expect.objectContaining({
|
||||||
|
disposition: "debuff",
|
||||||
|
dispelCategory: "magic",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it("honors encounter-state conditions before selecting an ability", () => {
|
it("honors encounter-state conditions before selecting an ability", () => {
|
||||||
useWailingEncounterStore.getState().reset();
|
useWailingEncounterStore.getState().reset();
|
||||||
useCombatStore.getState().registerMob("event-caster", {
|
useCombatStore.getState().registerMob("event-caster", {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { enemyTelegraphContainsPosition } from "./enemyTelegraphGeometry";
|
||||||
|
|
||||||
|
const placement = {
|
||||||
|
sourcePosition: [0, 0, 0] as const,
|
||||||
|
targetPosition: [0, 0, 10] as const,
|
||||||
|
sourceYaw: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("enemy telegraph gameplay geometry", () => {
|
||||||
|
it("matches source and target anchored circles", () => {
|
||||||
|
expect(enemyTelegraphContainsPosition({
|
||||||
|
shape: "circle", anchor: "source", warningMs: 1_000, radius: 4,
|
||||||
|
}, placement, [3, 0, 0])).toBe(true);
|
||||||
|
expect(enemyTelegraphContainsPosition({
|
||||||
|
shape: "circle", anchor: "target", warningMs: 1_000, radius: 3,
|
||||||
|
}, placement, [0, 0, 8])).toBe(true);
|
||||||
|
expect(enemyTelegraphContainsPosition({
|
||||||
|
shape: "circle", anchor: "target", warningMs: 1_000, radius: 3,
|
||||||
|
}, placement, [0, 0, 4])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps cone hits in front and within the visible angle", () => {
|
||||||
|
const cone = {
|
||||||
|
shape: "cone" as const,
|
||||||
|
anchor: "source" as const,
|
||||||
|
warningMs: 1_000,
|
||||||
|
length: 10,
|
||||||
|
angleDegrees: 90,
|
||||||
|
};
|
||||||
|
expect(enemyTelegraphContainsPosition(cone, placement, [2, 0, 7])).toBe(true);
|
||||||
|
expect(enemyTelegraphContainsPosition(cone, placement, [8, 0, 2])).toBe(false);
|
||||||
|
expect(enemyTelegraphContainsPosition(cone, placement, [0, 0, -2])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the visible line width for collision", () => {
|
||||||
|
const line = {
|
||||||
|
shape: "line" as const,
|
||||||
|
anchor: "source" as const,
|
||||||
|
warningMs: 1_000,
|
||||||
|
length: 12,
|
||||||
|
width: 4,
|
||||||
|
};
|
||||||
|
expect(enemyTelegraphContainsPosition(line, placement, [1.9, 0, 8])).toBe(true);
|
||||||
|
expect(enemyTelegraphContainsPosition(line, placement, [2.1, 0, 8])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user