forked from phenom/healer-man
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f49e54f35f | ||
|
|
7b41b102fa |
No files matched your search
@@ -20,5 +20,6 @@ releases/
|
||||
/content/
|
||||
dist-android/
|
||||
.android-public/
|
||||
.web-public/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
+66
-33
@@ -1,10 +1,12 @@
|
||||
# Local-first content updates and Android releases
|
||||
# Local-first content updates for browser and Android
|
||||
|
||||
Healer Man separates the installed game client from large game content. The
|
||||
Android APK contains the React/Three.js runtime, native Android bridge, menus,
|
||||
local profile and save support, UI assets, and the Wailing Caverns starter
|
||||
pack. Other GLBs, textures, audio, character packs, dungeons, and Manastorm
|
||||
stages are published by TrueNAS as optional content.
|
||||
Healer Man separates the installed game client from large game content. Both
|
||||
the production browser build and Android APK contain a compact required set:
|
||||
the React/Three.js runtime, menus, local profile and save support, UI assets,
|
||||
starter equipment, Wailing Caverns starter content, and the basic Human
|
||||
character model with its default male and female appearances. Android also
|
||||
contains its native bridge. Other GLBs, textures, audio, character/skin packs,
|
||||
dungeons, and Manastorm stages are published by TrueNAS as optional content.
|
||||
|
||||
Single-player is local-first. Starting the game, loading a character, entering
|
||||
installed content, simulating combat, and saving progress never require the
|
||||
@@ -27,16 +29,12 @@ UI behavior, shaders or loaders, native Android code, permissions, or the
|
||||
content-manifest schema. A manifest can set `minimumAppVersion` so an older APK
|
||||
will not activate incompatible content.
|
||||
|
||||
The browser build remains a full build served by TrueNAS. It receives client
|
||||
code when the app checkout is pulled, rebuilt, and restarted. The Android build
|
||||
is deliberately separate and compact so the APK does not inherit the complete
|
||||
multi-gigabyte browser asset catalog.
|
||||
|
||||
Measured on August 14, 2026, the production browser `dist` is 3.88 GiB across
|
||||
18,895 files; GLBs account for 3.75 GiB. The compact Android `dist-android` is
|
||||
148.0 MiB across 5,561 files, including 47.8 MiB of starter GLBs. The final
|
||||
signed APK will be smaller because Android packages compress eligible files;
|
||||
record its exact size after the first release build.
|
||||
Production builds deliberately stage only required content. `npm run build`
|
||||
prepares `.web-public` and emits the compact browser `dist`; Android prepares
|
||||
`.android-public` and emits `dist-android`. The development server continues to
|
||||
use the source `public` tree so asset-pipeline work remains convenient. The
|
||||
complete source catalog and published immutable objects stay on TrueNAS, not in
|
||||
every player's initial browser or Android installation.
|
||||
|
||||
## Content manifest
|
||||
|
||||
@@ -56,8 +54,9 @@ The runtime continues to request stable paths such as:
|
||||
```
|
||||
|
||||
The content resolver maps that path to a verified file in Android app-private
|
||||
storage. Filenames are therefore not used as version numbers and runtime code
|
||||
does not need to know the hash.
|
||||
storage or, in production browsers, to the app-managed content cache through a
|
||||
same-origin service worker. Filenames are therefore not used as version numbers
|
||||
and runtime code does not need to know the hash.
|
||||
|
||||
## Safe publication and activation
|
||||
|
||||
@@ -68,17 +67,34 @@ Publication order is intentionally atomic:
|
||||
3. Write the versioned manifest.
|
||||
4. Replace `manifest.json` only after all referenced objects exist.
|
||||
|
||||
On a device, the client fetches the small manifest and compares it with its
|
||||
installed manifest. Only missing or hash-mismatched objects are downloaded.
|
||||
Android streams each object to a `.part` file, supports HTTP range resume,
|
||||
verifies its size and SHA-256, then renames it into the object cache. The active
|
||||
logical-path map changes only after the complete pack verifies. An interrupted
|
||||
or corrupt update leaves the previous pack playable.
|
||||
The client fetches the small manifest and compares it with its bundled and
|
||||
installed file identities. Only missing or hash-mismatched objects are
|
||||
downloaded. Browsers download into a temporary Cache Storage staging area,
|
||||
verify every object's byte count and SHA-256, and promote the pack to the active
|
||||
cache only after the complete pack succeeds. Android streams each object to a
|
||||
`.part` file, supports HTTP range resume, verifies its size and SHA-256, then
|
||||
renames it into the object cache. An interrupted or corrupt update leaves the
|
||||
previous pack playable on either platform.
|
||||
|
||||
The starter pack is bundled into the APK. Downloaded packs live under Android's
|
||||
app-private files directory, outside the packaged web bundle, and survive an
|
||||
APK update. Players may remove optional packs through the content manager; save
|
||||
data is stored separately and is never removed with content.
|
||||
The Downloads screen uses the same lifecycle in browser and Android. A missing
|
||||
pack offers **Download**, an installed pack with changed files offers
|
||||
**Update**, and an installed optional pack offers **Remove**. Required packs are
|
||||
marked as included and cannot be removed. Browser packs live in the app-managed
|
||||
cache; Android packs live in app-private storage and survive APK updates. Save
|
||||
data and roster identities are stored separately and are never removed with
|
||||
content.
|
||||
|
||||
Character packs are presentation-only. When a selected race's pack is absent,
|
||||
removed, disabled, or fails to load, the shared character renderer uses the
|
||||
matching-gender basic Human model and default appearance. The saved race, class,
|
||||
equipment, progression, and appearance choices remain intact and become visible
|
||||
again after the pack is installed.
|
||||
|
||||
Generated spell effects and synthesized combat cues are the default. The
|
||||
`core:combat-media` pack containing packaged original spell visuals and sounds
|
||||
is never part of required bootstrap content. Original mode becomes available
|
||||
only after an explicit download and falls back to generated effects if the pack
|
||||
is later removed.
|
||||
|
||||
## Offline behavior
|
||||
|
||||
@@ -87,6 +103,8 @@ data is stored separately and is never removed with content.
|
||||
- The last verified content map is read synchronously from local storage.
|
||||
- A failed update check is silent outside the content manager.
|
||||
- A failed or interrupted download never replaces active content.
|
||||
- Missing optional character packs fall back to the basic Human model.
|
||||
- Generated spell effects remain available without original spell media.
|
||||
- Local saves are authoritative during play and cloud synchronization retries
|
||||
when connectivity returns.
|
||||
- Content that has never been downloaded is clearly marked as unavailable
|
||||
@@ -104,9 +122,10 @@ Keeping the runtime in the APK is what makes offline startup reliable.
|
||||
/mnt/usbssds/apps/healer-man/backups Database backups
|
||||
```
|
||||
|
||||
The Node server exposes the content volume at `/content`. Manifests use
|
||||
`no-cache`; immutable hashed objects use long-lived cache headers and support
|
||||
byte ranges for resumed downloads.
|
||||
The Node server exposes the compact production browser client at `/` and the
|
||||
complete downloadable-content volume at `/content`. Manifests use `no-cache`;
|
||||
immutable hashed objects use long-lived cache headers and support byte ranges
|
||||
for resumed downloads.
|
||||
|
||||
After pulling source on TrueNAS, publish content before restarting the app:
|
||||
|
||||
@@ -121,15 +140,29 @@ discover a manifest whose files have not been published.
|
||||
## Pack policy
|
||||
|
||||
Packs are player-facing groups while files remain individually addressable.
|
||||
This permits a fast initial pack install and small later patches. Current pack
|
||||
IDs are derived predictably:
|
||||
This permits a compact initial install and small later patches. Required pack
|
||||
IDs include:
|
||||
|
||||
```text
|
||||
core:runtime
|
||||
core:ui
|
||||
core:equipment
|
||||
core:character-basic
|
||||
dungeon:wailing-caverns
|
||||
```
|
||||
|
||||
`core:character-basic` owns the two basic Human rigs, the character manifest,
|
||||
and only the default texture selections needed by the fallback. The remaining
|
||||
human customizations and every non-Human character model/skin are optional.
|
||||
|
||||
Downloadable pack IDs include:
|
||||
|
||||
```text
|
||||
dungeon:forsaken-abbey
|
||||
characters:wow335a-v1
|
||||
characters:ascension-v1
|
||||
characters:runewaker-v1
|
||||
core:combat-media
|
||||
manastorm:<stage-id>
|
||||
shared:creatures
|
||||
```
|
||||
|
||||
+24
-18
@@ -24,8 +24,9 @@ container, rebuilding `dist`, or pulling source must not replace the database.
|
||||
TrueNAS Gitea stores the source repository and APK release attachments; it is
|
||||
not the game database.
|
||||
|
||||
Large runtime assets are published separately under `/app/content`. Android
|
||||
clients download verified content objects from `/content` and retain them in
|
||||
Large runtime assets are published separately under `/app/content`. Production
|
||||
browser and Android clients download verified content objects from `/content`;
|
||||
browsers retain them in an app-managed cache and Android retains them in
|
||||
app-private storage for offline play. See `CONTENT_UPDATES.md` for the manifest,
|
||||
atomic activation, compatibility, and rollback contract.
|
||||
|
||||
@@ -55,9 +56,10 @@ Container/host port:
|
||||
```
|
||||
|
||||
The Gitea bare repository is not runnable. Clone it with Git; never copy it
|
||||
with `cp`. The checkout contains several gigabytes of required GLB assets, so
|
||||
the initial clone and first build will take substantially longer than later
|
||||
fast-forward updates.
|
||||
with `cp`. The checkout contains several gigabytes of content-source GLB assets,
|
||||
so the initial clone and first content publication will take substantially
|
||||
longer than later fast-forward updates. Production browser and Android bundles
|
||||
stage only their compact required-content subset.
|
||||
|
||||
Do not turn the GLBs into Git LFS pointers without also changing this deployment
|
||||
procedure. Gitea keeps LFS objects outside the bare repository, so a bare-path
|
||||
@@ -80,7 +82,8 @@ git branch --show-current
|
||||
|
||||
If the repository slug differs, change both the remote URL here and every
|
||||
`healer-man.git` bare-repository path below. Do not commit `.env` files,
|
||||
keystores, API tokens, `content`, `dist`, `dist-android`, or `.android-public`.
|
||||
keystores, API tokens, `content`, `dist`, `dist-android`, `.web-public`, or
|
||||
`.android-public`.
|
||||
|
||||
On TrueNAS, confirm the bare repository path. If the first command fails, use
|
||||
the search command:
|
||||
@@ -154,8 +157,9 @@ account/cloud-save data; `/app/content` owns downloadable manifests and
|
||||
immutable content objects. Never mount a volume at `/app/data`, because that
|
||||
would hide the checkout's required `/app/data/loot/schema.sql`. The
|
||||
startup command installs locked dependencies, initializes the schema, publishes
|
||||
new or changed content objects, builds the full browser bundle, and starts the
|
||||
combined static/API/content server. Existing content objects are reused.
|
||||
new or changed content objects, builds the compact required-content browser
|
||||
bundle, and starts the combined static/API/content server. Existing content
|
||||
objects are reused.
|
||||
|
||||
After startup, test on the LAN:
|
||||
|
||||
@@ -220,8 +224,8 @@ git rev-parse HEAD
|
||||
|
||||
Before `git commit`, read the staged file list printed by `git status --short`.
|
||||
Do not commit a `.env` file, password, API token, keystore, APK, database,
|
||||
`content/`, `dist/`, `dist-android/`, or `.android-public/` output. The final
|
||||
command prints the exact deployed commit ID.
|
||||
`content/`, `dist/`, `dist-android/`, `.web-public/`, or `.android-public/`
|
||||
output. The final command prints the exact deployed commit ID.
|
||||
|
||||
Next, open the TrueNAS Apps UI and **stop** `healer-man`. Open a TrueNAS shell
|
||||
and run these exact commands:
|
||||
@@ -247,11 +251,12 @@ sudo git -C /mnt/usbssds/apps/healer-man/app rev-parse HEAD
|
||||
The TrueNAS commit ID must match the one printed on the development PC. Start
|
||||
`healer-man` in the TrueNAS Apps UI afterward. Container startup
|
||||
publishes immutable content objects and replaces `manifest.json` only after all
|
||||
objects exist, then rebuilds the browser client and starts the server. Android
|
||||
devices discover the small new manifest and download only missing or changed
|
||||
files. Asset-only updates do not require a new APK. A failed fast-forward pull
|
||||
deliberately leaves the running checkout unchanged; resolve diverged history on
|
||||
the development machine rather than forcing the TrueNAS checkout.
|
||||
objects exist, then rebuilds the browser client and starts the server. Browser
|
||||
and Android clients discover the small new manifest and download only missing
|
||||
or changed files for packs the player installed. Asset-only updates do not
|
||||
require a new APK. A failed fast-forward pull deliberately leaves the running
|
||||
checkout unchanged; resolve diverged history on the development machine rather
|
||||
than forcing the TrueNAS checkout.
|
||||
|
||||
After the logs say the server is listening on port `4173`, run on TrueNAS:
|
||||
|
||||
@@ -286,9 +291,10 @@ copies; it does not replace dataset-level protection.
|
||||
## APK releases and Obtainium
|
||||
|
||||
Keep APK binaries out of the Git branch. The Android build uses `dist-android`,
|
||||
which contains the game runtime, UI, equipment, and Wailing Caverns starter
|
||||
content rather than the full multi-gigabyte browser catalog. Downloaded packs
|
||||
survive APK updates.
|
||||
which contains the game runtime, UI, starter equipment, basic Human character,
|
||||
and Wailing Caverns starter content. Optional character/skin packs, original
|
||||
spell media, dungeons, and other large content remain outside the APK.
|
||||
Downloaded packs survive APK updates.
|
||||
|
||||
Install JDK 21 and Android SDK Platform 36 on the development PC and set
|
||||
`JAVA_HOME` plus `ANDROID_SDK_ROOT`. The build wrapper also recognizes the
|
||||
|
||||
@@ -1572,3 +1572,116 @@ Vite's existing large-chunk advisory remains informational.
|
||||
106 environment assets passed their performance audit with no regressions,
|
||||
the KTX2 audit found no fallback textures, and the release-candidate check
|
||||
passed. The existing large-chunk advisory remains informational.
|
||||
|
||||
# 2026-09-04 — optional content offload and basic Human fallback
|
||||
|
||||
- Built and served the production browser output from a clean localhost origin.
|
||||
A fresh Undead female Priest preview rendered with the real bundled Human
|
||||
female rig and default textures while retaining the Undead/Priest identity in
|
||||
the roster. No optional character pack had been installed.
|
||||
- Entered Wailing Caverns as healer from a separate clean browser origin. The
|
||||
Undead player, Undead tank, and Goblin party member all rendered through the
|
||||
matching basic Human fallbacks while their saved race, role, class, equipment,
|
||||
and party labels remained intact. The dungeon stayed playable and emitted no
|
||||
new error; only the existing third-party initialization deprecation warning
|
||||
appeared.
|
||||
- Exercised the browser Downloads lifecycle with a small local two-revision
|
||||
content fixture. Missing character and original-media packs showed Download;
|
||||
a verified install showed Remove; the changed revision showed Update and
|
||||
Remove together; and applying Update returned the pack to offline-ready state.
|
||||
- Reloaded after service-worker activation. The application and lazy JavaScript
|
||||
chunks loaded normally, while the content worker continued to reserve managed
|
||||
character, creature, equipment, game, and UI paths for bundled or verified
|
||||
cached content. The initial broad `/assets/` interception found during this
|
||||
playtest was corrected before the final run.
|
||||
- In-dungeon Options showed Generated as the active default, kept Original
|
||||
disabled until its optional spell-effects-and-sounds pack is downloaded, and
|
||||
exposed a Download action for that pack.
|
||||
- Evidence:
|
||||
`playtest-artifacts/2026-09-04-optional-content/roster-undead-priest-basic-human-fallback.png`,
|
||||
`dungeon-optional-models-basic-human-fallback.png`,
|
||||
`browser-downloads-optional-packs.png`, and
|
||||
`browser-downloads-update-and-remove.png`.
|
||||
- Verification: all 43 avatar tests passed; the complete Vitest suite passed all
|
||||
896 cases across 164 files; production browser and Android builds completed;
|
||||
both binary inventories contained only the two Human rigs, starter equipment,
|
||||
and Wailing Caverns content; and neither output contained optional character
|
||||
packs, original combat media, or WAV files. KTX2 and environment-performance
|
||||
audits remained green, and the release-candidate check passed. The existing
|
||||
large-chunk advisory remains informational.
|
||||
|
||||
# 2026-09-05 — Power Word: Shield live character anchoring
|
||||
|
||||
- Installed the optional Original Spell Effects & Sounds pack through the
|
||||
production Downloads screen, enabled it, and selected Original spell visuals.
|
||||
- Entered Wailing Caverns as a Human Priest and cast Power Word: Shield on both
|
||||
the party tank and the player while exercising party movement and combat. The
|
||||
persistent original aura stayed at the rendered character root instead of
|
||||
following the throttled gameplay-position snapshot, with no inherited camera
|
||||
or character rotation.
|
||||
- Reviewed live dungeon screenshots before and during the movement test. The
|
||||
browser emitted no new errors; only the existing third-party initialization
|
||||
deprecation warning remained.
|
||||
- Verification: all 899 Vitest cases passed across 165 files, including three
|
||||
new visual-anchor registry cases. The production build completed with 920
|
||||
modules transformed, and asset audits remained green. The existing
|
||||
large-chunk advisory remains informational.
|
||||
|
||||
# 2026-09-05 — modular RuneWaker boss mechanics
|
||||
|
||||
- Built a temporary local production content feed for Forsaken Abbey and the
|
||||
first two Ystra Labyrinth boss rooms, then installed each pack through the
|
||||
browser Downloads workflow with checksum verification.
|
||||
- Loaded the source-backed Specter Guardian Creator encounter. Its mechanic
|
||||
panel appeared below the minimap and buff/debuff strips with Guard Bomb,
|
||||
Transport Magic, and Meteor Shower countdowns. A live Meteor Shower cast
|
||||
showed its one-second cast warning over the boss.
|
||||
- The first pass exposed that four countdown rows could crowd the boss's
|
||||
pending 80%, 60%, 40%, and 20% phase triggers out of the panel. The timeline
|
||||
now limits only countdown rows and always retains every pending health
|
||||
threshold. The corrected production build displayed all eight rows without
|
||||
covering the central playfield.
|
||||
- Loaded Rune Guardian Leader in Ystra Labyrinth Boss Room 2. Its source-backed
|
||||
Manastorms Duo Lv5 mechanic displayed a large purple circular floor warning
|
||||
while the matching panel row counted down at 0.6 seconds. The area indicator,
|
||||
cast timing, party damage, and effect/debuff presentation all advanced in the
|
||||
live encounter.
|
||||
- Browser logs contained no uncaught application errors. The existing
|
||||
third-party initialization deprecation warning remained; WebGL context-lost
|
||||
messages corresponded to intentional production rebuild reloads and opening
|
||||
replacement test tabs.
|
||||
- Verification: the full suite passed all 913 cases across 169 files, and the
|
||||
corrected production build completed with 923 modules transformed. KTX2 and
|
||||
environment-performance audits remained green, and the release-candidate
|
||||
check passed.
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## Content downloads
|
||||
|
||||
Production browser and Android builds ship a compact required set: the game
|
||||
runtime, interface, starter equipment, Wailing Caverns starter content, and a
|
||||
basic male/female Human character model with its default appearance. Large
|
||||
character and skin libraries, additional dungeons, Manastorm stages, and the
|
||||
original spell-effects and sound pack are optional downloads.
|
||||
|
||||
Open **Downloads** from the main menu to download, update, or remove optional
|
||||
packs on either platform. Browser downloads live in an app-managed verified
|
||||
cache; Android downloads live in app-private storage. Removing a pack never
|
||||
removes a roster or save. If a character's model/skin pack is unavailable, the
|
||||
shared character renderer displays the matching-gender basic Human while
|
||||
preserving that character's identity and progress for a later reinstall.
|
||||
|
||||
Generated spell effects and synthesized combat cues are the default and need
|
||||
no original-media download. Players who want the packaged original visuals and
|
||||
sounds can download that pack explicitly, select **Original** effects, and
|
||||
remove the pack again to reclaim its storage. See
|
||||
[`CONTENT_UPDATES.md`](CONTENT_UPDATES.md) for the content lifecycle and release
|
||||
contract.
|
||||
|
||||
## Conquest of Azeroth classes
|
||||
|
||||
All 21 CoA classes use a checked-in live Vol'Jin catalog generated from
|
||||
@@ -81,12 +103,23 @@ validates the relational data and ships both a browser-safe JSON catalog and
|
||||
for a future loot-browser screen.
|
||||
|
||||
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
|
||||
looted, and is then saved in that character's inventory. The SQL loot source
|
||||
contains the original 3.3.5a attributes, ratings, armor, block, weapon damage,
|
||||
weapon speed, regeneration, and spell-power values for each item. A scaled
|
||||
drop preserves that original stat distribution instead of replacing it with a
|
||||
generic item-power budget.
|
||||
captures its item level at the killing blow, remains on the corpse until
|
||||
looted, and is then saved in that character's inventory. Drops use character
|
||||
level through 79; level-80 drops begin the first endgame tier at item level 85.
|
||||
The SQL loot source contains the original 3.3.5a attributes, ratings, armor,
|
||||
block, weapon damage, weapon speed, regeneration, and spell-power values for
|
||||
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
|
||||
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
|
||||
and install directly onto a connected device, use `npm run android:install`.
|
||||
|
||||
Android uses a separate compact `dist-android` build. It bundles the client,
|
||||
interface, equipment, and Wailing Caverns starter content while optional
|
||||
dungeons, character packs, and Manastorm stages are downloaded into app-private
|
||||
storage. Installed content and local saves remain playable without a server
|
||||
connection and survive APK updates. See `CONTENT_UPDATES.md` for the update
|
||||
contract and `DEPLOYMENT.md` for TrueNAS publishing and signed Gitea releases.
|
||||
Android uses a compact `dist-android` build with the same required-content
|
||||
boundary as the production browser build. Optional dungeons, character/skin
|
||||
packs, Manastorm stages, and original spell media are downloaded into
|
||||
app-private storage. Installed content and local saves remain playable without
|
||||
a server connection and survive APK updates. See `CONTENT_UPDATES.md` for the
|
||||
update contract and `DEPLOYMENT.md` for TrueNAS publishing and signed Gitea
|
||||
releases.
|
||||
|
||||
## Runtime details
|
||||
|
||||
@@ -206,6 +240,8 @@ contract and `DEPLOYMENT.md` for TrueNAS publishing and signed Gitea releases.
|
||||
compressed in GPU memory instead of expanding to uncompressed RGBA textures.
|
||||
- Missing production GLBs activate a small cave proxy; malformed assets activate
|
||||
the recoverable scene-error screen.
|
||||
- Missing optional character or skin packs render the built-in basic Human
|
||||
model instead of hiding the player, party member, or roster preview.
|
||||
- The controller action HUD displays only the active eight-slot layer. Its
|
||||
face-button diamond, shoulders, and D-pad keep the physical controller layout
|
||||
visible without showing all 24 bindings over the playfield.
|
||||
|
||||
+8
-4
@@ -71,9 +71,12 @@ file, use the shorter procedure at the end. Those updates can be published by
|
||||
the server and downloaded by an already-installed APK without an Obtainium
|
||||
release.
|
||||
|
||||
Single-player remains offline-capable. A fresh APK includes the runtime and
|
||||
starter content. Additional content that a player has downloaded is retained
|
||||
in Android app-private storage and can be used offline after download.
|
||||
Single-player remains offline-capable. A fresh APK includes the runtime,
|
||||
interface, starter equipment, Wailing Caverns, and the basic Human character
|
||||
fallback. Additional content that a player has downloaded is retained in
|
||||
Android app-private storage and can be used offline after download. Generated
|
||||
spell effects are available without downloading the optional original-media
|
||||
pack.
|
||||
|
||||
## Fixed locations
|
||||
|
||||
@@ -287,7 +290,8 @@ git diff --cached --check
|
||||
```
|
||||
|
||||
Do not continue if the staged list contains a keystore, APK, `.env` file,
|
||||
database, `content/`, `dist/`, `dist-android/`, or `.android-public/` output.
|
||||
database, `content/`, `dist/`, `dist-android/`, `.web-public/`, or
|
||||
`.android-public/` output.
|
||||
Unstage any accidental file with `git restore --staged -- <path>` without
|
||||
deleting the local file.
|
||||
|
||||
|
||||
@@ -73,6 +73,23 @@ attaches native animated client models by display ID. It writes:
|
||||
- `src/game/generated/dungeonCampaignCatalog.compressed.ts`, the bundled catalog
|
||||
- `src/game/generated/dungeonAvailability.json`, the generated availability list
|
||||
|
||||
Boss specials are compiled as independent mechanic records. AzerothCore
|
||||
SmartAI timers and health events are imported automatically; C++ encounter
|
||||
spells are imported from the pinned script checkout. Add or override a boss in
|
||||
`dungeon-pipeline/boss-mechanics.json` when an encounter needs hand-authored
|
||||
targeting, summon behavior, timing, or telegraph geometry. Use `mode: "replace"`
|
||||
to make that list authoritative or `mode: "append"` to layer entries ahead of
|
||||
the imported mechanics, then run `npm run dungeon:catalog`. The generated
|
||||
`combat.mechanicIds` list is the stable loadout boundary intended for later
|
||||
encounter randomization.
|
||||
|
||||
The same catalog also stores Runewaker kits under
|
||||
`rw-<dungeon>-<entity-id>` keys. Run `npm run runewaker:bosses:refresh` to
|
||||
rebuild those records from preserved RuneWaker spell/Lua evidence, regenerate
|
||||
the population packages, and run `npm run runewaker:audit:gameplay`. A record
|
||||
whose preserved source has no recoverable combat ability is explicitly marked
|
||||
`source.kind: "healer-man-source-gap"` rather than being claimed as authentic.
|
||||
|
||||
Stock AzerothCore population is used for the standard dungeons. The three
|
||||
Ascension-specific instances—Tinkertech Showdown, Frozen Reach, and Forgotten
|
||||
Mine—keep their client encounter identities and use an explicit procedural
|
||||
@@ -229,4 +246,4 @@ Kresh is intentionally recorded as a source-faithful melee-only boss: entry
|
||||
3653 has no spell or SmartAI row in the pinned AzerothCore snapshot. Trial of
|
||||
the Champion is likewise documented as a boss-arena population rather than a
|
||||
trash route. These exceptions are asserted by exact dungeon/entity IDs; any
|
||||
new basic-only boss or mob-less dungeon fails the batch.
|
||||
new basic-only boss or mob-less dungeon fails the batch.
|
||||
File diff suppressed because it is too large.
Load diff
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.15",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.15",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
@@ -10,6 +10,7 @@
|
||||
"db:backup": "node scripts/backup-game-database.mjs",
|
||||
"server:test": "node --test server/*.test.mjs",
|
||||
"content:publish": "node scripts/content/publish-content.mjs",
|
||||
"web:prepare": "node scripts/content/prepare-web-bootstrap.mjs",
|
||||
"android:prepare": "node scripts/content/prepare-android-bootstrap.mjs",
|
||||
"content:test": "node --test scripts/content/*.test.mjs",
|
||||
"effects:generate": "node scripts/combat-effects/generate-manifest.mjs --package-audio && node scripts/combat-effects/package-priest-original.mjs",
|
||||
@@ -73,6 +74,7 @@
|
||||
"runewaker:register:all": "node scripts/runewaker-pipeline/cli.mjs register-all",
|
||||
"runewaker:refresh:all": "node scripts/runewaker-pipeline/cli.mjs refresh-all",
|
||||
"runewaker:spells:refresh": "node scripts/runewaker-pipeline/export-spell-catalog.mjs",
|
||||
"runewaker:bosses:refresh": "npm run runewaker:spells:refresh && node scripts/runewaker-pipeline/import-boss-mechanics.mjs",
|
||||
"runewaker:players:refresh": "node scripts/runewaker-pipeline/export-player-catalog.mjs && npm run runewaker:players:avatars",
|
||||
"runewaker:players:avatars": "node scripts/runewaker-pipeline/build-population.mjs --recipe scripts/runewaker-pipeline/recipes/runewaker-player-avatars.json --actors-only && node scripts/runewaker-pipeline/finalize-player-avatar-manifest.mjs",
|
||||
"runewaker:paperdolls:refresh": "node scripts/runewaker-pipeline/refresh-paperdoll-appearances.mjs",
|
||||
@@ -96,7 +98,7 @@
|
||||
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
|
||||
"balance:benchmark": "vite-node scripts/combat-benchmark/run.ts",
|
||||
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
|
||||
"prebuild": "npm run loot:generate && npm run assets:ktx2:audit && npm run assets:performance:audit",
|
||||
"prebuild": "npm run loot:generate && npm run web:prepare && npm run assets:ktx2:audit && npm run assets:performance:audit",
|
||||
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
|
||||
"build:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android",
|
||||
"android:sync": "npm run build:android:web && cap sync android",
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,68 @@
|
||||
const CONTENT_CACHE_NAME = "healer-man-content-v1";
|
||||
const MANAGED_ASSET_PREFIXES = [
|
||||
"/assets/characters/",
|
||||
"/assets/creatures/",
|
||||
"/assets/equipment/",
|
||||
"/assets/game/",
|
||||
"/assets/ui/",
|
||||
];
|
||||
const BUNDLED_ASSET_PREFIXES = [
|
||||
"/assets/ui/",
|
||||
"/assets/equipment/",
|
||||
"/assets/characters/wow335a-v1/manifest.json",
|
||||
"/assets/characters/wow335a-v1/human/",
|
||||
"/assets/creatures/wailing-caverns/",
|
||||
"/assets/game/dungeons/wailing-caverns/",
|
||||
];
|
||||
const BUNDLED_ASSET_PATHS = new Set([
|
||||
"/assets/characters/wow335a-v1/textures/character/human/faciallowerhair00_00.webp",
|
||||
"/assets/characters/wow335a-v1/textures/character/human/facialupperhair00_00.webp",
|
||||
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemalefacelower00_00.webp",
|
||||
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemalefaceupper00_00.webp",
|
||||
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedpelvisskin00_00.webp",
|
||||
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedtorsoskin00_00.webp",
|
||||
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemaleskin00_00.webp",
|
||||
"/assets/characters/wow335a-v1/textures/character/human/hair00_00.webp",
|
||||
"/assets/characters/wow335a-v1/textures/character/human/male/humanmalefacelower00_00.webp",
|
||||
"/assets/characters/wow335a-v1/textures/character/human/male/humanmalefaceupper00_00.webp",
|
||||
"/assets/characters/wow335a-v1/textures/character/human/male/humanmalenakedpelvisskin00_00.webp",
|
||||
"/assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp",
|
||||
"/assets/characters/wow335a-v1/textures/character/human/scalplowerhair00_00.webp",
|
||||
"/assets/characters/wow335a-v1/textures/character/human/scalpupperhair00_00.webp",
|
||||
]);
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(self.skipWaiting());
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil((async () => {
|
||||
const names = await caches.keys();
|
||||
await Promise.all(names
|
||||
.filter((name) => name.startsWith(`${CONTENT_CACHE_NAME}-staging-`))
|
||||
.map((name) => caches.delete(name)));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== "GET") return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin
|
||||
|| !MANAGED_ASSET_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) return;
|
||||
|
||||
event.respondWith((async () => {
|
||||
const cache = await caches.open(CONTENT_CACHE_NAME);
|
||||
const cached = await cache.match(request, { ignoreSearch: true });
|
||||
if (cached) return cached;
|
||||
if (BUNDLED_ASSET_PATHS.has(url.pathname)
|
||||
|| BUNDLED_ASSET_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) {
|
||||
return fetch(request);
|
||||
}
|
||||
return new Response("Optional content is not installed.", {
|
||||
status: 404,
|
||||
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
})());
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.13",
|
||||
"versionCode": 1013
|
||||
"versionName": "0.1.15",
|
||||
"versionCode": 1015
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
BASIC_HUMAN_TEXTURE_PATHS,
|
||||
PROJECT_ROOT,
|
||||
createContentManifest,
|
||||
scanContentSources,
|
||||
@@ -12,8 +13,21 @@ if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirect
|
||||
}
|
||||
|
||||
const sources = [
|
||||
{ directory: path.join(PROJECT_ROOT, "public", "basis"), logicalPrefix: "basis" },
|
||||
{ directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" },
|
||||
{ directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" },
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "manifest.json"),
|
||||
logicalPrefix: "assets/characters/wow335a-v1/manifest.json",
|
||||
},
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "human"),
|
||||
logicalPrefix: "assets/characters/wow335a-v1/human",
|
||||
},
|
||||
...BASIC_HUMAN_TEXTURE_PATHS.map((logicalPath) => ({
|
||||
directory: path.join(PROJECT_ROOT, "public", ...logicalPath.split("/")),
|
||||
logicalPrefix: logicalPath,
|
||||
})),
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
|
||||
logicalPrefix: "assets/creatures/wailing-caverns",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
BASIC_HUMAN_TEXTURE_PATHS,
|
||||
PROJECT_ROOT,
|
||||
createContentManifest,
|
||||
scanContentSources,
|
||||
} from "./publish-content.mjs";
|
||||
|
||||
const outputDirectory = path.join(PROJECT_ROOT, ".web-public");
|
||||
if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirectory) !== ".web-public") {
|
||||
throw new Error(`Refusing to replace unexpected browser staging directory: ${outputDirectory}`);
|
||||
}
|
||||
|
||||
const sources = [
|
||||
{ directory: path.join(PROJECT_ROOT, "public", "basis"), logicalPrefix: "basis" },
|
||||
{ directory: path.join(PROJECT_ROOT, "public", "data"), logicalPrefix: "data" },
|
||||
{ directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" },
|
||||
{ directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" },
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "manifest.json"),
|
||||
logicalPrefix: "assets/characters/wow335a-v1/manifest.json",
|
||||
},
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "human"),
|
||||
logicalPrefix: "assets/characters/wow335a-v1/human",
|
||||
},
|
||||
...BASIC_HUMAN_TEXTURE_PATHS.map((logicalPath) => ({
|
||||
directory: path.join(PROJECT_ROOT, "public", ...logicalPath.split("/")),
|
||||
logicalPrefix: logicalPath,
|
||||
})),
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
|
||||
logicalPrefix: "assets/creatures/wailing-caverns",
|
||||
},
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
|
||||
logicalPrefix: "assets/game/dungeons/wailing-caverns",
|
||||
},
|
||||
];
|
||||
|
||||
await rm(outputDirectory, { recursive: true, force: true });
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
const files = await scanContentSources(sources);
|
||||
for (const file of files) {
|
||||
const destination = path.join(outputDirectory, ...file.logicalPath.split("/"));
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
await copyFile(file.sourcePath, destination);
|
||||
}
|
||||
|
||||
await copyFile(
|
||||
path.join(PROJECT_ROOT, "public", "content-cache-sw.js"),
|
||||
path.join(outputDirectory, "content-cache-sw.js"),
|
||||
);
|
||||
|
||||
const generated = createContentManifest(files, { baseUrl: "/" });
|
||||
const bootstrapManifest = {
|
||||
...generated,
|
||||
files: Object.fromEntries(Object.entries(generated.files).map(([logicalPath, entry]) => [
|
||||
logicalPath,
|
||||
{ ...entry, url: logicalPath },
|
||||
])),
|
||||
};
|
||||
const manifestPath = path.join(outputDirectory, "content", "bootstrap-manifest.json");
|
||||
await mkdir(path.dirname(manifestPath), { recursive: true });
|
||||
await writeFile(manifestPath, `${JSON.stringify(bootstrapManifest, null, 2)}\n`, "utf8");
|
||||
|
||||
const bytes = files.reduce((total, file) => total + file.bytes, 0);
|
||||
console.log(`Prepared browser bootstrap content: ${files.length} files (${bytes} bytes)`);
|
||||
console.log(`Bootstrap version: ${bootstrapManifest.version}`);
|
||||
@@ -18,14 +18,34 @@ export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0";
|
||||
export const BUNDLED_ANDROID_PACKS = Object.freeze([
|
||||
"core:ui",
|
||||
"core:equipment",
|
||||
"core:character-basic",
|
||||
"core:runtime",
|
||||
"dungeon:wailing-caverns",
|
||||
]);
|
||||
|
||||
const RUNTIME_EXTENSIONS = new Set([
|
||||
".avif", ".glb", ".json", ".ktx2", ".mp3", ".ogg", ".png",
|
||||
".svg", ".wav", ".webm", ".webp",
|
||||
".avif", ".glb", ".js", ".json", ".ktx2", ".mp3", ".ogg", ".png",
|
||||
".svg", ".wasm", ".wav", ".webm", ".webp",
|
||||
]);
|
||||
|
||||
export const BASIC_HUMAN_TEXTURE_PATHS = Object.freeze([
|
||||
"assets/characters/wow335a-v1/textures/character/human/faciallowerhair00_00.webp",
|
||||
"assets/characters/wow335a-v1/textures/character/human/facialupperhair00_00.webp",
|
||||
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalefacelower00_00.webp",
|
||||
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalefaceupper00_00.webp",
|
||||
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedpelvisskin00_00.webp",
|
||||
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedtorsoskin00_00.webp",
|
||||
"assets/characters/wow335a-v1/textures/character/human/female/humanfemaleskin00_00.webp",
|
||||
"assets/characters/wow335a-v1/textures/character/human/hair00_00.webp",
|
||||
"assets/characters/wow335a-v1/textures/character/human/male/humanmalefacelower00_00.webp",
|
||||
"assets/characters/wow335a-v1/textures/character/human/male/humanmalefaceupper00_00.webp",
|
||||
"assets/characters/wow335a-v1/textures/character/human/male/humanmalenakedpelvisskin00_00.webp",
|
||||
"assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp",
|
||||
"assets/characters/wow335a-v1/textures/character/human/scalplowerhair00_00.webp",
|
||||
"assets/characters/wow335a-v1/textures/character/human/scalpupperhair00_00.webp",
|
||||
]);
|
||||
const BASIC_HUMAN_TEXTURE_PATH_SET = new Set(BASIC_HUMAN_TEXTURE_PATHS);
|
||||
|
||||
export const DEFAULT_CONTENT_SOURCES = Object.freeze([
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets"),
|
||||
@@ -49,18 +69,33 @@ function packLabel(packId) {
|
||||
const [category, id = category] = packId.split(":", 2);
|
||||
const name = id.split("-").map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`).join(" ");
|
||||
if (category === "dungeon") return name;
|
||||
if (category === "characters") return `${name} Characters`;
|
||||
if (packId === "characters:wow335a-v1") return "Extra Wrath Character Models & Skins";
|
||||
if (packId === "characters:ascension-v1") return "Ascension Character Models & Skins";
|
||||
if (packId === "characters:runewaker-v1") return "RuneWaker Character Models & Skins";
|
||||
if (category === "characters") return `${name} Character Models & Skins`;
|
||||
if (category === "manastorm") return `${name} Manastorm`;
|
||||
if (packId === "shared:creatures") return "Shared Creatures";
|
||||
if (packId === "core:ui") return "Interface";
|
||||
if (packId === "core:equipment") return "Starter Equipment";
|
||||
if (packId === "core:character-basic") return "Basic Human Character Model";
|
||||
if (packId === "core:runtime") return "Game Runtime";
|
||||
if (packId === "core:combat-media") return "Original Spell Effects & Sounds";
|
||||
return name;
|
||||
}
|
||||
|
||||
export function contentPackForPath(logicalPath) {
|
||||
const segments = normalizedRelativePath(logicalPath).split("/");
|
||||
const normalizedPath = normalizedRelativePath(logicalPath);
|
||||
const segments = normalizedPath.split("/");
|
||||
if (segments[0] === "basis" || segments[0] === "data") return "core:runtime";
|
||||
if (segments[0] !== "assets") return "core:misc";
|
||||
if (segments[1] === "characters" && segments[2]) return `characters:${segments[2]}`;
|
||||
if (segments[1] === "characters" && segments[2]) {
|
||||
const basicWrathHuman = segments[2] === "wow335a-v1" && (
|
||||
segments[3] === "manifest.json"
|
||||
|| segments[3] === "human"
|
||||
|| BASIC_HUMAN_TEXTURE_PATH_SET.has(normalizedPath)
|
||||
);
|
||||
return basicWrathHuman ? "core:character-basic" : `characters:${segments[2]}`;
|
||||
}
|
||||
if (segments[1] === "creatures" && segments[2]) {
|
||||
return segments[2] === "shared" ? "shared:creatures" : `dungeon:${segments[2]}`;
|
||||
}
|
||||
@@ -73,12 +108,17 @@ export function contentPackForPath(logicalPath) {
|
||||
if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) {
|
||||
return `manastorm:${segments[3]}`;
|
||||
}
|
||||
if (segments[1] === "game" && segments[2] === "combat-effects") return "core:combat-media";
|
||||
if (segments[1] === "ui") return "core:ui";
|
||||
if (segments[1] === "equipment") return "core:equipment";
|
||||
return "core:misc";
|
||||
}
|
||||
|
||||
async function walkFiles(directory) {
|
||||
const rootStats = await stat(directory);
|
||||
if (rootStats.isFile()) {
|
||||
return RUNTIME_EXTENSIONS.has(path.extname(directory).toLowerCase()) ? [directory] : [];
|
||||
}
|
||||
const files = [];
|
||||
async function visit(current) {
|
||||
const entries = await readdir(current, { withFileTypes: true });
|
||||
@@ -111,7 +151,9 @@ export async function scanContentSources(sources = DEFAULT_CONTENT_SOURCES) {
|
||||
const files = await walkFiles(sourceDirectory);
|
||||
for (const sourcePath of files) {
|
||||
const relative = normalizedRelativePath(path.relative(sourceDirectory, sourcePath));
|
||||
const logicalPath = normalizedRelativePath(`${source.logicalPrefix}/${relative}`);
|
||||
const logicalPath = normalizedRelativePath(relative
|
||||
? `${source.logicalPrefix}/${relative}`
|
||||
: source.logicalPrefix);
|
||||
const fileStats = await stat(sourcePath);
|
||||
const sha256 = await sha256File(sourcePath);
|
||||
const prior = discovered.get(logicalPath);
|
||||
@@ -170,11 +212,17 @@ export function createContentManifest(files, options = {}) {
|
||||
|
||||
if (packs.has("shared:creatures")) {
|
||||
for (const pack of packs.values()) {
|
||||
if (pack.id.startsWith("dungeon:") || pack.id.startsWith("manastorm:")) {
|
||||
if ((pack.id.startsWith("dungeon:") && pack.id !== "dungeon:wailing-caverns")
|
||||
|| pack.id.startsWith("manastorm:")) {
|
||||
pack.dependencies = ["shared:creatures"];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (packs.has("core:character-basic")) {
|
||||
for (const pack of packs.values()) {
|
||||
if (pack.id.startsWith("characters:")) pack.dependencies = ["core:character-basic"];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: CONTENT_SCHEMA_VERSION,
|
||||
|
||||
@@ -4,6 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
BUNDLED_ANDROID_PACKS,
|
||||
contentPackForPath,
|
||||
createContentManifest,
|
||||
publishContent,
|
||||
@@ -13,8 +14,19 @@ import {
|
||||
test("assigns stable player-facing packs from logical asset paths", () => {
|
||||
assert.equal(contentPackForPath("assets/game/dungeons/wailing-caverns/visual.glb"), "dungeon:wailing-caverns");
|
||||
assert.equal(contentPackForPath("assets/creatures/wailing-caverns/mutanus.glb"), "dungeon:wailing-caverns");
|
||||
assert.equal(contentPackForPath("assets/characters/wow335a-v1/manifest.json"), "characters:wow335a-v1");
|
||||
assert.equal(contentPackForPath("assets/characters/wow335a-v1/manifest.json"), "core:character-basic");
|
||||
assert.equal(contentPackForPath("assets/characters/wow335a-v1/human/male/rig.glb"), "core:character-basic");
|
||||
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp"), "core:character-basic");
|
||||
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_01.webp"), "characters:wow335a-v1");
|
||||
assert.equal(contentPackForPath("assets/characters/wow335a-v1/orc/male/rig.glb"), "characters:wow335a-v1");
|
||||
assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1");
|
||||
assert.equal(contentPackForPath("assets/game/combat-effects/audio/smite.wav"), "core:combat-media");
|
||||
assert.equal(
|
||||
contentPackForPath("assets/game/combat-effects/audio/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"),
|
||||
"core:combat-media",
|
||||
);
|
||||
assert.equal(BUNDLED_ANDROID_PACKS.includes("core:combat-media"), false);
|
||||
assert.equal(contentPackForPath("basis/basis_transcoder.wasm"), "core:runtime");
|
||||
});
|
||||
|
||||
test("publishes objects before a deterministic latest manifest", async () => {
|
||||
|
||||
@@ -47,6 +47,14 @@ const azerothCoreReferenceFile = path.join(
|
||||
"generated",
|
||||
"azerothCoreReference.json",
|
||||
);
|
||||
const azerothCoreWorldAiFile = path.join(
|
||||
projectRoot,
|
||||
"src",
|
||||
"game",
|
||||
"generated",
|
||||
"azerothCoreWorldAi.json",
|
||||
);
|
||||
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
|
||||
const encounterFile = path.join(projectRoot, "src", "game", "generated", "manastormRuntimeCatalog.json");
|
||||
const sourceCatalogFile = path.join(projectRoot, "src", "game", "generated", "manastormCatalog.json");
|
||||
const epochCatalogFile = path.join(
|
||||
@@ -533,8 +541,194 @@ function fallbackSpecial(id, boss) {
|
||||
};
|
||||
}
|
||||
|
||||
function combatFor(entity, boss, spells, scriptCatalog = undefined) {
|
||||
function validateBossMechanicsCatalog(catalog) {
|
||||
if (catalog.schemaVersion !== 1) {
|
||||
throw new Error(`Unsupported boss mechanic schema ${catalog.schemaVersion}.`);
|
||||
}
|
||||
const deliveries = new Set(["melee", "projectile", "area"]);
|
||||
const targets = new Set(["primary", "random-party", "nearby-party", "self", "lowest-health-friendly"]);
|
||||
const schools = new Set(["physical", "arcane", "fire", "frost", "nature", "shadow", "holy"]);
|
||||
const animations = new Set(["attack", "cast"]);
|
||||
for (const [entry, boss] of Object.entries(catalog.bosses ?? {})) {
|
||||
if (!/^\d+$/.test(entry) && !/^rw-[a-z0-9-]+-\d+$/.test(entry)) {
|
||||
throw new Error(`Boss mechanic key ${entry} is not a creature entry or RuneWaker entity id.`);
|
||||
}
|
||||
if (!["append", "replace"].includes(boss.mode)) {
|
||||
throw new Error(`${entry}: mechanic mode must be append or replace.`);
|
||||
}
|
||||
if (!Array.isArray(boss.mechanics)) throw new Error(`${entry}: mechanics must be an array.`);
|
||||
const ids = new Set();
|
||||
for (const mechanic of boss.mechanics) {
|
||||
if (!mechanic.id || ids.has(mechanic.id)) {
|
||||
throw new Error(`${entry}: mechanic ids must be non-empty and unique (${mechanic.id}).`);
|
||||
}
|
||||
ids.add(mechanic.id);
|
||||
if (!mechanic.name || !deliveries.has(mechanic.delivery) || !targets.has(mechanic.target)
|
||||
|| !schools.has(mechanic.school) || !animations.has(mechanic.animation)
|
||||
|| !Number.isFinite(mechanic.cooldownMs) || mechanic.cooldownMs <= 0
|
||||
|| !Number.isFinite(mechanic.range) || mechanic.range < 0) {
|
||||
throw new Error(`${entry}:${mechanic.id}: mechanic fields are incomplete or invalid.`);
|
||||
}
|
||||
if (mechanic.telegraph && !["circle", "cone", "line"].includes(mechanic.telegraph.shape)) {
|
||||
throw new Error(`${entry}:${mechanic.id}: unsupported telegraph shape.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function smartAiEventProperties(row) {
|
||||
if (row.event_type === 0) {
|
||||
const initial = [Number(row.event_param1) || 0, Number(row.event_param2) || Number(row.event_param1) || 0];
|
||||
const repeat = [Number(row.event_param3) || 1_000, Number(row.event_param4) || Number(row.event_param3) || 1_000];
|
||||
return {
|
||||
initialCooldownMs: initial,
|
||||
repeatCooldownMs: repeat,
|
||||
cooldownMs: Math.max(1_000, Math.round((repeat[0] + repeat[1]) / 2)),
|
||||
};
|
||||
}
|
||||
if (row.event_type === 2) {
|
||||
return {
|
||||
initialCooldownMs: [0, 0],
|
||||
repeatCooldownMs: [1_000, 1_000],
|
||||
maximumCasts: 1,
|
||||
cooldownMs: 1_000,
|
||||
condition: {
|
||||
kind: "self-health-percent",
|
||||
minimum: Math.max(0, Number(row.event_param1) || 0),
|
||||
maximum: Math.min(100, Number(row.event_param2) || 100),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (row.event_type === 4) {
|
||||
return {
|
||||
initialCooldownMs: [0, 0],
|
||||
repeatCooldownMs: [1_000, 1_000],
|
||||
maximumCasts: 1,
|
||||
cooldownMs: 1_000,
|
||||
};
|
||||
}
|
||||
if (row.event_type === 14) {
|
||||
return {
|
||||
initialCooldownMs: [0, 0],
|
||||
repeatCooldownMs: [1_000, 1_000],
|
||||
cooldownMs: 1_000,
|
||||
condition: {
|
||||
kind: "friendly-missing-health",
|
||||
amount: Math.max(1, Number(row.event_param1) || 1),
|
||||
range: Math.max(1, Number(row.event_param2) || 30),
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function smartAiMechanicsFor(entity, boss, spells, worldAi) {
|
||||
if (!boss) return [];
|
||||
const entry = Number(entity.entry);
|
||||
const rows = worldAi?.smartScriptsByEntry?.[String(entry)] ?? [];
|
||||
const rowsById = new Map(rows.map((row) => [Number(row.id), row]));
|
||||
const mechanics = [];
|
||||
for (const trigger of rows.filter((row) => Number(row.event_type) !== 61)) {
|
||||
const event = smartAiEventProperties(trigger);
|
||||
if (!event) continue;
|
||||
const chain = [];
|
||||
let row = trigger;
|
||||
const visited = new Set();
|
||||
while (row && !visited.has(Number(row.id))) {
|
||||
visited.add(Number(row.id));
|
||||
chain.push(row);
|
||||
row = Number(row.link) ? rowsById.get(Number(row.link)) : undefined;
|
||||
}
|
||||
for (const action of chain) {
|
||||
const actionType = Number(action.action_type);
|
||||
const source = {
|
||||
entry,
|
||||
sourceType: Number(action.source_type) || 0,
|
||||
rowId: Number(action.id) || 0,
|
||||
eventType: Number(trigger.event_type) || 0,
|
||||
actionType,
|
||||
targetType: Number(action.target_type) || 0,
|
||||
comment: String(action.comment ?? trigger.comment ?? ""),
|
||||
};
|
||||
if (actionType === 11) {
|
||||
const spellId = Number(action.action_param1);
|
||||
const spell = spells.get(spellId);
|
||||
if (!spell) continue;
|
||||
const hash = stableHash(`${entry}:${spellId}:${action.id}`);
|
||||
const attack = spellAttack(spell, hash, true);
|
||||
mechanics.push({
|
||||
...attack,
|
||||
...event,
|
||||
id: `smart-${entry}-${action.id}-${slugPart(spell.name)}`,
|
||||
target: [5, 6].includes(Number(action.target_type))
|
||||
? "random-party"
|
||||
: attack.target,
|
||||
source,
|
||||
});
|
||||
} else if (actionType === 12) {
|
||||
const creatureEntry = Number(action.action_param1);
|
||||
if (!creatureEntry) continue;
|
||||
mechanics.push({
|
||||
id: `smart-${entry}-${action.id}-summon-${creatureEntry}`,
|
||||
name: String(action.comment ?? "Summon Reinforcements")
|
||||
.replace(/^.*? - (?:In Combat|Health Between [^-]+) - /, ""),
|
||||
delivery: "area",
|
||||
target: "self",
|
||||
school: "physical",
|
||||
animation: "cast",
|
||||
castTimeMs: 1_200,
|
||||
interruptible: false,
|
||||
range: 0,
|
||||
damageMultiplier: 0,
|
||||
effect: { kind: "summon", creatureEntry, count: 1 },
|
||||
...event,
|
||||
source,
|
||||
});
|
||||
} else if (actionType === 39) {
|
||||
const radius = Math.max(1, Number(action.action_param1) || 30);
|
||||
mechanics.push({
|
||||
id: `smart-${entry}-${action.id}-call-for-help`,
|
||||
name: "Call for Help",
|
||||
delivery: "area",
|
||||
target: "self",
|
||||
school: "physical",
|
||||
animation: "cast",
|
||||
castTimeMs: 800,
|
||||
interruptible: false,
|
||||
range: 0,
|
||||
damageMultiplier: 0,
|
||||
effect: { kind: "call-for-help", radius },
|
||||
...event,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return mechanics;
|
||||
}
|
||||
|
||||
function uniqueMechanics(mechanics) {
|
||||
const seenIds = new Set();
|
||||
const seenSpellIds = new Set();
|
||||
return mechanics.filter((mechanic) => {
|
||||
if (seenIds.has(mechanic.id)) return false;
|
||||
if (mechanic.spellId && seenSpellIds.has(mechanic.spellId)) return false;
|
||||
seenIds.add(mechanic.id);
|
||||
if (mechanic.spellId) seenSpellIds.add(mechanic.spellId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function combatFor(
|
||||
entity,
|
||||
boss,
|
||||
spells,
|
||||
scriptCatalog = undefined,
|
||||
worldAi = undefined,
|
||||
bossMechanics = undefined,
|
||||
) {
|
||||
const hash = stableHash(`${entity.id}:${entity.name}`);
|
||||
const entry = Number(entity.entry);
|
||||
const basicCooldown = Math.max(1_250, Math.min(3_200, Number(entity.baseAttackTimeMs) || 1_900));
|
||||
const scriptDetails = (scriptCatalog?.scripts?.[entity.scriptName]?.spells ?? [])
|
||||
.filter((spell) => spell.combat);
|
||||
@@ -565,20 +759,27 @@ function combatFor(entity, boss, spells, scriptCatalog = undefined) {
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, boss ? 4 : 2)
|
||||
.map((spell) => spellAttack(spell, hash, boss));
|
||||
if (!spellAttacks.length && boss) spellAttacks.push(fallbackSpecial(entity.id, true));
|
||||
const importedMechanics = smartAiMechanicsFor(entity, boss, spells, worldAi);
|
||||
const authored = boss ? bossMechanics?.bosses?.[String(entry)] : undefined;
|
||||
let mechanics = authored?.mode === "replace"
|
||||
? authored.mechanics ?? []
|
||||
: [...(authored?.mechanics ?? []), ...importedMechanics, ...spellAttacks];
|
||||
mechanics = uniqueMechanics(mechanics);
|
||||
if (!mechanics.length && boss && !authored) mechanics.push(fallbackSpecial(entity.id, true));
|
||||
const levels = (entity.level ?? []).map(Number).filter(Number.isFinite);
|
||||
const level = levels.length
|
||||
? Math.max(1, Math.round(levels.reduce((sum, value) => sum + value, 0) / levels.length))
|
||||
: undefined;
|
||||
return {
|
||||
...(entry ? { serverEntry: entry } : {}),
|
||||
...(level ? { level } : {}),
|
||||
healthMultiplier: boss ? 1.15 + Math.min(1.5, Number(entity.rank || 0) * 0.18) : 1,
|
||||
damageMultiplier: boss ? 1.12 : 1,
|
||||
moveSpeed: Math.max(2.4, Math.min(5.2, 3.2 * (Number(entity.runSpeed) || 1))),
|
||||
leashRange: boss ? 70 : 52,
|
||||
attacks: [basicAttack(basicCooldown), ...spellAttacks],
|
||||
...(boss ? { mechanicIds: mechanics.map((mechanic) => mechanic.id) } : {}),
|
||||
attacks: [basicAttack(basicCooldown), ...mechanics],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -596,6 +797,7 @@ function wailingServerCombatFor(entity, boss, reference) {
|
||||
damageMultiplier: 1,
|
||||
moveSpeed: Math.max(2.4, Math.min(5.2, 3.2 * (Number(entity.runSpeed) || 1))),
|
||||
leashRange: boss ? 55 : 42,
|
||||
...(boss ? { mechanicIds: scripted.map((attack) => attack.id) } : {}),
|
||||
attacks: [{
|
||||
...basicAttack(basicCooldown),
|
||||
initialCooldownMs: [450, 450],
|
||||
@@ -664,7 +866,7 @@ function offsetPosition(position, index, radius = 3.2) {
|
||||
];
|
||||
}
|
||||
|
||||
function syntheticEntity(id, name, boss, level) {
|
||||
function syntheticEntity(id, name, boss, level, bossMechanics = undefined) {
|
||||
const entity = {
|
||||
id,
|
||||
entry: Number(id.match(/\d+/)?.[0] ?? stableHash(id)),
|
||||
@@ -681,7 +883,7 @@ function syntheticEntity(id, name, boss, level) {
|
||||
name,
|
||||
...(boss ? { title: "Dungeon Boss", hasLoot: true } : { hasLoot: false }),
|
||||
combat: {
|
||||
...combatFor(entity, boss, new Map()),
|
||||
...combatFor(entity, boss, new Map(), undefined, undefined, bossMechanics),
|
||||
...(!boss ? { attacks: [basicAttack(), fallbackSpecial(id, false)] } : {}),
|
||||
},
|
||||
visual: visualFor(id, name, boss, entity.rank),
|
||||
@@ -764,6 +966,9 @@ async function compile() {
|
||||
const scriptSpellCatalog = await exists(scriptSpellCatalogFile)
|
||||
? await readJson(scriptSpellCatalogFile)
|
||||
: { scripts: {} };
|
||||
const azerothCoreWorldAi = await readJson(azerothCoreWorldAiFile);
|
||||
const bossMechanics = await readJson(bossMechanicsFile);
|
||||
validateBossMechanicsCatalog(bossMechanics);
|
||||
const azerothCoreReference = await readJson(azerothCoreReferenceFile);
|
||||
if (azerothCoreReference.wailingCaverns.unsupportedRequiredBossActions.length) {
|
||||
throw new Error("Wailing Caverns has unsupported required-boss SmartAI actions.");
|
||||
@@ -798,6 +1003,14 @@ async function compile() {
|
||||
for (const detail of scriptSpellCatalog.scripts?.[entity.scriptName]?.spells ?? []) {
|
||||
if (detail.combat) wantedSpellIds.add(Number(detail.spellId));
|
||||
}
|
||||
for (const row of azerothCoreWorldAi.smartScriptsByEntry?.[String(entity.entry)] ?? []) {
|
||||
if (Number(row.action_type) === 11) wantedSpellIds.add(Number(row.action_param1));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const boss of Object.values(bossMechanics.bosses ?? {})) {
|
||||
for (const mechanic of boss.mechanics ?? []) {
|
||||
if (mechanic.spellId) wantedSpellIds.add(Number(mechanic.spellId));
|
||||
}
|
||||
}
|
||||
const spellDbc = path.resolve(process.env.ASCENSION_SPELL_DBC ?? defaultSpellDbc);
|
||||
@@ -897,7 +1110,14 @@ async function compile() {
|
||||
...(boss ? { title: "Dungeon Boss", hasLoot: true } : {}),
|
||||
combat: recipe.slug === "wailing-caverns"
|
||||
? wailingServerCombatFor(source, boss, azerothCoreReference)
|
||||
: combatFor(source, boss, spells, scriptSpellCatalog),
|
||||
: combatFor(
|
||||
source,
|
||||
boss,
|
||||
spells,
|
||||
scriptSpellCatalog,
|
||||
azerothCoreWorldAi,
|
||||
bossMechanics,
|
||||
),
|
||||
visual: visualFor(
|
||||
source.id,
|
||||
source.name,
|
||||
@@ -920,7 +1140,14 @@ async function compile() {
|
||||
hasLoot: true,
|
||||
combat: recipe.slug === "wailing-caverns"
|
||||
? wailingServerCombatFor(source, true, azerothCoreReference)
|
||||
: combatFor(source, true, spells, scriptSpellCatalog),
|
||||
: combatFor(
|
||||
source,
|
||||
true,
|
||||
spells,
|
||||
scriptSpellCatalog,
|
||||
azerothCoreWorldAi,
|
||||
bossMechanics,
|
||||
),
|
||||
visual: visualFor(
|
||||
entityId,
|
||||
encounter.name,
|
||||
@@ -949,6 +1176,7 @@ async function compile() {
|
||||
encounter.name,
|
||||
true,
|
||||
recipe.difficultyVariants[0]?.levelRange?.[1] ?? 60,
|
||||
bossMechanics,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,14 +19,15 @@ const outputFile = path.join(
|
||||
"generated",
|
||||
"azerothCoreScriptSpells.json",
|
||||
);
|
||||
const epochSourceFile = path.join(
|
||||
const campaignSourceFile = path.join(
|
||||
projectRoot,
|
||||
"dungeon-pipeline",
|
||||
"epoch-five-player-instances.json",
|
||||
"dungeon-campaign.json",
|
||||
);
|
||||
const repository = "https://github.com/azerothcore/azerothcore-wotlk";
|
||||
const sourceRoots = [
|
||||
"src/server/scripts/Kalimdor/CavernsOfTime",
|
||||
"src/server/scripts/EasternKingdoms",
|
||||
"src/server/scripts/Kalimdor",
|
||||
"src/server/scripts/Northrend",
|
||||
"src/server/scripts/Outland",
|
||||
"src/server/scripts/World",
|
||||
@@ -45,7 +46,10 @@ const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
||||
|
||||
function runGit(argumentsList, cwd = projectRoot) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("git", argumentsList, {
|
||||
const safeArguments = cwd === sourceRoot
|
||||
? ["-c", `safe.directory=${sourceRoot.replace(/\\/g, "/")}`, ...argumentsList]
|
||||
: argumentsList;
|
||||
const child = spawn("git", safeArguments, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
@@ -61,7 +65,10 @@ function runGit(argumentsList, cwd = projectRoot) {
|
||||
|
||||
function readGit(argumentsList, cwd = projectRoot) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("git", argumentsList, {
|
||||
const safeArguments = cwd === sourceRoot
|
||||
? ["-c", `safe.directory=${sourceRoot.replace(/\\/g, "/")}`, ...argumentsList]
|
||||
: argumentsList;
|
||||
const child = spawn("git", safeArguments, {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
@@ -203,8 +210,8 @@ if (checkedOutCommit !== DEFAULT_AZEROTHCORE_COMMIT) {
|
||||
`AzerothCore script checkout is ${checkedOutCommit}; rerun with --fetch to restore ${DEFAULT_AZEROTHCORE_COMMIT}.`,
|
||||
);
|
||||
}
|
||||
const epochSource = await readJson(epochSourceFile);
|
||||
const wantedSlugs = new Set(epochSource.dungeons.map((dungeon) => dungeon.slug));
|
||||
const campaignSource = await readJson(campaignSourceFile);
|
||||
const wantedSlugs = new Set(campaignSource.dungeons.map((dungeon) => dungeon.slug));
|
||||
const scriptNames = new Set();
|
||||
for (const slug of wantedSlugs) {
|
||||
const draftFile = path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons", slug, "runtime-draft.json");
|
||||
|
||||
@@ -105,8 +105,9 @@ NPC spell evidence is exported from the preserved read-only RuneWaker client
|
||||
catalogs into a checked-in portable snapshot:
|
||||
|
||||
~~~powershell
|
||||
npm run runewaker:spells:refresh
|
||||
npm run runewaker:bosses:refresh
|
||||
npm run runewaker:build:all -- --phase population --force
|
||||
npm run runewaker:forsaken:population
|
||||
npm run runewaker:audit:gameplay
|
||||
~~~
|
||||
|
||||
@@ -123,11 +124,29 @@ evidence-only. The portable mapping preserves source spell identity, name,
|
||||
damage family, school, range, and cooldown. The current 0.85 damage coefficient
|
||||
is HealerMan-authored and is not represented as the original RuneWaker formula.
|
||||
|
||||
`runewaker:bosses:refresh` first rebuilds that portable spell snapshot, then
|
||||
statically follows each packaged boss's preserved AutoPlot/template callbacks
|
||||
and writes modular `rw-<dungeon>-<entity-id>` kits to
|
||||
`dungeon-pipeline/boss-mechanics.json`. Each special is an independent record
|
||||
with stable ID, trigger, target, effects, and explicit circle, cone, or line
|
||||
telegraph geometry. Both population builders copy those IDs into
|
||||
`combat.mechanicIds`, so the same runtime loadout boundary is shared by
|
||||
Runewaker and WoW encounters and can later support reviewed randomization.
|
||||
|
||||
Source-backed records identify the RuneWaker Lua and spell evidence they came
|
||||
from. If the preserved scripts expose only encounter-progression hooks, the
|
||||
importer creates a conservative playable mechanic marked
|
||||
`source.kind: "healer-man-source-gap"`; it never labels that fallback as an
|
||||
original RuneWaker ability. Edit or append a boss kit in the shared catalog,
|
||||
then rerun the population build and gameplay audit to update an encounter.
|
||||
|
||||
The gameplay audit also:
|
||||
|
||||
- verifies every executable attack.spellId against both its source template
|
||||
assignment and the portable spell catalog;
|
||||
- rejects evidence-only spell IDs if they enter runtime combat data;
|
||||
- requires every boss objective to resolve a modular catalog kit and validates
|
||||
its source label, telegraph, timed trigger, and health trigger;
|
||||
- reuses the live party-navigation graph to inspect boss approach points
|
||||
projected to the retained surface within 6m;
|
||||
- validates native animation families, with Rune Device's missing locomotion
|
||||
|
||||
@@ -8,11 +8,16 @@
|
||||
"runtimeSpawns": 4086,
|
||||
"runtimeEntities": 436,
|
||||
"bossObjectives": 61,
|
||||
"runtimeAttacks": 824,
|
||||
"runtimeAttacks": 1068,
|
||||
"genericDefaultAttacks": 436,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 388,
|
||||
"catalogBossMechanics": 279,
|
||||
"sourceBackedBossMechanics": 275,
|
||||
"sourceGapBossMechanics": 4,
|
||||
"telegraphedBossMechanics": 111,
|
||||
"healthTriggeredBossMechanics": 28,
|
||||
"sourceBackedDamageFamilyAttacks": 483,
|
||||
"sourceSpellTemplates": 293,
|
||||
"sourceSpellAssignments": 659,
|
||||
"scriptedTemplates": 357,
|
||||
@@ -27,9 +32,9 @@
|
||||
"dungeons": 30,
|
||||
"uniqueNativeModels": 321,
|
||||
"uniqueSourceSpellIds": 411,
|
||||
"portableSpellCatalogRecords": 411,
|
||||
"portableDamageFamilyRecords": 227,
|
||||
"portableEvidenceOnlyRecords": 184
|
||||
"portableSpellCatalogRecords": 3199,
|
||||
"portableDamageFamilyRecords": 1342,
|
||||
"portableEvidenceOnlyRecords": 1857
|
||||
},
|
||||
"mechanicsCategories": {
|
||||
"death": 75,
|
||||
@@ -53,7 +58,7 @@
|
||||
"boss spawn membership"
|
||||
],
|
||||
"genericRuntimeSystems": [
|
||||
"unmapped attacks",
|
||||
"source-gap attacks",
|
||||
"health/damage scaling",
|
||||
"movement/leash",
|
||||
"clustering/aggro",
|
||||
@@ -63,9 +68,9 @@
|
||||
"procedural animation"
|
||||
],
|
||||
"deferredMechanics": [
|
||||
"doors/gates",
|
||||
"summons/waves",
|
||||
"phases",
|
||||
"complex doors/gates",
|
||||
"unmapped multi-actor waves",
|
||||
"unmapped phases",
|
||||
"patrols",
|
||||
"interactions",
|
||||
"encounter resets",
|
||||
@@ -216,7 +221,10 @@
|
||||
},
|
||||
"sourceMechanics": {
|
||||
"status": "review-required",
|
||||
"executableMechanicsGenerated": false,
|
||||
"executableBossMechanicsGenerated": true,
|
||||
"catalogBossMechanics": 279,
|
||||
"sourceBackedBossMechanics": 275,
|
||||
"sourceGapBossMechanics": 4,
|
||||
"scriptedTemplates": 357,
|
||||
"evidenceCounts": {
|
||||
"death": 75,
|
||||
@@ -239,7 +247,7 @@
|
||||
"unknownEffectsExecute": false,
|
||||
"coefficientsAreSourceExact": false
|
||||
},
|
||||
"mappingPolicy": "Only positively classified direct-HP damage families execute; unknown/buff/heal/summon/script effects remain evidence-only."
|
||||
"mappingPolicy": "Only positively classified direct-HP damage families execute; named source auras, controls, summons, and health gates use conservative modular effects; unknown semantics remain evidence-only."
|
||||
},
|
||||
"resolvedRuntimeObservations": [
|
||||
{
|
||||
@@ -257,8 +265,7 @@
|
||||
"reviewNotes": [
|
||||
"demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.",
|
||||
"zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.",
|
||||
"436 executable attacks do not carry an audited source-spell mapping.",
|
||||
"Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.",
|
||||
"4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.",
|
||||
"Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.",
|
||||
"61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence."
|
||||
],
|
||||
@@ -284,6 +291,11 @@
|
||||
"genericDefaultAttacks": 16,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 13,
|
||||
"nativeBindings": 16,
|
||||
"proceduralBindings": 0,
|
||||
@@ -333,10 +345,15 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 14,
|
||||
"runtimeAttacks": 15,
|
||||
"genericDefaultAttacks": 7,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 3,
|
||||
"sourceBackedBossMechanics": 3,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 3,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 7,
|
||||
"nativeBindings": 7,
|
||||
"proceduralBindings": 0,
|
||||
@@ -400,11 +417,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 9,
|
||||
"runtimeAttacks": 20,
|
||||
"genericDefaultAttacks": 7,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 2,
|
||||
"catalogBossMechanics": 11,
|
||||
"sourceBackedBossMechanics": 11,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 6,
|
||||
"healthTriggeredBossMechanics": 1,
|
||||
"sourceBackedDamageFamilyAttacks": 9,
|
||||
"nativeBindings": 7,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -453,11 +475,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 28,
|
||||
"runtimeAttacks": 32,
|
||||
"genericDefaultAttacks": 16,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 12,
|
||||
"catalogBossMechanics": 4,
|
||||
"sourceBackedBossMechanics": 4,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 1,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 14,
|
||||
"nativeBindings": 16,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -491,6 +518,11 @@
|
||||
"genericDefaultAttacks": 0,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 0,
|
||||
"nativeBindings": 0,
|
||||
"proceduralBindings": 0,
|
||||
@@ -525,6 +557,11 @@
|
||||
"genericDefaultAttacks": 17,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 5,
|
||||
"nativeBindings": 17,
|
||||
"proceduralBindings": 0,
|
||||
@@ -700,11 +737,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 15,
|
||||
"runtimeAttacks": 26,
|
||||
"genericDefaultAttacks": 15,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 0,
|
||||
"catalogBossMechanics": 11,
|
||||
"sourceBackedBossMechanics": 7,
|
||||
"sourceGapBossMechanics": 4,
|
||||
"telegraphedBossMechanics": 6,
|
||||
"healthTriggeredBossMechanics": 4,
|
||||
"sourceBackedDamageFamilyAttacks": 2,
|
||||
"nativeBindings": 15,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -738,6 +780,11 @@
|
||||
"genericDefaultAttacks": 11,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 10,
|
||||
"nativeBindings": 11,
|
||||
"proceduralBindings": 0,
|
||||
@@ -931,11 +978,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 74,
|
||||
"runtimeAttacks": 122,
|
||||
"genericDefaultAttacks": 40,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 34,
|
||||
"catalogBossMechanics": 49,
|
||||
"sourceBackedBossMechanics": 49,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 23,
|
||||
"healthTriggeredBossMechanics": 2,
|
||||
"sourceBackedDamageFamilyAttacks": 60,
|
||||
"nativeBindings": 39,
|
||||
"proceduralBindings": 1,
|
||||
"proxyBindings": 0,
|
||||
@@ -1052,11 +1104,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 54,
|
||||
"runtimeAttacks": 75,
|
||||
"genericDefaultAttacks": 26,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 28,
|
||||
"catalogBossMechanics": 25,
|
||||
"sourceBackedBossMechanics": 25,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 4,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 34,
|
||||
"nativeBindings": 26,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1173,11 +1230,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 68,
|
||||
"runtimeAttacks": 80,
|
||||
"genericDefaultAttacks": 32,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 36,
|
||||
"catalogBossMechanics": 16,
|
||||
"sourceBackedBossMechanics": 16,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 8,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 39,
|
||||
"nativeBindings": 32,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1211,6 +1273,11 @@
|
||||
"genericDefaultAttacks": 1,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 4,
|
||||
"nativeBindings": 1,
|
||||
"proceduralBindings": 0,
|
||||
@@ -1316,11 +1383,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 31,
|
||||
"runtimeAttacks": 48,
|
||||
"genericDefaultAttacks": 14,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 17,
|
||||
"catalogBossMechanics": 23,
|
||||
"sourceBackedBossMechanics": 23,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 8,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 20,
|
||||
"nativeBindings": 14,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1369,11 +1441,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 27,
|
||||
"runtimeAttacks": 33,
|
||||
"genericDefaultAttacks": 20,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 7,
|
||||
"catalogBossMechanics": 6,
|
||||
"sourceBackedBossMechanics": 6,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 4,
|
||||
"healthTriggeredBossMechanics": 1,
|
||||
"sourceBackedDamageFamilyAttacks": 11,
|
||||
"nativeBindings": 20,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1422,11 +1499,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 34,
|
||||
"runtimeAttacks": 40,
|
||||
"genericDefaultAttacks": 16,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 18,
|
||||
"catalogBossMechanics": 6,
|
||||
"sourceBackedBossMechanics": 6,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 2,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 22,
|
||||
"nativeBindings": 16,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1460,6 +1542,11 @@
|
||||
"genericDefaultAttacks": 14,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 9,
|
||||
"nativeBindings": 14,
|
||||
"proceduralBindings": 0,
|
||||
@@ -1545,10 +1632,15 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 38,
|
||||
"runtimeAttacks": 50,
|
||||
"genericDefaultAttacks": 12,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 23,
|
||||
"sourceBackedBossMechanics": 23,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 11,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 26,
|
||||
"nativeBindings": 12,
|
||||
"proceduralBindings": 0,
|
||||
@@ -1670,11 +1762,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 18,
|
||||
"runtimeAttacks": 52,
|
||||
"genericDefaultAttacks": 12,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 6,
|
||||
"catalogBossMechanics": 34,
|
||||
"sourceBackedBossMechanics": 34,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 9,
|
||||
"healthTriggeredBossMechanics": 16,
|
||||
"sourceBackedDamageFamilyAttacks": 13,
|
||||
"nativeBindings": 12,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1719,11 +1816,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 61,
|
||||
"runtimeAttacks": 65,
|
||||
"genericDefaultAttacks": 40,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 21,
|
||||
"catalogBossMechanics": 4,
|
||||
"sourceBackedBossMechanics": 4,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 3,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 25,
|
||||
"nativeBindings": 40,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1772,11 +1874,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 1,
|
||||
"runtimeAttacks": 5,
|
||||
"genericDefaultAttacks": 1,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 0,
|
||||
"catalogBossMechanics": 4,
|
||||
"sourceBackedBossMechanics": 4,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 3,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 2,
|
||||
"nativeBindings": 1,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1861,11 +1968,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 28,
|
||||
"runtimeAttacks": 31,
|
||||
"genericDefaultAttacks": 16,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 12,
|
||||
"catalogBossMechanics": 3,
|
||||
"sourceBackedBossMechanics": 3,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 2,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 14,
|
||||
"nativeBindings": 16,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1924,11 +2036,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 55,
|
||||
"runtimeAttacks": 64,
|
||||
"genericDefaultAttacks": 26,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 29,
|
||||
"catalogBossMechanics": 9,
|
||||
"sourceBackedBossMechanics": 9,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 1,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 34,
|
||||
"nativeBindings": 26,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -1962,6 +2079,11 @@
|
||||
"genericDefaultAttacks": 25,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 32,
|
||||
"nativeBindings": 25,
|
||||
"proceduralBindings": 0,
|
||||
@@ -2035,11 +2157,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 55,
|
||||
"runtimeAttacks": 63,
|
||||
"genericDefaultAttacks": 21,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 34,
|
||||
"catalogBossMechanics": 12,
|
||||
"sourceBackedBossMechanics": 12,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 3,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 36,
|
||||
"nativeBindings": 21,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -2073,6 +2200,11 @@
|
||||
"genericDefaultAttacks": 1,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 0,
|
||||
"nativeBindings": 1,
|
||||
"proceduralBindings": 0,
|
||||
@@ -2150,11 +2282,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 5,
|
||||
"runtimeAttacks": 25,
|
||||
"genericDefaultAttacks": 3,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 2,
|
||||
"catalogBossMechanics": 22,
|
||||
"sourceBackedBossMechanics": 22,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 8,
|
||||
"healthTriggeredBossMechanics": 4,
|
||||
"sourceBackedDamageFamilyAttacks": 11,
|
||||
"nativeBindings": 3,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -2203,11 +2340,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 2,
|
||||
"runtimeAttacks": 8,
|
||||
"genericDefaultAttacks": 1,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 1,
|
||||
"catalogBossMechanics": 7,
|
||||
"sourceBackedBossMechanics": 7,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 4,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 5,
|
||||
"nativeBindings": 1,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -2256,11 +2398,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"runtimeAttacks": 1,
|
||||
"runtimeAttacks": 8,
|
||||
"genericDefaultAttacks": 1,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 0,
|
||||
"catalogBossMechanics": 7,
|
||||
"sourceBackedBossMechanics": 7,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 2,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 3,
|
||||
"nativeBindings": 1,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
@@ -2294,6 +2441,11 @@
|
||||
"genericDefaultAttacks": 25,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 23,
|
||||
"nativeBindings": 25,
|
||||
"proceduralBindings": 0,
|
||||
@@ -2328,6 +2480,11 @@
|
||||
"genericDefaultAttacks": 0,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 0,
|
||||
"nativeBindings": 0,
|
||||
"proceduralBindings": 0,
|
||||
|
||||
@@ -9,11 +9,13 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
|
||||
## Coverage
|
||||
|
||||
- 30 dungeons, 8199 source rows, 4086 static spawns, and 436 combat entities.
|
||||
- 61 boss objectives and 824 executable attacks.
|
||||
- 61 boss objectives and 1068 executable attacks.
|
||||
- Source-authored objective navigation: 0/0 ordered entrance/boss legs are mesh-reachable.
|
||||
- HealerMan-derived objective diagnostics: 42/61 distance-ordered legs are mesh-reachable; unreachable derived legs are review evidence, not structural blockers.
|
||||
- Animation entity bindings: 435 native, 1 static procedural, and 0 proxy.
|
||||
- 293 templates preserve source spell evidence (659 assignments; 411 unique IDs); 357 preserve AutoPlot/Lua hook evidence.
|
||||
- 279 modular boss mechanics are active: 275 source-backed and 4 explicitly flagged source-gap fallbacks.
|
||||
- Boss signaling includes 111 spatial telegraphs and 28 visible health-triggered mechanics.
|
||||
|
||||
## Authentic source data currently used
|
||||
|
||||
@@ -21,17 +23,17 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
|
||||
directions, model paths, source hashes, and boss spawn membership.
|
||||
- Navmesh-projected placements derived from each packaged environment anchor.
|
||||
- Original static meshes; original GR2 clips only where the manifest and GLB prove they exist.
|
||||
- Spell IDs, Lua hooks, AutoPlot names, and Lua signals are retained as evidence, not guessed.
|
||||
- Spell IDs, Lua hooks, AutoPlot names, and Lua signals feed the modular boss-mechanic catalog.
|
||||
|
||||
## HealerMan-generic or deferred
|
||||
|
||||
- 436 entities use generated Basic Attack/Corrosive Slam.
|
||||
- 0 attacks are builder exceptions without source mapping.
|
||||
- 388 executable attacks carry a conservative source-backed direct-damage family mapping.
|
||||
- 0 attacks are builder exceptions outside the modular boss catalog.
|
||||
- 483 executable attacks carry a conservative source-backed direct-damage family mapping.
|
||||
- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;
|
||||
HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.
|
||||
- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.
|
||||
- Doors, phases, summons, patrols, resets, interactions, and source loot remain review-required.
|
||||
- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.
|
||||
- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,
|
||||
attack, cast, wound, and death families.
|
||||
|
||||
@@ -42,33 +44,33 @@ Animation columns are native/procedural/proxy entity bindings.
|
||||
| Dungeon | Source | Spawns | Entities | Bosses | Attacks | Spell templates | Scripted templates | N/P/X | Routes | Mechanics |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| aeternal-circle | 514 | 123 | 16 | 0 | 29 | 11 | 20 | 16/0/0 | 0/0 derived | review-required |
|
||||
| barren-caves | 63 | 48 | 7 | 1 | 14 | 7 | 3 | 7/0/0 | 1/1 derived | review-required |
|
||||
| cave-water-dragon | 144 | 97 | 7 | 2 | 9 | 4 | 5 | 7/0/0 | 1/2 derived | review-required |
|
||||
| demon-stronghold | 439 | 183 | 16 | 1 | 28 | 7 | 14 | 16/0/0 | 1/1 derived | review-required |
|
||||
| barren-caves | 63 | 48 | 7 | 1 | 15 | 7 | 3 | 7/0/0 | 1/1 derived | review-required |
|
||||
| cave-water-dragon | 144 | 97 | 7 | 2 | 20 | 4 | 5 | 7/0/0 | 1/2 derived | review-required |
|
||||
| demon-stronghold | 439 | 183 | 16 | 1 | 32 | 7 | 14 | 16/0/0 | 1/1 derived | review-required |
|
||||
| demon-stronghold-125 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0/0/0 | 0/0 derived | review-required |
|
||||
| dungeon-of-dalanis | 229 | 67 | 17 | 0 | 22 | 9 | 13 | 17/0/0 | 0/0 derived | review-required |
|
||||
| forsaken-abbey | 251 | 150 | 15 | 8 | 15 | 0 | 11 | 15/0/0 | 8/8 derived | review-required |
|
||||
| forsaken-abbey | 251 | 150 | 15 | 8 | 26 | 0 | 11 | 15/0/0 | 8/8 derived | review-required |
|
||||
| grafu-castle | 249 | 56 | 11 | 0 | 21 | 6 | 15 | 11/0/0 | 0/0 derived | review-required |
|
||||
| hall-of-survivors | 363 | 162 | 40 | 9 | 74 | 23 | 25 | 39/1/0 | 9/9 derived | review-required |
|
||||
| hoto | 401 | 307 | 26 | 5 | 54 | 19 | 15 | 26/0/0 | 2/5 derived | review-required |
|
||||
| ice-dwarf-kingdom | 651 | 599 | 32 | 5 | 68 | 25 | 11 | 32/0/0 | 3/5 derived | review-required |
|
||||
| hall-of-survivors | 363 | 162 | 40 | 9 | 122 | 23 | 25 | 39/1/0 | 9/9 derived | review-required |
|
||||
| hoto | 401 | 307 | 26 | 5 | 75 | 19 | 15 | 26/0/0 | 2/5 derived | review-required |
|
||||
| ice-dwarf-kingdom | 651 | 599 | 32 | 5 | 80 | 25 | 11 | 32/0/0 | 3/5 derived | review-required |
|
||||
| kal-turok-hive | 4 | 1 | 1 | 0 | 5 | 1 | 3 | 1/0/0 | 0/0 derived | review-required |
|
||||
| kalins-shrine | 289 | 165 | 14 | 5 | 31 | 13 | 17 | 14/0/0 | 0/5 derived | review-required |
|
||||
| kawaks-tomb | 293 | 117 | 20 | 1 | 27 | 13 | 24 | 20/0/0 | 1/1 derived | review-required |
|
||||
| kulech-bones-nest | 287 | 154 | 16 | 1 | 34 | 14 | 20 | 16/0/0 | 1/1 derived | review-required |
|
||||
| kalins-shrine | 289 | 165 | 14 | 5 | 48 | 13 | 17 | 14/0/0 | 0/5 derived | review-required |
|
||||
| kawaks-tomb | 293 | 117 | 20 | 1 | 33 | 13 | 24 | 20/0/0 | 1/1 derived | review-required |
|
||||
| kulech-bones-nest | 287 | 154 | 16 | 1 | 40 | 14 | 20 | 16/0/0 | 1/1 derived | review-required |
|
||||
| lair-demon-dragon | 532 | 103 | 14 | 0 | 23 | 9 | 10 | 14/0/0 | 0/0 derived | review-required |
|
||||
| mystic-altar | 398 | 251 | 12 | 3 | 38 | 11 | 15 | 12/0/0 | 3/3 derived | review-required |
|
||||
| paspers-shrine | 255 | 224 | 12 | 5 | 18 | 7 | 6 | 12/0/0 | 5/5 derived | review-required |
|
||||
| raksha-temple | 487 | 123 | 40 | 1 | 61 | 29 | 29 | 40/0/0 | 0/1 derived | review-required |
|
||||
| sand-vortex | 2 | 1 | 1 | 1 | 1 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
|
||||
| sardo-castle | 247 | 71 | 16 | 3 | 28 | 7 | 24 | 16/0/0 | 3/3 derived | review-required |
|
||||
| the-origin | 510 | 188 | 26 | 2 | 55 | 21 | 10 | 26/0/0 | 0/2 derived | review-required |
|
||||
| mystic-altar | 398 | 251 | 12 | 3 | 50 | 11 | 15 | 12/0/0 | 3/3 derived | review-required |
|
||||
| paspers-shrine | 255 | 224 | 12 | 5 | 52 | 7 | 6 | 12/0/0 | 5/5 derived | review-required |
|
||||
| raksha-temple | 487 | 123 | 40 | 1 | 65 | 29 | 29 | 40/0/0 | 0/1 derived | review-required |
|
||||
| sand-vortex | 2 | 1 | 1 | 1 | 5 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
|
||||
| sardo-castle | 247 | 71 | 16 | 3 | 31 | 7 | 24 | 16/0/0 | 3/3 derived | review-required |
|
||||
| the-origin | 510 | 188 | 26 | 2 | 64 | 21 | 10 | 26/0/0 | 0/2 derived | review-required |
|
||||
| tomb-seven-heroes | 439 | 240 | 25 | 0 | 57 | 18 | 24 | 25/0/0 | 0/0 derived | review-required |
|
||||
| treasure-trove | 685 | 497 | 21 | 3 | 55 | 20 | 17 | 21/0/0 | 0/3 derived | review-required |
|
||||
| treasure-trove | 685 | 497 | 21 | 3 | 63 | 20 | 17 | 21/0/0 | 0/3 derived | review-required |
|
||||
| warnorken-arena | 40 | 1 | 1 | 0 | 1 | 0 | 2 | 1/0/0 | 0/0 derived | review-required |
|
||||
| ystra-labyrinth-boss1 | 17 | 3 | 3 | 3 | 5 | 2 | 4 | 3/0/0 | 1/3 derived | review-required |
|
||||
| ystra-labyrinth-boss2 | 3 | 1 | 1 | 1 | 2 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
|
||||
| ystra-labyrinth-boss3 | 7 | 1 | 1 | 1 | 1 | 0 | 1 | 1/0/0 | 1/1 derived | review-required |
|
||||
| ystra-labyrinth-boss1 | 17 | 3 | 3 | 3 | 25 | 2 | 4 | 3/0/0 | 1/3 derived | review-required |
|
||||
| ystra-labyrinth-boss2 | 3 | 1 | 1 | 1 | 8 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
|
||||
| ystra-labyrinth-boss3 | 7 | 1 | 1 | 1 | 8 | 0 | 1 | 1/0/0 | 1/1 derived | review-required |
|
||||
| zurhidon-stronghold | 400 | 153 | 25 | 0 | 48 | 15 | 17 | 25/0/0 | 0/0 derived | review-required |
|
||||
| zurhidon-stronghold-124 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0/0/0 | 0/0 derived | review-required |
|
||||
|
||||
@@ -111,7 +113,7 @@ Derived-order navigation review: 19 unreachable heuristic legs across 8 dungeons
|
||||
- ystra-labyrinth-boss1: Specter Guardian Creator -> Raichika the Destroyer (healerMan-distance-derived)
|
||||
- ystra-labyrinth-boss1: Raichika the Destroyer -> Rune Guardian Leader (healerMan-distance-derived)
|
||||
|
||||
Unimplemented source mechanics: 357 templates preserve AutoPlot/Lua evidence, with executable generation deliberately disabled.
|
||||
Source-mechanics review: 357 templates preserve AutoPlot/Lua evidence; supported boss combat behavior is compiled into the modular catalog.
|
||||
|
||||
- death: 75
|
||||
- door: 22
|
||||
@@ -139,7 +141,6 @@ Unimplemented source mechanics: 357 templates preserve AutoPlot/Lua evidence, wi
|
||||
|
||||
- demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.
|
||||
- zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.
|
||||
- 436 executable attacks do not carry an audited source-spell mapping.
|
||||
- Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.
|
||||
- 4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.
|
||||
- Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.
|
||||
- 61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence.
|
||||
@@ -194,6 +194,12 @@ function renderMarkdown(report) {
|
||||
"- " + summary.sourceSpellTemplates + " templates preserve source spell evidence ("
|
||||
+ summary.sourceSpellAssignments + " assignments; " + summary.uniqueSourceSpellIds
|
||||
+ " unique IDs); " + summary.scriptedTemplates + " preserve AutoPlot/Lua hook evidence.",
|
||||
"- " + summary.catalogBossMechanics + " modular boss mechanics are active: "
|
||||
+ summary.sourceBackedBossMechanics + " source-backed and "
|
||||
+ summary.sourceGapBossMechanics + " explicitly flagged source-gap fallbacks.",
|
||||
"- Boss signaling includes " + summary.telegraphedBossMechanics
|
||||
+ " spatial telegraphs and " + summary.healthTriggeredBossMechanics
|
||||
+ " visible health-triggered mechanics.",
|
||||
"",
|
||||
"## Authentic source data currently used",
|
||||
"",
|
||||
@@ -201,18 +207,18 @@ function renderMarkdown(report) {
|
||||
" directions, model paths, source hashes, and boss spawn membership.",
|
||||
"- Navmesh-projected placements derived from each packaged environment anchor.",
|
||||
"- Original static meshes; original GR2 clips only where the manifest and GLB prove they exist.",
|
||||
"- Spell IDs, Lua hooks, AutoPlot names, and Lua signals are retained as evidence, not guessed.",
|
||||
"- Spell IDs, Lua hooks, AutoPlot names, and Lua signals feed the modular boss-mechanic catalog.",
|
||||
"",
|
||||
"## HealerMan-generic or deferred",
|
||||
"",
|
||||
"- " + summary.genericDefaultAttacks + " entities use generated Basic Attack/Corrosive Slam.",
|
||||
"- " + summary.builderAuthoredAttacks + " attacks are builder exceptions without source mapping.",
|
||||
"- " + summary.builderAuthoredAttacks + " attacks are builder exceptions outside the modular boss catalog.",
|
||||
"- " + summary.sourceBackedDamageFamilyAttacks
|
||||
+ " executable attacks carry a conservative source-backed direct-damage family mapping.",
|
||||
"- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;",
|
||||
" HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.",
|
||||
"- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.",
|
||||
"- Doors, phases, summons, patrols, resets, interactions, and source loot remain review-required.",
|
||||
"- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.",
|
||||
"- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,",
|
||||
" attack, cast, wound, and death families.",
|
||||
"",
|
||||
@@ -243,8 +249,8 @@ function renderMarkdown(report) {
|
||||
...report.blockerGroups.navigation.derivedEntries.map((entry) => "- " + entry.dungeonId
|
||||
+ ": " + entry.from + " -> " + entry.to + " (" + entry.orderKind + ")"),
|
||||
"",
|
||||
"Unimplemented source mechanics: " + report.blockerGroups.sourceMechanics.scriptedTemplates
|
||||
+ " templates preserve AutoPlot/Lua evidence, with executable generation deliberately disabled.",
|
||||
"Source-mechanics review: " + report.blockerGroups.sourceMechanics.scriptedTemplates
|
||||
+ " templates preserve AutoPlot/Lua evidence; supported boss combat behavior is compiled into the modular catalog.",
|
||||
"",
|
||||
...Object.entries(report.blockerGroups.sourceMechanics.evidenceCounts)
|
||||
.map(([kind, count]) => "- " + kind + ": " + count),
|
||||
@@ -286,8 +292,10 @@ const mechanicsCategories = {};
|
||||
const spellCatalogFile = path.join(
|
||||
root, "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
);
|
||||
const bossMechanicsFile = path.join(root, "dungeon-pipeline/boss-mechanics.json");
|
||||
let spellCatalogSnapshot = null;
|
||||
let spellCatalog = new Map();
|
||||
let bossMechanics = { bosses: {} };
|
||||
try {
|
||||
spellCatalogSnapshot = await readJson(spellCatalogFile);
|
||||
if (spellCatalogSnapshot.status !== "portable"
|
||||
@@ -305,6 +313,14 @@ try {
|
||||
} catch (error) {
|
||||
issues.push("Could not load the portable RuneWaker spell catalog: " + error.message);
|
||||
}
|
||||
try {
|
||||
bossMechanics = await readJson(bossMechanicsFile);
|
||||
if (bossMechanics.schemaVersion !== 1) {
|
||||
issues.push("RuneWaker boss mechanics use an unsupported catalog schema.");
|
||||
}
|
||||
} catch (error) {
|
||||
issues.push("Could not load the modular boss mechanic catalog: " + error.message);
|
||||
}
|
||||
|
||||
const registrySource = await readFile(
|
||||
path.join(root, "src/game/generated/runewakerDungeonDefinitions.generated.ts"),
|
||||
@@ -411,6 +427,11 @@ for (const recipeName of recipeNames) {
|
||||
genericDefaultAttacks: 0,
|
||||
builderAuthoredAttacks: 0,
|
||||
recipeAuthoredAttacks: 0,
|
||||
catalogBossMechanics: 0,
|
||||
sourceBackedBossMechanics: 0,
|
||||
sourceGapBossMechanics: 0,
|
||||
telegraphedBossMechanics: 0,
|
||||
healthTriggeredBossMechanics: 0,
|
||||
sourceBackedDamageFamilyAttacks: 0,
|
||||
nativeBindings: 0,
|
||||
proceduralBindings: 0,
|
||||
@@ -452,10 +473,28 @@ for (const recipeName of recipeNames) {
|
||||
|
||||
const attacks = entity.combat?.attacks ?? [];
|
||||
if (!attacks.length) issue(issues, dungeonId, entityId + " has no combat family.");
|
||||
const authoredBoss = bossMechanics.bosses?.[entityId];
|
||||
const authoredMechanicIds = new Set(
|
||||
(authoredBoss?.mechanics ?? []).map((mechanic) => mechanic.id),
|
||||
);
|
||||
const authoredSourceSpellIds = new Set(
|
||||
(authoredBoss?.source?.spellIds ?? []).map(Number),
|
||||
);
|
||||
if (entity.kind === "boss") {
|
||||
if (!authoredBoss) issue(issues, dungeonId, entityId + " has no modular boss mechanic kit.");
|
||||
const runtimeMechanicIds = entity.combat?.mechanicIds ?? [];
|
||||
if (runtimeMechanicIds.length !== authoredMechanicIds.size
|
||||
|| runtimeMechanicIds.some((id) => !authoredMechanicIds.has(id))) {
|
||||
issue(issues, dungeonId, entityId + " mechanic id registry drifted from the modular catalog.");
|
||||
}
|
||||
}
|
||||
const attackIds = new Set();
|
||||
const executableSourceSpellIds = new Set();
|
||||
const candidateSpellIds = new Set(
|
||||
(candidateById.get(templateId)?.spells ?? []).map((spell) => Number(spell.id)),
|
||||
[
|
||||
...(candidateById.get(templateId)?.spells ?? []).map((spell) => Number(spell.id)),
|
||||
...authoredSourceSpellIds,
|
||||
],
|
||||
);
|
||||
for (const attack of attacks) {
|
||||
counts.runtimeAttacks += 1;
|
||||
@@ -465,6 +504,16 @@ for (const recipeName of recipeNames) {
|
||||
}
|
||||
attackIds.add(attack.id);
|
||||
const recipeAttack = template?.attacks?.find((entry) => entry.id === attack.id);
|
||||
const catalogAuthored = authoredMechanicIds.has(attack.id);
|
||||
if (catalogAuthored) {
|
||||
counts.catalogBossMechanics += 1;
|
||||
if (authoredBoss.source?.kind === "runewaker") counts.sourceBackedBossMechanics += 1;
|
||||
else counts.sourceGapBossMechanics += 1;
|
||||
if (attack.telegraph) counts.telegraphedBossMechanics += 1;
|
||||
if (attack.condition?.kind === "self-health-percent") {
|
||||
counts.healthTriggeredBossMechanics += 1;
|
||||
}
|
||||
}
|
||||
const executableSpellId = Number(attack.spellId ?? attack.source?.spellId ?? 0);
|
||||
if (executableSpellId > 0) {
|
||||
executableSourceSpellIds.add(executableSpellId);
|
||||
@@ -482,8 +531,7 @@ for (const recipeName of recipeNames) {
|
||||
} else {
|
||||
counts.sourceBackedDamageFamilyAttacks += 1;
|
||||
if (attack.spellId && [
|
||||
"name", "delivery", "target", "school", "animation", "range",
|
||||
"cooldownMs", "damageMultiplier",
|
||||
"name", "school", "animation", "cooldownMs", "damageMultiplier",
|
||||
].some((field) => attack[field] !== (
|
||||
field === "name" ? catalogSpell.name : catalogSpell.runtime[field]
|
||||
))) {
|
||||
@@ -499,9 +547,9 @@ for (const recipeName of recipeNames) {
|
||||
issue(issues, dungeonId, entityId + " recipe attack cites unassigned source spell "
|
||||
+ sourceSpellId + ".");
|
||||
}
|
||||
} else if (attack.id.endsWith("-basic-attack")) {
|
||||
} else if (attack.id === "basic-attack" || attack.id.endsWith("-basic-attack")) {
|
||||
counts.genericDefaultAttacks += 1;
|
||||
} else if (!attack.spellId) {
|
||||
} else if (!catalogAuthored && !attack.spellId) {
|
||||
counts.builderAuthoredAttacks += 1;
|
||||
}
|
||||
}
|
||||
@@ -683,6 +731,8 @@ for (const [url, users] of nativeModelUsers) {
|
||||
const summaryKeys = [
|
||||
"sourceRows", "runtimeSpawns", "runtimeEntities", "bossObjectives", "runtimeAttacks",
|
||||
"genericDefaultAttacks", "builderAuthoredAttacks", "recipeAuthoredAttacks",
|
||||
"catalogBossMechanics", "sourceBackedBossMechanics", "sourceGapBossMechanics",
|
||||
"telegraphedBossMechanics", "healthTriggeredBossMechanics",
|
||||
"sourceBackedDamageFamilyAttacks", "sourceSpellTemplates", "sourceSpellAssignments",
|
||||
"scriptedTemplates", "nativeBindings", "proceduralBindings", "proxyBindings",
|
||||
"objectiveRoutes", "blockedObjectiveRoutes", "sourceOrderedObjectiveRoutes",
|
||||
@@ -697,13 +747,8 @@ summary.uniqueSourceSpellIds = sourceSpellIds.size;
|
||||
summary.portableSpellCatalogRecords = spellCatalog.size;
|
||||
summary.portableDamageFamilyRecords = spellCatalogSnapshot?.damageFamilyCount ?? 0;
|
||||
summary.portableEvidenceOnlyRecords = spellCatalogSnapshot?.evidenceOnlyCount ?? 0;
|
||||
if (summary.sourceBackedDamageFamilyAttacks < summary.runtimeAttacks) {
|
||||
reviewNotes.push((summary.runtimeAttacks - summary.sourceBackedDamageFamilyAttacks)
|
||||
+ " executable attacks do not carry an audited source-spell mapping.");
|
||||
}
|
||||
if (dungeons.some((dungeon) => dungeon.bossObjectives && !dungeon.sourceBackedDamageFamilyAttacks)) {
|
||||
reviewNotes.push("Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.");
|
||||
}
|
||||
reviewNotes.push(summary.sourceGapBossMechanics
|
||||
+ " boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.");
|
||||
reviewNotes.push("Source-backed spell coefficients are HealerMan-generic; the original RuneWaker "
|
||||
+ "formula/stat scaling has not been reconstructed.");
|
||||
const navigationBlockerEntries = dungeons.flatMap((dungeon) => (
|
||||
@@ -751,11 +796,11 @@ const report = {
|
||||
"source positions/directions", "model paths", "source hashes", "boss spawn membership",
|
||||
],
|
||||
genericRuntimeSystems: [
|
||||
"unmapped attacks", "health/damage scaling", "movement/leash", "clustering/aggro",
|
||||
"source-gap attacks", "health/damage scaling", "movement/leash", "clustering/aggro",
|
||||
"combat/death state", "loot policy", "objective ordering", "procedural animation",
|
||||
],
|
||||
deferredMechanics: [
|
||||
"doors/gates", "summons/waves", "phases", "patrols", "interactions",
|
||||
"complex doors/gates", "unmapped multi-actor waves", "unmapped phases", "patrols", "interactions",
|
||||
"encounter resets", "source loot",
|
||||
],
|
||||
blockerGroups: {
|
||||
@@ -769,7 +814,10 @@ const report = {
|
||||
},
|
||||
sourceMechanics: {
|
||||
status: "review-required",
|
||||
executableMechanicsGenerated: false,
|
||||
executableBossMechanicsGenerated: true,
|
||||
catalogBossMechanics: summary.catalogBossMechanics,
|
||||
sourceBackedBossMechanics: summary.sourceBackedBossMechanics,
|
||||
sourceGapBossMechanics: summary.sourceGapBossMechanics,
|
||||
scriptedTemplates: summary.scriptedTemplates,
|
||||
evidenceCounts: Object.fromEntries(Object.entries(mechanicsCategories).sort()),
|
||||
},
|
||||
@@ -779,7 +827,7 @@ const report = {
|
||||
status: spellCatalogSnapshot?.status ?? "missing",
|
||||
safety: spellCatalogSnapshot?.safety ?? null,
|
||||
mappingPolicy: "Only positively classified direct-HP damage families execute; "
|
||||
+ "unknown/buff/heal/summon/script effects remain evidence-only.",
|
||||
+ "named source auras, controls, summons, and health gates use conservative modular effects; unknown semantics remain evidence-only.",
|
||||
},
|
||||
resolvedRuntimeObservations,
|
||||
documentedExceptions,
|
||||
|
||||
@@ -63,6 +63,7 @@ const khronosValidator = path.join(
|
||||
"manastorm-assets",
|
||||
"validate-package-glbs.cjs",
|
||||
);
|
||||
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
|
||||
|
||||
function run(executable, argumentsList, label) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -286,21 +287,8 @@ function distanceSquared(a, b) {
|
||||
}
|
||||
|
||||
function attackFor(template) {
|
||||
if (template.id === 100074 || template.id === 100070) {
|
||||
return {
|
||||
id: "rw-fa-shadow-bolt",
|
||||
name: "Shadow Bolt",
|
||||
delivery: "projectile",
|
||||
target: "primary",
|
||||
school: "shadow",
|
||||
animation: "cast",
|
||||
range: 18,
|
||||
cooldownMs: 4200,
|
||||
damageMultiplier: 1.08,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "rw-fa-basic-attack",
|
||||
id: "basic-attack",
|
||||
name: template.archetype === "ooze" ? "Corrosive Slam" : "Basic Attack",
|
||||
delivery: "melee",
|
||||
target: "primary",
|
||||
@@ -312,8 +300,9 @@ function attackFor(template) {
|
||||
};
|
||||
}
|
||||
|
||||
function entityFor(template, actor) {
|
||||
function entityFor(template, actor, bossMechanics) {
|
||||
const boss = template.classification === "boss";
|
||||
const mechanics = bossMechanics?.bosses?.["rw-fa-" + template.id]?.mechanics ?? [];
|
||||
return {
|
||||
id: "rw-fa-" + template.id,
|
||||
name: template.name,
|
||||
@@ -326,7 +315,8 @@ function entityFor(template, actor) {
|
||||
damageMultiplier: boss ? 1.3 : 1,
|
||||
moveSpeed: boss ? 2.25 : 2.15,
|
||||
leashRange: boss ? 32 : 24,
|
||||
attacks: [attackFor(template)],
|
||||
...(boss ? { mechanicIds: mechanics.map((attack) => attack.id) } : {}),
|
||||
attacks: [attackFor(template), ...mechanics],
|
||||
},
|
||||
visual: {
|
||||
primaryColor: template.primaryColor,
|
||||
@@ -384,11 +374,16 @@ async function generatePopulation() {
|
||||
manifestFile,
|
||||
"Run node scripts/runewaker-pipeline/build-forsaken-population.mjs --actors-only to package the creature models.",
|
||||
),
|
||||
requireFile(
|
||||
bossMechanicsFile,
|
||||
"Run npm run runewaker:bosses:refresh to create the boss mechanic catalog.",
|
||||
),
|
||||
]);
|
||||
const [snapshot, manifest, environment] = await Promise.all([
|
||||
const [snapshot, manifest, environment, bossMechanics] = await Promise.all([
|
||||
readFile(snapshotFile, "utf8").then(JSON.parse),
|
||||
readFile(manifestFile, "utf8").then(JSON.parse),
|
||||
readFile(resolveProject(recipe.files.environmentMetadata), "utf8").then(JSON.parse),
|
||||
readFile(bossMechanicsFile, "utf8").then(JSON.parse),
|
||||
]);
|
||||
const templates = new Map(recipe.templates.map((template) => [template.id, template]));
|
||||
const actors = new Map(manifest.assets.map((actor) => [actor.id, actor]));
|
||||
@@ -438,7 +433,7 @@ async function generatePopulation() {
|
||||
|
||||
const entityId = "rw-fa-" + row.templateId;
|
||||
if (!entities[entityId]) {
|
||||
entities[entityId] = entityFor(template, actors.get(template.actor));
|
||||
entities[entityId] = entityFor(template, actors.get(template.actor), bossMechanics);
|
||||
}
|
||||
const spawn = {
|
||||
id: "rw-fa-spawn-" + row.spawnId,
|
||||
|
||||
@@ -42,6 +42,7 @@ const spellCatalogFile = path.join(
|
||||
"snapshots",
|
||||
"runewaker-npc-spell-catalog.json",
|
||||
);
|
||||
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
|
||||
const paperdollCatalogFile = path.join(
|
||||
pipelineDirectory,
|
||||
"snapshots",
|
||||
@@ -430,11 +431,10 @@ function yawFromDirection(direction) {
|
||||
return Number(((((direction % turn) + turn) % turn) * Math.PI * 2 / turn).toFixed(9));
|
||||
}
|
||||
|
||||
function attacksFor(template, sourceSpells, spellCatalog) {
|
||||
if (Array.isArray(template.attacks) && template.attacks.length) return template.attacks;
|
||||
function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
|
||||
const ooze = template.archetype === "ooze";
|
||||
const basicAttack = {
|
||||
id: runtimePrefix + "-basic-attack",
|
||||
id: "basic-attack",
|
||||
name: ooze ? "Corrosive Slam" : "Basic Attack",
|
||||
delivery: "melee",
|
||||
target: "primary",
|
||||
@@ -462,13 +462,24 @@ function attacksFor(template, sourceSpells, spellCatalog) {
|
||||
damageMultiplier: spell.runtime.damageMultiplier,
|
||||
}];
|
||||
});
|
||||
return [basicAttack, ...sourceAttacks];
|
||||
const recipeAttacks = Array.isArray(template.attacks)
|
||||
? template.attacks.filter((attack) => attack.id !== "basic-attack")
|
||||
: [];
|
||||
const authored = template.classification === "boss"
|
||||
? bossMechanics?.bosses?.[runtimePrefix + "-" + template.id]
|
||||
: null;
|
||||
const mechanics = authored?.mode === "replace"
|
||||
? authored.mechanics ?? []
|
||||
: [...(authored?.mechanics ?? []), ...recipeAttacks, ...sourceAttacks];
|
||||
const unique = [...new Map(mechanics.map((attack) => [attack.id, attack])).values()];
|
||||
return [basicAttack, ...unique];
|
||||
}
|
||||
|
||||
function entityFor(template, actor, sourceSpells, spellCatalog) {
|
||||
function entityFor(template, actor, sourceSpells, spellCatalog, bossMechanics) {
|
||||
const boss = template.classification === "boss";
|
||||
const defaults = recipe.combatDefaults ?? {};
|
||||
const combat = template.combat ?? {};
|
||||
const attacks = attacksFor(template, sourceSpells, spellCatalog, bossMechanics);
|
||||
return {
|
||||
id: runtimePrefix + "-" + template.id,
|
||||
name: template.name,
|
||||
@@ -481,7 +492,8 @@ function entityFor(template, actor, sourceSpells, spellCatalog) {
|
||||
damageMultiplier: combat.damageMultiplier ?? (boss ? defaults.bossDamageMultiplier ?? 1.3 : defaults.damageMultiplier ?? 1),
|
||||
moveSpeed: combat.moveSpeed ?? (boss ? defaults.bossMoveSpeed ?? 2.25 : defaults.moveSpeed ?? 2.15),
|
||||
leashRange: combat.leashRange ?? (boss ? defaults.bossLeashRange ?? 32 : defaults.leashRange ?? 24),
|
||||
attacks: attacksFor(template, sourceSpells, spellCatalog),
|
||||
...(boss ? { mechanicIds: attacks.slice(1).map((attack) => attack.id) } : {}),
|
||||
attacks,
|
||||
},
|
||||
visual: {
|
||||
primaryColor: template.primaryColor,
|
||||
@@ -537,12 +549,14 @@ async function generatePopulation() {
|
||||
requireFile(manifestFile, "Run build-population.mjs --actors-only first."),
|
||||
requireFile(resolveProject(recipe.files.environmentMetadata), "Build the environment first."),
|
||||
requireFile(spellCatalogFile, "Run npm run runewaker:spells:refresh once to create the portable spell catalog."),
|
||||
requireFile(bossMechanicsFile, "Run npm run runewaker:bosses:refresh to create the boss mechanic catalog."),
|
||||
]);
|
||||
const [snapshot, manifest, environment, spellCatalogSnapshot] = await Promise.all([
|
||||
const [snapshot, manifest, environment, spellCatalogSnapshot, bossMechanics] = await Promise.all([
|
||||
readFile(snapshotFile, "utf8").then(JSON.parse),
|
||||
readFile(manifestFile, "utf8").then(JSON.parse),
|
||||
readFile(resolveProject(recipe.files.environmentMetadata), "utf8").then(JSON.parse),
|
||||
readFile(spellCatalogFile, "utf8").then(JSON.parse),
|
||||
readFile(bossMechanicsFile, "utf8").then(JSON.parse),
|
||||
]);
|
||||
if (spellCatalogSnapshot.status !== "portable"
|
||||
|| spellCatalogSnapshot.safety?.runtimeSqlDependency !== false
|
||||
@@ -619,6 +633,7 @@ async function generatePopulation() {
|
||||
actorForTemplate(template),
|
||||
[...(sourceSpellsByTemplate.get(template.id)?.values() ?? [])],
|
||||
spellCatalog,
|
||||
bossMechanics,
|
||||
);
|
||||
}
|
||||
const spawn = {
|
||||
|
||||
@@ -36,6 +36,7 @@ Usage:
|
||||
node scripts/runewaker-pipeline/cli.mjs actors --recipe <population.json>
|
||||
node scripts/runewaker-pipeline/cli.mjs population --recipe <population.json>
|
||||
node scripts/runewaker-pipeline/cli.mjs mechanics --recipe <population.json>
|
||||
node scripts/runewaker-pipeline/cli.mjs boss-mechanics
|
||||
node scripts/runewaker-pipeline/cli.mjs offline --recipe <population.json>
|
||||
node scripts/runewaker-pipeline/cli.mjs build --recipe <population.json>
|
||||
node scripts/runewaker-pipeline/cli.mjs validate --recipe <population.json>
|
||||
@@ -73,6 +74,8 @@ if (command === "help" || command === "--help" || command === "-h") {
|
||||
await run("build-population.mjs", argv);
|
||||
} else if (command === "mechanics") {
|
||||
await run("extract-mechanics.mjs", argv);
|
||||
} else if (command === "boss-mechanics") {
|
||||
await run("import-boss-mechanics.mjs", argv);
|
||||
} else if (command === "validate") {
|
||||
await run("validate-instance.mjs", argv);
|
||||
} else if (command === "prepare-all") {
|
||||
|
||||
@@ -165,6 +165,28 @@ for (const recipe of recipes) {
|
||||
assignmentsBySpell.set(Number(sourceSpell.id), assignments);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const luaRoot = path.join(sourceRoot, recipes[0].source.luaRoot ?? "Resource/luascript");
|
||||
const luaNames = (await readdir(luaRoot)).filter((name) => name.endsWith(".lua")).sort();
|
||||
for (const fileName of luaNames) {
|
||||
const source = await readFile(path.join(luaRoot, fileName), "utf8");
|
||||
const executableSource = source
|
||||
.replace(/--\[\[[\s\S]*?\]\]/g, "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.replace(/--.*$/, ""))
|
||||
.join("\n");
|
||||
for (const match of executableSource.matchAll(/\b49\d{4}\b/g)) {
|
||||
const spellId = Number(match[0]);
|
||||
const assignments = assignmentsBySpell.get(spellId) ?? new Map();
|
||||
const key = "lua:" + fileName + ":" + spellId;
|
||||
assignments.set(key, {
|
||||
sourceKind: "lua-reference",
|
||||
sourceFile: fileName,
|
||||
});
|
||||
assignmentsBySpell.set(spellId, assignments);
|
||||
}
|
||||
}
|
||||
|
||||
const spells = [];
|
||||
|
||||
@@ -70,7 +70,7 @@ for (const dungeon of dungeons) {
|
||||
String(actorManifest.proceduralFallbacks.length)
|
||||
+ " runtime/dynamic actor containers use HealerMan procedural visuals because their ROS files expose no static triangles.",
|
||||
] : []),
|
||||
"Mechanics candidates are source-audited; complex doors, summons, phases, patrol scripts, and loot remain review-required.",
|
||||
"Boss combat kits are compiled from RuneWaker spell/script evidence; complex doors, patrol scripts, and loot remain review-required.",
|
||||
];
|
||||
const definition = {
|
||||
schemaVersion: "__SCHEMA__",
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
#!/usr/bin/env node
|
||||
import { readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
pipelineDirectory,
|
||||
projectPath,
|
||||
projectRoot,
|
||||
readJson,
|
||||
sourceRootFor,
|
||||
} from "./lib/recipe.mjs";
|
||||
|
||||
const recipesDirectory = path.join(pipelineDirectory, "recipes");
|
||||
const mechanicCatalogFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
|
||||
const spellCatalogFile = path.join(
|
||||
pipelineDirectory,
|
||||
"snapshots",
|
||||
"runewaker-npc-spell-catalog.json",
|
||||
);
|
||||
const recipeNames = (await readdir(recipesDirectory))
|
||||
.filter((name) => name.endsWith("-population.json"))
|
||||
.sort();
|
||||
|
||||
function slugify(value) {
|
||||
return String(value ?? "")
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function cleanLua(source) {
|
||||
return source
|
||||
.replace(/--\[\[[\s\S]*?\]\]/g, "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.replace(/--.*$/, ""))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function functionIndex(luaSources) {
|
||||
const functions = new Map();
|
||||
for (const source of luaSources) {
|
||||
const cleaned = cleanLua(source.text);
|
||||
const matches = [...cleaned.matchAll(/^\s*function\s+([A-Za-z_][A-Za-z0-9_:.]*)\s*\(/gm)];
|
||||
for (let index = 0; index < matches.length; index += 1) {
|
||||
const match = matches[index];
|
||||
const next = matches[index + 1];
|
||||
functions.set(match[1], {
|
||||
file: source.file,
|
||||
body: cleaned.slice(match.index, next?.index ?? cleaned.length),
|
||||
});
|
||||
}
|
||||
}
|
||||
return functions;
|
||||
}
|
||||
|
||||
function rootName(value) {
|
||||
return String(value ?? "").trim().match(/^([A-Za-z_][A-Za-z0-9_:.]*)/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function reachableFunctions(index, roots) {
|
||||
const queue = roots.filter(Boolean);
|
||||
const visited = new Set();
|
||||
const result = [];
|
||||
while (queue.length) {
|
||||
const name = queue.shift();
|
||||
if (!name || visited.has(name)) continue;
|
||||
visited.add(name);
|
||||
const definition = index.get(name);
|
||||
if (!definition) continue;
|
||||
result.push({ name, ...definition });
|
||||
for (const match of definition.body.matchAll(/\b([A-Za-z_][A-Za-z0-9_:.]*)\s*\(/g)) {
|
||||
if (index.has(match[1]) && !visited.has(match[1])) queue.push(match[1]);
|
||||
}
|
||||
for (const callbackCall of definition.body.matchAll(/\b(?:BeginPlot|SetPlot)\s*\([^\n]*/g)) {
|
||||
for (const match of callbackCall[0].matchAll(/["']([A-Za-z_][A-Za-z0-9_:.]*)["']/g)) {
|
||||
if (index.has(match[1]) && !visited.has(match[1])) queue.push(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function telegraphFor(spell, delivery, target, range) {
|
||||
const name = spell.name ?? "";
|
||||
const effectRadius = Math.max(3, Math.min(15, Number(spell.source?.effectRange ?? 0) * 0.1));
|
||||
if (/\b(?:breath|cleave|frontal|sweep|fan)\b/i.test(name)) {
|
||||
return {
|
||||
delivery: "area",
|
||||
target: "nearby-party",
|
||||
radius: undefined,
|
||||
telegraph: {
|
||||
shape: "cone",
|
||||
anchor: "source",
|
||||
warningMs: 1800,
|
||||
length: Math.max(6, range),
|
||||
angleDegrees: /breath/i.test(name) ? 70 : 95,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (/\b(?:beam|charge|line|lance)\b/i.test(name)) {
|
||||
return {
|
||||
delivery: "area",
|
||||
target: "nearby-party",
|
||||
radius: undefined,
|
||||
telegraph: {
|
||||
shape: "line",
|
||||
anchor: "source",
|
||||
warningMs: 1800,
|
||||
length: Math.max(7, range),
|
||||
width: Math.max(2, effectRadius * 0.45),
|
||||
},
|
||||
};
|
||||
}
|
||||
const area = Number(spell.source?.rangeType ?? 0) !== 0
|
||||
|| Number(spell.source?.effectRange ?? 0) >= 30
|
||||
|| /\b(?:area|group|storm|rain|nova|vortex|wave|burst|explosion|whirl|spin|quake|shock)\b/i.test(name);
|
||||
if (!area) return { delivery, target, radius: undefined, telegraph: undefined };
|
||||
const targetAnchored = /\b(?:rain|meteor|eruption|ground|barrage)\b/i.test(name)
|
||||
&& Number(spell.source?.targetType ?? 0) !== 0;
|
||||
return {
|
||||
delivery: "area",
|
||||
target: "nearby-party",
|
||||
radius: effectRadius,
|
||||
telegraph: {
|
||||
shape: "circle",
|
||||
anchor: targetAnchored ? "target" : "source",
|
||||
warningMs: 1800,
|
||||
radius: effectRadius,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function damageMechanic(entityId, spell) {
|
||||
const runtime = spell.runtime;
|
||||
const spatial = telegraphFor(spell, runtime.delivery, runtime.target, runtime.range);
|
||||
const castTimeMs = Math.max(
|
||||
spatial.telegraph?.warningMs ?? 0,
|
||||
Math.round(Math.max(0, Number(spell.source?.spellTimeSeconds ?? 0)) * 1000),
|
||||
);
|
||||
return {
|
||||
id: entityId + "-spell-" + spell.spellId,
|
||||
name: spell.name,
|
||||
spellId: spell.spellId,
|
||||
delivery: spatial.delivery,
|
||||
target: spatial.target,
|
||||
school: runtime.school,
|
||||
animation: runtime.animation,
|
||||
castTimeMs,
|
||||
range: Math.max(runtime.range, spatial.radius ?? 0),
|
||||
cooldownMs: runtime.cooldownMs,
|
||||
initialCooldownMs: [runtime.cooldownMs, runtime.cooldownMs],
|
||||
repeatCooldownMs: [runtime.cooldownMs, runtime.cooldownMs],
|
||||
damageMultiplier: runtime.damageMultiplier,
|
||||
...(spatial.radius === undefined ? {} : { radius: spatial.radius }),
|
||||
...(spatial.telegraph ? { telegraph: spatial.telegraph } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function controlMechanic(entityId, spell) {
|
||||
const identity = [spell.name, ...(spell.components ?? []).map((component) => component.name)]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const mechanic = /fear|terr(?:or|if)/i.test(identity)
|
||||
? "fear"
|
||||
: /sleep/i.test(identity)
|
||||
? "sleep"
|
||||
: /root|catch|prison|immobili/i.test(identity)
|
||||
? "root"
|
||||
: /silence/i.test(identity)
|
||||
? "silence"
|
||||
: /stun|paraly/i.test(identity)
|
||||
? "stun"
|
||||
: null;
|
||||
if (!mechanic) return null;
|
||||
const cooldownMs = Math.max(4_000, Math.min(
|
||||
25_000,
|
||||
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 12_000,
|
||||
));
|
||||
const range = Math.max(4.5, Math.min(
|
||||
30,
|
||||
Number(spell.source?.attackDistance ?? 0) * 0.1 || 18,
|
||||
));
|
||||
const spatial = telegraphFor(spell, "projectile", "primary", range);
|
||||
return {
|
||||
id: entityId + "-control-" + spell.spellId,
|
||||
name: spell.name,
|
||||
delivery: spatial.delivery,
|
||||
target: spatial.target,
|
||||
school: "shadow",
|
||||
animation: "cast",
|
||||
castTimeMs: Math.max(1800, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
|
||||
range,
|
||||
cooldownMs,
|
||||
initialCooldownMs: [cooldownMs, cooldownMs],
|
||||
repeatCooldownMs: [cooldownMs, cooldownMs],
|
||||
damageMultiplier: 0,
|
||||
...(spatial.radius === undefined ? {} : { radius: spatial.radius }),
|
||||
...(spatial.telegraph ? { telegraph: spatial.telegraph } : {}),
|
||||
effect: { kind: "control", mechanic, durationMs: 3_000 },
|
||||
};
|
||||
}
|
||||
|
||||
function auraMechanic(entityId, spell) {
|
||||
if (Number(spell.source?.targetType ?? -1) !== 0) return null;
|
||||
const namedComponent = (spell.components ?? []).find((component) => (
|
||||
component.name && Number(component.magicFunc) === 1
|
||||
));
|
||||
if (!namedComponent && /^RuneWaker Spell /i.test(spell.name ?? "")) return null;
|
||||
const cooldownMs = Math.max(6_000, Math.min(
|
||||
45_000,
|
||||
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 15_000,
|
||||
));
|
||||
return {
|
||||
id: entityId + "-aura-" + spell.spellId,
|
||||
name: spell.name,
|
||||
delivery: "area",
|
||||
target: "self",
|
||||
school: "arcane",
|
||||
animation: "cast",
|
||||
castTimeMs: Math.max(1_200, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
|
||||
range: 0,
|
||||
cooldownMs,
|
||||
initialCooldownMs: [cooldownMs, cooldownMs],
|
||||
repeatCooldownMs: [cooldownMs, cooldownMs],
|
||||
damageMultiplier: 0,
|
||||
effect: {
|
||||
kind: "aura",
|
||||
aura: slugify(namedComponent?.name ?? spell.name),
|
||||
durationMs: Math.min(12_000, Math.max(4_000, Math.round(cooldownMs * 0.5))),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function targetAuraMechanic(entityId, spell) {
|
||||
if (Number(spell.source?.targetType ?? 0) === 0) return null;
|
||||
const namedComponent = (spell.components ?? []).find((component) => (
|
||||
component.name && Number(component.magicFunc) === 1
|
||||
));
|
||||
if (!namedComponent) return null;
|
||||
const cooldownMs = Math.max(6_000, Math.min(
|
||||
30_000,
|
||||
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 12_000,
|
||||
));
|
||||
const range = Math.max(4.5, Math.min(
|
||||
30,
|
||||
Number(spell.source?.attackDistance ?? 0) * 0.1 || 18,
|
||||
));
|
||||
const spatial = telegraphFor(spell, "projectile", "primary", range);
|
||||
return {
|
||||
id: entityId + "-debuff-" + spell.spellId,
|
||||
name: spell.name,
|
||||
delivery: spatial.delivery,
|
||||
target: spatial.target,
|
||||
school: "arcane",
|
||||
animation: "cast",
|
||||
castTimeMs: Math.max(1_800, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
|
||||
range,
|
||||
cooldownMs,
|
||||
initialCooldownMs: [cooldownMs, cooldownMs],
|
||||
repeatCooldownMs: [cooldownMs, cooldownMs],
|
||||
damageMultiplier: 0,
|
||||
...(spatial.radius === undefined ? {} : { radius: spatial.radius }),
|
||||
...(spatial.telegraph ? { telegraph: spatial.telegraph } : {}),
|
||||
effect: {
|
||||
kind: "target-aura",
|
||||
aura: slugify(namedComponent.name),
|
||||
durationMs: Math.min(10_000, Math.max(4_000, Math.round(cooldownMs * 0.5))),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function scriptPhaseMechanics(entityId, bodies) {
|
||||
const thresholds = [...new Set(bodies.flatMap((body) => (
|
||||
[...body.matchAll(/\bHPPercent\s*<=\s*(\d+(?:\.\d+)?)\b/g)].map((match) => {
|
||||
const raw = Number(match[1]);
|
||||
return Math.round(raw <= 1 ? raw * 100 : raw);
|
||||
})
|
||||
)).filter((value) => value > 0 && value < 100))].sort((left, right) => right - left);
|
||||
return thresholds.map((threshold) => ({
|
||||
id: entityId + "-phase-" + threshold,
|
||||
name: "Phase Change",
|
||||
delivery: "area",
|
||||
target: "self",
|
||||
school: "arcane",
|
||||
animation: "cast",
|
||||
castTimeMs: 1_200,
|
||||
interruptible: false,
|
||||
range: 0,
|
||||
cooldownMs: 1_000,
|
||||
initialCooldownMs: [0, 0],
|
||||
repeatCooldownMs: [1_000, 1_000],
|
||||
maximumCasts: 1,
|
||||
damageMultiplier: 0,
|
||||
effect: { kind: "aura", aura: "phase-" + threshold, durationMs: 5_000 },
|
||||
condition: { kind: "self-health-percent", minimum: 0, maximum: threshold },
|
||||
}));
|
||||
}
|
||||
|
||||
function summonMechanic(entityId, spell) {
|
||||
const summon = (spell.components ?? []).find((component) => Number(component.summonCreatureId) > 0);
|
||||
if (!summon) return null;
|
||||
const cooldownMs = Math.max(8_000, Math.min(
|
||||
40_000,
|
||||
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 20_000,
|
||||
));
|
||||
return {
|
||||
id: entityId + "-summon-" + spell.spellId,
|
||||
name: spell.name,
|
||||
delivery: "area",
|
||||
target: "self",
|
||||
school: "arcane",
|
||||
animation: "cast",
|
||||
castTimeMs: Math.max(1_800, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
|
||||
interruptible: false,
|
||||
range: 0,
|
||||
cooldownMs,
|
||||
initialCooldownMs: [cooldownMs, cooldownMs],
|
||||
repeatCooldownMs: [cooldownMs, cooldownMs],
|
||||
damageMultiplier: 0,
|
||||
effect: { kind: "summon", creatureEntry: summon.summonCreatureId, count: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
function scriptSummons(entityId, bodies) {
|
||||
const joined = bodies.join("\n");
|
||||
if (!/\b(?:CallMonster|CreateNPC|CreateMonster)/i.test(joined)) return [];
|
||||
const constants = new Map([...joined.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(1\d{5})\b/g)]
|
||||
.map((match) => [match[1], Number(match[2])]));
|
||||
const creatureEntry = [...constants.entries()]
|
||||
.find(([name]) => /monster|mob|summon/i.test(name))?.[1];
|
||||
if (!creatureEntry) return [];
|
||||
const count = Math.max(1, Math.min(6, Number(
|
||||
[...joined.matchAll(/\b(?:MonsterNumber|MobNumber|SummonNumber)\w*\s*=\s*(\d+)\b/gi)][0]?.[1] ?? 1,
|
||||
)));
|
||||
const thresholds = [...new Set([...joined.matchAll(/\bHPPercent\s*<=\s*(0?\.\d+|1(?:\.0+)?)\b/g)]
|
||||
.map((match) => Math.round(Number(match[1]) * 100))
|
||||
.filter((value) => value > 0 && value < 100))].sort((left, right) => right - left);
|
||||
const events = thresholds.length ? thresholds : [null];
|
||||
return events.map((threshold, index) => ({
|
||||
id: entityId + "-summon-" + creatureEntry + (threshold ? "-at-" + threshold : ""),
|
||||
name: threshold ? "Reinforcements at " + threshold + "%" : "Call Reinforcements",
|
||||
delivery: "area",
|
||||
target: "self",
|
||||
school: "physical",
|
||||
animation: "cast",
|
||||
castTimeMs: 1_800,
|
||||
interruptible: false,
|
||||
range: 0,
|
||||
cooldownMs: threshold ? 1_000 : 20_000,
|
||||
initialCooldownMs: threshold ? [0, 0] : [8_000, 8_000],
|
||||
repeatCooldownMs: threshold ? [1_000, 1_000] : [20_000, 20_000],
|
||||
...(threshold ? { maximumCasts: 1 } : {}),
|
||||
damageMultiplier: 0,
|
||||
effect: { kind: "summon", creatureEntry, count },
|
||||
...(threshold ? {
|
||||
condition: { kind: "self-health-percent", minimum: 0, maximum: threshold },
|
||||
} : {}),
|
||||
_sourceOrder: index,
|
||||
})).map(({ _sourceOrder, ...mechanic }) => mechanic);
|
||||
}
|
||||
|
||||
function sourceGapMechanic(entityId, name, archetype) {
|
||||
const caster = /mage|witch|goddess|disciple|tree|seeker|guardian/i.test(name);
|
||||
if (caster) {
|
||||
return {
|
||||
id: entityId + "-source-gap-burst",
|
||||
name: "Arcane Burst",
|
||||
delivery: "area",
|
||||
target: "nearby-party",
|
||||
school: "arcane",
|
||||
animation: "cast",
|
||||
castTimeMs: 2_000,
|
||||
range: 12,
|
||||
cooldownMs: 12_000,
|
||||
initialCooldownMs: [8_000, 8_000],
|
||||
repeatCooldownMs: [12_000, 12_000],
|
||||
damageMultiplier: 0.82,
|
||||
radius: 8,
|
||||
telegraph: { shape: "circle", anchor: "source", warningMs: 2_000, radius: 8 },
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: entityId + "-source-gap-sweep",
|
||||
name: archetype === "ooze" ? "Corrosive Sweep" : "Heavy Sweep",
|
||||
delivery: "area",
|
||||
target: "nearby-party",
|
||||
school: archetype === "ooze" ? "nature" : "physical",
|
||||
animation: "attack",
|
||||
castTimeMs: 1_800,
|
||||
range: 7,
|
||||
cooldownMs: 10_000,
|
||||
initialCooldownMs: [7_000, 7_000],
|
||||
repeatCooldownMs: [10_000, 10_000],
|
||||
damageMultiplier: 1.1,
|
||||
telegraph: {
|
||||
shape: "cone",
|
||||
anchor: "source",
|
||||
warningMs: 1_800,
|
||||
length: 7,
|
||||
angleDegrees: 100,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const spellCatalog = await readJson(spellCatalogFile);
|
||||
const spellsById = new Map(spellCatalog.spells.map((spell) => [Number(spell.spellId), spell]));
|
||||
const catalog = await readJson(mechanicCatalogFile);
|
||||
const runewakerBosses = {};
|
||||
let sourceBacked = 0;
|
||||
let sourceGap = 0;
|
||||
const firstRecipe = await readJson(path.join(recipesDirectory, recipeNames[0]));
|
||||
const sharedSourceRoot = sourceRootFor(firstRecipe);
|
||||
const sharedLuaRoot = path.join(
|
||||
sharedSourceRoot,
|
||||
firstRecipe.source.luaRoot ?? "Resource/luascript",
|
||||
);
|
||||
const sharedLuaSources = [];
|
||||
for (const file of (await readdir(sharedLuaRoot)).filter((name) => name.endsWith(".lua")).sort()) {
|
||||
sharedLuaSources.push({ file, text: await readFile(path.join(sharedLuaRoot, file), "utf8") });
|
||||
}
|
||||
const sharedFunctions = functionIndex(sharedLuaSources);
|
||||
|
||||
for (const recipeName of recipeNames) {
|
||||
const recipe = await readJson(path.join(recipesDirectory, recipeName));
|
||||
const snapshot = await readJson(path.resolve(projectRoot, recipe.files.snapshot));
|
||||
const rowsByTemplate = new Map();
|
||||
for (const row of snapshot.rows ?? []) {
|
||||
if (row.classification !== "boss") continue;
|
||||
const rows = rowsByTemplate.get(Number(row.templateId)) ?? [];
|
||||
rows.push(row);
|
||||
rowsByTemplate.set(Number(row.templateId), rows);
|
||||
}
|
||||
|
||||
for (const template of recipe.templates.filter((entry) => entry.classification === "boss")) {
|
||||
const rows = rowsByTemplate.get(Number(template.id)) ?? [];
|
||||
if (!rows.length) continue;
|
||||
const roots = [...new Set(rows.flatMap((row) => [
|
||||
rootName(row.source?.autoPlot),
|
||||
rootName(row.source?.plotClassName),
|
||||
...Object.values(row.source?.templateScripts ?? {}).map(rootName),
|
||||
]).filter(Boolean))];
|
||||
const reachable = reachableFunctions(sharedFunctions, roots);
|
||||
const bodies = reachable.map((entry) => entry.body);
|
||||
const scriptSpellIds = [...new Set(bodies.flatMap((body) => (
|
||||
[...body.matchAll(/\b49\d{4}\b/g)].map((match) => Number(match[0]))
|
||||
)))];
|
||||
const templateSpellIds = [...new Set(rows.flatMap((row) => (
|
||||
(row.source?.spells ?? []).map((spell) => Number(spell.id)).filter((id) => id > 0)
|
||||
)))];
|
||||
const spellIds = [...new Set([...templateSpellIds, ...scriptSpellIds])].sort((a, b) => a - b);
|
||||
const entityId = (recipe.runtimeIdPrefix ?? "rw-" + recipe.dungeonId) + "-" + template.id;
|
||||
const mechanics = [];
|
||||
for (const spellId of spellIds) {
|
||||
const spell = spellsById.get(spellId);
|
||||
if (!spell) continue;
|
||||
if (spell.mappingStatus === "damage-family" && spell.runtime) {
|
||||
mechanics.push(damageMechanic(entityId, spell));
|
||||
continue;
|
||||
}
|
||||
const summon = summonMechanic(entityId, spell);
|
||||
if (summon) {
|
||||
mechanics.push(summon);
|
||||
continue;
|
||||
}
|
||||
const control = controlMechanic(entityId, spell);
|
||||
if (control) {
|
||||
mechanics.push(control);
|
||||
continue;
|
||||
}
|
||||
const aura = auraMechanic(entityId, spell);
|
||||
if (aura) {
|
||||
mechanics.push(aura);
|
||||
continue;
|
||||
}
|
||||
const targetAura = targetAuraMechanic(entityId, spell);
|
||||
if (targetAura) mechanics.push(targetAura);
|
||||
}
|
||||
mechanics.push(...scriptSummons(entityId, bodies));
|
||||
mechanics.push(...scriptPhaseMechanics(entityId, bodies));
|
||||
const uniqueMechanics = [...new Map(mechanics.map((mechanic) => [mechanic.id, mechanic])).values()];
|
||||
const fallback = uniqueMechanics.length === 0;
|
||||
if (fallback) uniqueMechanics.push(sourceGapMechanic(entityId, template.name, template.archetype));
|
||||
if (fallback) sourceGap += 1;
|
||||
else sourceBacked += 1;
|
||||
runewakerBosses[entityId] = {
|
||||
name: template.name,
|
||||
mode: "replace",
|
||||
source: {
|
||||
kind: fallback ? "healer-man-source-gap" : "runewaker",
|
||||
dungeonId: recipe.dungeonId,
|
||||
templateId: template.id,
|
||||
roots,
|
||||
functions: reachable.map((entry) => entry.name),
|
||||
spellIds,
|
||||
},
|
||||
mechanics: uniqueMechanics,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
catalog.bosses = Object.fromEntries([
|
||||
...Object.entries(catalog.bosses).filter(([key]) => !key.startsWith("rw-")),
|
||||
...Object.entries(runewakerBosses).sort(([left], [right]) => left.localeCompare(right)),
|
||||
]);
|
||||
await writeFile(mechanicCatalogFile, JSON.stringify(catalog, null, 2) + "\n", "utf8");
|
||||
console.log("[runewaker-boss-mechanics] wrote " + Object.keys(runewakerBosses).length + " boss kits.");
|
||||
console.log("[runewaker-boss-mechanics] source-backed: " + sourceBacked + "; source-gap fallback: " + sourceGap + ".");
|
||||
console.log("[runewaker-boss-mechanics] " + projectPath(mechanicCatalogFile));
|
||||
File diff suppressed because it is too large.
Load diff
@@ -488,6 +488,9 @@ export function openGameDatabase(options = {}) {
|
||||
id,
|
||||
name,
|
||||
classId,
|
||||
specializationId: typeof character.specializationId === "string"
|
||||
? character.specializationId.slice(0, 100)
|
||||
: null,
|
||||
categoryId: String(character.categoryId ?? "wow").slice(0, 20),
|
||||
raceId: String(character.raceId ?? "human").slice(0, 50),
|
||||
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.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");
|
||||
assert.equal(database.onlineGroupState(leader.id).group.members[0].character.specializationId, "warrior:protection");
|
||||
assert.throws(() => database.startOnlineGroupActivity(leader.id, {
|
||||
type: "dungeon", contentId: "wailing-caverns", fillWithAi: true, partySize: 5,
|
||||
}), (error) => error instanceof GameDatabaseError && error.code === "group_not_ready");
|
||||
|
||||
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");
|
||||
const playersOnly = database.startOnlineGroupActivity(leader.id, {
|
||||
type: "dungeon", contentId: "wailing-caverns", fillWithAi: false, partySize: 5,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
updateCharacterManastormProgress,
|
||||
updateCharacterProgression,
|
||||
updateCharacterSettings,
|
||||
updateCharacterSpecialization,
|
||||
updateCombatPresentationSettings,
|
||||
type StorageLike,
|
||||
} from "./accountRepository";
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
recordManastormFailure,
|
||||
setManastormLoadout,
|
||||
} from "../game/manastormProgress";
|
||||
import { abilitiesForClass } from "../game/abilityCatalog";
|
||||
|
||||
class MemoryStorage implements StorageLike {
|
||||
private values = new Map<string, string>();
|
||||
@@ -38,6 +40,37 @@ class MemoryStorage implements StorageLike {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const storage = new MemoryStorage();
|
||||
const created = await createLocalAccount("CaveHealer", "serpent-pass", storage);
|
||||
@@ -149,7 +182,7 @@ describe("local profile repository", () => {
|
||||
}));
|
||||
expect(loadCombatPresentationSettings("offline-roster", storage))
|
||||
.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();
|
||||
@@ -159,6 +192,7 @@ describe("local profile repository", () => {
|
||||
rosters: { "offline-roster": [] },
|
||||
presentationSettingsByOwner: {
|
||||
"offline-roster": {
|
||||
vfxMode: "original",
|
||||
vfxQuality: "low",
|
||||
vfxEnabled: { player: true, party: false, enemy: true },
|
||||
audioEnabled: { player: true, party: true, enemy: false },
|
||||
@@ -172,12 +206,15 @@ describe("local profile repository", () => {
|
||||
});
|
||||
expect(JSON.parse(legacyV3Storage.getItem(PROFILE_STORAGE_KEYS.database)!)
|
||||
.presentationSettingsByOwner["offline-roster"].vfxMode).toBe("generated");
|
||||
expect(JSON.parse(legacyV3Storage.getItem(PROFILE_STORAGE_KEYS.database)!)
|
||||
.presentationSettingsByOwner["offline-roster"].originalMediaEnabled).toBe(false);
|
||||
|
||||
const storage = new MemoryStorage();
|
||||
const firstOwner = (await createLocalAccount("EffectsOne", "eight-plus", storage)).session!.ownerId;
|
||||
const secondOwner = (await createLocalAccount("EffectsTwo", "eight-plus", storage)).session!.ownerId;
|
||||
const customized = updateCombatPresentationSettings(firstOwner, {
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
originalMediaEnabled: true,
|
||||
vfxMode: "original",
|
||||
vfxQuality: "low",
|
||||
vfxEnabled: { player: true, party: false, enemy: true },
|
||||
|
||||
@@ -22,6 +22,11 @@ import {
|
||||
normalizeManastormProgress,
|
||||
type ManastormProgress,
|
||||
} from "../game/manastormProgress";
|
||||
import { abilitiesForClass } from "../game/abilityCatalog";
|
||||
import {
|
||||
normalizeSpecializationId,
|
||||
specializationById,
|
||||
} from "../game/specializationCatalog";
|
||||
|
||||
const DATABASE_KEY = "healer-man.profile-database.v1";
|
||||
const SESSION_KEY = "healer-man.session.v1";
|
||||
@@ -51,7 +56,7 @@ interface LocalAccount {
|
||||
}
|
||||
|
||||
interface ProfileDatabase {
|
||||
version: 3;
|
||||
version: 4;
|
||||
accounts: LocalAccount[];
|
||||
rosters: Record<string, CharacterProfile[]>;
|
||||
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
|
||||
@@ -93,7 +98,7 @@ function browserSessionStorage(): StorageLike | null {
|
||||
}
|
||||
|
||||
function emptyDatabase(): ProfileDatabase {
|
||||
return { version: 3, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
|
||||
return { version: 4, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
|
||||
}
|
||||
|
||||
function isCharacterProfile(value: unknown): value is CharacterProfile {
|
||||
@@ -120,6 +125,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
settings?: unknown;
|
||||
secondaryClassId?: unknown;
|
||||
actionLoadouts?: unknown;
|
||||
specializationId?: unknown;
|
||||
};
|
||||
const level = typeof legacy.level === "number" && Number.isFinite(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]))
|
||||
: {};
|
||||
const categoryId = contentCategoryForClass(value.classId);
|
||||
const specializationId = categoryId === "rom"
|
||||
? null
|
||||
: normalizeSpecializationId(value.classId, legacy.specializationId);
|
||||
const secondaryClassId = categoryId === "rom"
|
||||
&& isRomClassId(value.classId)
|
||||
&& typeof legacy.secondaryClassId === "string"
|
||||
@@ -168,6 +177,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
talentRanks,
|
||||
actionBindings,
|
||||
categoryId,
|
||||
specializationId,
|
||||
secondaryClassId,
|
||||
actionLoadouts,
|
||||
inventory,
|
||||
@@ -186,7 +196,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
rosters?: unknown;
|
||||
presentationSettingsByOwner?: unknown;
|
||||
}) | 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();
|
||||
}
|
||||
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)
|
||||
? (settings as { vfxMode?: unknown }).vfxMode
|
||||
: undefined;
|
||||
if (storedMode !== normalized.vfxMode) presentationSettingsMigrated = true;
|
||||
const storedOriginalMediaEnabled = settings && typeof settings === "object" && !Array.isArray(settings)
|
||||
? (settings as { originalMediaEnabled?: unknown }).originalMediaEnabled
|
||||
: undefined;
|
||||
if (
|
||||
storedMode !== normalized.vfxMode
|
||||
|| storedOriginalMediaEnabled !== normalized.originalMediaEnabled
|
||||
) presentationSettingsMigrated = true;
|
||||
return [ownerId, normalized];
|
||||
}),
|
||||
);
|
||||
const database: ProfileDatabase = { version: 3, accounts, rosters, presentationSettingsByOwner };
|
||||
if (parsed.version !== 3 || presentationSettingsMigrated) writeDatabase(database, storage);
|
||||
const database: ProfileDatabase = { version: 4, accounts, rosters, presentationSettingsByOwner };
|
||||
if (parsed.version !== 4 || presentationSettingsMigrated) writeDatabase(database, storage);
|
||||
return database;
|
||||
} catch {
|
||||
return emptyDatabase();
|
||||
@@ -415,7 +431,9 @@ export function replaceCharactersForOwner(
|
||||
}
|
||||
|
||||
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(),
|
||||
): CharacterResult {
|
||||
if (!storage) return { ok: false, error: "Local storage is unavailable on this device." };
|
||||
@@ -485,6 +503,54 @@ export function updateCharacterProgression(
|
||||
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(
|
||||
ownerId: string,
|
||||
characterId: string,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useShellStore } from "./shellStore";
|
||||
import { registerDungeonShellRuntime } from "./dungeonShellRuntimeBridge";
|
||||
import { touchCharacter } from "./accountRepository";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
|
||||
export function installDungeonShellRuntime(): void {
|
||||
registerDungeonShellRuntime({ enterDungeon });
|
||||
@@ -33,7 +34,16 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
|
||||
useShellStore.setState({ notice: "That character is no longer available." });
|
||||
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)) {
|
||||
useShellStore.setState({
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type ManastormPartySize,
|
||||
} from "../game/manastormProgress";
|
||||
import { activateManastormStage, releaseActiveManastormStage } from "../game/manastormStageLoader";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import {
|
||||
manastormActiveSpellLoadoutModel,
|
||||
validateManastormActiveSpellLoadout,
|
||||
@@ -63,7 +64,16 @@ function enterManastorm(
|
||||
useShellStore.setState({ notice: "That character is no longer available." });
|
||||
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)) {
|
||||
useShellStore.setState({
|
||||
notice: `${activeCharacter.name}'s class cannot enter as ${PARTY_ROLE_LABELS[playerRole]}.`,
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface OnlineGroupCharacter {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly classId: string;
|
||||
readonly specializationId?: string | null;
|
||||
readonly categoryId: string;
|
||||
readonly raceId: string;
|
||||
readonly gender: "female" | "male";
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
|
||||
"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", () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ const character = {
|
||||
name: "Remedy",
|
||||
raceId: "human" as const,
|
||||
classId: "priest" as const,
|
||||
specializationId: "priest:holy",
|
||||
gender: "female" as const,
|
||||
appearance: createDefaultAppearance(),
|
||||
level: 1,
|
||||
@@ -100,6 +101,15 @@ describe("shell store", () => {
|
||||
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", () => {
|
||||
prepareSelectedCharacter();
|
||||
useGameStore.setState({ paused: true, mapOpen: true, companionOpen: true, distanceTravelled: 42 });
|
||||
@@ -124,11 +134,11 @@ describe("shell store", () => {
|
||||
useShellStore.getState().openDungeons();
|
||||
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({
|
||||
phase: "game",
|
||||
activeCharacter: character,
|
||||
activeDungeonRole: "damage",
|
||||
activeDungeonRole: "healer",
|
||||
});
|
||||
expect(useGameStore.getState()).toMatchObject({
|
||||
paused: false,
|
||||
@@ -142,7 +152,7 @@ describe("shell store", () => {
|
||||
prepareSelectedCharacter();
|
||||
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "tank")).toBe(false);
|
||||
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", () => {
|
||||
@@ -234,7 +244,7 @@ describe("shell store", () => {
|
||||
expect(useShellStore.getState().openManastorms()).toBe(true);
|
||||
expect(useShellStore.getState().phase).toBe("manastorms");
|
||||
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()).toMatchObject({
|
||||
|
||||
+64
-3
@@ -14,6 +14,7 @@ import {
|
||||
updateCharacterManastormProgress,
|
||||
updateCharacterProgression,
|
||||
updateCharacterSettings,
|
||||
updateCharacterSpecialization,
|
||||
updateCombatPresentationSettings as persistCombatPresentationSettings,
|
||||
swapRomClasses,
|
||||
} from "./accountRepository";
|
||||
@@ -55,6 +56,11 @@ import {
|
||||
} from "./onlineAccountClient";
|
||||
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
||||
import { useGameStore } from "../game/store";
|
||||
import {
|
||||
normalizeSpecializationId,
|
||||
specializationById,
|
||||
specializationsForClass,
|
||||
} from "../game/specializationCatalog";
|
||||
|
||||
export interface ShellState {
|
||||
phase: ShellPhase;
|
||||
@@ -82,6 +88,7 @@ export interface ShellState {
|
||||
openManastorms: () => boolean;
|
||||
openGmAdmin: () => boolean;
|
||||
openClassHall: () => boolean;
|
||||
changeSelectedSpecialization: (specializationId: string, resetTalents: boolean) => boolean;
|
||||
chooseRomSecondaryClass: (classId: RomClassId) => boolean;
|
||||
swapSelectedRomClasses: () => boolean;
|
||||
returnToMainMenu: () => void;
|
||||
@@ -110,11 +117,13 @@ export interface ShellState {
|
||||
|
||||
function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
|
||||
const raceId: RaceId = categoryId === "rom" ? "rom-human" : "human";
|
||||
const classId = categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest";
|
||||
return {
|
||||
name: "",
|
||||
categoryId,
|
||||
raceId,
|
||||
classId: categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest",
|
||||
classId,
|
||||
specializationId: specializationsForClass(classId)[0]?.id ?? null,
|
||||
gender: "female",
|
||||
appearance: createDefaultAppearance(),
|
||||
rotation: -12,
|
||||
@@ -202,6 +211,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
if (!availableClasses.some((definition) => definition.id === next.classId)) {
|
||||
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);
|
||||
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." });
|
||||
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({
|
||||
ownerId: session.ownerId,
|
||||
name: normalizeCharacterName(draft.name),
|
||||
categoryId: draft.categoryId,
|
||||
raceId: draft.raceId,
|
||||
classId: draft.classId,
|
||||
specializationId: draft.categoryId === "rom" ? null : draft.specializationId,
|
||||
gender: draft.gender,
|
||||
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." });
|
||||
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: "" });
|
||||
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." });
|
||||
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: "" });
|
||||
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." });
|
||||
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: "" });
|
||||
return true;
|
||||
},
|
||||
@@ -315,13 +349,40 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
openClassHall: () => {
|
||||
const { session, selectedCharacterId, characters } = get();
|
||||
const character = characters.find((entry) => entry.id === selectedCharacterId);
|
||||
if (!session || !character || character.categoryId !== "rom") {
|
||||
set({ notice: "The Class Hall is available to Runes of Magic characters." });
|
||||
if (!session || !character) {
|
||||
set({ notice: "Select a character before opening the Class Hall." });
|
||||
return false;
|
||||
}
|
||||
set({ phase: "class-hall", activeCharacter: null, notice: "" });
|
||||
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) => {
|
||||
const { session, selectedCharacterId } = get();
|
||||
if (!session || !selectedCharacterId) return false;
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface CharacterProfile {
|
||||
name: string;
|
||||
raceId: RaceId;
|
||||
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. */
|
||||
categoryId?: ContentCategoryId;
|
||||
/** RoM's inactive class. The active primary remains `classId`. */
|
||||
@@ -51,6 +53,7 @@ export interface CharacterDraft {
|
||||
categoryId: ContentCategoryId;
|
||||
raceId: RaceId;
|
||||
classId: ClassId;
|
||||
specializationId: string | null;
|
||||
gender: GenderId;
|
||||
appearance: CharacterAppearance;
|
||||
rotation: number;
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/aeternal-circle-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/barren-caves-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/cave-water-dragon-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold-125-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/dungeon-of-dalanis-population.json",
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"schemaVersion": 1,
|
||||
"dungeonId": "forsaken-abbey",
|
||||
"zoneId": 102,
|
||||
"sourceRowCount": 251,
|
||||
"importedSpawnCount": 150,
|
||||
"excludedRowCount": 101,
|
||||
"bossSpawnCount": 8,
|
||||
"objectiveOrder": {
|
||||
"authoritative": false,
|
||||
"kind": "healerMan-distance-derived",
|
||||
"evidence": [
|
||||
"scripts/runewaker-pipeline/build-population.mjs"
|
||||
]
|
||||
},
|
||||
"sourceSnapshot": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json",
|
||||
"sourceContentSha256": "13ef6edd3df6e71389aa92d01201889148c0794cf9ecaafdbaf90d3145d2bf7b",
|
||||
"sourceHashes": {
|
||||
@@ -26,7 +19,7 @@
|
||||
"Resource/data/string_enus.db": "4fce021ba9ed110a213e08d3228b2de57cec66ddbbc8cdcf5753204874d9ab75"
|
||||
}
|
||||
},
|
||||
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(meters,meters,-meters)",
|
||||
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
|
||||
"navmeshProjection": {
|
||||
"maximumAllowedDistance": 2,
|
||||
"maximumObservedDistance": 2.5965,
|
||||
@@ -35,9 +28,6 @@
|
||||
},
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/forsaken-abbey-population.json",
|
||||
"actorManifest": "public/assets/creatures/forsaken-abbey/manifest.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/grafu-castle-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/hall-of-survivors-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/hoto-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/ice-dwarf-kingdom-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/kal-turok-hive-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/kalins-shrine-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/kawaks-tomb-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/kulech-bones-nest-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/lair-demon-dragon-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/mystic-altar-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/paspers-shrine-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/raksha-temple-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/sand-vortex-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/sardo-castle-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/the-origin-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/tomb-seven-heroes-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/treasure-trove-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/warnorken-arena-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss2-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss3-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold-124-population.json",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"runeWakerIdsAreNamespaced": true,
|
||||
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
"sourceSpellCatalogSha256": "ad29efc1d4906fdd4f2ac7a69316cac32d8fcbaea674678f050265c86e0c506c",
|
||||
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
|
||||
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
"status": "green",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold-population.json",
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
} from "../app/characterCatalog";
|
||||
import type { InventoryItem } from "../game/lootTypes";
|
||||
import { resolveContentUrl } from "../content/contentManager";
|
||||
import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||
import {
|
||||
selectCharacterBaseClip,
|
||||
selectCharacterEventClip,
|
||||
@@ -144,6 +145,7 @@ function useManifestEntry(identity: AvatarIdentity | null | undefined): {
|
||||
manifest: AvatarManifest | null;
|
||||
entry: AvatarManifestEntry | null;
|
||||
} {
|
||||
const contentRevision = useContentManagerSnapshot().installedRevision;
|
||||
const [state, setState] = useState<{ manifest: AvatarManifest; entry: AvatarManifestEntry } | null>(null);
|
||||
const key = identity ? avatarManifestKey(identity.raceId, identity.gender) : "";
|
||||
const raceId = identity?.raceId;
|
||||
@@ -162,7 +164,7 @@ function useManifestEntry(identity: AvatarIdentity | null | undefined): {
|
||||
if (!cancelled) console.warn("Playable-character manifest could not be loaded", error);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [gender, key, raceId]);
|
||||
}, [contentRevision, gender, key, raceId]);
|
||||
return { manifest: state?.manifest ?? null, entry: state?.entry ?? null };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useGLTF } from "@react-three/drei";
|
||||
import { resolveContentUrl } from "../content/contentManager";
|
||||
import type { AvatarIdentity } from "./CharacterModel";
|
||||
import {
|
||||
availableAvatarManifestUrls,
|
||||
getAvatarEntry,
|
||||
loadAvatarManifest,
|
||||
} from "./characterManifest";
|
||||
@@ -16,6 +17,7 @@ const requestedModels = new Set<string>();
|
||||
export async function preloadAvatarModels(
|
||||
identities: readonly (AvatarIdentity | null | undefined)[],
|
||||
): Promise<void> {
|
||||
if (!availableAvatarManifestUrls().length) return;
|
||||
const manifest = await loadAvatarManifest();
|
||||
for (const identity of identities) {
|
||||
if (!identity) continue;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
availableAvatarManifestUrls,
|
||||
getAvatarEntry,
|
||||
loadAvatarManifestSources,
|
||||
type AvatarManifest,
|
||||
type AvatarManifestEntry,
|
||||
@@ -33,6 +35,32 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("playable-character manifest loading", () => {
|
||||
it("does not expose optional character manifests until their packs are usable", () => {
|
||||
expect(availableAvatarManifestUrls(() => false)).toEqual([]);
|
||||
expect(availableAvatarManifestUrls((packId) => packId === "core:character-basic")).toEqual([
|
||||
"/assets/characters/wow335a-v1/manifest.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the matching basic human when an extra race pack is unavailable", async () => {
|
||||
const loaded = await loadAvatarManifestSources(
|
||||
["/assets/characters/wow335a-v1/manifest.json"],
|
||||
async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => manifest("3.3.5", {
|
||||
"human:male": { raceId: "human", gender: "male", model: "/base-human.glb" },
|
||||
"orc:male": { raceId: "orc", gender: "male", model: "/orc.glb" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const fallback = getAvatarEntry(loaded, "orc", "male", (packId) => packId === "core:character-basic");
|
||||
expect(fallback?.model).toBe("/base-human.glb");
|
||||
expect(fallback?.counts).toEqual({ skinColor: 1, face: 1, hairStyle: 1, hairColor: 1, feature: 1 });
|
||||
expect(getAvatarEntry(loaded, "orc", "male", () => true)?.model).toBe("/orc.glb");
|
||||
});
|
||||
|
||||
it("keeps valid base characters available when an optional pack fails", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const base = manifest("3.3.5", {
|
||||
|
||||
+108
-20
@@ -1,5 +1,8 @@
|
||||
import type { AppearanceCounts, GenderId, RaceId } from "../app/characterCatalog";
|
||||
import { resolveContentUrl } from "../content/contentManager";
|
||||
import {
|
||||
contentPackIsUsable,
|
||||
resolveContentUrl,
|
||||
} from "../content/contentManager";
|
||||
|
||||
export type TextureTriplet = readonly [string, string, string];
|
||||
export type TextureRegion = readonly [number, number, number, number];
|
||||
@@ -73,6 +76,8 @@ export interface AvatarManifestEntry {
|
||||
featureOptions: readonly AvatarFeatureOption[];
|
||||
attachmentPoints?: Readonly<Record<string, AvatarAttachmentPoint>>;
|
||||
rendererStrategy?: RunewakerModularRendererStrategy;
|
||||
/** Runtime ownership; generated manifests may omit it. */
|
||||
contentPackId?: string;
|
||||
}
|
||||
|
||||
export interface AvatarManifest {
|
||||
@@ -97,12 +102,24 @@ export interface AvatarManifest {
|
||||
export type CharacterManifest = AvatarManifest;
|
||||
|
||||
export const AVATAR_MANIFEST_URL = "/assets/characters/wow335a-v1/manifest.json";
|
||||
export const AVATAR_MANIFEST_URLS = [
|
||||
AVATAR_MANIFEST_URL,
|
||||
"/assets/characters/ascension-v1/manifest.json",
|
||||
"/assets/characters/runewaker-v1/manifest.json",
|
||||
export const BASIC_CHARACTER_CONTENT_PACK_ID = "core:character-basic";
|
||||
export const WOW_CHARACTER_CONTENT_PACK_ID = "characters:wow335a-v1";
|
||||
export const AVATAR_MANIFEST_SOURCES = [
|
||||
{
|
||||
packIds: [BASIC_CHARACTER_CONTENT_PACK_ID, WOW_CHARACTER_CONTENT_PACK_ID],
|
||||
url: AVATAR_MANIFEST_URL,
|
||||
},
|
||||
{
|
||||
packIds: ["characters:ascension-v1"],
|
||||
url: "/assets/characters/ascension-v1/manifest.json",
|
||||
},
|
||||
{
|
||||
packIds: ["characters:runewaker-v1"],
|
||||
url: "/assets/characters/runewaker-v1/manifest.json",
|
||||
},
|
||||
] as const;
|
||||
let manifestPromise: Promise<AvatarManifest> | null = null;
|
||||
export const AVATAR_MANIFEST_URLS = AVATAR_MANIFEST_SOURCES.map((source) => source.url);
|
||||
let manifestRequest: { readonly key: string; readonly promise: Promise<AvatarManifest> } | null = null;
|
||||
|
||||
interface AvatarManifestResponse {
|
||||
readonly ok: boolean;
|
||||
@@ -114,12 +131,37 @@ export function avatarManifestKey(raceId: RaceId, gender: GenderId): string {
|
||||
return `${raceId}:${gender}`;
|
||||
}
|
||||
|
||||
function mergeAvatarManifests(manifests: readonly AvatarManifest[]): AvatarManifest {
|
||||
const [baseManifest, ...extensions] = manifests;
|
||||
export function availableAvatarManifestUrls(
|
||||
isUsable: (packId: string) => boolean = contentPackIsUsable,
|
||||
): readonly string[] {
|
||||
return AVATAR_MANIFEST_SOURCES
|
||||
.filter((source) => source.packIds.some((packId) => isUsable(packId)))
|
||||
.map((source) => source.url);
|
||||
}
|
||||
|
||||
function contentPackForAvatarEntry(url: string, entry: AvatarManifestEntry): string {
|
||||
if (url === AVATAR_MANIFEST_URL) {
|
||||
return entry.raceId === "human"
|
||||
? BASIC_CHARACTER_CONTENT_PACK_ID
|
||||
: WOW_CHARACTER_CONTENT_PACK_ID;
|
||||
}
|
||||
return AVATAR_MANIFEST_SOURCES.find((source) => source.url === url)?.packIds[0]
|
||||
?? WOW_CHARACTER_CONTENT_PACK_ID;
|
||||
}
|
||||
|
||||
function mergeAvatarManifests(
|
||||
sources: readonly { readonly url: string; readonly manifest: AvatarManifest }[],
|
||||
): AvatarManifest {
|
||||
const [{ manifest: baseManifest }, ...extensions] = sources;
|
||||
return {
|
||||
...baseManifest,
|
||||
sourceBuild: manifests.map((manifest) => manifest.sourceBuild).join(" + "),
|
||||
entries: Object.assign({}, baseManifest.entries, ...extensions.map((manifest) => manifest.entries)),
|
||||
sourceBuild: sources.map(({ manifest }) => manifest.sourceBuild).join(" + "),
|
||||
entries: Object.assign({}, ...sources.map(({ manifest, url }) => Object.fromEntries(
|
||||
Object.entries(manifest.entries).map(([key, entry]) => [
|
||||
key,
|
||||
{ ...entry, contentPackId: contentPackForAvatarEntry(url, entry) },
|
||||
]),
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,10 +180,10 @@ export async function loadAvatarManifestSources(
|
||||
if (manifest.version !== 1 || !manifest.entries) {
|
||||
throw new Error(`Avatar manifest is malformed: ${url}`);
|
||||
}
|
||||
return manifest;
|
||||
return { url, manifest };
|
||||
}));
|
||||
const manifests = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
|
||||
if (!manifests.length) {
|
||||
const sources = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
|
||||
if (!sources.length) {
|
||||
const firstFailure = results.find((result) => result.status === "rejected");
|
||||
throw firstFailure?.status === "rejected"
|
||||
? firstFailure.reason
|
||||
@@ -152,27 +194,73 @@ export async function loadAvatarManifestSources(
|
||||
console.warn(`Optional playable-character pack could not be loaded: ${urls[index]}`, result.reason);
|
||||
}
|
||||
});
|
||||
return mergeAvatarManifests(manifests);
|
||||
return mergeAvatarManifests(sources);
|
||||
}
|
||||
|
||||
export async function loadAvatarManifest(): Promise<AvatarManifest> {
|
||||
if (!manifestPromise) {
|
||||
manifestPromise = loadAvatarManifestSources(
|
||||
AVATAR_MANIFEST_URLS,
|
||||
const urls = availableAvatarManifestUrls();
|
||||
if (!urls.length) throw new Error("No optional playable-character packs are installed");
|
||||
const key = urls.join("|");
|
||||
if (manifestRequest?.key !== key) {
|
||||
const promise = loadAvatarManifestSources(
|
||||
urls,
|
||||
(url) => fetch(resolveContentUrl(url), { cache: "force-cache" }),
|
||||
)
|
||||
.catch((error) => {
|
||||
manifestPromise = null;
|
||||
if (manifestRequest?.promise === promise) manifestRequest = null;
|
||||
throw error;
|
||||
});
|
||||
manifestRequest = { key, promise };
|
||||
}
|
||||
return manifestPromise;
|
||||
return manifestRequest.promise;
|
||||
}
|
||||
|
||||
function firstOrEmpty<T>(values: readonly T[]): readonly T[] {
|
||||
return values.length ? [values[0]] : [];
|
||||
}
|
||||
|
||||
function basicHumanEntry(entry: AvatarManifestEntry): AvatarManifestEntry {
|
||||
const skin = entry.skinOptions?.[0];
|
||||
const face = entry.faceOptions?.[0];
|
||||
const hair = entry.hairOptions?.[0];
|
||||
const feature = entry.featureOptions?.[0];
|
||||
return {
|
||||
...entry,
|
||||
contentPackId: BASIC_CHARACTER_CONTENT_PACK_ID,
|
||||
counts: { skinColor: 1, face: 1, hairStyle: 1, hairColor: 1, feature: 1 },
|
||||
dbcIndices: {
|
||||
skinColor: firstOrEmpty(entry.dbcIndices?.skinColor ?? []),
|
||||
face: firstOrEmpty(entry.dbcIndices?.face ?? []),
|
||||
hairStyle: firstOrEmpty(entry.dbcIndices?.hairStyle ?? []),
|
||||
hairColor: firstOrEmpty(entry.dbcIndices?.hairColor ?? []),
|
||||
feature: firstOrEmpty(entry.dbcIndices?.feature ?? []),
|
||||
},
|
||||
skinOptions: skin ? [skin] : [],
|
||||
faceOptions: face ? [{ ...face, bySkin: firstOrEmpty(face.bySkin) }] : [],
|
||||
hairOptions: hair ? [{ ...hair, byColor: firstOrEmpty(hair.byColor) }] : [],
|
||||
featureOptions: feature ? [{ ...feature, byColor: firstOrEmpty(feature.byColor) }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function getAvatarEntry(
|
||||
manifest: AvatarManifest,
|
||||
raceId: RaceId,
|
||||
gender: GenderId,
|
||||
isUsable: (packId: string) => boolean = contentPackIsUsable,
|
||||
): AvatarManifestEntry | null {
|
||||
return manifest.entries[avatarManifestKey(raceId, gender)] ?? null;
|
||||
const requested = manifest.entries[avatarManifestKey(raceId, gender)] ?? null;
|
||||
if (requested) {
|
||||
const requestedPackUsable = !requested.contentPackId || isUsable(requested.contentPackId);
|
||||
if (requested.raceId === "human") {
|
||||
if (requestedPackUsable && isUsable(WOW_CHARACTER_CONTENT_PACK_ID)) return requested;
|
||||
} else if (requestedPackUsable) {
|
||||
return requested;
|
||||
}
|
||||
}
|
||||
const basicHuman = manifest.entries[avatarManifestKey("human", gender)]
|
||||
?? manifest.entries[avatarManifestKey("human", "male")]
|
||||
?? null;
|
||||
return basicHuman && (!basicHuman.contentPackId || isUsable(basicHuman.contentPackId))
|
||||
? basicHumanEntry(basicHuman)
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BROWSER_CONTENT_CACHE_NAME } from "./contentManager";
|
||||
|
||||
const workerSource = readFileSync(
|
||||
new URL("../../public/content-cache-sw.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("browser content cache worker", () => {
|
||||
it("serves only cached same-origin asset requests from the active content cache", () => {
|
||||
expect(workerSource).toContain(`const CONTENT_CACHE_NAME = "${BROWSER_CONTENT_CACHE_NAME}";`);
|
||||
expect(workerSource).toContain('request.method !== "GET"');
|
||||
expect(workerSource).toContain("url.origin !== self.location.origin");
|
||||
expect(workerSource).toContain("MANAGED_ASSET_PREFIXES.some");
|
||||
expect(workerSource).toContain('"/assets/game/"');
|
||||
expect(workerSource).toContain("if (cached) return cached");
|
||||
expect(workerSource).toContain("BUNDLED_ASSET_PATHS.has(url.pathname)");
|
||||
expect(workerSource).toContain("BUNDLED_ASSET_PREFIXES.some");
|
||||
expect(workerSource).not.toContain('"/assets/characters/wow335a-v1/textures/character/human/",');
|
||||
expect(workerSource).toContain('status: 404');
|
||||
expect(workerSource).not.toContain("cached ?? fetch(request)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
BROWSER_CONTENT_CACHE_NAME,
|
||||
checkForContentUpdates,
|
||||
contentPackHasUpdate,
|
||||
contentPackIsBundled,
|
||||
contentPackIsInstalled,
|
||||
contentPackIsUsable,
|
||||
getContentManagerSnapshot,
|
||||
installContentPack,
|
||||
removeContentPack,
|
||||
} from "./contentManager";
|
||||
import type { ContentManifest } from "./contentManifest";
|
||||
|
||||
class MemoryCache {
|
||||
readonly responses = new Map<string, Response>();
|
||||
|
||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||
return this.responses.get(this.key(request))?.clone();
|
||||
}
|
||||
|
||||
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||
this.responses.set(this.key(request), response.clone());
|
||||
}
|
||||
|
||||
async delete(request: RequestInfo | URL): Promise<boolean> {
|
||||
return this.responses.delete(this.key(request));
|
||||
}
|
||||
|
||||
private key(request: RequestInfo | URL): string {
|
||||
if (request instanceof Request) return request.url;
|
||||
return new URL(String(request), "http://localhost").toString();
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryCacheStorage {
|
||||
readonly stores = new Map<string, MemoryCache>();
|
||||
|
||||
async open(name: string): Promise<Cache> {
|
||||
const cache = this.stores.get(name) ?? new MemoryCache();
|
||||
this.stores.set(name, cache);
|
||||
return cache as unknown as Cache;
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<boolean> {
|
||||
return this.stores.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(bytes: Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function contentManifest(version: string, objectName: string, bytes: Uint8Array): ContentManifest {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
version,
|
||||
createdAt: "2026-09-04T00:00:00.000Z",
|
||||
minimumAppVersion: "0.1.0",
|
||||
packs: [{
|
||||
id: "characters:test",
|
||||
label: "Test characters",
|
||||
bytes: bytes.byteLength,
|
||||
fileCount: 1,
|
||||
bundledOnAndroid: false,
|
||||
dependencies: [],
|
||||
}],
|
||||
files: {
|
||||
"/assets/characters/test.glb": {
|
||||
packId: "characters:test",
|
||||
url: `/content/objects/${objectName}`,
|
||||
sha256: sha256(bytes),
|
||||
bytes: bytes.byteLength,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("browser content packs", () => {
|
||||
it("downloads, updates, and removes verified optional content", async () => {
|
||||
const firstBytes = new TextEncoder().encode("first-model");
|
||||
const updatedBytes = new TextEncoder().encode("updated-model");
|
||||
let manifest = contentManifest("content-one", "first.glb", firstBytes);
|
||||
let corruptUpdate = false;
|
||||
const storage = new MemoryCacheStorage();
|
||||
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
|
||||
vi.stubGlobal("caches", storage as unknown as CacheStorage);
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/content/manifest.json")) return Response.json(manifest);
|
||||
if (url.endsWith("/first.glb")) return new Response(firstBytes);
|
||||
if (url.endsWith("/updated.glb")) {
|
||||
return new Response(corruptUpdate ? new Uint8Array(updatedBytes.byteLength).fill(7) : updatedBytes);
|
||||
}
|
||||
return new Response(null, { status: 404 });
|
||||
}));
|
||||
|
||||
await checkForContentUpdates();
|
||||
expect(contentPackIsInstalled("characters:test")).toBe(false);
|
||||
expect(contentPackIsUsable("characters:test")).toBe(false);
|
||||
expect(contentPackHasUpdate("characters:test")).toBe(false);
|
||||
|
||||
await installContentPack("characters:test");
|
||||
expect(contentPackIsInstalled("characters:test")).toBe(true);
|
||||
expect(contentPackIsUsable("characters:test")).toBe(true);
|
||||
expect(contentPackHasUpdate("characters:test")).toBe(false);
|
||||
expect(getContentManagerSnapshot()).toMatchObject({
|
||||
installingPackId: null,
|
||||
downloadedBytes: firstBytes.byteLength,
|
||||
totalBytes: firstBytes.byteLength,
|
||||
error: "",
|
||||
});
|
||||
const active = storage.stores.get(BROWSER_CONTENT_CACHE_NAME);
|
||||
expect(await (await active?.match("http://localhost/assets/characters/test.glb"))?.text()).toBe("first-model");
|
||||
|
||||
manifest = contentManifest("content-two", "updated.glb", updatedBytes);
|
||||
await checkForContentUpdates();
|
||||
expect(contentPackHasUpdate("characters:test")).toBe(true);
|
||||
|
||||
corruptUpdate = true;
|
||||
await expect(installContentPack("characters:test")).rejects.toThrow(/verification/i);
|
||||
expect(await (await active?.match("http://localhost/assets/characters/test.glb"))?.text()).toBe("first-model");
|
||||
|
||||
corruptUpdate = false;
|
||||
await installContentPack("characters:test");
|
||||
expect(contentPackHasUpdate("characters:test")).toBe(false);
|
||||
expect(await (await active?.match("http://localhost/assets/characters/test.glb"))?.text()).toBe("updated-model");
|
||||
|
||||
await removeContentPack("characters:test");
|
||||
expect(contentPackIsInstalled("characters:test")).toBe(false);
|
||||
expect(contentPackIsUsable("characters:test")).toBe(false);
|
||||
expect(await active?.match("http://localhost/assets/characters/test.glb")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps default browser content installed and non-removable", async () => {
|
||||
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
|
||||
|
||||
expect(contentPackIsBundled("core:ui")).toBe(true);
|
||||
expect(contentPackIsUsable("core:ui")).toBe(true);
|
||||
await expect(removeContentPack("core:ui")).resolves.toBeUndefined();
|
||||
expect(contentPackIsUsable("core:ui")).toBe(true);
|
||||
});
|
||||
});
|
||||
+194
-24
@@ -11,7 +11,23 @@ import { NativeContentStorage } from "./nativeContentStorage";
|
||||
|
||||
const CONTENT_STATE_KEY = "healer-man.content-state.v1";
|
||||
const PRODUCTION_ORIGIN = "https://iwanttoheal.phenomrom.com";
|
||||
const STATIC_ANDROID_BUNDLED_PACKS = new Set(["core:ui", "core:equipment", "dungeon:wailing-caverns"]);
|
||||
const STATIC_ANDROID_BUNDLED_PACKS = new Set([
|
||||
"core:ui",
|
||||
"core:equipment",
|
||||
"core:character-basic",
|
||||
"core:runtime",
|
||||
"dungeon:wailing-caverns",
|
||||
]);
|
||||
const STATIC_BROWSER_BUNDLED_PACKS = new Set([
|
||||
"core:ui",
|
||||
"core:equipment",
|
||||
"core:character-basic",
|
||||
"core:runtime",
|
||||
"dungeon:wailing-caverns",
|
||||
]);
|
||||
|
||||
export const BROWSER_CONTENT_CACHE_NAME = "healer-man-content-v1";
|
||||
export const BROWSER_CONTENT_WORKER_URL = "/content-cache-sw.js";
|
||||
|
||||
export interface InstalledContentFile {
|
||||
readonly packId: string;
|
||||
@@ -29,6 +45,7 @@ interface PersistedContentState {
|
||||
|
||||
export interface ContentManagerSnapshot {
|
||||
readonly revision: number;
|
||||
readonly installedRevision: number;
|
||||
readonly checking: boolean;
|
||||
readonly manifest: ContentManifest | null;
|
||||
readonly bundledManifest: ContentManifest | null;
|
||||
@@ -42,6 +59,7 @@ const EMPTY_STATE: PersistedContentState = { version: 1, packVersions: {}, files
|
||||
let installedState = readInstalledState();
|
||||
let snapshot: ContentManagerSnapshot = {
|
||||
revision: 0,
|
||||
installedRevision: 0,
|
||||
checking: false,
|
||||
manifest: null,
|
||||
bundledManifest: null,
|
||||
@@ -87,13 +105,45 @@ function absoluteContentUrl(value: string): string {
|
||||
return new URL(value, `${contentOrigin() || "http://localhost"}/`).toString();
|
||||
}
|
||||
|
||||
function browserCacheRequest(logicalPath: string): Request {
|
||||
const appOrigin = typeof window === "undefined" ? "http://localhost" : window.location.origin;
|
||||
return new Request(new URL(logicalPath, `${appOrigin}/`).toString(), { method: "GET" });
|
||||
}
|
||||
|
||||
function browserCacheStorage(): CacheStorage | null {
|
||||
return typeof caches === "undefined" ? null : caches;
|
||||
}
|
||||
|
||||
async function registerBrowserContentWorker(): Promise<void> {
|
||||
if (Capacitor.isNativePlatform()
|
||||
|| import.meta.env.DEV
|
||||
|| typeof navigator === "undefined"
|
||||
|| !("serviceWorker" in navigator)) return;
|
||||
try {
|
||||
await navigator.serviceWorker.register(BROWSER_CONTENT_WORKER_URL, {
|
||||
scope: "/",
|
||||
updateViaCache: "none",
|
||||
});
|
||||
await navigator.serviceWorker.ready;
|
||||
} catch (error) {
|
||||
console.warn("Browser content cache worker could not be registered.", error);
|
||||
}
|
||||
}
|
||||
|
||||
function extensionForEntry(entry: ContentManifestFile): string {
|
||||
const match = /(?:\.[A-Za-z0-9]+)(?:\?.*)?$/.exec(entry.url);
|
||||
return match?.[0].split("?")[0].toLowerCase() ?? ".bin";
|
||||
}
|
||||
|
||||
function bundledFiles(): Readonly<Record<string, { readonly sha256: string }>> {
|
||||
return snapshot.bundledManifest?.files ?? {};
|
||||
if (snapshot.bundledManifest) return snapshot.bundledManifest.files;
|
||||
if (!snapshot.manifest) return {};
|
||||
const bundledPacks = Capacitor.isNativePlatform()
|
||||
? STATIC_ANDROID_BUNDLED_PACKS
|
||||
: STATIC_BROWSER_BUNDLED_PACKS;
|
||||
return Object.fromEntries(Object.entries(snapshot.manifest.files).filter(([, entry]) => (
|
||||
bundledPacks.has(entry.packId)
|
||||
)));
|
||||
}
|
||||
|
||||
function activeFileIdentities(): Readonly<Record<string, { readonly sha256: string }>> {
|
||||
@@ -117,21 +167,31 @@ export function resolveContentUrl(logicalUrl: string): string {
|
||||
return installed?.fileUri ? Capacitor.convertFileSrc(installed.fileUri) : logicalUrl;
|
||||
}
|
||||
|
||||
export function contentPackIsBundled(packId: string): boolean {
|
||||
if (!Capacitor.isNativePlatform()) return STATIC_BROWSER_BUNDLED_PACKS.has(packId);
|
||||
return STATIC_ANDROID_BUNDLED_PACKS.has(packId)
|
||||
|| Boolean(snapshot.bundledManifest?.packs.some((pack) => pack.id === packId));
|
||||
}
|
||||
|
||||
export function contentPackIsInstalled(packId: string): boolean {
|
||||
return contentPackIsBundled(packId) || Boolean(installedState.packVersions[packId]);
|
||||
}
|
||||
|
||||
export function contentPackIsUsable(packId: string): boolean {
|
||||
if (!Capacitor.isNativePlatform()) return true;
|
||||
const installed = STATIC_ANDROID_BUNDLED_PACKS.has(packId)
|
||||
|| snapshot.bundledManifest?.packs.some((pack) => pack.id === packId)
|
||||
|| Boolean(installedState.packVersions[packId]);
|
||||
if (!installed) return false;
|
||||
if (contentPackIsBundled(packId)) return true;
|
||||
if (!installedState.packVersions[packId]) return false;
|
||||
const pack = snapshot.manifest?.packs.find((candidate) => candidate.id === packId);
|
||||
return pack?.dependencies.every((dependency) => contentPackIsUsable(dependency)) ?? true;
|
||||
}
|
||||
|
||||
export function contentPackHasUpdate(packId: string): boolean {
|
||||
if (!snapshot.manifest) return false;
|
||||
if (!snapshot.manifest || !contentPackIsInstalled(packId)) return false;
|
||||
const pack = snapshot.manifest.packs.find((candidate) => candidate.id === packId);
|
||||
return changedFilesForPack(snapshot.manifest, packId, activeFileIdentities()).length > 0
|
||||
|| Boolean(pack?.dependencies.some((dependency) => contentPackHasUpdate(dependency)));
|
||||
|| Boolean(!contentPackIsBundled(packId)
|
||||
&& pack?.dependencies.some((dependency) => (
|
||||
!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)
|
||||
)));
|
||||
}
|
||||
|
||||
export async function checkForContentUpdates(): Promise<ContentManifest | null> {
|
||||
@@ -149,7 +209,7 @@ export async function checkForContentUpdates(): Promise<ContentManifest | null>
|
||||
}
|
||||
|
||||
async function loadBundledManifest(): Promise<void> {
|
||||
if (!Capacitor.isNativePlatform()) return;
|
||||
if (!Capacitor.isNativePlatform() && import.meta.env.VITE_CONTENT_MODE !== "downloadable") return;
|
||||
try {
|
||||
const response = await fetch("/content/bootstrap-manifest.json", { cache: "force-cache" });
|
||||
if (!response.ok) return;
|
||||
@@ -162,13 +222,102 @@ async function loadBundledManifest(): Promise<void> {
|
||||
export function initializeContentSystem(): Promise<void> {
|
||||
if (!initialization) {
|
||||
initialization = (async () => {
|
||||
await loadBundledManifest();
|
||||
await Promise.all([loadBundledManifest(), registerBrowserContentWorker()]);
|
||||
if (typeof navigator === "undefined" || navigator.onLine !== false) void checkForContentUpdates();
|
||||
})();
|
||||
}
|
||||
return initialization;
|
||||
}
|
||||
|
||||
async function sha256Bytes(bytes: ArrayBuffer): Promise<string> {
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
throw new Error("This browser cannot verify downloaded content.");
|
||||
}
|
||||
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
||||
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function installBrowserContentPack(
|
||||
packId: string,
|
||||
manifest: ContentManifest,
|
||||
changes: ReturnType<typeof changedFilesForPack>,
|
||||
): Promise<void> {
|
||||
const storage = browserCacheStorage();
|
||||
if (!storage) throw new Error("Offline content storage is unavailable in this browser.");
|
||||
|
||||
const totalBytes = changes.reduce((total, [, entry]) => total + entry.bytes, 0);
|
||||
const stagingName = `${BROWSER_CONTENT_CACHE_NAME}-staging-${encodeURIComponent(packId)}`;
|
||||
await storage.delete(stagingName);
|
||||
const staging = await storage.open(stagingName);
|
||||
publish({ installingPackId: packId, downloadedBytes: 0, totalBytes, error: "" });
|
||||
|
||||
let completedBytes = 0;
|
||||
try {
|
||||
for (const [logicalPath, entry] of changes) {
|
||||
const response = await fetch(absoluteContentUrl(entry.url), { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`Content request failed (${response.status}): ${logicalPath}`);
|
||||
const bytes = await response.arrayBuffer();
|
||||
if (bytes.byteLength !== entry.bytes) {
|
||||
throw new Error(`Downloaded content has the wrong size: ${logicalPath}`);
|
||||
}
|
||||
if (await sha256Bytes(bytes) !== entry.sha256) {
|
||||
throw new Error(`Downloaded content failed verification: ${logicalPath}`);
|
||||
}
|
||||
const headers = new Headers();
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (contentType) headers.set("content-type", contentType);
|
||||
await staging.put(browserCacheRequest(logicalPath), new Response(bytes, {
|
||||
headers,
|
||||
status: 200,
|
||||
}));
|
||||
completedBytes += entry.bytes;
|
||||
publish({ downloadedBytes: completedBytes });
|
||||
}
|
||||
|
||||
const active = await storage.open(BROWSER_CONTENT_CACHE_NAME);
|
||||
for (const [logicalPath] of changes) {
|
||||
const cached = await staging.match(browserCacheRequest(logicalPath));
|
||||
if (!cached) throw new Error(`Verified content was not staged: ${logicalPath}`);
|
||||
await active.put(browserCacheRequest(logicalPath), cached);
|
||||
}
|
||||
|
||||
const pendingFiles = { ...installedState.files };
|
||||
const latestPaths = new Set(filesForContentPack(manifest, packId).map(([logicalPath]) => logicalPath));
|
||||
for (const [logicalPath, installed] of Object.entries(pendingFiles)) {
|
||||
if (installed.packId !== packId || latestPaths.has(logicalPath)) continue;
|
||||
await active.delete(browserCacheRequest(logicalPath));
|
||||
delete pendingFiles[logicalPath];
|
||||
}
|
||||
for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) {
|
||||
pendingFiles[logicalPath] = {
|
||||
packId,
|
||||
sha256: entry.sha256,
|
||||
bytes: entry.bytes,
|
||||
extension: extensionForEntry(entry),
|
||||
fileUri: absoluteContentUrl(logicalPath),
|
||||
};
|
||||
}
|
||||
installedState = {
|
||||
version: 1,
|
||||
files: pendingFiles,
|
||||
packVersions: { ...installedState.packVersions, [packId]: manifest.version },
|
||||
};
|
||||
persistInstalledState();
|
||||
publish({
|
||||
installingPackId: null,
|
||||
downloadedBytes: totalBytes,
|
||||
totalBytes,
|
||||
error: "",
|
||||
installedRevision: snapshot.installedRevision + 1,
|
||||
});
|
||||
} catch (error) {
|
||||
publish({ installingPackId: null, error: error instanceof Error ? error.message : "Content download failed." });
|
||||
throw error;
|
||||
} finally {
|
||||
await storage.delete(stagingName).catch(() => false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function installContentPack(packId: string): Promise<void> {
|
||||
const manifest = snapshot.manifest ?? await checkForContentUpdates();
|
||||
if (!manifest) throw new Error(snapshot.error || "Content server is unavailable.");
|
||||
@@ -177,15 +326,20 @@ export async function installContentPack(packId: string): Promise<void> {
|
||||
}
|
||||
const pack = manifest.packs.find((candidate) => candidate.id === packId);
|
||||
if (!pack) throw new Error(`Unknown content pack: ${packId}`);
|
||||
if (!Capacitor.isNativePlatform()) return;
|
||||
|
||||
for (const dependency of pack.dependencies) {
|
||||
if (!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)) {
|
||||
await installContentPack(dependency);
|
||||
if (!contentPackIsBundled(packId)) {
|
||||
for (const dependency of pack.dependencies) {
|
||||
if (!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)) {
|
||||
await installContentPack(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const changes = changedFilesForPack(manifest, packId, activeFileIdentities());
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
await installBrowserContentPack(packId, manifest, changes);
|
||||
return;
|
||||
}
|
||||
const totalBytes = changes.reduce((total, [, entry]) => total + entry.bytes, 0);
|
||||
const storage = await NativeContentStorage.getFreeBytes();
|
||||
const safetyReserve = 128 * 1024 * 1024;
|
||||
@@ -232,7 +386,13 @@ export async function installContentPack(packId: string): Promise<void> {
|
||||
packVersions: { ...installedState.packVersions, [packId]: manifest.version },
|
||||
};
|
||||
persistInstalledState();
|
||||
publish({ installingPackId: null, downloadedBytes: totalBytes, totalBytes, error: "" });
|
||||
publish({
|
||||
installingPackId: null,
|
||||
downloadedBytes: totalBytes,
|
||||
totalBytes,
|
||||
error: "",
|
||||
installedRevision: snapshot.installedRevision + 1,
|
||||
});
|
||||
} catch (error) {
|
||||
publish({ installingPackId: null, error: error instanceof Error ? error.message : "Content download failed." });
|
||||
throw error;
|
||||
@@ -242,19 +402,29 @@ export async function installContentPack(packId: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function removeContentPack(packId: string): Promise<void> {
|
||||
if (!Capacitor.isNativePlatform()) return;
|
||||
if (contentPackIsBundled(packId)) return;
|
||||
const remainingFiles = { ...installedState.files };
|
||||
const targetFiles = Object.entries(remainingFiles).filter(([, entry]) => entry.packId === packId);
|
||||
for (const [logicalPath, entry] of targetFiles) {
|
||||
const shared = Object.entries(remainingFiles).some(([otherPath, other]) => (
|
||||
otherPath !== logicalPath && other.sha256 === entry.sha256 && other.extension === entry.extension && other.packId !== packId
|
||||
));
|
||||
if (!shared) await NativeContentStorage.remove({ sha256: entry.sha256, extension: entry.extension });
|
||||
delete remainingFiles[logicalPath];
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
for (const [logicalPath, entry] of targetFiles) {
|
||||
const shared = Object.entries(remainingFiles).some(([otherPath, other]) => (
|
||||
otherPath !== logicalPath && other.sha256 === entry.sha256 && other.extension === entry.extension && other.packId !== packId
|
||||
));
|
||||
if (!shared) await NativeContentStorage.remove({ sha256: entry.sha256, extension: entry.extension });
|
||||
delete remainingFiles[logicalPath];
|
||||
}
|
||||
} else {
|
||||
const storage = browserCacheStorage();
|
||||
if (!storage) throw new Error("Offline content storage is unavailable in this browser.");
|
||||
const active = await storage.open(BROWSER_CONTENT_CACHE_NAME);
|
||||
for (const [logicalPath] of targetFiles) {
|
||||
await active.delete(browserCacheRequest(logicalPath));
|
||||
delete remainingFiles[logicalPath];
|
||||
}
|
||||
}
|
||||
const packVersions = { ...installedState.packVersions };
|
||||
delete packVersions[packId];
|
||||
installedState = { version: 1, files: remainingFiles, packVersions };
|
||||
persistInstalledState();
|
||||
publish({ error: "" });
|
||||
publish({ error: "", installedRevision: snapshot.installedRevision + 1 });
|
||||
}
|
||||
@@ -21,19 +21,20 @@ import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
|
||||
export function CombatBridge() {
|
||||
const character = useShellStore((state) => state.activeCharacter);
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const activeRole = useShellStore((state) => state.activeDungeonRole);
|
||||
const resetRevision = useGameStore((state) => state.resetRevision);
|
||||
const mountedResetRevision = useRef(resetRevision);
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
useCombatStore.getState().initializeCharacter(character);
|
||||
useCombatStore.getState().initializeCharacter({ ...character, role: activeRole });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
if (gameMode === "manastorm") {
|
||||
activateManastormActiveSpellLoadout(
|
||||
useManastormStore.getState().activeSpellLoadout,
|
||||
);
|
||||
}
|
||||
}, [character?.id, gameMode]);
|
||||
}, [activeRole, character?.id, gameMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mountedResetRevision.current === resetRevision) return;
|
||||
@@ -65,6 +66,7 @@ export function CombatBridge() {
|
||||
// also guarantees a fast return to Character Select cannot outrun a
|
||||
// deferred save and lose the last level or talent point.
|
||||
shell.saveActiveProgression(state.level, state.xp, state.talentRanks);
|
||||
if (state.level !== previousState.level) useCombatStore.getState().refreshResolvedGear();
|
||||
}
|
||||
|
||||
if (state.actionBindings !== previousState.actionBindings) {
|
||||
|
||||
@@ -11,7 +11,8 @@ import type {
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
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 {
|
||||
configureOnlineSessionRuntime,
|
||||
@@ -266,10 +267,17 @@ export function OnlineSessionBridge() {
|
||||
selectedTargetId: combat.selectedTargetId,
|
||||
activeCast: combat.activeCast,
|
||||
animationEvent: combat.playerAnimationEvent,
|
||||
equipment: equipmentItemsForOwner(
|
||||
equipment: resolvedEquipmentItems(
|
||||
combat.equipment,
|
||||
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() } : {}),
|
||||
|
||||
@@ -40,7 +40,10 @@ export function PartyBridge() {
|
||||
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
|
||||
|
||||
useEffect(() => {
|
||||
usePartyStore.getState().syncLevel(level);
|
||||
const party = usePartyStore.getState();
|
||||
const combat = useCombatStore.getState();
|
||||
party.syncLevel(level);
|
||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||
}, [level]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -48,6 +51,7 @@ export function PartyBridge() {
|
||||
mountedResetRevision.current = resetRevision;
|
||||
const combat = useCombatStore.getState();
|
||||
usePartyStore.getState().resetCombat(combat.level, combat.health);
|
||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||
resetPartyRuntimeClock();
|
||||
}, [resetRevision]);
|
||||
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const combatMedia = vi.hoisted(() => ({ ready: false }));
|
||||
|
||||
vi.mock("./combatMediaPack", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./combatMediaPack")>()),
|
||||
combatMediaPackReady: () => combatMedia.ready,
|
||||
}));
|
||||
|
||||
import {
|
||||
DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
type CombatPresentationEvent,
|
||||
} from "./combatPresentation";
|
||||
import {
|
||||
combatAudioOutputEnabled,
|
||||
authenticCombatAudioEnabled,
|
||||
combatMediaPreloadEnabled,
|
||||
configureCombatAudio,
|
||||
masterAudioOutputEnabled,
|
||||
playCombatAudio,
|
||||
@@ -28,7 +38,10 @@ const fixtureEvent: CombatPresentationEvent = {
|
||||
targetPosition: [1, 0, 0],
|
||||
};
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
afterEach(() => {
|
||||
combatMedia.ready = false;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("combat audio output policy", () => {
|
||||
it("does not create or fetch audio while the master volume is disabled", async () => {
|
||||
@@ -50,6 +63,30 @@ describe("combat audio output policy", () => {
|
||||
expect(masterAudioOutputEnabled(0.6)).toBe(true);
|
||||
expect(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0)).toBe(false);
|
||||
expect(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(true);
|
||||
expect(authenticCombatAudioEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS)).toBe(false);
|
||||
combatMedia.ready = true;
|
||||
expect(authenticCombatAudioEnabled({
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
originalMediaEnabled: true,
|
||||
})).toBe(true);
|
||||
expect(combatMediaPreloadEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(false);
|
||||
expect(combatMediaPreloadEnabled({
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
originalMediaEnabled: true,
|
||||
}, 0.6)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not fetch packaged sound effects before the player downloads them", async () => {
|
||||
const AudioContextConstructor = vi.fn();
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal("window", { AudioContext: AudioContextConstructor });
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
|
||||
|
||||
await expect(playMasterAudioCue("/assets/game/combat-effects/audio/fixture.wav")).resolves.toBeNull();
|
||||
|
||||
expect(AudioContextConstructor).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not initialize audio for a zero-volume actor group", () => {
|
||||
|
||||
+22
-2
@@ -6,6 +6,10 @@ import {
|
||||
type CombatPresentationSettings,
|
||||
type CombatPresentationSchool,
|
||||
} from "./combatPresentation";
|
||||
import {
|
||||
combatMediaPackReady,
|
||||
fetchOptionalMediaAsset,
|
||||
} from "./combatMediaPack";
|
||||
|
||||
const MAX_COMBAT_VOICES = 32;
|
||||
|
||||
@@ -37,6 +41,19 @@ export function combatAudioOutputEnabled(
|
||||
));
|
||||
}
|
||||
|
||||
export function authenticCombatAudioEnabled(settings: CombatPresentationSettings): boolean {
|
||||
return settings.originalMediaEnabled && combatMediaPackReady();
|
||||
}
|
||||
|
||||
export function combatMediaPreloadEnabled(
|
||||
settings: CombatPresentationSettings,
|
||||
masterVolume: number,
|
||||
): boolean {
|
||||
return authenticCombatAudioEnabled(settings) && (
|
||||
settings.vfxQuality !== "off" || combatAudioOutputEnabled(settings, masterVolume)
|
||||
);
|
||||
}
|
||||
|
||||
function combatAudioGroupOutputEnabled(
|
||||
settings: CombatPresentationSettings,
|
||||
masterVolume: number,
|
||||
@@ -185,6 +202,7 @@ function playSynthesizedCombatCue(event: CombatPresentationEvent): void {
|
||||
}
|
||||
|
||||
function weightedSound(event: CombatPresentationEvent): CombatEffectSoundAsset | null {
|
||||
if (!configuredSettings || !authenticCombatAudioEnabled(configuredSettings)) return null;
|
||||
const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? [];
|
||||
const playable = sounds.filter((sound) => sound.url);
|
||||
if (!playable.length) return null;
|
||||
@@ -201,7 +219,7 @@ async function decodeAudio(url: string): Promise<AudioBuffer | null> {
|
||||
const audioContext = ensureContext();
|
||||
if (!audioContext) return null;
|
||||
if (!decodedBuffers.has(url)) {
|
||||
decodedBuffers.set(url, fetch(url)
|
||||
decodedBuffers.set(url, fetchOptionalMediaAsset(url)
|
||||
.then((response) => response.ok ? response.arrayBuffer() : Promise.reject(new Error(String(response.status))))
|
||||
.then((data) => audioContext.decodeAudioData(data))
|
||||
.catch(() => null));
|
||||
@@ -213,7 +231,9 @@ export async function playMasterAudioCue(
|
||||
url: string,
|
||||
volume = 1,
|
||||
): Promise<(() => void) | null> {
|
||||
if (!masterAudioOutputEnabled(configuredMasterVolume)) return null;
|
||||
if (!configuredSettings
|
||||
|| !authenticCombatAudioEnabled(configuredSettings)
|
||||
|| !masterAudioOutputEnabled(configuredMasterVolume)) return null;
|
||||
const audioContext = ensureContext();
|
||||
const buffer = await decodeAudio(url);
|
||||
if (
|
||||
|
||||
@@ -116,6 +116,9 @@ describe("named aura combat runtime", () => {
|
||||
school: "fire",
|
||||
interruptible: true,
|
||||
targetActorId: PLAYER_AGGRO_ID,
|
||||
sourcePosition: [0, 0, 0],
|
||||
targetPosition: [1, 0, 0],
|
||||
sourceYaw: Math.PI / 2,
|
||||
startedAt: 1_000,
|
||||
completesAt: 6_000,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const content = vi.hoisted(() => ({
|
||||
usable: false,
|
||||
install: vi.fn(async () => undefined),
|
||||
remove: vi.fn(async () => undefined),
|
||||
listener: null as (() => void) | null,
|
||||
snapshot: {
|
||||
downloadedBytes: 4,
|
||||
totalBytes: 8,
|
||||
manifest: {
|
||||
packs: [{ id: "core:combat-media", fileCount: 12 }],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../content/contentManager", () => ({
|
||||
contentPackIsUsable: () => content.usable,
|
||||
getContentManagerSnapshot: () => content.snapshot,
|
||||
installContentPack: content.install,
|
||||
removeContentPack: content.remove,
|
||||
resolveContentUrl: (url: string) => `resolved:${url}`,
|
||||
subscribeContentManager: (listener: () => void) => {
|
||||
content.listener = listener;
|
||||
return () => { content.listener = null; };
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
combatMediaPackReady,
|
||||
downloadCombatMediaPack,
|
||||
fetchCombatMediaAsset,
|
||||
removeCombatMediaPack,
|
||||
} from "./combatMediaPack";
|
||||
|
||||
beforeEach(() => {
|
||||
content.usable = false;
|
||||
content.install.mockClear();
|
||||
content.remove.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("optional combat media pack", () => {
|
||||
it("uses the shared verified content-pack installer only after an explicit request", async () => {
|
||||
const progress = vi.fn();
|
||||
await expect(downloadCombatMediaPack(progress)).resolves.toEqual({ fileCount: 12 });
|
||||
expect(content.install).toHaveBeenCalledWith("core:combat-media");
|
||||
expect(progress).toHaveBeenLastCalledWith({ completed: 4, total: 8 });
|
||||
});
|
||||
|
||||
it("does not fetch source media while its pack is offloaded", async () => {
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
expect(combatMediaPackReady()).toBe(false);
|
||||
await expect(fetchCombatMediaAsset("/assets/game/combat-effects/audio/shield.wav"))
|
||||
.rejects.toThrow("not installed");
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves installed media through the shared content router and removes the same pack", async () => {
|
||||
content.usable = true;
|
||||
const response = new Response(new Uint8Array([1, 2, 3]));
|
||||
const fetch = vi.fn(async () => response);
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
await expect(fetchCombatMediaAsset("/assets/game/combat-effects/audio/shield.wav"))
|
||||
.resolves.toBe(response);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"resolved:/assets/game/combat-effects/audio/shield.wav",
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
|
||||
await removeCombatMediaPack();
|
||||
expect(content.remove).toHaveBeenCalledWith("core:combat-media");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
contentPackIsUsable,
|
||||
getContentManagerSnapshot,
|
||||
installContentPack,
|
||||
removeContentPack,
|
||||
resolveContentUrl,
|
||||
subscribeContentManager,
|
||||
} from "../content/contentManager";
|
||||
|
||||
export const COMBAT_MEDIA_MANIFEST_URL = "/assets/game/combat-effects/manifest.json";
|
||||
export const COMBAT_MEDIA_CONTENT_PACK_ID = "core:combat-media";
|
||||
|
||||
export interface CombatMediaDownloadProgress {
|
||||
readonly completed: number;
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
export interface CombatMediaDownloadResult {
|
||||
readonly fileCount: number;
|
||||
}
|
||||
|
||||
type ProgressListener = (progress: CombatMediaDownloadProgress) => void;
|
||||
|
||||
function isCombatMediaUrl(value: unknown): value is string {
|
||||
return typeof value === "string" && value.startsWith("/assets/game/combat-effects/");
|
||||
}
|
||||
|
||||
/** Callers must enforce the player's original-media opt-in before fetching. */
|
||||
export async function fetchCombatMediaAsset(url: string): Promise<Response> {
|
||||
if (!isCombatMediaUrl(url)) throw new Error(`Unsupported combat media URL: ${url}`);
|
||||
return fetchOptionalMediaAsset(url);
|
||||
}
|
||||
|
||||
/** Fetch source media only after its shared optional pack has been enabled. */
|
||||
export async function fetchOptionalMediaAsset(url: string): Promise<Response> {
|
||||
if (!combatMediaPackReady()) throw new Error("Original spell media is not installed.");
|
||||
return fetch(resolveContentUrl(url), { cache: "no-store" });
|
||||
}
|
||||
|
||||
/** Download and verify every file owned by the original combat-media pack. */
|
||||
export async function downloadCombatMediaPack(
|
||||
onProgress?: ProgressListener,
|
||||
): Promise<CombatMediaDownloadResult> {
|
||||
const publishProgress = () => {
|
||||
const state = getContentManagerSnapshot();
|
||||
onProgress?.({
|
||||
completed: state.downloadedBytes,
|
||||
total: Math.max(1, state.totalBytes),
|
||||
});
|
||||
};
|
||||
const unsubscribe = subscribeContentManager(publishProgress);
|
||||
try {
|
||||
await installContentPack(COMBAT_MEDIA_CONTENT_PACK_ID);
|
||||
publishProgress();
|
||||
const pack = getContentManagerSnapshot().manifest?.packs.find((candidate) => (
|
||||
candidate.id === COMBAT_MEDIA_CONTENT_PACK_ID
|
||||
));
|
||||
return { fileCount: pack?.fileCount ?? 0 };
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCombatMediaPack(): Promise<void> {
|
||||
await removeContentPack(COMBAT_MEDIA_CONTENT_PACK_ID);
|
||||
}
|
||||
|
||||
export function combatMediaPackReady(): boolean {
|
||||
return contentPackIsUsable(COMBAT_MEDIA_CONTENT_PACK_ID);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ describe("combat presentation", () => {
|
||||
audioEnabled: { enemy: false },
|
||||
audioVolume: { player: 2, party: -1, enemy: 0.35 },
|
||||
})).toEqual({
|
||||
originalMediaEnabled: false,
|
||||
vfxMode: "generated",
|
||||
vfxQuality: "low",
|
||||
vfxEnabled: { player: false, party: true, enemy: true },
|
||||
@@ -31,8 +32,13 @@ describe("combat presentation", () => {
|
||||
});
|
||||
expect(normalizeCombatPresentationSettings({
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
originalMediaEnabled: true,
|
||||
vfxMode: "original",
|
||||
}).vfxMode).toBe("original");
|
||||
expect(normalizeCombatPresentationSettings({
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
vfxMode: "original",
|
||||
}).vfxMode).toBe("generated");
|
||||
expect(normalizeCombatPresentationSettings({
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
vfxMode: "hybrid",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
import {
|
||||
COMBAT_MEDIA_MANIFEST_URL,
|
||||
combatMediaPackReady,
|
||||
fetchCombatMediaAsset,
|
||||
} from "./combatMediaPack";
|
||||
|
||||
export type CombatActorGroup = "player" | "party" | "enemy";
|
||||
export type CombatVfxMode = "generated" | "original";
|
||||
@@ -24,6 +29,7 @@ export type CombatPresentationSource = "wow335" | "ascension" | "runewaker" | "f
|
||||
export type CombatPresentationPosition = readonly [number, number, number];
|
||||
|
||||
export interface CombatPresentationSettings {
|
||||
readonly originalMediaEnabled: boolean;
|
||||
readonly vfxMode: CombatVfxMode;
|
||||
readonly vfxQuality: CombatVfxQuality;
|
||||
readonly vfxEnabled: Readonly<Record<CombatActorGroup, boolean>>;
|
||||
@@ -34,6 +40,7 @@ export interface CombatPresentationSettings {
|
||||
const ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
|
||||
|
||||
export const DEFAULT_COMBAT_PRESENTATION_SETTINGS: CombatPresentationSettings = Object.freeze({
|
||||
originalMediaEnabled: false,
|
||||
vfxMode: "generated",
|
||||
vfxQuality: "high",
|
||||
vfxEnabled: Object.freeze({ player: true, party: true, enemy: true }),
|
||||
@@ -80,13 +87,20 @@ export function normalizeCombatPresentationSettings(
|
||||
const record = value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Partial<Record<keyof CombatPresentationSettings, unknown>>
|
||||
: {};
|
||||
const vfxMode = record.vfxMode === "generated" || record.vfxMode === "original"
|
||||
const originalMediaEnabled = typeof record.originalMediaEnabled === "boolean"
|
||||
? record.originalMediaEnabled
|
||||
: fallback.originalMediaEnabled;
|
||||
const requestedVfxMode = record.vfxMode === "generated" || record.vfxMode === "original"
|
||||
? record.vfxMode
|
||||
: fallback.vfxMode;
|
||||
const vfxMode = requestedVfxMode === "original" && !originalMediaEnabled
|
||||
? "generated"
|
||||
: requestedVfxMode;
|
||||
const vfxQuality = record.vfxQuality === "off" || record.vfxQuality === "low" || record.vfxQuality === "high"
|
||||
? record.vfxQuality
|
||||
: fallback.vfxQuality;
|
||||
return Object.freeze({
|
||||
originalMediaEnabled,
|
||||
vfxMode,
|
||||
vfxQuality,
|
||||
vfxEnabled: booleanGroups(record.vfxEnabled, fallback.vfxEnabled),
|
||||
@@ -191,7 +205,6 @@ interface GeneratedCombatEffectManifest {
|
||||
readonly entries: readonly CombatEffectManifestEntry[];
|
||||
}
|
||||
|
||||
const COMBAT_EFFECT_MANIFEST_URL = "/assets/game/combat-effects/manifest.json";
|
||||
const manifestByKey = new Map<string, CombatEffectManifestEntry>();
|
||||
const manifestBySpellId = new Map<number, CombatEffectManifestEntry>();
|
||||
let manifestRequest: Promise<void> | null = null;
|
||||
@@ -207,13 +220,19 @@ function installCombatEffectManifest(manifest: GeneratedCombatEffectManifest): v
|
||||
export function preloadCombatEffectManifest(): Promise<void> {
|
||||
if (manifestRequest) return manifestRequest;
|
||||
if (typeof fetch === "undefined") return Promise.resolve();
|
||||
manifestRequest = fetch(COMBAT_EFFECT_MANIFEST_URL)
|
||||
manifestRequest = fetchCombatMediaAsset(COMBAT_MEDIA_MANIFEST_URL)
|
||||
.then((response) => response.ok ? response.json() : Promise.reject(new Error(String(response.status))))
|
||||
.then((manifest) => installCombatEffectManifest(manifest as GeneratedCombatEffectManifest))
|
||||
.catch(() => undefined);
|
||||
return manifestRequest;
|
||||
}
|
||||
|
||||
export function effectiveCombatVfxMode(settings: CombatPresentationSettings): CombatVfxMode {
|
||||
return settings.originalMediaEnabled && combatMediaPackReady()
|
||||
? settings.vfxMode
|
||||
: "generated";
|
||||
}
|
||||
|
||||
export function combatEffectManifestEntry(
|
||||
spellId: number | undefined,
|
||||
source?: CombatPresentationSource,
|
||||
|
||||
+118
-9
@@ -83,12 +83,14 @@ import {
|
||||
} from "./talentModifiers";
|
||||
import {
|
||||
getMobPosition,
|
||||
getMobRuntimeTransform,
|
||||
mobRuntimeDistanceSquared,
|
||||
nearbyMobRuntimeIds,
|
||||
removeMobPosition,
|
||||
setMobPosition,
|
||||
type CombatPosition,
|
||||
} from "./mobRuntimeRegistry";
|
||||
import { enemyTelegraphContainsPosition } from "./enemyTelegraphGeometry";
|
||||
import {
|
||||
getPartyRuntimePosition,
|
||||
partyRuntimeDistanceSquared,
|
||||
@@ -129,7 +131,6 @@ import { rollBossLoot } from "./lootCatalog";
|
||||
import { normalizeInventory, type InventoryItem } from "./lootTypes";
|
||||
import {
|
||||
PLAYER_EQUIPMENT_OWNER_ID,
|
||||
equipmentStats,
|
||||
activeEquipmentStatsForClass,
|
||||
equipInventoryItem,
|
||||
normalizeEquipment,
|
||||
@@ -137,6 +138,12 @@ import {
|
||||
type EquipmentAssignments,
|
||||
type EquipmentSlot,
|
||||
} from "./equipment";
|
||||
import {
|
||||
resolvedEquipmentStats,
|
||||
type HeirloomContext,
|
||||
} from "./heirloomEquipment";
|
||||
import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles";
|
||||
import { specializationById } from "./specializationCatalog";
|
||||
import {
|
||||
EMPTY_ITEM_STATS,
|
||||
itemStatSummary,
|
||||
@@ -299,6 +306,9 @@ export interface EnemyActiveCast {
|
||||
readonly school: EnemyDamageSchool;
|
||||
readonly interruptible: boolean;
|
||||
readonly targetActorId: AggroActorId | null;
|
||||
readonly sourcePosition: CombatPosition;
|
||||
readonly targetPosition: CombatPosition;
|
||||
readonly sourceYaw: number;
|
||||
readonly startedAt: number;
|
||||
readonly completesAt: number;
|
||||
}
|
||||
@@ -486,6 +496,8 @@ export interface ActorHealingRequest {
|
||||
|
||||
export interface CharacterCombatSeed {
|
||||
readonly classId: ClassId;
|
||||
readonly specializationId?: string | null;
|
||||
readonly role?: PartyRole;
|
||||
readonly secondaryClassId?: ClassId | null;
|
||||
readonly raceId?: RaceId;
|
||||
readonly level?: number;
|
||||
@@ -588,6 +600,8 @@ export interface PendingGroundTarget {
|
||||
|
||||
export interface CombatState {
|
||||
classId: ClassId;
|
||||
specializationId: string | null;
|
||||
gearRole: PartyRole;
|
||||
secondaryClassId: ClassId | null;
|
||||
raceId: RaceId;
|
||||
level: number;
|
||||
@@ -640,6 +654,7 @@ export interface CombatState {
|
||||
settings: GameplaySettings;
|
||||
playerPosition: CombatPosition;
|
||||
initializeCharacter: (profile: CharacterCombatSeed) => void;
|
||||
refreshResolvedGear: () => void;
|
||||
registerMob: (id: string, registration: MobRegistration) => void;
|
||||
removeMob: (id: string) => void;
|
||||
lootMob: (id: string, now?: number) => boolean;
|
||||
@@ -3112,6 +3127,8 @@ function initialState() {
|
||||
const initialPools = initialResourcePools(classId, abilities, maxResource);
|
||||
return {
|
||||
classId,
|
||||
specializationId: null,
|
||||
gearRole: "healer" as PartyRole,
|
||||
secondaryClassId: null,
|
||||
raceId,
|
||||
level,
|
||||
@@ -3170,6 +3187,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
initializeCharacter: (profile) => {
|
||||
resetCombatPresentationSession();
|
||||
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 raceId = profile.raceId ?? "human";
|
||||
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 inventory = normalizeInventory(profile.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(
|
||||
classId,
|
||||
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) => {
|
||||
if (!id) return state;
|
||||
const existing = state.mobs[id];
|
||||
@@ -3466,12 +3531,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const ownerName = ownerId === PLAYER_EQUIPMENT_OWNER_ID
|
||||
? "You"
|
||||
: usePartyStore.getState().members.find((member) => member.id === ownerId)?.name ?? "Companion";
|
||||
const gearStats = activeEquipmentStatsForClass(
|
||||
change.assignments,
|
||||
state.inventory,
|
||||
PLAYER_EQUIPMENT_OWNER_ID,
|
||||
state.classId,
|
||||
);
|
||||
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, state.inventory, {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player",
|
||||
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(
|
||||
state.classId,
|
||||
state.level,
|
||||
@@ -3513,7 +3580,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const state = get();
|
||||
const next = unequipInventorySlot(state.equipment, ownerId, slot);
|
||||
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(
|
||||
state.classId,
|
||||
state.level,
|
||||
@@ -6163,6 +6237,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
);
|
||||
if (selectedAttack && startingEnemyCast) {
|
||||
const attack = selectedAttack;
|
||||
const sourceTransform = getMobRuntimeTransform(id);
|
||||
enemyActiveCast = {
|
||||
attackId: attack.id,
|
||||
name: attack.name,
|
||||
@@ -6170,6 +6245,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
school: attack.school,
|
||||
interruptible: attack.interruptible !== false,
|
||||
targetActorId: resolution.targetActorId,
|
||||
sourcePosition: [...position],
|
||||
targetPosition: [...targetPosition],
|
||||
sourceYaw: sourceTransform?.yaw
|
||||
?? Math.atan2(targetPosition[0] - position[0], targetPosition[2] - position[2]),
|
||||
startedAt: now,
|
||||
completesAt: now + (attack.castTimeMs ?? 0),
|
||||
};
|
||||
@@ -6187,6 +6266,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
);
|
||||
} else if (selectedAttack && !cannotAttack) {
|
||||
const attack = selectedAttack!;
|
||||
const resolvedEnemyCast = enemyActiveCast;
|
||||
enemyActiveCast = null;
|
||||
const effect = attack.effect ?? {
|
||||
kind: "damage" as const,
|
||||
@@ -6198,10 +6278,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
stableCombatUnit(`${id}:${attack.id}:${attackRevision}:target`) * eligibleActors.length,
|
||||
)]
|
||||
: resolution.targetActorId;
|
||||
const telegraphPlacement = resolvedEnemyCast
|
||||
? {
|
||||
sourcePosition: resolvedEnemyCast.sourcePosition,
|
||||
targetPosition: resolvedEnemyCast.targetPosition,
|
||||
sourceYaw: resolvedEnemyCast.sourceYaw,
|
||||
}
|
||||
: {
|
||||
sourcePosition: position,
|
||||
targetPosition,
|
||||
sourceYaw: Math.atan2(targetPosition[0] - position[0], targetPosition[2] - position[2]),
|
||||
};
|
||||
const unvalidatedTargets = attack.target === "nearby-party"
|
||||
? eligibleActors.filter((actorId) => {
|
||||
const candidate = actorPosition(runtimeState, actorId);
|
||||
if (!candidate) return false;
|
||||
if (attack.telegraph) {
|
||||
return enemyTelegraphContainsPosition(attack.telegraph, telegraphPlacement, candidate);
|
||||
}
|
||||
const radius = attack.radius ?? Math.max(1, attack.range * 0.35);
|
||||
const targetDx = candidate[0] - position[0];
|
||||
const targetDy = candidate[1] - position[1];
|
||||
@@ -6298,6 +6392,21 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
targetId: id,
|
||||
now,
|
||||
}).auras];
|
||||
} else if (effect.kind === "target-aura") {
|
||||
for (const actorId of targets) {
|
||||
namedAuras = [...applyCombatAura(namedAuras, {
|
||||
definition: {
|
||||
id: `enemy-debuff:${id}:${attack.id}:${effect.aura}`,
|
||||
name: `${attack.name}: ${effect.aura}`,
|
||||
disposition: "debuff",
|
||||
durationMs: effect.durationMs > 0 ? effect.durationMs : null,
|
||||
dispelCategory: "magic",
|
||||
},
|
||||
sourceId: id,
|
||||
targetId: actorId,
|
||||
now,
|
||||
}).auras];
|
||||
}
|
||||
} else if (effect.kind === "control") {
|
||||
for (const actorId of targets) {
|
||||
if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue;
|
||||
|
||||
@@ -167,6 +167,7 @@ describe("generated dungeon campaign catalog", () => {
|
||||
"Fear",
|
||||
"Magic's Bane",
|
||||
"Corrupt Soul",
|
||||
"Soulstorm",
|
||||
]);
|
||||
expect(attackNames(culling!, "Mal'Ganis")).toEqual([
|
||||
"Basic Attack",
|
||||
@@ -204,6 +205,49 @@ describe("generated dungeon campaign catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("compiles Archaedas's timed and health-based encounter mechanics", () => {
|
||||
const uldaman = generated.definitions.find((dungeon) => dungeon.id === "uldaman")!;
|
||||
const archaedas = Object.values(uldaman.entities).find(
|
||||
(entity) => entity.name === "Archaedas",
|
||||
) as unknown as {
|
||||
readonly combat: {
|
||||
readonly mechanicIds: readonly string[];
|
||||
readonly attacks: ReadonlyArray<{
|
||||
readonly id: string;
|
||||
readonly initialCooldownMs?: readonly [number, number];
|
||||
readonly repeatCooldownMs?: readonly [number, number];
|
||||
readonly condition?: { readonly kind: string; readonly maximum?: number };
|
||||
readonly effect?: { readonly kind: string; readonly creatureEntry?: number; readonly count?: number };
|
||||
readonly telegraph?: { readonly shape: string; readonly anchor: string; readonly radius?: number };
|
||||
}>;
|
||||
};
|
||||
};
|
||||
expect(archaedas.combat.mechanicIds).toEqual([
|
||||
"archaedas-awaken-earthen-custodian",
|
||||
"archaedas-ground-tremor",
|
||||
"archaedas-awaken-earthen-guardians",
|
||||
"archaedas-awaken-vault-warders",
|
||||
]);
|
||||
expect(archaedas.combat.attacks.find((attack) => attack.id === "archaedas-ground-tremor"))
|
||||
.toMatchObject({
|
||||
initialCooldownMs: [0, 10_000],
|
||||
repeatCooldownMs: [14_000, 20_000],
|
||||
telegraph: { shape: "circle", anchor: "source", radius: 18 },
|
||||
});
|
||||
expect(archaedas.combat.attacks.find(
|
||||
(attack) => attack.id === "archaedas-awaken-earthen-guardians",
|
||||
)).toMatchObject({
|
||||
condition: { kind: "self-health-percent", maximum: 70 },
|
||||
effect: { kind: "summon", creatureEntry: 7076, count: 6 },
|
||||
});
|
||||
expect(archaedas.combat.attacks.find(
|
||||
(attack) => attack.id === "archaedas-awaken-vault-warders",
|
||||
)).toMatchObject({
|
||||
condition: { kind: "self-health-percent", maximum: 40 },
|
||||
effect: { kind: "summon", creatureEntry: 10120, count: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not leak outdoor zones, arenas, or raids into the dungeon browser", () => {
|
||||
const ids = new Set(generated.definitions.map((dungeon) => dungeon.id));
|
||||
for (const excluded of [
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { encounterTimeline } from "./encounterTimeline";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
|
||||
describe("encounter mechanic timeline", () => {
|
||||
beforeEach(() => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 40 });
|
||||
useCombatStore.getState().registerMob("boss", {
|
||||
name: "Timeline Boss",
|
||||
boss: true,
|
||||
maxHealth: 1_000,
|
||||
attacks: [{
|
||||
id: "timed-quake",
|
||||
name: "Timed Quake",
|
||||
delivery: "area",
|
||||
target: "nearby-party",
|
||||
school: "nature",
|
||||
animation: "cast",
|
||||
range: 20,
|
||||
radius: 10,
|
||||
cooldownMs: 15_000,
|
||||
repeatCooldownMs: [12_000, 18_000],
|
||||
damageMultiplier: 1,
|
||||
}, {
|
||||
id: "health-adds",
|
||||
name: "Awaken Adds",
|
||||
delivery: "area",
|
||||
target: "self",
|
||||
school: "physical",
|
||||
animation: "cast",
|
||||
range: 0,
|
||||
cooldownMs: 1_000,
|
||||
maximumCasts: 1,
|
||||
damageMultiplier: 0,
|
||||
effect: { kind: "summon", creatureEntry: 1, count: 2 },
|
||||
condition: { kind: "self-health-percent", minimum: 0, maximum: 70 },
|
||||
}],
|
||||
});
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
boss: {
|
||||
...state.mobs.boss,
|
||||
engaged: true,
|
||||
abilityReadyAt: { "timed-quake": 16_000, "health-adds": 1_000 },
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("shows both timer countdowns and pending health thresholds", () => {
|
||||
const model = encounterTimeline(useCombatStore.getState().mobs, 10_000);
|
||||
expect(model?.bossName).toBe("Timeline Boss");
|
||||
expect(model?.rows.map((row) => [row.name, row.kind, row.value])).toEqual([
|
||||
["Timed Quake", "timer", "6.0s"],
|
||||
["Awaken Adds", "health", "70% HP"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("promotes an active cast and hides a completed one-shot threshold", () => {
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
boss: {
|
||||
...state.mobs.boss,
|
||||
abilityCastCounts: { "health-adds": 1 },
|
||||
activeCast: {
|
||||
attackId: "timed-quake",
|
||||
name: "Timed Quake",
|
||||
school: "nature",
|
||||
interruptible: true,
|
||||
targetActorId: "__player__",
|
||||
sourcePosition: [0, 0, 0],
|
||||
targetPosition: [0, 0, 5],
|
||||
sourceYaw: 0,
|
||||
startedAt: 10_000,
|
||||
completesAt: 12_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
expect(encounterTimeline(useCombatStore.getState().mobs, 11_250)?.rows).toMatchObject([{
|
||||
name: "Timed Quake",
|
||||
kind: "casting",
|
||||
value: "0.8s",
|
||||
}]);
|
||||
});
|
||||
|
||||
it("keeps every pending health threshold visible beyond the countdown row limit", () => {
|
||||
const boss = useCombatStore.getState().mobs.boss;
|
||||
const extraTimers = Array.from({ length: 5 }, (_, index) => ({
|
||||
...boss.attacks[0],
|
||||
id: `extra-timer-${index}`,
|
||||
name: `Extra Timer ${index}`,
|
||||
}));
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
boss: {
|
||||
...state.mobs.boss,
|
||||
attacks: [...state.mobs.boss.attacks, ...extraTimers],
|
||||
abilityReadyAt: {
|
||||
...state.mobs.boss.abilityReadyAt,
|
||||
...Object.fromEntries(extraTimers.map((attack, index) => [attack.id, 11_000 + index * 1_000])),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const rows = encounterTimeline(useCombatStore.getState().mobs, 10_000, 4)?.rows ?? [];
|
||||
expect(rows.filter((row) => row.kind === "timer")).toHaveLength(4);
|
||||
expect(rows.filter((row) => row.kind === "health").map((row) => row.value)).toEqual(["70% HP"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { MobCombatState } from "./combatStore";
|
||||
import type { EnemyDamageSchool, EnemyTelegraphShape } from "./enemyAttacks";
|
||||
|
||||
export type EncounterTimelineKind = "casting" | "timer" | "health";
|
||||
|
||||
export interface EncounterTimelineRow {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly kind: EncounterTimelineKind;
|
||||
readonly value: string;
|
||||
readonly progress: number;
|
||||
readonly sortAt: number;
|
||||
readonly school: EnemyDamageSchool;
|
||||
readonly telegraphShape?: EnemyTelegraphShape;
|
||||
}
|
||||
|
||||
export interface EncounterTimelineModel {
|
||||
readonly bossId: string;
|
||||
readonly bossName: string;
|
||||
readonly rows: readonly EncounterTimelineRow[];
|
||||
}
|
||||
|
||||
function clampedProgress(value: number): number {
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
|
||||
export function encounterTimeline(
|
||||
mobs: Readonly<Record<string, MobCombatState>>,
|
||||
now: number,
|
||||
limit = 4,
|
||||
): EncounterTimelineModel | null {
|
||||
const boss = Object.values(mobs)
|
||||
.filter((mob) => mob.boss && mob.engaged && !mob.dead && !mob.despawned)
|
||||
.sort((left, right) => (
|
||||
Number(Boolean(right.activeCast)) - Number(Boolean(left.activeCast))
|
||||
|| left.id.localeCompare(right.id)
|
||||
))[0];
|
||||
if (!boss) return null;
|
||||
|
||||
const healthPercent = (boss.health / Math.max(1, boss.maxHealth)) * 100;
|
||||
const rows = boss.attacks.flatMap<EncounterTimelineRow>((attack) => {
|
||||
if (attack.id === "basic-attack") return [];
|
||||
const castCount = boss.abilityCastCounts[attack.id] ?? 0;
|
||||
if (attack.maximumCasts && castCount >= attack.maximumCasts) return [];
|
||||
const common = {
|
||||
id: attack.id,
|
||||
name: attack.name,
|
||||
school: attack.school,
|
||||
...(attack.telegraph ? { telegraphShape: attack.telegraph.shape } : {}),
|
||||
};
|
||||
if (boss.activeCast?.attackId === attack.id) {
|
||||
const duration = Math.max(1, boss.activeCast.completesAt - boss.activeCast.startedAt);
|
||||
const remaining = Math.max(0, boss.activeCast.completesAt - now);
|
||||
return [{
|
||||
...common,
|
||||
kind: "casting",
|
||||
value: `${(remaining / 1_000).toFixed(1)}s`,
|
||||
progress: clampedProgress(remaining / duration),
|
||||
sortAt: -1,
|
||||
}];
|
||||
}
|
||||
if (attack.condition?.kind === "self-health-percent") {
|
||||
const threshold = attack.condition.maximum;
|
||||
return [{
|
||||
...common,
|
||||
kind: "health",
|
||||
value: `${Math.round(threshold)}% HP`,
|
||||
progress: clampedProgress(healthPercent / Math.max(1, threshold)),
|
||||
sortAt: 1_000_000 + (100 - threshold) * 1_000,
|
||||
}];
|
||||
}
|
||||
const readyAt = boss.abilityReadyAt[attack.id];
|
||||
if (!Number.isFinite(readyAt)) return [];
|
||||
const remaining = Math.max(0, (readyAt ?? now) - now);
|
||||
const cooldown = Math.max(
|
||||
1,
|
||||
attack.repeatCooldownMs?.[1] ?? attack.initialCooldownMs?.[1] ?? attack.cooldownMs,
|
||||
);
|
||||
return [{
|
||||
...common,
|
||||
kind: "timer",
|
||||
value: `${(remaining / 1_000).toFixed(1)}s`,
|
||||
progress: clampedProgress(remaining / cooldown),
|
||||
sortAt: remaining,
|
||||
}];
|
||||
}).sort((left, right) => left.sortAt - right.sortAt || left.id.localeCompare(right.id));
|
||||
|
||||
const alwaysVisibleHealthRows = rows.filter((row) => row.kind === "health");
|
||||
const countdownRows = rows
|
||||
.filter((row) => row.kind !== "health")
|
||||
.slice(0, Math.max(1, limit));
|
||||
const visibleRows = [...countdownRows, ...alwaysVisibleHealthRows];
|
||||
|
||||
return visibleRows.length ? {
|
||||
bossId: boss.id,
|
||||
bossName: boss.name,
|
||||
rows: visibleRows,
|
||||
} : null;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeEnemyAttack, type EnemyAttackDefinition } from "./enemyAttacks";
|
||||
|
||||
function attack(overrides: Partial<EnemyAttackDefinition>): EnemyAttackDefinition {
|
||||
return {
|
||||
id: "test-special",
|
||||
name: "Test Special",
|
||||
delivery: "area",
|
||||
target: "nearby-party",
|
||||
school: "physical",
|
||||
animation: "attack",
|
||||
range: 12,
|
||||
cooldownMs: 10_000,
|
||||
damageMultiplier: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("enemy attack telegraphs", () => {
|
||||
it("infers a warned cone for legacy frontal mechanics", () => {
|
||||
const normalized = normalizeEnemyAttack(attack({ name: "Stone Cleave" }));
|
||||
expect(normalized.telegraph).toMatchObject({
|
||||
shape: "cone",
|
||||
anchor: "source",
|
||||
warningMs: 1_650,
|
||||
});
|
||||
expect(normalized.castTimeMs).toBe(1_650);
|
||||
});
|
||||
|
||||
it("preserves authored geometry and extends too-short cast warnings", () => {
|
||||
const normalized = normalizeEnemyAttack(attack({
|
||||
name: "Falling Ruin",
|
||||
castTimeMs: 500,
|
||||
telegraph: {
|
||||
shape: "circle",
|
||||
anchor: "target",
|
||||
warningMs: 2_200,
|
||||
radius: 7,
|
||||
},
|
||||
}));
|
||||
expect(normalized.castTimeMs).toBe(2_200);
|
||||
expect(normalized.telegraph).toEqual({
|
||||
shape: "circle",
|
||||
anchor: "target",
|
||||
warningMs: 2_200,
|
||||
radius: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not telegraph basic attacks or beneficial mechanics", () => {
|
||||
expect(normalizeEnemyAttack(attack({ id: "basic-attack" })).telegraph).toBeUndefined();
|
||||
expect(normalizeEnemyAttack(attack({
|
||||
effect: { kind: "heal", multiplier: 1 },
|
||||
})).telegraph).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+106
-2
@@ -17,6 +17,21 @@ export type EnemyDamageSchool =
|
||||
|
||||
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun" | "silence";
|
||||
|
||||
export type EnemyTelegraphShape = "circle" | "cone" | "line";
|
||||
export type EnemyTelegraphAnchor = "source" | "target";
|
||||
|
||||
/** World-space warning geometry shared by simulation and Three presentation. */
|
||||
export interface EnemyAttackTelegraph {
|
||||
readonly shape: EnemyTelegraphShape;
|
||||
readonly anchor: EnemyTelegraphAnchor;
|
||||
/** Minimum warning time before the mechanic resolves. */
|
||||
readonly warningMs: number;
|
||||
readonly radius?: number;
|
||||
readonly length?: number;
|
||||
readonly width?: number;
|
||||
readonly angleDegrees?: number;
|
||||
}
|
||||
|
||||
export type EnemyAbilityEffect =
|
||||
| { readonly kind: "damage"; readonly multiplier: number }
|
||||
| { readonly kind: "heal"; readonly multiplier: number }
|
||||
@@ -26,6 +41,11 @@ export type EnemyAbilityEffect =
|
||||
readonly durationMs: number;
|
||||
readonly magnitude?: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: "target-aura";
|
||||
readonly aura: string;
|
||||
readonly durationMs: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: "control";
|
||||
readonly mechanic: EnemyControlMechanic;
|
||||
@@ -83,6 +103,7 @@ export interface EnemyAttackDefinition {
|
||||
readonly maximumCasts?: number;
|
||||
readonly damageMultiplier: number;
|
||||
readonly radius?: number;
|
||||
readonly telegraph?: EnemyAttackTelegraph;
|
||||
readonly effect?: EnemyAbilityEffect;
|
||||
readonly condition?: EnemyAbilityCondition;
|
||||
readonly source?: EnemyAbilitySource;
|
||||
@@ -96,6 +117,8 @@ export interface EnemyCombatDefinition {
|
||||
readonly moveSpeed?: number;
|
||||
readonly aggroRange?: number;
|
||||
readonly leashRange?: number;
|
||||
/** Stable special-attack ids available for future encounter loadout randomization. */
|
||||
readonly mechanicIds?: readonly string[];
|
||||
readonly attacks: readonly EnemyAttackDefinition[];
|
||||
}
|
||||
|
||||
@@ -125,14 +148,94 @@ function normalizeCooldownRange(
|
||||
return Object.freeze([Math.round(lower), Math.round(upper)]);
|
||||
}
|
||||
|
||||
function harmfulEnemyAttack(value: EnemyAttackDefinition): boolean {
|
||||
return !value.effect
|
||||
|| value.effect.kind === "damage"
|
||||
|| value.effect.kind === "control"
|
||||
|| value.effect.kind === "target-aura";
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy/imported encounters receive safe defaults while authored mechanics can
|
||||
* override every dimension in the modular mechanic catalog.
|
||||
*/
|
||||
export function inferEnemyAttackTelegraph(
|
||||
value: EnemyAttackDefinition,
|
||||
): EnemyAttackTelegraph | undefined {
|
||||
if (value.id === "basic-attack" || !harmfulEnemyAttack(value)) return undefined;
|
||||
const radius = value.radius ?? Math.max(3, value.range * 0.35);
|
||||
if (/\b(?:breath|cleave|frontal|sweep|cone)\b/i.test(value.name)) {
|
||||
return {
|
||||
shape: "cone",
|
||||
anchor: "source",
|
||||
warningMs: 1_650,
|
||||
length: Math.max(5.5, value.range),
|
||||
angleDegrees: /breath/i.test(value.name) ? 70 : 95,
|
||||
};
|
||||
}
|
||||
if (/\b(?:beam|charge|line|lance)\b/i.test(value.name)) {
|
||||
return {
|
||||
shape: "line",
|
||||
anchor: "source",
|
||||
warningMs: 1_650,
|
||||
length: Math.max(7, value.range),
|
||||
width: Math.max(2, radius * 0.45),
|
||||
};
|
||||
}
|
||||
if (value.delivery === "area" || value.radius !== undefined) {
|
||||
const targetAnchored = /\b(?:blizzard|rain|barrage|volley|eruption|ground|meteor)\b/i
|
||||
.test(value.name) && value.target !== "self";
|
||||
return {
|
||||
shape: "circle",
|
||||
anchor: targetAnchored ? "target" : "source",
|
||||
warningMs: 1_650,
|
||||
radius,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeTelegraph(value: EnemyAttackTelegraph): EnemyAttackTelegraph {
|
||||
const warningMs = Math.round(finiteAtLeast(value.warningMs, 1_650, 250));
|
||||
if (value.shape === "circle") {
|
||||
return Object.freeze({
|
||||
shape: value.shape,
|
||||
anchor: value.anchor,
|
||||
warningMs,
|
||||
radius: finiteAtLeast(value.radius, 4, 0.5),
|
||||
});
|
||||
}
|
||||
if (value.shape === "cone") {
|
||||
return Object.freeze({
|
||||
shape: value.shape,
|
||||
anchor: "source",
|
||||
warningMs,
|
||||
length: finiteAtLeast(value.length, 8, 1),
|
||||
angleDegrees: Math.min(180, finiteAtLeast(value.angleDegrees, 90, 5)),
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
shape: value.shape,
|
||||
anchor: "source",
|
||||
warningMs,
|
||||
length: finiteAtLeast(value.length, 8, 1),
|
||||
width: finiteAtLeast(value.width, 3, 0.5),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeEnemyAttack(
|
||||
value: EnemyAttackDefinition,
|
||||
fallback: EnemyAttackDefinition = DEFAULT_ENEMY_ATTACK,
|
||||
): EnemyAttackDefinition {
|
||||
const cooldownMs = Math.round(finiteAtLeast(value.cooldownMs, fallback.cooldownMs, 1));
|
||||
const telegraph = value.telegraph ?? inferEnemyAttackTelegraph(value);
|
||||
const inferredCastTimeMs = value.castTimeMs === undefined && value.animation === "cast"
|
||||
? (fallback.castTimeMs ?? 1_500)
|
||||
: (fallback.castTimeMs ?? 0);
|
||||
: telegraph?.warningMs ?? fallback.castTimeMs ?? 0;
|
||||
const castTimeMs = Math.max(
|
||||
Math.round(finiteAtLeast(value.castTimeMs, inferredCastTimeMs, 0)),
|
||||
telegraph?.warningMs ?? 0,
|
||||
);
|
||||
return Object.freeze({
|
||||
id: value.id.trim() || fallback.id,
|
||||
name: value.name.trim() || fallback.name,
|
||||
@@ -143,7 +246,7 @@ export function normalizeEnemyAttack(
|
||||
target: value.target,
|
||||
school: value.school,
|
||||
animation: value.animation,
|
||||
castTimeMs: Math.round(finiteAtLeast(value.castTimeMs, inferredCastTimeMs, 0)),
|
||||
castTimeMs,
|
||||
interruptible: value.interruptible ?? fallback.interruptible ?? true,
|
||||
range: finiteAtLeast(value.range, fallback.range, 0),
|
||||
cooldownMs,
|
||||
@@ -160,6 +263,7 @@ export function normalizeEnemyAttack(
|
||||
...(value.radius !== undefined
|
||||
? { radius: finiteAtLeast(value.radius, 0, 0) }
|
||||
: {}),
|
||||
...(telegraph ? { telegraph: normalizeTelegraph(telegraph) } : {}),
|
||||
...(value.effect ? { effect: value.effect } : {}),
|
||||
...(value.condition ? { condition: value.condition } : {}),
|
||||
...(value.source ? { source: value.source } : {}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import {
|
||||
deterministicEnemyCooldown,
|
||||
useCombatStore,
|
||||
@@ -171,6 +172,42 @@ describe("server-backed enemy ability runtime", () => {
|
||||
expect(summonIds.every((id) => getMobPosition(id) === null)).toBe(true);
|
||||
});
|
||||
|
||||
it("applies a modular target aura as a dispellable player debuff", () => {
|
||||
useCombatStore.getState().registerMob("runewaker-debuffer", {
|
||||
name: "RuneWaker Debuffer",
|
||||
maxHealth: 1_000,
|
||||
attacks: [{
|
||||
id: "conductivity",
|
||||
name: "Conductivity",
|
||||
delivery: "projectile",
|
||||
target: "primary",
|
||||
school: "arcane",
|
||||
animation: "cast",
|
||||
castTimeMs: 1_000,
|
||||
range: 20,
|
||||
cooldownMs: 10_000,
|
||||
initialCooldownMs: [0, 0],
|
||||
repeatCooldownMs: [10_000, 10_000],
|
||||
damageMultiplier: 0,
|
||||
effect: { kind: "target-aura", aura: "conductivity", durationMs: 6_000 },
|
||||
}],
|
||||
});
|
||||
setMobPosition("runewaker-debuffer", [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob("runewaker-debuffer", 1, 1_000);
|
||||
useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0]);
|
||||
useCombatStore.getState().advanceMobCombat(2_000, [0, 0, 0]);
|
||||
|
||||
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
||||
sourceId: "runewaker-debuffer",
|
||||
targetId: PLAYER_AGGRO_ID,
|
||||
expiresAt: 8_000,
|
||||
definition: expect.objectContaining({
|
||||
disposition: "debuff",
|
||||
dispelCategory: "magic",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("honors encounter-state conditions before selecting an ability", () => {
|
||||
useWailingEncounterStore.getState().reset();
|
||||
useCombatStore.getState().registerMob("event-caster", {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { enemyTelegraphContainsPosition } from "./enemyTelegraphGeometry";
|
||||
|
||||
const placement = {
|
||||
sourcePosition: [0, 0, 0] as const,
|
||||
targetPosition: [0, 0, 10] as const,
|
||||
sourceYaw: 0,
|
||||
};
|
||||
|
||||
describe("enemy telegraph gameplay geometry", () => {
|
||||
it("matches source and target anchored circles", () => {
|
||||
expect(enemyTelegraphContainsPosition({
|
||||
shape: "circle", anchor: "source", warningMs: 1_000, radius: 4,
|
||||
}, placement, [3, 0, 0])).toBe(true);
|
||||
expect(enemyTelegraphContainsPosition({
|
||||
shape: "circle", anchor: "target", warningMs: 1_000, radius: 3,
|
||||
}, placement, [0, 0, 8])).toBe(true);
|
||||
expect(enemyTelegraphContainsPosition({
|
||||
shape: "circle", anchor: "target", warningMs: 1_000, radius: 3,
|
||||
}, placement, [0, 0, 4])).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps cone hits in front and within the visible angle", () => {
|
||||
const cone = {
|
||||
shape: "cone" as const,
|
||||
anchor: "source" as const,
|
||||
warningMs: 1_000,
|
||||
length: 10,
|
||||
angleDegrees: 90,
|
||||
};
|
||||
expect(enemyTelegraphContainsPosition(cone, placement, [2, 0, 7])).toBe(true);
|
||||
expect(enemyTelegraphContainsPosition(cone, placement, [8, 0, 2])).toBe(false);
|
||||
expect(enemyTelegraphContainsPosition(cone, placement, [0, 0, -2])).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the visible line width for collision", () => {
|
||||
const line = {
|
||||
shape: "line" as const,
|
||||
anchor: "source" as const,
|
||||
warningMs: 1_000,
|
||||
length: 12,
|
||||
width: 4,
|
||||
};
|
||||
expect(enemyTelegraphContainsPosition(line, placement, [1.9, 0, 8])).toBe(true);
|
||||
expect(enemyTelegraphContainsPosition(line, placement, [2.1, 0, 8])).toBe(false);
|
||||
});
|
||||
});
|
||||
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