forked from phenom/healer-man
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6f2375145 | ||
|
|
699fc858c9 | ||
|
|
d9bebb4e81 | ||
|
|
eaddad4134 | ||
|
|
fc8a7a722a | ||
|
|
b57d2b2839 | ||
|
|
f49e54f35f |
No files matched your search
@@ -0,0 +1,110 @@
|
|||||||
|
# Encounter authoring and fidelity
|
||||||
|
|
||||||
|
Healer Man keeps boss abilities, dungeon orchestration, presentation, and source
|
||||||
|
evidence separate. Boss abilities are modular `EnemyAttackDefinition` records;
|
||||||
|
dungeon-wide doors, waves, checkpoints, and transitions are modular
|
||||||
|
`DungeonEncounterDefinition` records. Neither runtime stores Three.js or React
|
||||||
|
objects, so the same definitions can later be selected or randomized without
|
||||||
|
rewriting a boss component.
|
||||||
|
|
||||||
|
## Adding or editing a boss mechanic
|
||||||
|
|
||||||
|
1. Add or edit the attack in the boss entity's `combat.attacks` list. Imported
|
||||||
|
RuneWaker lists are generated under `src/game/generated`; authored WoW
|
||||||
|
exceptions live with their dungeon content.
|
||||||
|
2. Give the mechanic a stable `id`, timing, condition, target, effect, and source
|
||||||
|
record. Health mechanics use `self-health-percent`; timed mechanics use
|
||||||
|
`initialCooldownMs` and `repeatCooldownMs`.
|
||||||
|
3. Author a `telegraph` when the source supplies geometry. Harmful area attacks
|
||||||
|
without explicit geometry receive a conservative circle/cone/line inference,
|
||||||
|
but inferred geometry is not described as source-exact.
|
||||||
|
4. Add a guide override only when the generated explanation needs encounter
|
||||||
|
context that cannot be derived from the mechanic. The Boss Guide otherwise
|
||||||
|
reads the runtime definition directly, including its trigger, timer, effect,
|
||||||
|
telegraph, counterplay, fidelity, and test procedure.
|
||||||
|
5. Run the focused mechanic, telegraph-geometry, campaign, timeline, and guide
|
||||||
|
tests, then perform the browser playtest described in `PLAYTEST_NOTES.md`.
|
||||||
|
|
||||||
|
Telegraph geometry is shared by presentation and hit resolution. A circle,
|
||||||
|
frontal cone, or line shown on the floor is therefore the area the resolver
|
||||||
|
tests when the cast completes. `warningMs` is also enforced as a minimum cast
|
||||||
|
time, so a telegraph cannot appear after its damage resolves.
|
||||||
|
|
||||||
|
## Adding a dungeon encounter rule
|
||||||
|
|
||||||
|
Attach a `DungeonEncounterDefinition` to `DungeonDefinition.encounters`. Rules
|
||||||
|
combine a trigger, optional guards, and reusable actions. Available primitives
|
||||||
|
cover tick/elapsed time, signals, interactions, entity engagement/death/health,
|
||||||
|
all-members-dead checks, phases, flags, counters, timers, spawn groups, dynamic
|
||||||
|
world-object state, objective completion, checkpoints, party teleports, loot,
|
||||||
|
and signal chaining.
|
||||||
|
|
||||||
|
Definitions and snapshots are JSON-safe and stable-ID based. Actions are
|
||||||
|
idempotent by default. Loot actions must use `persistence: "run"`; this prevents
|
||||||
|
a wipe or checkpoint reload from duplicating a reward. Declare encounter-owned
|
||||||
|
mob waves in `DungeonDefinition.encounterSpawnGroups`. Declare doors, switches,
|
||||||
|
chests, keys, bridges, NPC prompts, and quest objects in
|
||||||
|
`DungeonDefinition.serverGameObjects`; their collision, sight blocking, visual,
|
||||||
|
prompt, and allowed interaction states follow the current encounter snapshot.
|
||||||
|
|
||||||
|
Online groups use party-leader authority. A follower interaction is sent as a
|
||||||
|
request, then revalidated by the leader against the follower's synchronized
|
||||||
|
position, range, line of sight, object state, and object ID before any rule is
|
||||||
|
dispatched. The leader publishes serializable encounter snapshots to followers.
|
||||||
|
|
||||||
|
## Save behavior
|
||||||
|
|
||||||
|
Character profiles store a versioned safe dungeon checkpoint. The checkpoint
|
||||||
|
projection retains only actions marked `checkpoint` or `run`; active timers,
|
||||||
|
temporary waves, and a half-finished pull are reset. Entering the same dungeon
|
||||||
|
hydrates that safe projection and its spawn after definitions are registered.
|
||||||
|
Loading a different dungeon starts at its normal entrance.
|
||||||
|
|
||||||
|
## Source fidelity and remaining review
|
||||||
|
|
||||||
|
The deterministic ledgers are:
|
||||||
|
|
||||||
|
- `scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md`
|
||||||
|
- `scripts/runewaker-pipeline/RUNEWAKER_GAMEPLAY_CONTENT_REPORT.md`
|
||||||
|
- `scripts/runewaker-pipeline/RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.md`
|
||||||
|
- `scripts/runewaker-pipeline/RUNEWAKER_NAVIGATION_GAP_REPORT.md`
|
||||||
|
|
||||||
|
Current encounter evidence covers 30 RuneWaker packages, 4,113 deferred source
|
||||||
|
rows, and 1,524 unique direct evidence records. Of those evidence records, 388
|
||||||
|
have exact executable spell mappings and 1,136 still need source review. Four
|
||||||
|
bosses have clearly labeled, playable Healer Man fallback kits because the
|
||||||
|
preserved data does not expose their original executable behavior. Eight
|
||||||
|
non-empty packages do not contain source-confirmed boss membership; two
|
||||||
|
authoritative variants are intentionally empty.
|
||||||
|
|
||||||
|
The upgraded WDB environment builder can assemble all referenced ROS resources
|
||||||
|
with full placement translation, quaternion, and nonuniform scale, source-hash
|
||||||
|
caching, shared meshes, collision chunking, and a separate dynamic-placement
|
||||||
|
manifest. The integration pass regenerated Raksha Temple and The Origin through
|
||||||
|
that full-WDB path; remaining shipping packages retain their last validated
|
||||||
|
build until they are individually regenerated. The current audit finds 107
|
||||||
|
filename-only dynamic candidates; they remain static and review-required
|
||||||
|
because filenames alone do not prove door/switch behavior.
|
||||||
|
|
||||||
|
Treasure Trove's 908-placement full-WDB staging export is reproducible and its
|
||||||
|
487,380-triangle visual/collision assembly, meshopt pass, and KTX2 compression
|
||||||
|
all succeed. Its 0.3-metre, 512-cell full-resolution Recast navigation bake did
|
||||||
|
not complete: the watershed run was stopped after nearly two CPU-hours when it
|
||||||
|
showed the smaller-tile retry signature, and the explicit monotone run was
|
||||||
|
stopped after more than four CPU-hours on its first pass. The fail-closed
|
||||||
|
pipeline therefore did not publish that staged package. The last validated
|
||||||
|
shipping package remains active. The recipe retains the monotone tiled policy
|
||||||
|
for a future offline bake or a reviewed spatial-partitioning optimization;
|
||||||
|
lowering navigation resolution was not silently substituted.
|
||||||
|
|
||||||
|
The route report contains 19 unreachable legs across eight dungeons, all from a
|
||||||
|
distance-from-entrance objective-order heuristic. Those legs are evidence that
|
||||||
|
objective order needs review, not proof that an off-mesh link should exist.
|
||||||
|
Links are added only when source geometry or a reviewed traversal justifies
|
||||||
|
them.
|
||||||
|
|
||||||
|
Run `npm run runewaker:audit:gameplay`, `npm run
|
||||||
|
runewaker:audit:environment-assembly`, and `npm run runewaker:audit:navigation`
|
||||||
|
after importing new source data. A report can be structurally green while its
|
||||||
|
content status remains `review-required`; this is intentional and prevents an
|
||||||
|
incomplete source inference from being presented as an original mechanic.
|
||||||
@@ -1654,3 +1654,77 @@ Vite's existing large-chunk advisory remains informational.
|
|||||||
corrected production build completed with 923 modules transformed. KTX2 and
|
corrected production build completed with 923 modules transformed. KTX2 and
|
||||||
environment-performance audits remained green, and the release-candidate
|
environment-performance audits remained green, and the release-candidate
|
||||||
check passed.
|
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.
|
||||||
|
|
||||||
|
# 2026-09-07 — modular encounter completion and boss guide
|
||||||
|
|
||||||
|
- Entered Wailing Caverns through the live offline roster and verified the 3D
|
||||||
|
dungeon, party HUD, minimap, and buff/debuff strips remained readable. The
|
||||||
|
encounter timeline occupies the same top-right HUD column beneath those
|
||||||
|
elements, keeping timed casts and pending health thresholds out of the
|
||||||
|
central playfield.
|
||||||
|
- Opened the new Boss Guide from the in-dungeon pause menu. The three-column
|
||||||
|
field manual listed both WoW and RuneWaker dungeons, their bosses, source
|
||||||
|
provenance, triggers, timings, target/effect descriptions, warning geometry,
|
||||||
|
counterplay, and a concrete test procedure for every listed mechanic.
|
||||||
|
- Selected Archaedas in Uldaman and confirmed all four authored mechanics:
|
||||||
|
Awaken Earthen Custodian every 10 seconds, the two-second 18-metre Ground
|
||||||
|
Tremor warning circle, six Earthen Guardians at 70% health, and two Vault
|
||||||
|
Warders at 40% health. Bosses with no sourced special attack are explicitly
|
||||||
|
labelled with zero special mechanics instead of receiving invented behavior.
|
||||||
|
- The encounter authoring layer now drives serializable timers, health gates,
|
||||||
|
guards, actions, summon groups, chained signals, stateful world objects,
|
||||||
|
teleports, rewards, checkpoints, and online leader-authoritative interaction
|
||||||
|
validation. Harmful area mechanics use the same geometry for both the world
|
||||||
|
warning and hit resolution.
|
||||||
|
- Browser review found no visible layout or interaction regressions. The only
|
||||||
|
previously observed browser message remains Rapier's third-party
|
||||||
|
initialization deprecation warning.
|
||||||
|
- Source limitations and the exact remaining RuneWaker evidence, navigation,
|
||||||
|
formula, loot, animation, and asset-fidelity gaps are recorded in the linked
|
||||||
|
deterministic audit reports and authoring documentation rather than silently
|
||||||
|
approximated.
|
||||||
|
- Full-WDB environment packages for Raksha Temple and The Origin completed and
|
||||||
|
passed their GLB gates. Treasure Trove's 908-placement visual/collision stage
|
||||||
|
also completed, but its full-resolution 36-tile Recast bake did not terminate
|
||||||
|
after nearly two CPU-hours on the watershed path and more than four CPU-hours
|
||||||
|
on the monotone path. The fail-closed pipeline preserved the prior validated
|
||||||
|
shipping package; this remaining offline bake is documented in
|
||||||
|
`ENCOUNTER_AUTHORING.md` instead of lowering navigation resolution.
|
||||||
|
- Verification passed: the complete Vitest suite passed all 981 cases across
|
||||||
|
185 files; online authority passed all 4 server cases; RuneWaker gameplay,
|
||||||
|
encounter, environment, navigation, formula/loot, and animation audits passed;
|
||||||
|
and the production build completed with 955 modules transformed. The KTX2
|
||||||
|
audit covered 12,347 compressed textures with zero fallbacks, and the
|
||||||
|
environment-performance audit found no regressions. The existing large-chunk
|
||||||
|
advisory and Rapier initialization deprecation warning remain informational.
|
||||||
@@ -41,6 +41,10 @@ npm test
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Boss-mechanic and dungeon-event extension points, telegraph rules, checkpoint
|
||||||
|
behavior, test workflow, and the explicit source-gap ledger are documented in
|
||||||
|
[`ENCOUNTER_AUTHORING.md`](ENCOUNTER_AUTHORING.md).
|
||||||
|
|
||||||
## Content downloads
|
## Content downloads
|
||||||
|
|
||||||
Production browser and Android builds ship a compact required set: the game
|
Production browser and Android builds ship a compact required set: the game
|
||||||
@@ -103,12 +107,23 @@ validates the relational data and ships both a browser-safe JSON catalog and
|
|||||||
for a future loot-browser screen.
|
for a future loot-browser screen.
|
||||||
|
|
||||||
Every Wailing Caverns boss rolls one item when it dies. The item instance
|
Every Wailing Caverns boss rolls one item when it dies. The item instance
|
||||||
captures the player's level at the killing blow, remains on the corpse until
|
captures its item level at the killing blow, remains on the corpse until
|
||||||
looted, and is then saved in that character's inventory. The SQL loot source
|
looted, and is then saved in that character's inventory. Drops use character
|
||||||
contains the original 3.3.5a attributes, ratings, armor, block, weapon damage,
|
level through 79; level-80 drops begin the first endgame tier at item level 85.
|
||||||
weapon speed, regeneration, and spell-power values for each item. A scaled
|
The SQL loot source contains the original 3.3.5a attributes, ratings, armor,
|
||||||
drop preserves that original stat distribution instead of replacing it with a
|
block, weapon damage, weapon speed, regeneration, and spell-power values for
|
||||||
generic item-power budget.
|
each item. A scaled drop preserves that original stat distribution instead of
|
||||||
|
replacing it with a generic item-power budget.
|
||||||
|
|
||||||
|
Every WoW and Conquest of Azeroth character has a saved specialization that
|
||||||
|
locks its activity role and selects a complete specialization-aware heirloom
|
||||||
|
loadout. Runes of Magic derives the same profile from its active primary class
|
||||||
|
and selected role. Virtual heirlooms fill every applicable unoccupied slot,
|
||||||
|
rescale through item level 79 for players and 80 for AI party members, never
|
||||||
|
enter inventory, and automatically return when earned equipment is removed.
|
||||||
|
During leveling their score is normalized above compatible same-level catalog
|
||||||
|
drops. At level 80 the item-level-85 boss tier contains real upgrades, which
|
||||||
|
the equipment and loot interfaces highlight without equipping automatically.
|
||||||
|
|
||||||
The equipment runtime uses the Wrath primary attributes and itemizable combat
|
The equipment runtime uses the Wrath primary attributes and itemizable combat
|
||||||
stats. Strength, Agility, Stamina, Intellect, Spirit, Attack Power, Spell
|
stats. Strength, Agility, Stamina, Intellect, Spirit, Attack Power, Spell
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# RuneWaker encounter content catalog
|
||||||
|
|
||||||
|
This is the reviewed runtime layer above the exhaustive, machine-generated RuneWaker
|
||||||
|
encounter audit. It intentionally contains only source behavior that can be mapped without
|
||||||
|
guessing. The executable catalog is `src/game/runewakerEncounterCatalog.ts`.
|
||||||
|
|
||||||
|
## Bounded reviewed coverage
|
||||||
|
|
||||||
|
| Dungeon | Deferred source rows mapped | Runtime behavior | Fidelity |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Forsaken Abbey | `12230`, `12236`, `12238` | Three two-member Ghoul/Carrion Spider formations are created on encounter initialization. After the source `sleep(20)` (2 seconds because RuneWaker sleep units are tenths of a second), the encounter enters its patrol phase. | source-backed adapted |
|
||||||
|
| Treasure Trove | `5952428`, `5952438` | Killing Boulder Sidaar (`5952494`) arms switch `112100`; interacting with it activates the switch and lowers drawbridge `112098` once. | source-backed adapted |
|
||||||
|
|
||||||
|
The Forsaken mapping is backed by preserved Lua `00571.lua` and matrix helper `00727.lua`.
|
||||||
|
The Treasure Trove mapping is backed by preserved Lua `01159.lua`. Snapshot, mechanics-audit,
|
||||||
|
and source hashes are attached to each catalog review record.
|
||||||
|
|
||||||
|
“Source-backed adapted” is deliberate. Source IDs, trigger order, spawn membership, timing,
|
||||||
|
and placement transforms are retained. HealerMan supplies serializable phases, procedural
|
||||||
|
world-object geometry, projected heights, interaction range, and the packaged canonical Ghoul
|
||||||
|
visual/combat definition for the source-only level-scaled Ghoul template `100653`.
|
||||||
|
|
||||||
|
## Effective ledger after this review
|
||||||
|
|
||||||
|
The generated audit baseline inventories 4,113 deferred source rows and 1,136 unresolved
|
||||||
|
mechanics-evidence records. This catalog maps five unique deferred rows and clears five stable
|
||||||
|
evidence IDs, leaving:
|
||||||
|
|
||||||
|
| Ledger item | Baseline | Reviewed here | Remaining |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| Deferred source rows | 4,113 | 5 | 4,108 |
|
||||||
|
| Unresolved mechanics-evidence records | 1,136 | 5 | 1,131 |
|
||||||
|
|
||||||
|
Per affected package, Forsaken Abbey moves from 101 to 98 deferred rows and 77 to 74
|
||||||
|
unresolved evidence records. Treasure Trove moves from 188 to 186 deferred rows and 47 to 45
|
||||||
|
unresolved evidence records. All other package counts remain unchanged.
|
||||||
|
|
||||||
|
The exhaustive generated JSON remains the immutable audit baseline until its generator learns
|
||||||
|
to consume the runtime catalog. The effective counts above are exported as
|
||||||
|
`RUNEWAKER_ENCOUNTER_CONTENT_LEDGER` and are regression tested.
|
||||||
|
|
||||||
|
## Explicit remaining gaps
|
||||||
|
|
||||||
|
- Forsaken formations remain at their source start positions after the two-second hold. The
|
||||||
|
source uses endless flag-path patrols; the encounter runtime has no reviewed path-following
|
||||||
|
action yet.
|
||||||
|
- Forsaken source template `100653` is a level-scaled Ghoul variant created only by Lua and has
|
||||||
|
no packaged actor row. The runtime alias reuses canonical Ghoul `100072`; this is not claimed
|
||||||
|
as an exact visual or formula mapping.
|
||||||
|
- The Forsaken keeper gate (`7883`) remains deferred. Its four Necromage deaths source-summon
|
||||||
|
Ghoul Drake `100073`, but that summon has neither a packaged entity nor a reviewed combat
|
||||||
|
definition. Opening the gate without that fight would be false fidelity.
|
||||||
|
- Black Mary's Hold Door (`5952433`) remains deferred because its source interaction consumes
|
||||||
|
inventory item `203550`; encounter guards currently expose no inventory predicate.
|
||||||
|
- Treasure Trove uses procedural bridge/switch proxies because source object meshes and ACT
|
||||||
|
motions are not packaged.
|
||||||
|
- The remaining 4,108 rows require the same source-by-source review. Bulk promotion from names,
|
||||||
|
model paths, or regex categories is not evidence of executable behavior.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
`src/game/runewakerEncounterCatalog.test.ts` validates each encounter definition and requires
|
||||||
|
every referenced runtime spawn, entity, encounter spawn group, world object state, and objective
|
||||||
|
to exist. It also simulates both reviewed state machines and locks the ledger arithmetic.
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"status": "green",
|
"status": "green",
|
||||||
"scope": "HealerMan WoW five-player campaign",
|
"scope": "HealerMan WoW five-player combat and animation coverage",
|
||||||
"summary": {
|
"summary": {
|
||||||
"dungeons": 54,
|
"dungeons": 54,
|
||||||
"mobs": 1031,
|
"mobs": 1031,
|
||||||
"bosses": 253,
|
"bosses": 253,
|
||||||
"attacks": 2953,
|
"attacks": 3765,
|
||||||
"specialAttacks": 1669,
|
"specialAttacks": 2481,
|
||||||
"nativeModels": 1266,
|
"nativeModels": 1266,
|
||||||
"proceduralModels": 18,
|
"proceduralModels": 18,
|
||||||
"uniqueNativeModels": 1166,
|
"uniqueNativeModels": 1166,
|
||||||
"nativeStateFallbacks": 16
|
"nativeStateFallbacks": 18
|
||||||
},
|
},
|
||||||
"documentedExceptions": [
|
"documentedExceptions": [
|
||||||
{
|
{
|
||||||
@@ -47,6 +47,14 @@
|
|||||||
],
|
],
|
||||||
"renderer": "OriginalCreatureModel combat fallback"
|
"renderer": "OriginalCreatureModel combat fallback"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"url": "/assets/game/dungeons/dire-maul/creatures/display-14430-animated.glb",
|
||||||
|
"state": "move",
|
||||||
|
"users": [
|
||||||
|
"dire-maul:entry-14386"
|
||||||
|
],
|
||||||
|
"renderer": "AnimatedMobBody locomotion fallback"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"url": "/assets/game/dungeons/dire-maul/creatures/display-14430-animated.glb",
|
"url": "/assets/game/dungeons/dire-maul/creatures/display-14430-animated.glb",
|
||||||
"state": "attack",
|
"state": "attack",
|
||||||
@@ -56,7 +64,15 @@
|
|||||||
"renderer": "OriginalCreatureModel combat fallback"
|
"renderer": "OriginalCreatureModel combat fallback"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"url": "/assets/game/creatures/shared/2fbb8d2226a7fdf3e9a7af13568f6bb46cb1b27afc008732832b9fd42c2e510e.glb",
|
"url": "/assets/game/dungeons/dire-maul/creatures/display-14430-animated.glb",
|
||||||
|
"state": "wound",
|
||||||
|
"users": [
|
||||||
|
"dire-maul:entry-14386"
|
||||||
|
],
|
||||||
|
"renderer": "OriginalCreatureModel combat fallback"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "/assets/game/creatures/shared/52c8b3aea63f21f8e98ad84eaf601f2918d1df17a05525d41f5b3e17da53c8ff.glb",
|
||||||
"state": "wound",
|
"state": "wound",
|
||||||
"users": [
|
"users": [
|
||||||
"steamvault:entry-21694",
|
"steamvault:entry-21694",
|
||||||
@@ -170,8 +186,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 24,
|
"mobs": 24,
|
||||||
"bosses": 7,
|
"bosses": 7,
|
||||||
"attacks": 58,
|
"attacks": 65,
|
||||||
"specialAttacks": 27,
|
"specialAttacks": 34,
|
||||||
"nativeModels": 31,
|
"nativeModels": 31,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -182,8 +198,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 6,
|
"mobs": 6,
|
||||||
"bosses": 5,
|
"bosses": 5,
|
||||||
"attacks": 32,
|
"attacks": 42,
|
||||||
"specialAttacks": 21,
|
"specialAttacks": 31,
|
||||||
"nativeModels": 11,
|
"nativeModels": 11,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -194,8 +210,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 15,
|
"mobs": 15,
|
||||||
"bosses": 7,
|
"bosses": 7,
|
||||||
"attacks": 48,
|
"attacks": 55,
|
||||||
"specialAttacks": 26,
|
"specialAttacks": 33,
|
||||||
"nativeModels": 22,
|
"nativeModels": 22,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -218,8 +234,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 31,
|
"mobs": 31,
|
||||||
"bosses": 7,
|
"bosses": 7,
|
||||||
"attacks": 78,
|
"attacks": 87,
|
||||||
"specialAttacks": 40,
|
"specialAttacks": 49,
|
||||||
"nativeModels": 38,
|
"nativeModels": 38,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -230,8 +246,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 21,
|
"mobs": 21,
|
||||||
"bosses": 6,
|
"bosses": 6,
|
||||||
"attacks": 58,
|
"attacks": 65,
|
||||||
"specialAttacks": 31,
|
"specialAttacks": 38,
|
||||||
"nativeModels": 27,
|
"nativeModels": 27,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -242,8 +258,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 31,
|
"mobs": 31,
|
||||||
"bosses": 7,
|
"bosses": 7,
|
||||||
"attacks": 85,
|
"attacks": 104,
|
||||||
"specialAttacks": 47,
|
"specialAttacks": 66,
|
||||||
"nativeModels": 38,
|
"nativeModels": 38,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -254,8 +270,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 27,
|
"mobs": 27,
|
||||||
"bosses": 4,
|
"bosses": 4,
|
||||||
"attacks": 68,
|
"attacks": 82,
|
||||||
"specialAttacks": 37,
|
"specialAttacks": 51,
|
||||||
"nativeModels": 31,
|
"nativeModels": 31,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -266,8 +282,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 29,
|
"mobs": 29,
|
||||||
"bosses": 4,
|
"bosses": 4,
|
||||||
"attacks": 86,
|
"attacks": 93,
|
||||||
"specialAttacks": 53,
|
"specialAttacks": 60,
|
||||||
"nativeModels": 33,
|
"nativeModels": 33,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -278,8 +294,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 19,
|
"mobs": 19,
|
||||||
"bosses": 6,
|
"bosses": 6,
|
||||||
"attacks": 55,
|
"attacks": 63,
|
||||||
"specialAttacks": 30,
|
"specialAttacks": 38,
|
||||||
"nativeModels": 25,
|
"nativeModels": 25,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -290,8 +306,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 32,
|
"mobs": 32,
|
||||||
"bosses": 5,
|
"bosses": 5,
|
||||||
"attacks": 89,
|
"attacks": 107,
|
||||||
"specialAttacks": 52,
|
"specialAttacks": 70,
|
||||||
"nativeModels": 37,
|
"nativeModels": 37,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -302,8 +318,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 16,
|
"mobs": 16,
|
||||||
"bosses": 5,
|
"bosses": 5,
|
||||||
"attacks": 53,
|
"attacks": 69,
|
||||||
"specialAttacks": 32,
|
"specialAttacks": 48,
|
||||||
"nativeModels": 21,
|
"nativeModels": 21,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -314,8 +330,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 63,
|
"mobs": 63,
|
||||||
"bosses": 15,
|
"bosses": 15,
|
||||||
"attacks": 210,
|
"attacks": 273,
|
||||||
"specialAttacks": 132,
|
"specialAttacks": 195,
|
||||||
"nativeModels": 78,
|
"nativeModels": 78,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -326,8 +342,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 48,
|
"mobs": 48,
|
||||||
"bosses": 13,
|
"bosses": 13,
|
||||||
"attacks": 145,
|
"attacks": 182,
|
||||||
"specialAttacks": 84,
|
"specialAttacks": 121,
|
||||||
"nativeModels": 61,
|
"nativeModels": 61,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -338,8 +354,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 30,
|
"mobs": 30,
|
||||||
"bosses": 8,
|
"bosses": 8,
|
||||||
"attacks": 115,
|
"attacks": 150,
|
||||||
"specialAttacks": 77,
|
"specialAttacks": 112,
|
||||||
"nativeModels": 38,
|
"nativeModels": 38,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -350,8 +366,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 41,
|
"mobs": 41,
|
||||||
"bosses": 9,
|
"bosses": 9,
|
||||||
"attacks": 131,
|
"attacks": 176,
|
||||||
"specialAttacks": 81,
|
"specialAttacks": 126,
|
||||||
"nativeModels": 50,
|
"nativeModels": 50,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -362,8 +378,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 25,
|
"mobs": 25,
|
||||||
"bosses": 8,
|
"bosses": 8,
|
||||||
"attacks": 83,
|
"attacks": 95,
|
||||||
"specialAttacks": 50,
|
"specialAttacks": 62,
|
||||||
"nativeModels": 33,
|
"nativeModels": 33,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -374,8 +390,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 7,
|
"mobs": 7,
|
||||||
"bosses": 4,
|
"bosses": 4,
|
||||||
"attacks": 21,
|
"attacks": 23,
|
||||||
"specialAttacks": 10,
|
"specialAttacks": 12,
|
||||||
"nativeModels": 11,
|
"nativeModels": 11,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -386,8 +402,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 63,
|
"mobs": 63,
|
||||||
"bosses": 12,
|
"bosses": 12,
|
||||||
"attacks": 184,
|
"attacks": 242,
|
||||||
"specialAttacks": 109,
|
"specialAttacks": 167,
|
||||||
"nativeModels": 75,
|
"nativeModels": 75,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -398,8 +414,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 29,
|
"mobs": 29,
|
||||||
"bosses": 3,
|
"bosses": 3,
|
||||||
"attacks": 74,
|
"attacks": 92,
|
||||||
"specialAttacks": 42,
|
"specialAttacks": 60,
|
||||||
"nativeModels": 32,
|
"nativeModels": 32,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -410,8 +426,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 14,
|
"mobs": 14,
|
||||||
"bosses": 3,
|
"bosses": 3,
|
||||||
"attacks": 44,
|
"attacks": 53,
|
||||||
"specialAttacks": 27,
|
"specialAttacks": 36,
|
||||||
"nativeModels": 17,
|
"nativeModels": 17,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -422,8 +438,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 10,
|
"mobs": 10,
|
||||||
"bosses": 2,
|
"bosses": 2,
|
||||||
"attacks": 36,
|
"attacks": 41,
|
||||||
"specialAttacks": 24,
|
"specialAttacks": 29,
|
||||||
"nativeModels": 12,
|
"nativeModels": 12,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -434,8 +450,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 14,
|
"mobs": 14,
|
||||||
"bosses": 3,
|
"bosses": 3,
|
||||||
"attacks": 50,
|
"attacks": 63,
|
||||||
"specialAttacks": 33,
|
"specialAttacks": 46,
|
||||||
"nativeModels": 17,
|
"nativeModels": 17,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -446,8 +462,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 18,
|
"mobs": 18,
|
||||||
"bosses": 4,
|
"bosses": 4,
|
||||||
"attacks": 59,
|
"attacks": 80,
|
||||||
"specialAttacks": 37,
|
"specialAttacks": 58,
|
||||||
"nativeModels": 22,
|
"nativeModels": 22,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -458,8 +474,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 18,
|
"mobs": 18,
|
||||||
"bosses": 3,
|
"bosses": 3,
|
||||||
"attacks": 53,
|
"attacks": 60,
|
||||||
"specialAttacks": 32,
|
"specialAttacks": 39,
|
||||||
"nativeModels": 21,
|
"nativeModels": 21,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -470,8 +486,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 25,
|
"mobs": 25,
|
||||||
"bosses": 4,
|
"bosses": 4,
|
||||||
"attacks": 73,
|
"attacks": 105,
|
||||||
"specialAttacks": 44,
|
"specialAttacks": 76,
|
||||||
"nativeModels": 29,
|
"nativeModels": 29,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -482,8 +498,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 24,
|
"mobs": 24,
|
||||||
"bosses": 5,
|
"bosses": 5,
|
||||||
"attacks": 67,
|
"attacks": 85,
|
||||||
"specialAttacks": 38,
|
"specialAttacks": 56,
|
||||||
"nativeModels": 29,
|
"nativeModels": 29,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -494,8 +510,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 14,
|
"mobs": 14,
|
||||||
"bosses": 3,
|
"bosses": 3,
|
||||||
"attacks": 45,
|
"attacks": 69,
|
||||||
"specialAttacks": 28,
|
"specialAttacks": 52,
|
||||||
"nativeModels": 17,
|
"nativeModels": 17,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -506,8 +522,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 18,
|
"mobs": 18,
|
||||||
"bosses": 4,
|
"bosses": 4,
|
||||||
"attacks": 46,
|
"attacks": 67,
|
||||||
"specialAttacks": 24,
|
"specialAttacks": 45,
|
||||||
"nativeModels": 22,
|
"nativeModels": 22,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -518,8 +534,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 14,
|
"mobs": 14,
|
||||||
"bosses": 2,
|
"bosses": 2,
|
||||||
"attacks": 39,
|
"attacks": 50,
|
||||||
"specialAttacks": 23,
|
"specialAttacks": 34,
|
||||||
"nativeModels": 16,
|
"nativeModels": 16,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -530,8 +546,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 11,
|
"mobs": 11,
|
||||||
"bosses": 3,
|
"bosses": 3,
|
||||||
"attacks": 33,
|
"attacks": 46,
|
||||||
"specialAttacks": 19,
|
"specialAttacks": 32,
|
||||||
"nativeModels": 14,
|
"nativeModels": 14,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -542,8 +558,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 12,
|
"mobs": 12,
|
||||||
"bosses": 2,
|
"bosses": 2,
|
||||||
"attacks": 30,
|
"attacks": 44,
|
||||||
"specialAttacks": 16,
|
"specialAttacks": 30,
|
||||||
"nativeModels": 14,
|
"nativeModels": 14,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -554,8 +570,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 15,
|
"mobs": 15,
|
||||||
"bosses": 3,
|
"bosses": 3,
|
||||||
"attacks": 33,
|
"attacks": 57,
|
||||||
"specialAttacks": 15,
|
"specialAttacks": 39,
|
||||||
"nativeModels": 18,
|
"nativeModels": 18,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -566,8 +582,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 4,
|
"mobs": 4,
|
||||||
"bosses": 3,
|
"bosses": 3,
|
||||||
"attacks": 20,
|
"attacks": 25,
|
||||||
"specialAttacks": 13,
|
"specialAttacks": 18,
|
||||||
"nativeModels": 7,
|
"nativeModels": 7,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -578,8 +594,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 26,
|
"mobs": 26,
|
||||||
"bosses": 3,
|
"bosses": 3,
|
||||||
"attacks": 45,
|
"attacks": 53,
|
||||||
"specialAttacks": 16,
|
"specialAttacks": 24,
|
||||||
"nativeModels": 29,
|
"nativeModels": 29,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -590,8 +606,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 19,
|
"mobs": 19,
|
||||||
"bosses": 3,
|
"bosses": 3,
|
||||||
"attacks": 35,
|
"attacks": 44,
|
||||||
"specialAttacks": 13,
|
"specialAttacks": 22,
|
||||||
"nativeModels": 22,
|
"nativeModels": 22,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -602,8 +618,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 13,
|
"mobs": 13,
|
||||||
"bosses": 4,
|
"bosses": 4,
|
||||||
"attacks": 32,
|
"attacks": 44,
|
||||||
"specialAttacks": 15,
|
"specialAttacks": 27,
|
||||||
"nativeModels": 17,
|
"nativeModels": 17,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -614,8 +630,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 21,
|
"mobs": 21,
|
||||||
"bosses": 5,
|
"bosses": 5,
|
||||||
"attacks": 50,
|
"attacks": 61,
|
||||||
"specialAttacks": 24,
|
"specialAttacks": 35,
|
||||||
"nativeModels": 26,
|
"nativeModels": 26,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -626,8 +642,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 8,
|
"mobs": 8,
|
||||||
"bosses": 4,
|
"bosses": 4,
|
||||||
"attacks": 27,
|
"attacks": 33,
|
||||||
"specialAttacks": 15,
|
"specialAttacks": 21,
|
||||||
"nativeModels": 12,
|
"nativeModels": 12,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -638,8 +654,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 16,
|
"mobs": 16,
|
||||||
"bosses": 4,
|
"bosses": 4,
|
||||||
"attacks": 41,
|
"attacks": 44,
|
||||||
"specialAttacks": 21,
|
"specialAttacks": 24,
|
||||||
"nativeModels": 20,
|
"nativeModels": 20,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -650,8 +666,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 13,
|
"mobs": 13,
|
||||||
"bosses": 4,
|
"bosses": 4,
|
||||||
"attacks": 32,
|
"attacks": 42,
|
||||||
"specialAttacks": 15,
|
"specialAttacks": 25,
|
||||||
"nativeModels": 16,
|
"nativeModels": 16,
|
||||||
"proceduralModels": 1,
|
"proceduralModels": 1,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -662,8 +678,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 18,
|
"mobs": 18,
|
||||||
"bosses": 4,
|
"bosses": 4,
|
||||||
"attacks": 38,
|
"attacks": 45,
|
||||||
"specialAttacks": 16,
|
"specialAttacks": 23,
|
||||||
"nativeModels": 22,
|
"nativeModels": 22,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -674,8 +690,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 10,
|
"mobs": 10,
|
||||||
"bosses": 3,
|
"bosses": 3,
|
||||||
"attacks": 25,
|
"attacks": 30,
|
||||||
"specialAttacks": 12,
|
"specialAttacks": 17,
|
||||||
"nativeModels": 13,
|
"nativeModels": 13,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -686,8 +702,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 16,
|
"mobs": 16,
|
||||||
"bosses": 4,
|
"bosses": 4,
|
||||||
"attacks": 35,
|
"attacks": 49,
|
||||||
"specialAttacks": 15,
|
"specialAttacks": 29,
|
||||||
"nativeModels": 20,
|
"nativeModels": 20,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -698,8 +714,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 15,
|
"mobs": 15,
|
||||||
"bosses": 5,
|
"bosses": 5,
|
||||||
"attacks": 43,
|
"attacks": 53,
|
||||||
"specialAttacks": 23,
|
"specialAttacks": 33,
|
||||||
"nativeModels": 20,
|
"nativeModels": 20,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -710,8 +726,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 2,
|
"mobs": 2,
|
||||||
"bosses": 3,
|
"bosses": 3,
|
||||||
"attacks": 19,
|
"attacks": 24,
|
||||||
"specialAttacks": 14,
|
"specialAttacks": 19,
|
||||||
"nativeModels": 5,
|
"nativeModels": 5,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -722,8 +738,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 19,
|
"mobs": 19,
|
||||||
"bosses": 5,
|
"bosses": 5,
|
||||||
"attacks": 43,
|
"attacks": 60,
|
||||||
"specialAttacks": 19,
|
"specialAttacks": 36,
|
||||||
"nativeModels": 24,
|
"nativeModels": 24,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -734,8 +750,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 11,
|
"mobs": 11,
|
||||||
"bosses": 2,
|
"bosses": 2,
|
||||||
"attacks": 21,
|
"attacks": 23,
|
||||||
"specialAttacks": 8,
|
"specialAttacks": 10,
|
||||||
"nativeModels": 13,
|
"nativeModels": 13,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -746,8 +762,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 0,
|
"mobs": 0,
|
||||||
"bosses": 5,
|
"bosses": 5,
|
||||||
"attacks": 22,
|
"attacks": 88,
|
||||||
"specialAttacks": 17,
|
"specialAttacks": 83,
|
||||||
"nativeModels": 5,
|
"nativeModels": 5,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -758,8 +774,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 27,
|
"mobs": 27,
|
||||||
"bosses": 3,
|
"bosses": 3,
|
||||||
"attacks": 43,
|
"attacks": 49,
|
||||||
"specialAttacks": 13,
|
"specialAttacks": 19,
|
||||||
"nativeModels": 30,
|
"nativeModels": 30,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
@@ -770,8 +786,8 @@
|
|||||||
"source": "azerothcore",
|
"source": "azerothcore",
|
||||||
"mobs": 8,
|
"mobs": 8,
|
||||||
"bosses": 2,
|
"bosses": 2,
|
||||||
"attacks": 26,
|
"attacks": 38,
|
||||||
"specialAttacks": 16,
|
"specialAttacks": 28,
|
||||||
"nativeModels": 10,
|
"nativeModels": 10,
|
||||||
"proceduralModels": 0,
|
"proceduralModels": 0,
|
||||||
"status": "green"
|
"status": "green"
|
||||||
|
|||||||
@@ -1,64 +1,66 @@
|
|||||||
# WoW dungeon content report
|
# WoW dungeon combat and animation report
|
||||||
|
|
||||||
Status: **green**
|
Status: **green**
|
||||||
|
|
||||||
Coverage: 54 dungeons, 1031 mobs, 253 bosses, 2953 executable attacks, and 1669 special attacks/mechanics.
|
Scope: executable combat populations and creature animation bindings. This report does not certify instance scripts, doors, interactables, escorts, waves, vehicles, or navigation.
|
||||||
|
|
||||||
Animation paths: 1266 native model bindings and 18 procedural bindings across 1166 unique native GLBs; 16 missing native states use the shared procedural fallback.
|
Coverage: 54 dungeons, 1031 mobs, 253 bosses, 3765 executable attacks, and 2481 special attacks/mechanics.
|
||||||
|
|
||||||
|
Animation paths: 1266 native model bindings and 18 procedural bindings across 1166 unique native GLBs; 18 missing native states use the shared procedural fallback.
|
||||||
|
|
||||||
| Dungeon | Mobs | Bosses | Attacks | Specials | Native | Procedural | Status |
|
| Dungeon | Mobs | Bosses | Attacks | Specials | Native | Procedural | Status |
|
||||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
||||||
| Shadowfang Keep | 24 | 7 | 58 | 27 | 31 | 0 | green |
|
| Shadowfang Keep | 24 | 7 | 65 | 34 | 31 | 0 | green |
|
||||||
| Stormwind Stockade | 6 | 5 | 32 | 21 | 11 | 0 | green |
|
| Stormwind Stockade | 6 | 5 | 42 | 31 | 11 | 0 | green |
|
||||||
| The Deadmines | 15 | 7 | 48 | 26 | 22 | 0 | green |
|
| The Deadmines | 15 | 7 | 55 | 33 | 22 | 0 | green |
|
||||||
| Wailing Caverns | 12 | 8 | 51 | 31 | 15 | 5 | green |
|
| Wailing Caverns | 12 | 8 | 51 | 31 | 15 | 5 | green |
|
||||||
| Razorfen Kraul | 31 | 7 | 78 | 40 | 38 | 0 | green |
|
| Razorfen Kraul | 31 | 7 | 87 | 49 | 38 | 0 | green |
|
||||||
| Blackfathom Deeps | 21 | 6 | 58 | 31 | 27 | 0 | green |
|
| Blackfathom Deeps | 21 | 6 | 65 | 38 | 27 | 0 | green |
|
||||||
| Uldaman | 31 | 7 | 85 | 47 | 38 | 0 | green |
|
| Uldaman | 31 | 7 | 104 | 66 | 38 | 0 | green |
|
||||||
| Gnomeregan | 27 | 4 | 68 | 37 | 31 | 0 | green |
|
| Gnomeregan | 27 | 4 | 82 | 51 | 31 | 0 | green |
|
||||||
| Sunken Temple | 29 | 4 | 86 | 53 | 33 | 0 | green |
|
| Sunken Temple | 29 | 4 | 93 | 60 | 33 | 0 | green |
|
||||||
| Razorfen Downs | 19 | 6 | 55 | 30 | 25 | 0 | green |
|
| Razorfen Downs | 19 | 6 | 63 | 38 | 25 | 0 | green |
|
||||||
| Scarlet Monastery | 32 | 5 | 89 | 52 | 37 | 0 | green |
|
| Scarlet Monastery | 32 | 5 | 107 | 70 | 37 | 0 | green |
|
||||||
| Zul'Farrak | 16 | 5 | 53 | 32 | 21 | 0 | green |
|
| Zul'Farrak | 16 | 5 | 69 | 48 | 21 | 0 | green |
|
||||||
| Blackrock Spire | 63 | 15 | 210 | 132 | 78 | 0 | green |
|
| Blackrock Spire | 63 | 15 | 273 | 195 | 78 | 0 | green |
|
||||||
| Blackrock Depths | 48 | 13 | 145 | 84 | 61 | 0 | green |
|
| Blackrock Depths | 48 | 13 | 182 | 121 | 61 | 0 | green |
|
||||||
| Scholomance | 30 | 8 | 115 | 77 | 38 | 0 | green |
|
| Scholomance | 30 | 8 | 150 | 112 | 38 | 0 | green |
|
||||||
| Stratholme | 41 | 9 | 131 | 81 | 50 | 0 | green |
|
| Stratholme | 41 | 9 | 176 | 126 | 50 | 0 | green |
|
||||||
| Maraudon | 25 | 8 | 83 | 50 | 33 | 0 | green |
|
| Maraudon | 25 | 8 | 95 | 62 | 33 | 0 | green |
|
||||||
| Ragefire Chasm | 7 | 4 | 21 | 10 | 11 | 0 | green |
|
| Ragefire Chasm | 7 | 4 | 23 | 12 | 11 | 0 | green |
|
||||||
| Dire Maul | 63 | 12 | 184 | 109 | 75 | 0 | green |
|
| Dire Maul | 63 | 12 | 242 | 167 | 75 | 0 | green |
|
||||||
| Hellfire Citadel: The Shattered Halls | 29 | 3 | 74 | 42 | 32 | 0 | green |
|
| Hellfire Citadel: The Shattered Halls | 29 | 3 | 92 | 60 | 32 | 0 | green |
|
||||||
| Hellfire Citadel: The Blood Furnace | 14 | 3 | 44 | 27 | 17 | 0 | green |
|
| Hellfire Citadel: The Blood Furnace | 14 | 3 | 53 | 36 | 17 | 0 | green |
|
||||||
| Hellfire Citadel: Ramparts | 10 | 2 | 36 | 24 | 12 | 0 | green |
|
| Hellfire Citadel: Ramparts | 10 | 2 | 41 | 29 | 12 | 0 | green |
|
||||||
| Coilfang: The Steamvault | 14 | 3 | 50 | 33 | 17 | 0 | green |
|
| Coilfang: The Steamvault | 14 | 3 | 63 | 46 | 17 | 0 | green |
|
||||||
| Coilfang: The Underbog | 18 | 4 | 59 | 37 | 22 | 0 | green |
|
| Coilfang: The Underbog | 18 | 4 | 80 | 58 | 22 | 0 | green |
|
||||||
| Coilfang: The Slave Pens | 18 | 3 | 53 | 32 | 21 | 0 | green |
|
| Coilfang: The Slave Pens | 18 | 3 | 60 | 39 | 21 | 0 | green |
|
||||||
| Tempest Keep: The Arcatraz | 25 | 4 | 73 | 44 | 29 | 0 | green |
|
| Tempest Keep: The Arcatraz | 25 | 4 | 105 | 76 | 29 | 0 | green |
|
||||||
| Tempest Keep: The Botanica | 24 | 5 | 67 | 38 | 29 | 0 | green |
|
| Tempest Keep: The Botanica | 24 | 5 | 85 | 56 | 29 | 0 | green |
|
||||||
| Tempest Keep: The Mechanar | 14 | 3 | 45 | 28 | 17 | 0 | green |
|
| Tempest Keep: The Mechanar | 14 | 3 | 69 | 52 | 17 | 0 | green |
|
||||||
| Auchindoun: Shadow Labyrinth | 18 | 4 | 46 | 24 | 22 | 0 | green |
|
| Auchindoun: Shadow Labyrinth | 18 | 4 | 67 | 45 | 22 | 0 | green |
|
||||||
| Auchindoun: Sethekk Halls | 14 | 2 | 39 | 23 | 16 | 0 | green |
|
| Auchindoun: Sethekk Halls | 14 | 2 | 50 | 34 | 16 | 0 | green |
|
||||||
| Auchindoun: Mana-Tombs | 11 | 3 | 33 | 19 | 14 | 0 | green |
|
| Auchindoun: Mana-Tombs | 11 | 3 | 46 | 32 | 14 | 0 | green |
|
||||||
| Auchindoun: Auchenai Crypts | 12 | 2 | 30 | 16 | 14 | 0 | green |
|
| Auchindoun: Auchenai Crypts | 12 | 2 | 44 | 30 | 14 | 0 | green |
|
||||||
| Magister's Terrace | 15 | 3 | 33 | 15 | 18 | 0 | green |
|
| Magister's Terrace | 15 | 3 | 57 | 39 | 18 | 0 | green |
|
||||||
| The Black Morass | 4 | 3 | 20 | 13 | 7 | 0 | green |
|
| The Black Morass | 4 | 3 | 25 | 18 | 7 | 0 | green |
|
||||||
| The Escape From Durnholde | 26 | 3 | 45 | 16 | 29 | 0 | green |
|
| The Escape From Durnholde | 26 | 3 | 53 | 24 | 29 | 0 | green |
|
||||||
| Utgarde Keep | 19 | 3 | 35 | 13 | 22 | 0 | green |
|
| Utgarde Keep | 19 | 3 | 44 | 22 | 22 | 0 | green |
|
||||||
| Utgarde Pinnacle | 13 | 4 | 32 | 15 | 17 | 0 | green |
|
| Utgarde Pinnacle | 13 | 4 | 44 | 27 | 17 | 0 | green |
|
||||||
| The Nexus | 21 | 5 | 50 | 24 | 26 | 0 | green |
|
| The Nexus | 21 | 5 | 61 | 35 | 26 | 0 | green |
|
||||||
| The Oculus | 8 | 4 | 27 | 15 | 12 | 0 | green |
|
| The Oculus | 8 | 4 | 33 | 21 | 12 | 0 | green |
|
||||||
| The Culling of Stratholme | 16 | 4 | 41 | 21 | 20 | 0 | green |
|
| The Culling of Stratholme | 16 | 4 | 44 | 24 | 20 | 0 | green |
|
||||||
| Halls of Stone | 13 | 4 | 32 | 15 | 16 | 1 | green |
|
| Halls of Stone | 13 | 4 | 42 | 25 | 16 | 1 | green |
|
||||||
| Drak'Tharon Keep | 18 | 4 | 38 | 16 | 22 | 0 | green |
|
| Drak'Tharon Keep | 18 | 4 | 45 | 23 | 22 | 0 | green |
|
||||||
| Azjol-Nerub | 10 | 3 | 25 | 12 | 13 | 0 | green |
|
| Azjol-Nerub | 10 | 3 | 30 | 17 | 13 | 0 | green |
|
||||||
| Halls of Lightning | 16 | 4 | 35 | 15 | 20 | 0 | green |
|
| Halls of Lightning | 16 | 4 | 49 | 29 | 20 | 0 | green |
|
||||||
| Gundrak | 15 | 5 | 43 | 23 | 20 | 0 | green |
|
| Gundrak | 15 | 5 | 53 | 33 | 20 | 0 | green |
|
||||||
| Violet Hold | 2 | 3 | 19 | 14 | 5 | 0 | green |
|
| Violet Hold | 2 | 3 | 24 | 19 | 5 | 0 | green |
|
||||||
| Ahn'kahet: The Old Kingdom | 19 | 5 | 43 | 19 | 24 | 0 | green |
|
| Ahn'kahet: The Old Kingdom | 19 | 5 | 60 | 36 | 24 | 0 | green |
|
||||||
| The Forge of Souls | 11 | 2 | 21 | 8 | 13 | 0 | green |
|
| The Forge of Souls | 11 | 2 | 23 | 10 | 13 | 0 | green |
|
||||||
| Trial of the Champion | 0 | 5 | 22 | 17 | 5 | 0 | green |
|
| Trial of the Champion | 0 | 5 | 88 | 83 | 5 | 0 | green |
|
||||||
| Pit of Saron | 27 | 3 | 43 | 13 | 30 | 0 | green |
|
| Pit of Saron | 27 | 3 | 49 | 19 | 30 | 0 | green |
|
||||||
| Halls of Reflection | 8 | 2 | 26 | 16 | 10 | 0 | green |
|
| Halls of Reflection | 8 | 2 | 38 | 28 | 10 | 0 | green |
|
||||||
| Tinkertech Showdown | 3 | 1 | 8 | 4 | 0 | 4 | green |
|
| Tinkertech Showdown | 3 | 1 | 8 | 4 | 0 | 4 | green |
|
||||||
| Frozen Reach | 3 | 1 | 8 | 4 | 0 | 4 | green |
|
| Frozen Reach | 3 | 1 | 8 | 4 | 0 | 4 | green |
|
||||||
| Forgotten Mine | 3 | 1 | 8 | 4 | 0 | 4 | green |
|
| Forgotten Mine | 3 | 1 | 8 | 4 | 0 | 4 | green |
|
||||||
|
|||||||
@@ -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",
|
"name": "healer-man",
|
||||||
"version": "0.1.14",
|
"version": "0.1.21",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.14",
|
"version": "0.1.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
|
|||||||
+9
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.14",
|
"version": "0.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
@@ -63,6 +63,8 @@
|
|||||||
"dungeon:navmesh": "node scripts/dungeon-pipeline/cli.mjs navmesh",
|
"dungeon:navmesh": "node scripts/dungeon-pipeline/cli.mjs navmesh",
|
||||||
"dungeon:navmesh:conversion": "node scripts/dungeon-pipeline/cli.mjs navmesh-conversion",
|
"dungeon:navmesh:conversion": "node scripts/dungeon-pipeline/cli.mjs navmesh-conversion",
|
||||||
"dungeon:validate": "node scripts/dungeon-pipeline/cli.mjs validate",
|
"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",
|
"dungeon:enable": "node scripts/dungeon-pipeline/cli.mjs enable",
|
||||||
"runewaker:forsaken": "node \"%INIT_CWD%\\scripts\\runewaker-pipeline\\build-forsaken-abbey.mjs\"",
|
"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",
|
"runewaker:forsaken:actors": "node scripts/runewaker-pipeline/build-forsaken-population.mjs --actors-only",
|
||||||
@@ -74,6 +76,8 @@
|
|||||||
"runewaker:register:all": "node scripts/runewaker-pipeline/cli.mjs register-all",
|
"runewaker:register:all": "node scripts/runewaker-pipeline/cli.mjs register-all",
|
||||||
"runewaker:refresh:all": "node scripts/runewaker-pipeline/cli.mjs refresh-all",
|
"runewaker:refresh:all": "node scripts/runewaker-pipeline/cli.mjs refresh-all",
|
||||||
"runewaker:spells:refresh": "node scripts/runewaker-pipeline/export-spell-catalog.mjs",
|
"runewaker:spells:refresh": "node scripts/runewaker-pipeline/export-spell-catalog.mjs",
|
||||||
|
"runewaker:loot:refresh": "node scripts/runewaker-pipeline/export-loot-catalog.mjs",
|
||||||
|
"runewaker:audit:formula-loot": "node --test scripts/runewaker-pipeline/runewaker-formula-export.test.mjs scripts/runewaker-pipeline/runewaker-loot-import.test.mjs && vitest run src/game/runewakerCombatFormula.test.ts src/game/runewakerLootCatalog.test.ts",
|
||||||
"runewaker:bosses:refresh": "npm run runewaker:spells:refresh && node scripts/runewaker-pipeline/import-boss-mechanics.mjs",
|
"runewaker:bosses:refresh": "npm run runewaker:spells:refresh && node scripts/runewaker-pipeline/import-boss-mechanics.mjs",
|
||||||
"runewaker:players:refresh": "node scripts/runewaker-pipeline/export-player-catalog.mjs && npm run runewaker:players:avatars",
|
"runewaker:players:refresh": "node scripts/runewaker-pipeline/export-player-catalog.mjs && npm run runewaker:players:avatars",
|
||||||
"runewaker:players:avatars": "node scripts/runewaker-pipeline/build-population.mjs --recipe scripts/runewaker-pipeline/recipes/runewaker-player-avatars.json --actors-only && node scripts/runewaker-pipeline/finalize-player-avatar-manifest.mjs",
|
"runewaker:players:avatars": "node scripts/runewaker-pipeline/build-population.mjs --recipe scripts/runewaker-pipeline/recipes/runewaker-player-avatars.json --actors-only && node scripts/runewaker-pipeline/finalize-player-avatar-manifest.mjs",
|
||||||
@@ -84,7 +88,9 @@
|
|||||||
"runewaker:audit:pose-batch": "node scripts/runewaker-pipeline/audit-animated-poses.mjs",
|
"runewaker:audit:pose-batch": "node scripts/runewaker-pipeline/audit-animated-poses.mjs",
|
||||||
"runewaker:audit:pose-master": "node scripts/runewaker-pipeline/generate-master-actor-pose-audit.mjs",
|
"runewaker:audit:pose-master": "node scripts/runewaker-pipeline/generate-master-actor-pose-audit.mjs",
|
||||||
"runewaker:audit:animations": "npm run runewaker:audit:animation-readiness && vitest run src/game/runewakerDungeonAnimation.test.ts src/game/mobAnimation.test.ts src/game/runewakerDungeonRegistry.test.ts",
|
"runewaker:audit:animations": "npm run runewaker:audit:animation-readiness && vitest run src/game/runewakerDungeonAnimation.test.ts src/game/mobAnimation.test.ts src/game/runewakerDungeonRegistry.test.ts",
|
||||||
"runewaker:audit:gameplay": "node scripts/runewaker-pipeline/audit-gameplay-content.mjs",
|
"runewaker:audit:gameplay": "node scripts/runewaker-pipeline/audit-gameplay-content.mjs && node --test scripts/runewaker-pipeline/encounter-implementation-report.test.mjs",
|
||||||
|
"runewaker:audit:encounters": "node scripts/runewaker-pipeline/audit-encounter-implementation.mjs && node --test scripts/runewaker-pipeline/encounter-implementation-report.test.mjs",
|
||||||
|
"runewaker:audit:environment-assembly": "node scripts/runewaker-pipeline/audit-environment-assembly.mjs && node --test scripts/runewaker-pipeline/environment-assembly.test.mjs",
|
||||||
"runewaker:audit:navigation": "node scripts/runewaker-pipeline/analyze-navigation-gaps.mjs && vitest run src/game/runewakerObjectiveRouteAudit.test.ts",
|
"runewaker:audit:navigation": "node scripts/runewaker-pipeline/analyze-navigation-gaps.mjs && vitest run src/game/runewakerObjectiveRouteAudit.test.ts",
|
||||||
"assets:wailing:instance": "gltf-transform instance ../wow335a/dungeon-export-work/WailingCaverns/converted/wailing-caverns.visual.glb src/assets/game/dungeons/wailing-caverns/wailing-caverns-visual.glb --min 3",
|
"assets:wailing:instance": "gltf-transform instance ../wow335a/dungeon-export-work/WailingCaverns/converted/wailing-caverns.visual.glb src/assets/game/dungeons/wailing-caverns/wailing-caverns-visual.glb --min 3",
|
||||||
"assets:wailing:creatures:animated": "node scripts/prepareAnimatedCreatures.mjs",
|
"assets:wailing:creatures:animated": "node scripts/prepareAnimatedCreatures.mjs",
|
||||||
@@ -98,7 +104,7 @@
|
|||||||
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
|
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
|
||||||
"balance:benchmark": "vite-node scripts/combat-benchmark/run.ts",
|
"balance:benchmark": "vite-node scripts/combat-benchmark/run.ts",
|
||||||
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
|
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
|
||||||
"prebuild": "npm run loot:generate && npm run web:prepare && 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": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
|
||||||
"build:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android",
|
"build:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android",
|
||||||
"android:sync": "npm run build:android:web && cap sync android",
|
"android:sync": "npm run build:android:web && cap sync android",
|
||||||
|
|||||||
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.
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.14",
|
"versionName": "0.1.21",
|
||||||
"versionCode": 1014
|
"versionCode": 1021
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -32,6 +32,10 @@ const sources = [
|
|||||||
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
|
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
|
||||||
logicalPrefix: "assets/creatures/wailing-caverns",
|
logicalPrefix: "assets/creatures/wailing-caverns",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
|
||||||
|
logicalPrefix: "assets/game/arenas",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
|
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
|
||||||
logicalPrefix: "assets/game/dungeons/wailing-caverns",
|
logicalPrefix: "assets/game/dungeons/wailing-caverns",
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ const sources = [
|
|||||||
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
|
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
|
||||||
logicalPrefix: "assets/creatures/wailing-caverns",
|
logicalPrefix: "assets/creatures/wailing-caverns",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
|
||||||
|
logicalPrefix: "assets/game/arenas",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
|
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
|
||||||
logicalPrefix: "assets/game/dungeons/wailing-caverns",
|
logicalPrefix: "assets/game/dungeons/wailing-caverns",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const BUNDLED_ANDROID_PACKS = Object.freeze([
|
|||||||
"core:equipment",
|
"core:equipment",
|
||||||
"core:character-basic",
|
"core:character-basic",
|
||||||
"core:runtime",
|
"core:runtime",
|
||||||
|
"core:arenas",
|
||||||
"dungeon:wailing-caverns",
|
"dungeon:wailing-caverns",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ function packLabel(packId) {
|
|||||||
if (packId === "core:equipment") return "Starter Equipment";
|
if (packId === "core:equipment") return "Starter Equipment";
|
||||||
if (packId === "core:character-basic") return "Basic Human Character Model";
|
if (packId === "core:character-basic") return "Basic Human Character Model";
|
||||||
if (packId === "core:runtime") return "Game Runtime";
|
if (packId === "core:runtime") return "Game Runtime";
|
||||||
|
if (packId === "core:arenas") return "Rated Arenas";
|
||||||
if (packId === "core:combat-media") return "Original Spell Effects & Sounds";
|
if (packId === "core:combat-media") return "Original Spell Effects & Sounds";
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
@@ -105,6 +107,7 @@ export function contentPackForPath(logicalPath) {
|
|||||||
if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) {
|
if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) {
|
||||||
return `dungeon:${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]) {
|
if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) {
|
||||||
return `manastorm:${segments[3]}`;
|
return `manastorm:${segments[3]}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ test("assigns stable player-facing packs from logical asset paths", () => {
|
|||||||
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_01.webp"), "characters:wow335a-v1");
|
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/characters/wow335a-v1/orc/male/rig.glb"), "characters:wow335a-v1");
|
||||||
assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1");
|
assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1");
|
||||||
|
assert.equal(contentPackForPath("assets/game/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/smite.wav"), "core:combat-media");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
contentPackForPath("assets/game/combat-effects/audio/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"),
|
contentPackForPath("assets/game/combat-effects/audio/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"),
|
||||||
|
|||||||
@@ -116,10 +116,12 @@ function reportMarkdown(report) {
|
|||||||
dungeon.proceduralModels, "|", dungeon.status, "|",
|
dungeon.proceduralModels, "|", dungeon.status, "|",
|
||||||
].join(" "));
|
].join(" "));
|
||||||
return [
|
return [
|
||||||
"# WoW dungeon content report",
|
"# WoW dungeon combat and animation report",
|
||||||
"",
|
"",
|
||||||
"Status: **" + report.status + "**",
|
"Status: **" + report.status + "**",
|
||||||
"",
|
"",
|
||||||
|
"Scope: executable combat populations and creature animation bindings. This report does not certify instance scripts, doors, interactables, escorts, waves, vehicles, or navigation.",
|
||||||
|
"",
|
||||||
"Coverage: " + report.summary.dungeons + " dungeons, " + report.summary.mobs
|
"Coverage: " + report.summary.dungeons + " dungeons, " + report.summary.mobs
|
||||||
+ " mobs, " + report.summary.bosses + " bosses, " + report.summary.attacks
|
+ " mobs, " + report.summary.bosses + " bosses, " + report.summary.attacks
|
||||||
+ " executable attacks, and " + report.summary.specialAttacks + " special attacks/mechanics.",
|
+ " executable attacks, and " + report.summary.specialAttacks + " special attacks/mechanics.",
|
||||||
@@ -177,6 +179,10 @@ for (const dungeon of definitions) {
|
|||||||
let specialAttacks = 0;
|
let specialAttacks = 0;
|
||||||
let nativeModels = 0;
|
let nativeModels = 0;
|
||||||
let proceduralModels = 0;
|
let proceduralModels = 0;
|
||||||
|
const optionalBossEntityIds = new Set((dungeon.objectiveOrder ?? [])
|
||||||
|
.filter((objective) => objective.optional)
|
||||||
|
.map((objective) => dungeon.staticSpawns?.find((spawn) => spawn.id === objective.id)?.entityId)
|
||||||
|
.filter(Boolean));
|
||||||
|
|
||||||
if (!entities.length) dungeonIssues.push("has no combat entities.");
|
if (!entities.length) dungeonIssues.push("has no combat entities.");
|
||||||
if (!bosses.length) dungeonIssues.push("has no bosses.");
|
if (!bosses.length) dungeonIssues.push("has no bosses.");
|
||||||
@@ -203,7 +209,7 @@ for (const dungeon of definitions) {
|
|||||||
attacks += entityAttacks.length;
|
attacks += entityAttacks.length;
|
||||||
const specials = entityAttacks.filter((attack) => attack.id !== "basic-attack");
|
const specials = entityAttacks.filter((attack) => attack.id !== "basic-attack");
|
||||||
specialAttacks += specials.length;
|
specialAttacks += specials.length;
|
||||||
if (entity.kind === "boss" && !specials.length) {
|
if (entity.kind === "boss" && !specials.length && !optionalBossEntityIds.has(entityId)) {
|
||||||
const exceptionKey = dungeon.id + ":" + entityId;
|
const exceptionKey = dungeon.id + ":" + entityId;
|
||||||
if (!basicOnlyBossExceptions.has(exceptionKey)) {
|
if (!basicOnlyBossExceptions.has(exceptionKey)) {
|
||||||
dungeonIssues.push(entityId + " has no boss mechanic beyond its basic attack.");
|
dungeonIssues.push(entityId + " has no boss mechanic beyond its basic attack.");
|
||||||
@@ -297,7 +303,7 @@ const summary = dungeons.reduce((result, dungeon) => ({
|
|||||||
const report = {
|
const report = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
status: issues.length ? "blocked" : "green",
|
status: issues.length ? "blocked" : "green",
|
||||||
scope: "HealerMan WoW five-player campaign",
|
scope: "HealerMan WoW five-player combat and animation coverage",
|
||||||
summary,
|
summary,
|
||||||
documentedExceptions,
|
documentedExceptions,
|
||||||
animationFallbacks,
|
animationFallbacks,
|
||||||
@@ -317,4 +323,4 @@ console.log(JSON.stringify({
|
|||||||
documentedExceptions: report.documentedExceptions,
|
documentedExceptions: report.documentedExceptions,
|
||||||
issues: report.issues,
|
issues: report.issues,
|
||||||
}, null, 2));
|
}, null, 2));
|
||||||
if (issues.length) process.exitCode = 1;
|
if (issues.length) process.exitCode = 1;
|
||||||
@@ -707,6 +707,18 @@ function smartAiMechanicsFor(entity, boss, spells, worldAi) {
|
|||||||
return mechanics;
|
return mechanics;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initializationSpawnChance(entry, worldAi) {
|
||||||
|
const despawnRows = (worldAi?.smartScriptsByEntry?.[String(entry)] ?? []).filter((row) => (
|
||||||
|
Number(row.event_type) === 37
|
||||||
|
&& Number(row.action_type) === 41
|
||||||
|
&& Number(row.target_type) === 1
|
||||||
|
));
|
||||||
|
if (!despawnRows.length) return 1;
|
||||||
|
return rounded(despawnRows.reduce((chance, row) => (
|
||||||
|
chance * (1 - Math.max(0, Math.min(100, Number(row.event_chance) || 0)) / 100)
|
||||||
|
), 1));
|
||||||
|
}
|
||||||
|
|
||||||
function uniqueMechanics(mechanics) {
|
function uniqueMechanics(mechanics) {
|
||||||
const seenIds = new Set();
|
const seenIds = new Set();
|
||||||
const seenSpellIds = new Set();
|
const seenSpellIds = new Set();
|
||||||
@@ -1233,6 +1245,7 @@ async function compile() {
|
|||||||
const id = encounter.eventOnly
|
const id = encounter.eventOnly
|
||||||
? "mutanus-spawn"
|
? "mutanus-spawn"
|
||||||
: candidate?.id ?? `encounter-${encounter.encounterId}`;
|
: candidate?.id ?? `encounter-${encounter.encounterId}`;
|
||||||
|
const spawnChance = initializationSpawnChance(encounter.creatureId, azerothCoreWorldAi);
|
||||||
usedBossSpawnIds.add(id);
|
usedBossSpawnIds.add(id);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -1240,6 +1253,7 @@ async function compile() {
|
|||||||
name: encounter.name,
|
name: encounter.name,
|
||||||
position,
|
position,
|
||||||
yaw: candidate?.yaw ?? 0,
|
yaw: candidate?.yaw ?? 0,
|
||||||
|
...(spawnChance < 1 ? { optional: true } : {}),
|
||||||
...(encounter.eventOnly ? { eventOnly: true } : {}),
|
...(encounter.eventOnly ? { eventOnly: true } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -1248,6 +1262,7 @@ async function compile() {
|
|||||||
const roamingPacks = [];
|
const roamingPacks = [];
|
||||||
for (const spawn of sourceSpawns) {
|
for (const spawn of sourceSpawns) {
|
||||||
const entry = Number(spawn.entityId.replace("entry-", ""));
|
const entry = Number(spawn.entityId.replace("entry-", ""));
|
||||||
|
const spawnChance = initializationSpawnChance(entry, azerothCoreWorldAi);
|
||||||
const isBoss = bossEntries.has(entry);
|
const isBoss = bossEntries.has(entry);
|
||||||
const transformedPosition = mapPosition(spawn.position);
|
const transformedPosition = mapPosition(spawn.position);
|
||||||
if (!isBoss && spawn.waypoints?.length >= 2) {
|
if (!isBoss && spawn.waypoints?.length >= 2) {
|
||||||
@@ -1269,6 +1284,7 @@ async function compile() {
|
|||||||
pathId: spawn.pathId,
|
pathId: spawn.pathId,
|
||||||
formationSource: "database-solo",
|
formationSource: "database-solo",
|
||||||
spawnMask: spawn.spawnMask,
|
spawnMask: spawn.spawnMask,
|
||||||
|
...(spawnChance < 1 ? { spawnChance } : {}),
|
||||||
waypoints,
|
waypoints,
|
||||||
members: [{
|
members: [{
|
||||||
id: "member",
|
id: "member",
|
||||||
@@ -1286,6 +1302,7 @@ async function compile() {
|
|||||||
position: transformedPosition,
|
position: transformedPosition,
|
||||||
yaw: spawn.yaw,
|
yaw: spawn.yaw,
|
||||||
spawnMask: spawn.spawnMask,
|
spawnMask: spawn.spawnMask,
|
||||||
|
...(spawnChance < 1 ? { spawnChance } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const boss of bossObjectives) {
|
for (const boss of bossObjectives) {
|
||||||
@@ -1337,10 +1354,11 @@ async function compile() {
|
|||||||
...roamingPacks.flatMap((pack) => pack.waypoints),
|
...roamingPacks.flatMap((pack) => pack.waypoints),
|
||||||
];
|
];
|
||||||
const bounds = finiteBounds(allPoints);
|
const bounds = finiteBounds(allPoints);
|
||||||
const bosses = bossObjectives.map(({ id, name, position, eventOnly }) => ({
|
const bosses = bossObjectives.map(({ id, name, position, optional, eventOnly }) => ({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
position,
|
position,
|
||||||
|
...(optional ? { optional: true } : {}),
|
||||||
...(eventOnly ? { eventOnly: true } : {}),
|
...(eventOnly ? { eventOnly: true } : {}),
|
||||||
}));
|
}));
|
||||||
const databaseKind = recipe.clientBuild === "3.3.5a-Epoch" || draft?.spawns?.length
|
const databaseKind = recipe.clientBuild === "3.3.5a-Epoch" || draft?.spawns?.length
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { threeToSoloNavMesh, threeToTiledNavMesh, NavMeshHelper } from "@recast-navigation/three";
|
import { threeToSoloNavMesh, threeToTiledNavMesh, NavMeshHelper } from "@recast-navigation/three";
|
||||||
import { Vector3 } from "three";
|
import { Vector3 } from "three";
|
||||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
|
import { MeshoptDecoder } from "meshoptimizer";
|
||||||
import {
|
import {
|
||||||
exists,
|
exists,
|
||||||
fixturesRoot,
|
fixturesRoot,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
nextTiledNavMeshSize,
|
nextTiledNavMeshSize,
|
||||||
planNavigationBuild,
|
planNavigationBuild,
|
||||||
|
shouldRetryFailedTiledBuildWithMonotone,
|
||||||
shouldUseMonotonePartition,
|
shouldUseMonotonePartition,
|
||||||
validateTiledBuild,
|
validateTiledBuild,
|
||||||
} from "./recast-policy.mjs";
|
} from "./recast-policy.mjs";
|
||||||
@@ -39,9 +41,10 @@ globalThis.ProgressEvent ??= class ProgressEvent {
|
|||||||
globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} });
|
globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} });
|
||||||
|
|
||||||
async function loadScene(file) {
|
async function loadScene(file) {
|
||||||
|
await MeshoptDecoder.ready;
|
||||||
const bytes = await readFile(file);
|
const bytes = await readFile(file);
|
||||||
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
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) {
|
function meshesIn(scene) {
|
||||||
@@ -273,9 +276,11 @@ export async function bakeNavigationFromCollisionAssets({
|
|||||||
const meshes = scenes.flatMap(meshesIn);
|
const meshes = scenes.flatMap(meshesIn);
|
||||||
if (!meshes.length) throw new Error(`${slug}: collision chunks contain no triangle meshes.`);
|
if (!meshes.length) throw new Error(`${slug}: collision chunks contain no triangle meshes.`);
|
||||||
const input = inspectNavigationInput(meshes);
|
const input = inspectNavigationInput(meshes);
|
||||||
let build = planNavigationBuild(input.bounds, settings.cellSize, {
|
const navigationPlan = (tileSize) => planNavigationBuild(input.bounds, settings.cellSize, {
|
||||||
tileSize: settings.tileSize,
|
tileSize,
|
||||||
|
...(settings.forceTiled === true ? { maxSoloAxis: 0, maxSoloCells: 0 } : {}),
|
||||||
});
|
});
|
||||||
|
let build = navigationPlan(settings.tileSize);
|
||||||
const generatorConfig = {
|
const generatorConfig = {
|
||||||
cs: settings.cellSize,
|
cs: settings.cellSize,
|
||||||
ch: settings.cellHeight,
|
ch: settings.cellHeight,
|
||||||
@@ -323,7 +328,20 @@ export async function bakeNavigationFromCollisionAssets({
|
|||||||
retainTileIntermediates,
|
retainTileIntermediates,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!generated.success) throw new Error(`${slug}: Recast generation failed: ${generated.error}`);
|
if (!generated.success) {
|
||||||
|
buildAttempts.push({
|
||||||
|
partition: tiledPartition,
|
||||||
|
tileSize: build.tileSize,
|
||||||
|
tileCount: build.tileCount,
|
||||||
|
valid: false,
|
||||||
|
failures: [{ x: null, y: null, message: generated.error }],
|
||||||
|
});
|
||||||
|
if (shouldRetryFailedTiledBuildWithMonotone(generated.error, tiledPartition)) {
|
||||||
|
tiledPartition = "monotone";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`${slug}: Recast generation failed: ${generated.error}`);
|
||||||
|
}
|
||||||
tiledValidation = validateTiledBuild(generated.intermediates, {
|
tiledValidation = validateTiledBuild(generated.intermediates, {
|
||||||
errorCategory: Recast.RC_LOG_ERROR,
|
errorCategory: Recast.RC_LOG_ERROR,
|
||||||
warningCategory: Recast.RC_LOG_WARNING,
|
warningCategory: Recast.RC_LOG_WARNING,
|
||||||
@@ -349,9 +367,7 @@ export async function bakeNavigationFromCollisionAssets({
|
|||||||
releaseTiledIntermediates(generated.intermediates);
|
releaseTiledIntermediates(generated.intermediates);
|
||||||
generated.navMesh.destroy();
|
generated.navMesh.destroy();
|
||||||
generated = null;
|
generated = null;
|
||||||
build = planNavigationBuild(input.bounds, settings.cellSize, {
|
build = navigationPlan(nextTileSize);
|
||||||
tileSize: nextTileSize,
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -362,9 +378,7 @@ export async function bakeNavigationFromCollisionAssets({
|
|||||||
generated.navMesh.destroy();
|
generated.navMesh.destroy();
|
||||||
generated = null;
|
generated = null;
|
||||||
tiledPartition = "monotone";
|
tiledPartition = "monotone";
|
||||||
build = planNavigationBuild(input.bounds, settings.cellSize, {
|
build = navigationPlan(settings.tileSize);
|
||||||
tileSize: settings.tileSize,
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const sample = tiledValidation.failures
|
const sample = tiledValidation.failures
|
||||||
|
|||||||
@@ -146,3 +146,8 @@ export function shouldUseMonotonePartition(validation) {
|
|||||||
(failure) => /^rcBuildRegions: \d+ overlapping regions\.$/.test(failure.message),
|
(failure) => /^rcBuildRegions: \d+ overlapping regions\.$/.test(failure.message),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldRetryFailedTiledBuildWithMonotone(error, partition) {
|
||||||
|
return partition === "watershed"
|
||||||
|
&& /failed to triangulate contours/i.test(String(error ?? ""));
|
||||||
|
}
|
||||||
@@ -25,16 +25,31 @@ async function main() {
|
|||||||
new Uint8Array(fs.readFileSync(file.path)),
|
new Uint8Array(fs.readFileSync(file.path)),
|
||||||
{ uri: file.file, maxIssues: 1_000 },
|
{ uri: file.file, maxIssues: 1_000 },
|
||||||
);
|
);
|
||||||
|
const validatorMessages = report.issues.messages;
|
||||||
|
const hasBasisu = validatorMessages.some((issue) => (
|
||||||
|
issue.code === "UNSUPPORTED_EXTENSION" && issue.message.includes("'KHR_texture_basisu'")
|
||||||
|
));
|
||||||
|
const actionable = validatorMessages.filter((issue) => {
|
||||||
|
if (issue.code === "UNSUPPORTED_EXTENSION"
|
||||||
|
&& /'(?:EXT_mesh_gpu_instancing|KHR_texture_basisu)'/.test(issue.message)) return false;
|
||||||
|
if (issue.code === "VALUE_NOT_IN_LIST"
|
||||||
|
&& hasBasisu
|
||||||
|
&& /^\/images\/\d+\/mimeType$/.test(issue.pointer ?? "")
|
||||||
|
&& issue.message.includes("'image/ktx2'")) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
rows.push({
|
rows.push({
|
||||||
file: file.file,
|
file: file.file,
|
||||||
errors: report.issues.numErrors,
|
errors: actionable.filter((issue) => issue.severity === 0).length,
|
||||||
warnings: report.issues.numWarnings,
|
warnings: actionable.filter((issue) => issue.severity === 1).length,
|
||||||
|
ignoredKnownToolingIssues: validatorMessages.length - actionable.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const result = {
|
const result = {
|
||||||
files: rows.length,
|
files: rows.length,
|
||||||
errors: rows.reduce((sum, row) => sum + row.errors, 0),
|
errors: rows.reduce((sum, row) => sum + row.errors, 0),
|
||||||
warnings: rows.reduce((sum, row) => sum + row.warnings, 0),
|
warnings: rows.reduce((sum, row) => sum + row.warnings, 0),
|
||||||
|
ignoredKnownToolingIssues: rows.reduce((sum, row) => sum + row.ignoredKnownToolingIssues, 0),
|
||||||
nonGreen: rows.filter((row) => row.errors || row.warnings),
|
nonGreen: rows.filter((row) => row.errors || row.warnings),
|
||||||
};
|
};
|
||||||
console.log(JSON.stringify(result, null, 2));
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
|||||||
@@ -47,15 +47,23 @@ reviewed mechanics passes.
|
|||||||
|
|
||||||
## Coordinate conversion
|
## Coordinate conversion
|
||||||
|
|
||||||
RuneWaker server positions are world coordinates. The environment ROS is
|
RuneWaker server positions are world coordinates. The reviewed primary WDB
|
||||||
placed by one WDB descriptor. Spawns must be moved into that ROS's local frame
|
descriptor remains the coordinate anchor, while every static and reviewed
|
||||||
before the RuneWaker-to-Three coordinate conversion:
|
dynamic WDB resource is assembled relative to that anchor. Spawns use:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
sourceLocal = inverse(primaryWdbPlacement) * serverPosition
|
sourceLocal = inverse(primaryWdbPlacement) * serverPosition
|
||||||
threeLocal = (sourceLocal.x, sourceLocal.y, -sourceLocal.z) * metersPerSourceUnit
|
threeLocal = (sourceLocal.x, sourceLocal.y, -sourceLocal.z) * metersPerSourceUnit
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Every resource placement uses the full matrix equivalent, including translation,
|
||||||
|
quaternion rotation, and nonuniform scale:
|
||||||
|
|
||||||
|
```text
|
||||||
|
threeMatrix = S * inverse(primaryTRS) * placementTRS * inverse(S)
|
||||||
|
S = scale(metersPerSourceUnit, metersPerSourceUnit, -metersPerSourceUnit)
|
||||||
|
```
|
||||||
|
|
||||||
The current calibrated scale is `0.1` meter per RuneWaker source unit. The
|
The current calibrated scale is `0.1` meter per RuneWaker source unit. The
|
||||||
authoritative test-dungeon table uses 360 degrees per turn:
|
authoritative test-dungeon table uses 360 degrees per turn:
|
||||||
|
|
||||||
@@ -283,12 +291,17 @@ Review:
|
|||||||
- collision chunks for decorative blockers;
|
- collision chunks for decorative blockers;
|
||||||
- Recast report and entrance projection;
|
- Recast report and entrance projection;
|
||||||
- WDB anchor in `import-metadata.json`;
|
- WDB anchor in `import-metadata.json`;
|
||||||
|
- complete placement accounting in `wdb-placement-manifest.json`;
|
||||||
|
- source-evidenced dynamic placements are absent from static collision and have separately packaged runtime assets;
|
||||||
|
- dynamic filename candidates remain static and review-required until a reviewed recipe rule exists;
|
||||||
- Khronos validation output.
|
- Khronos validation output.
|
||||||
|
|
||||||
Secondary WDB doodads/effects remain audited. Repeated primary room placements
|
Every valid WDB ROS resource is exported once into
|
||||||
and documented source-coordinate support/fallback geometry are automated; a
|
`runewaker-export-work/resource-cache/<resource-key>`, then reused for all of its
|
||||||
complete arbitrary multi-ROS WDB scene still requires explicit placement,
|
placements and across dungeon builds. Missing ROS files, non-ROS descriptors,
|
||||||
material, collision, and performance review.
|
export failures, and non-mesh particle/light/sound/script components are recorded
|
||||||
|
instead of receiving substitute geometry. The preview and editable Blender file
|
||||||
|
remain primary-ROS diagnostics; review the assembled GLB for final coverage.
|
||||||
|
|
||||||
### 6. Review and build creature actors
|
### 6. Review and build creature actors
|
||||||
|
|
||||||
@@ -401,6 +414,13 @@ Each mechanic must specify trigger, targets, timing, reset behavior, persistence
|
|||||||
offline behavior, and multiplayer synchronization boundary before being marked
|
offline behavior, and multiplayer synchronization boundary before being marked
|
||||||
implemented.
|
implemented.
|
||||||
|
|
||||||
|
Run `npm run runewaker:audit:encounters` after changing source snapshots,
|
||||||
|
mechanics evidence, generated populations, or the shared boss-mechanic catalog.
|
||||||
|
Its JSON report is the exhaustive implementation ledger. A source-spawn row or
|
||||||
|
evidence record may be promoted from
|
||||||
|
`deferred-source-gap` only when an exact executable mapping and its clearing test
|
||||||
|
are both present; a regex category or suggestive object name is not sufficient.
|
||||||
|
|
||||||
### 9. Register the dungeon
|
### 9. Register the dungeon
|
||||||
|
|
||||||
Use the generated environment metadata and population constants to create the
|
Use the generated environment metadata and population constants to create the
|
||||||
@@ -443,6 +463,8 @@ errors, WebGL errors, and mobile/desktop performance.
|
|||||||
| `public/assets/creatures/<slug>/*` | Browser-shipping actor GLBs/manifest. |
|
| `public/assets/creatures/<slug>/*` | Browser-shipping actor GLBs/manifest. |
|
||||||
| `src/game/generated/*Population.generated.ts` | Runtime entities/spawns/objectives. |
|
| `src/game/generated/*Population.generated.ts` | Runtime entities/spawns/objectives. |
|
||||||
| `src/game/generated/*Mechanics.generated.ts` | Non-executable mechanics evidence summary. |
|
| `src/game/generated/*Mechanics.generated.ts` | Non-executable mechanics evidence summary. |
|
||||||
|
| `scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.*` | Deterministic per-row/per-signal fidelity and blocker ledger. |
|
||||||
|
| `scripts/runewaker-pipeline/RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.*` | Non-mutating WDB resource/placement coverage and unsupported-type audit. |
|
||||||
|
|
||||||
Generated runtime data must be reproducible from recipes plus portable snapshots
|
Generated runtime data must be reproducible from recipes plus portable snapshots
|
||||||
without SQL or network access.
|
without SQL or network access.
|
||||||
@@ -462,13 +484,13 @@ without SQL or network access.
|
|||||||
|
|
||||||
## Current deliberate limits
|
## Current deliberate limits
|
||||||
|
|
||||||
The generic scripts automate source discovery, primary environment conversion,
|
The generic scripts automate source discovery, source-hash-validated multi-resource WDB environment conversion,
|
||||||
portable population recovery, direct GR2 actor conversion, exact paperdoll
|
portable population recovery, direct GR2 actor conversion, exact paperdoll
|
||||||
assembly, source-safe direct-damage attacks, spawn generation, repeated-room
|
assembly, source-safe direct-damage attacks, spawn generation, repeated-room
|
||||||
assembly, source-anchor navigation, mechanics evidence extraction, runtime
|
assembly, full placement transforms, static navigation, reviewed dynamic-object packaging, mechanics evidence extraction, runtime
|
||||||
registration, and validation. They do not pretend that arbitrary Lua/AutoPlot
|
registration, and validation. They do not pretend that arbitrary Lua/AutoPlot
|
||||||
code can be safely translated without review. Disconnected navmesh islands,
|
code can be safely translated without review. Disconnected navmesh islands,
|
||||||
doors, phases, summons, patrols, timers, interactions, loot semantics, arbitrary
|
doors, phases, summons, patrols, timers, interactions, loot semantics, arbitrary
|
||||||
multi-ROS placement assembly, source-exact damage formulas, and multiplayer
|
source behavior for unreviewed dynamic candidates, source-exact damage formulas, and multiplayer
|
||||||
encounter synchronization remain explicit per-instance engineering work until
|
encounter synchronization remain explicit per-instance engineering work until
|
||||||
implemented and playtested.
|
implemented and playtested.
|
||||||
@@ -3,11 +3,12 @@
|
|||||||
This pipeline converts preserved RuneWaker `.ros` dungeon assets into the same
|
This pipeline converts preserved RuneWaker `.ros` dungeon assets into the same
|
||||||
browser-facing GLB roles used by HealerMan's WoW dungeon pipeline:
|
browser-facing GLB roles used by HealerMan's WoW dungeon pipeline:
|
||||||
|
|
||||||
1. the preserved Rune engine reads the WDB entity table and exports the primary ROS as an OBJ/MTL bridge;
|
1. the preserved Rune engine reads every WDB descriptor and exports each unique ROS once into a normalized-path cache validated by the source SHA-256;
|
||||||
2. Microsoft `texconv` decodes the legacy DDS textures to PNG;
|
2. Microsoft `texconv` decodes each cached resource's legacy DDS textures to PNG;
|
||||||
3. Blender converts the Y-up source to textured visual and chunked collision GLBs;
|
3. Blender converts the Y-up resources to reusable visual and collision GLBs;
|
||||||
4. glTF Transform optimizes the visual asset with meshopt;
|
4. the assembler applies every placement's full translation/quaternion/scale transform relative to the reviewed primary WDB anchor;
|
||||||
5. HealerMan's Recast stage bakes navigation and the Khronos validator gates every GLB.
|
5. glTF Transform instances and optimizes the assembled visual with meshopt;
|
||||||
|
6. HealerMan's Recast stage bakes static navigation and the Khronos validator gates every GLB.
|
||||||
|
|
||||||
Run the Forsaken Abbey pilot from the HealerMan root:
|
Run the Forsaken Abbey pilot from the HealerMan root:
|
||||||
|
|
||||||
@@ -24,10 +25,29 @@ Optional environment variables:
|
|||||||
|
|
||||||
The shipping environment is written to
|
The shipping environment is written to
|
||||||
`src/assets/game/dungeons/forsaken-abbey` and registered as a playable
|
`src/assets/game/dungeons/forsaken-abbey` and registered as a playable
|
||||||
dungeon. The build reads all 184 WDB entity descriptors,
|
dungeon. The build reads all 184 WDB entity descriptors, uses the primary ROS
|
||||||
uses the primary ROS placement as the server-to-local anchor, reads the entrance
|
placement only as the server-to-local coordinate anchor, reads the entrance from
|
||||||
from `Resource/luascript/03242.lua`, and requires that entrance to project within
|
`Resource/luascript/03242.lua`, and requires that entrance to project within 2
|
||||||
2 meters of the generated navmesh.
|
meters of the generated navmesh.
|
||||||
|
|
||||||
|
All valid ROS placement resources are assembled by default. Use `--primary-only`
|
||||||
|
only for a deliberate compatibility/debug build. The build writes
|
||||||
|
`wdb-placement-manifest.json` with stable placement IDs, source TRS, the complete
|
||||||
|
primary-local Three.js matrix, resource-cache keys, bounds, flags, asset status,
|
||||||
|
and reviewed dynamic-object metadata. Static placements enter visual, collision,
|
||||||
|
and navigation assets. Explicit dynamic placements ship separately so runtime
|
||||||
|
state can move, hide, or replace them without baking their collision into the
|
||||||
|
static world.
|
||||||
|
|
||||||
|
Audit the checked-in WDB coverage without regenerating GLBs:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run runewaker:audit:environment-assembly
|
||||||
|
```
|
||||||
|
|
||||||
|
See
|
||||||
|
[RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.md](./RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.md)
|
||||||
|
for unsupported types and dynamic candidates that still need source review.
|
||||||
|
|
||||||
Population and creature assets are separate, offline-capable stages:
|
Population and creature assets are separate, offline-capable stages:
|
||||||
|
|
||||||
@@ -36,6 +56,15 @@ npm run runewaker:forsaken:actors
|
|||||||
npm run runewaker:forsaken:population
|
npm run runewaker:forsaken:population
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Source-shaped combat coefficients and boss loot are refreshed independently:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run runewaker:spells:refresh
|
||||||
|
npm run runewaker:loot:refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
See [RUNEWAKER_FORMULA_AND_LOOT.md](./RUNEWAKER_FORMULA_AND_LOOT.md) for the exact formula mapping boundary and [RUNEWAKER_LOOT_IMPORT_REPORT.md](./RUNEWAKER_LOOT_IMPORT_REPORT.md) for every boss's loot status.
|
||||||
|
|
||||||
Only an explicit forensic refresh connects to the isolated development SQL
|
Only an explicit forensic refresh connects to the isolated development SQL
|
||||||
instance:
|
instance:
|
||||||
|
|
||||||
@@ -121,8 +150,10 @@ Spell conversion is deliberately fail-closed. Only a MagicObject component
|
|||||||
positively classified as direct HP damage becomes an executable HealerMan
|
positively classified as direct HP damage becomes an executable HealerMan
|
||||||
attack. Buffs, healing, summons, plot hooks, and unknown effects remain
|
attack. Buffs, healing, summons, plot hooks, and unknown effects remain
|
||||||
evidence-only. The portable mapping preserves source spell identity, name,
|
evidence-only. The portable mapping preserves source spell identity, name,
|
||||||
damage family, school, range, and cooldown. The current 0.85 damage coefficient
|
damage family, school, range, cooldown, source power fields, and source-code
|
||||||
is HealerMan-authored and is not represented as the original RuneWaker formula.
|
formula provenance. Live damage uses a labeled HealerMan baseline adapter because
|
||||||
|
the required native ATK/MATK, DEF/MDEF, weapon, resistance, critical, level-rate,
|
||||||
|
and global-rate inputs are not packaged; it is not represented as source-exact.
|
||||||
|
|
||||||
`runewaker:bosses:refresh` first rebuilds that portable spell snapshot, then
|
`runewaker:bosses:refresh` first rebuilds that portable spell snapshot, then
|
||||||
statically follows each packaged boss's preserved AutoPlot/template callbacks
|
statically follows each packaged boss's preserved AutoPlot/template callbacks
|
||||||
@@ -165,6 +196,21 @@ accepts that reviewed override only as `bossObjectiveOrder: { kind:
|
|||||||
"source-authored", spawnIds: [...], evidence: [...] }`; it rejects missing
|
"source-authored", spawnIds: [...], evidence: [...] }`; it rejects missing
|
||||||
evidence, duplicates, or an order that does not account for every boss spawn.
|
evidence, duplicates, or an order that does not account for every boss spawn.
|
||||||
|
|
||||||
|
The same gameplay audit also regenerates the deterministic
|
||||||
|
[RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md](./RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md)
|
||||||
|
and its exhaustive JSON companion. The ledger records all deferred source spawn
|
||||||
|
rows and direct mechanics evidence with stable IDs, source files and hashes,
|
||||||
|
fidelity, implementation status, blocker, next work, and a clearing test.
|
||||||
|
Category totals are overlapping review labels, never a claimed count of unique
|
||||||
|
mechanics. Unproven behavior remains `deferred-source-gap` instead of being
|
||||||
|
silently inferred from a name or regex match.
|
||||||
|
|
||||||
|
Run only that ledger and its pipeline tests with:
|
||||||
|
|
||||||
|
~~~powershell
|
||||||
|
npm run runewaker:audit:encounters
|
||||||
|
~~~
|
||||||
|
|
||||||
Run the component-level navigation review separately:
|
Run the component-level navigation review separately:
|
||||||
|
|
||||||
~~~powershell
|
~~~powershell
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Required top-level fields:
|
|||||||
- `title`: display/source title.
|
- `title`: display/source title.
|
||||||
- `source`: preserved WDB, primary ROS, config, and entrance evidence.
|
- `source`: preserved WDB, primary ROS, config, and entrance evidence.
|
||||||
- `coordinateSystem`: source axes, units, and direction units.
|
- `coordinateSystem`: source axes, units, and direction units.
|
||||||
- `navigation`: Recast settings.
|
- `navigation`: Recast settings. `partition` may select `watershed` or `monotone`; `forceTiled` is an explicit source-geometry workaround when a compact solo heightfield cannot triangulate its contours.
|
||||||
- `review`: human review states.
|
- `review`: human review states.
|
||||||
- `exceptions`: known limitations retained in metadata.
|
- `exceptions`: known limitations retained in metadata.
|
||||||
|
|
||||||
@@ -27,6 +27,25 @@ Required top-level fields:
|
|||||||
- `entryLua` / `entryConstantPrefix`: authoritative entry constants; or
|
- `entryLua` / `entryConstantPrefix`: authoritative entry constants; or
|
||||||
- `entryCoordinates`: explicit position/direction with `reviewRequired`.
|
- `entryCoordinates`: explicit position/direction with `reviewRequired`.
|
||||||
|
|
||||||
|
Optional `source.wdbAssembly` fields:
|
||||||
|
|
||||||
|
- `mode`: `all-ros` by default, or `primary-only` for a deliberate compatibility build.
|
||||||
|
- `dynamicPlacements`: reviewed dynamic object selectors. Each entry requires a
|
||||||
|
stable `id`, `kind`, `evidence`, and either an exact WDB `guid` or normalized
|
||||||
|
`resource`; optional `initialState` and `collision` describe the runtime contract.
|
||||||
|
|
||||||
|
Valid ROS placements not selected as dynamic are static. Filename hints are
|
||||||
|
reported for review but never infer stateful behavior. Non-ROS descriptors,
|
||||||
|
invalid transforms, missing resources, and exporter failures remain explicit
|
||||||
|
unsupported records in the placement manifest.
|
||||||
|
|
||||||
|
The full placement transform is preserved as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
threeMatrix = S * inverse(primaryTRS) * placementTRS * inverse(S)
|
||||||
|
S = scale(metersPerSourceUnit, metersPerSourceUnit, -metersPerSourceUnit)
|
||||||
|
```
|
||||||
|
|
||||||
## Population recipe
|
## Population recipe
|
||||||
|
|
||||||
Required fields:
|
Required fields:
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,121 @@
|
|||||||
|
# RuneWaker encounter implementation and fidelity ledger
|
||||||
|
|
||||||
|
Structural validation: **green**
|
||||||
|
Content completion: **review-required**
|
||||||
|
|
||||||
|
This report is deterministic and offline. Its JSON companion inventories every deferred
|
||||||
|
source spawn row and every direct mechanics evidence record. A regex category membership
|
||||||
|
is a review label, not a unique mechanic; one evidence record may belong to several categories.
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
- 30 dungeon packages.
|
||||||
|
- 4113 deferred source rows with stable spawn IDs.
|
||||||
|
- 1524 unique mechanics evidence records: 329 Lua lines and 1195 template references.
|
||||||
|
- 1146 overlapping category memberships; this number is intentionally not reported as mechanics.
|
||||||
|
- 388 evidence records have an exact executable source-spell mapping.
|
||||||
|
- 1136 evidence records remain source-review gaps.
|
||||||
|
|
||||||
|
## Status definitions
|
||||||
|
|
||||||
|
- `verified`: an exact source identifier is present in executable runtime data and checked by the audit.
|
||||||
|
- `implemented`: executable behavior exists, but source fidelity is incomplete or explicitly authored.
|
||||||
|
- `deferred-source-gap`: source context does not prove the full behavior; the generator never guesses it.
|
||||||
|
- `deferred-asset-gap`: behavior is understood but a required asset is unavailable.
|
||||||
|
- `deferred-runtime-gap`: behavior is understood but the runtime primitive is unavailable.
|
||||||
|
- `excluded-non-gameplay`: a reviewed source item is confirmed to have no gameplay behavior.
|
||||||
|
- `intentional-empty`: the authoritative packaged snapshot contains no population rows.
|
||||||
|
|
||||||
|
## Per-dungeon inventory
|
||||||
|
|
||||||
|
| Dungeon | Source rows | Deferred rows | Evidence | Verified | Source gaps | Bosses | Package status |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
||||||
|
| aeternal-circle | 514 | 391 | 47 | 13 | 34 | 0 | deferred-source-gap |
|
||||||
|
| barren-caves | 63 | 15 | 14 | 7 | 7 | 1 | verified |
|
||||||
|
| cave-water-dragon | 144 | 47 | 15 | 2 | 13 | 2 | verified |
|
||||||
|
| demon-stronghold | 439 | 256 | 38 | 12 | 26 | 1 | verified |
|
||||||
|
| demon-stronghold-125 | 0 | 0 | 0 | 0 | 0 | 0 | intentional-empty |
|
||||||
|
| dungeon-of-dalanis | 229 | 162 | 26 | 5 | 21 | 0 | deferred-source-gap |
|
||||||
|
| forsaken-abbey | 251 | 101 | 77 | 0 | 77 | 8 | verified |
|
||||||
|
| grafu-castle | 249 | 193 | 50 | 10 | 40 | 0 | deferred-source-gap |
|
||||||
|
| hall-of-survivors | 363 | 201 | 84 | 34 | 50 | 9 | verified |
|
||||||
|
| hoto | 401 | 94 | 95 | 28 | 67 | 5 | verified |
|
||||||
|
| ice-dwarf-kingdom | 651 | 52 | 87 | 36 | 51 | 5 | verified |
|
||||||
|
| kal-turok-hive | 4 | 3 | 10 | 4 | 6 | 0 | deferred-source-gap |
|
||||||
|
| kalins-shrine | 289 | 124 | 294 | 17 | 277 | 5 | verified |
|
||||||
|
| kawaks-tomb | 293 | 176 | 46 | 7 | 39 | 1 | verified |
|
||||||
|
| kulech-bones-nest | 287 | 133 | 59 | 18 | 41 | 1 | verified |
|
||||||
|
| lair-demon-dragon | 532 | 429 | 28 | 9 | 19 | 0 | deferred-source-gap |
|
||||||
|
| mystic-altar | 398 | 147 | 68 | 26 | 42 | 3 | verified |
|
||||||
|
| paspers-shrine | 255 | 31 | 22 | 6 | 16 | 5 | verified |
|
||||||
|
| raksha-temple | 487 | 364 | 85 | 21 | 64 | 1 | verified |
|
||||||
|
| sand-vortex | 2 | 1 | 2 | 0 | 2 | 1 | verified |
|
||||||
|
| sardo-castle | 247 | 176 | 53 | 12 | 41 | 3 | verified |
|
||||||
|
| the-origin | 510 | 322 | 65 | 29 | 36 | 2 | verified |
|
||||||
|
| tomb-seven-heroes | 439 | 199 | 101 | 32 | 69 | 0 | deferred-source-gap |
|
||||||
|
| treasure-trove | 685 | 188 | 81 | 34 | 47 | 3 | verified |
|
||||||
|
| warnorken-arena | 40 | 39 | 2 | 0 | 2 | 0 | deferred-source-gap |
|
||||||
|
| ystra-labyrinth-boss1 | 17 | 14 | 12 | 2 | 10 | 3 | verified |
|
||||||
|
| ystra-labyrinth-boss2 | 3 | 2 | 3 | 1 | 2 | 1 | verified |
|
||||||
|
| ystra-labyrinth-boss3 | 7 | 6 | 2 | 0 | 2 | 1 | verified |
|
||||||
|
| zurhidon-stronghold | 400 | 247 | 58 | 23 | 35 | 0 | deferred-source-gap |
|
||||||
|
| zurhidon-stronghold-124 | 0 | 0 | 0 | 0 | 0 | 0 | intentional-empty |
|
||||||
|
|
||||||
|
## Four explicit fallback bosses
|
||||||
|
|
||||||
|
| Boss | Runtime ID | Status | Fidelity | Blocker |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Necromage | `rw-fa-100070` | implemented | healer-man-authored | Preserved source evidence does not expose an executable original combat kit. |
|
||||||
|
| Flowing Chaos | `rw-fa-100561` | implemented | healer-man-authored | Preserved source evidence does not expose an executable original combat kit. |
|
||||||
|
| Cruel Eater | `rw-fa-100562` | implemented | healer-man-authored | Preserved source evidence does not expose an executable original combat kit. |
|
||||||
|
| Hollow Shell | `rw-fa-100563` | implemented | healer-man-authored | Preserved source evidence does not expose an executable original combat kit. |
|
||||||
|
|
||||||
|
These attacks are intentionally playable and telegraphed HealerMan behavior. They are not
|
||||||
|
presented as original RuneWaker mechanics until authoritative source behavior is recovered.
|
||||||
|
|
||||||
|
## Non-empty packages without confirmed boss objectives
|
||||||
|
|
||||||
|
- `aeternal-circle`: 514 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||||
|
- `dungeon-of-dalanis`: 229 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||||
|
- `grafu-castle`: 249 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||||
|
- `kal-turok-hive`: 4 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||||
|
- `lair-demon-dragon`: 532 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||||
|
- `tomb-seven-heroes`: 439 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||||
|
- `warnorken-arena`: 40 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||||
|
- `zurhidon-stronghold`: 400 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||||
|
|
||||||
|
## Intentional empty variants
|
||||||
|
|
||||||
|
- `demon-stronghold-125` (Zone 125): The authoritative packaged snapshot contains no active population rows.
|
||||||
|
- `zurhidon-stronghold-124` (Zone 124): The authoritative packaged snapshot contains no active population rows.
|
||||||
|
|
||||||
|
## Evidence category memberships
|
||||||
|
|
||||||
|
| Category | Evidence records carrying the label |
|
||||||
|
| --- | ---: |
|
||||||
|
| death | 100 |
|
||||||
|
| door | 23 |
|
||||||
|
| interaction | 166 |
|
||||||
|
| loot | 77 |
|
||||||
|
| movement | 6 |
|
||||||
|
| phase | 58 |
|
||||||
|
| spell | 679 |
|
||||||
|
| summon | 11 |
|
||||||
|
| timer | 26 |
|
||||||
|
|
||||||
|
## Remaining work ledger
|
||||||
|
|
||||||
|
| Blocker group | Items | Clearing work |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| deferredSourceRows | 4113 | Review source semantics and add explicit source-spawn encounter mappings. |
|
||||||
|
| unresolvedMechanicsEvidence | 1136 | Review surrounding Lua/template context; specify full trigger, effect, and reset behavior. |
|
||||||
|
| adaptedSpellFormula | 388 | Recover native formula inputs before claiming coefficient-level fidelity. |
|
||||||
|
| sourceGapFallbackBosses | 4 | Recover authoritative combat kits and replace only with source-backed modular mechanics. |
|
||||||
|
| nonEmptyZeroBossPackages | 8 | Recover authoritative boss membership and objective order. |
|
||||||
|
|
||||||
|
The exhaustive per-item blocker, next-work instruction, clearing test, source IDs, source
|
||||||
|
files, and SHA-256 hashes are in `RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json`.
|
||||||
|
|
||||||
|
## Validation issues
|
||||||
|
|
||||||
|
- None.
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"generatedBy": "scripts/runewaker-pipeline/audit-environment-assembly.mjs",
|
||||||
|
"deterministic": true,
|
||||||
|
"validationStatus": "green",
|
||||||
|
"reviewStatus": "review-required",
|
||||||
|
"summary": {
|
||||||
|
"dungeons": 30,
|
||||||
|
"sourceDescriptors": 15976,
|
||||||
|
"uniqueDungeonResources": 2799,
|
||||||
|
"staticPlacements": 15976,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 107,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0
|
||||||
|
},
|
||||||
|
"unsupportedResourceTypes": [
|
||||||
|
"WDB placement resources other than ROS are recorded but not converted.",
|
||||||
|
"Particle, light, sound, camera, and script-only components absent from ROS mesh descriptors remain unsupported.",
|
||||||
|
"Animated/stateful behavior remains unsupported until an exact reviewed dynamic-placement rule supplies its kind, initial state, collision policy, and evidence.",
|
||||||
|
"A ROS that the preserved exporter cannot decode is skipped, recorded with its resource path and failure, and never replaced with guessed geometry."
|
||||||
|
],
|
||||||
|
"issues": [],
|
||||||
|
"dungeons": [
|
||||||
|
{
|
||||||
|
"id": "aeternal-circle",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/aeternal-circle.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/aeternal-circle/wdb-report.json",
|
||||||
|
"sourceDescriptors": 575,
|
||||||
|
"uniqueResources": 182,
|
||||||
|
"staticPlacements": 575,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 54,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "barren-caves",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/barren-caves.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/barren-caves/wdb-report.json",
|
||||||
|
"sourceDescriptors": 1,
|
||||||
|
"uniqueResources": 1,
|
||||||
|
"staticPlacements": 1,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cave-water-dragon",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/cave-water-dragon.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/cave-water-dragon/wdb-report.json",
|
||||||
|
"sourceDescriptors": 121,
|
||||||
|
"uniqueResources": 16,
|
||||||
|
"staticPlacements": 121,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "demon-stronghold",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/demon-stronghold/wdb-report.json",
|
||||||
|
"sourceDescriptors": 90,
|
||||||
|
"uniqueResources": 4,
|
||||||
|
"staticPlacements": 90,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "demon-stronghold-125",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold-125.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/demon-stronghold-125/wdb-report.json",
|
||||||
|
"sourceDescriptors": 90,
|
||||||
|
"uniqueResources": 4,
|
||||||
|
"staticPlacements": 90,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dungeon-of-dalanis",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/dungeon-of-dalanis.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/dungeon-of-dalanis/wdb-report.json",
|
||||||
|
"sourceDescriptors": 652,
|
||||||
|
"uniqueResources": 124,
|
||||||
|
"staticPlacements": 652,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 5,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "forsaken-abbey",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/forsaken-abbey.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/forsaken-abbey/wdb-report.json",
|
||||||
|
"sourceDescriptors": 184,
|
||||||
|
"uniqueResources": 26,
|
||||||
|
"staticPlacements": 184,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 1,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grafu-castle",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/grafu-castle.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/grafu-castle/wdb-report.json",
|
||||||
|
"sourceDescriptors": 899,
|
||||||
|
"uniqueResources": 286,
|
||||||
|
"staticPlacements": 899,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 17,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hall-of-survivors",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/hall-of-survivors.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/hall-of-survivors/wdb-report.json",
|
||||||
|
"sourceDescriptors": 296,
|
||||||
|
"uniqueResources": 32,
|
||||||
|
"staticPlacements": 296,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hoto",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/hoto.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/hoto/wdb-report.json",
|
||||||
|
"sourceDescriptors": 1627,
|
||||||
|
"uniqueResources": 119,
|
||||||
|
"staticPlacements": 1627,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ice-dwarf-kingdom",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/ice-dwarf-kingdom.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/ice-dwarf-kingdom/wdb-report.json",
|
||||||
|
"sourceDescriptors": 1054,
|
||||||
|
"uniqueResources": 209,
|
||||||
|
"staticPlacements": 1054,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kal-turok-hive",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/kal-turok-hive.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/kal-turok-hive/wdb-report.json",
|
||||||
|
"sourceDescriptors": 2,
|
||||||
|
"uniqueResources": 2,
|
||||||
|
"staticPlacements": 2,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 1,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kalins-shrine",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/kalins-shrine.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/kalins-shrine/wdb-report.json",
|
||||||
|
"sourceDescriptors": 943,
|
||||||
|
"uniqueResources": 133,
|
||||||
|
"staticPlacements": 943,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kawaks-tomb",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/kawaks-tomb.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/kawaks-tomb/wdb-report.json",
|
||||||
|
"sourceDescriptors": 573,
|
||||||
|
"uniqueResources": 60,
|
||||||
|
"staticPlacements": 573,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kulech-bones-nest",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/kulech-bones-nest.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/kulech-bones-nest/wdb-report.json",
|
||||||
|
"sourceDescriptors": 199,
|
||||||
|
"uniqueResources": 52,
|
||||||
|
"staticPlacements": 199,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lair-demon-dragon",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/lair-demon-dragon.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/lair-demon-dragon/wdb-report.json",
|
||||||
|
"sourceDescriptors": 530,
|
||||||
|
"uniqueResources": 96,
|
||||||
|
"staticPlacements": 530,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mystic-altar",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/mystic-altar.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/mystic-altar/wdb-report.json",
|
||||||
|
"sourceDescriptors": 2096,
|
||||||
|
"uniqueResources": 48,
|
||||||
|
"staticPlacements": 2096,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "paspers-shrine",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/paspers-shrine.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/paspers-shrine/wdb-report.json",
|
||||||
|
"sourceDescriptors": 564,
|
||||||
|
"uniqueResources": 143,
|
||||||
|
"staticPlacements": 564,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "raksha-temple",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/raksha-temple.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/raksha-temple/wdb-report.json",
|
||||||
|
"sourceDescriptors": 1028,
|
||||||
|
"uniqueResources": 228,
|
||||||
|
"staticPlacements": 1028,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 11,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sand-vortex",
|
||||||
|
"mode": "primary-only-procedural-fallback",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/sand-vortex.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/sand-vortex/wdb-report.json",
|
||||||
|
"sourceDescriptors": 20,
|
||||||
|
"uniqueResources": 14,
|
||||||
|
"staticPlacements": 20,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sardo-castle",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/sardo-castle.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/sardo-castle/wdb-report.json",
|
||||||
|
"sourceDescriptors": 436,
|
||||||
|
"uniqueResources": 132,
|
||||||
|
"staticPlacements": 436,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "the-origin",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/the-origin.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/the-origin/wdb-report.json",
|
||||||
|
"sourceDescriptors": 693,
|
||||||
|
"uniqueResources": 220,
|
||||||
|
"staticPlacements": 693,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 17,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tomb-seven-heroes",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/tomb-seven-heroes.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/tomb-seven-heroes/wdb-report.json",
|
||||||
|
"sourceDescriptors": 450,
|
||||||
|
"uniqueResources": 56,
|
||||||
|
"staticPlacements": 450,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "treasure-trove",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/treasure-trove.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/treasure-trove/wdb-report.json",
|
||||||
|
"sourceDescriptors": 908,
|
||||||
|
"uniqueResources": 193,
|
||||||
|
"staticPlacements": 908,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 1,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "warnorken-arena",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/warnorken-arena.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/warnorken-arena/wdb-report.json",
|
||||||
|
"sourceDescriptors": 256,
|
||||||
|
"uniqueResources": 14,
|
||||||
|
"staticPlacements": 256,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ystra-labyrinth-boss1",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/ystra-labyrinth-boss1/wdb-report.json",
|
||||||
|
"sourceDescriptors": 3,
|
||||||
|
"uniqueResources": 1,
|
||||||
|
"staticPlacements": 3,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ystra-labyrinth-boss2",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss2.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/ystra-labyrinth-boss2/wdb-report.json",
|
||||||
|
"sourceDescriptors": 3,
|
||||||
|
"uniqueResources": 1,
|
||||||
|
"staticPlacements": 3,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ystra-labyrinth-boss3",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss3.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/ystra-labyrinth-boss3/wdb-report.json",
|
||||||
|
"sourceDescriptors": 3,
|
||||||
|
"uniqueResources": 1,
|
||||||
|
"staticPlacements": 3,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "zurhidon-stronghold",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/zurhidon-stronghold/wdb-report.json",
|
||||||
|
"sourceDescriptors": 840,
|
||||||
|
"uniqueResources": 201,
|
||||||
|
"staticPlacements": 840,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "zurhidon-stronghold-124",
|
||||||
|
"mode": "all-ros",
|
||||||
|
"recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold-124.json",
|
||||||
|
"wdbReport": "src/assets/game/dungeons/zurhidon-stronghold-124/wdb-report.json",
|
||||||
|
"sourceDescriptors": 840,
|
||||||
|
"uniqueResources": 201,
|
||||||
|
"staticPlacements": 840,
|
||||||
|
"dynamicPlacements": 0,
|
||||||
|
"dynamicReviewCandidates": 0,
|
||||||
|
"unsupportedPlacements": 0,
|
||||||
|
"missingSourceResources": 0,
|
||||||
|
"missingResources": [],
|
||||||
|
"unsupported": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# RuneWaker WDB environment assembly audit
|
||||||
|
|
||||||
|
Structural validation: **green**
|
||||||
|
Source-behavior review: **review-required**
|
||||||
|
|
||||||
|
This deterministic audit reads the checked-in WDB reports and environment recipes. It does
|
||||||
|
not convert or replace shipping GLBs. The builder now supports every valid ROS placement
|
||||||
|
resource with normalized-path model caching validated by source hashes and the complete translation/quaternion/scale
|
||||||
|
transform relative to the reviewed primary placement.
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
- 30 dungeon WDB packages and 15976 source descriptors.
|
||||||
|
- 2799 per-dungeon unique ROS resources (resources reused across dungeons share cache keys).
|
||||||
|
- 15976 static placements and 0 explicitly reviewed dynamic placements.
|
||||||
|
- 107 filename-based dynamic candidates remain static and review-required; names never prove behavior.
|
||||||
|
- 0 unsupported placement records and 0 missing source ROS resources.
|
||||||
|
|
||||||
|
| Dungeon | Mode | Descriptors | Resources | Static | Dynamic | Dynamic review | Unsupported | Missing ROS |
|
||||||
|
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| aeternal-circle | all-ros | 575 | 182 | 575 | 0 | 54 | 0 | 0 |
|
||||||
|
| barren-caves | all-ros | 1 | 1 | 1 | 0 | 0 | 0 | 0 |
|
||||||
|
| cave-water-dragon | all-ros | 121 | 16 | 121 | 0 | 0 | 0 | 0 |
|
||||||
|
| demon-stronghold | all-ros | 90 | 4 | 90 | 0 | 0 | 0 | 0 |
|
||||||
|
| demon-stronghold-125 | all-ros | 90 | 4 | 90 | 0 | 0 | 0 | 0 |
|
||||||
|
| dungeon-of-dalanis | all-ros | 652 | 124 | 652 | 0 | 5 | 0 | 0 |
|
||||||
|
| forsaken-abbey | all-ros | 184 | 26 | 184 | 0 | 1 | 0 | 0 |
|
||||||
|
| grafu-castle | all-ros | 899 | 286 | 899 | 0 | 17 | 0 | 0 |
|
||||||
|
| hall-of-survivors | all-ros | 296 | 32 | 296 | 0 | 0 | 0 | 0 |
|
||||||
|
| hoto | all-ros | 1627 | 119 | 1627 | 0 | 0 | 0 | 0 |
|
||||||
|
| ice-dwarf-kingdom | all-ros | 1054 | 209 | 1054 | 0 | 0 | 0 | 0 |
|
||||||
|
| kal-turok-hive | all-ros | 2 | 2 | 2 | 0 | 1 | 0 | 0 |
|
||||||
|
| kalins-shrine | all-ros | 943 | 133 | 943 | 0 | 0 | 0 | 0 |
|
||||||
|
| kawaks-tomb | all-ros | 573 | 60 | 573 | 0 | 0 | 0 | 0 |
|
||||||
|
| kulech-bones-nest | all-ros | 199 | 52 | 199 | 0 | 0 | 0 | 0 |
|
||||||
|
| lair-demon-dragon | all-ros | 530 | 96 | 530 | 0 | 0 | 0 | 0 |
|
||||||
|
| mystic-altar | all-ros | 2096 | 48 | 2096 | 0 | 0 | 0 | 0 |
|
||||||
|
| paspers-shrine | all-ros | 564 | 143 | 564 | 0 | 0 | 0 | 0 |
|
||||||
|
| raksha-temple | all-ros | 1028 | 228 | 1028 | 0 | 11 | 0 | 0 |
|
||||||
|
| sand-vortex | primary-only-procedural-fallback | 20 | 14 | 20 | 0 | 0 | 0 | 0 |
|
||||||
|
| sardo-castle | all-ros | 436 | 132 | 436 | 0 | 0 | 0 | 0 |
|
||||||
|
| the-origin | all-ros | 693 | 220 | 693 | 0 | 17 | 0 | 0 |
|
||||||
|
| tomb-seven-heroes | all-ros | 450 | 56 | 450 | 0 | 0 | 0 | 0 |
|
||||||
|
| treasure-trove | all-ros | 908 | 193 | 908 | 0 | 1 | 0 | 0 |
|
||||||
|
| warnorken-arena | all-ros | 256 | 14 | 256 | 0 | 0 | 0 | 0 |
|
||||||
|
| ystra-labyrinth-boss1 | all-ros | 3 | 1 | 3 | 0 | 0 | 0 | 0 |
|
||||||
|
| ystra-labyrinth-boss2 | all-ros | 3 | 1 | 3 | 0 | 0 | 0 | 0 |
|
||||||
|
| ystra-labyrinth-boss3 | all-ros | 3 | 1 | 3 | 0 | 0 | 0 | 0 |
|
||||||
|
| zurhidon-stronghold | all-ros | 840 | 201 | 840 | 0 | 0 | 0 | 0 |
|
||||||
|
| zurhidon-stronghold-124 | all-ros | 840 | 201 | 840 | 0 | 0 | 0 | 0 |
|
||||||
|
|
||||||
|
## Runtime-facing output
|
||||||
|
|
||||||
|
A full build writes `wdb-placement-manifest.json` beside the environment package. Each
|
||||||
|
placement has a stable WDB GUID ID, normalized resource/cache key, source TRS, primary-local
|
||||||
|
Three.js matrix, bounds, flags, asset status, and optional reviewed dynamic definition.
|
||||||
|
Static placements are assembled into the optimized visual and navigation collision. Reviewed
|
||||||
|
dynamic placements are omitted from static collision and packaged as separately addressable GLBs.
|
||||||
|
|
||||||
|
## Unsupported or review-required behavior
|
||||||
|
|
||||||
|
- WDB placement resources other than ROS are recorded but not converted.
|
||||||
|
- Particle, light, sound, camera, and script-only components absent from ROS mesh descriptors remain unsupported.
|
||||||
|
- Animated/stateful behavior remains unsupported until an exact reviewed dynamic-placement rule supplies its kind, initial state, collision policy, and evidence.
|
||||||
|
- A ROS that the preserved exporter cannot decode is skipped, recorded with its resource path and failure, and never replaced with guessed geometry.
|
||||||
|
- The primary-only top-down preview and editable `.blend` do not include the final merged WDB
|
||||||
|
assembly; the generated GLB and placement manifest are authoritative.
|
||||||
|
- Dynamic-candidate filenames are not enough to remove geometry from the static package. Add a
|
||||||
|
reviewed `source.wdbAssembly.dynamicPlacements` rule with evidence first.
|
||||||
|
|
||||||
|
## Validation issues
|
||||||
|
|
||||||
|
- None.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# RuneWaker combat formula and loot fidelity
|
||||||
|
|
||||||
|
## Combat damage
|
||||||
|
|
||||||
|
Imported RuneWaker damage mechanics now carry a `runewakerFormula` descriptor from `magicobject.db` into the generated enemy attack. The descriptor keeps the source spell/component ids, spell level, damage power, fixed value, damage-over-time base, calculation family, and source-code provenance.
|
||||||
|
|
||||||
|
The preserved implementation is in `SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp`:
|
||||||
|
|
||||||
|
- `CalPhyDamage` begins at line 3366.
|
||||||
|
- `CalMagicDamage` begins at line 3651.
|
||||||
|
- Both use the source ATK/DEF or MATK/MDEF ratio `max(0.2, 1 + ((attack + 100) - (defense + 200)) / max(attack + 100, defense + 200))`.
|
||||||
|
- Both also depend on native weapon or magic damage, level-difference tables, resistance processing, critical power, fixed-attribute lookup, and global NPC/PvP/AoE/target rates.
|
||||||
|
|
||||||
|
HealerMan does not have equivalent RuneWaker ATK, MATK, DEF, MDEF, `FixType`, native weapon/magic damage, level-rate tables, or global rate arrays. The adapter therefore does not label its live result as exact RuneWaker damage:
|
||||||
|
|
||||||
|
- Negative `Atk.DmgPower` values with magnitude at most 4 are treated as source ratios against HealerMan's level-scaled enemy damage budget; the native 0.2 minimum is retained.
|
||||||
|
- Point-valued source powers use the explicit `runtimeBaselineMultiplier` balancing adapter stored with the formula.
|
||||||
|
- HealerMan's existing hit table, armor, resistance, critical, aura, and absorb pipeline runs afterward and remains identified as HealerMan/WotLK-shaped mitigation.
|
||||||
|
- Unclassified formula records return zero from the adapter rather than executing guessed damage.
|
||||||
|
|
||||||
|
`resolveRunewakerNativeResolvedPower` exposes the source-shaped tail for later use. It requires every native-only input as an argument and supplies no silent one-valued defaults.
|
||||||
|
|
||||||
|
## Loot
|
||||||
|
|
||||||
|
`export-loot-catalog.mjs` reads each source-classified boss's 15 independent `NPCObjectDB.DropItem` slots. It preserves the original rate denominator of 100,000 and recursively expands `TreasureObjectDB`'s weighted, mutually exclusive outcomes and fixed `DropCount` quantities.
|
||||||
|
|
||||||
|
Runtime rules are intentionally bounded:
|
||||||
|
|
||||||
|
- Recognized leaves come only from `itemobject.db`, `armorobject.db`, `weaponobject.db`, `cardobject.db`, `runeobject.db`, `recipeobject.db`, and `keyitemobject.db`.
|
||||||
|
- Treasure recursion stops at 12 levels and cycles fail closed.
|
||||||
|
- Unknown object ids fail closed.
|
||||||
|
- Treasure tables with a Lua predicate fail closed because executing arbitrary preserved server Lua is outside the browser runtime.
|
||||||
|
- `NeedDLv` requirements are preserved and evaluated from the caller-provided source level delta.
|
||||||
|
- Source names are retained, but source stats, quality, and equipment slots are not inferred. Portable drops enter inventory as common, non-equipable `misc` items with zero stats.
|
||||||
|
|
||||||
|
The detailed status for every boss is generated in `RUNEWAKER_LOOT_IMPORT_REPORT.md`. The full probability graph is available in `public/data/runewaker-loot-catalog.json` and the runtime snapshot.
|
||||||
|
|
||||||
|
## Refresh and verification
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run runewaker:spells:refresh
|
||||||
|
npm run runewaker:loot:refresh
|
||||||
|
node --test scripts/runewaker-pipeline/runewaker-loot-import.test.mjs
|
||||||
|
vitest run src/game/runewakerCombatFormula.test.ts src/game/runewakerLootCatalog.test.ts
|
||||||
|
```
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
"movement/leash",
|
"movement/leash",
|
||||||
"clustering/aggro",
|
"clustering/aggro",
|
||||||
"combat/death state",
|
"combat/death state",
|
||||||
"loot policy",
|
"loot item stats/slots",
|
||||||
"objective ordering",
|
"objective ordering",
|
||||||
"procedural animation"
|
"procedural animation"
|
||||||
],
|
],
|
||||||
@@ -74,7 +74,8 @@
|
|||||||
"patrols",
|
"patrols",
|
||||||
"interactions",
|
"interactions",
|
||||||
"encounter resets",
|
"encounter resets",
|
||||||
"source loot"
|
"blocked source-loot predicates",
|
||||||
|
"source item stat/slot mapping"
|
||||||
],
|
],
|
||||||
"blockerGroups": {
|
"blockerGroups": {
|
||||||
"navigation": {
|
"navigation": {
|
||||||
@@ -245,10 +246,20 @@
|
|||||||
"safety": {
|
"safety": {
|
||||||
"runtimeSqlDependency": false,
|
"runtimeSqlDependency": false,
|
||||||
"unknownEffectsExecute": false,
|
"unknownEffectsExecute": false,
|
||||||
"coefficientsAreSourceExact": false
|
"coefficientsAreSourceExact": false,
|
||||||
|
"unavailableNativeInputsDefaulted": false,
|
||||||
|
"pointValuedDamageUsesLabeledScaledAdapter": true
|
||||||
},
|
},
|
||||||
"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."
|
"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."
|
||||||
},
|
},
|
||||||
|
"encounterImplementationLedger": {
|
||||||
|
"json": "scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json",
|
||||||
|
"markdown": "scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md",
|
||||||
|
"validationStatus": "green",
|
||||||
|
"completionStatus": "review-required",
|
||||||
|
"deferredSourceRows": 4113,
|
||||||
|
"uniqueMechanicsEvidence": 1524
|
||||||
|
},
|
||||||
"resolvedRuntimeObservations": [
|
"resolvedRuntimeObservations": [
|
||||||
{
|
{
|
||||||
"dungeonId": "paspers-shrine",
|
"dungeonId": "paspers-shrine",
|
||||||
@@ -266,7 +277,7 @@
|
|||||||
"demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.",
|
"demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.",
|
||||||
"zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.",
|
"zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.",
|
||||||
"4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.",
|
"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.",
|
"Source formula structure is preserved, but live damage uses a labeled HealerMan baseline adapter because the required native combat-stat inputs are not packaged.",
|
||||||
"61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence."
|
"61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence."
|
||||||
],
|
],
|
||||||
"dungeons": [
|
"dungeons": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# RuneWaker gameplay content audit
|
# RuneWaker gameplay content audit
|
||||||
|
|
||||||
Structural validation: **green**
|
Structural validation: **green**
|
||||||
Original-mechanics fidelity: **review-required**
|
Original-mechanics fidelity: **review-required**
|
||||||
|
|
||||||
This offline audit reads packaged recipes, snapshots, generated TypeScript, reports,
|
This offline audit reads packaged recipes, snapshots, generated TypeScript, reports,
|
||||||
@@ -16,6 +16,7 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
|
|||||||
- 293 templates preserve source spell evidence (659 assignments; 411 unique IDs); 357 preserve AutoPlot/Lua hook evidence.
|
- 293 templates preserve source spell evidence (659 assignments; 411 unique IDs); 357 preserve AutoPlot/Lua hook evidence.
|
||||||
- 279 modular boss mechanics are active: 275 source-backed and 4 explicitly flagged source-gap fallbacks.
|
- 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.
|
- Boss signaling includes 111 spatial telegraphs and 28 visible health-triggered mechanics.
|
||||||
|
- The exhaustive encounter implementation ledger tracks 4113 deferred source rows and 1524 unique mechanics evidence records without inflating overlapping regex labels.
|
||||||
|
|
||||||
## Authentic source data currently used
|
## Authentic source data currently used
|
||||||
|
|
||||||
@@ -30,10 +31,12 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
|
|||||||
- 436 entities use generated Basic Attack/Corrosive Slam.
|
- 436 entities use generated Basic Attack/Corrosive Slam.
|
||||||
- 0 attacks are builder exceptions outside the modular boss catalog.
|
- 0 attacks are builder exceptions outside the modular boss catalog.
|
||||||
- 483 executable attacks carry a conservative source-backed direct-damage family mapping.
|
- 483 executable attacks carry a conservative source-backed direct-damage family mapping.
|
||||||
- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;
|
- Source-backed mapping preserves spell identity, damage family, school, range, cooldown,
|
||||||
HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.
|
source power fields, and the exact native formula shape/provenance.
|
||||||
- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.
|
- Damage execution uses a labeled HealerMan baseline adapter because native ATK/MATK,
|
||||||
- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.
|
DEF/MDEF, weapon power, resist, critical, level-delta, and global rate inputs are unavailable.
|
||||||
|
- Scaling, movement/leash, clustering, aggro, death state, loot item stats/slots, and objective ordering are generic.
|
||||||
|
- Unmapped complex doors, multi-actor phases, patrols, resets, and interactions remain review-required.
|
||||||
- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,
|
- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,
|
||||||
attack, cast, wound, and death families.
|
attack, cast, wound, and death families.
|
||||||
|
|
||||||
@@ -142,5 +145,5 @@ Source-mechanics review: 357 templates preserve AutoPlot/Lua evidence; supported
|
|||||||
- demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.
|
- demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.
|
||||||
- zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.
|
- zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.
|
||||||
- 4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.
|
- 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.
|
- Source formula structure is preserved, but live damage uses a labeled HealerMan baseline adapter because the required native combat-stat inputs are not packaged.
|
||||||
- 61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence.
|
- 61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence.
|
||||||
@@ -0,0 +1,763 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"status": "portable-with-explicit-fallbacks",
|
||||||
|
"generatedFrom": {
|
||||||
|
"sourceFiles": [
|
||||||
|
{
|
||||||
|
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/npcobject.db",
|
||||||
|
"sha256": "b627b6eb269c30576a6558000a31abe2db472689ef9a60cc1c1e20207abd69f1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/treasureobject.db",
|
||||||
|
"sha256": "6e7433190f4b9f49e3cea32e346128a57c62a5993dd3b98b0de1cd11152895a0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/itemobject.db",
|
||||||
|
"sha256": "00e082b4c18d561d29c893a6400bfe62e3daf90ae539728cb05d1c2893a69880"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/armorobject.db",
|
||||||
|
"sha256": "a2f0017c4772165d562ebb08ca0ac51625cdc4453e3670a1501df7d741d2bad3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/weaponobject.db",
|
||||||
|
"sha256": "2bc28eebbe413bd6118f027e3fec1a3953cef371476937d0b65622c542971a67"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/cardobject.db",
|
||||||
|
"sha256": "4d154fe733e5359ca9e40c0aefd458ace0cf109b1b01e20da0f6e0683966a59f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/runeobject.db",
|
||||||
|
"sha256": "8ea064e1ae8220a69e3c72bbd5b64dbd149738a2c86f1a5d51201a7f3a799f85"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/recipeobject.db",
|
||||||
|
"sha256": "0f7d6ec38f8c52c403065d3f6f5b996117344c194ba42dd22f4d52061a6984e5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/keyitemobject.db",
|
||||||
|
"sha256": "ea06ed6dd9e4111cfd308b3d26d50c9e39a26ad358584db8dee6812e9a91358a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/string_enus.db",
|
||||||
|
"sha256": "4fce021ba9ed110a213e08d3228b2de57cec66ddbbc8cdcf5753204874d9ab75"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"npcDropAlgorithm": {
|
||||||
|
"file": "SourceCodeVS2019/Server/NetGameServer/MainProc/Global.cpp",
|
||||||
|
"firstRelevantLine": 4707,
|
||||||
|
"rateDenominator": 100000
|
||||||
|
},
|
||||||
|
"treasureAlgorithm": {
|
||||||
|
"file": "SourceCodeVS2019/Libraries/Union/RoleData/RD_ItemProc.cxx",
|
||||||
|
"firstRelevantLine": 3033,
|
||||||
|
"rateDenominator": 100000
|
||||||
|
},
|
||||||
|
"layout": "RuneWaker v0.20 fixed records; read-only export"
|
||||||
|
},
|
||||||
|
"runtimePolicy": {
|
||||||
|
"rollsAreIndependent": true,
|
||||||
|
"treasureOutcomesAreMutuallyExclusive": true,
|
||||||
|
"unknownObjectsExecute": false,
|
||||||
|
"luaPredicatesExecute": false,
|
||||||
|
"sourceItemStatsInvented": false,
|
||||||
|
"importedItemsUseGenericNonEquipableInventoryRepresentation": true
|
||||||
|
},
|
||||||
|
"leafCatalogs": [
|
||||||
|
{
|
||||||
|
"name": "itemobject.db",
|
||||||
|
"count": 13431,
|
||||||
|
"objectType": 2,
|
||||||
|
"recordSize": 1776
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "armorobject.db",
|
||||||
|
"count": 15050,
|
||||||
|
"objectType": 2,
|
||||||
|
"recordSize": 1776
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "weaponobject.db",
|
||||||
|
"count": 4843,
|
||||||
|
"objectType": 2,
|
||||||
|
"recordSize": 1776
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cardobject.db",
|
||||||
|
"count": 1765,
|
||||||
|
"objectType": 2,
|
||||||
|
"recordSize": 1776
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "runeobject.db",
|
||||||
|
"count": 1781,
|
||||||
|
"objectType": 2,
|
||||||
|
"recordSize": 1776
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "recipeobject.db",
|
||||||
|
"count": 3443,
|
||||||
|
"objectType": 10,
|
||||||
|
"recordSize": 416
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "keyitemobject.db",
|
||||||
|
"count": 10001,
|
||||||
|
"objectType": 9,
|
||||||
|
"recordSize": 196
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bossCount": 56,
|
||||||
|
"bossWithSourceRollsCount": 47,
|
||||||
|
"portableBossCount": 34,
|
||||||
|
"partialBossCount": 13,
|
||||||
|
"noSourceDropsBossCount": 9,
|
||||||
|
"blockedBossCount": 0,
|
||||||
|
"executableOutcomeCount": 8741,
|
||||||
|
"blockedOutcomeCount": 27,
|
||||||
|
"bosses": [
|
||||||
|
{
|
||||||
|
"bossKey": "rw-barren-caves-101462",
|
||||||
|
"dungeonId": "barren-caves",
|
||||||
|
"sourceNpcId": 101462,
|
||||||
|
"bossName": "Anglo",
|
||||||
|
"status": "partial",
|
||||||
|
"sourceRollCount": 13,
|
||||||
|
"executableOutcomeCount": 76,
|
||||||
|
"blockedOutcomeCount": 3,
|
||||||
|
"blockedReasons": [
|
||||||
|
"Treasure table has no positive, resolvable outcomes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-cave-water-dragon-102623",
|
||||||
|
"dungeonId": "cave-water-dragon",
|
||||||
|
"sourceNpcId": 102623,
|
||||||
|
"bossName": "Lytfir",
|
||||||
|
"status": "no-source-drops",
|
||||||
|
"sourceRollCount": 0,
|
||||||
|
"executableOutcomeCount": 0,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-cave-water-dragon-104161",
|
||||||
|
"dungeonId": "cave-water-dragon",
|
||||||
|
"sourceNpcId": 104161,
|
||||||
|
"bossName": "Template 104161",
|
||||||
|
"status": "no-source-drops",
|
||||||
|
"sourceRollCount": 0,
|
||||||
|
"executableOutcomeCount": 0,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-demon-stronghold-102701",
|
||||||
|
"dungeonId": "demon-stronghold",
|
||||||
|
"sourceNpcId": 102701,
|
||||||
|
"bossName": "Naos",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 228,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-fa-100070",
|
||||||
|
"dungeonId": "forsaken-abbey",
|
||||||
|
"sourceNpcId": 100070,
|
||||||
|
"bossName": "Necromage",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 12,
|
||||||
|
"executableOutcomeCount": 117,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-fa-100074",
|
||||||
|
"dungeonId": "forsaken-abbey",
|
||||||
|
"sourceNpcId": 100074,
|
||||||
|
"bossName": "Demon Witch Ancalon",
|
||||||
|
"status": "partial",
|
||||||
|
"sourceRollCount": 13,
|
||||||
|
"executableOutcomeCount": 130,
|
||||||
|
"blockedOutcomeCount": 2,
|
||||||
|
"blockedReasons": [
|
||||||
|
"Treasure table has no positive, resolvable outcomes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-fa-100561",
|
||||||
|
"dungeonId": "forsaken-abbey",
|
||||||
|
"sourceNpcId": 100561,
|
||||||
|
"bossName": "Flowing Chaos",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 11,
|
||||||
|
"executableOutcomeCount": 127,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-fa-100562",
|
||||||
|
"dungeonId": "forsaken-abbey",
|
||||||
|
"sourceNpcId": 100562,
|
||||||
|
"bossName": "Cruel Eater",
|
||||||
|
"status": "partial",
|
||||||
|
"sourceRollCount": 12,
|
||||||
|
"executableOutcomeCount": 131,
|
||||||
|
"blockedOutcomeCount": 2,
|
||||||
|
"blockedReasons": [
|
||||||
|
"Treasure table has no positive, resolvable outcomes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-fa-100563",
|
||||||
|
"dungeonId": "forsaken-abbey",
|
||||||
|
"sourceNpcId": 100563,
|
||||||
|
"bossName": "Hollow Shell",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 11,
|
||||||
|
"executableOutcomeCount": 129,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-hall-of-survivors-102395",
|
||||||
|
"dungeonId": "hall-of-survivors",
|
||||||
|
"sourceNpcId": 102395,
|
||||||
|
"bossName": "Guldamor",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 228,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-hall-of-survivors-102421",
|
||||||
|
"dungeonId": "hall-of-survivors",
|
||||||
|
"sourceNpcId": 102421,
|
||||||
|
"bossName": "Andriol",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 227,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-hall-of-survivors-102425",
|
||||||
|
"dungeonId": "hall-of-survivors",
|
||||||
|
"sourceNpcId": 102425,
|
||||||
|
"bossName": "Zygro",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 229,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-hall-of-survivors-102430",
|
||||||
|
"dungeonId": "hall-of-survivors",
|
||||||
|
"sourceNpcId": 102430,
|
||||||
|
"bossName": "Andaphelmor",
|
||||||
|
"status": "partial",
|
||||||
|
"sourceRollCount": 14,
|
||||||
|
"executableOutcomeCount": 232,
|
||||||
|
"blockedOutcomeCount": 2,
|
||||||
|
"blockedReasons": [
|
||||||
|
"Treasure table requires unavailable Lua predicate star_WorldBattle_CheckBonus."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-hall-of-survivors-102452",
|
||||||
|
"dungeonId": "hall-of-survivors",
|
||||||
|
"sourceNpcId": 102452,
|
||||||
|
"bossName": "Mantarick",
|
||||||
|
"status": "no-source-drops",
|
||||||
|
"sourceRollCount": 0,
|
||||||
|
"executableOutcomeCount": 0,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-hall-of-survivors-102475",
|
||||||
|
"dungeonId": "hall-of-survivors",
|
||||||
|
"sourceNpcId": 102475,
|
||||||
|
"bossName": "Glamo",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 226,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-hall-of-survivors-102476",
|
||||||
|
"dungeonId": "hall-of-survivors",
|
||||||
|
"sourceNpcId": 102476,
|
||||||
|
"bossName": "Divine Punishment Pursuer",
|
||||||
|
"status": "no-source-drops",
|
||||||
|
"sourceRollCount": 0,
|
||||||
|
"executableOutcomeCount": 0,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-hall-of-survivors-102560",
|
||||||
|
"dungeonId": "hall-of-survivors",
|
||||||
|
"sourceNpcId": 102560,
|
||||||
|
"bossName": "Prison Pursuer",
|
||||||
|
"status": "no-source-drops",
|
||||||
|
"sourceRollCount": 0,
|
||||||
|
"executableOutcomeCount": 0,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-hall-of-survivors-102643",
|
||||||
|
"dungeonId": "hall-of-survivors",
|
||||||
|
"sourceNpcId": 102643,
|
||||||
|
"bossName": "Horror Seeker",
|
||||||
|
"status": "no-source-drops",
|
||||||
|
"sourceRollCount": 0,
|
||||||
|
"executableOutcomeCount": 0,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-hoto-102021",
|
||||||
|
"dungeonId": "hoto",
|
||||||
|
"sourceNpcId": 102021,
|
||||||
|
"bossName": "Jiasha",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 219,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-hoto-102040",
|
||||||
|
"dungeonId": "hoto",
|
||||||
|
"sourceNpcId": 102040,
|
||||||
|
"bossName": "Medusa",
|
||||||
|
"status": "partial",
|
||||||
|
"sourceRollCount": 13,
|
||||||
|
"executableOutcomeCount": 222,
|
||||||
|
"blockedOutcomeCount": 2,
|
||||||
|
"blockedReasons": [
|
||||||
|
"Treasure table requires unavailable Lua predicate star_WorldBattle_CheckBonus."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-hoto-102041",
|
||||||
|
"dungeonId": "hoto",
|
||||||
|
"sourceNpcId": 102041,
|
||||||
|
"bossName": "Teeth of the Reef",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 217,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-hoto-102046",
|
||||||
|
"dungeonId": "hoto",
|
||||||
|
"sourceNpcId": 102046,
|
||||||
|
"bossName": "Bloodthirsty Claw",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 218,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-hoto-102063",
|
||||||
|
"dungeonId": "hoto",
|
||||||
|
"sourceNpcId": 102063,
|
||||||
|
"bossName": "Geba",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 220,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-ice-dwarf-kingdom-101268",
|
||||||
|
"dungeonId": "ice-dwarf-kingdom",
|
||||||
|
"sourceNpcId": 101268,
|
||||||
|
"bossName": "Pangkor",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 9,
|
||||||
|
"executableOutcomeCount": 210,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-ice-dwarf-kingdom-101584",
|
||||||
|
"dungeonId": "ice-dwarf-kingdom",
|
||||||
|
"sourceNpcId": 101584,
|
||||||
|
"bossName": "Thynos",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 211,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-ice-dwarf-kingdom-101881",
|
||||||
|
"dungeonId": "ice-dwarf-kingdom",
|
||||||
|
"sourceNpcId": 101881,
|
||||||
|
"bossName": "Icebound Guardian",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 188,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-ice-dwarf-kingdom-101883",
|
||||||
|
"dungeonId": "ice-dwarf-kingdom",
|
||||||
|
"sourceNpcId": 101883,
|
||||||
|
"bossName": "Crasset Hairyfoot",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 181,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-ice-dwarf-kingdom-101884",
|
||||||
|
"dungeonId": "ice-dwarf-kingdom",
|
||||||
|
"sourceNpcId": 101884,
|
||||||
|
"bossName": "Mole King",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 8,
|
||||||
|
"executableOutcomeCount": 208,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-kalins-shrine-101500",
|
||||||
|
"dungeonId": "kalins-shrine",
|
||||||
|
"sourceNpcId": 101500,
|
||||||
|
"bossName": "Goddess of Art's Disciple",
|
||||||
|
"status": "partial",
|
||||||
|
"sourceRollCount": 13,
|
||||||
|
"executableOutcomeCount": 318,
|
||||||
|
"blockedOutcomeCount": 2,
|
||||||
|
"blockedReasons": [
|
||||||
|
"Treasure table has no positive, resolvable outcomes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-kalins-shrine-101501",
|
||||||
|
"dungeonId": "kalins-shrine",
|
||||||
|
"sourceNpcId": 101501,
|
||||||
|
"bossName": "Yusalien",
|
||||||
|
"status": "partial",
|
||||||
|
"sourceRollCount": 14,
|
||||||
|
"executableOutcomeCount": 326,
|
||||||
|
"blockedOutcomeCount": 2,
|
||||||
|
"blockedReasons": [
|
||||||
|
"Treasure table has no positive, resolvable outcomes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-kalins-shrine-101502",
|
||||||
|
"dungeonId": "kalins-shrine",
|
||||||
|
"sourceNpcId": 101502,
|
||||||
|
"bossName": "Locatha",
|
||||||
|
"status": "partial",
|
||||||
|
"sourceRollCount": 11,
|
||||||
|
"executableOutcomeCount": 325,
|
||||||
|
"blockedOutcomeCount": 2,
|
||||||
|
"blockedReasons": [
|
||||||
|
"Treasure table has no positive, resolvable outcomes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-kalins-shrine-101503",
|
||||||
|
"dungeonId": "kalins-shrine",
|
||||||
|
"sourceNpcId": 101503,
|
||||||
|
"bossName": "Ensia",
|
||||||
|
"status": "partial",
|
||||||
|
"sourceRollCount": 11,
|
||||||
|
"executableOutcomeCount": 325,
|
||||||
|
"blockedOutcomeCount": 2,
|
||||||
|
"blockedReasons": [
|
||||||
|
"Treasure table has no positive, resolvable outcomes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-kalins-shrine-101507",
|
||||||
|
"dungeonId": "kalins-shrine",
|
||||||
|
"sourceNpcId": 101507,
|
||||||
|
"bossName": "Iron Rune Warrior",
|
||||||
|
"status": "partial",
|
||||||
|
"sourceRollCount": 11,
|
||||||
|
"executableOutcomeCount": 323,
|
||||||
|
"blockedOutcomeCount": 2,
|
||||||
|
"blockedReasons": [
|
||||||
|
"Treasure table has no positive, resolvable outcomes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-kawaks-tomb-104228",
|
||||||
|
"dungeonId": "kawaks-tomb",
|
||||||
|
"sourceNpcId": 104228,
|
||||||
|
"bossName": "Linobia",
|
||||||
|
"status": "no-source-drops",
|
||||||
|
"sourceRollCount": 0,
|
||||||
|
"executableOutcomeCount": 0,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-kulech-bones-nest-107306",
|
||||||
|
"dungeonId": "kulech-bones-nest",
|
||||||
|
"sourceNpcId": 107306,
|
||||||
|
"bossName": "Template 107306",
|
||||||
|
"status": "no-source-drops",
|
||||||
|
"sourceRollCount": 0,
|
||||||
|
"executableOutcomeCount": 0,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-mystic-altar-100138",
|
||||||
|
"dungeonId": "mystic-altar",
|
||||||
|
"sourceNpcId": 100138,
|
||||||
|
"bossName": "Cursed lron Golem",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 128,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-mystic-altar-100154",
|
||||||
|
"dungeonId": "mystic-altar",
|
||||||
|
"sourceNpcId": 100154,
|
||||||
|
"bossName": "Razeela",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 142,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-mystic-altar-100611",
|
||||||
|
"dungeonId": "mystic-altar",
|
||||||
|
"sourceNpcId": 100611,
|
||||||
|
"bossName": "Dorlos",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 11,
|
||||||
|
"executableOutcomeCount": 124,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-paspers-shrine-100794",
|
||||||
|
"dungeonId": "paspers-shrine",
|
||||||
|
"sourceNpcId": 100794,
|
||||||
|
"bossName": "Blackhorn Hafiz",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 11,
|
||||||
|
"executableOutcomeCount": 128,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-paspers-shrine-101018",
|
||||||
|
"dungeonId": "paspers-shrine",
|
||||||
|
"sourceNpcId": 101018,
|
||||||
|
"bossName": "Blackhorn Afalen",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 109,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-paspers-shrine-101019",
|
||||||
|
"dungeonId": "paspers-shrine",
|
||||||
|
"sourceNpcId": 101019,
|
||||||
|
"bossName": "Blackhorn Bareth",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 103,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-paspers-shrine-101020",
|
||||||
|
"dungeonId": "paspers-shrine",
|
||||||
|
"sourceNpcId": 101020,
|
||||||
|
"bossName": "Blackhorn Nisorn",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 105,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-paspers-shrine-101021",
|
||||||
|
"dungeonId": "paspers-shrine",
|
||||||
|
"sourceNpcId": 101021,
|
||||||
|
"bossName": "Blackhorn Dorglas",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 13,
|
||||||
|
"executableOutcomeCount": 112,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-raksha-temple-103946",
|
||||||
|
"dungeonId": "raksha-temple",
|
||||||
|
"sourceNpcId": 103946,
|
||||||
|
"bossName": "Shadoj",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 7,
|
||||||
|
"executableOutcomeCount": 71,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-sand-vortex-100645",
|
||||||
|
"dungeonId": "sand-vortex",
|
||||||
|
"sourceNpcId": 100645,
|
||||||
|
"bossName": "Sandlord Sesphiroth",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 9,
|
||||||
|
"executableOutcomeCount": 101,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-sardo-castle-105701",
|
||||||
|
"dungeonId": "sardo-castle",
|
||||||
|
"sourceNpcId": 105701,
|
||||||
|
"bossName": "Undead Guard",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 7,
|
||||||
|
"executableOutcomeCount": 91,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-the-origin-102307",
|
||||||
|
"dungeonId": "the-origin",
|
||||||
|
"sourceNpcId": 102307,
|
||||||
|
"bossName": "Holy Tree Falynum",
|
||||||
|
"status": "no-source-drops",
|
||||||
|
"sourceRollCount": 0,
|
||||||
|
"executableOutcomeCount": 0,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-the-origin-102349",
|
||||||
|
"dungeonId": "the-origin",
|
||||||
|
"sourceNpcId": 102349,
|
||||||
|
"bossName": "Life Leecher",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 12,
|
||||||
|
"executableOutcomeCount": 279,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-treasure-trove-101533",
|
||||||
|
"dungeonId": "treasure-trove",
|
||||||
|
"sourceNpcId": 101533,
|
||||||
|
"bossName": "Boulder Sidaar",
|
||||||
|
"status": "partial",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 313,
|
||||||
|
"blockedOutcomeCount": 2,
|
||||||
|
"blockedReasons": [
|
||||||
|
"Treasure table has no positive, resolvable outcomes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-treasure-trove-101534",
|
||||||
|
"dungeonId": "treasure-trove",
|
||||||
|
"sourceNpcId": 101534,
|
||||||
|
"bossName": "Lookout Luke",
|
||||||
|
"status": "partial",
|
||||||
|
"sourceRollCount": 11,
|
||||||
|
"executableOutcomeCount": 315,
|
||||||
|
"blockedOutcomeCount": 2,
|
||||||
|
"blockedReasons": [
|
||||||
|
"Treasure table has no positive, resolvable outcomes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-treasure-trove-101535",
|
||||||
|
"dungeonId": "treasure-trove",
|
||||||
|
"sourceNpcId": 101535,
|
||||||
|
"bossName": "Snow Blake",
|
||||||
|
"status": "partial",
|
||||||
|
"sourceRollCount": 13,
|
||||||
|
"executableOutcomeCount": 322,
|
||||||
|
"blockedOutcomeCount": 2,
|
||||||
|
"blockedReasons": [
|
||||||
|
"Treasure table has no positive, resolvable outcomes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-ystra-labyrinth-boss1-101432",
|
||||||
|
"dungeonId": "ystra-labyrinth-boss1",
|
||||||
|
"sourceNpcId": 101432,
|
||||||
|
"bossName": "Rune Guardian Leader",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 12,
|
||||||
|
"executableOutcomeCount": 92,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-ystra-labyrinth-boss1-101433",
|
||||||
|
"dungeonId": "ystra-labyrinth-boss1",
|
||||||
|
"sourceNpcId": 101433,
|
||||||
|
"bossName": "Raichika the Destroyer",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 13,
|
||||||
|
"executableOutcomeCount": 125,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-ystra-labyrinth-boss1-102195",
|
||||||
|
"dungeonId": "ystra-labyrinth-boss1",
|
||||||
|
"sourceNpcId": 102195,
|
||||||
|
"bossName": "Specter Guardian Creator",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 8,
|
||||||
|
"executableOutcomeCount": 84,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-ystra-labyrinth-boss2-102196",
|
||||||
|
"dungeonId": "ystra-labyrinth-boss2",
|
||||||
|
"sourceNpcId": 102196,
|
||||||
|
"bossName": "Rune Guardian Leader",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 99,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bossKey": "rw-ystra-labyrinth-boss3-102197",
|
||||||
|
"dungeonId": "ystra-labyrinth-boss3",
|
||||||
|
"sourceNpcId": 102197,
|
||||||
|
"bossName": "Raichika the Destroyer",
|
||||||
|
"status": "portable",
|
||||||
|
"sourceRollCount": 10,
|
||||||
|
"executableOutcomeCount": 107,
|
||||||
|
"blockedOutcomeCount": 0,
|
||||||
|
"blockedReasons": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# RuneWaker loot import report
|
||||||
|
|
||||||
|
Generated from the preserved fixed-record databases. Source rates use the original 1-in-100,000 denominator.
|
||||||
|
|
||||||
|
- Boss templates audited: 56
|
||||||
|
- Bosses with source rolls: 47
|
||||||
|
- Fully portable bosses: 34
|
||||||
|
- Partially portable bosses: 13
|
||||||
|
- Bosses with no authored drop slots: 9
|
||||||
|
- Executable item outcomes: 8741
|
||||||
|
- Blocked outcomes: 27
|
||||||
|
|
||||||
|
Unknown object relationships and Lua-gated treasure paths fail closed. Imported source items are collected as named, non-equipable generic items because the RuneWaker item-stat and equipment-slot conversion is not yet proven.
|
||||||
|
|
||||||
|
| Dungeon | Boss | Status | Rolls | Executable | Blocked | Blocker |
|
||||||
|
| --- | --- | --- | ---: | ---: | ---: | --- |
|
||||||
|
| barren-caves | Anglo | partial | 13 | 76 | 3 | Treasure table has no positive, resolvable outcomes. |
|
||||||
|
| cave-water-dragon | Lytfir | no-source-drops | 0 | 0 | 0 | — |
|
||||||
|
| cave-water-dragon | Template 104161 | no-source-drops | 0 | 0 | 0 | — |
|
||||||
|
| demon-stronghold | Naos | portable | 10 | 228 | 0 | — |
|
||||||
|
| forsaken-abbey | Necromage | portable | 12 | 117 | 0 | — |
|
||||||
|
| forsaken-abbey | Demon Witch Ancalon | partial | 13 | 130 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||||
|
| forsaken-abbey | Flowing Chaos | portable | 11 | 127 | 0 | — |
|
||||||
|
| forsaken-abbey | Cruel Eater | partial | 12 | 131 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||||
|
| forsaken-abbey | Hollow Shell | portable | 11 | 129 | 0 | — |
|
||||||
|
| hall-of-survivors | Guldamor | portable | 10 | 228 | 0 | — |
|
||||||
|
| hall-of-survivors | Andriol | portable | 10 | 227 | 0 | — |
|
||||||
|
| hall-of-survivors | Zygro | portable | 10 | 229 | 0 | — |
|
||||||
|
| hall-of-survivors | Andaphelmor | partial | 14 | 232 | 2 | Treasure table requires unavailable Lua predicate star_WorldBattle_CheckBonus. |
|
||||||
|
| hall-of-survivors | Mantarick | no-source-drops | 0 | 0 | 0 | — |
|
||||||
|
| hall-of-survivors | Glamo | portable | 10 | 226 | 0 | — |
|
||||||
|
| hall-of-survivors | Divine Punishment Pursuer | no-source-drops | 0 | 0 | 0 | — |
|
||||||
|
| hall-of-survivors | Prison Pursuer | no-source-drops | 0 | 0 | 0 | — |
|
||||||
|
| hall-of-survivors | Horror Seeker | no-source-drops | 0 | 0 | 0 | — |
|
||||||
|
| hoto | Jiasha | portable | 10 | 219 | 0 | — |
|
||||||
|
| hoto | Medusa | partial | 13 | 222 | 2 | Treasure table requires unavailable Lua predicate star_WorldBattle_CheckBonus. |
|
||||||
|
| hoto | Teeth of the Reef | portable | 10 | 217 | 0 | — |
|
||||||
|
| hoto | Bloodthirsty Claw | portable | 10 | 218 | 0 | — |
|
||||||
|
| hoto | Geba | portable | 10 | 220 | 0 | — |
|
||||||
|
| ice-dwarf-kingdom | Pangkor | portable | 9 | 210 | 0 | — |
|
||||||
|
| ice-dwarf-kingdom | Thynos | portable | 10 | 211 | 0 | — |
|
||||||
|
| ice-dwarf-kingdom | Icebound Guardian | portable | 10 | 188 | 0 | — |
|
||||||
|
| ice-dwarf-kingdom | Crasset Hairyfoot | portable | 10 | 181 | 0 | — |
|
||||||
|
| ice-dwarf-kingdom | Mole King | portable | 8 | 208 | 0 | — |
|
||||||
|
| kalins-shrine | Goddess of Art's Disciple | partial | 13 | 318 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||||
|
| kalins-shrine | Yusalien | partial | 14 | 326 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||||
|
| kalins-shrine | Locatha | partial | 11 | 325 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||||
|
| kalins-shrine | Ensia | partial | 11 | 325 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||||
|
| kalins-shrine | Iron Rune Warrior | partial | 11 | 323 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||||
|
| kawaks-tomb | Linobia | no-source-drops | 0 | 0 | 0 | — |
|
||||||
|
| kulech-bones-nest | Template 107306 | no-source-drops | 0 | 0 | 0 | — |
|
||||||
|
| mystic-altar | Cursed lron Golem | portable | 10 | 128 | 0 | — |
|
||||||
|
| mystic-altar | Razeela | portable | 10 | 142 | 0 | — |
|
||||||
|
| mystic-altar | Dorlos | portable | 11 | 124 | 0 | — |
|
||||||
|
| paspers-shrine | Blackhorn Hafiz | portable | 11 | 128 | 0 | — |
|
||||||
|
| paspers-shrine | Blackhorn Afalen | portable | 10 | 109 | 0 | — |
|
||||||
|
| paspers-shrine | Blackhorn Bareth | portable | 10 | 103 | 0 | — |
|
||||||
|
| paspers-shrine | Blackhorn Nisorn | portable | 10 | 105 | 0 | — |
|
||||||
|
| paspers-shrine | Blackhorn Dorglas | portable | 13 | 112 | 0 | — |
|
||||||
|
| raksha-temple | Shadoj | portable | 7 | 71 | 0 | — |
|
||||||
|
| sand-vortex | Sandlord Sesphiroth | portable | 9 | 101 | 0 | — |
|
||||||
|
| sardo-castle | Undead Guard | portable | 7 | 91 | 0 | — |
|
||||||
|
| the-origin | Holy Tree Falynum | no-source-drops | 0 | 0 | 0 | — |
|
||||||
|
| the-origin | Life Leecher | portable | 12 | 279 | 0 | — |
|
||||||
|
| treasure-trove | Boulder Sidaar | partial | 10 | 313 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||||
|
| treasure-trove | Lookout Luke | partial | 11 | 315 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||||
|
| treasure-trove | Snow Blake | partial | 13 | 322 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||||
|
| ystra-labyrinth-boss1 | Rune Guardian Leader | portable | 12 | 92 | 0 | — |
|
||||||
|
| ystra-labyrinth-boss1 | Raichika the Destroyer | portable | 13 | 125 | 0 | — |
|
||||||
|
| ystra-labyrinth-boss1 | Specter Guardian Creator | portable | 8 | 84 | 0 | — |
|
||||||
|
| ystra-labyrinth-boss2 | Rune Guardian Leader | portable | 10 | 99 | 0 | — |
|
||||||
|
| ystra-labyrinth-boss3 | Raichika the Destroyer | portable | 10 | 107 | 0 | — |
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,9 @@ against every raw Recast surface component and against the retained runtime surf
|
|||||||
The current boss sequence is HealerMan's distance-from-entrance fallback, not a claimed
|
The current boss sequence is HealerMan's distance-from-entrance fallback, not a claimed
|
||||||
source-authored order. It does not create links or infer doors/teleports.
|
source-authored order. It does not create links or infer doors/teleports.
|
||||||
|
|
||||||
Unreachable heuristic legs analyzed: **19** across **8** dungeons.
|
Unreachable currently ordered legs analyzed: **19** across **8** dungeons.
|
||||||
|
Source-authored fixed orders implemented by the audited packages: **0**.
|
||||||
|
Authored off-mesh links added by this review: **0**.
|
||||||
|
|
||||||
| Dungeon | Ordered leg | Geometry classification | From raw comp/dist | To raw comp/dist | Retained distances |
|
| Dungeon | Ordered leg | Geometry classification | From raw comp/dist | To raw comp/dist | Retained distances |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
@@ -20,9 +22,9 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
|
|||||||
| kalins-shrine | Yusalien -> Locatha | separate-recast-surface-components | 2/0m | 12/0m | 245.55m -> 122.731m |
|
| kalins-shrine | Yusalien -> Locatha | separate-recast-surface-components | 2/0m | 12/0m | 245.55m -> 122.731m |
|
||||||
| kalins-shrine | Locatha -> Goddess of Art's Disciple | separate-recast-surface-components | 12/0m | 3/0m | 122.731m -> 12.741m |
|
| kalins-shrine | Locatha -> Goddess of Art's Disciple | separate-recast-surface-components | 12/0m | 3/0m | 122.731m -> 12.741m |
|
||||||
| kalins-shrine | Goddess of Art's Disciple -> Ensia | separate-recast-surface-components | 3/0m | 0/0m | 12.741m -> 0m |
|
| kalins-shrine | Goddess of Art's Disciple -> Ensia | separate-recast-surface-components | 3/0m | 0/0m | 12.741m -> 0m |
|
||||||
| raksha-temple | entrance -> Shadoj | missing-primary-environment-geometry-source-support-pad | 41/0m | 165/0m | 0.203m -> 200.079m |
|
| raksha-temple | entrance -> Shadoj | objective-or-entrance-outside-all-navigation | 6069/0m | 2982/7.159m | 578.214m -> 699.329m |
|
||||||
| the-origin | entrance -> Life Leecher | missing-primary-environment-geometry-source-support-pad | 106/0m | 302/0m | 466.405m -> 234.143m |
|
| the-origin | entrance -> Life Leecher | separate-recast-surface-components | 4276/0m | 2868/1.099m | 456.333m -> 258.631m |
|
||||||
| the-origin | Life Leecher -> Holy Tree Falynum | missing-primary-environment-geometry-source-support-pad | 302/0m | 305/0m | 234.143m -> 361.263m |
|
| the-origin | Life Leecher -> Holy Tree Falynum | separate-recast-surface-components | 2868/1.099m | 4857/5.632m | 258.631m -> 559.406m |
|
||||||
| treasure-trove | entrance -> Lookout Luke | missing-primary-environment-geometry-source-support-pad | 487/0m | 854/0m | 0.184m -> 519.5m |
|
| treasure-trove | entrance -> Lookout Luke | missing-primary-environment-geometry-source-support-pad | 487/0m | 854/0m | 0.184m -> 519.5m |
|
||||||
| treasure-trove | Lookout Luke -> Snow Blake | missing-primary-environment-geometry-source-support-pad | 854/0m | 490/0m | 519.5m -> 525.287m |
|
| treasure-trove | Lookout Luke -> Snow Blake | missing-primary-environment-geometry-source-support-pad | 854/0m | 490/0m | 519.5m -> 525.287m |
|
||||||
| treasure-trove | Snow Blake -> Boulder Sidaar | missing-primary-environment-geometry-source-support-pad | 490/0m | 489/0m | 525.287m -> 565.311m |
|
| treasure-trove | Snow Blake -> Boulder Sidaar | missing-primary-environment-geometry-source-support-pad | 490/0m | 489/0m | 525.287m -> 565.311m |
|
||||||
@@ -35,8 +37,8 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
|
|||||||
- hoto: 260 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 5777 welded nodes discarded by largest-surface retention.
|
- hoto: 260 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 5777 welded nodes discarded by largest-surface retention.
|
||||||
- ice-dwarf-kingdom: 599 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 6237 welded nodes discarded by largest-surface retention.
|
- ice-dwarf-kingdom: 599 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 6237 welded nodes discarded by largest-surface retention.
|
||||||
- kalins-shrine: 93 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 4858 welded nodes discarded by largest-surface retention.
|
- kalins-shrine: 93 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 4858 welded nodes discarded by largest-surface retention.
|
||||||
- raksha-temple: 167 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 1262 welded nodes discarded by largest-surface retention.
|
- raksha-temple: 13137 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 180838 welded nodes discarded by largest-surface retention.
|
||||||
- the-origin: 306 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 1678 welded nodes discarded by largest-surface retention.
|
- the-origin: 6736 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 97103 welded nodes discarded by largest-surface retention.
|
||||||
- treasure-trove: 1025 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 16502 welded nodes discarded by largest-surface retention.
|
- treasure-trove: 1025 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 16502 welded nodes discarded by largest-surface retention.
|
||||||
- ystra-labyrinth-boss1: 36 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 901 welded nodes discarded by largest-surface retention.
|
- ystra-labyrinth-boss1: 36 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 901 welded nodes discarded by largest-surface retention.
|
||||||
|
|
||||||
@@ -46,7 +48,12 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
|
|||||||
|
|
||||||
- Disposition: gated-wave-and-door-review.
|
- Disposition: gated-wave-and-door-review.
|
||||||
- Safe automatic link: no.
|
- Safe automatic link: no.
|
||||||
|
- Objective order: partial-gate-evidence-insufficient-for-complete-order.
|
||||||
|
- Current package order: healerMan-distance-derived (non-authoritative).
|
||||||
- Finding: Template 104161 runs the ten-add water-elemental controller, creates a blocking door, and removes it only after the counter reaches ten. Lytfir is on a lower isolated surface, so a permanent link would bypass the preserved gate/drop sequence.
|
- Finding: Template 104161 runs the ten-add water-elemental controller, creates a blocking door, and removes it only after the counter reaches ten. Lytfir is on a lower isolated surface, so a permanent link would bypass the preserved gate/drop sequence.
|
||||||
|
- Remaining requirement: encounter-gate-state.
|
||||||
|
- Remaining requirement: source-objective-review.
|
||||||
|
- Remaining requirement: dynamic-navigation-transition.
|
||||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:1
|
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:1
|
||||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:21
|
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:21
|
||||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:42
|
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:42
|
||||||
@@ -55,7 +62,11 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
|
|||||||
|
|
||||||
- Disposition: source-order-required-before-linking.
|
- Disposition: source-order-required-before-linking.
|
||||||
- Safe automatic link: no.
|
- Safe automatic link: no.
|
||||||
|
- Objective order: source-objective-review-required.
|
||||||
|
- Current package order: healerMan-distance-derived (non-authoritative).
|
||||||
- Finding: The distance fallback crosses the same 6.65m vertical surface break three times. Preserved local configuration supplies boss anchors but no authoritative encounter order, so these heuristic legs cannot justify a link.
|
- Finding: The distance fallback crosses the same 6.65m vertical surface break three times. Preserved local configuration supplies boss anchors but no authoritative encounter order, so these heuristic legs cannot justify a link.
|
||||||
|
- Remaining requirement: source-objective-review.
|
||||||
|
- Remaining requirement: navigation-geometry-review.
|
||||||
- Evidence: scripts/runewaker-pipeline/build-population.mjs:634
|
- Evidence: scripts/runewaker-pipeline/build-population.mjs:634
|
||||||
- Evidence: scripts/runewaker-pipeline/recipes/hoto-population.json
|
- Evidence: scripts/runewaker-pipeline/recipes/hoto-population.json
|
||||||
|
|
||||||
@@ -63,7 +74,12 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
|
|||||||
|
|
||||||
- Disposition: door-gated-room-review.
|
- Disposition: door-gated-room-review.
|
||||||
- Safe automatic link: no.
|
- Safe automatic link: no.
|
||||||
|
- Objective order: partial-gate-evidence-insufficient-for-complete-order.
|
||||||
|
- Current package order: healerMan-distance-derived (non-authoritative).
|
||||||
- Finding: Crasset's isolated surface is separated by a 1.64m/0.60m gap, but his preserved death hook creates an orb that finds and hides door 112321. A permanent bidirectional link would erase that gate state.
|
- Finding: Crasset's isolated surface is separated by a 1.64m/0.60m gap, but his preserved death hook creates an orb that finds and hides door 112321. A permanent bidirectional link would erase that gate state.
|
||||||
|
- Remaining requirement: encounter-gate-state.
|
||||||
|
- Remaining requirement: source-objective-review.
|
||||||
|
- Remaining requirement: dynamic-navigation-transition.
|
||||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:16
|
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:16
|
||||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:36
|
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:36
|
||||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:46
|
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:46
|
||||||
@@ -73,30 +89,48 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
|
|||||||
|
|
||||||
- Disposition: teleport-room-mechanic.
|
- Disposition: teleport-room-mechanic.
|
||||||
- Safe automatic link: no.
|
- Safe automatic link: no.
|
||||||
- Finding: The preserved server scripts register six Kalin locations as separate keeper rooms and advance with a group teleport. The vertical component gaps are therefore encounter state, not wall seams.
|
- Objective order: source-randomized-not-linear.
|
||||||
|
- Current package order: healerMan-distance-derived (non-authoritative).
|
||||||
|
- Finding: The preserved server scripts register six Kalin locations as separate keeper rooms, select the next uncleared room randomly, and advance with a group teleport. The vertical component gaps are encounter state, not wall seams, and a fixed objective order would contradict the source.
|
||||||
|
- Remaining requirement: nonlinear-objectives.
|
||||||
|
- Remaining requirement: encounter-teleport-state.
|
||||||
|
- Remaining requirement: dynamic-navigation-transition.
|
||||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:713
|
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:713
|
||||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1298
|
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1298
|
||||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1363
|
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1354
|
||||||
|
|
||||||
### raksha-temple
|
### raksha-temple
|
||||||
|
|
||||||
- Disposition: missing-modular-environment-assembly.
|
- Disposition: missing-modular-environment-assembly.
|
||||||
- Safe automatic link: no.
|
- Safe automatic link: no.
|
||||||
|
- Objective order: not-applicable-single-objective.
|
||||||
|
- Current package order: healerMan-distance-derived (non-authoritative).
|
||||||
- Finding: The environment recipe explicitly adds isolated source-anchor support pads because the single primary ROS does not cover the modular WDB combat anchors. Secondary environment pieces must be assembled before route authoring.
|
- Finding: The environment recipe explicitly adds isolated source-anchor support pads because the single primary ROS does not cover the modular WDB combat anchors. Secondary environment pieces must be assembled before route authoring.
|
||||||
|
- Remaining requirement: modular-environment-assembly.
|
||||||
- Evidence: scripts/runewaker-pipeline/recipes/raksha-temple.json:16
|
- Evidence: scripts/runewaker-pipeline/recipes/raksha-temple.json:16
|
||||||
|
|
||||||
### the-origin
|
### the-origin
|
||||||
|
|
||||||
- Disposition: missing-modular-environment-assembly.
|
- Disposition: missing-modular-environment-assembly.
|
||||||
- Safe automatic link: no.
|
- Safe automatic link: no.
|
||||||
|
- Objective order: source-objective-review-required.
|
||||||
|
- Current package order: healerMan-distance-derived (non-authoritative).
|
||||||
- Finding: Both objectives and the entrance sit on isolated source-anchor support pads. The WDB contains many separately placed cathedral, slope, bridge, and connector ROS pieces that the primary-only environment package does not yet assemble.
|
- Finding: Both objectives and the entrance sit on isolated source-anchor support pads. The WDB contains many separately placed cathedral, slope, bridge, and connector ROS pieces that the primary-only environment package does not yet assemble.
|
||||||
|
- Remaining requirement: modular-environment-assembly.
|
||||||
|
- Remaining requirement: source-objective-review.
|
||||||
- Evidence: scripts/runewaker-pipeline/recipes/the-origin.json:16
|
- Evidence: scripts/runewaker-pipeline/recipes/the-origin.json:16
|
||||||
|
|
||||||
### treasure-trove
|
### treasure-trove
|
||||||
|
|
||||||
- Disposition: missing-modular-environment-plus-bridge-door-mechanics.
|
- Disposition: missing-modular-environment-plus-bridge-door-mechanics.
|
||||||
- Safe automatic link: no.
|
- Safe automatic link: no.
|
||||||
|
- Objective order: partial-gate-evidence-insufficient-for-complete-order.
|
||||||
|
- Current package order: healerMan-distance-derived (non-authoritative).
|
||||||
- Finding: Boss anchors are isolated support pads, while preserved Lua requires a boss-enabled drawbridge switch and a key-gated hold door. The modular environment and stateful bridge/door must precede navigation links.
|
- Finding: Boss anchors are isolated support pads, while preserved Lua requires a boss-enabled drawbridge switch and a key-gated hold door. The modular environment and stateful bridge/door must precede navigation links.
|
||||||
|
- Remaining requirement: modular-environment-assembly.
|
||||||
|
- Remaining requirement: encounter-gate-state.
|
||||||
|
- Remaining requirement: source-objective-review.
|
||||||
|
- Remaining requirement: dynamic-navigation-transition.
|
||||||
- Evidence: scripts/runewaker-pipeline/recipes/treasure-trove.json:16
|
- Evidence: scripts/runewaker-pipeline/recipes/treasure-trove.json:16
|
||||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01159.lua:1
|
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01159.lua:1
|
||||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01159.lua:38
|
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01159.lua:38
|
||||||
@@ -106,9 +140,29 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
|
|||||||
|
|
||||||
- Disposition: separate-authored-room-placements.
|
- Disposition: separate-authored-room-placements.
|
||||||
- Safe automatic link: no.
|
- Safe automatic link: no.
|
||||||
|
- Objective order: independent-room-order-unresolved.
|
||||||
|
- Current package order: healerMan-distance-derived (non-authoritative).
|
||||||
- Finding: The package intentionally clones three distinct WDB room placements hundreds of meters apart; the preserved travel catalog also treats the boss-room destinations separately. Connecting them through open space would invent geometry.
|
- Finding: The package intentionally clones three distinct WDB room placements hundreds of meters apart; the preserved travel catalog also treats the boss-room destinations separately. Connecting them through open space would invent geometry.
|
||||||
|
- Remaining requirement: encounter-teleport-state.
|
||||||
|
- Remaining requirement: source-objective-review.
|
||||||
|
- Remaining requirement: dynamic-navigation-transition.
|
||||||
- Evidence: scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1.json:16
|
- Evidence: scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1.json:16
|
||||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1423
|
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1423
|
||||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1425
|
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1425
|
||||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1426
|
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1426
|
||||||
|
|
||||||
|
## Resolution boundary
|
||||||
|
|
||||||
|
No off-mesh link was emitted. Every failed leg is either controlled by encounter state,
|
||||||
|
depends on environment geometry that is not yet in the runtime package, represents
|
||||||
|
separate authored rooms, or still lacks a complete source-authored boss sequence.
|
||||||
|
A static link would let companions bypass a door, bridge, wave, or teleport mechanic.
|
||||||
|
The structured requirement counts below are deterministic and identify the remaining work.
|
||||||
|
|
||||||
|
- dynamic-navigation-transition: 5 dungeon(s).
|
||||||
|
- encounter-gate-state: 3 dungeon(s).
|
||||||
|
- encounter-teleport-state: 2 dungeon(s).
|
||||||
|
- modular-environment-assembly: 3 dungeon(s).
|
||||||
|
- navigation-geometry-review: 1 dungeon(s).
|
||||||
|
- nonlinear-objectives: 1 dungeon(s).
|
||||||
|
- source-objective-review: 6 dungeon(s).
|
||||||
@@ -29,6 +29,8 @@ const reviewedFindings = {
|
|||||||
"cave-water-dragon": {
|
"cave-water-dragon": {
|
||||||
disposition: "gated-wave-and-door-review",
|
disposition: "gated-wave-and-door-review",
|
||||||
safeAutomaticLink: false,
|
safeAutomaticLink: false,
|
||||||
|
objectiveOrderStatus: "partial-gate-evidence-insufficient-for-complete-order",
|
||||||
|
requirements: ["encounter-gate-state", "source-objective-review", "dynamic-navigation-transition"],
|
||||||
finding: "Template 104161 runs the ten-add water-elemental controller, creates a blocking door, and removes it only after the counter reaches ten. Lytfir is on a lower isolated surface, so a permanent link would bypass the preserved gate/drop sequence.",
|
finding: "Template 104161 runs the ten-add water-elemental controller, creates a blocking door, and removes it only after the counter reaches ten. Lytfir is on a lower isolated surface, so a permanent link would bypass the preserved gate/drop sequence.",
|
||||||
evidence: [
|
evidence: [
|
||||||
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:1",
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:1",
|
||||||
@@ -39,6 +41,8 @@ const reviewedFindings = {
|
|||||||
hoto: {
|
hoto: {
|
||||||
disposition: "source-order-required-before-linking",
|
disposition: "source-order-required-before-linking",
|
||||||
safeAutomaticLink: false,
|
safeAutomaticLink: false,
|
||||||
|
objectiveOrderStatus: "source-objective-review-required",
|
||||||
|
requirements: ["source-objective-review", "navigation-geometry-review"],
|
||||||
finding: "The distance fallback crosses the same 6.65m vertical surface break three times. Preserved local configuration supplies boss anchors but no authoritative encounter order, so these heuristic legs cannot justify a link.",
|
finding: "The distance fallback crosses the same 6.65m vertical surface break three times. Preserved local configuration supplies boss anchors but no authoritative encounter order, so these heuristic legs cannot justify a link.",
|
||||||
evidence: [
|
evidence: [
|
||||||
"scripts/runewaker-pipeline/build-population.mjs:634",
|
"scripts/runewaker-pipeline/build-population.mjs:634",
|
||||||
@@ -48,6 +52,8 @@ const reviewedFindings = {
|
|||||||
"ice-dwarf-kingdom": {
|
"ice-dwarf-kingdom": {
|
||||||
disposition: "door-gated-room-review",
|
disposition: "door-gated-room-review",
|
||||||
safeAutomaticLink: false,
|
safeAutomaticLink: false,
|
||||||
|
objectiveOrderStatus: "partial-gate-evidence-insufficient-for-complete-order",
|
||||||
|
requirements: ["encounter-gate-state", "source-objective-review", "dynamic-navigation-transition"],
|
||||||
finding: "Crasset's isolated surface is separated by a 1.64m/0.60m gap, but his preserved death hook creates an orb that finds and hides door 112321. A permanent bidirectional link would erase that gate state.",
|
finding: "Crasset's isolated surface is separated by a 1.64m/0.60m gap, but his preserved death hook creates an orb that finds and hides door 112321. A permanent bidirectional link would erase that gate state.",
|
||||||
evidence: [
|
evidence: [
|
||||||
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:16",
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:16",
|
||||||
@@ -59,28 +65,41 @@ const reviewedFindings = {
|
|||||||
"kalins-shrine": {
|
"kalins-shrine": {
|
||||||
disposition: "teleport-room-mechanic",
|
disposition: "teleport-room-mechanic",
|
||||||
safeAutomaticLink: false,
|
safeAutomaticLink: false,
|
||||||
finding: "The preserved server scripts register six Kalin locations as separate keeper rooms and advance with a group teleport. The vertical component gaps are therefore encounter state, not wall seams.",
|
objectiveOrderStatus: "source-randomized-not-linear",
|
||||||
|
requirements: ["nonlinear-objectives", "encounter-teleport-state", "dynamic-navigation-transition"],
|
||||||
|
finding: "The preserved server scripts register six Kalin locations as separate keeper rooms, select the next uncleared room randomly, and advance with a group teleport. The vertical component gaps are encounter state, not wall seams, and a fixed objective order would contradict the source.",
|
||||||
evidence: [
|
evidence: [
|
||||||
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:713",
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:713",
|
||||||
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1298",
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1298",
|
||||||
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1363",
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1354",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"raksha-temple": {
|
"raksha-temple": {
|
||||||
disposition: "missing-modular-environment-assembly",
|
disposition: "missing-modular-environment-assembly",
|
||||||
safeAutomaticLink: false,
|
safeAutomaticLink: false,
|
||||||
|
objectiveOrderStatus: "not-applicable-single-objective",
|
||||||
|
requirements: ["modular-environment-assembly"],
|
||||||
finding: "The environment recipe explicitly adds isolated source-anchor support pads because the single primary ROS does not cover the modular WDB combat anchors. Secondary environment pieces must be assembled before route authoring.",
|
finding: "The environment recipe explicitly adds isolated source-anchor support pads because the single primary ROS does not cover the modular WDB combat anchors. Secondary environment pieces must be assembled before route authoring.",
|
||||||
evidence: ["scripts/runewaker-pipeline/recipes/raksha-temple.json:16"],
|
evidence: ["scripts/runewaker-pipeline/recipes/raksha-temple.json:16"],
|
||||||
},
|
},
|
||||||
"the-origin": {
|
"the-origin": {
|
||||||
disposition: "missing-modular-environment-assembly",
|
disposition: "missing-modular-environment-assembly",
|
||||||
safeAutomaticLink: false,
|
safeAutomaticLink: false,
|
||||||
|
objectiveOrderStatus: "source-objective-review-required",
|
||||||
|
requirements: ["modular-environment-assembly", "source-objective-review"],
|
||||||
finding: "Both objectives and the entrance sit on isolated source-anchor support pads. The WDB contains many separately placed cathedral, slope, bridge, and connector ROS pieces that the primary-only environment package does not yet assemble.",
|
finding: "Both objectives and the entrance sit on isolated source-anchor support pads. The WDB contains many separately placed cathedral, slope, bridge, and connector ROS pieces that the primary-only environment package does not yet assemble.",
|
||||||
evidence: ["scripts/runewaker-pipeline/recipes/the-origin.json:16"],
|
evidence: ["scripts/runewaker-pipeline/recipes/the-origin.json:16"],
|
||||||
},
|
},
|
||||||
"treasure-trove": {
|
"treasure-trove": {
|
||||||
disposition: "missing-modular-environment-plus-bridge-door-mechanics",
|
disposition: "missing-modular-environment-plus-bridge-door-mechanics",
|
||||||
safeAutomaticLink: false,
|
safeAutomaticLink: false,
|
||||||
|
objectiveOrderStatus: "partial-gate-evidence-insufficient-for-complete-order",
|
||||||
|
requirements: [
|
||||||
|
"modular-environment-assembly",
|
||||||
|
"encounter-gate-state",
|
||||||
|
"source-objective-review",
|
||||||
|
"dynamic-navigation-transition",
|
||||||
|
],
|
||||||
finding: "Boss anchors are isolated support pads, while preserved Lua requires a boss-enabled drawbridge switch and a key-gated hold door. The modular environment and stateful bridge/door must precede navigation links.",
|
finding: "Boss anchors are isolated support pads, while preserved Lua requires a boss-enabled drawbridge switch and a key-gated hold door. The modular environment and stateful bridge/door must precede navigation links.",
|
||||||
evidence: [
|
evidence: [
|
||||||
"scripts/runewaker-pipeline/recipes/treasure-trove.json:16",
|
"scripts/runewaker-pipeline/recipes/treasure-trove.json:16",
|
||||||
@@ -92,6 +111,8 @@ const reviewedFindings = {
|
|||||||
"ystra-labyrinth-boss1": {
|
"ystra-labyrinth-boss1": {
|
||||||
disposition: "separate-authored-room-placements",
|
disposition: "separate-authored-room-placements",
|
||||||
safeAutomaticLink: false,
|
safeAutomaticLink: false,
|
||||||
|
objectiveOrderStatus: "independent-room-order-unresolved",
|
||||||
|
requirements: ["encounter-teleport-state", "source-objective-review", "dynamic-navigation-transition"],
|
||||||
finding: "The package intentionally clones three distinct WDB room placements hundreds of meters apart; the preserved travel catalog also treats the boss-room destinations separately. Connecting them through open space would invent geometry.",
|
finding: "The package intentionally clones three distinct WDB room placements hundreds of meters apart; the preserved travel catalog also treats the boss-room destinations separately. Connecting them through open space would invent geometry.",
|
||||||
evidence: [
|
evidence: [
|
||||||
"scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1.json:16",
|
"scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1.json:16",
|
||||||
@@ -418,7 +439,9 @@ function renderMarkdown(report) {
|
|||||||
"The current boss sequence is HealerMan's distance-from-entrance fallback, not a claimed",
|
"The current boss sequence is HealerMan's distance-from-entrance fallback, not a claimed",
|
||||||
"source-authored order. It does not create links or infer doors/teleports.",
|
"source-authored order. It does not create links or infer doors/teleports.",
|
||||||
"",
|
"",
|
||||||
`Unreachable heuristic legs analyzed: **${report.unreachableLegs}** across **${report.dungeons.length}** dungeons.`,
|
`Unreachable currently ordered legs analyzed: **${report.unreachableLegs}** across **${report.dungeons.length}** dungeons.`,
|
||||||
|
`Source-authored fixed orders implemented by the audited packages: **${report.resolutionSummary.sourceAuthoredFixedOrders}**.`,
|
||||||
|
`Authored off-mesh links added by this review: **${report.resolutionSummary.authoredOffMeshLinksAdded}**.`,
|
||||||
"",
|
"",
|
||||||
"| Dungeon | Ordered leg | Geometry classification | From raw comp/dist | To raw comp/dist | Retained distances |",
|
"| Dungeon | Ordered leg | Geometry classification | From raw comp/dist | To raw comp/dist | Retained distances |",
|
||||||
"| --- | --- | --- | --- | --- | --- |",
|
"| --- | --- | --- | --- | --- | --- |",
|
||||||
@@ -442,9 +465,21 @@ function renderMarkdown(report) {
|
|||||||
const finding = dungeon.reviewedFinding;
|
const finding = dungeon.reviewedFinding;
|
||||||
lines.push(`### ${dungeon.id}`, "", `- Disposition: ${finding.disposition}.`,
|
lines.push(`### ${dungeon.id}`, "", `- Disposition: ${finding.disposition}.`,
|
||||||
`- Safe automatic link: ${finding.safeAutomaticLink ? "yes" : "no"}.`,
|
`- Safe automatic link: ${finding.safeAutomaticLink ? "yes" : "no"}.`,
|
||||||
|
`- Objective order: ${finding.objectiveOrderStatus}.`,
|
||||||
|
`- Current package order: ${dungeon.objectiveOrdering.kind} (${dungeon.objectiveOrdering.authoritative ? "authoritative" : "non-authoritative"}).`,
|
||||||
`- Finding: ${finding.finding}`,
|
`- Finding: ${finding.finding}`,
|
||||||
|
...finding.requirements.map((entry) => `- Remaining requirement: ${entry}.`),
|
||||||
...finding.evidence.map((entry) => `- Evidence: ${entry}`), "");
|
...finding.evidence.map((entry) => `- Evidence: ${entry}`), "");
|
||||||
}
|
}
|
||||||
|
lines.push("## Resolution boundary", "",
|
||||||
|
"No off-mesh link was emitted. Every failed leg is either controlled by encounter state,",
|
||||||
|
"depends on environment geometry that is not yet in the runtime package, represents",
|
||||||
|
"separate authored rooms, or still lacks a complete source-authored boss sequence.",
|
||||||
|
"A static link would let companions bypass a door, bridge, wave, or teleport mechanic.",
|
||||||
|
"The structured requirement counts below are deterministic and identify the remaining work.", "");
|
||||||
|
for (const [requirement, count] of Object.entries(report.resolutionSummary.requirementDungeonCounts)) {
|
||||||
|
lines.push(`- ${requirement}: ${count} dungeon(s).`);
|
||||||
|
}
|
||||||
lines.push("");
|
lines.push("");
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
@@ -474,6 +509,11 @@ for (const recipeName of recipeNames) {
|
|||||||
constantCase(recipe.dungeonId) + "_BOSSES",
|
constantCase(recipe.dungeonId) + "_BOSSES",
|
||||||
" as const satisfies readonly DungeonBossObjective[];",
|
" as const satisfies readonly DungeonBossObjective[];",
|
||||||
);
|
);
|
||||||
|
const populationProvenance = generatedJson(
|
||||||
|
source,
|
||||||
|
constantCase(recipe.dungeonId) + "_POPULATION_PROVENANCE",
|
||||||
|
" as const;",
|
||||||
|
);
|
||||||
const graph = buildPartyNavigationGraph(geometryFromPositions(positions));
|
const graph = buildPartyNavigationGraph(geometryFromPositions(positions));
|
||||||
const retainedTriangleComponents = triangleComponents(graph);
|
const retainedTriangleComponents = triangleComponents(graph);
|
||||||
const raw = analyzeRawComponents(positions);
|
const raw = analyzeRawComponents(positions);
|
||||||
@@ -519,9 +559,12 @@ for (const recipeName of recipeNames) {
|
|||||||
reviewedFinding: reviewedFindings[recipe.dungeonId] ?? {
|
reviewedFinding: reviewedFindings[recipe.dungeonId] ?? {
|
||||||
disposition: "manual-review-required",
|
disposition: "manual-review-required",
|
||||||
safeAutomaticLink: false,
|
safeAutomaticLink: false,
|
||||||
|
objectiveOrderStatus: "source-objective-review-required",
|
||||||
|
requirements: ["source-objective-review"],
|
||||||
finding: "No reviewed source-backed navigation disposition is recorded.",
|
finding: "No reviewed source-backed navigation disposition is recorded.",
|
||||||
evidence: [],
|
evidence: [],
|
||||||
},
|
},
|
||||||
|
objectiveOrdering: populationProvenance.objectiveOrder,
|
||||||
rawComponents: raw.components.map((component) => ({
|
rawComponents: raw.components.map((component) => ({
|
||||||
id: component.id,
|
id: component.id,
|
||||||
vertexCount: component.vertices.length,
|
vertexCount: component.vertices.length,
|
||||||
@@ -532,15 +575,26 @@ for (const recipeName of recipeNames) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requirementDungeonCounts = {};
|
||||||
|
for (const dungeon of dungeons) {
|
||||||
|
for (const requirement of new Set(dungeon.reviewedFinding.requirements)) {
|
||||||
|
requirementDungeonCounts[requirement] = (requirementDungeonCounts[requirement] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
const report = {
|
const report = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 2,
|
||||||
generatedAt: new Date().toISOString(),
|
generator: "scripts/runewaker-pipeline/analyze-navigation-gaps.mjs",
|
||||||
methodology: {
|
methodology: {
|
||||||
weldTolerance,
|
weldTolerance,
|
||||||
liveStartSnapDistance: 3,
|
liveStartSnapDistance: 3,
|
||||||
liveObjectiveSnapDistance: 6,
|
liveObjectiveSnapDistance: 6,
|
||||||
mutatesAssets: false,
|
mutatesAssets: false,
|
||||||
},
|
},
|
||||||
|
resolutionSummary: {
|
||||||
|
sourceAuthoredFixedOrders: dungeons.filter((dungeon) => dungeon.objectiveOrdering.authoritative).length,
|
||||||
|
authoredOffMeshLinksAdded: 0,
|
||||||
|
requirementDungeonCounts: Object.fromEntries(Object.entries(requirementDungeonCounts).sort()),
|
||||||
|
},
|
||||||
unreachableLegs: dungeons.flatMap((dungeon) => dungeon.legs).filter((leg) => !leg.reachable).length,
|
unreachableLegs: dungeons.flatMap((dungeon) => dungeon.legs).filter((leg) => !leg.reachable).length,
|
||||||
dungeons,
|
dungeons,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { generateEncounterImplementationReport } from "./lib/encounter-implementation-report.mjs";
|
||||||
|
|
||||||
|
const result = await generateEncounterImplementationReport();
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
validationStatus: result.report.validationStatus,
|
||||||
|
completionStatus: result.report.completionStatus,
|
||||||
|
reportJson: result.reportJson,
|
||||||
|
reportMarkdown: result.reportMarkdown,
|
||||||
|
summary: result.report.summary,
|
||||||
|
issues: result.report.issues,
|
||||||
|
}, null, 2));
|
||||||
|
if (result.report.issues.length) process.exitCode = 1;
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { buildWdbPlacementPlan } from "./lib/environment-assembly.mjs";
|
||||||
|
import { sourceRootFor } from "./lib/recipe.mjs";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
const recipeDirectory = path.join(root, "scripts/runewaker-pipeline/recipes");
|
||||||
|
const reportJsonFile = path.join(
|
||||||
|
root,
|
||||||
|
"scripts/runewaker-pipeline/RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.json",
|
||||||
|
);
|
||||||
|
const reportMarkdownFile = path.join(
|
||||||
|
root,
|
||||||
|
"scripts/runewaker-pipeline/RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.md",
|
||||||
|
);
|
||||||
|
const projectPath = (file) => path.relative(root, file).split(path.sep).join("/");
|
||||||
|
|
||||||
|
async function exists(file) {
|
||||||
|
try {
|
||||||
|
await stat(file);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(report) {
|
||||||
|
const rows = report.dungeons.map((dungeon) => [
|
||||||
|
"|", dungeon.id, "|", dungeon.mode, "|", dungeon.sourceDescriptors, "|",
|
||||||
|
dungeon.uniqueResources, "|", dungeon.staticPlacements, "|",
|
||||||
|
dungeon.dynamicPlacements, "|", dungeon.dynamicReviewCandidates, "|",
|
||||||
|
dungeon.unsupportedPlacements, "|", dungeon.missingSourceResources, "|",
|
||||||
|
].join(" "));
|
||||||
|
return [
|
||||||
|
"# RuneWaker WDB environment assembly audit",
|
||||||
|
"",
|
||||||
|
"Structural validation: **" + report.validationStatus + "**",
|
||||||
|
"Source-behavior review: **" + report.reviewStatus + "**",
|
||||||
|
"",
|
||||||
|
"This deterministic audit reads the checked-in WDB reports and environment recipes. It does",
|
||||||
|
"not convert or replace shipping GLBs. The builder now supports every valid ROS placement",
|
||||||
|
"resource with normalized-path model caching validated by source hashes and the complete translation/quaternion/scale",
|
||||||
|
"transform relative to the reviewed primary placement.",
|
||||||
|
"",
|
||||||
|
"## Coverage",
|
||||||
|
"",
|
||||||
|
"- " + report.summary.dungeons + " dungeon WDB packages and "
|
||||||
|
+ report.summary.sourceDescriptors + " source descriptors.",
|
||||||
|
"- " + report.summary.uniqueDungeonResources
|
||||||
|
+ " per-dungeon unique ROS resources (resources reused across dungeons share cache keys).",
|
||||||
|
"- " + report.summary.staticPlacements + " static placements and "
|
||||||
|
+ report.summary.dynamicPlacements + " explicitly reviewed dynamic placements.",
|
||||||
|
"- " + report.summary.dynamicReviewCandidates
|
||||||
|
+ " filename-based dynamic candidates remain static and review-required; names never prove behavior.",
|
||||||
|
"- " + report.summary.unsupportedPlacements + " unsupported placement records and "
|
||||||
|
+ report.summary.missingSourceResources + " missing source ROS resources.",
|
||||||
|
"",
|
||||||
|
"| Dungeon | Mode | Descriptors | Resources | Static | Dynamic | Dynamic review | Unsupported | Missing ROS |",
|
||||||
|
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
||||||
|
...rows,
|
||||||
|
"",
|
||||||
|
"## Runtime-facing output",
|
||||||
|
"",
|
||||||
|
"A full build writes `wdb-placement-manifest.json` beside the environment package. Each",
|
||||||
|
"placement has a stable WDB GUID ID, normalized resource/cache key, source TRS, primary-local",
|
||||||
|
"Three.js matrix, bounds, flags, asset status, and optional reviewed dynamic definition.",
|
||||||
|
"Static placements are assembled into the optimized visual and navigation collision. Reviewed",
|
||||||
|
"dynamic placements are omitted from static collision and packaged as separately addressable GLBs.",
|
||||||
|
"",
|
||||||
|
"## Unsupported or review-required behavior",
|
||||||
|
"",
|
||||||
|
...report.unsupportedResourceTypes.map((item) => "- " + item),
|
||||||
|
"- The primary-only top-down preview and editable `.blend` do not include the final merged WDB",
|
||||||
|
" assembly; the generated GLB and placement manifest are authoritative.",
|
||||||
|
"- Dynamic-candidate filenames are not enough to remove geometry from the static package. Add a",
|
||||||
|
" reviewed `source.wdbAssembly.dynamicPlacements` rule with evidence first.",
|
||||||
|
"",
|
||||||
|
"## Validation issues",
|
||||||
|
"",
|
||||||
|
...(report.issues.length ? report.issues.map((item) => "- " + item) : ["- None."]),
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildEnvironmentAssemblyAudit() {
|
||||||
|
const recipeNames = (await readdir(recipeDirectory))
|
||||||
|
.filter((name) => name.endsWith(".json")
|
||||||
|
&& !name.endsWith("-population.json")
|
||||||
|
&& name !== "runewaker-player-avatars.json")
|
||||||
|
.sort();
|
||||||
|
const dungeons = [];
|
||||||
|
const issues = [];
|
||||||
|
for (const recipeName of recipeNames) {
|
||||||
|
const recipe = JSON.parse(await readFile(path.join(recipeDirectory, recipeName), "utf8"));
|
||||||
|
const wdbReportFile = path.join(
|
||||||
|
root,
|
||||||
|
"src/assets/game/dungeons",
|
||||||
|
recipe.slug,
|
||||||
|
"wdb-report.json",
|
||||||
|
);
|
||||||
|
if (!await exists(wdbReportFile)) {
|
||||||
|
issues.push(recipe.slug + ": checked-in WDB report is missing.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const wdbReport = JSON.parse(await readFile(wdbReportFile, "utf8"));
|
||||||
|
const plan = buildWdbPlacementPlan(wdbReport, recipe);
|
||||||
|
const resourceRoot = path.join(sourceRootFor(recipe), recipe.source.resourceRoot);
|
||||||
|
const missingResources = [];
|
||||||
|
for (const resource of plan.resources) {
|
||||||
|
const file = path.join(resourceRoot, ...resource.normalizedResource.split("\\"));
|
||||||
|
if (!await exists(file)) missingResources.push(resource.resource);
|
||||||
|
}
|
||||||
|
dungeons.push({
|
||||||
|
id: recipe.slug,
|
||||||
|
mode: recipe.source.proceduralTerrainFallback
|
||||||
|
? "primary-only-procedural-fallback"
|
||||||
|
: recipe.source.wdbAssembly?.mode ?? "all-ros",
|
||||||
|
recipe: projectPath(path.join(recipeDirectory, recipeName)),
|
||||||
|
wdbReport: projectPath(wdbReportFile),
|
||||||
|
sourceDescriptors: plan.sourceDescriptorCount,
|
||||||
|
uniqueResources: plan.resources.length,
|
||||||
|
staticPlacements: plan.staticPlacements.length,
|
||||||
|
dynamicPlacements: plan.dynamicPlacements.length,
|
||||||
|
dynamicReviewCandidates: plan.reviewRequiredPlacements.length,
|
||||||
|
unsupportedPlacements: plan.unsupportedPlacements.length,
|
||||||
|
missingSourceResources: missingResources.length,
|
||||||
|
missingResources,
|
||||||
|
unsupported: plan.unsupportedPlacements,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
issues.push(recipe.slug + ": " + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dungeons.sort((left, right) => left.id.localeCompare(right.id));
|
||||||
|
const sum = (field) => dungeons.reduce((total, dungeon) => total + dungeon[field], 0);
|
||||||
|
const unsupportedResourceTypes = [
|
||||||
|
"WDB placement resources other than ROS are recorded but not converted.",
|
||||||
|
"Particle, light, sound, camera, and script-only components absent from ROS mesh descriptors remain unsupported.",
|
||||||
|
"Animated/stateful behavior remains unsupported until an exact reviewed dynamic-placement rule supplies its kind, initial state, collision policy, and evidence.",
|
||||||
|
"A ROS that the preserved exporter cannot decode is skipped, recorded with its resource path and failure, and never replaced with guessed geometry.",
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
generatedBy: "scripts/runewaker-pipeline/audit-environment-assembly.mjs",
|
||||||
|
deterministic: true,
|
||||||
|
validationStatus: issues.length ? "blocked" : "green",
|
||||||
|
reviewStatus: sum("dynamicReviewCandidates") || sum("unsupportedPlacements")
|
||||||
|
|| sum("missingSourceResources") ? "review-required" : "green",
|
||||||
|
summary: {
|
||||||
|
dungeons: dungeons.length,
|
||||||
|
sourceDescriptors: sum("sourceDescriptors"),
|
||||||
|
uniqueDungeonResources: sum("uniqueResources"),
|
||||||
|
staticPlacements: sum("staticPlacements"),
|
||||||
|
dynamicPlacements: sum("dynamicPlacements"),
|
||||||
|
dynamicReviewCandidates: sum("dynamicReviewCandidates"),
|
||||||
|
unsupportedPlacements: sum("unsupportedPlacements"),
|
||||||
|
missingSourceResources: sum("missingSourceResources"),
|
||||||
|
},
|
||||||
|
unsupportedResourceTypes,
|
||||||
|
issues,
|
||||||
|
dungeons,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await buildEnvironmentAssemblyAudit();
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(reportJsonFile, JSON.stringify(report, null, 2) + "\n", "utf8"),
|
||||||
|
writeFile(reportMarkdownFile, renderMarkdown(report), "utf8"),
|
||||||
|
]);
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
validationStatus: report.validationStatus,
|
||||||
|
reviewStatus: report.reviewStatus,
|
||||||
|
reportJson: projectPath(reportJsonFile),
|
||||||
|
reportMarkdown: projectPath(reportMarkdownFile),
|
||||||
|
summary: report.summary,
|
||||||
|
issues: report.issues,
|
||||||
|
}, null, 2));
|
||||||
|
if (report.issues.length) process.exitCode = 1;
|
||||||
@@ -7,6 +7,7 @@ import { BufferAttribute, BufferGeometry, Matrix4, Vector3 } from "three";
|
|||||||
import {
|
import {
|
||||||
buildPartyNavigationGraph,
|
buildPartyNavigationGraph,
|
||||||
} from "../../src/game/partyNavigation.ts";
|
} from "../../src/game/partyNavigation.ts";
|
||||||
|
import { generateEncounterImplementationReport } from "./lib/encounter-implementation-report.mjs";
|
||||||
import { auditOrderedObjectiveRoutes } from "./lib/objective-route-audit.mjs";
|
import { auditOrderedObjectiveRoutes } from "./lib/objective-route-audit.mjs";
|
||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
@@ -156,6 +157,19 @@ function validateAttack(dungeonId, entityId, attack, issues) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formulaMatchesCatalog(attackFormula, catalogFormula) {
|
||||||
|
if (!catalogFormula) return attackFormula === undefined;
|
||||||
|
if (!attackFormula || typeof attackFormula !== "object") return false;
|
||||||
|
const expected = {
|
||||||
|
...catalogFormula,
|
||||||
|
// NPC assignments carry their source spell level. Script-derived boss
|
||||||
|
// references use the documented zero sentinel because no assignment level
|
||||||
|
// exists in the packaged row.
|
||||||
|
spellLevel: attackFormula.spellLevel,
|
||||||
|
};
|
||||||
|
return JSON.stringify(attackFormula) === JSON.stringify(expected);
|
||||||
|
}
|
||||||
|
|
||||||
function renderMarkdown(report) {
|
function renderMarkdown(report) {
|
||||||
const rows = report.dungeons.map((dungeon) => [
|
const rows = report.dungeons.map((dungeon) => [
|
||||||
"|", dungeon.id, "|", dungeon.sourceRows, "|", dungeon.runtimeSpawns, "|",
|
"|", dungeon.id, "|", dungeon.sourceRows, "|", dungeon.runtimeSpawns, "|",
|
||||||
@@ -171,7 +185,7 @@ function renderMarkdown(report) {
|
|||||||
return [
|
return [
|
||||||
"# RuneWaker gameplay content audit",
|
"# RuneWaker gameplay content audit",
|
||||||
"",
|
"",
|
||||||
"Structural validation: **" + report.validationStatus + "** ",
|
"Structural validation: **" + report.validationStatus + "**",
|
||||||
"Original-mechanics fidelity: **" + report.fidelityStatus + "**",
|
"Original-mechanics fidelity: **" + report.fidelityStatus + "**",
|
||||||
"",
|
"",
|
||||||
"This offline audit reads packaged recipes, snapshots, generated TypeScript, reports,",
|
"This offline audit reads packaged recipes, snapshots, generated TypeScript, reports,",
|
||||||
@@ -200,6 +214,10 @@ function renderMarkdown(report) {
|
|||||||
"- Boss signaling includes " + summary.telegraphedBossMechanics
|
"- Boss signaling includes " + summary.telegraphedBossMechanics
|
||||||
+ " spatial telegraphs and " + summary.healthTriggeredBossMechanics
|
+ " spatial telegraphs and " + summary.healthTriggeredBossMechanics
|
||||||
+ " visible health-triggered mechanics.",
|
+ " visible health-triggered mechanics.",
|
||||||
|
"- The exhaustive encounter implementation ledger tracks "
|
||||||
|
+ report.encounterImplementationLedger.deferredSourceRows + " deferred source rows and "
|
||||||
|
+ report.encounterImplementationLedger.uniqueMechanicsEvidence
|
||||||
|
+ " unique mechanics evidence records without inflating overlapping regex labels.",
|
||||||
"",
|
"",
|
||||||
"## Authentic source data currently used",
|
"## Authentic source data currently used",
|
||||||
"",
|
"",
|
||||||
@@ -215,10 +233,12 @@ function renderMarkdown(report) {
|
|||||||
"- " + summary.builderAuthoredAttacks + " attacks are builder exceptions outside the modular boss catalog.",
|
"- " + summary.builderAuthoredAttacks + " attacks are builder exceptions outside the modular boss catalog.",
|
||||||
"- " + summary.sourceBackedDamageFamilyAttacks
|
"- " + summary.sourceBackedDamageFamilyAttacks
|
||||||
+ " executable attacks carry a conservative source-backed direct-damage family mapping.",
|
+ " executable attacks carry a conservative source-backed direct-damage family mapping.",
|
||||||
"- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;",
|
"- Source-backed mapping preserves spell identity, damage family, school, range, cooldown,",
|
||||||
" HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.",
|
" source power fields, and the exact native formula shape/provenance.",
|
||||||
"- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.",
|
"- Damage execution uses a labeled HealerMan baseline adapter because native ATK/MATK,",
|
||||||
"- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.",
|
" DEF/MDEF, weapon power, resist, critical, level-delta, and global rate inputs are unavailable.",
|
||||||
|
"- Scaling, movement/leash, clustering, aggro, death state, loot item stats/slots, and objective ordering are generic.",
|
||||||
|
"- Unmapped complex doors, multi-actor phases, patrols, resets, and interactions remain review-required.",
|
||||||
"- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,",
|
"- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,",
|
||||||
" attack, cast, wound, and death families.",
|
" attack, cast, wound, and death families.",
|
||||||
"",
|
"",
|
||||||
@@ -530,11 +550,16 @@ for (const recipeName of recipeNames) {
|
|||||||
+ executableSpellId + ".");
|
+ executableSpellId + ".");
|
||||||
} else {
|
} else {
|
||||||
counts.sourceBackedDamageFamilyAttacks += 1;
|
counts.sourceBackedDamageFamilyAttacks += 1;
|
||||||
if (attack.spellId && [
|
const runtimeMappingDrift = !catalogAuthored && [
|
||||||
"name", "school", "animation", "cooldownMs", "damageMultiplier",
|
"name", "school", "animation", "cooldownMs", "damageMultiplier",
|
||||||
].some((field) => attack[field] !== (
|
].some((field) => attack[field] !== (
|
||||||
field === "name" ? catalogSpell.name : catalogSpell.runtime[field]
|
field === "name" ? catalogSpell.name : catalogSpell.runtime[field]
|
||||||
))) {
|
));
|
||||||
|
const formulaDrift = !formulaMatchesCatalog(
|
||||||
|
attack.runewakerFormula,
|
||||||
|
catalogSpell.runtime.formula,
|
||||||
|
);
|
||||||
|
if (attack.spellId && (runtimeMappingDrift || formulaDrift)) {
|
||||||
issue(issues, dungeonId, entityId + " spell " + executableSpellId
|
issue(issues, dungeonId, entityId + " spell " + executableSpellId
|
||||||
+ " drifted from the portable source-backed runtime mapping.");
|
+ " drifted from the portable source-backed runtime mapping.");
|
||||||
}
|
}
|
||||||
@@ -749,8 +774,8 @@ summary.portableDamageFamilyRecords = spellCatalogSnapshot?.damageFamilyCount ??
|
|||||||
summary.portableEvidenceOnlyRecords = spellCatalogSnapshot?.evidenceOnlyCount ?? 0;
|
summary.portableEvidenceOnlyRecords = spellCatalogSnapshot?.evidenceOnlyCount ?? 0;
|
||||||
reviewNotes.push(summary.sourceGapBossMechanics
|
reviewNotes.push(summary.sourceGapBossMechanics
|
||||||
+ " boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.");
|
+ " 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 "
|
reviewNotes.push("Source formula structure is preserved, but live damage uses a labeled HealerMan "
|
||||||
+ "formula/stat scaling has not been reconstructed.");
|
+ "baseline adapter because the required native combat-stat inputs are not packaged.");
|
||||||
const navigationBlockerEntries = dungeons.flatMap((dungeon) => (
|
const navigationBlockerEntries = dungeons.flatMap((dungeon) => (
|
||||||
dungeon.objectiveRouteEvidence
|
dungeon.objectiveRouteEvidence
|
||||||
.filter((route) => route.orderAuthoritative && !route.reachable)
|
.filter((route) => route.orderAuthoritative && !route.reachable)
|
||||||
@@ -784,6 +809,12 @@ if (summary.derivedObjectiveRoutes) {
|
|||||||
+ summary.unreachableDerivedObjectiveRoutes + " unreachable heuristic legs remain navigation/order review evidence.");
|
+ summary.unreachableDerivedObjectiveRoutes + " unreachable heuristic legs remain navigation/order review evidence.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const encounterImplementation = await generateEncounterImplementationReport();
|
||||||
|
if (encounterImplementation.report.issues.length) {
|
||||||
|
issues.push("Encounter implementation ledger is blocked: "
|
||||||
|
+ encounterImplementation.report.issues.join("; "));
|
||||||
|
}
|
||||||
|
|
||||||
const report = {
|
const report = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
validationStatus: issues.length ? "blocked" : "green",
|
validationStatus: issues.length ? "blocked" : "green",
|
||||||
@@ -797,11 +828,11 @@ const report = {
|
|||||||
],
|
],
|
||||||
genericRuntimeSystems: [
|
genericRuntimeSystems: [
|
||||||
"source-gap attacks", "health/damage scaling", "movement/leash", "clustering/aggro",
|
"source-gap attacks", "health/damage scaling", "movement/leash", "clustering/aggro",
|
||||||
"combat/death state", "loot policy", "objective ordering", "procedural animation",
|
"combat/death state", "loot item stats/slots", "objective ordering", "procedural animation",
|
||||||
],
|
],
|
||||||
deferredMechanics: [
|
deferredMechanics: [
|
||||||
"complex doors/gates", "unmapped multi-actor waves", "unmapped phases", "patrols", "interactions",
|
"complex doors/gates", "unmapped multi-actor waves", "unmapped phases", "patrols", "interactions",
|
||||||
"encounter resets", "source loot",
|
"encounter resets", "blocked source-loot predicates", "source item stat/slot mapping",
|
||||||
],
|
],
|
||||||
blockerGroups: {
|
blockerGroups: {
|
||||||
navigation: {
|
navigation: {
|
||||||
@@ -829,6 +860,14 @@ const report = {
|
|||||||
mappingPolicy: "Only positively classified direct-HP damage families execute; "
|
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.",
|
+ "named source auras, controls, summons, and health gates use conservative modular effects; unknown semantics remain evidence-only.",
|
||||||
},
|
},
|
||||||
|
encounterImplementationLedger: {
|
||||||
|
json: encounterImplementation.reportJson,
|
||||||
|
markdown: encounterImplementation.reportMarkdown,
|
||||||
|
validationStatus: encounterImplementation.report.validationStatus,
|
||||||
|
completionStatus: encounterImplementation.report.completionStatus,
|
||||||
|
deferredSourceRows: encounterImplementation.report.summary.deferredSourceRows,
|
||||||
|
uniqueMechanicsEvidence: encounterImplementation.report.summary.uniqueMechanicsEvidence,
|
||||||
|
},
|
||||||
resolvedRuntimeObservations,
|
resolvedRuntimeObservations,
|
||||||
documentedExceptions,
|
documentedExceptions,
|
||||||
issues,
|
issues,
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import {
|
|||||||
projectRoot,
|
projectRoot,
|
||||||
sourceRootFor,
|
sourceRootFor,
|
||||||
} from "./lib/recipe.mjs";
|
} from "./lib/recipe.mjs";
|
||||||
|
import {
|
||||||
|
assembleInstancedGlb,
|
||||||
|
buildWdbPlacementPlan,
|
||||||
|
normalizeWdbResourcePath,
|
||||||
|
} from "./lib/environment-assembly.mjs";
|
||||||
|
|
||||||
const argv = process.argv.slice(2);
|
const argv = process.argv.slice(2);
|
||||||
const fallbackRecipe = path.join(pipelineDirectory, "recipes", "forsaken-abbey.json");
|
const fallbackRecipe = path.join(pipelineDirectory, "recipes", "forsaken-abbey.json");
|
||||||
@@ -23,6 +28,7 @@ const workDirectory = path.join(projectRoot, "runewaker-export-work", slug);
|
|||||||
const rawDirectory = path.join(workDirectory, "raw");
|
const rawDirectory = path.join(workDirectory, "raw");
|
||||||
const preparedDirectory = path.join(workDirectory, "prepared");
|
const preparedDirectory = path.join(workDirectory, "prepared");
|
||||||
const convertedDirectory = path.join(workDirectory, "converted");
|
const convertedDirectory = path.join(workDirectory, "converted");
|
||||||
|
const resourceCacheDirectory = path.join(projectRoot, "runewaker-export-work", "resource-cache");
|
||||||
const shippingDirectory = path.join(projectRoot, "src", "assets", "game", "dungeons", slug);
|
const shippingDirectory = path.join(projectRoot, "src", "assets", "game", "dungeons", slug);
|
||||||
const sourceRoot = sourceRootFor(recipe);
|
const sourceRoot = sourceRootFor(recipe);
|
||||||
const resourceRoot = path.join(sourceRoot, recipe.source.resourceRoot);
|
const resourceRoot = path.join(sourceRoot, recipe.source.resourceRoot);
|
||||||
@@ -45,6 +51,10 @@ const blenderScript = path.join(pipelineDirectory, "blender", "convert-runewaker
|
|||||||
const gltfTransform = path.join(projectRoot, "node_modules", "@gltf-transform", "cli", "bin", "cli.js");
|
const gltfTransform = path.join(projectRoot, "node_modules", "@gltf-transform", "cli", "bin", "cli.js");
|
||||||
const ktx2Compressor = path.join(projectRoot, "scripts", "asset-pipeline", "compress-ktx2.mjs");
|
const ktx2Compressor = path.join(projectRoot, "scripts", "asset-pipeline", "compress-ktx2.mjs");
|
||||||
const khronosValidator = path.join(projectRoot, "scripts", "manastorm-assets", "validate-package-glbs.cjs");
|
const khronosValidator = path.join(projectRoot, "scripts", "manastorm-assets", "validate-package-glbs.cjs");
|
||||||
|
const fullWdbAssembly = !recipe.source.proceduralTerrainFallback
|
||||||
|
&& recipe.source.wdbAssembly?.mode !== "primary-only"
|
||||||
|
&& !argv.includes("--primary-only");
|
||||||
|
const collisionChunkTriangleTarget = 90_000;
|
||||||
|
|
||||||
function run(executable, argumentsList, label) {
|
function run(executable, argumentsList, label) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -67,6 +77,15 @@ async function requirePath(file, guidance) {
|
|||||||
catch { throw new Error(guidance + "\nMissing: " + file); }
|
catch { throw new Error(guidance + "\nMissing: " + file); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exists(file) {
|
||||||
|
try {
|
||||||
|
await stat(file);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function sha256(file) {
|
async function sha256(file) {
|
||||||
return createHash("sha256").update(await readFile(file)).digest("hex");
|
return createHash("sha256").update(await readFile(file)).digest("hex");
|
||||||
}
|
}
|
||||||
@@ -148,7 +167,7 @@ function sourcePointToPrimaryLocal(sourcePosition, placement) {
|
|||||||
|
|
||||||
async function appendSourceAnchorSupport() {
|
async function appendSourceAnchorSupport() {
|
||||||
const support = recipe.source.sourceAnchorSupport;
|
const support = recipe.source.sourceAnchorSupport;
|
||||||
if (!support) return;
|
if (!support || fullWdbAssembly) return;
|
||||||
const { placement } = await primaryPlacement();
|
const { placement } = await primaryPlacement();
|
||||||
const half = Number(support.halfExtentSourceUnits ?? 60);
|
const half = Number(support.halfExtentSourceUnits ?? 60);
|
||||||
const thickness = Number(support.thicknessSourceUnits ?? 3);
|
const thickness = Number(support.thicknessSourceUnits ?? 3);
|
||||||
@@ -185,6 +204,128 @@ async function appendSourceAnchorSupport() {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resourceSourceFile(resource) {
|
||||||
|
return path.join(resourceRoot, ...normalizeWdbResourcePath(resource).split("\\"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareCachedResourceTextures(raw, prepared) {
|
||||||
|
const sourceTextures = path.join(raw, "textures");
|
||||||
|
const preparedTextures = path.join(prepared, "textures");
|
||||||
|
await mkdir(preparedTextures, { recursive: true });
|
||||||
|
const ddsFiles = await exists(sourceTextures)
|
||||||
|
? (await readdir(sourceTextures))
|
||||||
|
.filter((file) => file.toLowerCase().endsWith(".dds"))
|
||||||
|
.sort()
|
||||||
|
.map((file) => path.join(sourceTextures, file))
|
||||||
|
: [];
|
||||||
|
if (ddsFiles.length) {
|
||||||
|
await run(
|
||||||
|
texconv,
|
||||||
|
["-ft", "PNG", "-o", preparedTextures, "-y", "--permissive", ...ddsFiles],
|
||||||
|
"convert " + ddsFiles.length + " cached DDS textures",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await copyFile(path.join(raw, "scene.obj"), path.join(prepared, "scene.obj"));
|
||||||
|
await copyFile(path.join(raw, "export-report.json"), path.join(prepared, "export-report.json"));
|
||||||
|
const sourceMtl = await readFile(path.join(raw, "scene.mtl"), "utf8");
|
||||||
|
await writeFile(
|
||||||
|
path.join(prepared, "scene.mtl"),
|
||||||
|
sourceMtl.replace(/^(map_Kd\s+.+)\.dds\s*$/gim, "$1.png"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cachedResourceAsset(resourceRecord) {
|
||||||
|
const sourceFile = resourceSourceFile(resourceRecord.resource);
|
||||||
|
if (!await exists(sourceFile)) {
|
||||||
|
throw new Error("source ROS is missing: " + projectPath(sourceFile));
|
||||||
|
}
|
||||||
|
const sourceSha256 = await sha256(sourceFile);
|
||||||
|
const cacheRoot = path.join(resourceCacheDirectory, resourceRecord.cacheKey);
|
||||||
|
const raw = path.join(cacheRoot, "raw");
|
||||||
|
const prepared = path.join(cacheRoot, "prepared");
|
||||||
|
const converted = path.join(cacheRoot, "converted");
|
||||||
|
const manifestFile = path.join(cacheRoot, "cache-manifest.json");
|
||||||
|
if (!argv.includes("--force-resource-cache") && await exists(manifestFile)) {
|
||||||
|
const cached = JSON.parse(await readFile(manifestFile, "utf8"));
|
||||||
|
const outputs = [cached.visual?.file, ...(cached.collision ?? []).map((entry) => entry.file)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((file) => path.join(cacheRoot, file));
|
||||||
|
if (cached.source?.resource === resourceRecord.normalizedResource
|
||||||
|
&& cached.source?.sha256 === sourceSha256
|
||||||
|
&& cached.coordinateSystem?.metersPerSourceUnit === recipe.coordinateSystem.metersPerSourceUnit
|
||||||
|
&& (await Promise.all(outputs.map(exists))).every(Boolean)) {
|
||||||
|
return {
|
||||||
|
...cached,
|
||||||
|
cacheHit: true,
|
||||||
|
visualFile: path.join(cacheRoot, cached.visual.file),
|
||||||
|
collision: cached.collision.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
absoluteFile: path.join(cacheRoot, entry.file),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(raw, { recursive: true }),
|
||||||
|
mkdir(prepared, { recursive: true }),
|
||||||
|
mkdir(converted, { recursive: true }),
|
||||||
|
]);
|
||||||
|
await run(
|
||||||
|
nativeExporter,
|
||||||
|
[resourceRoot, resourceRecord.resource.replaceAll("/", "\\"), raw],
|
||||||
|
"export cached WDB resource " + resourceRecord.resource,
|
||||||
|
);
|
||||||
|
await prepareCachedResourceTextures(raw, prepared);
|
||||||
|
await run(
|
||||||
|
blender,
|
||||||
|
[
|
||||||
|
"--background", "--factory-startup", "--python", blenderScript, "--",
|
||||||
|
path.join(prepared, "scene.obj"),
|
||||||
|
converted,
|
||||||
|
resourceRecord.cacheKey,
|
||||||
|
String(recipe.coordinateSystem.metersPerSourceUnit),
|
||||||
|
],
|
||||||
|
"convert cached WDB resource " + resourceRecord.resource,
|
||||||
|
);
|
||||||
|
const conversion = JSON.parse(await readFile(path.join(converted, "conversion-report.json"), "utf8"));
|
||||||
|
const manifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
source: {
|
||||||
|
resource: resourceRecord.normalizedResource,
|
||||||
|
file: projectPath(sourceFile),
|
||||||
|
sha256: sourceSha256,
|
||||||
|
},
|
||||||
|
coordinateSystem: {
|
||||||
|
nativeBridge: recipe.coordinateSystem.nativeBridge,
|
||||||
|
metersPerSourceUnit: recipe.coordinateSystem.metersPerSourceUnit,
|
||||||
|
},
|
||||||
|
visual: {
|
||||||
|
file: path.relative(
|
||||||
|
cacheRoot,
|
||||||
|
path.join(converted, resourceRecord.cacheKey + "-visual.glb"),
|
||||||
|
).split(path.sep).join("/"),
|
||||||
|
triangles: conversion.visual.triangles,
|
||||||
|
meshObjects: conversion.visual.meshObjects,
|
||||||
|
bounds: conversion.visual.bounds,
|
||||||
|
},
|
||||||
|
collision: conversion.collision.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
file: path.relative(cacheRoot, path.join(converted, entry.file)).split(path.sep).join("/"),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
await writeFile(manifestFile, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
||||||
|
return {
|
||||||
|
...manifest,
|
||||||
|
cacheHit: false,
|
||||||
|
visualFile: path.join(cacheRoot, manifest.visual.file),
|
||||||
|
collision: manifest.collision.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
absoluteFile: path.join(cacheRoot, entry.file),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function prepareTextures() {
|
async function prepareTextures() {
|
||||||
const sourceTextures = path.join(rawDirectory, "textures");
|
const sourceTextures = path.join(rawDirectory, "textures");
|
||||||
const preparedTextures = path.join(preparedDirectory, "textures");
|
const preparedTextures = path.join(preparedDirectory, "textures");
|
||||||
@@ -252,8 +393,208 @@ function cloneNodeTree(document, node) {
|
|||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupCollisionRecords(records) {
|
||||||
|
const groups = [];
|
||||||
|
let current = [];
|
||||||
|
let triangles = 0;
|
||||||
|
for (const record of records) {
|
||||||
|
if (current.length && triangles + record.triangles > collisionChunkTriangleTarget) {
|
||||||
|
groups.push({ records: current, triangles });
|
||||||
|
current = [];
|
||||||
|
triangles = 0;
|
||||||
|
}
|
||||||
|
current.push(record);
|
||||||
|
triangles += record.triangles;
|
||||||
|
}
|
||||||
|
if (current.length) groups.push({ records: current, triangles });
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assembleAllWdbPlacements() {
|
||||||
|
const wdbReport = JSON.parse(await readFile(path.join(rawDirectory, "wdb-report.json"), "utf8"));
|
||||||
|
const plan = buildWdbPlacementPlan(wdbReport, recipe);
|
||||||
|
const primaryResource = normalizeWdbResourcePath(plan.primaryPlacement.resource);
|
||||||
|
const primaryConversion = JSON.parse(
|
||||||
|
await readFile(path.join(convertedDirectory, "conversion-report.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const assets = new Map();
|
||||||
|
assets.set(primaryResource, {
|
||||||
|
cacheHit: true,
|
||||||
|
cacheKind: "primary-conversion",
|
||||||
|
visualFile: path.join(convertedDirectory, slug + "-visual.glb"),
|
||||||
|
visual: primaryConversion.visual,
|
||||||
|
collision: primaryConversion.collision.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
absoluteFile: path.join(convertedDirectory, entry.file),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const failures = [];
|
||||||
|
for (const resource of plan.resources) {
|
||||||
|
if (resource.normalizedResource === primaryResource) continue;
|
||||||
|
try {
|
||||||
|
assets.set(resource.normalizedResource, await cachedResourceAsset(resource));
|
||||||
|
} catch (error) {
|
||||||
|
failures.push({
|
||||||
|
resource: resource.resource,
|
||||||
|
normalizedResource: resource.normalizedResource,
|
||||||
|
placementCount: resource.placementCount,
|
||||||
|
reason: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const unavailable = new Set(failures.map((failure) => failure.normalizedResource));
|
||||||
|
for (const resource of plan.resources) {
|
||||||
|
const asset = assets.get(resource.normalizedResource);
|
||||||
|
resource.assetStatus = asset ? "available" : "unsupported";
|
||||||
|
resource.cacheHit = asset?.cacheHit ?? false;
|
||||||
|
resource.cacheKind = asset?.cacheKind ?? "normalized-path-cache-with-source-hash-validation";
|
||||||
|
if (!asset) resource.blocker = failures.find((entry) => (
|
||||||
|
entry.normalizedResource === resource.normalizedResource
|
||||||
|
))?.reason;
|
||||||
|
}
|
||||||
|
for (const placement of [...plan.staticPlacements, ...plan.dynamicPlacements]) {
|
||||||
|
placement.assetStatus = unavailable.has(placement.normalizedResource) ? "unsupported" : "available";
|
||||||
|
placement.assetId = "wdb-resource:" + placement.resourceCacheKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticPlacements = plan.staticPlacements.filter((placement) => (
|
||||||
|
placement.assetStatus === "available"
|
||||||
|
));
|
||||||
|
if (!staticPlacements.length) throw new Error("WDB assembly produced no available static placements.");
|
||||||
|
const visualRecords = staticPlacements.map((placement) => ({
|
||||||
|
placement,
|
||||||
|
assetFile: assets.get(placement.normalizedResource).visualFile,
|
||||||
|
}));
|
||||||
|
const assembledVisualFile = path.join(convertedDirectory, slug + "-visual.wdb-assembled.glb");
|
||||||
|
const visualAssembly = await assembleInstancedGlb(visualRecords, assembledVisualFile);
|
||||||
|
await copyFile(assembledVisualFile, path.join(convertedDirectory, slug + "-visual.glb"));
|
||||||
|
|
||||||
|
const collisionRecords = staticPlacements.flatMap((placement) => (
|
||||||
|
assets.get(placement.normalizedResource).collision.map((collision) => ({
|
||||||
|
placement,
|
||||||
|
assetFile: collision.absoluteFile,
|
||||||
|
triangles: Number(collision.triangles ?? collision.sourceTriangleEstimate ?? 0),
|
||||||
|
}))
|
||||||
|
)).sort((left, right) => (
|
||||||
|
left.placement.id.localeCompare(right.placement.id)
|
||||||
|
|| left.assetFile.localeCompare(right.assetFile)
|
||||||
|
));
|
||||||
|
const collision = [];
|
||||||
|
for (const [index, group] of groupCollisionRecords(collisionRecords).entries()) {
|
||||||
|
const file = slug + "-wdb-collision-" + String(index).padStart(3, "0") + ".glb";
|
||||||
|
await assembleInstancedGlb(group.records, path.join(convertedDirectory, file));
|
||||||
|
collision.push({
|
||||||
|
file,
|
||||||
|
triangles: group.triangles,
|
||||||
|
sourceTriangleEstimate: group.triangles,
|
||||||
|
placementCount: group.records.length,
|
||||||
|
assembledFromWdb: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dynamicDirectory = path.join(convertedDirectory, "dynamic");
|
||||||
|
const dynamicAssets = [];
|
||||||
|
const dynamicResources = plan.resources.filter((resource) => (
|
||||||
|
resource.dynamicPlacementCount > 0 && resource.assetStatus === "available"
|
||||||
|
));
|
||||||
|
if (dynamicResources.length) await mkdir(dynamicDirectory, { recursive: true });
|
||||||
|
for (const resource of dynamicResources) {
|
||||||
|
const asset = assets.get(resource.normalizedResource);
|
||||||
|
const visualName = resource.cacheKey + "-visual.glb";
|
||||||
|
await copyFile(asset.visualFile, path.join(dynamicDirectory, visualName));
|
||||||
|
dynamicAssets.push({
|
||||||
|
role: "dynamic-visual",
|
||||||
|
file: "dynamic/" + visualName,
|
||||||
|
assetId: "wdb-resource:" + resource.cacheKey,
|
||||||
|
resource: resource.resource,
|
||||||
|
});
|
||||||
|
for (const [index, sourceCollision] of asset.collision.entries()) {
|
||||||
|
const collisionName = resource.cacheKey + "-collision-" + String(index).padStart(3, "0") + ".glb";
|
||||||
|
await copyFile(sourceCollision.absoluteFile, path.join(dynamicDirectory, collisionName));
|
||||||
|
dynamicAssets.push({
|
||||||
|
role: "dynamic-collision",
|
||||||
|
file: "dynamic/" + collisionName,
|
||||||
|
assetId: "wdb-resource:" + resource.cacheKey,
|
||||||
|
resource: resource.resource,
|
||||||
|
triangles: Number(sourceCollision.triangles ?? 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.resourceFailures = failures;
|
||||||
|
plan.buildSummary = {
|
||||||
|
staticPlacements: staticPlacements.length,
|
||||||
|
dynamicPlacements: plan.dynamicPlacements.filter((placement) => (
|
||||||
|
placement.assetStatus === "available"
|
||||||
|
)).length,
|
||||||
|
unavailablePlacements: [...plan.staticPlacements, ...plan.dynamicPlacements]
|
||||||
|
.filter((placement) => placement.assetStatus === "unsupported").length,
|
||||||
|
uniqueResources: plan.resources.length,
|
||||||
|
availableResources: assets.size,
|
||||||
|
unavailableResources: failures.length,
|
||||||
|
resourceCacheHits: [...assets.values()].filter((asset) => (
|
||||||
|
asset.cacheHit && asset.cacheKind !== "primary-conversion"
|
||||||
|
)).length,
|
||||||
|
};
|
||||||
|
const placementManifestFile = path.join(convertedDirectory, "wdb-placement-manifest.json");
|
||||||
|
await writeFile(placementManifestFile, JSON.stringify(plan, null, 2) + "\n", "utf8");
|
||||||
|
|
||||||
|
const visualTriangles = staticPlacements.reduce((total, placement) => (
|
||||||
|
total + Number(assets.get(placement.normalizedResource).visual.triangles ?? 0)
|
||||||
|
), 0);
|
||||||
|
const visualMeshObjects = staticPlacements.reduce((total, placement) => (
|
||||||
|
total + Number(assets.get(placement.normalizedResource).visual.meshObjects ?? 0)
|
||||||
|
), 0);
|
||||||
|
const conversion = {
|
||||||
|
schemaVersion: 2,
|
||||||
|
dungeonId: slug,
|
||||||
|
status: failures.length || plan.reviewRequiredPlacements.length
|
||||||
|
? "review-required"
|
||||||
|
: "source-packaged",
|
||||||
|
environmentMode: "runewaker-wdb-assembly",
|
||||||
|
source: {
|
||||||
|
wdb: recipe.source.wdb,
|
||||||
|
primaryModel: recipe.source.model,
|
||||||
|
descriptorCount: plan.sourceDescriptorCount,
|
||||||
|
placementManifest: "wdb-placement-manifest.json",
|
||||||
|
},
|
||||||
|
coordinateTransform: plan.coordinateTransform,
|
||||||
|
visual: {
|
||||||
|
file: slug + "-visual.glb",
|
||||||
|
meshObjects: visualMeshObjects,
|
||||||
|
triangles: visualTriangles,
|
||||||
|
sourceTriangles: visualTriangles,
|
||||||
|
bounds: visualAssembly.bounds,
|
||||||
|
assembledWdbPlacements: staticPlacements.length,
|
||||||
|
uniqueResources: new Set(staticPlacements.map((placement) => placement.normalizedResource)).size,
|
||||||
|
},
|
||||||
|
collision,
|
||||||
|
collisionPolicy: {
|
||||||
|
kind: "wdb-static-placement-trimesh",
|
||||||
|
maximumTrianglesPerChunk: 100_000,
|
||||||
|
workingChunkTarget: collisionChunkTriangleTarget,
|
||||||
|
excludesReviewedDynamicPlacements: true,
|
||||||
|
includesUnreviewedDynamicCandidates: true,
|
||||||
|
},
|
||||||
|
dynamicAssets,
|
||||||
|
preview: slug + "-preview.png",
|
||||||
|
editableSource: slug + ".source.blend",
|
||||||
|
wdbAssembly: plan.buildSummary,
|
||||||
|
warnings: [
|
||||||
|
"The preview and editable Blender source cover the primary ROS only; the shipping GLB is assembled from the WDB manifest.",
|
||||||
|
"Name-based dynamic candidates remain static until a reviewed dynamic-placement rule supplies source evidence.",
|
||||||
|
...failures.map((failure) => failure.resource + ": " + failure.reason),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await writeFile(
|
||||||
|
path.join(convertedDirectory, "conversion-report.json"),
|
||||||
|
JSON.stringify(conversion, null, 2) + "\n",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function assembleRepeatedPlacements() {
|
async function assembleRepeatedPlacements() {
|
||||||
const translations = assemblyTranslations();
|
const translations = fullWdbAssembly ? [] : assemblyTranslations();
|
||||||
if (!translations.length) return;
|
if (!translations.length) return;
|
||||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||||
const visualFile = path.join(convertedDirectory, slug + "-visual.glb");
|
const visualFile = path.join(convertedDirectory, slug + "-visual.glb");
|
||||||
@@ -385,7 +726,7 @@ async function bakeNavigation() {
|
|||||||
|
|
||||||
async function appendSourceAnchorNavigation(navigationFile, reportFile) {
|
async function appendSourceAnchorNavigation(navigationFile, reportFile) {
|
||||||
const support = recipe.source.sourceAnchorSupport;
|
const support = recipe.source.sourceAnchorSupport;
|
||||||
if (!support) return;
|
if (!support || fullWdbAssembly) return;
|
||||||
const { placement } = await primaryPlacement();
|
const { placement } = await primaryPlacement();
|
||||||
const half = Number(support.halfExtentSourceUnits ?? 60);
|
const half = Number(support.halfExtentSourceUnits ?? 60);
|
||||||
const meters = recipe.coordinateSystem.metersPerSourceUnit;
|
const meters = recipe.coordinateSystem.metersPerSourceUnit;
|
||||||
@@ -556,13 +897,29 @@ async function stageShippingPackage() {
|
|||||||
const files = [
|
const files = [
|
||||||
{ role: "visual", source: path.join(convertedDirectory, slug + "-visual.optimized.glb"), target: path.join(shippingDirectory, slug + "-visual.glb") },
|
{ role: "visual", source: path.join(convertedDirectory, slug + "-visual.optimized.glb"), target: path.join(shippingDirectory, slug + "-visual.glb") },
|
||||||
...conversion.collision.map((chunk) => ({ role: "collision", source: path.join(convertedDirectory, chunk.file), target: path.join(shippingDirectory, chunk.file) })),
|
...conversion.collision.map((chunk) => ({ role: "collision", source: path.join(convertedDirectory, chunk.file), target: path.join(shippingDirectory, chunk.file) })),
|
||||||
|
...(conversion.dynamicAssets ?? []).map((asset) => ({
|
||||||
|
role: asset.role,
|
||||||
|
source: path.join(convertedDirectory, asset.file),
|
||||||
|
target: path.join(shippingDirectory, asset.file),
|
||||||
|
assetId: asset.assetId,
|
||||||
|
resource: asset.resource,
|
||||||
|
})),
|
||||||
{ role: "navigation", source: path.join(convertedDirectory, slug + "-navigation.glb"), target: path.join(shippingDirectory, slug + "-navigation.glb") },
|
{ role: "navigation", source: path.join(convertedDirectory, slug + "-navigation.glb"), target: path.join(shippingDirectory, slug + "-navigation.glb") },
|
||||||
];
|
];
|
||||||
for (const entry of files) await copyFile(entry.source, entry.target);
|
for (const entry of files) {
|
||||||
|
await mkdir(path.dirname(entry.target), { recursive: true });
|
||||||
|
await copyFile(entry.source, entry.target);
|
||||||
|
}
|
||||||
for (const report of ["conversion-report.json", "optimization-report.json", "recast-report.json", slug + "-preview.png"]) {
|
for (const report of ["conversion-report.json", "optimization-report.json", "recast-report.json", slug + "-preview.png"]) {
|
||||||
await copyFile(path.join(convertedDirectory, report), path.join(shippingDirectory, report));
|
await copyFile(path.join(convertedDirectory, report), path.join(shippingDirectory, report));
|
||||||
}
|
}
|
||||||
await copyFile(path.join(rawDirectory, "wdb-report.json"), path.join(shippingDirectory, "wdb-report.json"));
|
await copyFile(path.join(rawDirectory, "wdb-report.json"), path.join(shippingDirectory, "wdb-report.json"));
|
||||||
|
if (fullWdbAssembly) {
|
||||||
|
await copyFile(
|
||||||
|
path.join(convertedDirectory, "wdb-placement-manifest.json"),
|
||||||
|
path.join(shippingDirectory, "wdb-placement-manifest.json"),
|
||||||
|
);
|
||||||
|
}
|
||||||
const server = await sourceMetadata();
|
const server = await sourceMetadata();
|
||||||
const { report, placement } = await primaryPlacement();
|
const { report, placement } = await primaryPlacement();
|
||||||
const sourceEntrance = [server.playerSpawn.x, server.playerSpawn.y, server.playerSpawn.z];
|
const sourceEntrance = [server.playerSpawn.x, server.playerSpawn.y, server.playerSpawn.z];
|
||||||
@@ -604,12 +961,21 @@ async function stageShippingPackage() {
|
|||||||
} : {}),
|
} : {}),
|
||||||
server,
|
server,
|
||||||
wdbReport: { descriptorCount: report.descriptorCount, file: "wdb-report.json", primaryPlacement: placement },
|
wdbReport: { descriptorCount: report.descriptorCount, file: "wdb-report.json", primaryPlacement: placement },
|
||||||
|
...(fullWdbAssembly ? {
|
||||||
|
wdbAssembly: {
|
||||||
|
mode: "all-ros",
|
||||||
|
placementManifest: "wdb-placement-manifest.json",
|
||||||
|
...conversion.wdbAssembly,
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
},
|
},
|
||||||
coordinateSystem: recipe.coordinateSystem,
|
coordinateSystem: recipe.coordinateSystem,
|
||||||
entrance,
|
entrance,
|
||||||
assets: await Promise.all(files.map(async (entry) => ({
|
assets: await Promise.all(files.map(async (entry) => ({
|
||||||
role: entry.role,
|
role: entry.role,
|
||||||
fileName: path.basename(entry.target),
|
fileName: path.basename(entry.target),
|
||||||
|
relativeFile: path.relative(shippingDirectory, entry.target).split(path.sep).join("/"),
|
||||||
|
...(entry.assetId ? { assetId: entry.assetId, sourceResource: entry.resource } : {}),
|
||||||
size: (await stat(entry.target)).size,
|
size: (await stat(entry.target)).size,
|
||||||
sha256: await sha256(entry.target),
|
sha256: await sha256(entry.target),
|
||||||
}))),
|
}))),
|
||||||
@@ -634,7 +1000,8 @@ await buildNativeExporterIfNeeded();
|
|||||||
await exportSource();
|
await exportSource();
|
||||||
await prepareTextures();
|
await prepareTextures();
|
||||||
await convertEnvironment();
|
await convertEnvironment();
|
||||||
await assembleRepeatedPlacements();
|
if (fullWdbAssembly) await assembleAllWdbPlacements();
|
||||||
|
else await assembleRepeatedPlacements();
|
||||||
await optimizeVisual();
|
await optimizeVisual();
|
||||||
await bakeNavigation();
|
await bakeNavigation();
|
||||||
await stageShippingPackage();
|
await stageShippingPackage();
|
||||||
|
|||||||
@@ -460,6 +460,12 @@ function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
|
|||||||
initialCooldownMs: [spell.runtime.cooldownMs, spell.runtime.cooldownMs],
|
initialCooldownMs: [spell.runtime.cooldownMs, spell.runtime.cooldownMs],
|
||||||
repeatCooldownMs: [spell.runtime.cooldownMs, spell.runtime.cooldownMs],
|
repeatCooldownMs: [spell.runtime.cooldownMs, spell.runtime.cooldownMs],
|
||||||
damageMultiplier: spell.runtime.damageMultiplier,
|
damageMultiplier: spell.runtime.damageMultiplier,
|
||||||
|
...(spell.runtime.formula ? {
|
||||||
|
runewakerFormula: {
|
||||||
|
...spell.runtime.formula,
|
||||||
|
spellLevel: Number(sourceSpell.level ?? 0),
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
}];
|
}];
|
||||||
});
|
});
|
||||||
const recipeAttacks = Array.isArray(template.attacks)
|
const recipeAttacks = Array.isArray(template.attacks)
|
||||||
@@ -471,7 +477,34 @@ function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
|
|||||||
const mechanics = authored?.mode === "replace"
|
const mechanics = authored?.mode === "replace"
|
||||||
? authored.mechanics ?? []
|
? authored.mechanics ?? []
|
||||||
: [...(authored?.mechanics ?? []), ...recipeAttacks, ...sourceAttacks];
|
: [...(authored?.mechanics ?? []), ...recipeAttacks, ...sourceAttacks];
|
||||||
const unique = [...new Map(mechanics.map((attack) => [attack.id, attack])).values()];
|
const sourceSpellLevels = new Map(
|
||||||
|
(sourceSpells ?? []).map((sourceSpell) => [Number(sourceSpell.id), Number(sourceSpell.level ?? 0)]),
|
||||||
|
);
|
||||||
|
for (const spellId of authored?.source?.spellIds ?? []) {
|
||||||
|
if (!sourceSpellLevels.has(Number(spellId))) {
|
||||||
|
// Script-derived spell evidence has no NPC assignment level in the packaged row.
|
||||||
|
// Zero is the catalog's explicit unassigned-level sentinel and is not used by
|
||||||
|
// the HealerMan baseline adapter.
|
||||||
|
sourceSpellLevels.set(Number(spellId), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const withSourceFormula = mechanics.map((attack) => {
|
||||||
|
const spellId = Number(attack.spellId ?? attack.source?.spellId ?? 0);
|
||||||
|
const spell = spellCatalog.get(spellId);
|
||||||
|
if (!sourceSpellLevels.has(spellId)
|
||||||
|
|| spell?.mappingStatus !== "damage-family"
|
||||||
|
|| !spell.runtime?.formula) {
|
||||||
|
return attack;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...attack,
|
||||||
|
runewakerFormula: {
|
||||||
|
...spell.runtime.formula,
|
||||||
|
spellLevel: sourceSpellLevels.get(spellId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const unique = [...new Map(withSourceFormula.map((attack) => [attack.id, attack])).values()];
|
||||||
return [basicAttack, ...unique];
|
return [basicAttack, ...unique];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,7 +739,7 @@ async function generatePopulation() {
|
|||||||
serverEntryPolicy: "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
serverEntryPolicy: "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||||
sourceSpellCatalog: projectPath(spellCatalogFile),
|
sourceSpellCatalog: projectPath(spellCatalogFile),
|
||||||
sourceSpellCatalogSha256: await sha256(spellCatalogFile),
|
sourceSpellCatalogSha256: await sha256(spellCatalogFile),
|
||||||
sourceSpellPolicy: "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
sourceSpellPolicy: "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
|
||||||
};
|
};
|
||||||
const reportFile = resolveProject(recipe.files.report);
|
const reportFile = resolveProject(recipe.files.report);
|
||||||
await mkdir(path.dirname(reportFile), { recursive: true });
|
await mkdir(path.dirname(reportFile), { recursive: true });
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import {
|
||||||
|
FIDELITY_LEVELS,
|
||||||
|
IMPLEMENTATION_STATUSES,
|
||||||
|
buildEncounterImplementationReport,
|
||||||
|
} from "./lib/encounter-implementation-report.mjs";
|
||||||
|
|
||||||
|
test("encounter ledger exhaustively classifies portable RuneWaker evidence", async () => {
|
||||||
|
const report = await buildEncounterImplementationReport();
|
||||||
|
assert.equal(report.validationStatus, "green", report.issues.join("\n"));
|
||||||
|
assert.equal(report.summary.dungeons, 30);
|
||||||
|
assert.equal(report.summary.deferredSourceRows, 4_113);
|
||||||
|
assert.equal(report.summary.uniqueMechanicsEvidence, 1_524);
|
||||||
|
assert.equal(report.summary.luaEvidenceSignals, 329);
|
||||||
|
assert.equal(report.summary.templateEvidenceSignals, 1_195);
|
||||||
|
|
||||||
|
const inventory = report.dungeons.flatMap((dungeon) => [
|
||||||
|
...dungeon.deferredSourceRows,
|
||||||
|
...dungeon.mechanicsEvidence,
|
||||||
|
]);
|
||||||
|
assert.equal(new Set(inventory.map((item) => item.id)).size, inventory.length);
|
||||||
|
for (const item of inventory) {
|
||||||
|
assert.ok(IMPLEMENTATION_STATUSES.includes(item.status), item.id);
|
||||||
|
assert.ok(FIDELITY_LEVELS.includes(item.fidelity), item.id);
|
||||||
|
assert.ok(item.blocker, item.id);
|
||||||
|
assert.ok(item.nextWork, item.id);
|
||||||
|
assert.ok(item.clearingTest, item.id);
|
||||||
|
}
|
||||||
|
for (const row of report.dungeons.flatMap((dungeon) => dungeon.deferredSourceRows)) {
|
||||||
|
assert.ok(row.sourceIds.spawnId > 0, row.id);
|
||||||
|
assert.ok(row.sourceIds.templateId > 0, row.id);
|
||||||
|
assert.ok(row.source.snapshot.path.endsWith(".json"), row.id);
|
||||||
|
assert.match(row.source.snapshot.sha256, /^[a-f0-9]{64}$/, row.id);
|
||||||
|
assert.match(row.source.snapshot.contentSha256, /^[a-f0-9]{64}$/, row.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("regex labels are memberships rather than inflated mechanic records", async () => {
|
||||||
|
const report = await buildEncounterImplementationReport();
|
||||||
|
const evidence = report.dungeons.flatMap((dungeon) => dungeon.mechanicsEvidence);
|
||||||
|
const multiCategory = evidence.find((item) => item.categories.length > 1);
|
||||||
|
assert.ok(multiCategory, "expected at least one source line with overlapping review labels");
|
||||||
|
assert.equal(evidence.filter((item) => item.id === multiCategory.id).length, 1);
|
||||||
|
assert.equal(
|
||||||
|
report.summary.evidenceCategoryMembershipCount,
|
||||||
|
evidence.reduce((total, item) => total + item.categories.length, 0),
|
||||||
|
);
|
||||||
|
assert.notEqual(report.summary.evidenceCategoryMembershipCount, evidence.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("source gaps, fallbacks, zero-boss packages, and empty variants stay explicit", async () => {
|
||||||
|
const report = await buildEncounterImplementationReport();
|
||||||
|
assert.deepEqual(
|
||||||
|
report.specialCases.fallbackBosses.map((boss) => boss.entityId),
|
||||||
|
["rw-fa-100070", "rw-fa-100561", "rw-fa-100562", "rw-fa-100563"],
|
||||||
|
);
|
||||||
|
assert.ok(report.specialCases.fallbackBosses.every((boss) => (
|
||||||
|
boss.status === "implemented" && boss.fidelity === "healer-man-authored"
|
||||||
|
)));
|
||||||
|
assert.ok(report.specialCases.fallbackBosses.every((boss) => (
|
||||||
|
boss.mechanicIds.length > 0
|
||||||
|
&& boss.telegraphedMechanicIds.length === boss.mechanicIds.length
|
||||||
|
)));
|
||||||
|
assert.deepEqual(
|
||||||
|
report.specialCases.nonEmptyZeroBossPackages.map((entry) => entry.dungeonId),
|
||||||
|
[
|
||||||
|
"aeternal-circle",
|
||||||
|
"dungeon-of-dalanis",
|
||||||
|
"grafu-castle",
|
||||||
|
"kal-turok-hive",
|
||||||
|
"lair-demon-dragon",
|
||||||
|
"tomb-seven-heroes",
|
||||||
|
"warnorken-arena",
|
||||||
|
"zurhidon-stronghold",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
report.specialCases.intentionalEmptyPackages.map((entry) => entry.dungeonId),
|
||||||
|
["demon-stronghold-125", "zurhidon-stronghold-124"],
|
||||||
|
);
|
||||||
|
assert.equal(report.summary.deferredRowStatuses["deferred-source-gap"], 4_113);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the report has no timestamps and is byte-deterministic in memory", async () => {
|
||||||
|
const first = await buildEncounterImplementationReport();
|
||||||
|
const second = await buildEncounterImplementationReport();
|
||||||
|
assert.equal(JSON.stringify(first), JSON.stringify(second));
|
||||||
|
assert.equal(Object.hasOwn(first, "generatedAt"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checked-in JSON is the current deterministic report", async () => {
|
||||||
|
const built = await buildEncounterImplementationReport();
|
||||||
|
const checkedIn = JSON.parse(await readFile(
|
||||||
|
new URL("./RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
));
|
||||||
|
assert.deepEqual(checkedIn, built);
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { Document, NodeIO } from "@gltf-transform/core";
|
||||||
|
import { Matrix4, Vector3 } from "three";
|
||||||
|
import {
|
||||||
|
assembleInstancedGlb,
|
||||||
|
buildWdbPlacementPlan,
|
||||||
|
normalizeWdbResourcePath,
|
||||||
|
placementMatrixInPrimaryThree,
|
||||||
|
resourceCacheKey,
|
||||||
|
transformSourcePointToPrimaryThree,
|
||||||
|
} from "./lib/environment-assembly.mjs";
|
||||||
|
|
||||||
|
const identityPlacement = (overrides = {}) => ({
|
||||||
|
guid: "PRIMARY",
|
||||||
|
resource: "model\\dungeon\\fixture\\room.ros",
|
||||||
|
scale: [1, 1, 1],
|
||||||
|
rotation: [0, 0, 0, 1],
|
||||||
|
translation: [10, 20, 30],
|
||||||
|
localBounds: { center: [0, 0, 0], radius: 1 },
|
||||||
|
worldBounds: { center: [10, 20, 30], radius: 1 },
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixtureRecipe = (source = {}) => ({
|
||||||
|
slug: "fixture",
|
||||||
|
coordinateSystem: {
|
||||||
|
source: "left-handed Y-up",
|
||||||
|
nativeBridge: "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)",
|
||||||
|
metersPerSourceUnit: 0.1,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
model: "model/dungeon/fixture/room.ros",
|
||||||
|
primaryPlacementTranslation: [10, 20, 30],
|
||||||
|
...source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes resource paths and makes case-insensitive stable cache keys", () => {
|
||||||
|
assert.equal(
|
||||||
|
normalizeWdbResourcePath("/Model/Dungeon/Fixture/Room.ROS"),
|
||||||
|
"model\\dungeon\\fixture\\room.ros",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resourceCacheKey("Model/Dungeon/Fixture/Room.ROS"),
|
||||||
|
resourceCacheKey("model\\dungeon\\fixture\\room.ros"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("converts full placement translation, quaternion, and nonuniform scale", () => {
|
||||||
|
const primary = identityPlacement();
|
||||||
|
const placed = identityPlacement({
|
||||||
|
guid: "PLACED",
|
||||||
|
resource: "model\\dungeon\\fixture\\bridge.ros",
|
||||||
|
translation: [20, 22, 40],
|
||||||
|
rotation: [0, Math.SQRT1_2, 0, Math.SQRT1_2],
|
||||||
|
scale: [2, 3, 4],
|
||||||
|
});
|
||||||
|
const matrix = placementMatrixInPrimaryThree(placed, primary, 0.1);
|
||||||
|
const sourcePoint = [3, 4, 5];
|
||||||
|
const viaMatrix = new Vector3(sourcePoint[0] * 0.1, sourcePoint[1] * 0.1, -sourcePoint[2] * 0.1)
|
||||||
|
.applyMatrix4(new Matrix4().fromArray(matrix.toArray()))
|
||||||
|
.toArray();
|
||||||
|
const direct = transformSourcePointToPrimaryThree(sourcePoint, placed, primary, 0.1);
|
||||||
|
assert.deepEqual(viaMatrix.map((value) => Number(value.toFixed(9))), direct.map((value) => Number(value.toFixed(9))));
|
||||||
|
assert.notDeepEqual(matrix.toArray(), new Matrix4().identity().toArray());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds deterministic static, dynamic, review, and unsupported placement groups", () => {
|
||||||
|
const primary = identityPlacement();
|
||||||
|
const door = identityPlacement({
|
||||||
|
guid: "DOOR",
|
||||||
|
resource: "model\\dungeon\\fixture\\boss_door.ros",
|
||||||
|
translation: [15, 20, 30],
|
||||||
|
});
|
||||||
|
const hintedBridge = identityPlacement({
|
||||||
|
guid: "BRIDGE",
|
||||||
|
resource: "model\\dungeon\\fixture\\drawbridge.ros",
|
||||||
|
translation: [20, 20, 30],
|
||||||
|
});
|
||||||
|
const unsupported = identityPlacement({
|
||||||
|
guid: "FX",
|
||||||
|
resource: "effect\\fixture\\mist.eft",
|
||||||
|
});
|
||||||
|
const recipe = fixtureRecipe({
|
||||||
|
wdbAssembly: {
|
||||||
|
mode: "all-ros",
|
||||||
|
dynamicPlacements: [{
|
||||||
|
id: "boss-door",
|
||||||
|
guid: "DOOR",
|
||||||
|
kind: "door",
|
||||||
|
initialState: "closed",
|
||||||
|
collision: "stateful",
|
||||||
|
evidence: ["fixture.lua:10"],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const first = buildWdbPlacementPlan({
|
||||||
|
sourceWdb: "fixture.wdb",
|
||||||
|
descriptors: [unsupported, hintedBridge, door, primary],
|
||||||
|
}, recipe);
|
||||||
|
const second = buildWdbPlacementPlan({
|
||||||
|
sourceWdb: "fixture.wdb",
|
||||||
|
descriptors: [unsupported, hintedBridge, door, primary],
|
||||||
|
}, recipe);
|
||||||
|
assert.deepEqual(first, second);
|
||||||
|
assert.equal(first.staticPlacements.length, 2);
|
||||||
|
assert.equal(first.dynamicPlacements.length, 1);
|
||||||
|
assert.equal(first.dynamicPlacements[0].dynamic.kind, "door");
|
||||||
|
assert.equal(first.reviewRequiredPlacements.length, 1);
|
||||||
|
assert.equal(first.reviewRequiredPlacements[0].guid, "BRIDGE");
|
||||||
|
assert.equal(first.unsupportedPlacements.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("assembles repeated cached GLBs as transformed resource instances", async () => {
|
||||||
|
const temporary = await mkdtemp(path.join(os.tmpdir(), "healer-man-wdb-assembly-"));
|
||||||
|
try {
|
||||||
|
const sourceFile = path.join(temporary, "source.glb");
|
||||||
|
const outputFile = path.join(temporary, "assembled.glb");
|
||||||
|
const source = new Document();
|
||||||
|
const buffer = source.createBuffer("fixture-buffer");
|
||||||
|
const position = source.createAccessor("fixture-position", buffer)
|
||||||
|
.setType("VEC3")
|
||||||
|
.setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]));
|
||||||
|
const primitive = source.createPrimitive().setAttribute("POSITION", position);
|
||||||
|
const mesh = source.createMesh("fixture-mesh").addPrimitive(primitive);
|
||||||
|
source.createScene("fixture-scene").addChild(source.createNode("fixture-node").setMesh(mesh));
|
||||||
|
const io = new NodeIO();
|
||||||
|
await io.write(sourceFile, source);
|
||||||
|
const makePlacement = (id, x) => ({
|
||||||
|
id,
|
||||||
|
guid: id.toUpperCase(),
|
||||||
|
resource: "model/dungeon/fixture/room.ros",
|
||||||
|
resourceCacheKey: resourceCacheKey("model/dungeon/fixture/room.ros"),
|
||||||
|
transform: {
|
||||||
|
primaryLocalThreeMatrix: new Matrix4().makeTranslation(x, 0, 0).toArray(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await assembleInstancedGlb([
|
||||||
|
{ assetFile: sourceFile, placement: makePlacement("first", 0) },
|
||||||
|
{ assetFile: sourceFile, placement: makePlacement("second", 5) },
|
||||||
|
], outputFile);
|
||||||
|
const assembled = await io.read(outputFile);
|
||||||
|
const scene = assembled.getRoot().getDefaultScene() ?? assembled.getRoot().listScenes()[0];
|
||||||
|
assert.deepEqual(scene.listChildren().map((node) => node.getName()), ["first", "second"]);
|
||||||
|
assert.deepEqual(result.bounds, { min: [0, 0, 0], max: [6, 1, 0] });
|
||||||
|
assert.equal(assembled.getRoot().listMeshes().length, 1, "repeated placements share cached mesh data");
|
||||||
|
} finally {
|
||||||
|
await rm(temporary, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects dynamic rules that would remove the primary coordinate anchor", () => {
|
||||||
|
const primary = identityPlacement();
|
||||||
|
assert.throws(() => buildWdbPlacementPlan({ descriptors: [primary] }, fixtureRecipe({
|
||||||
|
wdbAssembly: {
|
||||||
|
dynamicPlacements: [{
|
||||||
|
id: "invalid-primary-door",
|
||||||
|
guid: "PRIMARY",
|
||||||
|
kind: "door",
|
||||||
|
evidence: ["fixture.lua:1"],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})), /primary coordinate-anchor placement cannot be dynamic/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("current modular WDB reports account for every descriptor without guessed dynamics", async () => {
|
||||||
|
for (const slug of ["raksha-temple", "the-origin", "treasure-trove"]) {
|
||||||
|
const [recipe, report] = await Promise.all([
|
||||||
|
readFile(new URL("./recipes/" + slug + ".json", import.meta.url), "utf8").then(JSON.parse),
|
||||||
|
readFile(new URL("../../src/assets/game/dungeons/" + slug + "/wdb-report.json", import.meta.url), "utf8").then(JSON.parse),
|
||||||
|
]);
|
||||||
|
const plan = buildWdbPlacementPlan(report, recipe);
|
||||||
|
assert.equal(
|
||||||
|
plan.staticPlacements.length + plan.dynamicPlacements.length + plan.unsupportedPlacements.length,
|
||||||
|
report.descriptorCount,
|
||||||
|
slug,
|
||||||
|
);
|
||||||
|
assert.equal(plan.dynamicPlacements.length, 0, slug + " must not infer stateful behavior from names");
|
||||||
|
assert.ok(plan.resources.length > 1, slug + " should expose arbitrary resource assembly");
|
||||||
|
assert.equal(plan.primaryPlacement.resource.toLowerCase(), recipe.source.model.toLowerCase());
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
pipelineDirectory,
|
||||||
|
projectPath,
|
||||||
|
projectRoot,
|
||||||
|
readJson,
|
||||||
|
sourceRootFor,
|
||||||
|
} from "./lib/recipe.mjs";
|
||||||
|
|
||||||
|
const DATA_START = 140;
|
||||||
|
const COUNT_OFFSET = 132;
|
||||||
|
const NPC_RECORD_SIZE = 2224;
|
||||||
|
const NPC_OBJECT_TYPE = 1;
|
||||||
|
const NPC_DROP_START = 0xf4;
|
||||||
|
const NPC_DROP_COUNT = 15;
|
||||||
|
const TREASURE_RECORD_SIZE = 1516;
|
||||||
|
const TREASURE_OBJECT_TYPE = 20;
|
||||||
|
const TREASURE_NEED_LEVEL_DELTA = 0xac;
|
||||||
|
const TREASURE_ITEM_COUNT = 0xb0;
|
||||||
|
const TREASURE_ITEMS_START = 0xb4;
|
||||||
|
const TREASURE_MAX_ITEMS = 100;
|
||||||
|
const TREASURE_LUA_START = 0x5ac;
|
||||||
|
const TREASURE_LUA_LENGTH = 64;
|
||||||
|
const RATE_DENOMINATOR = 100_000;
|
||||||
|
const MAX_TREASURE_DEPTH = 12;
|
||||||
|
|
||||||
|
const recipesDirectory = path.join(pipelineDirectory, "recipes");
|
||||||
|
const recipeNames = (await readdir(recipesDirectory))
|
||||||
|
.filter((name) => name.endsWith("-population.json"))
|
||||||
|
.sort();
|
||||||
|
const recipes = await Promise.all(recipeNames.map(async (name) => (
|
||||||
|
readJson(path.join(recipesDirectory, name))
|
||||||
|
)));
|
||||||
|
if (!recipes.length) throw new Error("No RuneWaker population recipes were found.");
|
||||||
|
const sourceRoot = sourceRootFor(recipes[0]);
|
||||||
|
const dataRoot = path.join(sourceRoot, recipes[0].source.resourceRoot ?? "Resource", "data");
|
||||||
|
|
||||||
|
const databaseNames = [
|
||||||
|
"npcobject.db",
|
||||||
|
"treasureobject.db",
|
||||||
|
"itemobject.db",
|
||||||
|
"armorobject.db",
|
||||||
|
"weaponobject.db",
|
||||||
|
"cardobject.db",
|
||||||
|
"runeobject.db",
|
||||||
|
"recipeobject.db",
|
||||||
|
"keyitemobject.db",
|
||||||
|
"string_enus.db",
|
||||||
|
];
|
||||||
|
const buffers = new Map(await Promise.all(databaseNames.map(async (name) => (
|
||||||
|
[name, await readFile(path.join(dataRoot, name))]
|
||||||
|
))));
|
||||||
|
|
||||||
|
function sha256(buffer) {
|
||||||
|
return createHash("sha256").update(buffer).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function int32(buffer, offset) {
|
||||||
|
return buffer.readInt32LE(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFixedCatalog(buffer, recordSize, objectType, label) {
|
||||||
|
const count = int32(buffer, COUNT_OFFSET);
|
||||||
|
if (count <= 0 || DATA_START + count * recordSize > buffer.length) {
|
||||||
|
throw new Error(label + " has an incompatible record count or size.");
|
||||||
|
}
|
||||||
|
for (const index of [0, Math.floor(count / 2), count - 1]) {
|
||||||
|
if (int32(buffer, DATA_START + index * recordSize + 4) !== objectType) {
|
||||||
|
throw new Error(label + " object-type sentinel drifted.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectRecordSize(buffer, label) {
|
||||||
|
const count = int32(buffer, COUNT_OFFSET);
|
||||||
|
const objectType = int32(buffer, DATA_START + 4);
|
||||||
|
for (let recordSize = 100; recordSize <= 20_000; recordSize += 4) {
|
||||||
|
if (DATA_START + recordSize * count > buffer.length) continue;
|
||||||
|
const sample = [1, 2, 3, Math.floor(count / 2), count - 1];
|
||||||
|
if (sample.every((index) => (
|
||||||
|
int32(buffer, DATA_START + index * recordSize + 4) === objectType
|
||||||
|
))) return { count, objectType, recordSize };
|
||||||
|
}
|
||||||
|
throw new Error(label + " fixed-record layout could not be detected safely.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexCatalog(buffer, count, recordSize) {
|
||||||
|
const offsets = new Map();
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const offset = DATA_START + index * recordSize;
|
||||||
|
const id = int32(buffer, offset);
|
||||||
|
if (id > 0) offsets.set(id, offset);
|
||||||
|
}
|
||||||
|
return offsets;
|
||||||
|
}
|
||||||
|
|
||||||
|
const npc = buffers.get("npcobject.db");
|
||||||
|
const treasure = buffers.get("treasureobject.db");
|
||||||
|
const strings = buffers.get("string_enus.db");
|
||||||
|
const npcCount = validateFixedCatalog(npc, NPC_RECORD_SIZE, NPC_OBJECT_TYPE, "NPCObjectDB");
|
||||||
|
const treasureCount = validateFixedCatalog(
|
||||||
|
treasure, TREASURE_RECORD_SIZE, TREASURE_OBJECT_TYPE, "TreasureObjectDB",
|
||||||
|
);
|
||||||
|
const npcOffsets = indexCatalog(npc, npcCount, NPC_RECORD_SIZE);
|
||||||
|
const treasureOffsets = indexCatalog(treasure, treasureCount, TREASURE_RECORD_SIZE);
|
||||||
|
|
||||||
|
const leafObjects = new Map();
|
||||||
|
const leafCatalogs = [];
|
||||||
|
for (const name of databaseNames.filter((candidate) => ![
|
||||||
|
"npcobject.db", "treasureobject.db", "string_enus.db",
|
||||||
|
].includes(candidate))) {
|
||||||
|
const buffer = buffers.get(name);
|
||||||
|
const layout = detectRecordSize(buffer, name);
|
||||||
|
const offsets = indexCatalog(buffer, layout.count, layout.recordSize);
|
||||||
|
leafCatalogs.push({ name, ...layout, count: offsets.size });
|
||||||
|
for (const id of offsets.keys()) {
|
||||||
|
if (!leafObjects.has(id)) leafObjects.set(id, { catalog: name, objectType: layout.objectType });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringCache = new Map();
|
||||||
|
function stringValue(key) {
|
||||||
|
if (stringCache.has(key)) return stringCache.get(key);
|
||||||
|
const marker = Buffer.concat([Buffer.from(key, "ascii"), Buffer.from([0])]);
|
||||||
|
const keyPosition = strings.indexOf(marker);
|
||||||
|
if (keyPosition < 0) {
|
||||||
|
stringCache.set(key, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const start = keyPosition + marker.length;
|
||||||
|
const end = Math.min(strings.length - 4, start + 512);
|
||||||
|
for (let offset = start; offset < end; offset += 1) {
|
||||||
|
const length = strings.readInt32LE(offset);
|
||||||
|
if (length < 1 || length > 4001 || offset + 4 + length > strings.length) continue;
|
||||||
|
const value = strings.subarray(offset + 4, offset + 3 + length);
|
||||||
|
if (strings[offset + 3 + length] !== 0 || value.includes(0)) continue;
|
||||||
|
try {
|
||||||
|
const result = new TextDecoder("utf-8", { fatal: true }).decode(value).trim() || null;
|
||||||
|
stringCache.set(key, result);
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
// Metadata can resemble a string length; continue to the next byte.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stringCache.set(key, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixedString(buffer, offset, length) {
|
||||||
|
const end = buffer.indexOf(0, offset);
|
||||||
|
const boundedEnd = end < 0 || end > offset + length ? offset + length : end;
|
||||||
|
return buffer.subarray(offset, boundedEnd).toString("utf8").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanProbability(value) {
|
||||||
|
return Number(Math.max(0, Math.min(1, value)).toFixed(12));
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockedOutcome(objectId, chance, quantity, sourcePath, reason, constraints = {}) {
|
||||||
|
return {
|
||||||
|
objectId,
|
||||||
|
name: stringValue("Sys" + objectId + "_name") ?? "RuneWaker Object " + objectId,
|
||||||
|
sourceCatalog: null,
|
||||||
|
runtimeStatus: "blocked",
|
||||||
|
blockedReason: reason,
|
||||||
|
conditionalChance: cleanProbability(chance),
|
||||||
|
quantity,
|
||||||
|
sourcePath,
|
||||||
|
...constraints,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandObject(objectId, chance, quantity, sourcePath, stack = [], constraints = {}) {
|
||||||
|
if (chance <= 0 || quantity <= 0) return [];
|
||||||
|
const leaf = leafObjects.get(objectId);
|
||||||
|
if (leaf) {
|
||||||
|
return [{
|
||||||
|
objectId,
|
||||||
|
name: stringValue("Sys" + objectId + "_name") ?? "RuneWaker Item " + objectId,
|
||||||
|
sourceCatalog: leaf.catalog,
|
||||||
|
sourceObjectType: leaf.objectType,
|
||||||
|
runtimeStatus: "executable-generic-item",
|
||||||
|
blockedReason: null,
|
||||||
|
conditionalChance: cleanProbability(chance),
|
||||||
|
quantity,
|
||||||
|
sourcePath,
|
||||||
|
...constraints,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
const offset = treasureOffsets.get(objectId);
|
||||||
|
if (offset === undefined) {
|
||||||
|
return [blockedOutcome(
|
||||||
|
objectId, chance, quantity, sourcePath,
|
||||||
|
"Object is absent from the bounded item and treasure source catalogs.", constraints,
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
if (stack.includes(objectId) || stack.length >= MAX_TREASURE_DEPTH) {
|
||||||
|
return [blockedOutcome(
|
||||||
|
objectId, chance, quantity, sourcePath,
|
||||||
|
"Treasure recursion is cyclic or exceeds the audited depth limit.", constraints,
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
const luaCheck = fixedString(treasure, offset + TREASURE_LUA_START, TREASURE_LUA_LENGTH);
|
||||||
|
if (luaCheck) {
|
||||||
|
return [blockedOutcome(
|
||||||
|
objectId, chance, quantity, sourcePath,
|
||||||
|
"Treasure table requires unavailable Lua predicate " + luaCheck + ".",
|
||||||
|
{ ...constraints, luaCheck },
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
const needLevelDelta = int32(treasure, offset + TREASURE_NEED_LEVEL_DELTA);
|
||||||
|
const nextConstraints = {
|
||||||
|
...constraints,
|
||||||
|
minimumLevelDelta: Math.max(constraints.minimumLevelDelta ?? -2_147_483_648, needLevelDelta),
|
||||||
|
};
|
||||||
|
const itemCount = int32(treasure, offset + TREASURE_ITEM_COUNT);
|
||||||
|
if (itemCount < 0 || itemCount > TREASURE_MAX_ITEMS) {
|
||||||
|
return [blockedOutcome(
|
||||||
|
objectId, chance, quantity, sourcePath,
|
||||||
|
"Treasure item count is outside the preserved 0..100 layout.", nextConstraints,
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
const outcomes = [];
|
||||||
|
for (let index = 0; index < itemCount; index += 1) {
|
||||||
|
const itemOffset = offset + TREASURE_ITEMS_START + index * 12;
|
||||||
|
const childId = int32(treasure, itemOffset);
|
||||||
|
const childWeight = int32(treasure, itemOffset + 4);
|
||||||
|
const childQuantity = int32(treasure, itemOffset + 8);
|
||||||
|
if (childId <= 0 || childWeight <= 0 || childQuantity <= 0) continue;
|
||||||
|
outcomes.push(...expandObject(
|
||||||
|
childId,
|
||||||
|
chance * Math.min(RATE_DENOMINATOR, childWeight) / RATE_DENOMINATOR,
|
||||||
|
quantity * childQuantity,
|
||||||
|
[...sourcePath, objectId],
|
||||||
|
[...stack, objectId],
|
||||||
|
nextConstraints,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (outcomes.length) return outcomes;
|
||||||
|
return [blockedOutcome(
|
||||||
|
objectId, chance, quantity, sourcePath,
|
||||||
|
"Treasure table has no positive, resolvable outcomes.", nextConstraints,
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const bosses = [];
|
||||||
|
for (const recipe of recipes) {
|
||||||
|
const runtimePrefix = recipe.runtimeIdPrefix ?? "rw-" + recipe.dungeonId;
|
||||||
|
for (const template of recipe.templates.filter((candidate) => candidate.classification === "boss")) {
|
||||||
|
const bossKey = runtimePrefix + "-" + template.id;
|
||||||
|
const npcOffset = npcOffsets.get(template.id);
|
||||||
|
if (npcOffset === undefined) {
|
||||||
|
bosses.push({
|
||||||
|
bossKey,
|
||||||
|
dungeonId: recipe.dungeonId,
|
||||||
|
sourceNpcId: template.id,
|
||||||
|
bossName: template.name,
|
||||||
|
sourceLevel: Number(template.level ?? 1),
|
||||||
|
status: "blocked",
|
||||||
|
blockedReasons: ["NPCObjectDB record is missing."],
|
||||||
|
rolls: [],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rolls = [];
|
||||||
|
for (let slot = 0; slot < NPC_DROP_COUNT; slot += 1) {
|
||||||
|
const dropOffset = npcOffset + NPC_DROP_START + slot * 8;
|
||||||
|
const sourceObjectId = int32(npc, dropOffset);
|
||||||
|
const sourceRate = int32(npc, dropOffset + 4);
|
||||||
|
if (sourceObjectId <= 0 || sourceRate <= 0) continue;
|
||||||
|
const rootChance = cleanProbability(Math.min(RATE_DENOMINATOR, sourceRate) / RATE_DENOMINATOR);
|
||||||
|
const outcomes = expandObject(sourceObjectId, 1, 1, []);
|
||||||
|
rolls.push({
|
||||||
|
sourceSlot: slot + 1,
|
||||||
|
sourceObjectId,
|
||||||
|
sourceRatePer100000: sourceRate,
|
||||||
|
chancePerKill: rootChance,
|
||||||
|
outcomes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const executable = rolls.flatMap((roll) => roll.outcomes)
|
||||||
|
.filter((outcome) => outcome.runtimeStatus === "executable-generic-item");
|
||||||
|
const blocked = rolls.flatMap((roll) => roll.outcomes)
|
||||||
|
.filter((outcome) => outcome.runtimeStatus === "blocked");
|
||||||
|
bosses.push({
|
||||||
|
bossKey,
|
||||||
|
dungeonId: recipe.dungeonId,
|
||||||
|
sourceNpcId: template.id,
|
||||||
|
bossName: template.name,
|
||||||
|
sourceLevel: Number(template.level ?? 1),
|
||||||
|
status: !rolls.length ? "no-source-drops" : blocked.length ? "partial" : "portable",
|
||||||
|
blockedReasons: [...new Set(blocked.map((outcome) => outcome.blockedReason))],
|
||||||
|
rolls,
|
||||||
|
executableOutcomeCount: executable.length,
|
||||||
|
blockedOutcomeCount: blocked.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bosses.sort((left, right) => left.dungeonId.localeCompare(right.dungeonId)
|
||||||
|
|| left.sourceNpcId - right.sourceNpcId);
|
||||||
|
const sourceFiles = databaseNames.map((name) => ({
|
||||||
|
file: projectPath(path.join(dataRoot, name)),
|
||||||
|
sha256: sha256(buffers.get(name)),
|
||||||
|
}));
|
||||||
|
const catalog = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
status: "portable-with-explicit-fallbacks",
|
||||||
|
generatedFrom: {
|
||||||
|
sourceFiles,
|
||||||
|
npcDropAlgorithm: {
|
||||||
|
file: "SourceCodeVS2019/Server/NetGameServer/MainProc/Global.cpp",
|
||||||
|
firstRelevantLine: 4707,
|
||||||
|
rateDenominator: RATE_DENOMINATOR,
|
||||||
|
},
|
||||||
|
treasureAlgorithm: {
|
||||||
|
file: "SourceCodeVS2019/Libraries/Union/RoleData/RD_ItemProc.cxx",
|
||||||
|
firstRelevantLine: 3033,
|
||||||
|
rateDenominator: RATE_DENOMINATOR,
|
||||||
|
},
|
||||||
|
layout: "RuneWaker v0.20 fixed records; read-only export",
|
||||||
|
},
|
||||||
|
runtimePolicy: {
|
||||||
|
rollsAreIndependent: true,
|
||||||
|
treasureOutcomesAreMutuallyExclusive: true,
|
||||||
|
unknownObjectsExecute: false,
|
||||||
|
luaPredicatesExecute: false,
|
||||||
|
sourceItemStatsInvented: false,
|
||||||
|
importedItemsUseGenericNonEquipableInventoryRepresentation: true,
|
||||||
|
},
|
||||||
|
leafCatalogs,
|
||||||
|
bossCount: bosses.length,
|
||||||
|
bossWithSourceRollsCount: bosses.filter((boss) => boss.rolls.length).length,
|
||||||
|
portableBossCount: bosses.filter((boss) => boss.status === "portable").length,
|
||||||
|
partialBossCount: bosses.filter((boss) => boss.status === "partial").length,
|
||||||
|
noSourceDropsBossCount: bosses.filter((boss) => boss.status === "no-source-drops").length,
|
||||||
|
blockedBossCount: bosses.filter((boss) => boss.status === "blocked").length,
|
||||||
|
executableOutcomeCount: bosses.reduce((total, boss) => total + (boss.executableOutcomeCount ?? 0), 0),
|
||||||
|
blockedOutcomeCount: bosses.reduce((total, boss) => total + (boss.blockedOutcomeCount ?? 0), 0),
|
||||||
|
bosses,
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtimeFile = path.join(projectRoot, "src", "game", "generated", "runewakerLootCatalog.json");
|
||||||
|
const publicFile = path.join(projectRoot, "public", "data", "runewaker-loot-catalog.json");
|
||||||
|
const reportFile = path.join(pipelineDirectory, "RUNEWAKER_LOOT_IMPORT_REPORT.json");
|
||||||
|
const reportMarkdownFile = path.join(pipelineDirectory, "RUNEWAKER_LOOT_IMPORT_REPORT.md");
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(path.dirname(runtimeFile), { recursive: true }),
|
||||||
|
mkdir(path.dirname(publicFile), { recursive: true }),
|
||||||
|
]);
|
||||||
|
const serialized = JSON.stringify(catalog, null, 2) + "\n";
|
||||||
|
const reportSummary = {
|
||||||
|
...Object.fromEntries(Object.entries(catalog).filter(([key]) => key !== "bosses")),
|
||||||
|
bosses: bosses.map((boss) => ({
|
||||||
|
bossKey: boss.bossKey,
|
||||||
|
dungeonId: boss.dungeonId,
|
||||||
|
sourceNpcId: boss.sourceNpcId,
|
||||||
|
bossName: boss.bossName,
|
||||||
|
status: boss.status,
|
||||||
|
sourceRollCount: boss.rolls.length,
|
||||||
|
executableOutcomeCount: boss.executableOutcomeCount ?? 0,
|
||||||
|
blockedOutcomeCount: boss.blockedOutcomeCount ?? 0,
|
||||||
|
blockedReasons: boss.blockedReasons,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const markdown = [
|
||||||
|
"# RuneWaker loot import report",
|
||||||
|
"",
|
||||||
|
"Generated from the preserved fixed-record databases. Source rates use the original 1-in-100,000 denominator.",
|
||||||
|
"",
|
||||||
|
`- Boss templates audited: ${catalog.bossCount}`,
|
||||||
|
`- Bosses with source rolls: ${catalog.bossWithSourceRollsCount}`,
|
||||||
|
`- Fully portable bosses: ${catalog.portableBossCount}`,
|
||||||
|
`- Partially portable bosses: ${catalog.partialBossCount}`,
|
||||||
|
`- Bosses with no authored drop slots: ${catalog.noSourceDropsBossCount}`,
|
||||||
|
`- Executable item outcomes: ${catalog.executableOutcomeCount}`,
|
||||||
|
`- Blocked outcomes: ${catalog.blockedOutcomeCount}`,
|
||||||
|
"",
|
||||||
|
"Unknown object relationships and Lua-gated treasure paths fail closed. Imported source items are collected as named, non-equipable generic items because the RuneWaker item-stat and equipment-slot conversion is not yet proven.",
|
||||||
|
"",
|
||||||
|
"| Dungeon | Boss | Status | Rolls | Executable | Blocked | Blocker |",
|
||||||
|
"| --- | --- | --- | ---: | ---: | ---: | --- |",
|
||||||
|
...reportSummary.bosses.map((boss) => (
|
||||||
|
`| ${boss.dungeonId} | ${boss.bossName.replaceAll("|", "\\|")} | ${boss.status} | ${boss.sourceRollCount} | ${boss.executableOutcomeCount} | ${boss.blockedOutcomeCount} | ${(boss.blockedReasons.join("; ") || "—").replaceAll("|", "\\|")} |`
|
||||||
|
)),
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(runtimeFile, serialized, "utf8"),
|
||||||
|
writeFile(publicFile, serialized, "utf8"),
|
||||||
|
writeFile(reportFile, JSON.stringify(reportSummary, null, 2) + "\n", "utf8"),
|
||||||
|
writeFile(reportMarkdownFile, markdown, "utf8"),
|
||||||
|
]);
|
||||||
|
console.log("[runewaker-loot] exported " + catalog.bossCount + " bosses, "
|
||||||
|
+ catalog.executableOutcomeCount + " executable outcomes, and "
|
||||||
|
+ catalog.blockedOutcomeCount + " blocked outcomes.");
|
||||||
|
console.log("[runewaker-loot] " + projectPath(runtimeFile));
|
||||||
@@ -147,6 +147,35 @@ function schoolFor(component) {
|
|||||||
?? "arcane";
|
?? "arcane";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formulaFor(spellId, component) {
|
||||||
|
const magnitude = Math.abs(component.damagePower);
|
||||||
|
const powerKind = component.damagePower < 0
|
||||||
|
? magnitude <= 4 ? "source-ratio" : "source-points"
|
||||||
|
: component.dotBase < 0
|
||||||
|
? "dot-only"
|
||||||
|
: "unresolved";
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
spellId,
|
||||||
|
componentId: component.id,
|
||||||
|
spellLevel: 0,
|
||||||
|
calculationPath: component.effectType === 1 ? "physical" : "magic",
|
||||||
|
powerKind,
|
||||||
|
damagePower: component.damagePower,
|
||||||
|
fixedValue: component.fixedValue,
|
||||||
|
dotBase: component.dotBase,
|
||||||
|
runtimeBaselineMultiplier: powerKind === "source-ratio"
|
||||||
|
? Math.max(0.2, Math.min(4, magnitude))
|
||||||
|
: powerKind === "unresolved" ? 0 : 0.85,
|
||||||
|
provenance: {
|
||||||
|
sourceFile: "SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp",
|
||||||
|
physicalFunctionLine: 3366,
|
||||||
|
magicFunctionLine: 3651,
|
||||||
|
magicObjectDatabase: "Resource/data/magicobject.db",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const assignmentsBySpell = new Map();
|
const assignmentsBySpell = new Map();
|
||||||
for (const recipe of recipes) {
|
for (const recipe of recipes) {
|
||||||
const snapshot = await readJson(path.resolve(projectRoot, recipe.files.snapshot));
|
const snapshot = await readJson(path.resolve(projectRoot, recipe.files.snapshot));
|
||||||
@@ -270,7 +299,8 @@ for (const [spellId, assignmentMap] of [...assignmentsBySpell].sort((left, right
|
|||||||
cooldownMs: Math.max(2500, Math.min(20000, sourceCooldownSeconds > 0
|
cooldownMs: Math.max(2500, Math.min(20000, sourceCooldownSeconds > 0
|
||||||
? sourceCooldownSeconds * 1000
|
? sourceCooldownSeconds * 1000
|
||||||
: 6500)),
|
: 6500)),
|
||||||
damageMultiplier: 0.85,
|
damageMultiplier: formulaFor(spellId, primaryDamage).runtimeBaselineMultiplier,
|
||||||
|
formula: formulaFor(spellId, primaryDamage),
|
||||||
},
|
},
|
||||||
} : {}),
|
} : {}),
|
||||||
components,
|
components,
|
||||||
@@ -298,6 +328,8 @@ const report = {
|
|||||||
runtimeSqlDependency: false,
|
runtimeSqlDependency: false,
|
||||||
unknownEffectsExecute: false,
|
unknownEffectsExecute: false,
|
||||||
coefficientsAreSourceExact: false,
|
coefficientsAreSourceExact: false,
|
||||||
|
unavailableNativeInputsDefaulted: false,
|
||||||
|
pointValuedDamageUsesLabeledScaledAdapter: true,
|
||||||
},
|
},
|
||||||
spells,
|
spells,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,702 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||||
|
const recipeDirectory = path.join(projectRoot, "scripts/runewaker-pipeline/recipes");
|
||||||
|
const reportJsonFile = path.join(
|
||||||
|
projectRoot,
|
||||||
|
"scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json",
|
||||||
|
);
|
||||||
|
const reportMarkdownFile = path.join(
|
||||||
|
projectRoot,
|
||||||
|
"scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md",
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IMPLEMENTATION_STATUSES = Object.freeze([
|
||||||
|
"implemented",
|
||||||
|
"verified",
|
||||||
|
"deferred-source-gap",
|
||||||
|
"deferred-asset-gap",
|
||||||
|
"deferred-runtime-gap",
|
||||||
|
"excluded-non-gameplay",
|
||||||
|
"intentional-empty",
|
||||||
|
]);
|
||||||
|
export const FIDELITY_LEVELS = Object.freeze([
|
||||||
|
"source-exact",
|
||||||
|
"source-backed-adapted",
|
||||||
|
"healer-man-authored",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const EXPECTED_FALLBACK_BOSSES = Object.freeze([
|
||||||
|
"rw-fa-100070",
|
||||||
|
"rw-fa-100561",
|
||||||
|
"rw-fa-100562",
|
||||||
|
"rw-fa-100563",
|
||||||
|
]);
|
||||||
|
const EXPECTED_NON_EMPTY_ZERO_BOSS_PACKAGES = Object.freeze([
|
||||||
|
"aeternal-circle",
|
||||||
|
"dungeon-of-dalanis",
|
||||||
|
"grafu-castle",
|
||||||
|
"kal-turok-hive",
|
||||||
|
"lair-demon-dragon",
|
||||||
|
"tomb-seven-heroes",
|
||||||
|
"warnorken-arena",
|
||||||
|
"zurhidon-stronghold",
|
||||||
|
]);
|
||||||
|
const EXPECTED_INTENTIONAL_EMPTY_PACKAGES = Object.freeze([
|
||||||
|
"demon-stronghold-125",
|
||||||
|
"zurhidon-stronghold-124",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SIGNAL_PATTERNS = Object.freeze([
|
||||||
|
["summon", /summon|spawn.*npc|create.*npc|create.*monster|add.*npc/i],
|
||||||
|
["door", /door|gate|seal|barrier|open.*object|close.*object/i],
|
||||||
|
["timer", /timer|delay|sleep|wait|schedule/i],
|
||||||
|
["movement", /moveto|move_to|patrol|waypoint|autoplot|path/i],
|
||||||
|
["spell", /cast|magic|spell|skill/i],
|
||||||
|
["death", /ondead|on_dead|dead|death|onkill|on_kill/i],
|
||||||
|
["phase", /phase|stage|state|wave/i],
|
||||||
|
["interaction", /click|useobject|talk|speak|interact|trigger/i],
|
||||||
|
["loot", /loot|treasure|drop|reward|chest/i],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const readJson = async (file) => JSON.parse((await readFile(file, "utf8")).replace(/^\uFEFF/, ""));
|
||||||
|
const projectPath = (file) => path.relative(projectRoot, file).split(path.sep).join("/");
|
||||||
|
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
||||||
|
const sha256File = async (file) => sha256(await readFile(file));
|
||||||
|
const compareText = (left, right) => String(left).localeCompare(String(right));
|
||||||
|
const sameMembers = (left, right) => (
|
||||||
|
left.length === right.length && left.every((value, index) => value === right[index])
|
||||||
|
);
|
||||||
|
const constantCase = (value) => String(value).replaceAll("-", "_").toUpperCase();
|
||||||
|
|
||||||
|
function generatedJson(source, name, suffix) {
|
||||||
|
const marker = "export const " + name + " = ";
|
||||||
|
const start = source.indexOf(marker);
|
||||||
|
if (start < 0) throw new Error("Missing generated constant " + name + ".");
|
||||||
|
const valueStart = start + marker.length;
|
||||||
|
const valueEnd = source.indexOf(suffix, valueStart);
|
||||||
|
if (valueEnd < 0) throw new Error("Missing type suffix for " + name + ".");
|
||||||
|
return JSON.parse(source.slice(valueStart, valueEnd));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeTemplateId(entityId) {
|
||||||
|
const match = String(entityId).match(/-(\d+)$/);
|
||||||
|
return match ? Number(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoriesFor(value) {
|
||||||
|
return SIGNAL_PATTERNS
|
||||||
|
.filter(([, pattern]) => pattern.test(String(value ?? "")))
|
||||||
|
.map(([kind]) => kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusCounts(items) {
|
||||||
|
return Object.fromEntries(IMPLEMENTATION_STATUSES.map((status) => [
|
||||||
|
status,
|
||||||
|
items.filter((item) => item.status === status).length,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fidelityCounts(items) {
|
||||||
|
return Object.fromEntries(FIDELITY_LEVELS.map((fidelity) => [
|
||||||
|
fidelity,
|
||||||
|
items.filter((item) => item.fidelity === fidelity).length,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceFile(pathName, fileSha256, contentSha256 = null) {
|
||||||
|
return {
|
||||||
|
path: pathName,
|
||||||
|
sha256: fileSha256,
|
||||||
|
...(contentSha256 ? { contentSha256 } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferredRowRecord({ dungeonId, zoneId, row, snapshotPath, snapshotFileSha256, snapshot }) {
|
||||||
|
const evidenceText = [
|
||||||
|
row.name,
|
||||||
|
row.sourceName,
|
||||||
|
row.modelPath,
|
||||||
|
row.source?.roleName,
|
||||||
|
row.source?.autoPlot,
|
||||||
|
row.source?.plotClassName,
|
||||||
|
...Object.values(row.source?.templateScripts ?? {}),
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
return {
|
||||||
|
id: "row:" + dungeonId + ":" + row.spawnId,
|
||||||
|
kind: "deferred-source-row",
|
||||||
|
dungeonId,
|
||||||
|
zoneId,
|
||||||
|
sourceIds: {
|
||||||
|
spawnId: row.spawnId,
|
||||||
|
templateId: row.templateId,
|
||||||
|
roomId: Number(row.source?.roomId ?? 0),
|
||||||
|
},
|
||||||
|
name: row.name,
|
||||||
|
sourceName: row.sourceName,
|
||||||
|
sourceClassification: row.classification,
|
||||||
|
sourcePosition: row.sourcePosition,
|
||||||
|
direction: row.direction,
|
||||||
|
modelPath: row.modelPath ?? null,
|
||||||
|
heuristicCategories: categoriesFor(evidenceText),
|
||||||
|
status: "deferred-source-gap",
|
||||||
|
fidelity: "source-exact",
|
||||||
|
source: {
|
||||||
|
snapshot: sourceFile(snapshotPath, snapshotFileSha256, snapshot.contentSha256),
|
||||||
|
},
|
||||||
|
blocker: "The source row preserves identity and placement, but no reviewed evidence proves its gameplay behavior.",
|
||||||
|
nextWork: "Identify the authoritative trigger, state transition, reset/persistence rules, and asset requirements; then add an explicit source-spawn mapping.",
|
||||||
|
clearingTest: "Add a deterministic fixture proving that source spawn " + row.spawnId
|
||||||
|
+ " executes the reviewed behavior and resets idempotently.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeSpellMatches(entities, templateId, spellId) {
|
||||||
|
return Object.entries(entities).flatMap(([entityId, entity]) => {
|
||||||
|
if (runtimeTemplateId(entityId) !== templateId) return [];
|
||||||
|
return (entity.combat?.attacks ?? [])
|
||||||
|
.filter((attack) => Number(attack.spellId ?? attack.source?.spellId ?? 0) === spellId)
|
||||||
|
.map((attack) => ({
|
||||||
|
entityId,
|
||||||
|
attackId: attack.id,
|
||||||
|
telegraph: attack.telegraph ?? null,
|
||||||
|
delivery: attack.delivery,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseEvidenceRecord({
|
||||||
|
id,
|
||||||
|
evidenceKind,
|
||||||
|
dungeonId,
|
||||||
|
zoneId,
|
||||||
|
templateId = null,
|
||||||
|
categories,
|
||||||
|
value,
|
||||||
|
source,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind: "mechanics-evidence",
|
||||||
|
evidenceKind,
|
||||||
|
dungeonId,
|
||||||
|
zoneId,
|
||||||
|
sourceIds: templateId == null ? {} : { templateId },
|
||||||
|
categories: [...new Set(categories)].sort(compareText),
|
||||||
|
value,
|
||||||
|
status: "deferred-source-gap",
|
||||||
|
fidelity: "source-exact",
|
||||||
|
source,
|
||||||
|
blocker: "This is source evidence, but it does not prove a complete executable mechanic by itself.",
|
||||||
|
nextWork: "Review the surrounding source to establish trigger, target, timing, state effects, reset, persistence, and multiplayer behavior.",
|
||||||
|
clearingTest: "Add a focused encounter fixture tied to this stable evidence ID and verify its full state transition and reset behavior.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateEvidenceRecords({
|
||||||
|
dungeonId,
|
||||||
|
zoneId,
|
||||||
|
mechanics,
|
||||||
|
mechanicsPath,
|
||||||
|
mechanicsFileSha256,
|
||||||
|
snapshot,
|
||||||
|
snapshotPath,
|
||||||
|
snapshotFileSha256,
|
||||||
|
entities,
|
||||||
|
}) {
|
||||||
|
const records = [];
|
||||||
|
const commonSource = {
|
||||||
|
mechanicsAudit: sourceFile(mechanicsPath, mechanicsFileSha256),
|
||||||
|
snapshot: sourceFile(snapshotPath, snapshotFileSha256, snapshot.contentSha256),
|
||||||
|
};
|
||||||
|
for (const candidate of mechanics.templateCandidates ?? []) {
|
||||||
|
const templateId = Number(candidate.templateId);
|
||||||
|
for (const value of candidate.autoPlots ?? []) {
|
||||||
|
records.push(baseEvidenceRecord({
|
||||||
|
id: "signal:" + dungeonId + ":template:" + templateId + ":autoplot:" + sha256(value).slice(0, 12),
|
||||||
|
evidenceKind: "auto-plot",
|
||||||
|
dungeonId,
|
||||||
|
zoneId,
|
||||||
|
templateId,
|
||||||
|
categories: categoriesFor(value),
|
||||||
|
value,
|
||||||
|
source: commonSource,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for (const value of candidate.plotClassNames ?? []) {
|
||||||
|
records.push(baseEvidenceRecord({
|
||||||
|
id: "signal:" + dungeonId + ":template:" + templateId + ":plot-class:" + sha256(value).slice(0, 12),
|
||||||
|
evidenceKind: "plot-class",
|
||||||
|
dungeonId,
|
||||||
|
zoneId,
|
||||||
|
templateId,
|
||||||
|
categories: categoriesFor(value),
|
||||||
|
value,
|
||||||
|
source: commonSource,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for (const script of candidate.templateScripts ?? []) {
|
||||||
|
const value = script.event + ":" + script.script;
|
||||||
|
records.push(baseEvidenceRecord({
|
||||||
|
id: "signal:" + dungeonId + ":template:" + templateId + ":script:"
|
||||||
|
+ script.event + ":" + sha256(script.script).slice(0, 12),
|
||||||
|
evidenceKind: "template-script",
|
||||||
|
dungeonId,
|
||||||
|
zoneId,
|
||||||
|
templateId,
|
||||||
|
categories: categoriesFor(value),
|
||||||
|
value: { event: script.event, script: script.script },
|
||||||
|
source: commonSource,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for (const spell of candidate.spells ?? []) {
|
||||||
|
const spellId = Number(spell.id);
|
||||||
|
const matches = runtimeSpellMatches(entities, templateId, spellId);
|
||||||
|
const record = baseEvidenceRecord({
|
||||||
|
id: "signal:" + dungeonId + ":template:" + templateId + ":spell:" + spellId,
|
||||||
|
evidenceKind: "source-spell",
|
||||||
|
dungeonId,
|
||||||
|
zoneId,
|
||||||
|
templateId,
|
||||||
|
categories: ["spell"],
|
||||||
|
value: { spellId, level: Number(spell.level ?? 0) },
|
||||||
|
source: commonSource,
|
||||||
|
});
|
||||||
|
if (matches.length) {
|
||||||
|
record.status = "verified";
|
||||||
|
record.fidelity = "source-backed-adapted";
|
||||||
|
record.runtimeMappings = matches;
|
||||||
|
record.blocker = "The original RuneWaker coefficient and native stat inputs are not available in the portable package.";
|
||||||
|
record.nextWork = "Retain the exact spell identity and revisit numerical fidelity only if authoritative formula inputs are recovered.";
|
||||||
|
record.clearingTest = "npm run runewaker:audit:gameplay verifies the source spell ID and runtime attack remain aligned.";
|
||||||
|
}
|
||||||
|
records.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
function luaEvidenceRecords({ dungeonId, zoneId, mechanics, mechanicsPath, mechanicsFileSha256 }) {
|
||||||
|
const luaHashByFile = new Map(
|
||||||
|
(mechanics.luaSources ?? []).map((source) => [source.file, source.sha256]),
|
||||||
|
);
|
||||||
|
return (mechanics.luaSignals ?? []).map((signal) => baseEvidenceRecord({
|
||||||
|
id: "signal:" + dungeonId + ":lua:" + signal.file + ":" + signal.line,
|
||||||
|
evidenceKind: "lua-line",
|
||||||
|
dungeonId,
|
||||||
|
zoneId,
|
||||||
|
categories: signal.kinds ?? [],
|
||||||
|
value: { line: signal.line, text: signal.text },
|
||||||
|
source: {
|
||||||
|
mechanicsAudit: sourceFile(mechanicsPath, mechanicsFileSha256),
|
||||||
|
lua: sourceFile(signal.file, luaHashByFile.get(signal.file) ?? null),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(report) {
|
||||||
|
const rows = report.dungeons.map((dungeon) => [
|
||||||
|
"|", dungeon.id, "|", dungeon.sourceRows, "|", dungeon.deferredSourceRows.length, "|",
|
||||||
|
dungeon.mechanicsEvidence.length, "|", dungeon.evidenceStatusCounts.verified, "|",
|
||||||
|
dungeon.evidenceStatusCounts["deferred-source-gap"], "|", dungeon.bossObjectives, "|",
|
||||||
|
dungeon.packageAssessment.status, "|",
|
||||||
|
].join(" "));
|
||||||
|
const categoryRows = Object.entries(report.summary.evidenceCategoryMemberships)
|
||||||
|
.map(([kind, count]) => "| " + kind + " | " + count + " |");
|
||||||
|
const blockerRows = Object.entries(report.blockerGroups)
|
||||||
|
.map(([key, value]) => "| " + key + " | " + value.items + " | " + value.nextWork + " |");
|
||||||
|
return [
|
||||||
|
"# RuneWaker encounter implementation and fidelity ledger",
|
||||||
|
"",
|
||||||
|
"Structural validation: **" + report.validationStatus + "**",
|
||||||
|
"Content completion: **" + report.completionStatus + "**",
|
||||||
|
"",
|
||||||
|
"This report is deterministic and offline. Its JSON companion inventories every deferred",
|
||||||
|
"source spawn row and every direct mechanics evidence record. A regex category membership",
|
||||||
|
"is a review label, not a unique mechanic; one evidence record may belong to several categories.",
|
||||||
|
"",
|
||||||
|
"## Coverage",
|
||||||
|
"",
|
||||||
|
"- " + report.summary.dungeons + " dungeon packages.",
|
||||||
|
"- " + report.summary.deferredSourceRows + " deferred source rows with stable spawn IDs.",
|
||||||
|
"- " + report.summary.uniqueMechanicsEvidence + " unique mechanics evidence records: "
|
||||||
|
+ report.summary.luaEvidenceSignals + " Lua lines and "
|
||||||
|
+ report.summary.templateEvidenceSignals + " template references.",
|
||||||
|
"- " + report.summary.evidenceCategoryMembershipCount
|
||||||
|
+ " overlapping category memberships; this number is intentionally not reported as mechanics.",
|
||||||
|
"- " + report.summary.verifiedEvidence + " evidence records have an exact executable source-spell mapping.",
|
||||||
|
"- " + report.summary.sourceGapEvidence + " evidence records remain source-review gaps.",
|
||||||
|
"",
|
||||||
|
"## Status definitions",
|
||||||
|
"",
|
||||||
|
"- `verified`: an exact source identifier is present in executable runtime data and checked by the audit.",
|
||||||
|
"- `implemented`: executable behavior exists, but source fidelity is incomplete or explicitly authored.",
|
||||||
|
"- `deferred-source-gap`: source context does not prove the full behavior; the generator never guesses it.",
|
||||||
|
"- `deferred-asset-gap`: behavior is understood but a required asset is unavailable.",
|
||||||
|
"- `deferred-runtime-gap`: behavior is understood but the runtime primitive is unavailable.",
|
||||||
|
"- `excluded-non-gameplay`: a reviewed source item is confirmed to have no gameplay behavior.",
|
||||||
|
"- `intentional-empty`: the authoritative packaged snapshot contains no population rows.",
|
||||||
|
"",
|
||||||
|
"## Per-dungeon inventory",
|
||||||
|
"",
|
||||||
|
"| Dungeon | Source rows | Deferred rows | Evidence | Verified | Source gaps | Bosses | Package status |",
|
||||||
|
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
|
||||||
|
...rows,
|
||||||
|
"",
|
||||||
|
"## Four explicit fallback bosses",
|
||||||
|
"",
|
||||||
|
"| Boss | Runtime ID | Status | Fidelity | Blocker |",
|
||||||
|
"| --- | --- | --- | --- | --- |",
|
||||||
|
...report.specialCases.fallbackBosses.map((boss) => "| " + boss.name + " | `" + boss.entityId
|
||||||
|
+ "` | " + boss.status + " | " + boss.fidelity + " | " + boss.blocker + " |"),
|
||||||
|
"",
|
||||||
|
"These attacks are intentionally playable and telegraphed HealerMan behavior. They are not",
|
||||||
|
"presented as original RuneWaker mechanics until authoritative source behavior is recovered.",
|
||||||
|
"",
|
||||||
|
"## Non-empty packages without confirmed boss objectives",
|
||||||
|
"",
|
||||||
|
...report.specialCases.nonEmptyZeroBossPackages.map((entry) => "- `" + entry.dungeonId + "`: "
|
||||||
|
+ entry.sourceRows + " rows; " + entry.blocker + " Next: " + entry.nextWork),
|
||||||
|
"",
|
||||||
|
"## Intentional empty variants",
|
||||||
|
"",
|
||||||
|
...report.specialCases.intentionalEmptyPackages.map((entry) => "- `" + entry.dungeonId + "` (Zone "
|
||||||
|
+ entry.zoneId + "): " + entry.blocker),
|
||||||
|
"",
|
||||||
|
"## Evidence category memberships",
|
||||||
|
"",
|
||||||
|
"| Category | Evidence records carrying the label |",
|
||||||
|
"| --- | ---: |",
|
||||||
|
...categoryRows,
|
||||||
|
"",
|
||||||
|
"## Remaining work ledger",
|
||||||
|
"",
|
||||||
|
"| Blocker group | Items | Clearing work |",
|
||||||
|
"| --- | ---: | --- |",
|
||||||
|
...blockerRows,
|
||||||
|
"",
|
||||||
|
"The exhaustive per-item blocker, next-work instruction, clearing test, source IDs, source",
|
||||||
|
"files, and SHA-256 hashes are in `RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json`.",
|
||||||
|
"",
|
||||||
|
"## Validation issues",
|
||||||
|
"",
|
||||||
|
...(report.issues.length ? report.issues.map((entry) => "- " + entry) : ["- None."]),
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateReport(report) {
|
||||||
|
const issues = [];
|
||||||
|
const inventory = report.dungeons.flatMap((dungeon) => [
|
||||||
|
...dungeon.deferredSourceRows,
|
||||||
|
...dungeon.mechanicsEvidence,
|
||||||
|
]);
|
||||||
|
const ids = new Set();
|
||||||
|
for (const item of inventory) {
|
||||||
|
if (ids.has(item.id)) issues.push("Duplicate inventory ID: " + item.id + ".");
|
||||||
|
ids.add(item.id);
|
||||||
|
if (!IMPLEMENTATION_STATUSES.includes(item.status)) {
|
||||||
|
issues.push(item.id + " has unsupported status " + item.status + ".");
|
||||||
|
}
|
||||||
|
if (!FIDELITY_LEVELS.includes(item.fidelity)) {
|
||||||
|
issues.push(item.id + " has unsupported fidelity " + item.fidelity + ".");
|
||||||
|
}
|
||||||
|
if (!item.blocker || !item.nextWork || !item.clearingTest) {
|
||||||
|
issues.push(item.id + " does not document blocker, next work, and clearing test.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fallbackIds = report.specialCases.fallbackBosses.map((boss) => boss.entityId).sort(compareText);
|
||||||
|
if (!sameMembers(fallbackIds, [...EXPECTED_FALLBACK_BOSSES])) {
|
||||||
|
issues.push("Expected exactly the four documented Forsaken Abbey fallback bosses; found "
|
||||||
|
+ fallbackIds.join(", ") + ".");
|
||||||
|
}
|
||||||
|
const zeroBossIds = report.specialCases.nonEmptyZeroBossPackages
|
||||||
|
.map((entry) => entry.dungeonId).sort(compareText);
|
||||||
|
if (!sameMembers(zeroBossIds, [...EXPECTED_NON_EMPTY_ZERO_BOSS_PACKAGES])) {
|
||||||
|
issues.push("Non-empty zero-boss package set drifted: " + zeroBossIds.join(", ") + ".");
|
||||||
|
}
|
||||||
|
const emptyIds = report.specialCases.intentionalEmptyPackages
|
||||||
|
.map((entry) => entry.dungeonId).sort(compareText);
|
||||||
|
if (!sameMembers(emptyIds, [...EXPECTED_INTENTIONAL_EMPTY_PACKAGES])) {
|
||||||
|
issues.push("Intentional-empty package set drifted: " + emptyIds.join(", ") + ".");
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildEncounterImplementationReport() {
|
||||||
|
const recipeNames = (await readdir(recipeDirectory))
|
||||||
|
.filter((name) => name.endsWith("-population.json"))
|
||||||
|
.sort(compareText);
|
||||||
|
const bossMechanicsPath = path.join(projectRoot, "dungeon-pipeline/boss-mechanics.json");
|
||||||
|
const [bossMechanics, bossMechanicsFileSha256] = await Promise.all([
|
||||||
|
readJson(bossMechanicsPath),
|
||||||
|
sha256File(bossMechanicsPath),
|
||||||
|
]);
|
||||||
|
const dungeons = [];
|
||||||
|
|
||||||
|
for (const recipeName of recipeNames) {
|
||||||
|
const recipeFile = path.join(recipeDirectory, recipeName);
|
||||||
|
const recipe = await readJson(recipeFile);
|
||||||
|
const dungeonId = recipe.dungeonId;
|
||||||
|
const zoneId = recipe.zoneId;
|
||||||
|
const snapshotFile = path.resolve(projectRoot, recipe.files.snapshot);
|
||||||
|
const mechanicsFile = path.resolve(
|
||||||
|
projectRoot,
|
||||||
|
recipe.files.mechanicsReport
|
||||||
|
?? "src/assets/game/dungeons/" + dungeonId + "/mechanics-audit.json",
|
||||||
|
);
|
||||||
|
const generatedSourceFile = path.resolve(projectRoot, recipe.files.generatedSource);
|
||||||
|
const [
|
||||||
|
recipeFileSha256,
|
||||||
|
snapshot,
|
||||||
|
snapshotFileSha256,
|
||||||
|
mechanics,
|
||||||
|
mechanicsFileSha256,
|
||||||
|
generatedSource,
|
||||||
|
generatedSourceSha256,
|
||||||
|
] = await Promise.all([
|
||||||
|
sha256File(recipeFile),
|
||||||
|
readJson(snapshotFile),
|
||||||
|
sha256File(snapshotFile),
|
||||||
|
readJson(mechanicsFile),
|
||||||
|
sha256File(mechanicsFile),
|
||||||
|
readFile(generatedSourceFile, "utf8"),
|
||||||
|
sha256File(generatedSourceFile),
|
||||||
|
]);
|
||||||
|
const prefix = constantCase(dungeonId);
|
||||||
|
const entities = generatedJson(
|
||||||
|
generatedSource,
|
||||||
|
prefix + "_ENTITIES",
|
||||||
|
" as const satisfies PopulationDefinitionMap;",
|
||||||
|
);
|
||||||
|
const bosses = generatedJson(
|
||||||
|
generatedSource,
|
||||||
|
prefix + "_BOSSES",
|
||||||
|
" as const satisfies readonly DungeonBossObjective[];",
|
||||||
|
);
|
||||||
|
const snapshotPath = projectPath(snapshotFile);
|
||||||
|
const mechanicsPath = projectPath(mechanicsFile);
|
||||||
|
const deferredSourceRows = (snapshot.rows ?? [])
|
||||||
|
.filter((row) => String(row.classification).startsWith("deferred"))
|
||||||
|
.map((row) => deferredRowRecord({
|
||||||
|
dungeonId,
|
||||||
|
zoneId,
|
||||||
|
row,
|
||||||
|
snapshotPath,
|
||||||
|
snapshotFileSha256,
|
||||||
|
snapshot,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.sourceIds.spawnId - right.sourceIds.spawnId);
|
||||||
|
const mechanicsEvidence = [
|
||||||
|
...luaEvidenceRecords({
|
||||||
|
dungeonId,
|
||||||
|
zoneId,
|
||||||
|
mechanics,
|
||||||
|
mechanicsPath,
|
||||||
|
mechanicsFileSha256,
|
||||||
|
}),
|
||||||
|
...templateEvidenceRecords({
|
||||||
|
dungeonId,
|
||||||
|
zoneId,
|
||||||
|
mechanics,
|
||||||
|
mechanicsPath,
|
||||||
|
mechanicsFileSha256,
|
||||||
|
snapshot,
|
||||||
|
snapshotPath,
|
||||||
|
snapshotFileSha256,
|
||||||
|
entities,
|
||||||
|
}),
|
||||||
|
].sort((left, right) => compareText(left.id, right.id));
|
||||||
|
const packageStatus = snapshot.rows.length === 0
|
||||||
|
? "intentional-empty"
|
||||||
|
: bosses.length === 0
|
||||||
|
? "deferred-source-gap"
|
||||||
|
: "verified";
|
||||||
|
dungeons.push({
|
||||||
|
id: dungeonId,
|
||||||
|
zoneId,
|
||||||
|
sourceRows: snapshot.rows.length,
|
||||||
|
bossObjectives: bosses.length,
|
||||||
|
sourceArtifacts: {
|
||||||
|
recipe: sourceFile(projectPath(recipeFile), recipeFileSha256),
|
||||||
|
snapshot: sourceFile(snapshotPath, snapshotFileSha256, snapshot.contentSha256),
|
||||||
|
mechanicsAudit: sourceFile(mechanicsPath, mechanicsFileSha256),
|
||||||
|
generatedRuntime: sourceFile(projectPath(generatedSourceFile), generatedSourceSha256),
|
||||||
|
upstreamHashes: snapshot.sourceHashes ?? {},
|
||||||
|
},
|
||||||
|
packageAssessment: {
|
||||||
|
status: packageStatus,
|
||||||
|
fidelity: "source-exact",
|
||||||
|
blocker: packageStatus === "intentional-empty"
|
||||||
|
? "The authoritative snapshot contains zero population rows; no combat population is fabricated."
|
||||||
|
: packageStatus === "deferred-source-gap"
|
||||||
|
? "The package is non-empty, but its preserved source classification confirms no boss objectives."
|
||||||
|
: "No package-level population blocker is recorded.",
|
||||||
|
nextWork: packageStatus === "deferred-source-gap"
|
||||||
|
? "Recover authoritative boss membership and encounter order before creating boss objectives."
|
||||||
|
: "Re-run the ledger whenever portable source content changes.",
|
||||||
|
clearingTest: packageStatus === "deferred-source-gap"
|
||||||
|
? "Add a source-backed boss-membership fixture and verify the generated objective list."
|
||||||
|
: "npm run runewaker:audit:gameplay",
|
||||||
|
},
|
||||||
|
deferredRowStatusCounts: statusCounts(deferredSourceRows),
|
||||||
|
evidenceStatusCounts: statusCounts(mechanicsEvidence),
|
||||||
|
evidenceFidelityCounts: fidelityCounts(mechanicsEvidence),
|
||||||
|
deferredSourceRows,
|
||||||
|
mechanicsEvidence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dungeons.sort((left, right) => compareText(left.id, right.id));
|
||||||
|
const allRows = dungeons.flatMap((dungeon) => dungeon.deferredSourceRows);
|
||||||
|
const allEvidence = dungeons.flatMap((dungeon) => dungeon.mechanicsEvidence);
|
||||||
|
const fallbackBosses = Object.entries(bossMechanics.bosses ?? {})
|
||||||
|
.filter(([, boss]) => boss.source?.kind === "healer-man-source-gap")
|
||||||
|
.map(([entityId, boss]) => {
|
||||||
|
const dungeon = dungeons.find((entry) => entry.id === boss.source.dungeonId);
|
||||||
|
return {
|
||||||
|
entityId,
|
||||||
|
name: boss.name,
|
||||||
|
dungeonId: boss.source.dungeonId,
|
||||||
|
templateId: Number(boss.source.templateId),
|
||||||
|
mechanicIds: (boss.mechanics ?? []).map((mechanic) => mechanic.id).sort(compareText),
|
||||||
|
telegraphedMechanicIds: (boss.mechanics ?? [])
|
||||||
|
.filter((mechanic) => mechanic.telegraph)
|
||||||
|
.map((mechanic) => mechanic.id)
|
||||||
|
.sort(compareText),
|
||||||
|
status: "implemented",
|
||||||
|
fidelity: "healer-man-authored",
|
||||||
|
source: {
|
||||||
|
bossMechanicsCatalog: sourceFile(projectPath(bossMechanicsPath), bossMechanicsFileSha256),
|
||||||
|
snapshot: dungeon?.sourceArtifacts.snapshot ?? null,
|
||||||
|
roots: boss.source.roots ?? [],
|
||||||
|
functions: boss.source.functions ?? [],
|
||||||
|
spellIds: boss.source.spellIds ?? [],
|
||||||
|
},
|
||||||
|
blocker: "Preserved source evidence does not expose an executable original combat kit.",
|
||||||
|
nextWork: "Replace the fallback only after authoritative spell timing, targeting, and effects are recovered; preserve modular IDs and telegraphs.",
|
||||||
|
clearingTest: "Add source-backed mechanic fixtures and assert the catalog source kind changes from healer-man-source-gap to runewaker.",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((left, right) => compareText(left.entityId, right.entityId));
|
||||||
|
const nonEmptyZeroBossPackages = dungeons
|
||||||
|
.filter((dungeon) => dungeon.sourceRows > 0 && dungeon.bossObjectives === 0)
|
||||||
|
.map((dungeon) => ({
|
||||||
|
dungeonId: dungeon.id,
|
||||||
|
zoneId: dungeon.zoneId,
|
||||||
|
sourceRows: dungeon.sourceRows,
|
||||||
|
status: "deferred-source-gap",
|
||||||
|
fidelity: "source-exact",
|
||||||
|
source: dungeon.sourceArtifacts.snapshot,
|
||||||
|
blocker: "The portable source package contains population rows but no source-confirmed boss membership.",
|
||||||
|
nextWork: "Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.",
|
||||||
|
clearingTest: "Verify at least one source-backed boss objective, or add reviewed evidence that this package intentionally has none.",
|
||||||
|
}));
|
||||||
|
const intentionalEmptyPackages = dungeons
|
||||||
|
.filter((dungeon) => dungeon.sourceRows === 0)
|
||||||
|
.map((dungeon) => ({
|
||||||
|
dungeonId: dungeon.id,
|
||||||
|
zoneId: dungeon.zoneId,
|
||||||
|
sourceRows: 0,
|
||||||
|
status: "intentional-empty",
|
||||||
|
fidelity: "source-exact",
|
||||||
|
source: dungeon.sourceArtifacts.snapshot,
|
||||||
|
blocker: "The authoritative packaged snapshot contains no active population rows.",
|
||||||
|
nextWork: "No population work is scheduled unless a different authoritative source snapshot is supplied.",
|
||||||
|
clearingTest: "Keep the zero-row snapshot hash and intentional-empty classification stable in this audit.",
|
||||||
|
}));
|
||||||
|
const categoryMemberships = {};
|
||||||
|
for (const evidence of allEvidence) {
|
||||||
|
for (const category of evidence.categories) {
|
||||||
|
categoryMemberships[category] = (categoryMemberships[category] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const luaEvidenceSignals = allEvidence.filter((item) => item.evidenceKind === "lua-line").length;
|
||||||
|
const templateEvidenceSignals = allEvidence.length - luaEvidenceSignals;
|
||||||
|
const report = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
validationStatus: "green",
|
||||||
|
completionStatus: allRows.length || allEvidence.some((item) => item.status.startsWith("deferred"))
|
||||||
|
? "review-required"
|
||||||
|
: "complete",
|
||||||
|
scope: "All configured offline RuneWaker dungeon packages",
|
||||||
|
generatedBy: "scripts/runewaker-pipeline/audit-encounter-implementation.mjs",
|
||||||
|
deterministic: true,
|
||||||
|
policy: {
|
||||||
|
statuses: IMPLEMENTATION_STATUSES,
|
||||||
|
fidelities: FIDELITY_LEVELS,
|
||||||
|
evidenceRule: "Direct source rows and source references are inventoried once. Regex category memberships may overlap and are never counted as unique mechanics.",
|
||||||
|
failClosedRule: "When static evidence cannot prove complete behavior, the item remains deferred-source-gap rather than receiving a guessed implementation status.",
|
||||||
|
sourceExactRule: "source-exact describes preserved evidence identity and provenance, not an assertion that deferred gameplay behavior is known.",
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
dungeons: dungeons.length,
|
||||||
|
deferredSourceRows: allRows.length,
|
||||||
|
uniqueMechanicsEvidence: allEvidence.length,
|
||||||
|
luaEvidenceSignals,
|
||||||
|
templateEvidenceSignals,
|
||||||
|
evidenceCategoryMembershipCount: Object.values(categoryMemberships)
|
||||||
|
.reduce((total, count) => total + count, 0),
|
||||||
|
evidenceCategoryMemberships: Object.fromEntries(
|
||||||
|
Object.entries(categoryMemberships).sort(([left], [right]) => compareText(left, right)),
|
||||||
|
),
|
||||||
|
deferredRowStatuses: statusCounts(allRows),
|
||||||
|
evidenceStatuses: statusCounts(allEvidence),
|
||||||
|
evidenceFidelities: fidelityCounts(allEvidence),
|
||||||
|
verifiedEvidence: allEvidence.filter((item) => item.status === "verified").length,
|
||||||
|
sourceGapEvidence: allEvidence.filter((item) => item.status === "deferred-source-gap").length,
|
||||||
|
fallbackBosses: fallbackBosses.length,
|
||||||
|
nonEmptyZeroBossPackages: nonEmptyZeroBossPackages.length,
|
||||||
|
intentionalEmptyPackages: intentionalEmptyPackages.length,
|
||||||
|
},
|
||||||
|
blockerGroups: {
|
||||||
|
deferredSourceRows: {
|
||||||
|
items: allRows.length,
|
||||||
|
nextWork: "Review source semantics and add explicit source-spawn encounter mappings.",
|
||||||
|
},
|
||||||
|
unresolvedMechanicsEvidence: {
|
||||||
|
items: allEvidence.filter((item) => item.status === "deferred-source-gap").length,
|
||||||
|
nextWork: "Review surrounding Lua/template context; specify full trigger, effect, and reset behavior.",
|
||||||
|
},
|
||||||
|
adaptedSpellFormula: {
|
||||||
|
items: allEvidence.filter((item) => item.status === "verified"
|
||||||
|
&& item.fidelity === "source-backed-adapted").length,
|
||||||
|
nextWork: "Recover native formula inputs before claiming coefficient-level fidelity.",
|
||||||
|
},
|
||||||
|
sourceGapFallbackBosses: {
|
||||||
|
items: fallbackBosses.length,
|
||||||
|
nextWork: "Recover authoritative combat kits and replace only with source-backed modular mechanics.",
|
||||||
|
},
|
||||||
|
nonEmptyZeroBossPackages: {
|
||||||
|
items: nonEmptyZeroBossPackages.length,
|
||||||
|
nextWork: "Recover authoritative boss membership and objective order.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
specialCases: {
|
||||||
|
fallbackBosses,
|
||||||
|
nonEmptyZeroBossPackages,
|
||||||
|
intentionalEmptyPackages,
|
||||||
|
},
|
||||||
|
issues: [],
|
||||||
|
dungeons,
|
||||||
|
};
|
||||||
|
report.issues = validateReport(report);
|
||||||
|
report.validationStatus = report.issues.length ? "blocked" : "green";
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateEncounterImplementationReport() {
|
||||||
|
const report = await buildEncounterImplementationReport();
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(reportJsonFile, JSON.stringify(report, null, 2) + "\n", "utf8"),
|
||||||
|
writeFile(reportMarkdownFile, renderMarkdown(report), "utf8"),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
report,
|
||||||
|
reportJson: projectPath(reportJsonFile),
|
||||||
|
reportMarkdown: projectPath(reportMarkdownFile),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
import { Document, NodeIO } from "@gltf-transform/core";
|
||||||
|
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||||
|
import { getBounds, mergeDocuments, unpartition } from "@gltf-transform/functions";
|
||||||
|
import { Matrix4, Quaternion, Vector3 } from "three";
|
||||||
|
|
||||||
|
const SUPPORTED_RESOURCE_EXTENSIONS = new Set([".ros"]);
|
||||||
|
const DYNAMIC_HINT_PATTERN = /(?:^|[\\/_-])(door|gate|bridge|drawbridge|lift|elevator|switch|lever|chest|treasure|portal)(?:[\\/_.-]|$)/i;
|
||||||
|
const DYNAMIC_KINDS = new Set([
|
||||||
|
"door",
|
||||||
|
"gate",
|
||||||
|
"bridge",
|
||||||
|
"lift",
|
||||||
|
"switch",
|
||||||
|
"chest",
|
||||||
|
"portal",
|
||||||
|
"other",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function normalizeWdbResourcePath(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("/", "\\")
|
||||||
|
.replace(/^\\+/, "")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resourceCacheKey(value) {
|
||||||
|
return createHash("sha256")
|
||||||
|
.update(normalizeWdbResourcePath(value))
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteTuple(value, length, name) {
|
||||||
|
if (!Array.isArray(value) || value.length !== length
|
||||||
|
|| value.some((entry) => !Number.isFinite(Number(entry)))) {
|
||||||
|
throw new Error(name + " must contain exactly " + length + " finite numbers.");
|
||||||
|
}
|
||||||
|
return value.map(Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function descriptorSourceMatrix(descriptor) {
|
||||||
|
const translation = finiteTuple(descriptor.translation, 3, "descriptor.translation");
|
||||||
|
const rotation = finiteTuple(descriptor.rotation, 4, "descriptor.rotation");
|
||||||
|
const scale = finiteTuple(descriptor.scale, 3, "descriptor.scale");
|
||||||
|
const quaternion = new Quaternion(...rotation);
|
||||||
|
if (quaternion.lengthSq() < 1e-12) throw new Error("descriptor.rotation cannot be zero.");
|
||||||
|
quaternion.normalize();
|
||||||
|
return new Matrix4().compose(
|
||||||
|
new Vector3(...translation),
|
||||||
|
quaternion,
|
||||||
|
new Vector3(...scale),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function placementMatrixInPrimaryThree(descriptor, primary, metersPerSourceUnit) {
|
||||||
|
const meters = Number(metersPerSourceUnit);
|
||||||
|
if (!Number.isFinite(meters) || meters <= 0) {
|
||||||
|
throw new Error("metersPerSourceUnit must be a positive finite number.");
|
||||||
|
}
|
||||||
|
const sourceToThree = new Matrix4().makeScale(meters, meters, -meters);
|
||||||
|
const threeToSource = sourceToThree.clone().invert();
|
||||||
|
return sourceToThree
|
||||||
|
.clone()
|
||||||
|
.multiply(descriptorSourceMatrix(primary).invert())
|
||||||
|
.multiply(descriptorSourceMatrix(descriptor))
|
||||||
|
.multiply(threeToSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformSourcePointToPrimaryThree(
|
||||||
|
sourcePoint,
|
||||||
|
descriptor,
|
||||||
|
primary,
|
||||||
|
metersPerSourceUnit,
|
||||||
|
) {
|
||||||
|
const point = new Vector3(...finiteTuple(sourcePoint, 3, "sourcePoint"));
|
||||||
|
point.applyMatrix4(descriptorSourceMatrix(descriptor));
|
||||||
|
point.applyMatrix4(descriptorSourceMatrix(primary).invert());
|
||||||
|
const meters = Number(metersPerSourceUnit);
|
||||||
|
return [point.x * meters, point.y * meters, -point.z * meters];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneNodeTree(document, node) {
|
||||||
|
const clone = document.createNode(node.getName())
|
||||||
|
.setMatrix(node.getMatrix())
|
||||||
|
.setExtras({ ...node.getExtras() })
|
||||||
|
.setMesh(node.getMesh())
|
||||||
|
.setCamera(node.getCamera())
|
||||||
|
.setSkin(node.getSkin())
|
||||||
|
.setWeights([...node.getWeights()]);
|
||||||
|
for (const child of node.listChildren()) clone.addChild(cloneNodeTree(document, child));
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assembleInstancedGlb(records, outputFile) {
|
||||||
|
if (!records.length) throw new Error("Cannot assemble an empty WDB GLB.");
|
||||||
|
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||||
|
const target = new Document();
|
||||||
|
const scene = target.createScene("WDB assembly");
|
||||||
|
const recordsByAsset = new Map();
|
||||||
|
for (const record of records) {
|
||||||
|
const rows = recordsByAsset.get(record.assetFile) ?? [];
|
||||||
|
rows.push(record);
|
||||||
|
recordsByAsset.set(record.assetFile, rows);
|
||||||
|
}
|
||||||
|
for (const assetFile of [...recordsByAsset.keys()].sort()) {
|
||||||
|
const source = await io.read(assetFile);
|
||||||
|
const sourceScene = source.getRoot().getDefaultScene() ?? source.getRoot().listScenes()[0];
|
||||||
|
if (!sourceScene) throw new Error("Cached WDB resource has no scene: " + assetFile);
|
||||||
|
const mapping = mergeDocuments(target, source);
|
||||||
|
const mergedScene = mapping.get(sourceScene);
|
||||||
|
if (!mergedScene) throw new Error("Could not merge cached WDB resource: " + assetFile);
|
||||||
|
const roots = mergedScene.listChildren();
|
||||||
|
if (!roots.length) throw new Error("Cached WDB resource has no scene roots: " + assetFile);
|
||||||
|
for (const [index, record] of recordsByAsset.get(assetFile).entries()) {
|
||||||
|
const placement = target.createNode(record.placement.id)
|
||||||
|
.setMatrix(record.placement.transform.primaryLocalThreeMatrix)
|
||||||
|
.setExtras({
|
||||||
|
wdbPlacementId: record.placement.id,
|
||||||
|
wdbGuid: record.placement.guid,
|
||||||
|
sourceResource: record.placement.resource,
|
||||||
|
resourceCacheKey: record.placement.resourceCacheKey,
|
||||||
|
});
|
||||||
|
const nodes = index === 0
|
||||||
|
? roots
|
||||||
|
: roots.map((rootNode) => cloneNodeTree(target, rootNode));
|
||||||
|
for (const rootNode of nodes) placement.addChild(rootNode);
|
||||||
|
scene.addChild(placement);
|
||||||
|
}
|
||||||
|
mergedScene.dispose();
|
||||||
|
}
|
||||||
|
await target.transform(unpartition());
|
||||||
|
const bounds = getBounds(scene);
|
||||||
|
await io.write(outputFile, target);
|
||||||
|
return {
|
||||||
|
bounds: {
|
||||||
|
min: bounds.min.map((value) => Number(value.toFixed(6))),
|
||||||
|
max: bounds.max.map((value) => Number(value.toFixed(6))),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matrixArray(matrix) {
|
||||||
|
return matrix.toArray().map((value) => Number(value.toFixed(10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPrimaryDescriptor(report, recipe) {
|
||||||
|
const target = normalizeWdbResourcePath(recipe.source.model);
|
||||||
|
const matches = (report.descriptors ?? []).filter((descriptor) => (
|
||||||
|
normalizeWdbResourcePath(descriptor.resource) === target
|
||||||
|
));
|
||||||
|
if (!matches.length) {
|
||||||
|
throw new Error("Expected at least one WDB placement for " + recipe.source.model + ".");
|
||||||
|
}
|
||||||
|
if (matches.length === 1) return matches[0];
|
||||||
|
const expected = recipe.source.primaryPlacementTranslation;
|
||||||
|
if (!Array.isArray(expected) || expected.length !== 3) {
|
||||||
|
throw new Error("Multiple primary-resource placements require source.primaryPlacementTranslation.");
|
||||||
|
}
|
||||||
|
const match = matches.find((descriptor) => descriptor.translation.every((value, index) => (
|
||||||
|
Math.abs(Number(value) - Number(expected[index])) <= 0.0001
|
||||||
|
)));
|
||||||
|
if (!match) throw new Error("Configured primary WDB placement was not found.");
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewedDynamicRules(recipe) {
|
||||||
|
const rules = recipe.source.wdbAssembly?.dynamicPlacements ?? [];
|
||||||
|
const ids = new Set();
|
||||||
|
return rules.map((rule, index) => {
|
||||||
|
if (!rule || typeof rule !== "object") {
|
||||||
|
throw new Error("source.wdbAssembly.dynamicPlacements[" + index + "] must be an object.");
|
||||||
|
}
|
||||||
|
if (!String(rule.id ?? "").trim()) throw new Error("Dynamic placement rule requires a stable id.");
|
||||||
|
if (ids.has(rule.id)) throw new Error("Duplicate dynamic placement id " + rule.id + ".");
|
||||||
|
ids.add(rule.id);
|
||||||
|
if (!DYNAMIC_KINDS.has(rule.kind)) {
|
||||||
|
throw new Error("Dynamic placement " + rule.id + " has unsupported kind " + rule.kind + ".");
|
||||||
|
}
|
||||||
|
if (!rule.guid && !rule.resource) {
|
||||||
|
throw new Error("Dynamic placement " + rule.id + " requires guid or resource evidence.");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(rule.evidence) || !rule.evidence.length) {
|
||||||
|
throw new Error("Dynamic placement " + rule.id + " requires reviewed evidence.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...rule,
|
||||||
|
normalizedResource: rule.resource ? normalizeWdbResourcePath(rule.resource) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchingDynamicRule(descriptor, rules) {
|
||||||
|
const resource = normalizeWdbResourcePath(descriptor.resource);
|
||||||
|
return rules.find((rule) => (
|
||||||
|
(rule.guid && String(rule.guid).toLowerCase() === String(descriptor.guid).toLowerCase())
|
||||||
|
|| (rule.normalizedResource && rule.normalizedResource === resource)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function placementRecord(descriptor, index, primary, recipe, classification, dynamicRule = null) {
|
||||||
|
const resource = normalizeWdbResourcePath(descriptor.resource);
|
||||||
|
const id = classification === "dynamic"
|
||||||
|
? dynamicRule.id + ":" + String(descriptor.guid ?? index)
|
||||||
|
: "wdb:" + String(descriptor.guid ?? index);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
guid: descriptor.guid ?? null,
|
||||||
|
resource: String(descriptor.resource).replaceAll("\\", "/"),
|
||||||
|
normalizedResource: resource,
|
||||||
|
resourceCacheKey: resourceCacheKey(resource),
|
||||||
|
classification,
|
||||||
|
transform: {
|
||||||
|
source: {
|
||||||
|
translation: descriptor.translation.map(Number),
|
||||||
|
rotation: descriptor.rotation.map(Number),
|
||||||
|
scale: descriptor.scale.map(Number),
|
||||||
|
},
|
||||||
|
primaryLocalThreeMatrix: matrixArray(placementMatrixInPrimaryThree(
|
||||||
|
descriptor,
|
||||||
|
primary,
|
||||||
|
recipe.coordinateSystem.metersPerSourceUnit,
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
sourceBounds: {
|
||||||
|
local: descriptor.localBounds ?? null,
|
||||||
|
world: descriptor.worldBounds ?? null,
|
||||||
|
},
|
||||||
|
sourceFlags: {
|
||||||
|
zoneFlags: Number(descriptor.zoneFlags ?? 0),
|
||||||
|
detailClass: Number(descriptor.detailClass ?? 0),
|
||||||
|
zoneType: Number(descriptor.zoneType ?? 0),
|
||||||
|
},
|
||||||
|
...(dynamicRule ? {
|
||||||
|
dynamic: {
|
||||||
|
definitionId: dynamicRule.id,
|
||||||
|
kind: dynamicRule.kind,
|
||||||
|
initialState: dynamicRule.initialState ?? "default",
|
||||||
|
collision: dynamicRule.collision ?? "stateful",
|
||||||
|
evidence: dynamicRule.evidence,
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWdbPlacementPlan(report, recipe) {
|
||||||
|
if (!report || !Array.isArray(report.descriptors)) {
|
||||||
|
throw new Error("WDB report must contain descriptors.");
|
||||||
|
}
|
||||||
|
const primary = selectPrimaryDescriptor(report, recipe);
|
||||||
|
const dynamicRules = reviewedDynamicRules(recipe);
|
||||||
|
const matchedDynamicRuleIds = new Set();
|
||||||
|
const staticPlacements = [];
|
||||||
|
const dynamicPlacements = [];
|
||||||
|
const reviewRequiredPlacements = [];
|
||||||
|
const unsupportedPlacements = [];
|
||||||
|
const resourceMap = new Map();
|
||||||
|
for (const [index, descriptor] of report.descriptors.entries()) {
|
||||||
|
const resource = normalizeWdbResourcePath(descriptor.resource);
|
||||||
|
const extension = path.extname(resource);
|
||||||
|
if (!SUPPORTED_RESOURCE_EXTENSIONS.has(extension)) {
|
||||||
|
unsupportedPlacements.push({
|
||||||
|
id: "wdb:" + String(descriptor.guid ?? index),
|
||||||
|
guid: descriptor.guid ?? null,
|
||||||
|
resource: descriptor.resource,
|
||||||
|
reason: extension
|
||||||
|
? "unsupported WDB placement resource extension " + extension
|
||||||
|
: "WDB placement resource has no extension",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let matrix;
|
||||||
|
try {
|
||||||
|
matrix = placementMatrixInPrimaryThree(
|
||||||
|
descriptor,
|
||||||
|
primary,
|
||||||
|
recipe.coordinateSystem.metersPerSourceUnit,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
unsupportedPlacements.push({
|
||||||
|
id: "wdb:" + String(descriptor.guid ?? index),
|
||||||
|
guid: descriptor.guid ?? null,
|
||||||
|
resource: descriptor.resource,
|
||||||
|
reason: "invalid WDB transform: " + error.message,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (matrix.elements.some((value) => !Number.isFinite(value))) {
|
||||||
|
unsupportedPlacements.push({
|
||||||
|
id: "wdb:" + String(descriptor.guid ?? index),
|
||||||
|
guid: descriptor.guid ?? null,
|
||||||
|
resource: descriptor.resource,
|
||||||
|
reason: "WDB transform produced a non-finite matrix",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dynamicRule = matchingDynamicRule(descriptor, dynamicRules);
|
||||||
|
if (descriptor === primary && dynamicRule) {
|
||||||
|
throw new Error("The primary coordinate-anchor placement cannot be dynamic ("
|
||||||
|
+ dynamicRule.id + ").");
|
||||||
|
}
|
||||||
|
const classification = dynamicRule ? "dynamic" : "static";
|
||||||
|
const placement = placementRecord(
|
||||||
|
descriptor,
|
||||||
|
index,
|
||||||
|
primary,
|
||||||
|
recipe,
|
||||||
|
classification,
|
||||||
|
dynamicRule,
|
||||||
|
);
|
||||||
|
if (dynamicRule) {
|
||||||
|
matchedDynamicRuleIds.add(dynamicRule.id);
|
||||||
|
dynamicPlacements.push(placement);
|
||||||
|
} else {
|
||||||
|
staticPlacements.push(placement);
|
||||||
|
if (DYNAMIC_HINT_PATTERN.test(resource)) {
|
||||||
|
reviewRequiredPlacements.push({
|
||||||
|
placementId: placement.id,
|
||||||
|
guid: placement.guid,
|
||||||
|
resource: placement.resource,
|
||||||
|
reason: "Resource name resembles a movable or interactive object, but no reviewed dynamic-placement rule proves its behavior.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!resourceMap.has(resource)) {
|
||||||
|
resourceMap.set(resource, {
|
||||||
|
resource: placement.resource,
|
||||||
|
normalizedResource: resource,
|
||||||
|
cacheKey: placement.resourceCacheKey,
|
||||||
|
placementCount: 0,
|
||||||
|
staticPlacementCount: 0,
|
||||||
|
dynamicPlacementCount: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const resourceRecord = resourceMap.get(resource);
|
||||||
|
resourceRecord.placementCount += 1;
|
||||||
|
resourceRecord[classification + "PlacementCount"] += 1;
|
||||||
|
}
|
||||||
|
const unmatchedDynamicRules = dynamicRules
|
||||||
|
.filter((rule) => !matchedDynamicRuleIds.has(rule.id))
|
||||||
|
.map((rule) => ({
|
||||||
|
id: rule.id,
|
||||||
|
guid: rule.guid ?? null,
|
||||||
|
resource: rule.resource ?? null,
|
||||||
|
reason: "Reviewed dynamic-placement selector matched no WDB descriptor.",
|
||||||
|
}));
|
||||||
|
if (unmatchedDynamicRules.length) {
|
||||||
|
throw new Error(unmatchedDynamicRules.map((rule) => rule.reason + " " + rule.id).join("\n"));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
dungeonId: recipe.slug,
|
||||||
|
sourceWdb: report.sourceWdb,
|
||||||
|
sourceDescriptorCount: report.descriptors.length,
|
||||||
|
primaryPlacement: {
|
||||||
|
guid: primary.guid ?? null,
|
||||||
|
resource: String(primary.resource).replaceAll("\\", "/"),
|
||||||
|
translation: primary.translation.map(Number),
|
||||||
|
rotation: primary.rotation.map(Number),
|
||||||
|
scale: primary.scale.map(Number),
|
||||||
|
},
|
||||||
|
coordinateTransform: {
|
||||||
|
source: recipe.coordinateSystem.source,
|
||||||
|
nativeBridge: recipe.coordinateSystem.nativeBridge,
|
||||||
|
metersPerSourceUnit: Number(recipe.coordinateSystem.metersPerSourceUnit),
|
||||||
|
formula: "threeMatrix=S*inverse(primaryTRS)*placementTRS*inverse(S), S=scale(meters,meters,-meters)",
|
||||||
|
},
|
||||||
|
resources: [...resourceMap.values()].sort((left, right) => (
|
||||||
|
left.normalizedResource.localeCompare(right.normalizedResource)
|
||||||
|
)),
|
||||||
|
staticPlacements: staticPlacements.sort((left, right) => left.id.localeCompare(right.id)),
|
||||||
|
dynamicPlacements: dynamicPlacements.sort((left, right) => left.id.localeCompare(right.id)),
|
||||||
|
reviewRequiredPlacements: reviewRequiredPlacements
|
||||||
|
.sort((left, right) => left.placementId.localeCompare(right.placementId)),
|
||||||
|
unsupportedPlacements: unsupportedPlacements.sort((left, right) => left.id.localeCompare(right.id)),
|
||||||
|
unsupportedResourceTypes: [
|
||||||
|
"WDB placement resources other than ROS",
|
||||||
|
"particle, light, sound, camera, and script-only components not represented as ROS mesh descriptors",
|
||||||
|
"animated or stateful behavior without a reviewed source.wdbAssembly.dynamicPlacements rule",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -694,15 +694,15 @@
|
|||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"environment": "authoritative-wdb-largest-model-selected",
|
"environment": "authoritative-wdb-largest-model-selected",
|
||||||
"collision": "primary-ros-plus-source-anchor-support-review-required",
|
"collision": "full-wdb-static-ros-provisional-review-required",
|
||||||
"entrance": "source-script",
|
"entrance": "source-script",
|
||||||
"bossAnchors": "source-population",
|
"bossAnchors": "source-population",
|
||||||
"wdbPlacements": "unique-primary-model"
|
"wdbPlacements": "all-static-ros-source-transform"
|
||||||
},
|
},
|
||||||
"exceptions": [
|
"exceptions": [
|
||||||
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
|
"All valid static WDB ROS placements are assembled; filename-only dynamic candidates remain static until script evidence maps their behavior.",
|
||||||
"Collision initially includes decorative primary-ROS geometry.",
|
"Collision initially includes decorative primary-ROS geometry.",
|
||||||
"Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work.",
|
"Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work.",
|
||||||
"Source-anchor support pads preserve combat and entrance coordinates where modular WDB sections or primary-ROS collision leave navmesh gaps; full secondary placement assembly remains a visual follow-up."
|
"The full WDB static assembly replaces the former source-anchor support pads; entrance and combat anchors still require live collision/navigation review."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loaded 100 of 349 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user