forked from phenom/healer-man
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9bebb4e81 | ||
|
|
eaddad4134 | ||
|
|
fc8a7a722a | ||
|
|
b57d2b2839 | ||
|
|
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
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "blades-edge-arena",
|
||||
"title": "Blade's Edge Arena",
|
||||
"mapId": 562,
|
||||
"lfgDungeonIds": [],
|
||||
"expansion": "the-burning-crusade",
|
||||
"clientBuild": "3.3.5a",
|
||||
"mapDirectory": "World/Maps/bladesedgearena",
|
||||
"environmentMode": "global-wmo",
|
||||
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
|
||||
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
|
||||
"exceptions": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "dalaran-sewers",
|
||||
"title": "Dalaran Sewers",
|
||||
"mapId": 617,
|
||||
"lfgDungeonIds": [],
|
||||
"expansion": "wrath-of-the-lich-king",
|
||||
"clientBuild": "3.3.5a",
|
||||
"mapDirectory": "World/Maps/DalaranArena",
|
||||
"environmentMode": "global-wmo",
|
||||
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
|
||||
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
|
||||
"exceptions": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "nagrand-arena",
|
||||
"title": "Nagrand Arena",
|
||||
"mapId": 559,
|
||||
"lfgDungeonIds": [],
|
||||
"expansion": "the-burning-crusade",
|
||||
"clientBuild": "3.3.5a",
|
||||
"mapDirectory": "World/Maps/PVPZone01",
|
||||
"environmentMode": "global-wmo",
|
||||
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
|
||||
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
|
||||
"exceptions": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "ring-of-valor",
|
||||
"title": "Ring of Valor",
|
||||
"mapId": 618,
|
||||
"lfgDungeonIds": [],
|
||||
"expansion": "wrath-of-the-lich-king",
|
||||
"clientBuild": "3.3.5a",
|
||||
"mapDirectory": "World/Maps/OrgrimmarArena",
|
||||
"environmentMode": "global-wmo",
|
||||
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
|
||||
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
|
||||
"exceptions": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "ruins-of-lordaeron",
|
||||
"title": "Ruins of Lordaeron",
|
||||
"mapId": 572,
|
||||
"lfgDungeonIds": [],
|
||||
"expansion": "the-burning-crusade",
|
||||
"clientBuild": "3.3.5a",
|
||||
"mapDirectory": "World/Maps/PVPLordaeron",
|
||||
"environmentMode": "global-wmo",
|
||||
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
|
||||
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
|
||||
"exceptions": []
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.19",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.19",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+6
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.19",
|
||||
"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",
|
||||
@@ -62,6 +63,8 @@
|
||||
"dungeon:navmesh": "node scripts/dungeon-pipeline/cli.mjs navmesh",
|
||||
"dungeon:navmesh:conversion": "node scripts/dungeon-pipeline/cli.mjs navmesh-conversion",
|
||||
"dungeon:validate": "node scripts/dungeon-pipeline/cli.mjs validate",
|
||||
"arena:assets:build": "node scripts/arena-pipeline/build-wmo-arenas.mjs && node scripts/arena-pipeline/build-custom-arenas.mjs && node scripts/arena-pipeline/prepare-warnorken.mjs",
|
||||
"arena:assets:validate": "node scripts/arena-pipeline/validate-arenas.mjs",
|
||||
"dungeon:enable": "node scripts/dungeon-pipeline/cli.mjs enable",
|
||||
"runewaker:forsaken": "node \"%INIT_CWD%\\scripts\\runewaker-pipeline\\build-forsaken-abbey.mjs\"",
|
||||
"runewaker:forsaken:actors": "node scripts/runewaker-pipeline/build-forsaken-population.mjs --actors-only",
|
||||
@@ -73,6 +76,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 +100,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 arena:assets:validate && 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",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"build": {
|
||||
"attempts": [
|
||||
{
|
||||
"failures": [],
|
||||
"tileCount": 1,
|
||||
"tileSize": null,
|
||||
"valid": true
|
||||
}
|
||||
],
|
||||
"gridCells": 258876,
|
||||
"gridHeight": 564,
|
||||
"gridWidth": 459,
|
||||
"ignoredEmptyTilePackingFailures": 0,
|
||||
"intermediateRetention": null,
|
||||
"mode": "solo",
|
||||
"partition": "watershed",
|
||||
"populatedTileCount": 1,
|
||||
"tileColumns": 1,
|
||||
"tileCount": 1,
|
||||
"tileRows": 1,
|
||||
"tileSize": null
|
||||
},
|
||||
"dungeonId": "ashamanes-fall",
|
||||
"generator": "@recast-navigation/three@0.43.1",
|
||||
"geometry": {
|
||||
"bounds": [
|
||||
[
|
||||
16.435361862182617,
|
||||
-2.676706314086914,
|
||||
4.506431579589844
|
||||
],
|
||||
[
|
||||
65.03536224365234,
|
||||
26.523292541503906,
|
||||
43.1064338684082
|
||||
]
|
||||
],
|
||||
"boundsTolerance": 0.4,
|
||||
"rejectedOutOfBoundsTriangles": 0,
|
||||
"sourceTriangles": 353,
|
||||
"sourceVertices": 1059,
|
||||
"triangles": 353,
|
||||
"vertices": 1059
|
||||
},
|
||||
"input": {
|
||||
"bounds": [
|
||||
[
|
||||
-6.364639759063721,
|
||||
-19.976707458496094,
|
||||
-59.093570709228516
|
||||
],
|
||||
[
|
||||
85.27696990966797,
|
||||
36.782005310058594,
|
||||
53.62468338012695
|
||||
]
|
||||
],
|
||||
"degenerateTriangleCount": 0,
|
||||
"invalidIndexCount": 0,
|
||||
"meshCount": 1,
|
||||
"nonFiniteCoordinateCount": 0,
|
||||
"triangleCount": 2783,
|
||||
"vertexCount": 8349
|
||||
},
|
||||
"inputChunkChecksums": {
|
||||
"public/assets/game/arenas/ashamanes-fall/navigation-collision.glb": "cdc39b903da6a6f9f21bd9e5e0b2ca61e76b09e13575b02349ca92d68815e5a8"
|
||||
},
|
||||
"inputChunks": [
|
||||
"public/assets/game/arenas/ashamanes-fall/navigation-collision.glb"
|
||||
],
|
||||
"output": "public/assets/game/arenas/ashamanes-fall/navigation-raw.glb",
|
||||
"schemaVersion": 1,
|
||||
"settings": {
|
||||
"agentHeight": 1.8,
|
||||
"agentMaxClimb": 0.5,
|
||||
"agentMaxSlope": 50,
|
||||
"agentRadius": 0.36,
|
||||
"cellHeight": 0.1,
|
||||
"cellSize": 0.2
|
||||
},
|
||||
"source": {
|
||||
"adtFiles": [
|
||||
"valsharaharena_20_24.adt",
|
||||
"valsharaharena_20_25.adt",
|
||||
"valsharaharena_20_26.adt",
|
||||
"valsharaharena_21_24.adt",
|
||||
"valsharaharena_21_25.adt",
|
||||
"valsharaharena_21_26.adt",
|
||||
"valsharaharena_22_24.adt",
|
||||
"valsharaharena_22_25.adt",
|
||||
"valsharaharena_22_26.adt"
|
||||
],
|
||||
"gameplayBounds": {
|
||||
"max": [
|
||||
58,
|
||||
16,
|
||||
36
|
||||
],
|
||||
"min": [
|
||||
14,
|
||||
-4,
|
||||
2
|
||||
]
|
||||
},
|
||||
"kind": "adt-placed-wmo-repair",
|
||||
"legacyCollision": "public/assets/game/manastorm/1785-valsharaharena/collision.glb",
|
||||
"placedWmos": [
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/VALSHARAH/7VS_CAVEMICRO01.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RUINPIECE06.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_TOWER01_NIGHTMARE.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01_NIGHTMARE.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RUINPIECE06.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_BRIDGESHORT.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/DUNGEON/PVP/CUSTOM_VALSHARAH_ARENA.WMO"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "ashamanes-fall",
|
||||
"title": "Ashamane's Fall",
|
||||
"status": "generated-review-required",
|
||||
"source": {
|
||||
"mapDirectory": "patch-WB2/world/maps/valsharaharena",
|
||||
"adtFiles": [
|
||||
"valsharaharena_20_24.adt",
|
||||
"valsharaharena_20_25.adt",
|
||||
"valsharaharena_20_26.adt",
|
||||
"valsharaharena_21_24.adt",
|
||||
"valsharaharena_21_25.adt",
|
||||
"valsharaharena_21_26.adt",
|
||||
"valsharaharena_22_24.adt",
|
||||
"valsharaharena_22_25.adt",
|
||||
"valsharaharena_22_26.adt"
|
||||
],
|
||||
"placementCount": 16,
|
||||
"placedWmos": [
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/VALSHARAH/7VS_CAVEMICRO01.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RUINPIECE06.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_TOWER01_NIGHTMARE.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01_NIGHTMARE.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RUINPIECE06.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_BRIDGESHORT.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/DUNGEON/PVP/CUSTOM_VALSHARAH_ARENA.WMO"
|
||||
]
|
||||
},
|
||||
"coordinateSystem": {
|
||||
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
|
||||
"rebasedOrigin": [
|
||||
6.0437164306640625,
|
||||
-37.49217987060547,
|
||||
30.740463256835938
|
||||
],
|
||||
"alignmentOffset": [
|
||||
34.174934,
|
||||
-32.347685,
|
||||
20.31486
|
||||
],
|
||||
"alignment": "primary WMO matched to the existing HealerMan stage coordinate frame"
|
||||
},
|
||||
"bounds": {
|
||||
"min": [
|
||||
-289.133544921875,
|
||||
-32.347686767578125,
|
||||
-200.8459930419922
|
||||
],
|
||||
"max": [
|
||||
357.4833984375,
|
||||
316.2064514160156,
|
||||
241.47572326660156
|
||||
]
|
||||
},
|
||||
"geometry": {
|
||||
"groups": 18,
|
||||
"triangles": 82815,
|
||||
"collisionTriangles": 82815,
|
||||
"collisionChunks": 1
|
||||
},
|
||||
"navigation": {
|
||||
"triangles": 209,
|
||||
"vertices": 627,
|
||||
"sourceComponents": 20,
|
||||
"spawnDistances": [
|
||||
0.5941920148506428,
|
||||
0.0212655626862784
|
||||
],
|
||||
"rawTriangles": 353,
|
||||
"report": "recast-report.json"
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"file": "visual.glb",
|
||||
"bytes": 2611788,
|
||||
"sha256": "25e8a0d63d420559a6bc965dc2b930039d951de8cdc189d785a00edc3610b112"
|
||||
},
|
||||
{
|
||||
"file": "collision.glb",
|
||||
"bytes": 1805536,
|
||||
"sha256": "48f45c434687f07969a618e1251e05e8672061d16a1f3a88338dbc667e33e78e"
|
||||
},
|
||||
{
|
||||
"file": "navigation.glb",
|
||||
"bytes": 10936,
|
||||
"sha256": "ac69ef1e845f632576598a8b2a5d0bbfa8086b21ed5d7fd17af06d5a565a5248"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"build": {
|
||||
"attempts": [
|
||||
{
|
||||
"failures": [],
|
||||
"tileCount": 1,
|
||||
"tileSize": null,
|
||||
"valid": true
|
||||
}
|
||||
],
|
||||
"gridCells": 2074478,
|
||||
"gridHeight": 994,
|
||||
"gridWidth": 2087,
|
||||
"ignoredEmptyTilePackingFailures": 0,
|
||||
"intermediateRetention": null,
|
||||
"mode": "solo",
|
||||
"partition": "watershed",
|
||||
"populatedTileCount": 1,
|
||||
"tileColumns": 1,
|
||||
"tileCount": 1,
|
||||
"tileRows": 1,
|
||||
"tileSize": null
|
||||
},
|
||||
"dungeonId": "black-rook-hold-arena",
|
||||
"generator": "@recast-navigation/three@0.43.1",
|
||||
"geometry": {
|
||||
"bounds": [
|
||||
[
|
||||
-92.08255767822266,
|
||||
-16.14961814880371,
|
||||
-279.4311218261719
|
||||
],
|
||||
[
|
||||
321.9174499511719,
|
||||
37.25038528442383,
|
||||
-82.83113098144531
|
||||
]
|
||||
],
|
||||
"boundsTolerance": 0.4,
|
||||
"rejectedOutOfBoundsTriangles": 0,
|
||||
"sourceTriangles": 7170,
|
||||
"sourceVertices": 21510,
|
||||
"triangles": 7170,
|
||||
"vertices": 21510
|
||||
},
|
||||
"input": {
|
||||
"bounds": [
|
||||
[
|
||||
-93.08255767822266,
|
||||
-39.549617767333984,
|
||||
-280.0311279296875
|
||||
],
|
||||
[
|
||||
324.1624450683594,
|
||||
69.1881103515625,
|
||||
-81.26593780517578
|
||||
]
|
||||
],
|
||||
"degenerateTriangleCount": 0,
|
||||
"invalidIndexCount": 0,
|
||||
"meshCount": 1,
|
||||
"nonFiniteCoordinateCount": 0,
|
||||
"triangleCount": 49821,
|
||||
"vertexCount": 149463
|
||||
},
|
||||
"inputChunkChecksums": {
|
||||
"public/assets/game/arenas/black-rook-hold-arena/navigation-collision.glb": "285bdce7d5b5085eef7d25f82a5b6a5b380605a310c539e8e94984182c835749"
|
||||
},
|
||||
"inputChunks": [
|
||||
"public/assets/game/arenas/black-rook-hold-arena/navigation-collision.glb"
|
||||
],
|
||||
"output": "public/assets/game/arenas/black-rook-hold-arena/navigation-raw.glb",
|
||||
"schemaVersion": 1,
|
||||
"settings": {
|
||||
"agentHeight": 1.8,
|
||||
"agentMaxClimb": 0.5,
|
||||
"agentMaxSlope": 50,
|
||||
"agentRadius": 0.36,
|
||||
"cellHeight": 0.1,
|
||||
"cellSize": 0.2
|
||||
},
|
||||
"source": {
|
||||
"adtFiles": [
|
||||
"blackrookholdarena_28_28.adt",
|
||||
"blackrookholdarena_28_29.adt",
|
||||
"blackrookholdarena_28_30.adt",
|
||||
"blackrookholdarena_29_28.adt",
|
||||
"blackrookholdarena_29_29.adt",
|
||||
"blackrookholdarena_29_30.adt",
|
||||
"blackrookholdarena_30_28.adt",
|
||||
"blackrookholdarena_30_29.adt",
|
||||
"blackrookholdarena_30_30.adt"
|
||||
],
|
||||
"gameplayBounds": {
|
||||
"max": [
|
||||
312,
|
||||
30,
|
||||
-110
|
||||
],
|
||||
"min": [
|
||||
-82,
|
||||
-10,
|
||||
-242
|
||||
]
|
||||
},
|
||||
"kind": "adt-placed-wmo-repair",
|
||||
"legacyCollision": "public/assets/game/manastorm/1784-blackrookholdarena/collision.glb",
|
||||
"placedWmos": [
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/VALSHARAH/7VS_BLACKROOKHOLD_MICRO01.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/PVP/BUILDINGS/BLACKROOKHOLD/7VS_BLACKROOKHOLD_ARENA.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/PVP/BUILDINGS/BLACKROOKHOLD/BLACKROOKHOLD_ARENA_PLATFORMTEST.WMO"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "black-rook-hold-arena",
|
||||
"title": "Black Rook Hold Arena",
|
||||
"status": "generated-review-required",
|
||||
"source": {
|
||||
"mapDirectory": "patch-WB1/world/maps/blackrookholdarena",
|
||||
"adtFiles": [
|
||||
"blackrookholdarena_28_28.adt",
|
||||
"blackrookholdarena_28_29.adt",
|
||||
"blackrookholdarena_28_30.adt",
|
||||
"blackrookholdarena_29_28.adt",
|
||||
"blackrookholdarena_29_29.adt",
|
||||
"blackrookholdarena_29_30.adt",
|
||||
"blackrookholdarena_30_28.adt",
|
||||
"blackrookholdarena_30_29.adt",
|
||||
"blackrookholdarena_30_30.adt"
|
||||
],
|
||||
"placementCount": 3,
|
||||
"placedWmos": [
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/VALSHARAH/7VS_BLACKROOKHOLD_MICRO01.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/PVP/BUILDINGS/BLACKROOKHOLD/7VS_BLACKROOKHOLD_ARENA.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/PVP/BUILDINGS/BLACKROOKHOLD/BLACKROOKHOLD_ARENA_PLATFORMTEST.WMO"
|
||||
]
|
||||
},
|
||||
"coordinateSystem": {
|
||||
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
|
||||
"rebasedOrigin": [
|
||||
-117.73179244995117,
|
||||
-104.28124237060547,
|
||||
60.68927764892578
|
||||
],
|
||||
"alignmentOffset": [
|
||||
117.542488,
|
||||
-102.29949,
|
||||
-224.463489
|
||||
],
|
||||
"alignment": "primary WMO matched to the existing HealerMan stage coordinate frame"
|
||||
},
|
||||
"bounds": {
|
||||
"min": [
|
||||
-110.3111343383789,
|
||||
-102.29949188232422,
|
||||
-519.4838256835938
|
||||
],
|
||||
"max": [
|
||||
345.3961181640625,
|
||||
410.11322021484375,
|
||||
70.55683898925781
|
||||
]
|
||||
},
|
||||
"geometry": {
|
||||
"groups": 20,
|
||||
"triangles": 298817,
|
||||
"collisionTriangles": 298817,
|
||||
"collisionChunks": 4
|
||||
},
|
||||
"navigation": {
|
||||
"triangles": 4948,
|
||||
"vertices": 14844,
|
||||
"sourceComponents": 805,
|
||||
"spawnDistances": [
|
||||
0.009180110009213026,
|
||||
0.01568599673120942
|
||||
],
|
||||
"rawTriangles": 7170,
|
||||
"report": "recast-report.json"
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"file": "visual.glb",
|
||||
"bytes": 9908648,
|
||||
"sha256": "622c0832eb5a3bca85837de418b02be6dc5dad54db3b05e69e740d93c870e319"
|
||||
},
|
||||
{
|
||||
"file": "collision.glb",
|
||||
"bytes": 2059780,
|
||||
"sha256": "27a698c59e08744bd7bf6cfe9585f643a28aea45850da85020e3b4bac45ce92f"
|
||||
},
|
||||
{
|
||||
"file": "collision-001.glb",
|
||||
"bytes": 2443032,
|
||||
"sha256": "914702646d8a6a657d677bd9f69b1b0aea13f4033952c143ff85d8932127a82f"
|
||||
},
|
||||
{
|
||||
"file": "collision-002.glb",
|
||||
"bytes": 2377504,
|
||||
"sha256": "33622cbbc5e82cebee74049a53cd9fbb4e1ca8196322697f751916a3dd9d30ec"
|
||||
},
|
||||
{
|
||||
"file": "collision-003.glb",
|
||||
"bytes": 916280,
|
||||
"sha256": "2f405e27efcf4e7b4d41d2286293dcbe700d854edf4d5aa276bf5b0523e9748a"
|
||||
},
|
||||
{
|
||||
"file": "navigation.glb",
|
||||
"bytes": 238420,
|
||||
"sha256": "e463a0220842a4793b2150e95f4fd704f575def288813c781792b021afe8d3f0"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"build": {
|
||||
"attempts": [
|
||||
{
|
||||
"failures": [],
|
||||
"tileCount": 1,
|
||||
"tileSize": null,
|
||||
"valid": true
|
||||
}
|
||||
],
|
||||
"gridCells": 652881,
|
||||
"gridHeight": 849,
|
||||
"gridWidth": 769,
|
||||
"ignoredEmptyTilePackingFailures": 0,
|
||||
"intermediateRetention": null,
|
||||
"mode": "solo",
|
||||
"partition": "watershed",
|
||||
"populatedTileCount": 1,
|
||||
"tileColumns": 1,
|
||||
"tileCount": 1,
|
||||
"tileRows": 1,
|
||||
"tileSize": null
|
||||
},
|
||||
"dungeonId": "blades-edge-arena",
|
||||
"generator": "@recast-navigation/three@0.43.1",
|
||||
"geometry": {
|
||||
"bounds": [
|
||||
[
|
||||
-75.85150146484375,
|
||||
6.099999904632568,
|
||||
-80.292236328125
|
||||
],
|
||||
[
|
||||
76.14849853515625,
|
||||
82.0999984741211,
|
||||
80.3077621459961
|
||||
]
|
||||
],
|
||||
"boundsTolerance": 0.4,
|
||||
"rejectedOutOfBoundsTriangles": 0,
|
||||
"sourceTriangles": 3932,
|
||||
"sourceVertices": 11796,
|
||||
"triangles": 3932,
|
||||
"vertices": 11796
|
||||
},
|
||||
"input": {
|
||||
"bounds": [
|
||||
[
|
||||
-76.85150146484375,
|
||||
0,
|
||||
-84.8922348022461
|
||||
],
|
||||
[
|
||||
76.85150146484375,
|
||||
82.12013244628906,
|
||||
84.8922348022461
|
||||
]
|
||||
],
|
||||
"degenerateTriangleCount": 0,
|
||||
"invalidIndexCount": 0,
|
||||
"meshCount": 1,
|
||||
"nonFiniteCoordinateCount": 0,
|
||||
"triangleCount": 7327,
|
||||
"vertexCount": 7086
|
||||
},
|
||||
"inputChunkChecksums": {
|
||||
"public/assets/game/arenas/blades-edge-arena/collision.glb": "96bb151cc43fcac887cee1e573878221e14d4690e023bc4cf7b5b40c2788333a"
|
||||
},
|
||||
"inputChunks": [
|
||||
"public/assets/game/arenas/blades-edge-arena/collision.glb"
|
||||
],
|
||||
"output": "public/assets/game/arenas/blades-edge-arena/navigation-raw.glb",
|
||||
"schemaVersion": 1,
|
||||
"settings": {
|
||||
"agentHeight": 1.8,
|
||||
"agentMaxClimb": 0.5,
|
||||
"agentMaxSlope": 50,
|
||||
"agentRadius": 0.36,
|
||||
"cellHeight": 0.1,
|
||||
"cellSize": 0.2
|
||||
},
|
||||
"source": {
|
||||
"files": [
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium_000.wmo"
|
||||
],
|
||||
"kind": "direct-wmo-parser"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "blades-edge-arena",
|
||||
"title": "Blade's Edge Arena",
|
||||
"status": "generated-review-required",
|
||||
"source": {
|
||||
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium.wmo",
|
||||
"groupCount": 1
|
||||
},
|
||||
"coordinateSystem": {
|
||||
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
|
||||
"rebasedOrigin": [
|
||||
-0.32387542724609375,
|
||||
-3.3863677978515625,
|
||||
-0.1770477294921875
|
||||
]
|
||||
},
|
||||
"sourceBounds": {
|
||||
"min": [
|
||||
-77.17537689208984,
|
||||
-3.3863677978515625,
|
||||
-85.06928253173828
|
||||
],
|
||||
"max": [
|
||||
76.52762603759766,
|
||||
78.7337646484375,
|
||||
84.7151870727539
|
||||
]
|
||||
},
|
||||
"bounds": {
|
||||
"min": [
|
||||
-76.85150146484375,
|
||||
0,
|
||||
-84.8922348022461
|
||||
],
|
||||
"max": [
|
||||
76.85150146484375,
|
||||
82.12013244628906,
|
||||
84.8922348022461
|
||||
]
|
||||
},
|
||||
"geometry": {
|
||||
"groups": 1,
|
||||
"vertices": 7086,
|
||||
"triangles": 7327,
|
||||
"collisionTriangles": 7327,
|
||||
"triangleFlags": [
|
||||
{
|
||||
"group": "group-000",
|
||||
"counts": {
|
||||
"8": 680,
|
||||
"36": 616,
|
||||
"38": 137,
|
||||
"72": 2729,
|
||||
"100": 2922,
|
||||
"102": 243
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"triangles": 465,
|
||||
"vertices": 1395,
|
||||
"sourceComponents": 134,
|
||||
"spawnDistances": [
|
||||
0.0026893801684426334,
|
||||
0.004853620635976984
|
||||
],
|
||||
"rawTriangles": 3932,
|
||||
"report": "recast-report.json"
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"role": "visual",
|
||||
"file": "visual.glb",
|
||||
"bytes": 259116,
|
||||
"sha256": "5dc8c6662bccf04892b059c819de91b1134cce6011a1f707c5ee9645491e5279"
|
||||
},
|
||||
{
|
||||
"role": "collision",
|
||||
"file": "collision.glb",
|
||||
"bytes": 173784,
|
||||
"sha256": "96bb151cc43fcac887cee1e573878221e14d4690e023bc4cf7b5b40c2788333a"
|
||||
},
|
||||
{
|
||||
"role": "navigation",
|
||||
"file": "navigation.glb",
|
||||
"bytes": 23228,
|
||||
"sha256": "d99822c69df3f6363c9fab1c6c67c70c3afa4e94ed0e66fa992adf8b6724cf2b"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"build": {
|
||||
"attempts": [
|
||||
{
|
||||
"failures": [],
|
||||
"tileCount": 1,
|
||||
"tileSize": null,
|
||||
"valid": true
|
||||
}
|
||||
],
|
||||
"gridCells": 529759,
|
||||
"gridHeight": 713,
|
||||
"gridWidth": 743,
|
||||
"ignoredEmptyTilePackingFailures": 0,
|
||||
"intermediateRetention": null,
|
||||
"mode": "solo",
|
||||
"partition": "watershed",
|
||||
"populatedTileCount": 1,
|
||||
"tileColumns": 1,
|
||||
"tileCount": 1,
|
||||
"tileRows": 1,
|
||||
"tileSize": null
|
||||
},
|
||||
"dungeonId": "dalaran-sewers",
|
||||
"generator": "@recast-navigation/three@0.43.1",
|
||||
"geometry": {
|
||||
"bounds": [
|
||||
[
|
||||
-73.60513305664062,
|
||||
0.30000001192092896,
|
||||
-69.85077667236328
|
||||
],
|
||||
[
|
||||
73.5948715209961,
|
||||
79,
|
||||
70.14923095703125
|
||||
]
|
||||
],
|
||||
"boundsTolerance": 0.4,
|
||||
"rejectedOutOfBoundsTriangles": 0,
|
||||
"sourceTriangles": 4054,
|
||||
"sourceVertices": 12162,
|
||||
"triangles": 4054,
|
||||
"vertices": 12162
|
||||
},
|
||||
"input": {
|
||||
"bounds": [
|
||||
[
|
||||
-74.20513153076172,
|
||||
0,
|
||||
-71.25077819824219
|
||||
],
|
||||
[
|
||||
74.20513153076172,
|
||||
92.40137481689453,
|
||||
71.25077819824219
|
||||
]
|
||||
],
|
||||
"degenerateTriangleCount": 0,
|
||||
"invalidIndexCount": 0,
|
||||
"meshCount": 2,
|
||||
"nonFiniteCoordinateCount": 0,
|
||||
"triangleCount": 15469,
|
||||
"vertexCount": 13094
|
||||
},
|
||||
"inputChunkChecksums": {
|
||||
"public/assets/game/arenas/dalaran-sewers/collision.glb": "88c889838b76365ee0541dcce567151419b3310a6dc1252934ffb997f31215bd"
|
||||
},
|
||||
"inputChunks": [
|
||||
"public/assets/game/arenas/dalaran-sewers/collision.glb"
|
||||
],
|
||||
"output": "public/assets/game/arenas/dalaran-sewers/navigation-raw.glb",
|
||||
"schemaVersion": 1,
|
||||
"settings": {
|
||||
"agentHeight": 1.8,
|
||||
"agentMaxClimb": 0.5,
|
||||
"agentMaxSlope": 50,
|
||||
"agentRadius": 0.36,
|
||||
"cellHeight": 0.1,
|
||||
"cellSize": 0.2
|
||||
},
|
||||
"source": {
|
||||
"files": [
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena_000.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena_001.wmo"
|
||||
],
|
||||
"kind": "direct-wmo-parser"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "dalaran-sewers",
|
||||
"title": "Dalaran Sewers",
|
||||
"status": "generated-review-required",
|
||||
"source": {
|
||||
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena.wmo",
|
||||
"groupCount": 2
|
||||
},
|
||||
"coordinateSystem": {
|
||||
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
|
||||
"rebasedOrigin": [
|
||||
-9.411468505859375,
|
||||
-16.876672744750977,
|
||||
10.746021270751953
|
||||
]
|
||||
},
|
||||
"sourceBounds": {
|
||||
"min": [
|
||||
-83.6166000366211,
|
||||
-16.876672744750977,
|
||||
-60.5047607421875
|
||||
],
|
||||
"max": [
|
||||
64.79366302490234,
|
||||
75.52470397949219,
|
||||
81.9968032836914
|
||||
]
|
||||
},
|
||||
"bounds": {
|
||||
"min": [
|
||||
-74.20513153076172,
|
||||
0,
|
||||
-71.25077819824219
|
||||
],
|
||||
"max": [
|
||||
74.20513153076172,
|
||||
92.40137481689453,
|
||||
71.25077819824219
|
||||
]
|
||||
},
|
||||
"geometry": {
|
||||
"groups": 2,
|
||||
"vertices": 13094,
|
||||
"triangles": 15469,
|
||||
"collisionTriangles": 15469,
|
||||
"triangleFlags": [
|
||||
{
|
||||
"group": "group-000",
|
||||
"counts": {
|
||||
"8": 85,
|
||||
"32": 545,
|
||||
"36": 573,
|
||||
"72": 40,
|
||||
"96": 2835,
|
||||
"100": 98
|
||||
}
|
||||
},
|
||||
{
|
||||
"group": "group-001",
|
||||
"counts": {
|
||||
"8": 165,
|
||||
"10": 480,
|
||||
"32": 766,
|
||||
"36": 2362,
|
||||
"72": 136,
|
||||
"74": 416,
|
||||
"96": 2053,
|
||||
"100": 4915
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"triangles": 458,
|
||||
"vertices": 1374,
|
||||
"sourceComponents": 104,
|
||||
"spawnDistances": [
|
||||
0.004929398584727517,
|
||||
0.0049300317713031425
|
||||
],
|
||||
"rawTriangles": 4054,
|
||||
"report": "recast-report.json"
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"role": "visual",
|
||||
"file": "visual.glb",
|
||||
"bytes": 501848,
|
||||
"sha256": "91b37f5df3c5c0d2ecad9e1096177abfe8eb36acc384df36a949ce246a0155ba"
|
||||
},
|
||||
{
|
||||
"role": "collision",
|
||||
"file": "collision.glb",
|
||||
"bytes": 344140,
|
||||
"sha256": "88c889838b76365ee0541dcce567151419b3310a6dc1252934ffb997f31215bd"
|
||||
},
|
||||
{
|
||||
"role": "navigation",
|
||||
"file": "navigation.glb",
|
||||
"bytes": 22896,
|
||||
"sha256": "2e1db39fe16bd3d4a747710e1da774d841bc9cd9ada446c666a1d95a4e1aafb2"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"build": {
|
||||
"attempts": [
|
||||
{
|
||||
"failures": [],
|
||||
"tileCount": 1,
|
||||
"tileSize": null,
|
||||
"valid": true
|
||||
}
|
||||
],
|
||||
"gridCells": 605259,
|
||||
"gridHeight": 783,
|
||||
"gridWidth": 773,
|
||||
"ignoredEmptyTilePackingFailures": 0,
|
||||
"intermediateRetention": null,
|
||||
"mode": "solo",
|
||||
"partition": "watershed",
|
||||
"populatedTileCount": 1,
|
||||
"tileColumns": 1,
|
||||
"tileCount": 1,
|
||||
"tileRows": 1,
|
||||
"tileSize": null
|
||||
},
|
||||
"dungeonId": "nagrand-arena",
|
||||
"generator": "@recast-navigation/three@0.43.1",
|
||||
"geometry": {
|
||||
"bounds": [
|
||||
[
|
||||
-75.41183471679688,
|
||||
1.2000000476837158,
|
||||
-74.84174346923828
|
||||
],
|
||||
[
|
||||
75.9881591796875,
|
||||
72.80000305175781,
|
||||
65.15826416015625
|
||||
]
|
||||
],
|
||||
"boundsTolerance": 0.4,
|
||||
"rejectedOutOfBoundsTriangles": 0,
|
||||
"sourceTriangles": 3536,
|
||||
"sourceVertices": 10608,
|
||||
"triangles": 3536,
|
||||
"vertices": 10608
|
||||
},
|
||||
"input": {
|
||||
"bounds": [
|
||||
[
|
||||
-77.21183776855469,
|
||||
0,
|
||||
-78.24174499511719
|
||||
],
|
||||
[
|
||||
77.21183776855469,
|
||||
82.11756134033203,
|
||||
78.24174499511719
|
||||
]
|
||||
],
|
||||
"degenerateTriangleCount": 0,
|
||||
"invalidIndexCount": 0,
|
||||
"meshCount": 1,
|
||||
"nonFiniteCoordinateCount": 0,
|
||||
"triangleCount": 7727,
|
||||
"vertexCount": 8197
|
||||
},
|
||||
"inputChunkChecksums": {
|
||||
"public/assets/game/arenas/nagrand-arena/collision.glb": "c5ec36344bc335aad5c63607a93d6cacb535d4055c2626fed7da250855f5fa74"
|
||||
},
|
||||
"inputChunks": [
|
||||
"public/assets/game/arenas/nagrand-arena/collision.glb"
|
||||
],
|
||||
"output": "public/assets/game/arenas/nagrand-arena/navigation-raw.glb",
|
||||
"schemaVersion": 1,
|
||||
"settings": {
|
||||
"agentHeight": 1.8,
|
||||
"agentMaxClimb": 0.5,
|
||||
"agentMaxSlope": 50,
|
||||
"agentRadius": 0.36,
|
||||
"cellHeight": 0.1,
|
||||
"cellSize": 0.2
|
||||
},
|
||||
"source": {
|
||||
"files": [
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01_000.wmo"
|
||||
],
|
||||
"kind": "direct-wmo-parser"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "nagrand-arena",
|
||||
"title": "Nagrand Arena",
|
||||
"status": "generated-review-required",
|
||||
"source": {
|
||||
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01.wmo",
|
||||
"groupCount": 1
|
||||
},
|
||||
"coordinateSystem": {
|
||||
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
|
||||
"rebasedOrigin": [
|
||||
49.19449806213379,
|
||||
-10.582855224609375,
|
||||
30.535707473754883
|
||||
]
|
||||
},
|
||||
"sourceBounds": {
|
||||
"min": [
|
||||
-28.017337799072266,
|
||||
-10.582855224609375,
|
||||
-47.70603561401367
|
||||
],
|
||||
"max": [
|
||||
126.40633392333984,
|
||||
71.53470611572266,
|
||||
108.77745056152344
|
||||
]
|
||||
},
|
||||
"bounds": {
|
||||
"min": [
|
||||
-77.21183776855469,
|
||||
0,
|
||||
-78.24174499511719
|
||||
],
|
||||
"max": [
|
||||
77.21183776855469,
|
||||
82.11756134033203,
|
||||
78.24174499511719
|
||||
]
|
||||
},
|
||||
"geometry": {
|
||||
"groups": 1,
|
||||
"vertices": 8197,
|
||||
"triangles": 7727,
|
||||
"collisionTriangles": 7727,
|
||||
"triangleFlags": [
|
||||
{
|
||||
"group": "group-000",
|
||||
"counts": {
|
||||
"8": 783,
|
||||
"32": 35,
|
||||
"36": 831,
|
||||
"38": 2,
|
||||
"72": 2230,
|
||||
"96": 502,
|
||||
"100": 3291,
|
||||
"102": 53
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"triangles": 1450,
|
||||
"vertices": 4350,
|
||||
"sourceComponents": 155,
|
||||
"spawnDistances": [
|
||||
0.0025342739142059146,
|
||||
0.0025336963502397706
|
||||
],
|
||||
"rawTriangles": 3536,
|
||||
"report": "recast-report.json"
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"role": "visual",
|
||||
"file": "visual.glb",
|
||||
"bytes": 290580,
|
||||
"sha256": "6730970440c31df26e4fe90a6eb3913e6402f0027a334d966fb2b57c2e7b3e7c"
|
||||
},
|
||||
{
|
||||
"role": "collision",
|
||||
"file": "collision.glb",
|
||||
"bytes": 191916,
|
||||
"sha256": "c5ec36344bc335aad5c63607a93d6cacb535d4055c2626fed7da250855f5fa74"
|
||||
},
|
||||
{
|
||||
"role": "navigation",
|
||||
"file": "navigation.glb",
|
||||
"bytes": 70512,
|
||||
"sha256": "cd9027c8d3cf175a8879efad1eb51584f3fbce1bc2eb9355959676471e06798a"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"build": {
|
||||
"attempts": [
|
||||
{
|
||||
"failures": [],
|
||||
"tileCount": 1,
|
||||
"tileSize": null,
|
||||
"valid": true
|
||||
}
|
||||
],
|
||||
"gridCells": 2185272,
|
||||
"gridHeight": 1208,
|
||||
"gridWidth": 1809,
|
||||
"ignoredEmptyTilePackingFailures": 0,
|
||||
"intermediateRetention": null,
|
||||
"mode": "solo",
|
||||
"partition": "watershed",
|
||||
"populatedTileCount": 1,
|
||||
"tileColumns": 1,
|
||||
"tileCount": 1,
|
||||
"tileRows": 1,
|
||||
"tileSize": null
|
||||
},
|
||||
"dungeonId": "ring-of-valor",
|
||||
"generator": "@recast-navigation/three@0.43.1",
|
||||
"geometry": {
|
||||
"bounds": [
|
||||
[
|
||||
-180.21835327148438,
|
||||
0.10000000149011612,
|
||||
-117.37078094482422
|
||||
],
|
||||
[
|
||||
180.181640625,
|
||||
100.80000305175781,
|
||||
120.02922058105469
|
||||
]
|
||||
],
|
||||
"boundsTolerance": 0.4,
|
||||
"rejectedOutOfBoundsTriangles": 0,
|
||||
"sourceTriangles": 4405,
|
||||
"sourceVertices": 13215,
|
||||
"triangles": 4405,
|
||||
"vertices": 13215
|
||||
},
|
||||
"input": {
|
||||
"bounds": [
|
||||
[
|
||||
-180.818359375,
|
||||
0,
|
||||
-120.77078247070312
|
||||
],
|
||||
[
|
||||
180.818359375,
|
||||
116.26216125488281,
|
||||
120.77078247070312
|
||||
]
|
||||
],
|
||||
"degenerateTriangleCount": 0,
|
||||
"invalidIndexCount": 0,
|
||||
"meshCount": 1,
|
||||
"nonFiniteCoordinateCount": 0,
|
||||
"triangleCount": 9713,
|
||||
"vertexCount": 12167
|
||||
},
|
||||
"inputChunkChecksums": {
|
||||
"public/assets/game/arenas/ring-of-valor/collision.glb": "25162e36ed6fea03d6de939cf4d742bc142fbebb2db1c9fbc6f2fcc9e15cffdf"
|
||||
},
|
||||
"inputChunks": [
|
||||
"public/assets/game/arenas/ring-of-valor/collision.glb"
|
||||
],
|
||||
"output": "public/assets/game/arenas/ring-of-valor/navigation-raw.glb",
|
||||
"schemaVersion": 1,
|
||||
"settings": {
|
||||
"agentHeight": 1.8,
|
||||
"agentMaxClimb": 0.5,
|
||||
"agentMaxSlope": 50,
|
||||
"agentRadius": 0.36,
|
||||
"cellHeight": 0.1,
|
||||
"cellSize": 0.2
|
||||
},
|
||||
"source": {
|
||||
"files": [
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena_000.wmo"
|
||||
],
|
||||
"kind": "direct-wmo-parser"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "ring-of-valor",
|
||||
"title": "Ring of Valor",
|
||||
"status": "generated-review-required",
|
||||
"source": {
|
||||
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena.wmo",
|
||||
"groupCount": 1
|
||||
},
|
||||
"coordinateSystem": {
|
||||
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
|
||||
"rebasedOrigin": [
|
||||
-0.44493865966796875,
|
||||
-32.01484298706055,
|
||||
0
|
||||
]
|
||||
},
|
||||
"sourceBounds": {
|
||||
"min": [
|
||||
-181.26329040527344,
|
||||
-32.01484298706055,
|
||||
-120.77078247070312
|
||||
],
|
||||
"max": [
|
||||
180.3734130859375,
|
||||
84.24732208251953,
|
||||
120.77078247070312
|
||||
]
|
||||
},
|
||||
"bounds": {
|
||||
"min": [
|
||||
-180.818359375,
|
||||
0,
|
||||
-120.77078247070312
|
||||
],
|
||||
"max": [
|
||||
180.818359375,
|
||||
116.26216125488281,
|
||||
120.77078247070312
|
||||
]
|
||||
},
|
||||
"geometry": {
|
||||
"groups": 1,
|
||||
"vertices": 12167,
|
||||
"triangles": 9713,
|
||||
"collisionTriangles": 9713,
|
||||
"triangleFlags": [
|
||||
{
|
||||
"group": "group-000",
|
||||
"counts": {
|
||||
"8": 18,
|
||||
"32": 1063,
|
||||
"36": 590,
|
||||
"38": 586,
|
||||
"72": 126,
|
||||
"96": 1307,
|
||||
"100": 5028,
|
||||
"102": 995
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"triangles": 702,
|
||||
"vertices": 2106,
|
||||
"sourceComponents": 61,
|
||||
"spawnDistances": [
|
||||
1.1157695927091633,
|
||||
1.258558140579426
|
||||
],
|
||||
"rawTriangles": 4405,
|
||||
"report": "recast-report.json"
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"role": "visual",
|
||||
"file": "visual.glb",
|
||||
"bytes": 409692,
|
||||
"sha256": "32d3dc93c8f5026d29f3e9bf2efb299145e8e191e358b201a62dc987e0fa3c46"
|
||||
},
|
||||
{
|
||||
"role": "collision",
|
||||
"file": "collision.glb",
|
||||
"bytes": 263388,
|
||||
"sha256": "25162e36ed6fea03d6de939cf4d742bc142fbebb2db1c9fbc6f2fcc9e15cffdf"
|
||||
},
|
||||
{
|
||||
"role": "navigation",
|
||||
"file": "navigation.glb",
|
||||
"bytes": 34608,
|
||||
"sha256": "db986ef21767aecf08edb435f11674a094996213685f83cd7d2172886949374d"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"build": {
|
||||
"attempts": [
|
||||
{
|
||||
"failures": [],
|
||||
"tileCount": 1,
|
||||
"tileSize": null,
|
||||
"valid": true
|
||||
}
|
||||
],
|
||||
"gridCells": 1226343,
|
||||
"gridHeight": 1213,
|
||||
"gridWidth": 1011,
|
||||
"ignoredEmptyTilePackingFailures": 0,
|
||||
"intermediateRetention": null,
|
||||
"mode": "solo",
|
||||
"partition": "watershed",
|
||||
"populatedTileCount": 1,
|
||||
"tileColumns": 1,
|
||||
"tileCount": 1,
|
||||
"tileRows": 1,
|
||||
"tileSize": null
|
||||
},
|
||||
"dungeonId": "ruins-of-lordaeron",
|
||||
"generator": "@recast-navigation/three@0.43.1",
|
||||
"geometry": {
|
||||
"bounds": [
|
||||
[
|
||||
-100.48125457763672,
|
||||
3.5,
|
||||
-119.45735168457031
|
||||
],
|
||||
[
|
||||
96.71875,
|
||||
77.70000457763672,
|
||||
120.54264831542969
|
||||
]
|
||||
],
|
||||
"boundsTolerance": 0.4,
|
||||
"rejectedOutOfBoundsTriangles": 0,
|
||||
"sourceTriangles": 7958,
|
||||
"sourceVertices": 23874,
|
||||
"triangles": 7958,
|
||||
"vertices": 23874
|
||||
},
|
||||
"input": {
|
||||
"bounds": [
|
||||
[
|
||||
-101.08125305175781,
|
||||
0,
|
||||
-121.25735473632812
|
||||
],
|
||||
[
|
||||
101.08125305175781,
|
||||
77.79415893554688,
|
||||
121.25735473632812
|
||||
]
|
||||
],
|
||||
"degenerateTriangleCount": 0,
|
||||
"invalidIndexCount": 0,
|
||||
"meshCount": 5,
|
||||
"nonFiniteCoordinateCount": 0,
|
||||
"triangleCount": 23642,
|
||||
"vertexCount": 26192
|
||||
},
|
||||
"inputChunkChecksums": {
|
||||
"public/assets/game/arenas/ruins-of-lordaeron/collision.glb": "a844092b66cbfcc8ae430879de3ce7a1e6441c2cfc69328dc2cb259e4cb6c645"
|
||||
},
|
||||
"inputChunks": [
|
||||
"public/assets/game/arenas/ruins-of-lordaeron/collision.glb"
|
||||
],
|
||||
"output": "public/assets/game/arenas/ruins-of-lordaeron/navigation-raw.glb",
|
||||
"schemaVersion": 1,
|
||||
"settings": {
|
||||
"agentHeight": 1.8,
|
||||
"agentMaxClimb": 0.5,
|
||||
"agentMaxSlope": 50,
|
||||
"agentRadius": 0.36,
|
||||
"cellHeight": 0.1,
|
||||
"cellSize": 0.2
|
||||
},
|
||||
"source": {
|
||||
"files": [
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_000.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_001.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_002.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_003.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_004.wmo"
|
||||
],
|
||||
"kind": "direct-wmo-parser"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "ruins-of-lordaeron",
|
||||
"title": "Ruins of Lordaeron",
|
||||
"status": "generated-review-required",
|
||||
"source": {
|
||||
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena.wmo",
|
||||
"groupCount": 5
|
||||
},
|
||||
"coordinateSystem": {
|
||||
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
|
||||
"rebasedOrigin": [
|
||||
4.71783447265625,
|
||||
-31.21632957458496,
|
||||
15.956310272216797
|
||||
]
|
||||
},
|
||||
"sourceBounds": {
|
||||
"min": [
|
||||
-96.36341857910156,
|
||||
-31.21632957458496,
|
||||
-105.3010482788086
|
||||
],
|
||||
"max": [
|
||||
105.79908752441406,
|
||||
46.57782745361328,
|
||||
137.2136688232422
|
||||
]
|
||||
},
|
||||
"bounds": {
|
||||
"min": [
|
||||
-101.08125305175781,
|
||||
0,
|
||||
-121.25735473632812
|
||||
],
|
||||
"max": [
|
||||
101.08125305175781,
|
||||
77.79415893554688,
|
||||
121.25735473632812
|
||||
]
|
||||
},
|
||||
"geometry": {
|
||||
"groups": 5,
|
||||
"vertices": 26192,
|
||||
"triangles": 23642,
|
||||
"collisionTriangles": 23642,
|
||||
"triangleFlags": [
|
||||
{
|
||||
"group": "group-000",
|
||||
"counts": {
|
||||
"8": 1135,
|
||||
"36": 3730,
|
||||
"72": 730,
|
||||
"100": 10270
|
||||
}
|
||||
},
|
||||
{
|
||||
"group": "group-001",
|
||||
"counts": {
|
||||
"8": 1,
|
||||
"9": 2,
|
||||
"32": 106,
|
||||
"33": 22,
|
||||
"36": 21,
|
||||
"37": 14,
|
||||
"72": 275,
|
||||
"73": 47,
|
||||
"96": 225,
|
||||
"97": 236,
|
||||
"100": 2550,
|
||||
"101": 117
|
||||
}
|
||||
},
|
||||
{
|
||||
"group": "group-002",
|
||||
"counts": {
|
||||
"32": 21,
|
||||
"96": 132
|
||||
}
|
||||
},
|
||||
{
|
||||
"group": "group-003",
|
||||
"counts": {
|
||||
"8": 13,
|
||||
"9": 2,
|
||||
"32": 185,
|
||||
"33": 22,
|
||||
"36": 53,
|
||||
"37": 14,
|
||||
"72": 252,
|
||||
"73": 56,
|
||||
"96": 276,
|
||||
"97": 229,
|
||||
"100": 2579,
|
||||
"101": 174
|
||||
}
|
||||
},
|
||||
{
|
||||
"group": "group-004",
|
||||
"counts": {
|
||||
"36": 21,
|
||||
"100": 132
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"triangles": 2085,
|
||||
"vertices": 6255,
|
||||
"sourceComponents": 157,
|
||||
"spawnDistances": [
|
||||
0.00292914523132256,
|
||||
0.004205306248988376
|
||||
],
|
||||
"rawTriangles": 7958,
|
||||
"report": "recast-report.json"
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"role": "visual",
|
||||
"file": "visual.glb",
|
||||
"bytes": 916788,
|
||||
"sha256": "b00bcd87bd72bbabcf3e7e7edab99e8f7ddbe820b849a40df90510096e9f2f4e"
|
||||
},
|
||||
{
|
||||
"role": "collision",
|
||||
"file": "collision.glb",
|
||||
"bytes": 601048,
|
||||
"sha256": "a844092b66cbfcc8ae430879de3ce7a1e6441c2cfc69328dc2cb259e4cb6c645"
|
||||
},
|
||||
{
|
||||
"role": "navigation",
|
||||
"file": "navigation.glb",
|
||||
"bytes": 100996,
|
||||
"sha256": "be7aa6c4822f0baecafe4eb148a5952d3ce8001c7617f351e7ab257d61e7cb59"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "warnorken-arena",
|
||||
"title": "Warnorken Arena",
|
||||
"status": "validated",
|
||||
"source": {
|
||||
"kind": "validated-runewaker-package",
|
||||
"navigationSource": "navigation-source.glb"
|
||||
},
|
||||
"navigation": {
|
||||
"sourceComponents": 96,
|
||||
"triangles": 365,
|
||||
"vertices": 1095,
|
||||
"spawnDistances": [
|
||||
0.00402499431590021,
|
||||
0.004012153291366225
|
||||
]
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"file": "visual.glb",
|
||||
"bytes": 3752136,
|
||||
"triangles": 55200,
|
||||
"sha256": "c452a843c2e4123d4fe0d20da41558f5b5a1906548b3d091dc691ffc42ed19d9"
|
||||
},
|
||||
{
|
||||
"file": "collision.glb",
|
||||
"bytes": 3005760,
|
||||
"triangles": 60751,
|
||||
"sha256": "0b1ce7555da0d2900465e3539e1bf17ac244d23a0f5eb57a2106f29570757c5c"
|
||||
},
|
||||
{
|
||||
"file": "navigation.glb",
|
||||
"bytes": 18436,
|
||||
"triangles": 365,
|
||||
"sha256": "c6085ffc96b40b1f437b8bb7e111604c135a856b02fd9b6ccae20243183cf417"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
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.19",
|
||||
"versionCode": 1019
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { access, mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs";
|
||||
import { extractNavigationComponent } from "./extract-navigation-component.mjs";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
import { Vector3 } from "three";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import {
|
||||
geometryBounds,
|
||||
parseRootGroupCount,
|
||||
parseWmoGroup,
|
||||
rebaseWmoGroups,
|
||||
wmoChunks,
|
||||
writeWmoGeometry,
|
||||
} from "./build-wmo-arenas.mjs";
|
||||
|
||||
const projectRoot = path.resolve(import.meta.dirname, "../..");
|
||||
const extractedRoot = path.resolve(projectRoot, "../LadiksMPQEditor");
|
||||
const outputRoot = path.join(projectRoot, "public/assets/game/arenas");
|
||||
const sourceLayers = ["patch-WC1", "patch-WB2", "patch-WB1", "patch-2", "patch", "lichking", "common-2.mpq"];
|
||||
|
||||
const DEFINITIONS = Object.freeze([
|
||||
{
|
||||
slug: "black-rook-hold-arena",
|
||||
title: "Black Rook Hold Arena",
|
||||
mapDirectory: "patch-WB1/world/maps/blackrookholdarena",
|
||||
primaryPattern: /7vs_blackrookhold_arena\.wmo$/i,
|
||||
alignmentOffset: [117.542488, -102.29949, -224.463489],
|
||||
legacyCollision: "public/assets/game/manastorm/1784-blackrookholdarena/collision.glb",
|
||||
gameplayBounds: { min: [-82, -10, -242], max: [312, 30, -110] },
|
||||
legacyNavigation: "public/assets/game/manastorm/1784-blackrookholdarena/navigation.glb",
|
||||
teamSpawns: [[-66.098564, 8.416428, -201.852448], [123.158585, 9.916428, -219.909592]],
|
||||
},
|
||||
{
|
||||
slug: "ashamanes-fall",
|
||||
title: "Ashamane's Fall",
|
||||
mapDirectory: "patch-WB2/world/maps/valsharaharena",
|
||||
primaryPattern: /custom_valsharah_arena\.wmo$/i,
|
||||
alignmentOffset: [34.174934, -32.347685, 20.31486],
|
||||
legacyCollision: "public/assets/game/manastorm/1785-valsharaharena/collision.glb",
|
||||
gameplayBounds: { min: [14, -4, 2], max: [58, 16, 36] },
|
||||
teamSpawns: [[18.223099, -0.480263, 32.034554], [55.023094, -0.480263, 7.123436]],
|
||||
},
|
||||
]);
|
||||
|
||||
function strings(buffer) {
|
||||
return buffer.toString("utf8").split("\0").filter(Boolean);
|
||||
}
|
||||
|
||||
export function parseAdtPlacements(buffer) {
|
||||
const top = wmoChunks(buffer);
|
||||
const mwmo = top.get("MWMO");
|
||||
const mwid = top.get("MWID");
|
||||
const modf = top.get("MODF");
|
||||
if (!mwmo || !mwid || !modf) return [];
|
||||
const names = new Map();
|
||||
for (let offset = 0; offset < mwmo.length;) {
|
||||
const end = mwmo.indexOf(0, offset);
|
||||
if (end < 0) break;
|
||||
if (end > offset) names.set(offset, mwmo.toString("utf8", offset, end));
|
||||
offset = end + 1;
|
||||
}
|
||||
const offsets = [];
|
||||
for (let offset = 0; offset + 4 <= mwid.length; offset += 4) offsets.push(mwid.readUInt32LE(offset));
|
||||
const placements = [];
|
||||
for (let offset = 0; offset + 64 <= modf.length; offset += 64) {
|
||||
const nameIndex = modf.readUInt32LE(offset);
|
||||
placements.push({
|
||||
path: names.get(offsets[nameIndex]) ?? strings(mwmo)[nameIndex] ?? null,
|
||||
uniqueId: modf.readUInt32LE(offset + 4),
|
||||
position: [modf.readFloatLE(offset + 8), modf.readFloatLE(offset + 12), modf.readFloatLE(offset + 16)],
|
||||
rotation: [modf.readFloatLE(offset + 20), modf.readFloatLE(offset + 24), modf.readFloatLE(offset + 28)],
|
||||
scale: modf.readUInt16LE(offset + 62) / 1024 || 1,
|
||||
});
|
||||
}
|
||||
return placements.filter((placement) => placement.path);
|
||||
}
|
||||
|
||||
async function exists(file) {
|
||||
try { await access(file); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
async function resolveWmo(relativePath) {
|
||||
const normalized = relativePath.replace(/\\/g, "/");
|
||||
for (const layer of sourceLayers) {
|
||||
const file = path.join(extractedRoot, layer, ...normalized.split("/"));
|
||||
if (await exists(file)) return file;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rotate([x, y, z], [xDegrees, yDegrees, zDegrees]) {
|
||||
const radians = [xDegrees, yDegrees, zDegrees].map((degrees) => degrees * Math.PI / 180);
|
||||
let [nextX, nextY, nextZ] = [x, y, z];
|
||||
let cosine = Math.cos(radians[0]); let sine = Math.sin(radians[0]);
|
||||
[nextY, nextZ] = [nextY * cosine - nextZ * sine, nextY * sine + nextZ * cosine];
|
||||
cosine = Math.cos(radians[1]); sine = Math.sin(radians[1]);
|
||||
[nextX, nextZ] = [nextX * cosine + nextZ * sine, -nextX * sine + nextZ * cosine];
|
||||
cosine = Math.cos(radians[2]); sine = Math.sin(radians[2]);
|
||||
[nextX, nextY] = [nextX * cosine - nextY * sine, nextX * sine + nextY * cosine];
|
||||
return [nextX, nextY, nextZ];
|
||||
}
|
||||
|
||||
function placementBase(position) {
|
||||
return [17066.6666666667 - position[2], 17066.6666666667 - position[0], position[1]];
|
||||
}
|
||||
|
||||
function placeGroup(group, placement, primaryBase) {
|
||||
const base = placementBase(placement.position);
|
||||
const positions = new Float32Array(group.positions.length);
|
||||
const normals = group.normals ? new Float32Array(group.normals.length) : null;
|
||||
for (let offset = 0; offset < group.positions.length; offset += 3) {
|
||||
// Undo the direct parser's Three-axis conversion before applying the MODF transform.
|
||||
const local = [-group.positions[offset], group.positions[offset + 2], group.positions[offset + 1]];
|
||||
const transformed = rotate(local.map((value) => value * placement.scale), placement.rotation);
|
||||
positions[offset] = -(base[0] + transformed[0] - primaryBase[0]);
|
||||
positions[offset + 1] = base[2] + transformed[2] - primaryBase[2];
|
||||
positions[offset + 2] = base[1] + transformed[1] - primaryBase[1];
|
||||
if (normals) {
|
||||
const localNormal = [-group.normals[offset], group.normals[offset + 2], group.normals[offset + 1]];
|
||||
const transformedNormal = rotate(localNormal, placement.rotation);
|
||||
normals[offset] = -transformedNormal[0];
|
||||
normals[offset + 1] = transformedNormal[2];
|
||||
normals[offset + 2] = transformedNormal[1];
|
||||
}
|
||||
}
|
||||
return { ...group, positions, normals };
|
||||
}
|
||||
|
||||
async function loadPlacementGroups(placement, placementIndex, primaryBase) {
|
||||
const rootFile = await resolveWmo(placement.path);
|
||||
if (!rootFile) throw new Error(`Missing placed WMO: ${placement.path}`);
|
||||
const root = await readFile(rootFile);
|
||||
const groupCount = parseRootGroupCount(root);
|
||||
const extension = path.extname(rootFile);
|
||||
const base = rootFile.slice(0, -extension.length);
|
||||
const groups = [];
|
||||
for (let groupIndex = 0; groupIndex < groupCount; groupIndex += 1) {
|
||||
const groupFile = `${base}_${String(groupIndex).padStart(3, "0")}${extension}`;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseWmoGroup(await readFile(groupFile), `placement-${placementIndex}-group-${groupIndex}`);
|
||||
} catch (error) {
|
||||
throw new Error(`${placement.path} group ${groupIndex}: ${error.message}`, { cause: error });
|
||||
}
|
||||
groups.push(placeGroup(parsed, placement, primaryBase));
|
||||
}
|
||||
return { rootFile, groups };
|
||||
}
|
||||
|
||||
function splitCollisionGroups(groups, triangleLimit = 90000) {
|
||||
const chunks = [];
|
||||
let current = [];
|
||||
let currentTriangles = 0;
|
||||
for (const group of groups) {
|
||||
let indexOffset = 0;
|
||||
while (indexOffset < group.collisionIndices.length) {
|
||||
const availableTriangles = triangleLimit - currentTriangles;
|
||||
if (availableTriangles <= 0) {
|
||||
chunks.push(current);
|
||||
current = [];
|
||||
currentTriangles = 0;
|
||||
continue;
|
||||
}
|
||||
const takeIndices = Math.min(group.collisionIndices.length - indexOffset, availableTriangles * 3);
|
||||
current.push({ ...group, collisionIndices: group.collisionIndices.slice(indexOffset, indexOffset + takeIndices) });
|
||||
currentTriangles += takeIndices / 3;
|
||||
indexOffset += takeIndices;
|
||||
}
|
||||
}
|
||||
if (current.length) chunks.push(current);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
async function hash(file) {
|
||||
return createHash("sha256").update(await readFile(file)).digest("hex");
|
||||
}
|
||||
|
||||
function triangleInsideGameplayBounds(points, bounds) {
|
||||
const centroid = [0, 1, 2].map((axis) => (points[0][axis] + points[1][axis] + points[2][axis]) / 3);
|
||||
return centroid[0] >= bounds.min[0] - 6 && centroid[0] <= bounds.max[0] + 6
|
||||
&& centroid[1] >= bounds.min[1] - 6 && centroid[1] <= bounds.max[1] + 16
|
||||
&& centroid[2] >= bounds.min[2] - 6 && centroid[2] <= bounds.max[2] + 6;
|
||||
}
|
||||
|
||||
async function croppedNavigationGroup(legacyCollisionFile, groups, bounds) {
|
||||
await MeshoptDecoder.ready;
|
||||
const bytes = await readFile(legacyCollisionFile);
|
||||
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
const scene = await new Promise((resolve, reject) => new GLTFLoader()
|
||||
.setMeshoptDecoder(MeshoptDecoder)
|
||||
.parse(buffer, "", (gltf) => resolve(gltf.scene), reject));
|
||||
scene.updateMatrixWorld(true);
|
||||
const output = [];
|
||||
const a = new Vector3();
|
||||
const b = new Vector3();
|
||||
const c = new Vector3();
|
||||
scene.traverse((object) => {
|
||||
if (!object.isMesh || !object.geometry?.getAttribute("position")) return;
|
||||
const positions = object.geometry.getAttribute("position");
|
||||
const indices = object.geometry.getIndex();
|
||||
const count = indices?.count ?? positions.count;
|
||||
const vertexIndex = (element) => indices ? indices.getX(element) : element;
|
||||
for (let element = 0; element < count; element += 3) {
|
||||
const points = [a, b, c].map((point, corner) => point.fromBufferAttribute(positions, vertexIndex(element + corner)).applyMatrix4(object.matrixWorld).toArray());
|
||||
if (triangleInsideGameplayBounds(points, bounds)) output.push(...points.flat());
|
||||
}
|
||||
});
|
||||
for (const group of groups) {
|
||||
for (let offset = 0; offset < group.collisionIndices.length; offset += 3) {
|
||||
const points = [0, 1, 2].map((corner) => {
|
||||
const index = group.collisionIndices[offset + corner] * 3;
|
||||
return [group.positions[index], group.positions[index + 1], group.positions[index + 2]];
|
||||
});
|
||||
if (triangleInsideGameplayBounds(points, bounds)) output.push(...points.flat());
|
||||
}
|
||||
}
|
||||
const positions = new Float32Array(output);
|
||||
const indices = Uint32Array.from({ length: positions.length / 3 }, (_, index) => index);
|
||||
return { name: "rated-gameplay-collision", positions, normals: null, indices, collisionIndices: indices, flagCounts: {} };
|
||||
}
|
||||
|
||||
async function build(definition) {
|
||||
const mapRoot = path.join(extractedRoot, ...definition.mapDirectory.split("/"));
|
||||
const adtFiles = (await readdir(mapRoot)).filter((name) => name.toLowerCase().endsWith(".adt")).sort();
|
||||
const placementsById = new Map();
|
||||
for (const adt of adtFiles) {
|
||||
for (const placement of parseAdtPlacements(await readFile(path.join(mapRoot, adt)))) placementsById.set(placement.uniqueId, placement);
|
||||
}
|
||||
const placements = [...placementsById.values()];
|
||||
const primary = placements.find((placement) => definition.primaryPattern.test(placement.path));
|
||||
if (!primary) throw new Error(`${definition.slug}: primary arena WMO placement was not found.`);
|
||||
const primaryBase = placementBase(primary.position);
|
||||
const groups = [];
|
||||
const sources = [];
|
||||
for (const [index, placement] of placements.entries()) {
|
||||
const loaded = await loadPlacementGroups(placement, index, primaryBase);
|
||||
groups.push(...loaded.groups);
|
||||
sources.push(path.relative(projectRoot, loaded.rootFile).replace(/\\/g, "/"));
|
||||
}
|
||||
const transform = rebaseWmoGroups(groups);
|
||||
for (const group of groups) {
|
||||
for (let offset = 0; offset < group.positions.length; offset += 3) {
|
||||
group.positions[offset] += definition.alignmentOffset[0];
|
||||
group.positions[offset + 1] += definition.alignmentOffset[1];
|
||||
group.positions[offset + 2] += definition.alignmentOffset[2];
|
||||
}
|
||||
}
|
||||
const alignedBounds = geometryBounds(groups);
|
||||
const outputDirectory = path.join(outputRoot, definition.slug);
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
const visualFile = path.join(outputDirectory, "visual.glb");
|
||||
await writeWmoGeometry(visualFile, groups, true);
|
||||
const collisionChunks = splitCollisionGroups(groups);
|
||||
const collisionFiles = [];
|
||||
for (const [index, chunk] of collisionChunks.entries()) {
|
||||
const file = path.join(outputDirectory, index === 0 ? "collision.glb" : `collision-${String(index).padStart(3, "0")}.glb`);
|
||||
await writeWmoGeometry(file, chunk, false);
|
||||
collisionFiles.push(file);
|
||||
}
|
||||
const navigationFile = path.join(outputDirectory, "navigation.glb");
|
||||
const rawNavigationFile = path.join(outputDirectory, "navigation-raw.glb");
|
||||
const legacyCollisionFile = path.join(projectRoot, ...definition.legacyCollision.split("/"));
|
||||
const navigationCollisionFile = path.join(outputDirectory, "navigation-collision.glb");
|
||||
const navigationGroup = await croppedNavigationGroup(legacyCollisionFile, groups, definition.gameplayBounds);
|
||||
await writeWmoGeometry(navigationCollisionFile, [navigationGroup], false);
|
||||
const navigation = await bakeNavigationFromCollisionAssets({
|
||||
slug: definition.slug,
|
||||
collisionAssets: [{ id: "rated-gameplay-collision", role: "collision", path: path.relative(projectRoot, navigationCollisionFile).replace(/\\/g, "/") }],
|
||||
settings: { cellSize: 0.2, cellHeight: 0.1, agentHeight: 1.8, agentRadius: 0.36, agentMaxClimb: 0.5, agentMaxSlope: 50 },
|
||||
output: rawNavigationFile,
|
||||
reportFile: path.join(outputDirectory, "recast-report.json"),
|
||||
source: { kind: "adt-placed-wmo-repair", adtFiles, placedWmos: sources, legacyCollision: definition.legacyCollision, gameplayBounds: definition.gameplayBounds },
|
||||
});
|
||||
const selectionSource = definition.legacyNavigation
|
||||
? path.join(projectRoot, ...definition.legacyNavigation.split("/"))
|
||||
: rawNavigationFile;
|
||||
const connectedNavigation = await extractNavigationComponent({
|
||||
source: selectionSource,
|
||||
output: navigationFile,
|
||||
teamSpawns: definition.teamSpawns,
|
||||
});
|
||||
await Promise.all([unlink(rawNavigationFile), unlink(navigationCollisionFile)]);
|
||||
const files = [visualFile, ...collisionFiles, navigationFile];
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
id: definition.slug,
|
||||
title: definition.title,
|
||||
status: "generated-review-required",
|
||||
source: { mapDirectory: definition.mapDirectory, adtFiles, placementCount: placements.length, placedWmos: sources },
|
||||
coordinateSystem: {
|
||||
conversion: "three(x,y,z)=(-wow.x,wow.z,wow.y)",
|
||||
rebasedOrigin: transform.origin,
|
||||
alignmentOffset: definition.alignmentOffset,
|
||||
alignment: "primary WMO matched to the existing HealerMan stage coordinate frame",
|
||||
},
|
||||
bounds: alignedBounds,
|
||||
geometry: {
|
||||
groups: groups.length,
|
||||
triangles: groups.reduce((sum, group) => sum + group.indices.length / 3, 0),
|
||||
collisionTriangles: groups.reduce((sum, group) => sum + group.collisionIndices.length / 3, 0),
|
||||
collisionChunks: collisionFiles.length,
|
||||
},
|
||||
navigation: {
|
||||
triangles: connectedNavigation.triangles,
|
||||
vertices: connectedNavigation.vertices,
|
||||
sourceComponents: connectedNavigation.sourceComponents,
|
||||
spawnDistances: connectedNavigation.spawnDistances,
|
||||
rawTriangles: navigation.geometry.triangles,
|
||||
report: "recast-report.json",
|
||||
},
|
||||
assets: await Promise.all(files.map(async (file) => ({ file: path.basename(file), bytes: (await readFile(file)).length, sha256: await hash(file) }))),
|
||||
};
|
||||
await writeFile(path.join(outputDirectory, "stage-pack.json"), `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(`${definition.slug}: ${placements.length} placed WMOs, ${report.geometry.triangles} triangles, ${report.navigation.triangles} nav triangles`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
|
||||
const requested = new Set(process.argv.slice(2));
|
||||
for (const definition of DEFINITIONS.filter((entry) => !requested.size || requested.has(entry.slug))) await build(definition);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Document, NodeIO } from "@gltf-transform/core";
|
||||
import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs";
|
||||
import { extractNavigationComponent } from "./extract-navigation-component.mjs";
|
||||
|
||||
const projectRoot = path.resolve(import.meta.dirname, "../..");
|
||||
const sourceRoot = path.resolve(projectRoot, "../LadiksMPQEditor/common-2.mpq");
|
||||
const outputRoot = path.join(projectRoot, "public/assets/game/arenas");
|
||||
|
||||
const ARENAS = Object.freeze([
|
||||
{ slug: "nagrand-arena", title: "Nagrand Arena", root: "World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01.wmo", teamSpawns: [[-43.41, 13.4, -42.04], [42.03, 14.5, -44.44]] },
|
||||
{ slug: "blades-edge-arena", title: "Blade's Edge Arena", root: "World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium.wmo", teamSpawns: [[29.2, 50.7, -51.49], [31.13, 54.4, 49.51]] },
|
||||
{ slug: "ruins-of-lordaeron", title: "Ruins of Lordaeron", root: "World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena.wmo", teamSpawns: [[-8.68, 30.1, -73.96], [-5.08, 27.6, 57.28]] },
|
||||
{ slug: "dalaran-sewers", title: "Dalaran Sewers", root: "World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena.wmo", teamSpawns: [[-12.41, 20.1, -63.05], [21.79, 20.2, 25.15]] },
|
||||
{ slug: "ring-of-valor", title: "Ring of Valor", root: "World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena.wmo", teamSpawns: [[0, 27, 25], [0, 27, -25]] },
|
||||
]);
|
||||
|
||||
function tag(buffer, offset) {
|
||||
return buffer.toString("ascii", offset, offset + 4).split("").reverse().join("");
|
||||
}
|
||||
|
||||
export function wmoChunks(buffer, start = 0, end = buffer.length) {
|
||||
const result = new Map();
|
||||
let offset = start;
|
||||
while (offset + 8 <= end) {
|
||||
const id = tag(buffer, offset);
|
||||
const size = buffer.readUInt32LE(offset + 4);
|
||||
const payloadStart = offset + 8;
|
||||
let payloadEnd = payloadStart + size;
|
||||
// Some Legion-era custom group files report the original uncompressed MOGP
|
||||
// container length even though trailing optional chunks were stripped.
|
||||
// MOGP is the terminal top-level container, so the physical EOF is safe.
|
||||
if (id === "MOGP" && payloadEnd > end) payloadEnd = end;
|
||||
if (payloadEnd > end) throw new Error(`Invalid ${id} chunk at ${offset}: ${size} bytes exceeds the WMO boundary.`);
|
||||
result.set(id, buffer.subarray(payloadStart, payloadEnd));
|
||||
offset = payloadEnd;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseRootGroupCount(buffer) {
|
||||
const root = wmoChunks(buffer);
|
||||
const header = root.get("MOHD");
|
||||
if (!header || header.length < 8) throw new Error("WMO root is missing MOHD.");
|
||||
return header.readUInt32LE(4);
|
||||
}
|
||||
|
||||
export function parseWmoGroup(buffer, name) {
|
||||
const top = wmoChunks(buffer);
|
||||
const group = top.get("MOGP");
|
||||
if (!group || group.length <= 68) throw new Error(`${name}: missing MOGP geometry.`);
|
||||
const nested = wmoChunks(group, 68, group.length);
|
||||
const vertexBytes = nested.get("MOVT");
|
||||
const normalBytes = nested.get("MONR");
|
||||
const indexBytes = nested.get("MOVI");
|
||||
const materialBytes = nested.get("MOPY");
|
||||
if (!vertexBytes || !indexBytes) throw new Error(`${name}: missing MOVT or MOVI.`);
|
||||
const positions = new Float32Array(vertexBytes.length / 4);
|
||||
for (let offset = 0; offset < vertexBytes.length; offset += 4) positions[offset / 4] = vertexBytes.readFloatLE(offset);
|
||||
const sourceNormals = normalBytes ? new Float32Array(normalBytes.length / 4) : null;
|
||||
if (normalBytes) for (let offset = 0; offset < normalBytes.length; offset += 4) sourceNormals[offset / 4] = normalBytes.readFloatLE(offset);
|
||||
const indices = new Uint32Array(indexBytes.length / 2);
|
||||
for (let offset = 0; offset < indexBytes.length; offset += 2) indices[offset / 2] = indexBytes.readUInt16LE(offset);
|
||||
const transformed = new Float32Array(positions.length);
|
||||
const normals = sourceNormals?.length === positions.length ? new Float32Array(positions.length) : null;
|
||||
for (let index = 0; index < positions.length; index += 3) {
|
||||
transformed[index] = -positions[index];
|
||||
transformed[index + 1] = positions[index + 2];
|
||||
transformed[index + 2] = positions[index + 1];
|
||||
if (normals) {
|
||||
normals[index] = -sourceNormals[index];
|
||||
normals[index + 1] = sourceNormals[index + 2];
|
||||
normals[index + 2] = sourceNormals[index + 1];
|
||||
}
|
||||
}
|
||||
const flagCounts = {};
|
||||
const collisionIndexValues = [];
|
||||
if (materialBytes) {
|
||||
for (let offset = 0; offset < materialBytes.length; offset += 2) {
|
||||
const flag = materialBytes[offset];
|
||||
flagCounts[flag] = (flagCounts[flag] ?? 0) + 1;
|
||||
if (flag & 0x04) {
|
||||
const triangleOffset = (offset / 2) * 3;
|
||||
collisionIndexValues.push(indices[triangleOffset], indices[triangleOffset + 1], indices[triangleOffset + 2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
name, positions: transformed, normals, indices, flagCounts,
|
||||
// Retain every structural WMO triangle for authoritative collision. MOPY
|
||||
// bit meanings vary by client generation and several arena floors use flag
|
||||
// combinations that would otherwise be omitted; Recast filters slopes.
|
||||
collisionIndices: indices,
|
||||
flaggedCollisionIndices: materialBytes ? new Uint32Array(collisionIndexValues) : indices,
|
||||
};
|
||||
}
|
||||
|
||||
export function geometryBounds(groups) {
|
||||
const min = [Infinity, Infinity, Infinity];
|
||||
const max = [-Infinity, -Infinity, -Infinity];
|
||||
for (const group of groups) {
|
||||
for (let offset = 0; offset < group.positions.length; offset += 3) {
|
||||
for (let axis = 0; axis < 3; axis += 1) {
|
||||
min[axis] = Math.min(min[axis], group.positions[offset + axis]);
|
||||
max[axis] = Math.max(max[axis], group.positions[offset + axis]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
export function rebaseWmoGroups(groups) {
|
||||
const sourceBounds = geometryBounds(groups);
|
||||
const center = [
|
||||
(sourceBounds.min[0] + sourceBounds.max[0]) / 2,
|
||||
sourceBounds.min[1],
|
||||
(sourceBounds.min[2] + sourceBounds.max[2]) / 2,
|
||||
];
|
||||
for (const group of groups) {
|
||||
for (let offset = 0; offset < group.positions.length; offset += 3) {
|
||||
group.positions[offset] -= center[0];
|
||||
group.positions[offset + 1] -= center[1];
|
||||
group.positions[offset + 2] -= center[2];
|
||||
}
|
||||
}
|
||||
return { sourceBounds, origin: center, bounds: geometryBounds(groups) };
|
||||
}
|
||||
|
||||
export async function writeWmoGeometry(file, groups, visual) {
|
||||
const document = new Document();
|
||||
const buffer = document.createBuffer("arena-buffer");
|
||||
const scene = document.createScene("arena");
|
||||
const colors = [
|
||||
[0.37, 0.32, 0.22, 1], [0.29, 0.34, 0.3, 1], [0.42, 0.4, 0.34, 1],
|
||||
[0.31, 0.27, 0.24, 1], [0.34, 0.37, 0.42, 1],
|
||||
];
|
||||
for (const [index, group] of groups.entries()) {
|
||||
const groupIndices = visual ? group.indices : group.collisionIndices;
|
||||
if (!groupIndices.length) continue;
|
||||
const position = document.createAccessor(`${group.name}-position`, buffer).setType("VEC3").setArray(group.positions);
|
||||
const indices = document.createAccessor(`${group.name}-indices`, buffer).setType("SCALAR").setArray(groupIndices);
|
||||
const primitive = document.createPrimitive().setAttribute("POSITION", position).setIndices(indices);
|
||||
if (visual && group.normals) {
|
||||
primitive.setAttribute("NORMAL", document.createAccessor(`${group.name}-normal`, buffer).setType("VEC3").setArray(group.normals));
|
||||
}
|
||||
if (visual) {
|
||||
primitive.setMaterial(document.createMaterial(`${group.name}-stone`)
|
||||
.setBaseColorFactor(colors[index % colors.length])
|
||||
.setRoughnessFactor(0.84)
|
||||
.setMetallicFactor(0.02)
|
||||
.setDoubleSided(true));
|
||||
}
|
||||
const mesh = document.createMesh(group.name).addPrimitive(primitive);
|
||||
scene.addChild(document.createNode(group.name).setMesh(mesh));
|
||||
}
|
||||
await new NodeIO().write(file, document);
|
||||
}
|
||||
|
||||
async function sha256(file) {
|
||||
const buffer = await readFile(file);
|
||||
return createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
async function buildArena(definition) {
|
||||
const rootFile = path.join(sourceRoot, ...definition.root.split("/"));
|
||||
const root = await readFile(rootFile);
|
||||
const groupCount = parseRootGroupCount(root);
|
||||
const extension = path.extname(rootFile);
|
||||
const base = rootFile.slice(0, -extension.length);
|
||||
const groups = [];
|
||||
const sourceFiles = [rootFile];
|
||||
for (let index = 0; index < groupCount; index += 1) {
|
||||
const groupFile = `${base}_${String(index).padStart(3, "0")}${extension}`;
|
||||
const data = await readFile(groupFile);
|
||||
sourceFiles.push(groupFile);
|
||||
groups.push(parseWmoGroup(data, `group-${String(index).padStart(3, "0")}`));
|
||||
}
|
||||
const transform = rebaseWmoGroups(groups);
|
||||
const outputDirectory = path.join(outputRoot, definition.slug);
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
const visualFile = path.join(outputDirectory, "visual.glb");
|
||||
const collisionFile = path.join(outputDirectory, "collision.glb");
|
||||
const navigationFile = path.join(outputDirectory, "navigation.glb");
|
||||
const rawNavigationFile = path.join(outputDirectory, "navigation-raw.glb");
|
||||
const recastReport = path.join(outputDirectory, "recast-report.json");
|
||||
await writeWmoGeometry(visualFile, groups, true);
|
||||
await writeWmoGeometry(collisionFile, groups, false);
|
||||
const triangles = groups.reduce((sum, group) => sum + group.indices.length / 3, 0);
|
||||
const collisionTriangles = groups.reduce((sum, group) => sum + group.collisionIndices.length / 3, 0);
|
||||
if (collisionTriangles > 100000) throw new Error(`${definition.slug}: ${collisionTriangles} collision triangles exceed the rated limit.`);
|
||||
const relativeCollision = path.relative(projectRoot, collisionFile).replace(/\\/g, "/");
|
||||
const navigation = await bakeNavigationFromCollisionAssets({
|
||||
slug: definition.slug,
|
||||
collisionAssets: [{ id: "collision", role: "collision", path: relativeCollision }],
|
||||
settings: { cellSize: 0.2, cellHeight: 0.1, agentHeight: 1.8, agentRadius: 0.36, agentMaxClimb: 0.5, agentMaxSlope: 50 },
|
||||
output: rawNavigationFile,
|
||||
reportFile: recastReport,
|
||||
source: { kind: "direct-wmo-parser", files: sourceFiles.map((file) => path.relative(projectRoot, file).replace(/\\/g, "/")) },
|
||||
});
|
||||
const connectedNavigation = await extractNavigationComponent({
|
||||
source: rawNavigationFile,
|
||||
output: navigationFile,
|
||||
teamSpawns: definition.teamSpawns,
|
||||
});
|
||||
await unlink(rawNavigationFile);
|
||||
const assets = await Promise.all([
|
||||
["visual", visualFile], ["collision", collisionFile], ["navigation", navigationFile],
|
||||
].map(async ([role, file]) => ({ role, file: path.basename(file), bytes: (await readFile(file)).length, sha256: await sha256(file) })));
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
id: definition.slug,
|
||||
title: definition.title,
|
||||
status: "generated-review-required",
|
||||
source: { root: path.relative(projectRoot, rootFile).replace(/\\/g, "/"), groupCount },
|
||||
coordinateSystem: { conversion: "three(x,y,z)=(-wow.x,wow.z,wow.y)", rebasedOrigin: transform.origin },
|
||||
sourceBounds: transform.sourceBounds,
|
||||
bounds: transform.bounds,
|
||||
geometry: {
|
||||
groups: groups.length,
|
||||
vertices: groups.reduce((sum, group) => sum + group.positions.length / 3, 0),
|
||||
triangles,
|
||||
collisionTriangles,
|
||||
triangleFlags: groups.map((group) => ({ group: group.name, counts: group.flagCounts })),
|
||||
},
|
||||
navigation: {
|
||||
triangles: connectedNavigation.triangles,
|
||||
vertices: connectedNavigation.vertices,
|
||||
sourceComponents: connectedNavigation.sourceComponents,
|
||||
spawnDistances: connectedNavigation.spawnDistances,
|
||||
rawTriangles: navigation.geometry.triangles,
|
||||
report: "recast-report.json",
|
||||
},
|
||||
assets,
|
||||
};
|
||||
await writeFile(path.join(outputDirectory, "stage-pack.json"), `${JSON.stringify(report, null, 2)}\n`);
|
||||
return report;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
|
||||
const requested = new Set(process.argv.slice(2));
|
||||
const selected = requested.size ? ARENAS.filter((arena) => requested.has(arena.slug)) : ARENAS;
|
||||
if (!selected.length) throw new Error(`No matching arena. Choose: ${ARENAS.map((arena) => arena.slug).join(", ")}`);
|
||||
for (const arena of selected) {
|
||||
const report = await buildArena(arena);
|
||||
console.log(`${arena.slug}: ${report.geometry.triangles} triangles, ${report.navigation.triangles} navigation triangles`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
import { Vector3 } from "three";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { writeWmoGeometry } from "./build-wmo-arenas.mjs";
|
||||
|
||||
globalThis.self = globalThis;
|
||||
globalThis.ProgressEvent ??= class ProgressEvent {
|
||||
constructor(type, values = {}) { this.type = type; Object.assign(this, values); }
|
||||
};
|
||||
|
||||
class UnionFind {
|
||||
constructor(size) { this.parent = Array.from({ length: size }, (_, index) => index); }
|
||||
find(value) {
|
||||
if (this.parent[value] !== value) this.parent[value] = this.find(this.parent[value]);
|
||||
return this.parent[value];
|
||||
}
|
||||
union(left, right) {
|
||||
const a = this.find(left);
|
||||
const b = this.find(right);
|
||||
if (a !== b) this.parent[b] = a;
|
||||
}
|
||||
}
|
||||
|
||||
function key(point) {
|
||||
return point.map((value) => Math.round(value * 1000)).join(":");
|
||||
}
|
||||
|
||||
async function loadTriangles(file) {
|
||||
await MeshoptDecoder.ready;
|
||||
const bytes = await readFile(file);
|
||||
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
const scene = await new Promise((resolve, reject) => new GLTFLoader()
|
||||
.setMeshoptDecoder(MeshoptDecoder)
|
||||
.parse(buffer, "", (gltf) => resolve(gltf.scene), reject));
|
||||
scene.updateMatrixWorld(true);
|
||||
const triangles = [];
|
||||
const points = [new Vector3(), new Vector3(), new Vector3()];
|
||||
scene.traverse((object) => {
|
||||
if (!object.isMesh || !object.geometry?.getAttribute("position")) return;
|
||||
const positions = object.geometry.getAttribute("position");
|
||||
const indices = object.geometry.getIndex();
|
||||
const count = indices?.count ?? positions.count;
|
||||
const vertexIndex = (element) => indices ? indices.getX(element) : element;
|
||||
for (let element = 0; element < count; element += 3) {
|
||||
triangles.push(points.map((point, corner) => [
|
||||
...point.fromBufferAttribute(positions, vertexIndex(element + corner)).applyMatrix4(object.matrixWorld).toArray(),
|
||||
]));
|
||||
}
|
||||
});
|
||||
return triangles;
|
||||
}
|
||||
|
||||
function distanceSquared(point, target) {
|
||||
return (point[0] - target[0]) ** 2 + (point[1] - target[1]) ** 2 + (point[2] - target[2]) ** 2;
|
||||
}
|
||||
|
||||
export async function extractNavigationComponent({ source, output, teamSpawns }) {
|
||||
const triangles = await loadTriangles(source);
|
||||
const union = new UnionFind(triangles.length);
|
||||
const ownerByVertex = new Map();
|
||||
for (const [triangleIndex, triangle] of triangles.entries()) {
|
||||
for (const point of triangle) {
|
||||
const positionKey = key(point);
|
||||
const owner = ownerByVertex.get(positionKey);
|
||||
if (owner == null) ownerByVertex.set(positionKey, triangleIndex);
|
||||
else union.union(owner, triangleIndex);
|
||||
}
|
||||
}
|
||||
const components = new Map();
|
||||
for (let index = 0; index < triangles.length; index += 1) {
|
||||
const root = union.find(index);
|
||||
const component = components.get(root) ?? [];
|
||||
component.push(triangles[index]);
|
||||
components.set(root, component);
|
||||
}
|
||||
const scored = [...components.values()].map((component) => {
|
||||
const vertices = component.flat();
|
||||
const spawnDistances = teamSpawns.map((spawn) => Math.sqrt(Math.min(...vertices.map((point) => distanceSquared(point, spawn)))));
|
||||
return { component, spawnDistances, score: Math.max(...spawnDistances) };
|
||||
}).sort((left, right) => left.score - right.score || right.component.length - left.component.length);
|
||||
const selected = scored[0];
|
||||
if (!selected?.component.length) throw new Error(`${source}: no navigation component could be selected.`);
|
||||
const values = selected.component.flat(2);
|
||||
const positions = new Float32Array(values);
|
||||
const indices = Uint32Array.from({ length: positions.length / 3 }, (_, index) => index);
|
||||
await writeWmoGeometry(output, [{
|
||||
name: "rated-connected-navigation",
|
||||
positions,
|
||||
normals: null,
|
||||
indices,
|
||||
collisionIndices: indices,
|
||||
flagCounts: {},
|
||||
}], false);
|
||||
return {
|
||||
sourceComponents: components.size,
|
||||
triangles: selected.component.length,
|
||||
vertices: positions.length / 3,
|
||||
spawnDistances: selected.spawnDistances,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { NodeIO } from "@gltf-transform/core";
|
||||
|
||||
const projectRoot = path.resolve(import.meta.dirname, "../..");
|
||||
const catalog = JSON.parse(await readFile(path.join(projectRoot, "shared/arena-stages.json"), "utf8"));
|
||||
const io = new NodeIO();
|
||||
const requested = new Set(process.argv.slice(2).filter((value) => !value.startsWith("--")));
|
||||
const detail = process.argv.includes("--all");
|
||||
|
||||
class UnionFind {
|
||||
constructor(size) { this.parent = Array.from({ length: size }, (_, index) => index); }
|
||||
find(value) { if (this.parent[value] !== value) this.parent[value] = this.find(this.parent[value]); return this.parent[value]; }
|
||||
union(left, right) { const a = this.find(left); const b = this.find(right); if (a !== b) this.parent[b] = a; }
|
||||
}
|
||||
|
||||
function round(values) { return values.map((value) => Math.round(value * 100) / 100); }
|
||||
|
||||
for (const stage of catalog.stages) {
|
||||
if (requested.size && !requested.has(stage.id)) continue;
|
||||
const base = path.join(projectRoot, "public", ...stage.assetBaseUrl.split("/").filter(Boolean));
|
||||
let document;
|
||||
try { document = await io.read(path.join(base, "navigation.glb")); } catch { continue; }
|
||||
const vertices = [];
|
||||
const triangles = [];
|
||||
const byPosition = new Map();
|
||||
for (const mesh of document.getRoot().listMeshes()) {
|
||||
for (const primitive of mesh.listPrimitives()) {
|
||||
const positions = primitive.getAttribute("POSITION")?.getArray();
|
||||
if (!positions) continue;
|
||||
const indices = primitive.getIndices()?.getArray() ?? Uint32Array.from({ length: positions.length / 3 }, (_, index) => index);
|
||||
for (let offset = 0; offset < indices.length; offset += 3) {
|
||||
const triangle = [];
|
||||
for (let corner = 0; corner < 3; corner += 1) {
|
||||
const index = indices[offset + corner];
|
||||
const vertex = [positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]];
|
||||
const key = vertex.map((value) => Math.round(value * 1000)).join(":");
|
||||
if (!byPosition.has(key)) {
|
||||
byPosition.set(key, vertices.length);
|
||||
vertices.push(vertex);
|
||||
}
|
||||
triangle.push(byPosition.get(key));
|
||||
}
|
||||
triangles.push(triangle);
|
||||
}
|
||||
}
|
||||
}
|
||||
const union = new UnionFind(vertices.length);
|
||||
for (const triangle of triangles) {
|
||||
union.union(triangle[0], triangle[1]);
|
||||
union.union(triangle[0], triangle[2]);
|
||||
}
|
||||
const grouped = new Map();
|
||||
for (let index = 0; index < vertices.length; index += 1) {
|
||||
const root = union.find(index);
|
||||
const component = grouped.get(root) ?? [];
|
||||
component.push(vertices[index]);
|
||||
grouped.set(root, component);
|
||||
}
|
||||
const components = [...grouped.values()];
|
||||
components.sort((left, right) => right.length - left.length);
|
||||
const main = components[0] ?? [];
|
||||
const min = [0, 1, 2].map((axis) => Math.min(...main.map((vertex) => vertex[axis])));
|
||||
const max = [0, 1, 2].map((axis) => Math.max(...main.map((vertex) => vertex[axis])));
|
||||
const center = min.map((value, axis) => (value + max[axis]) / 2);
|
||||
const axis = max[0] - min[0] >= max[2] - min[2] ? 0 : 2;
|
||||
const sorted = [...main].sort((left, right) => left[axis] - right[axis]);
|
||||
const inset = Math.max(1, Math.floor(sorted.length * 0.08));
|
||||
const teamSpawns = [sorted[inset] ?? sorted[0], sorted[sorted.length - inset - 1] ?? sorted.at(-1)];
|
||||
console.log(JSON.stringify({
|
||||
id: stage.id,
|
||||
componentCount: components.length,
|
||||
mainVertices: main.length,
|
||||
nextVertices: components[1]?.length ?? 0,
|
||||
mainBounds: { min: round(min), max: round(max) },
|
||||
center: round(center),
|
||||
suggestedTeamSpawns: teamSpawns.map(round),
|
||||
topComponents: components.slice(0, detail ? components.length : 8).map((component) => ({
|
||||
vertices: component.length,
|
||||
min: round([0, 1, 2].map((componentAxis) => Math.min(...component.map((vertex) => vertex[componentAxis])))),
|
||||
max: round([0, 1, 2].map((componentAxis) => Math.max(...component.map((vertex) => vertex[componentAxis])))),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { NodeIO } from "@gltf-transform/core";
|
||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
import { extractNavigationComponent } from "./extract-navigation-component.mjs";
|
||||
|
||||
const projectRoot = path.resolve(import.meta.dirname, "../..");
|
||||
const directory = path.join(projectRoot, "public/assets/game/arenas/warnorken-arena");
|
||||
const source = path.join(directory, "navigation-source.glb");
|
||||
const output = path.join(directory, "navigation.glb");
|
||||
const teamSpawns = [[-42.7, -21.75, 14.12], [41.3, -21.65, -6.58]];
|
||||
const connected = await extractNavigationComponent({ source, output, teamSpawns });
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.decoder": MeshoptDecoder });
|
||||
const assets = [];
|
||||
for (const name of ["visual", "collision", "navigation"]) {
|
||||
const file = path.join(directory, `${name}.glb`);
|
||||
const bytes = await readFile(file);
|
||||
const document = await io.read(file);
|
||||
const triangles = document.getRoot().listMeshes().reduce((sum, mesh) => sum + mesh.listPrimitives().reduce((primitiveSum, primitive) => primitiveSum + (primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION")?.getCount() ?? 0) / 3, 0), 0);
|
||||
assets.push({ file: `${name}.glb`, bytes: (await stat(file)).size, triangles: Math.round(triangles), sha256: createHash("sha256").update(bytes).digest("hex") });
|
||||
}
|
||||
await writeFile(path.join(directory, "stage-pack.json"), `${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "warnorken-arena",
|
||||
title: "Warnorken Arena",
|
||||
status: "validated",
|
||||
source: { kind: "validated-runewaker-package", navigationSource: "navigation-source.glb" },
|
||||
navigation: connected,
|
||||
assets,
|
||||
}, null, 2)}\n`);
|
||||
console.log(`warnorken-arena: ${connected.triangles} connected navigation triangles`);
|
||||
@@ -0,0 +1,118 @@
|
||||
import { access, readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { NodeIO } from "@gltf-transform/core";
|
||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
|
||||
const projectRoot = path.resolve(import.meta.dirname, "../..");
|
||||
const catalog = JSON.parse(await readFile(path.join(projectRoot, "shared/arena-stages.json"), "utf8"));
|
||||
const failures = [];
|
||||
const reports = [];
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.decoder": MeshoptDecoder });
|
||||
|
||||
class UnionFind {
|
||||
constructor(size) { this.parent = Array.from({ length: size }, (_, index) => index); }
|
||||
find(value) {
|
||||
if (this.parent[value] !== value) this.parent[value] = this.find(this.parent[value]);
|
||||
return this.parent[value];
|
||||
}
|
||||
union(left, right) {
|
||||
const a = this.find(left);
|
||||
const b = this.find(right);
|
||||
if (a !== b) this.parent[b] = a;
|
||||
}
|
||||
}
|
||||
|
||||
function inspectConnectedNavigation(document, spawns) {
|
||||
const vertices = [];
|
||||
const triangles = [];
|
||||
const byPosition = new Map();
|
||||
for (const mesh of document.getRoot().listMeshes()) {
|
||||
for (const primitive of mesh.listPrimitives()) {
|
||||
const positions = primitive.getAttribute("POSITION")?.getArray();
|
||||
if (!positions) continue;
|
||||
const indices = primitive.getIndices()?.getArray() ?? Uint32Array.from({ length: positions.length / 3 }, (_, index) => index);
|
||||
for (let offset = 0; offset < indices.length; offset += 3) {
|
||||
const triangle = [];
|
||||
for (let corner = 0; corner < 3; corner += 1) {
|
||||
const index = indices[offset + corner];
|
||||
const point = [positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]];
|
||||
const key = point.map((value) => Math.round(value * 1000)).join(":");
|
||||
if (!byPosition.has(key)) {
|
||||
byPosition.set(key, vertices.length);
|
||||
vertices.push(point);
|
||||
}
|
||||
triangle.push(byPosition.get(key));
|
||||
}
|
||||
triangles.push(triangle);
|
||||
}
|
||||
}
|
||||
}
|
||||
const union = new UnionFind(vertices.length);
|
||||
for (const triangle of triangles) {
|
||||
union.union(triangle[0], triangle[1]);
|
||||
union.union(triangle[0], triangle[2]);
|
||||
}
|
||||
const components = new Set(vertices.map((_, index) => union.find(index)));
|
||||
const spawnDistances = spawns.map((spawn) => Math.sqrt(Math.min(...vertices.map((point) => (
|
||||
(point[0] - spawn[0]) ** 2 + (point[1] - spawn[1]) ** 2 + (point[2] - spawn[2]) ** 2
|
||||
)))));
|
||||
return { components: components.size, vertices: vertices.length, triangles: triangles.length, spawnDistances };
|
||||
}
|
||||
|
||||
function validateVec3(value, label, stage) {
|
||||
if (!Array.isArray(value) || value.length !== 3 || value.some((entry) => !Number.isFinite(entry))) failures.push(`${stage.id}: invalid ${label}.`);
|
||||
}
|
||||
|
||||
for (const stage of catalog.stages) {
|
||||
validateVec3(stage.bounds?.min, "minimum bounds", stage);
|
||||
validateVec3(stage.bounds?.max, "maximum bounds", stage);
|
||||
for (const [index, spawn] of (stage.teamSpawns ?? []).entries()) validateVec3(spawn, `team spawn ${index}`, stage);
|
||||
if (stage.teamSpawns?.length !== 2) failures.push(`${stage.id}: two team spawns are required.`);
|
||||
if (stage.shadowSight?.length !== 2) failures.push(`${stage.id}: two Shadow Sight locations are required.`);
|
||||
for (const [index, spawn] of (stage.teamSpawns ?? []).entries()) {
|
||||
if (spawn.some((value, axis) => value < stage.bounds.min[axis] || value > stage.bounds.max[axis])) failures.push(`${stage.id}: team spawn ${index} is outside the sealed gameplay bounds.`);
|
||||
}
|
||||
for (const object of stage.lineOfSight ?? []) {
|
||||
validateVec3(object.position, `${object.id} LOS position`, stage);
|
||||
validateVec3(object.size, `${object.id} LOS size`, stage);
|
||||
if (object.position[0] < stage.bounds.min[0] || object.position[0] > stage.bounds.max[0]
|
||||
|| object.position[2] < stage.bounds.min[2] || object.position[2] > stage.bounds.max[2]) failures.push(`${stage.id}: ${object.id} LOS geometry is outside the gameplay bounds.`);
|
||||
}
|
||||
const dynamicIds = new Set((stage.dynamicObjects ?? []).map((object) => object.id));
|
||||
for (const object of stage.lineOfSight ?? []) {
|
||||
if (object.dynamicObjectId && !dynamicIds.has(object.dynamicObjectId)) failures.push(`${stage.id}: ${object.id} references a missing dynamic object.`);
|
||||
}
|
||||
const base = path.join(projectRoot, "public", ...stage.assetBaseUrl.split("/").filter(Boolean));
|
||||
const assets = {};
|
||||
for (const name of ["visual", "collision", "navigation"]) {
|
||||
const file = path.join(base, `${name}.glb`);
|
||||
try {
|
||||
await access(file);
|
||||
const info = await stat(file);
|
||||
const document = await io.read(file);
|
||||
const triangles = document.getRoot().listMeshes().reduce((sum, mesh) => sum + mesh.listPrimitives().reduce((primitiveSum, primitive) => primitiveSum + (primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION")?.getCount() ?? 0) / 3, 0), 0);
|
||||
assets[name] = { bytes: info.size, triangles: Math.round(triangles) };
|
||||
if (name === "navigation") {
|
||||
const navigation = inspectConnectedNavigation(document, stage.teamSpawns);
|
||||
assets[name].components = navigation.components;
|
||||
assets[name].spawnDistances = navigation.spawnDistances.map((distance) => Math.round(distance * 1000) / 1000);
|
||||
if (navigation.components !== 1) failures.push(`${stage.id}: rated navigation must contain one connected gameplay component.`);
|
||||
for (const [index, distance] of navigation.spawnDistances.entries()) {
|
||||
if (distance > 3) failures.push(`${stage.id}: team spawn ${index} is ${distance.toFixed(2)}m from the navigation surface.`);
|
||||
}
|
||||
}
|
||||
if (!triangles) failures.push(`${stage.id}: ${name}.glb has no triangles.`);
|
||||
} catch {
|
||||
if (stage.validation.status === "validated") failures.push(`${stage.id}: required ${name}.glb is missing or unreadable.`);
|
||||
}
|
||||
}
|
||||
if (assets.visual?.bytes > 25 * 1024 * 1024) failures.push(`${stage.id}: visual asset exceeds 25 MiB.`);
|
||||
if (stage.validation.status === "validated" && ["traversal", "collision", "spawnSafety", "lineOfSight", "performance"].some((key) => stage.validation[key] !== true)) {
|
||||
failures.push(`${stage.id}: validated stages must carry every validation flag.`);
|
||||
}
|
||||
reports.push({ id: stage.id, validation: stage.validation, assets });
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ schemaVersion: 1, arenaCount: reports.length, failures, stages: reports }, null, 2));
|
||||
if (failures.length) process.exitCode = 1;
|
||||
@@ -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,12 +13,29 @@ 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",
|
||||
},
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
|
||||
logicalPrefix: "assets/game/arenas",
|
||||
},
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
|
||||
logicalPrefix: "assets/game/dungeons/wailing-caverns",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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, "public", "assets", "game", "arenas"),
|
||||
logicalPrefix: "assets/game/arenas",
|
||||
},
|
||||
{
|
||||
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,35 @@ 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",
|
||||
"core:arenas",
|
||||
"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 +70,34 @@ 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:arenas") return "Rated Arenas";
|
||||
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]}`;
|
||||
}
|
||||
@@ -70,15 +107,21 @@ export function contentPackForPath(logicalPath) {
|
||||
if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) {
|
||||
return `dungeon:${segments[3]}`;
|
||||
}
|
||||
if (segments[1] === "game" && segments[2] === "arenas") return "core:arenas";
|
||||
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 +154,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 +215,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,21 @@ 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/arenas/nagrand-arena/visual.glb"), "core:arenas");
|
||||
assert.equal(BUNDLED_ANDROID_PACKS.includes("core:arenas"), true);
|
||||
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");
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { threeToSoloNavMesh, threeToTiledNavMesh, NavMeshHelper } from "@recast-navigation/three";
|
||||
import { Vector3 } from "three";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
import {
|
||||
exists,
|
||||
fixturesRoot,
|
||||
@@ -39,9 +40,10 @@ globalThis.ProgressEvent ??= class ProgressEvent {
|
||||
globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} });
|
||||
|
||||
async function loadScene(file) {
|
||||
await MeshoptDecoder.ready;
|
||||
const bytes = await readFile(file);
|
||||
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
return new Promise((resolve, reject) => new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject));
|
||||
return new Promise((resolve, reject) => new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).parse(buffer, "", (gltf) => resolve(gltf.scene), reject));
|
||||
}
|
||||
|
||||
function meshesIn(scene) {
|
||||
|
||||
@@ -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
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
||||
|
||||
test("arena loadouts are character-owned, tank-free, and matches settle exactly once", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-arena-db-test-"));
|
||||
const database = openGameDatabase({ dataDir: directory });
|
||||
try {
|
||||
const left = await database.register("ArenaLeft", "arena-left-pass");
|
||||
const right = await database.register("ArenaRight", "arena-right-pass");
|
||||
database.putCloudSave(left.account.id, { version: 1, characters: [{ id: "left-priest", name: "Left", classId: "priest", categoryId: "wow" }] });
|
||||
database.putCloudSave(right.account.id, { version: 1, characters: [{ id: "right-mage", name: "Right", classId: "mage", categoryId: "wow" }] });
|
||||
const loadout = database.putArenaLoadout(left.account.id, "left-priest", 1, {
|
||||
name: "Disc Control", classId: "priest", specializationId: "priest:discipline",
|
||||
role: "healer", ruleset: "wow", talents: { "priest:discipline:1": 5 }, actions: { "1": "heal" }, selected: true,
|
||||
});
|
||||
assert.equal(loadout.level, 80);
|
||||
assert.equal(loadout.version, 1);
|
||||
assert.equal(database.getArenaState(left.account.id, "left-priest").loadouts.length, 1);
|
||||
assert.throws(() => database.putArenaLoadout(right.account.id, "right-mage", 1, {
|
||||
name: "Protection", classId: "mage", specializationId: "warrior:protection", ruleset: "wow", talents: {}, actions: {},
|
||||
}), (error) => error instanceof GameDatabaseError);
|
||||
|
||||
database.createArenaMatch({
|
||||
id: "arena-idempotent", bracket: "2v2", mode: "rated-ai", ruleset: "wow", stageId: "warnorken-arena", startedAt: Date.now(),
|
||||
participants: [
|
||||
{ actorId: "left", accountId: left.account.id, characterId: "left-priest", team: 0, role: "healer", isBot: false, rating: 0 },
|
||||
{ actorId: "left-bot", team: 0, role: "damage", isBot: true, rating: 0 },
|
||||
{ actorId: "right", accountId: right.account.id, characterId: "right-mage", team: 1, role: "damage", isBot: false, rating: 0 },
|
||||
{ actorId: "right-bot", team: 1, role: "healer", isBot: true, rating: 0 },
|
||||
],
|
||||
});
|
||||
const first = database.settleArenaMatch("arena-idempotent", 0);
|
||||
const retry = database.settleArenaMatch("arena-idempotent", 0);
|
||||
assert.equal(first.ratingUpdates.find((update) => update.characterId === "left-priest").after, 32);
|
||||
assert.deepEqual(retry.ratingUpdates, first.ratingUpdates);
|
||||
assert.equal(database.getArenaRating(left.account.id, "left-priest", "2v2").rating, 32);
|
||||
assert.equal(database.getArenaRating(left.account.id, "left-priest", "3v3").rating, 0);
|
||||
} finally {
|
||||
database.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
export const ARENA_BRACKETS = Object.freeze({
|
||||
"2v2": Object.freeze({ size: 2, healers: 1, damage: 1 }),
|
||||
"3v3": Object.freeze({ size: 3, healers: 1, damage: 2 }),
|
||||
});
|
||||
|
||||
export const ARENA_RATING_BANDS = Object.freeze([
|
||||
Object.freeze({ min: 0, max: 499, title: "Recruit", k: 64, reaction: [1.4, 2.4], healerInterrupt: 0, coordinatedCc: 0.05, defensive: 0.35, error: 0.35 }),
|
||||
Object.freeze({ min: 500, max: 999, title: "Combatant", k: 48, reaction: [1, 1.8], healerInterrupt: 0.2, coordinatedCc: 0.2, defensive: 0.5, error: 0.28 }),
|
||||
Object.freeze({ min: 1000, max: 1399, title: "Challenger", k: 40, reaction: [0.75, 1.3], healerInterrupt: 0.4, coordinatedCc: 0.4, defensive: 0.65, error: 0.2 }),
|
||||
Object.freeze({ min: 1400, max: 1799, title: "Rival", k: 32, reaction: [0.55, 1], healerInterrupt: 0.6, coordinatedCc: 0.6, defensive: 0.75, error: 0.15 }),
|
||||
Object.freeze({ min: 1800, max: 2099, title: "Duelist", k: 28, reaction: [0.4, 0.8], healerInterrupt: 0.72, coordinatedCc: 0.72, defensive: 0.82, error: 0.12 }),
|
||||
Object.freeze({ min: 2100, max: 2399, title: "Elite", k: 24, reaction: [0.3, 0.65], healerInterrupt: 0.8, coordinatedCc: 0.8, defensive: 0.88, error: 0.1 }),
|
||||
Object.freeze({ min: 2400, max: 2500, title: "Gladiator", k: 20, reaction: [0.25, 0.55], healerInterrupt: 0.85, coordinatedCc: 0.85, defensive: 0.9, error: 0.08 }),
|
||||
]);
|
||||
|
||||
export const ARENA_MODES = Object.freeze(["rated-ai", "ranked-pvp"]);
|
||||
export const ARENA_RULESETS = Object.freeze(["wow", "coa", "rom"]);
|
||||
|
||||
export function clampArenaRating(value) {
|
||||
return Math.max(0, Math.min(2500, Math.round(Number(value) || 0)));
|
||||
}
|
||||
|
||||
export function arenaRatingBand(rating) {
|
||||
const normalized = clampArenaRating(rating);
|
||||
return ARENA_RATING_BANDS.find((band) => normalized >= band.min && normalized <= band.max)
|
||||
?? ARENA_RATING_BANDS[0];
|
||||
}
|
||||
|
||||
function lerp(left, right, amount) {
|
||||
return left + (right - left) * Math.max(0, Math.min(1, amount));
|
||||
}
|
||||
|
||||
export function arenaAiProfile(rating) {
|
||||
const normalized = clampArenaRating(rating);
|
||||
const index = ARENA_RATING_BANDS.findIndex((band) => normalized >= band.min && normalized <= band.max);
|
||||
const current = ARENA_RATING_BANDS[Math.max(0, index)];
|
||||
const next = ARENA_RATING_BANDS[Math.min(ARENA_RATING_BANDS.length - 1, Math.max(0, index) + 1)];
|
||||
const span = Math.max(1, current.max - current.min + 1);
|
||||
const progress = current === next ? 0 : (normalized - current.min) / span;
|
||||
return Object.freeze({
|
||||
rating: normalized,
|
||||
title: current.title,
|
||||
reactionMinSeconds: lerp(current.reaction[0], next.reaction[0], progress),
|
||||
reactionMaxSeconds: lerp(current.reaction[1], next.reaction[1], progress),
|
||||
healerInterruptChance: lerp(current.healerInterrupt, next.healerInterrupt, progress),
|
||||
coordinatedCcChance: lerp(current.coordinatedCc, next.coordinatedCc, progress),
|
||||
correctDefensiveChance: lerp(current.defensive, next.defensive, progress),
|
||||
minimumErrorChance: lerp(current.error, next.error, progress),
|
||||
});
|
||||
}
|
||||
|
||||
export function calculateArenaRating({ rating, opponentRating, score }) {
|
||||
const before = clampArenaRating(rating);
|
||||
const opposing = clampArenaRating(opponentRating);
|
||||
const normalizedScore = score === 0.5 ? 0.5 : score > 0 ? 1 : 0;
|
||||
if (normalizedScore === 0.5) return { before, expected: 0.5, delta: 0, after: before };
|
||||
const expected = 1 / (1 + 10 ** ((opposing - before) / 400));
|
||||
const delta = Math.round(arenaRatingBand(before).k * (normalizedScore - expected));
|
||||
const after = clampArenaRating(before + delta);
|
||||
return { before, expected, delta: after - before, after };
|
||||
}
|
||||
|
||||
export function arenaDampening(elapsedMs) {
|
||||
const increases = Math.floor(Math.max(0, Number(elapsedMs) || 0) / 30_000);
|
||||
return Math.min(1, 0.1 + increases * 0.05);
|
||||
}
|
||||
|
||||
export function applyArenaHealing(rawAmount, elapsedMs) {
|
||||
return Math.max(0, Math.round(Math.max(0, Number(rawAmount) || 0) * (1 - arenaDampening(elapsedMs))));
|
||||
}
|
||||
|
||||
export function arenaMatchTolerance(waitMs) {
|
||||
return 100 + Math.floor(Math.max(0, Number(waitMs) || 0) / 10_000) * 100;
|
||||
}
|
||||
|
||||
export function validateArenaComposition(bracket, roles, { allowPartial = false } = {}) {
|
||||
const definition = ARENA_BRACKETS[bracket];
|
||||
if (!definition) return { ok: false, code: "invalid_bracket", message: "Choose 2v2 or 3v3." };
|
||||
if (!Array.isArray(roles) || roles.some((role) => !["healer", "damage"].includes(role))) {
|
||||
return { ok: false, code: "invalid_arena_role", message: "Rated arenas allow healer and damage specializations only." };
|
||||
}
|
||||
const healers = roles.filter((role) => role === "healer").length;
|
||||
const damage = roles.filter((role) => role === "damage").length;
|
||||
if (roles.length > definition.size || healers > definition.healers || damage > definition.damage) {
|
||||
return { ok: false, code: "invalid_composition", message: `${bracket} requires exactly one healer and ${definition.damage} DPS.` };
|
||||
}
|
||||
if (!allowPartial && (roles.length !== definition.size || healers !== definition.healers || damage !== definition.damage)) {
|
||||
return { ok: false, code: "incomplete_composition", message: `${bracket} requires exactly one healer and ${definition.damage} DPS.` };
|
||||
}
|
||||
return { ok: true, missing: { healer: definition.healers - healers, damage: definition.damage - damage } };
|
||||
}
|
||||
|
||||
export function arenaDrDuration(baseDurationMs, priorApplications, elapsedSinceEndMs = Infinity) {
|
||||
const applications = elapsedSinceEndMs >= 15_000 ? 0 : Math.max(0, Math.trunc(priorApplications));
|
||||
const multiplier = [1, 0.5, 0.25, 0][Math.min(3, applications)];
|
||||
return { durationMs: Math.round(Math.max(0, baseDurationMs) * multiplier), applications: Math.min(3, applications + 1), multiplier };
|
||||
}
|
||||
|
||||
export function shouldArenaBotChooseCorrectly(successChance, minimumErrorChance, random = Math.random) {
|
||||
const cappedSuccess = Math.min(1 - Math.max(0, minimumErrorChance), Math.max(0, successChance));
|
||||
return random() < cappedSuccess;
|
||||
}
|
||||
|
||||
export function assertArenaQueueInput(input) {
|
||||
if (!ARENA_BRACKETS[input?.bracket]) throw new Error("Choose 2v2 or 3v3.");
|
||||
if (!ARENA_MODES.includes(input?.mode)) throw new Error("Choose Rated AI or Ranked PvP.");
|
||||
if (!ARENA_RULESETS.includes(input?.ruleset)) throw new Error("Choose a valid arena ruleset.");
|
||||
if (typeof input?.characterId !== "string" || !input.characterId.trim()) throw new Error("Choose a character.");
|
||||
return {
|
||||
bracket: input.bracket,
|
||||
mode: input.mode,
|
||||
ruleset: input.ruleset,
|
||||
characterId: input.characterId.trim().slice(0, 100),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
ARENA_RATING_BANDS,
|
||||
arenaAiProfile,
|
||||
arenaDampening,
|
||||
arenaDrDuration,
|
||||
arenaMatchTolerance,
|
||||
calculateArenaRating,
|
||||
shouldArenaBotChooseCorrectly,
|
||||
validateArenaComposition,
|
||||
} from "./arena-rules.mjs";
|
||||
|
||||
function seededRandom(seed) {
|
||||
let value = seed >>> 0;
|
||||
return () => {
|
||||
value = (value * 1664525 + 1013904223) >>> 0;
|
||||
return value / 0x100000000;
|
||||
};
|
||||
}
|
||||
|
||||
test("arena Elo uses band K values, draws are neutral, and clamps at both ends", () => {
|
||||
assert.deepEqual(calculateArenaRating({ rating: 1000, opponentRating: 1000, score: 1 }), {
|
||||
before: 1000, expected: 0.5, delta: 20, after: 1020,
|
||||
});
|
||||
assert.equal(calculateArenaRating({ rating: 2499, opponentRating: 2500, score: 1 }).after, 2500);
|
||||
assert.equal(calculateArenaRating({ rating: 0, opponentRating: 0, score: 0 }).after, 0);
|
||||
assert.equal(calculateArenaRating({ rating: 1600, opponentRating: 1900, score: 0.5 }).delta, 0);
|
||||
});
|
||||
|
||||
test("arena composition requires exactly one healer and rejects tanks", () => {
|
||||
assert.equal(validateArenaComposition("2v2", ["healer", "damage"]).ok, true);
|
||||
assert.equal(validateArenaComposition("3v3", ["healer", "damage", "damage"]).ok, true);
|
||||
assert.equal(validateArenaComposition("3v3", ["healer"], { allowPartial: true }).missing.damage, 2);
|
||||
assert.equal(validateArenaComposition("2v2", ["tank", "damage"]).code, "invalid_arena_role");
|
||||
assert.equal(validateArenaComposition("2v2", ["damage", "damage"]).code, "invalid_composition");
|
||||
});
|
||||
|
||||
test("difficulty, dampening, matchmaking, and DR follow the rated rules", () => {
|
||||
const gladiator = arenaAiProfile(2500);
|
||||
assert.equal(gladiator.healerInterruptChance, 0.85);
|
||||
assert.equal(gladiator.minimumErrorChance, 0.08);
|
||||
assert.equal(shouldArenaBotChooseCorrectly(1, gladiator.minimumErrorChance, () => 0.93), false);
|
||||
assert.equal(arenaDampening(0), 0.1);
|
||||
assert.equal(arenaDampening(540_000), 1);
|
||||
assert.equal(arenaMatchTolerance(0), 100);
|
||||
assert.equal(arenaMatchTolerance(30_000), 400);
|
||||
assert.equal(arenaDrDuration(8000, 2, 0).durationMs, 2000);
|
||||
assert.equal(arenaDrDuration(8000, 3, 15_000).durationMs, 8000);
|
||||
});
|
||||
|
||||
test("every AI band honors its settings and seeded minimum-error floor", () => {
|
||||
for (const [index, band] of ARENA_RATING_BANDS.entries()) {
|
||||
const profile = arenaAiProfile(band.min);
|
||||
assert.equal(profile.title, band.title);
|
||||
assert.equal(profile.reactionMinSeconds, band.reaction[0]);
|
||||
assert.equal(profile.reactionMaxSeconds, band.reaction[1]);
|
||||
assert.equal(profile.healerInterruptChance, band.healerInterrupt);
|
||||
assert.equal(profile.coordinatedCcChance, band.coordinatedCc);
|
||||
assert.equal(profile.correctDefensiveChance, band.defensive);
|
||||
assert.equal(profile.minimumErrorChance, band.error);
|
||||
|
||||
const random = seededRandom(index + 1);
|
||||
const decisions = Array.from({ length: 4000 }, () => shouldArenaBotChooseCorrectly(1, profile.minimumErrorChance, random));
|
||||
const errorRate = decisions.filter((decision) => !decision).length / decisions.length;
|
||||
assert.ok(errorRate >= profile.minimumErrorChance - 0.025, `${band.title} fell below its minimum-error floor`);
|
||||
assert.ok(errorRate <= profile.minimumErrorChance + 0.025, `${band.title} exceeded its seeded error tolerance`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,425 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { GameDatabaseError } from "./database.mjs";
|
||||
import { ARENA_BRACKETS, arenaMatchTolerance, assertArenaQueueInput, validateArenaComposition } from "./arena-rules.mjs";
|
||||
import { ARENA_STAGES, chooseArenaStage } from "./arena-stages.mjs";
|
||||
import { ArenaSimulation } from "./arena-simulation.mjs";
|
||||
|
||||
const READY_CHECK_MS = 15_000;
|
||||
const PVP_BOT_FILL_MS = 30_000;
|
||||
|
||||
function average(values) {
|
||||
return Math.round(values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length));
|
||||
}
|
||||
|
||||
function publicTicket(ticket, now = Date.now()) {
|
||||
if (!ticket) return { status: "idle" };
|
||||
return {
|
||||
status: ticket.status,
|
||||
ticketId: ticket.id,
|
||||
joinedAt: ticket.joinedAt,
|
||||
searchRange: arenaMatchTolerance(now - ticket.joinedAt),
|
||||
readyDeadline: ticket.lobby?.readyDeadline ?? null,
|
||||
matchId: ticket.matchId ?? ticket.lobby?.matchId ?? null,
|
||||
lockedLoadout: ticket.members.find((member) => member.accountId === ticket.ownerAccountId)?.loadout ?? ticket.members[0]?.loadout,
|
||||
};
|
||||
}
|
||||
|
||||
function roleCapacity(bracket, members) {
|
||||
const roles = members.map((member) => member.role);
|
||||
return validateArenaComposition(bracket, roles, { allowPartial: true }).ok;
|
||||
}
|
||||
|
||||
export function createArenaService({ database, random = Math.random, now = () => Date.now(), autoPump = true } = {}) {
|
||||
const tickets = new Map();
|
||||
const accountTickets = new Map();
|
||||
const lobbies = new Map();
|
||||
const matches = new Map();
|
||||
const socketTickets = new Map();
|
||||
const subscribers = new Map();
|
||||
const previousStageByAccount = new Map();
|
||||
|
||||
function broadcast(accountId, message) {
|
||||
for (const send of subscribers.get(accountId) ?? []) send(message);
|
||||
}
|
||||
|
||||
function broadcastMatch(match, message) {
|
||||
for (const participant of match.participants) if (participant.accountId) broadcast(participant.accountId, message);
|
||||
}
|
||||
|
||||
function broadcastSnapshot(match, snapshot) {
|
||||
for (const participant of match.participants) {
|
||||
if (participant.accountId) broadcast(participant.accountId, {
|
||||
type: "snapshot",
|
||||
snapshot: { ...snapshot, controlledActorId: participant.actorId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function queueState(accountId) {
|
||||
return publicTicket(accountTickets.get(accountId), now());
|
||||
}
|
||||
|
||||
function arenaState(accountId, characterId = null) {
|
||||
return {
|
||||
...database.getArenaState(accountId, characterId),
|
||||
queue: queueState(accountId),
|
||||
stages: ARENA_STAGES,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMembers(account, input) {
|
||||
const groupState = database.onlineGroupState(account.id);
|
||||
const group = groupState.group;
|
||||
const requested = Array.isArray(input.groupMembers) ? input.groupMembers : [];
|
||||
if (!group || group.members.length <= 1) {
|
||||
const loadout = database.arenaLoadoutForQueue(account.id, input.characterId, input.loadoutSlot);
|
||||
return [{ accountId: account.id, username: account.username, characterId: input.characterId, loadout, role: loadout.role }];
|
||||
}
|
||||
if (group.leaderAccountId !== account.id) {
|
||||
throw new GameDatabaseError("Only the group leader can enter the group into an arena queue.", 403, "arena_leader_required");
|
||||
}
|
||||
if (group.members.length > ARENA_BRACKETS[input.bracket].size) {
|
||||
throw new GameDatabaseError(`That group is too large for ${input.bracket}.`, 409, "arena_group_too_large");
|
||||
}
|
||||
return group.members.map((member) => {
|
||||
if (accountTickets.has(member.accountId)) throw new GameDatabaseError(`${member.username} is already queued.`, 409, "arena_already_queued");
|
||||
const selection = requested.find((entry) => entry.accountId === member.accountId)
|
||||
?? (member.accountId === account.id ? input : null);
|
||||
const characterId = selection?.characterId ?? member.character?.id;
|
||||
if (!characterId) throw new GameDatabaseError(`${member.username} must select a group character.`, 409, "arena_group_character_required");
|
||||
const loadout = selection?.loadoutSlot
|
||||
? database.arenaLoadoutForQueue(member.accountId, characterId, selection.loadoutSlot)
|
||||
: database.selectedArenaLoadoutForQueue(member.accountId, characterId);
|
||||
return {
|
||||
accountId: member.accountId,
|
||||
username: member.username,
|
||||
characterId,
|
||||
loadout,
|
||||
role: loadout.role,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function enqueue(account, inputValue) {
|
||||
if (accountTickets.has(account.id)) throw new GameDatabaseError("You are already in an arena queue.", 409, "arena_already_queued");
|
||||
let input;
|
||||
try { input = { ...inputValue, ...assertArenaQueueInput(inputValue) }; }
|
||||
catch (error) { throw new GameDatabaseError(error.message, 400, "invalid_arena_queue"); }
|
||||
const members = resolveMembers(account, input);
|
||||
const composition = validateArenaComposition(input.bracket, members.map((member) => member.role), { allowPartial: true });
|
||||
if (!composition.ok) throw new GameDatabaseError(composition.message, 400, composition.code);
|
||||
if (members.some((member) => member.loadout.ruleset !== input.ruleset)) {
|
||||
throw new GameDatabaseError("Every loadout must use the selected ruleset.", 400, "arena_ruleset_mismatch");
|
||||
}
|
||||
const ratings = members.map((member) => database.getArenaRating(member.accountId, member.characterId, input.bracket).rating);
|
||||
const ticket = {
|
||||
id: `queue-${randomUUID()}`,
|
||||
ownerAccountId: account.id,
|
||||
bracket: input.bracket,
|
||||
mode: input.mode,
|
||||
ruleset: input.ruleset,
|
||||
joinedAt: now(),
|
||||
rating: average(ratings),
|
||||
members,
|
||||
status: "queued",
|
||||
ready: new Set(),
|
||||
lobby: null,
|
||||
matchId: null,
|
||||
};
|
||||
tickets.set(ticket.id, ticket);
|
||||
for (const member of members) accountTickets.set(member.accountId, ticket);
|
||||
if (ticket.mode === "rated-ai") createLobby([[ticket], []], ticket.rating);
|
||||
else pump();
|
||||
return publicTicket(ticket, now());
|
||||
}
|
||||
|
||||
function removeTicket(ticket) {
|
||||
tickets.delete(ticket.id);
|
||||
for (const member of ticket.members) if (accountTickets.get(member.accountId) === ticket) accountTickets.delete(member.accountId);
|
||||
}
|
||||
|
||||
function leaveQueue(accountId) {
|
||||
const ticket = accountTickets.get(accountId);
|
||||
if (!ticket) return { status: "idle" };
|
||||
if (ticket.status === "matched") {
|
||||
const match = matches.get(ticket.matchId);
|
||||
const participant = match?.participants.find((entry) => entry.accountId === accountId);
|
||||
if (participant) match.simulation.disconnect(participant.actorId);
|
||||
return publicTicket(ticket, now());
|
||||
}
|
||||
if (ticket.lobby) cancelLobby(ticket.lobby, "A player left the ready check.");
|
||||
else removeTicket(ticket);
|
||||
return { status: "idle" };
|
||||
}
|
||||
|
||||
function compatible(left, right, timestamp) {
|
||||
if (left.id === right.id || left.bracket !== right.bracket || left.ruleset !== right.ruleset || left.mode !== right.mode) return false;
|
||||
const tolerance = Math.max(arenaMatchTolerance(timestamp - left.joinedAt), arenaMatchTolerance(timestamp - right.joinedAt));
|
||||
return Math.abs(left.rating - right.rating) <= tolerance;
|
||||
}
|
||||
|
||||
function assembleHumanLobby(pool, timestamp) {
|
||||
if (pool.length < 2) return null;
|
||||
const bracket = pool[0].bracket;
|
||||
const teamSize = ARENA_BRACKETS[bracket].size;
|
||||
for (let anchorIndex = 0; anchorIndex < pool.length; anchorIndex += 1) {
|
||||
const anchor = pool[anchorIndex];
|
||||
const compatiblePool = pool.filter((ticket) => compatible(anchor, ticket, timestamp) || ticket === anchor);
|
||||
const teams = [[], []];
|
||||
const memberSets = [[], []];
|
||||
for (const ticket of compatiblePool) {
|
||||
const side = [0, 1].find((team) => (
|
||||
memberSets[team].length + ticket.members.length <= teamSize
|
||||
&& roleCapacity(bracket, [...memberSets[team], ...ticket.members])
|
||||
));
|
||||
if (side == null) continue;
|
||||
teams[side].push(ticket);
|
||||
memberSets[side].push(...ticket.members);
|
||||
if (memberSets.every((members) => validateArenaComposition(bracket, members.map((member) => member.role)).ok)) return teams;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pump() {
|
||||
const timestamp = now();
|
||||
for (const ticket of [...tickets.values()]) {
|
||||
if (ticket.status === "ready-check" && ticket.lobby?.readyDeadline <= timestamp) cancelLobby(ticket.lobby, "The ready check expired.");
|
||||
}
|
||||
const waiting = [...tickets.values()].filter((ticket) => ticket.status === "queued" && ticket.mode === "ranked-pvp");
|
||||
const pools = new Map();
|
||||
for (const ticket of waiting) {
|
||||
const key = `${ticket.bracket}:${ticket.ruleset}`;
|
||||
const pool = pools.get(key) ?? [];
|
||||
pool.push(ticket);
|
||||
pools.set(key, pool);
|
||||
}
|
||||
for (const pool of pools.values()) {
|
||||
while (true) {
|
||||
const teams = assembleHumanLobby(pool.filter((ticket) => ticket.status === "queued"), timestamp);
|
||||
if (!teams) break;
|
||||
createLobby(teams, average(teams.flat().map((ticket) => ticket.rating)));
|
||||
}
|
||||
for (const ticket of pool) {
|
||||
if (ticket.status === "queued" && timestamp - ticket.joinedAt >= PVP_BOT_FILL_MS) createLobby([[ticket], []], ticket.rating);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function botMember(team, role, rating, index) {
|
||||
return {
|
||||
actorId: `bot-${randomUUID()}`,
|
||||
accountId: null,
|
||||
characterId: null,
|
||||
name: `${rating >= 2400 ? "Gladiator" : rating >= 1800 ? "Duelist" : rating >= 1000 ? "Challenger" : "Recruit"} ${role === "healer" ? "Medic" : `Fighter ${index + 1}`}`,
|
||||
team,
|
||||
role,
|
||||
isBot: true,
|
||||
disconnected: false,
|
||||
rating,
|
||||
loadout: null,
|
||||
};
|
||||
}
|
||||
|
||||
function fillTeam(humans, team, bracket, rating) {
|
||||
const members = humans.map((member) => ({
|
||||
actorId: `player-${randomUUID()}`,
|
||||
accountId: member.accountId,
|
||||
characterId: member.characterId,
|
||||
name: member.loadout.name || member.username,
|
||||
team,
|
||||
role: member.role,
|
||||
isBot: false,
|
||||
disconnected: false,
|
||||
rating: database.getArenaRating(member.accountId, member.characterId, bracket).rating,
|
||||
loadout: member.loadout,
|
||||
}));
|
||||
const composition = validateArenaComposition(bracket, members.map((member) => member.role), { allowPartial: true });
|
||||
if (!composition.ok) throw new GameDatabaseError(composition.message, 400, composition.code);
|
||||
for (let index = 0; index < composition.missing.healer; index += 1) members.push(botMember(team, "healer", rating, index));
|
||||
for (let index = 0; index < composition.missing.damage; index += 1) members.push(botMember(team, "damage", rating, index));
|
||||
return members;
|
||||
}
|
||||
|
||||
function createLobby(teamTickets, effectiveRating) {
|
||||
const allTickets = teamTickets.flat();
|
||||
if (!allTickets.length || allTickets.some((ticket) => ticket.status !== "queued")) return null;
|
||||
const first = allTickets[0];
|
||||
const matchId = `arena-${randomUUID()}`;
|
||||
const humanTeams = teamTickets.map((side) => side.flatMap((ticket) => ticket.members));
|
||||
const participants = [
|
||||
...fillTeam(humanTeams[0], 0, first.bracket, effectiveRating),
|
||||
...fillTeam(humanTeams[1], 1, first.bracket, effectiveRating),
|
||||
];
|
||||
const previousStageId = allTickets.map((ticket) => previousStageByAccount.get(ticket.ownerAccountId)).find(Boolean) ?? null;
|
||||
const stage = chooseArenaStage({ random, previousStageId });
|
||||
const lobby = {
|
||||
id: `lobby-${randomUUID()}`,
|
||||
matchId,
|
||||
bracket: first.bracket,
|
||||
mode: first.mode,
|
||||
ruleset: first.ruleset,
|
||||
stage,
|
||||
tickets: allTickets,
|
||||
participants,
|
||||
effectiveRating,
|
||||
readyAccounts: new Set(),
|
||||
readyDeadline: now() + READY_CHECK_MS,
|
||||
};
|
||||
lobbies.set(lobby.id, lobby);
|
||||
for (const ticket of allTickets) {
|
||||
ticket.status = "ready-check";
|
||||
ticket.lobby = lobby;
|
||||
ticket.matchId = matchId;
|
||||
for (const member of ticket.members) broadcast(member.accountId, { type: "ready-check", deadline: lobby.readyDeadline, matchId });
|
||||
}
|
||||
return lobby;
|
||||
}
|
||||
|
||||
function cancelLobby(lobby, message) {
|
||||
if (!lobbies.has(lobby.id)) return;
|
||||
lobbies.delete(lobby.id);
|
||||
for (const ticket of lobby.tickets) {
|
||||
ticket.lobby = null;
|
||||
ticket.matchId = null;
|
||||
ticket.status = "queued";
|
||||
ticket.ready.clear();
|
||||
ticket.joinedAt = now();
|
||||
for (const member of ticket.members) broadcast(member.accountId, { type: "queue-cancelled", message });
|
||||
}
|
||||
}
|
||||
|
||||
function ready(accountId) {
|
||||
const ticket = accountTickets.get(accountId);
|
||||
const lobby = ticket?.lobby;
|
||||
if (!ticket || ticket.status !== "ready-check" || !lobby) throw new GameDatabaseError("No arena ready check is active.", 409, "arena_ready_check_missing");
|
||||
if (lobby.readyDeadline <= now()) {
|
||||
cancelLobby(lobby, "The ready check expired.");
|
||||
throw new GameDatabaseError("The arena ready check expired.", 409, "arena_ready_check_expired");
|
||||
}
|
||||
lobby.readyAccounts.add(accountId);
|
||||
ticket.ready.add(accountId);
|
||||
const humanAccounts = lobby.participants.filter((participant) => participant.accountId).map((participant) => participant.accountId);
|
||||
if (humanAccounts.every((id) => lobby.readyAccounts.has(id))) startMatch(lobby);
|
||||
return publicTicket(ticket, now());
|
||||
}
|
||||
|
||||
function startMatch(lobby) {
|
||||
if (!lobbies.delete(lobby.id)) return;
|
||||
const startedAt = now();
|
||||
const stored = {
|
||||
id: lobby.matchId,
|
||||
bracket: lobby.bracket,
|
||||
mode: lobby.mode,
|
||||
ruleset: lobby.ruleset,
|
||||
stageId: lobby.stage.id,
|
||||
startedAt,
|
||||
participants: lobby.participants,
|
||||
};
|
||||
database.createArenaMatch(stored);
|
||||
const match = { ...stored, stage: lobby.stage, subscribers: new Set(), result: null, simulation: null };
|
||||
const simulation = new ArenaSimulation({
|
||||
...stored,
|
||||
stage: lobby.stage,
|
||||
startedAt,
|
||||
random,
|
||||
onSnapshot: (snapshot) => broadcastSnapshot(match, snapshot),
|
||||
onResult: (result) => settleMatch(match, result),
|
||||
});
|
||||
match.simulation = simulation;
|
||||
matches.set(match.id, match);
|
||||
for (const ticket of lobby.tickets) {
|
||||
ticket.status = "matched";
|
||||
ticket.lobby = null;
|
||||
ticket.matchId = match.id;
|
||||
for (const member of ticket.members) {
|
||||
previousStageByAccount.set(member.accountId, lobby.stage.id);
|
||||
const participant = match.participants.find((entry) => entry.accountId === member.accountId);
|
||||
broadcast(member.accountId, {
|
||||
type: "match-start", matchId: match.id, stage: lobby.stage,
|
||||
snapshot: { ...simulation.snapshot(), controlledActorId: participant?.actorId },
|
||||
});
|
||||
}
|
||||
}
|
||||
simulation.start();
|
||||
}
|
||||
|
||||
function settleMatch(match, result) {
|
||||
if (match.result) return match.result;
|
||||
const settlement = result.reason === "void"
|
||||
? (database.voidArenaMatch(match.id), { ...result, ratingUpdates: [] })
|
||||
: { ...result, ...database.settleArenaMatch(match.id, result.winnerTeam) };
|
||||
match.result = settlement;
|
||||
broadcastMatch(match, { type: "result", result: settlement });
|
||||
for (const participant of match.participants) {
|
||||
if (!participant.accountId) continue;
|
||||
const ticket = accountTickets.get(participant.accountId);
|
||||
if (ticket?.matchId === match.id) removeTicket(ticket);
|
||||
}
|
||||
return settlement;
|
||||
}
|
||||
|
||||
function socketTicket(accountId) {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
socketTickets.set(token, { accountId, expiresAt: now() + 30_000 });
|
||||
return { ticket: token, expiresAt: now() + 30_000, path: "/ws/arena" };
|
||||
}
|
||||
|
||||
function consumeSocketTicket(token) {
|
||||
const entry = socketTickets.get(token);
|
||||
socketTickets.delete(token);
|
||||
if (!entry || entry.expiresAt <= now()) return null;
|
||||
return entry.accountId;
|
||||
}
|
||||
|
||||
function subscribe(accountId, send) {
|
||||
const set = subscribers.get(accountId) ?? new Set();
|
||||
set.add(send);
|
||||
subscribers.set(accountId, set);
|
||||
const ticket = accountTickets.get(accountId);
|
||||
if (ticket?.matchId) {
|
||||
const match = matches.get(ticket.matchId);
|
||||
if (match) {
|
||||
const participant = match.participants.find((entry) => entry.accountId === accountId);
|
||||
send({ type: "snapshot", snapshot: { ...match.simulation.snapshot(), controlledActorId: participant?.actorId } });
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
set.delete(send);
|
||||
if (!set.size) subscribers.delete(accountId);
|
||||
const current = accountTickets.get(accountId);
|
||||
if (current?.status === "matched") {
|
||||
const match = matches.get(current.matchId);
|
||||
const participant = match?.participants.find((entry) => entry.accountId === accountId);
|
||||
if (participant) match.simulation.disconnect(participant.actorId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function receive(accountId, message) {
|
||||
const ticket = accountTickets.get(accountId);
|
||||
const match = matches.get(ticket?.matchId);
|
||||
if (!match || match.result) return { accepted: false, reason: "match-unavailable" };
|
||||
const participant = match.participants.find((entry) => entry.accountId === accountId);
|
||||
if (!participant) return { accepted: false, reason: "spectator" };
|
||||
return match.simulation.receiveIntent(participant.actorId, message);
|
||||
}
|
||||
|
||||
const interval = autoPump ? setInterval(pump, 250) : null;
|
||||
interval?.unref?.();
|
||||
|
||||
function close() {
|
||||
if (interval) clearInterval(interval);
|
||||
for (const match of matches.values()) {
|
||||
if (!match.result) match.simulation.finish(null, "void");
|
||||
}
|
||||
subscribers.clear();
|
||||
socketTickets.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
arenaState, queueState, enqueue, leaveQueue, ready, pump,
|
||||
socketTicket, consumeSocketTicket, subscribe, receive, close,
|
||||
inspect: () => ({ tickets, lobbies, matches }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createArenaService } from "./arena-service.mjs";
|
||||
|
||||
function fakeDatabase() {
|
||||
return {
|
||||
onlineGroupState: () => ({ group: null, invitations: [] }),
|
||||
arenaLoadoutForQueue: (_accountId, characterId) => ({
|
||||
slot: 1, name: "Disc", characterId, classId: "priest", specializationId: "priest:discipline",
|
||||
role: "healer", ruleset: "wow", level: 80, talents: {}, actions: {}, version: 1, selected: true, updatedAt: 1,
|
||||
}),
|
||||
selectedArenaLoadoutForQueue: (_accountId, characterId) => ({
|
||||
slot: 2, name: "Selected", characterId, classId: "warrior", specializationId: "warrior:arms",
|
||||
role: "damage", ruleset: "wow", level: 80, talents: {}, actions: {}, version: 1, selected: true, updatedAt: 1,
|
||||
}),
|
||||
getArenaRating: () => ({ rating: 1200, title: "Challenger" }),
|
||||
getArenaState: () => ({ characterId: "priest", loadouts: [], ratings: {} }),
|
||||
createArenaMatch: () => {}, settleArenaMatch: () => ({ ratingUpdates: [] }), voidArenaMatch: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
test("Rated AI fills immediately and requires the 15 second ready check", () => {
|
||||
let clock = 1000;
|
||||
const service = createArenaService({ database: fakeDatabase(), now: () => clock, random: () => 0, autoPump: false });
|
||||
const account = { id: "account", username: "ArenaPlayer" };
|
||||
const queued = service.enqueue(account, { characterId: "priest", loadoutSlot: 1, bracket: "2v2", mode: "rated-ai", ruleset: "wow" });
|
||||
assert.equal(queued.status, "ready-check");
|
||||
assert.equal(queued.readyDeadline, clock + 15_000);
|
||||
service.ready(account.id);
|
||||
const match = [...service.inspect().matches.values()][0];
|
||||
assert.equal(match.participants.length, 4);
|
||||
assert.equal(match.participants.filter((participant) => participant.team === 0 && participant.role === "healer").length, 1);
|
||||
match.simulation.stop();
|
||||
service.close();
|
||||
});
|
||||
|
||||
test("group leaders queue partial teams with each member's selected loadout", () => {
|
||||
const database = fakeDatabase();
|
||||
database.onlineGroupState = () => ({
|
||||
invitations: [],
|
||||
group: {
|
||||
leaderAccountId: "leader",
|
||||
members: [
|
||||
{ accountId: "leader", username: "Leader", character: { id: "priest" } },
|
||||
{ accountId: "member", username: "Member", character: { id: "warrior" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
const service = createArenaService({ database, now: () => 1000, random: () => 0, autoPump: false });
|
||||
const queued = service.enqueue({ id: "leader", username: "Leader" }, {
|
||||
characterId: "priest", loadoutSlot: 1, bracket: "3v3", mode: "rated-ai", ruleset: "wow",
|
||||
});
|
||||
assert.equal(queued.status, "ready-check");
|
||||
service.ready("leader");
|
||||
assert.equal(service.inspect().matches.size, 0);
|
||||
service.ready("member");
|
||||
const match = [...service.inspect().matches.values()][0];
|
||||
assert.equal(match.participants.filter((participant) => participant.accountId).length, 2);
|
||||
assert.equal(match.participants.filter((participant) => participant.team === 0 && participant.isBot).length, 1);
|
||||
match.simulation.stop();
|
||||
service.close();
|
||||
});
|
||||
|
||||
test("expired ready checks return tickets to matchmaking", () => {
|
||||
let clock = 1000;
|
||||
const service = createArenaService({ database: fakeDatabase(), now: () => clock, random: () => 0, autoPump: false });
|
||||
const account = { id: "account", username: "ArenaPlayer" };
|
||||
service.enqueue(account, { characterId: "priest", loadoutSlot: 1, bracket: "2v2", mode: "rated-ai", ruleset: "wow" });
|
||||
clock += 15_001;
|
||||
service.pump();
|
||||
assert.equal(service.queueState(account.id).status, "queued");
|
||||
assert.equal(service.inspect().matches.size, 0);
|
||||
service.close();
|
||||
});
|
||||
|
||||
test("Ranked PvP permanently fills every open slot after thirty seconds", () => {
|
||||
let clock = 1000;
|
||||
const service = createArenaService({ database: fakeDatabase(), now: () => clock, random: () => 0, autoPump: false });
|
||||
const account = { id: "account", username: "ArenaPlayer" };
|
||||
assert.equal(service.enqueue(account, { characterId: "priest", loadoutSlot: 1, bracket: "3v3", mode: "ranked-pvp", ruleset: "wow" }).status, "queued");
|
||||
clock += 30_000;
|
||||
service.pump();
|
||||
const lobby = [...service.inspect().lobbies.values()][0];
|
||||
assert.equal(lobby.participants.length, 6);
|
||||
assert.equal(lobby.participants.filter((participant) => participant.isBot).length, 5);
|
||||
service.close();
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
ArenaAuthoritativeSnapshot,
|
||||
ArenaCombatIntent,
|
||||
ArenaResult,
|
||||
ArenaRole,
|
||||
ArenaStageManifest,
|
||||
} from "../src/game/arenaTypes";
|
||||
import type { ArenaBracket, ArenaMode, ArenaRuleset } from "../src/game/arenaRules";
|
||||
|
||||
export interface ArenaSimulationParticipant {
|
||||
actorId: string;
|
||||
accountId?: string | null;
|
||||
characterId?: string | null;
|
||||
name: string;
|
||||
team: 0 | 1;
|
||||
role: ArenaRole;
|
||||
isBot: boolean;
|
||||
disconnected?: boolean;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export interface ArenaSimulationOptions {
|
||||
id: string;
|
||||
stage: ArenaStageManifest;
|
||||
bracket: ArenaBracket;
|
||||
mode: ArenaMode;
|
||||
ruleset: ArenaRuleset;
|
||||
participants: readonly ArenaSimulationParticipant[];
|
||||
random?: () => number;
|
||||
startedAt?: number;
|
||||
onSnapshot?: (snapshot: ArenaAuthoritativeSnapshot) => void;
|
||||
onResult?: (result: Omit<ArenaResult, "ratingUpdates">) => void;
|
||||
}
|
||||
|
||||
export declare const ARENA_TICK_RATE: 20;
|
||||
export declare const ARENA_SNAPSHOT_RATE: 10;
|
||||
export declare const ARENA_TICK_MS: 50;
|
||||
|
||||
export declare class ArenaSimulation {
|
||||
constructor(options: ArenaSimulationOptions);
|
||||
start(): this;
|
||||
resetPreparationClock(startedAt?: number): boolean;
|
||||
stop(): void;
|
||||
step(): void;
|
||||
snapshot(): ArenaAuthoritativeSnapshot;
|
||||
receiveIntent(actorId: string, intent: ArenaCombatIntent): { accepted: boolean; reason?: string };
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import { arenaAiProfile, arenaDampening, arenaDrDuration, applyArenaHealing, shouldArenaBotChooseCorrectly } from "./arena-rules.mjs";
|
||||
|
||||
export const ARENA_TICK_RATE = 20;
|
||||
export const ARENA_SNAPSHOT_RATE = 10;
|
||||
export const ARENA_TICK_MS = 1000 / ARENA_TICK_RATE;
|
||||
|
||||
const ABILITIES = Object.freeze({
|
||||
attack: Object.freeze({ cooldownMs: 1500, range: 5, damage: 40 }),
|
||||
strike: Object.freeze({ cooldownMs: 6000, range: 5, damage: 120 }),
|
||||
"ranged-attack": Object.freeze({ cooldownMs: 2200, range: 30, damage: 52 }),
|
||||
heal: Object.freeze({ cooldownMs: 0, range: 40, healing: 150, castMs: 1500, resource: 12 }),
|
||||
interrupt: Object.freeze({ cooldownMs: 15000, range: 8, lockoutMs: 4000 }),
|
||||
control: Object.freeze({ cooldownMs: 30000, range: 25, controlMs: 6000, drCategory: "incapacitate" }),
|
||||
defensive: Object.freeze({ cooldownMs: 90000, range: 0, defensiveMs: 8000 }),
|
||||
});
|
||||
|
||||
function distance2d(left, right) {
|
||||
return Math.hypot(left.position[0] - right.position[0], left.position[2] - right.position[2]);
|
||||
}
|
||||
|
||||
function segmentIntersectsBox(from, to, object) {
|
||||
if (object.shape !== "box" && object.shape !== "cylinder") return false;
|
||||
const radiusX = object.size[0] / 2;
|
||||
const radiusZ = object.size[2] / 2;
|
||||
const dx = to[0] - from[0];
|
||||
const dz = to[2] - from[2];
|
||||
const lengthSquared = dx * dx + dz * dz;
|
||||
const amount = lengthSquared > 0
|
||||
? Math.max(0, Math.min(1, ((object.position[0] - from[0]) * dx + (object.position[2] - from[2]) * dz) / lengthSquared))
|
||||
: 0;
|
||||
const nearestX = from[0] + dx * amount;
|
||||
const nearestZ = from[2] + dz * amount;
|
||||
return Math.abs(nearestX - object.position[0]) <= radiusX
|
||||
&& Math.abs(nearestZ - object.position[2]) <= radiusZ;
|
||||
}
|
||||
|
||||
function newActor(participant, spawn, teamIndex, arenaCenter) {
|
||||
const offset = participant.role === "healer" ? -2 : teamIndex * 2;
|
||||
const facing = Math.atan2(arenaCenter[0] - spawn[0], arenaCenter[2] - spawn[2]);
|
||||
return {
|
||||
id: participant.actorId,
|
||||
accountId: participant.accountId ?? null,
|
||||
characterId: participant.characterId ?? null,
|
||||
name: participant.name,
|
||||
team: participant.team,
|
||||
role: participant.role,
|
||||
isBot: Boolean(participant.isBot),
|
||||
disconnected: Boolean(participant.disconnected),
|
||||
alive: true,
|
||||
position: [spawn[0], spawn[1], spawn[2] + offset],
|
||||
facing,
|
||||
movement: [0, 0],
|
||||
health: 1000,
|
||||
maxHealth: 1000,
|
||||
resource: 100,
|
||||
maxResource: 100,
|
||||
targetActorId: null,
|
||||
cooldowns: new Map(),
|
||||
effects: new Map(),
|
||||
dr: new Map(),
|
||||
casting: null,
|
||||
lockoutUntil: 0,
|
||||
lastSequence: 0,
|
||||
nextBotDecisionAt: 0,
|
||||
rating: Number(participant.rating ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function arenaDynamicObjectState(stage, activeElapsedMs) {
|
||||
return stage.dynamicObjects.map((definition) => {
|
||||
if (definition.kind === "starting-elevators") {
|
||||
const delay = definition.initialDelayMs ?? 0;
|
||||
const travelMs = definition.activeMs || 1500;
|
||||
if (activeElapsedMs < delay) return { id: definition.id, state: "raised", progress: 0, collisionEnabled: true };
|
||||
if (activeElapsedMs < delay + travelMs) {
|
||||
return { id: definition.id, state: "moving", progress: (activeElapsedMs - delay) / travelMs, collisionEnabled: true };
|
||||
}
|
||||
return { id: definition.id, state: "open", progress: 1, collisionEnabled: false };
|
||||
}
|
||||
if (definition.kind === "waterfall") {
|
||||
const start = definition.runtimeStartMs ?? definition.initialDelayRangeMs?.[0] ?? 30000;
|
||||
const cycle = definition.activeMs + definition.cycleMs;
|
||||
const time = activeElapsedMs - start;
|
||||
if (time < -definition.warningMs) return { id: definition.id, state: "open", progress: 0, collisionEnabled: false };
|
||||
if (time < 0) return { id: definition.id, state: "warning", progress: (definition.warningMs + time) / definition.warningMs, collisionEnabled: false };
|
||||
const within = time % Math.max(1, cycle);
|
||||
if (within >= definition.activeMs) {
|
||||
const remaining = cycle - within;
|
||||
const warning = remaining <= definition.warningMs;
|
||||
return { id: definition.id, state: warning ? "warning" : "open", progress: warning ? 1 - remaining / definition.warningMs : 0, collisionEnabled: false };
|
||||
}
|
||||
return { id: definition.id, state: "active", progress: within / definition.activeMs, collisionEnabled: true };
|
||||
}
|
||||
if (definition.kind === "pipe-knockback") {
|
||||
const warningStart = Math.max(0, (definition.initialDelayMs ?? 5000) - definition.warningMs);
|
||||
if (activeElapsedMs < warningStart) return { id: definition.id, state: "closed", progress: 0, collisionEnabled: true };
|
||||
if (activeElapsedMs < (definition.initialDelayMs ?? 5000)) return { id: definition.id, state: "warning", progress: 1, collisionEnabled: true };
|
||||
if (activeElapsedMs < (definition.initialDelayMs ?? 5000) + definition.activeMs) return { id: definition.id, state: "active", progress: 1, collisionEnabled: false };
|
||||
return { id: definition.id, state: "closed", progress: 0, collisionEnabled: true };
|
||||
}
|
||||
const delay = definition.initialDelayMs ?? 0;
|
||||
const time = activeElapsedMs - delay;
|
||||
const raised = time >= 0 && (definition.cycleMs <= 0 || time % definition.cycleMs < definition.activeMs);
|
||||
return {
|
||||
id: definition.id,
|
||||
state: definition.kind === "fire-cycle" ? (raised ? "active" : "closed") : (raised ? "raised" : "lowered"),
|
||||
progress: raised ? 1 : 0,
|
||||
collisionEnabled: definition.kind === "moving-pillars" ? raised : false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export class ArenaSimulation {
|
||||
constructor(options) {
|
||||
this.id = options.id;
|
||||
this.stage = {
|
||||
...options.stage,
|
||||
dynamicObjects: options.stage.dynamicObjects.map((object) => ({ ...object })),
|
||||
lineOfSight: options.stage.lineOfSight.map((object) => ({ ...object })),
|
||||
};
|
||||
this.bracket = options.bracket;
|
||||
this.mode = options.mode;
|
||||
this.ruleset = options.ruleset;
|
||||
this.random = options.random ?? Math.random;
|
||||
this.onSnapshot = options.onSnapshot ?? (() => {});
|
||||
this.onResult = options.onResult ?? (() => {});
|
||||
this.phase = "preparation";
|
||||
this.tickNumber = 0;
|
||||
this.startedAt = Number(options.startedAt ?? Date.now());
|
||||
this.phaseEndsAt = this.startedAt + 10_000;
|
||||
this.activeStartedAt = null;
|
||||
this.finished = false;
|
||||
this.interval = null;
|
||||
this.actors = new Map();
|
||||
this.lastMechanicPulse = new Map();
|
||||
const teamCounts = [0, 0];
|
||||
const arenaCenter = [
|
||||
(this.stage.bounds.min[0] + this.stage.bounds.max[0]) / 2,
|
||||
(this.stage.bounds.min[2] + this.stage.bounds.max[2]) / 2,
|
||||
];
|
||||
for (const participant of options.participants) {
|
||||
const team = participant.team;
|
||||
const actor = newActor(participant, this.stage.teamSpawns[team], teamCounts[team]++, arenaCenter);
|
||||
this.actors.set(actor.id, actor);
|
||||
}
|
||||
for (const object of this.stage.dynamicObjects) {
|
||||
if (object.initialDelayRangeMs) {
|
||||
const [min, max] = object.initialDelayRangeMs;
|
||||
object.runtimeStartMs = min + this.random() * Math.max(0, max - min);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.interval && !this.finished) this.interval = setInterval(() => this.step(Date.now()), ARENA_TICK_MS);
|
||||
return this;
|
||||
}
|
||||
|
||||
resetPreparationClock(startedAt = Date.now()) {
|
||||
if (this.interval || this.tickNumber !== 0 || this.phase !== "preparation" || this.finished) return false;
|
||||
this.startedAt = Number(startedAt);
|
||||
this.phaseEndsAt = this.startedAt + 10_000;
|
||||
return true;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.interval) clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
actor(actorId) {
|
||||
return this.actors.get(actorId) ?? null;
|
||||
}
|
||||
|
||||
hasLineOfSight(left, right, dynamicStates = null) {
|
||||
const states = dynamicStates ?? new Map(this.dynamicObjects().map((state) => [state.id, state]));
|
||||
return !this.stage.lineOfSight.some((object) => {
|
||||
if (object.dynamicObjectId && !states.get(object.dynamicObjectId)?.collisionEnabled) return false;
|
||||
return segmentIntersectsBox(left.position, right.position, object);
|
||||
});
|
||||
}
|
||||
|
||||
receiveIntent(actorId, intent) {
|
||||
const actor = this.actor(actorId);
|
||||
if (!actor || actor.isBot || actor.disconnected || !actor.alive || this.phase !== "active") return { accepted: false, reason: "actor-unavailable" };
|
||||
const sequence = Math.trunc(Number(intent?.sequence));
|
||||
if (!Number.isSafeInteger(sequence) || sequence <= actor.lastSequence) return { accepted: false, reason: "stale-sequence" };
|
||||
actor.lastSequence = sequence;
|
||||
if (intent.type === "movement") {
|
||||
const x = Math.max(-1, Math.min(1, Number(intent.movement?.x) || 0));
|
||||
const z = Math.max(-1, Math.min(1, Number(intent.movement?.z) || 0));
|
||||
const length = Math.hypot(x, z);
|
||||
actor.movement = length > 1 ? [x / length, z / length] : [x, z];
|
||||
return { accepted: true };
|
||||
}
|
||||
if (intent.type === "facing") {
|
||||
actor.facing = Number.isFinite(Number(intent.facing)) ? Number(intent.facing) : actor.facing;
|
||||
return { accepted: true };
|
||||
}
|
||||
if (intent.type === "target") {
|
||||
actor.targetActorId = this.actors.has(intent.targetActorId) ? intent.targetActorId : null;
|
||||
return { accepted: true };
|
||||
}
|
||||
if (intent.type === "ability") return this.useAbility(actor, String(intent.abilityId ?? ""), intent.targetActorId);
|
||||
return { accepted: false, reason: "unknown-intent" };
|
||||
}
|
||||
|
||||
useAbility(actor, abilityId, targetActorId = null) {
|
||||
const now = this.startedAt + this.tickNumber * ARENA_TICK_MS;
|
||||
const ability = ABILITIES[abilityId];
|
||||
if (!ability || !actor.alive || now < (actor.cooldowns.get(abilityId) ?? 0) || now < actor.lockoutUntil) return { accepted: false, reason: "ability-unavailable" };
|
||||
if (actor.effects.get("control")?.endsAt > now) return { accepted: false, reason: "controlled" };
|
||||
const target = this.actor(targetActorId ?? actor.targetActorId) ?? (abilityId === "defensive" ? actor : null);
|
||||
if (!target || !target.alive) return { accepted: false, reason: "invalid-target" };
|
||||
const friendly = target.team === actor.team;
|
||||
if ((ability.healing && !friendly) || (!ability.healing && abilityId !== "defensive" && friendly)) return { accepted: false, reason: "invalid-target" };
|
||||
if (ability.range > 0 && distance2d(actor, target) > ability.range) return { accepted: false, reason: "out-of-range" };
|
||||
if (target !== actor && !this.hasLineOfSight(actor, target)) return { accepted: false, reason: "line-of-sight" };
|
||||
if (ability.resource && actor.resource < ability.resource) return { accepted: false, reason: "resource" };
|
||||
if (ability.castMs) {
|
||||
actor.resource -= ability.resource ?? 0;
|
||||
actor.casting = { abilityId, targetActorId: target.id, completesAt: now + ability.castMs };
|
||||
return { accepted: true, casting: true };
|
||||
}
|
||||
actor.cooldowns.set(abilityId, now + ability.cooldownMs);
|
||||
if (ability.damage) this.damage(target, ability.damage);
|
||||
if (ability.lockoutMs) {
|
||||
if (!target.casting) return { accepted: true, interrupted: false };
|
||||
target.casting = null;
|
||||
target.lockoutUntil = now + ability.lockoutMs;
|
||||
}
|
||||
if (ability.controlMs) {
|
||||
const state = target.dr.get(ability.drCategory) ?? { applications: 0, endedAt: -Infinity };
|
||||
const dr = arenaDrDuration(ability.controlMs, state.applications, now - state.endedAt);
|
||||
if (dr.durationMs > 0) target.effects.set("control", { id: "control", endsAt: now + dr.durationMs, drCategory: ability.drCategory });
|
||||
target.dr.set(ability.drCategory, { applications: dr.applications, endedAt: now + dr.durationMs });
|
||||
}
|
||||
if (ability.defensiveMs) actor.effects.set("defensive", { id: "defensive", endsAt: now + ability.defensiveMs });
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
damage(target, amount) {
|
||||
const now = this.startedAt + this.tickNumber * ARENA_TICK_MS;
|
||||
const reduction = target.effects.get("defensive")?.endsAt > now ? 0.5 : 0;
|
||||
target.health = Math.max(0, target.health - Math.round(amount * (1 - reduction)));
|
||||
if (target.health <= 0) {
|
||||
target.alive = false;
|
||||
target.movement = [0, 0];
|
||||
target.casting = null;
|
||||
}
|
||||
}
|
||||
|
||||
dynamicObjects() {
|
||||
const elapsed = this.activeStartedAt == null ? -1 : this.tickNumber * ARENA_TICK_MS - (this.activeStartedAt - this.startedAt);
|
||||
return arenaDynamicObjectState(this.stage, elapsed);
|
||||
}
|
||||
|
||||
step() {
|
||||
if (this.finished) return;
|
||||
this.tickNumber += 1;
|
||||
const simulationNow = this.startedAt + this.tickNumber * ARENA_TICK_MS;
|
||||
if (this.phase === "preparation" && simulationNow >= this.phaseEndsAt) {
|
||||
this.phase = "active";
|
||||
this.activeStartedAt = this.phaseEndsAt;
|
||||
this.phaseEndsAt = null;
|
||||
}
|
||||
if (this.phase === "active") {
|
||||
this.updateActors(simulationNow);
|
||||
this.applyMechanics(simulationNow);
|
||||
this.checkResult();
|
||||
}
|
||||
if (this.tickNumber % (ARENA_TICK_RATE / ARENA_SNAPSHOT_RATE) === 0) this.onSnapshot(this.snapshot());
|
||||
}
|
||||
|
||||
updateActors(now) {
|
||||
const deltaSeconds = ARENA_TICK_MS / 1000;
|
||||
for (const actor of this.actors.values()) {
|
||||
for (const [effectId, effect] of actor.effects) if (effect.endsAt <= now) actor.effects.delete(effectId);
|
||||
for (const [abilityId, endsAt] of actor.cooldowns) if (endsAt <= now) actor.cooldowns.delete(abilityId);
|
||||
actor.resource = Math.min(actor.maxResource, actor.resource + 3 * deltaSeconds);
|
||||
if (!actor.alive) continue;
|
||||
if (actor.casting?.completesAt <= now) {
|
||||
const cast = actor.casting;
|
||||
actor.casting = null;
|
||||
if (cast.abilityId === "heal") {
|
||||
const target = this.actor(cast.targetActorId);
|
||||
if (target?.alive && target.team === actor.team && distance2d(actor, target) <= ABILITIES.heal.range && this.hasLineOfSight(actor, target)) {
|
||||
const elapsed = Math.max(0, now - this.activeStartedAt);
|
||||
target.health = Math.min(target.maxHealth, target.health + applyArenaHealing(ABILITIES.heal.healing, elapsed));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!actor.casting && !(actor.effects.get("control")?.endsAt > now)) this.moveActor(actor, deltaSeconds);
|
||||
if (actor.isBot && now >= actor.nextBotDecisionAt) this.botDecision(actor, now);
|
||||
}
|
||||
}
|
||||
|
||||
moveActor(actor, deltaSeconds) {
|
||||
const speed = 7;
|
||||
const nextX = actor.position[0] + actor.movement[0] * speed * deltaSeconds;
|
||||
const nextZ = actor.position[2] + actor.movement[1] * speed * deltaSeconds;
|
||||
actor.position[0] = Math.max(this.stage.bounds.min[0], Math.min(this.stage.bounds.max[0], nextX));
|
||||
actor.position[2] = Math.max(this.stage.bounds.min[2], Math.min(this.stage.bounds.max[2], nextZ));
|
||||
}
|
||||
|
||||
botDecision(actor, now) {
|
||||
const rating = actor.rating ?? 0;
|
||||
const profile = arenaAiProfile(rating);
|
||||
actor.nextBotDecisionAt = now + (profile.reactionMinSeconds + this.random() * (profile.reactionMaxSeconds - profile.reactionMinSeconds)) * 1000;
|
||||
const allies = [...this.actors.values()].filter((entry) => entry.team === actor.team && entry.alive);
|
||||
const enemies = [...this.actors.values()].filter((entry) => entry.team !== actor.team && entry.alive && this.hasLineOfSight(actor, entry));
|
||||
const target = enemies.sort((left, right) => left.health - right.health)[0];
|
||||
if (!target) {
|
||||
actor.movement = [0, 0];
|
||||
return;
|
||||
}
|
||||
actor.targetActorId = target.id;
|
||||
const lowAlly = allies.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0];
|
||||
if (actor.role === "healer" && lowAlly.health < lowAlly.maxHealth * 0.86) {
|
||||
actor.movement = [0, 0];
|
||||
this.useAbility(actor, "heal", lowAlly.id);
|
||||
return;
|
||||
}
|
||||
const castingHealer = enemies.find((entry) => entry.role === "healer" && entry.casting);
|
||||
if (castingHealer && shouldArenaBotChooseCorrectly(profile.healerInterruptChance, profile.minimumErrorChance, this.random)) {
|
||||
this.useAbility(actor, "interrupt", castingHealer.id);
|
||||
return;
|
||||
}
|
||||
if (actor.health < actor.maxHealth * 0.4 && shouldArenaBotChooseCorrectly(profile.correctDefensiveChance, profile.minimumErrorChance, this.random)) {
|
||||
this.useAbility(actor, "defensive", actor.id);
|
||||
return;
|
||||
}
|
||||
if (shouldArenaBotChooseCorrectly(profile.coordinatedCcChance * 0.25, profile.minimumErrorChance, this.random)) {
|
||||
const healer = enemies.find((entry) => entry.role === "healer");
|
||||
if (healer && this.useAbility(actor, "control", healer.id).accepted) return;
|
||||
}
|
||||
const distance = distance2d(actor, target);
|
||||
if (distance > 4.5) {
|
||||
const dx = target.position[0] - actor.position[0];
|
||||
const dz = target.position[2] - actor.position[2];
|
||||
const length = Math.max(0.001, Math.hypot(dx, dz));
|
||||
actor.movement = [dx / length, dz / length];
|
||||
if (distance <= 30) this.useAbility(actor, "ranged-attack", target.id);
|
||||
} else {
|
||||
actor.movement = [0, 0];
|
||||
this.useAbility(actor, "strike", target.id);
|
||||
this.useAbility(actor, "attack", target.id);
|
||||
}
|
||||
}
|
||||
|
||||
applyMechanics(now) {
|
||||
const activeElapsed = now - this.activeStartedAt;
|
||||
const states = new Map(this.dynamicObjects().map((state) => [state.id, state]));
|
||||
if (this.stage.id === "dalaran-sewers") {
|
||||
for (const id of ["pipes", "waterfall"]) {
|
||||
if (states.get(id)?.state !== "active") continue;
|
||||
const pulseMs = id === "waterfall" ? 1500 : 750;
|
||||
const previous = this.lastMechanicPulse.get(id) ?? -Infinity;
|
||||
if (activeElapsed - previous < pulseMs) continue;
|
||||
this.lastMechanicPulse.set(id, activeElapsed);
|
||||
for (const actor of this.actors.values()) {
|
||||
if (!actor.alive || Math.abs(actor.position[0]) > 6 || Math.abs(actor.position[2]) > 14) continue;
|
||||
const direction = actor.position[2] >= 0 ? 1 : -1;
|
||||
actor.position[2] += direction * (id === "waterfall" ? 4 : 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.stage.id === "ring-of-valor" && states.get("fire")?.state === "active") {
|
||||
const previous = this.lastMechanicPulse.get("fire") ?? -Infinity;
|
||||
if (activeElapsed - previous >= 1000) {
|
||||
this.lastMechanicPulse.set("fire", activeElapsed);
|
||||
for (const actor of this.actors.values()) {
|
||||
if (actor.alive && Math.abs(actor.position[2]) > 25) this.damage(actor, 35);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkResult() {
|
||||
const alive = [0, 1].map((team) => [...this.actors.values()].some((actor) => actor.team === team && actor.alive));
|
||||
if (alive[0] && alive[1]) return;
|
||||
const winnerTeam = alive[0] === alive[1] ? null : alive[0] ? 0 : 1;
|
||||
this.finish(winnerTeam, winnerTeam == null ? "simultaneous-elimination" : "elimination");
|
||||
}
|
||||
|
||||
finish(winnerTeam, reason) {
|
||||
if (this.finished) return;
|
||||
this.finished = true;
|
||||
this.phase = reason === "void" ? "void" : "finished";
|
||||
this.stop();
|
||||
this.onSnapshot(this.snapshot());
|
||||
this.onResult({ matchId: this.id, winnerTeam, draw: winnerTeam == null, reason });
|
||||
}
|
||||
|
||||
disconnect(actorId) {
|
||||
const actor = this.actor(actorId);
|
||||
if (!actor) return false;
|
||||
actor.disconnected = true;
|
||||
actor.isBot = true;
|
||||
actor.nextBotDecisionAt = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
const now = this.startedAt + this.tickNumber * ARENA_TICK_MS;
|
||||
const elapsed = this.activeStartedAt == null ? 0 : Math.max(0, now - this.activeStartedAt);
|
||||
return {
|
||||
matchId: this.id,
|
||||
tick: this.tickNumber,
|
||||
serverTime: now,
|
||||
phase: this.phase,
|
||||
phaseEndsAt: this.phaseEndsAt,
|
||||
stageId: this.stage.id,
|
||||
bracket: this.bracket,
|
||||
mode: this.mode,
|
||||
ruleset: this.ruleset,
|
||||
dampening: this.phase === "active" || this.phase === "finished" ? arenaDampening(elapsed) : 0.1,
|
||||
actors: [...this.actors.values()].map((actor) => ({
|
||||
id: actor.id, name: actor.name, team: actor.team, role: actor.role,
|
||||
isBot: actor.isBot, disconnected: actor.disconnected, alive: actor.alive,
|
||||
position: [...actor.position], facing: actor.facing,
|
||||
health: actor.health, maxHealth: actor.maxHealth,
|
||||
resource: Math.round(actor.resource), maxResource: actor.maxResource,
|
||||
targetActorId: actor.targetActorId,
|
||||
cooldowns: Object.fromEntries([...actor.cooldowns].map(([id, endsAt]) => [id, Math.max(0, endsAt - now)])),
|
||||
effects: [
|
||||
...[...actor.effects.values()].map((effect) => ({ id: effect.id, remainingMs: Math.max(0, effect.endsAt - now) })),
|
||||
...(actor.casting ? [{ id: `casting:${actor.casting.abilityId}`, remainingMs: Math.max(0, actor.casting.completesAt - now) }] : []),
|
||||
],
|
||||
})),
|
||||
dynamicObjects: this.dynamicObjects(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { ARENA_STAGES } from "./arena-stages.mjs";
|
||||
import { ArenaSimulation, ARENA_TICK_RATE, arenaDynamicObjectState } from "./arena-simulation.mjs";
|
||||
|
||||
function participants() {
|
||||
return [
|
||||
{ actorId: "left-healer", name: "Left Healer", team: 0, role: "healer", isBot: false, rating: 1200 },
|
||||
{ actorId: "left-damage", name: "Left Damage", team: 0, role: "damage", isBot: true, rating: 1200 },
|
||||
{ actorId: "right-healer", name: "Right Healer", team: 1, role: "healer", isBot: true, rating: 1200 },
|
||||
{ actorId: "right-damage", name: "Right Damage", team: 1, role: "damage", isBot: true, rating: 1200 },
|
||||
];
|
||||
}
|
||||
|
||||
test("authoritative simulation seals ten seconds, runs 20 Hz, and rejects duplicate packets", () => {
|
||||
const stage = ARENA_STAGES.find((entry) => entry.id === "warnorken-arena");
|
||||
const simulation = new ArenaSimulation({ id: "sim", stage, bracket: "2v2", mode: "rated-ai", ruleset: "wow", participants: participants(), random: () => 0 });
|
||||
for (let index = 0; index < ARENA_TICK_RATE * 10 - 1; index += 1) simulation.step();
|
||||
assert.equal(simulation.snapshot().phase, "preparation");
|
||||
simulation.step();
|
||||
assert.equal(simulation.snapshot().phase, "active");
|
||||
assert.equal(simulation.receiveIntent("left-healer", { sequence: 1, type: "movement", movement: { x: 1, z: 0 } }).accepted, true);
|
||||
assert.equal(simulation.receiveIntent("left-healer", { sequence: 1, type: "movement", movement: { x: 1, z: 0 } }).reason, "stale-sequence");
|
||||
const before = simulation.actor("left-healer").position[0];
|
||||
simulation.step();
|
||||
assert.ok(simulation.actor("left-healer").position[0] > before);
|
||||
});
|
||||
|
||||
test("a deferred practice start receives a fresh preparation clock", () => {
|
||||
const stage = ARENA_STAGES.find((entry) => entry.id === "warnorken-arena");
|
||||
const simulation = new ArenaSimulation({ id: "deferred", stage, bracket: "2v2", mode: "practice", ruleset: "wow", participants: participants(), startedAt: 1_000 });
|
||||
assert.equal(simulation.resetPreparationClock(20_000), true);
|
||||
assert.equal(simulation.snapshot().serverTime, 20_000);
|
||||
assert.equal(simulation.snapshot().phaseEndsAt, 30_000);
|
||||
});
|
||||
|
||||
test("Dalaran and Ring of Valor mechanics synchronize collision state", () => {
|
||||
const dalaran = ARENA_STAGES.find((entry) => entry.id === "dalaran-sewers");
|
||||
const warning = arenaDynamicObjectState(dalaran, 27_000).find((entry) => entry.id === "waterfall");
|
||||
const active = arenaDynamicObjectState(dalaran, 31_000).find((entry) => entry.id === "waterfall");
|
||||
assert.equal(warning.state, "warning");
|
||||
assert.equal(active.state, "active");
|
||||
assert.equal(active.collisionEnabled, true);
|
||||
const ring = ARENA_STAGES.find((entry) => entry.id === "ring-of-valor");
|
||||
assert.equal(arenaDynamicObjectState(ring, 20_000).find((entry) => entry.id === "elevators").state, "raised");
|
||||
assert.equal(arenaDynamicObjectState(ring, 21_000).find((entry) => entry.id === "elevators").state, "moving");
|
||||
assert.equal(arenaDynamicObjectState(ring, 22_500).find((entry) => entry.id === "elevators").state, "open");
|
||||
assert.equal(arenaDynamicObjectState(ring, 24_000).find((entry) => entry.id === "pillars-north").state, "lowered");
|
||||
assert.equal(arenaDynamicObjectState(ring, 26_000).find((entry) => entry.id === "pillars-north").state, "raised");
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
const HEALER_SPECIALIZATIONS = new Set([
|
||||
"paladin:holy", "priest:discipline", "priest:holy", "shaman:restoration", "druid:restoration",
|
||||
"witch-doctor:brewing", "bloodmage:fleshweaver", "chronomancer:time", "cultist:heretic",
|
||||
"starcaller:moon-priest", "sun-cleric:blessings", "tinker:invention", "venomancer:vizier",
|
||||
"primalist:life",
|
||||
]);
|
||||
|
||||
const TANK_SPECIALIZATIONS = new Set([
|
||||
"warrior:protection", "paladin:protection", "death-knight:blood", "druid:feral-bear",
|
||||
"felsworn:infernal", "witch-hunter:black-knight", "knight-of-xoroth:defiance", "guardian:vanguard",
|
||||
"templar:oathkeeper", "bloodmage:eternal", "cultist:dreadnought", "starcaller:moon-guard",
|
||||
"sun-cleric:valkyrie", "venomancer:fortitude", "reaper:domination", "primalist:mountain-king",
|
||||
"runemaster:riftblade",
|
||||
]);
|
||||
|
||||
export function arenaRoleForSpecialization(classId, specializationId, ruleset) {
|
||||
const normalizedClass = String(classId ?? "").trim().toLowerCase();
|
||||
const normalizedSpec = String(specializationId ?? "").trim().toLowerCase();
|
||||
if (!normalizedClass || !normalizedSpec || !normalizedSpec.startsWith(`${normalizedClass}:`)) return null;
|
||||
if (ruleset === "rom") {
|
||||
if (normalizedSpec === `${normalizedClass}:role-healer`) return "healer";
|
||||
if (normalizedSpec === `${normalizedClass}:role-damage`) return "damage";
|
||||
return normalizedSpec.endsWith(":role-tank") ? "tank" : null;
|
||||
}
|
||||
if (TANK_SPECIALIZATIONS.has(normalizedSpec)) return "tank";
|
||||
return HEALER_SPECIALIZATIONS.has(normalizedSpec) ? "healer" : "damage";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const source = JSON.parse(readFileSync(fileURLToPath(new URL("../shared/arena-stages.json", import.meta.url)), "utf8"));
|
||||
|
||||
function normalizeStage(stage) {
|
||||
return Object.freeze({
|
||||
...stage,
|
||||
assets: Object.freeze([
|
||||
{ id: "visual", url: `${stage.assetBaseUrl}/visual.glb` },
|
||||
{ id: "collision", url: `${stage.assetBaseUrl}/collision.glb` },
|
||||
{ id: "navigation", url: `${stage.assetBaseUrl}/navigation.glb` },
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
export const ARENA_STAGES = Object.freeze(source.stages.map(normalizeStage));
|
||||
export const VALIDATED_ARENA_STAGES = Object.freeze(ARENA_STAGES.filter((stage) => (
|
||||
stage.validation.traversal
|
||||
&& stage.validation.collision
|
||||
&& stage.validation.spawnSafety
|
||||
&& stage.validation.lineOfSight
|
||||
&& stage.validation.performance
|
||||
)));
|
||||
|
||||
export function arenaStageById(stageId) {
|
||||
return ARENA_STAGES.find((stage) => stage.id === stageId) ?? null;
|
||||
}
|
||||
|
||||
export function chooseArenaStage({ random = Math.random, previousStageId = null, allowUnvalidated = false } = {}) {
|
||||
const pool = allowUnvalidated ? ARENA_STAGES : VALIDATED_ARENA_STAGES;
|
||||
if (!pool.length) throw new Error("No validated arena stages are available.");
|
||||
const alternatives = pool.length > 1 ? pool.filter((stage) => stage.id !== previousStageId) : pool;
|
||||
return alternatives[Math.floor(random() * alternatives.length)];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { ARENA_STAGES, VALIDATED_ARENA_STAGES, chooseArenaStage } from "./arena-stages.mjs";
|
||||
|
||||
test("the rated pool contains all eight validated arenas", () => {
|
||||
assert.equal(ARENA_STAGES.length, 8);
|
||||
assert.deepEqual(VALIDATED_ARENA_STAGES.map((stage) => stage.id), ARENA_STAGES.map((stage) => stage.id));
|
||||
});
|
||||
|
||||
test("rated selection covers the pool uniformly and avoids the previous arena", () => {
|
||||
const choices = VALIDATED_ARENA_STAGES.map((_, index, pool) => (
|
||||
chooseArenaStage({ random: () => (index + 0.25) / pool.length })
|
||||
));
|
||||
assert.deepEqual(choices.map((stage) => stage.id), VALIDATED_ARENA_STAGES.map((stage) => stage.id));
|
||||
|
||||
const previousStageId = VALIDATED_ARENA_STAGES[0].id;
|
||||
for (let index = 0; index < VALIDATED_ARENA_STAGES.length - 1; index += 1) {
|
||||
const selected = chooseArenaStage({
|
||||
previousStageId,
|
||||
random: () => (index + 0.25) / (VALIDATED_ARENA_STAGES.length - 1),
|
||||
});
|
||||
assert.notEqual(selected.id, previousStageId);
|
||||
}
|
||||
});
|
||||
Loaded 100 of 270 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user