Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6f2375145 | ||
|
|
699fc858c9 | ||
|
|
d9bebb4e81 | ||
|
|
eaddad4134 | ||
|
|
fc8a7a722a | ||
|
|
b57d2b2839 |
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.
|
||||
@@ -1685,3 +1685,46 @@ Vite's existing large-chunk advisory remains informational.
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Production browser and Android builds ship a compact required set: the game
|
||||
|
||||
@@ -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,
|
||||
"status": "green",
|
||||
"scope": "HealerMan WoW five-player campaign",
|
||||
"scope": "HealerMan WoW five-player combat and animation coverage",
|
||||
"summary": {
|
||||
"dungeons": 54,
|
||||
"mobs": 1031,
|
||||
"bosses": 253,
|
||||
"attacks": 2953,
|
||||
"specialAttacks": 1669,
|
||||
"attacks": 3765,
|
||||
"specialAttacks": 2481,
|
||||
"nativeModels": 1266,
|
||||
"proceduralModels": 18,
|
||||
"uniqueNativeModels": 1166,
|
||||
"nativeStateFallbacks": 16
|
||||
"nativeStateFallbacks": 18
|
||||
},
|
||||
"documentedExceptions": [
|
||||
{
|
||||
@@ -47,6 +47,14 @@
|
||||
],
|
||||
"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",
|
||||
"state": "attack",
|
||||
@@ -56,7 +64,15 @@
|
||||
"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",
|
||||
"users": [
|
||||
"steamvault:entry-21694",
|
||||
@@ -170,8 +186,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 24,
|
||||
"bosses": 7,
|
||||
"attacks": 58,
|
||||
"specialAttacks": 27,
|
||||
"attacks": 65,
|
||||
"specialAttacks": 34,
|
||||
"nativeModels": 31,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -182,8 +198,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 6,
|
||||
"bosses": 5,
|
||||
"attacks": 32,
|
||||
"specialAttacks": 21,
|
||||
"attacks": 42,
|
||||
"specialAttacks": 31,
|
||||
"nativeModels": 11,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -194,8 +210,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 15,
|
||||
"bosses": 7,
|
||||
"attacks": 48,
|
||||
"specialAttacks": 26,
|
||||
"attacks": 55,
|
||||
"specialAttacks": 33,
|
||||
"nativeModels": 22,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -218,8 +234,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 31,
|
||||
"bosses": 7,
|
||||
"attacks": 78,
|
||||
"specialAttacks": 40,
|
||||
"attacks": 87,
|
||||
"specialAttacks": 49,
|
||||
"nativeModels": 38,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -230,8 +246,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 21,
|
||||
"bosses": 6,
|
||||
"attacks": 58,
|
||||
"specialAttacks": 31,
|
||||
"attacks": 65,
|
||||
"specialAttacks": 38,
|
||||
"nativeModels": 27,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -242,8 +258,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 31,
|
||||
"bosses": 7,
|
||||
"attacks": 85,
|
||||
"specialAttacks": 47,
|
||||
"attacks": 104,
|
||||
"specialAttacks": 66,
|
||||
"nativeModels": 38,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -254,8 +270,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 27,
|
||||
"bosses": 4,
|
||||
"attacks": 68,
|
||||
"specialAttacks": 37,
|
||||
"attacks": 82,
|
||||
"specialAttacks": 51,
|
||||
"nativeModels": 31,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -266,8 +282,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 29,
|
||||
"bosses": 4,
|
||||
"attacks": 86,
|
||||
"specialAttacks": 53,
|
||||
"attacks": 93,
|
||||
"specialAttacks": 60,
|
||||
"nativeModels": 33,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -278,8 +294,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 19,
|
||||
"bosses": 6,
|
||||
"attacks": 55,
|
||||
"specialAttacks": 30,
|
||||
"attacks": 63,
|
||||
"specialAttacks": 38,
|
||||
"nativeModels": 25,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -290,8 +306,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 32,
|
||||
"bosses": 5,
|
||||
"attacks": 89,
|
||||
"specialAttacks": 52,
|
||||
"attacks": 107,
|
||||
"specialAttacks": 70,
|
||||
"nativeModels": 37,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -302,8 +318,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 16,
|
||||
"bosses": 5,
|
||||
"attacks": 53,
|
||||
"specialAttacks": 32,
|
||||
"attacks": 69,
|
||||
"specialAttacks": 48,
|
||||
"nativeModels": 21,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -314,8 +330,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 63,
|
||||
"bosses": 15,
|
||||
"attacks": 210,
|
||||
"specialAttacks": 132,
|
||||
"attacks": 273,
|
||||
"specialAttacks": 195,
|
||||
"nativeModels": 78,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -326,8 +342,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 48,
|
||||
"bosses": 13,
|
||||
"attacks": 145,
|
||||
"specialAttacks": 84,
|
||||
"attacks": 182,
|
||||
"specialAttacks": 121,
|
||||
"nativeModels": 61,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -338,8 +354,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 30,
|
||||
"bosses": 8,
|
||||
"attacks": 115,
|
||||
"specialAttacks": 77,
|
||||
"attacks": 150,
|
||||
"specialAttacks": 112,
|
||||
"nativeModels": 38,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -350,8 +366,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 41,
|
||||
"bosses": 9,
|
||||
"attacks": 131,
|
||||
"specialAttacks": 81,
|
||||
"attacks": 176,
|
||||
"specialAttacks": 126,
|
||||
"nativeModels": 50,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -362,8 +378,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 25,
|
||||
"bosses": 8,
|
||||
"attacks": 83,
|
||||
"specialAttacks": 50,
|
||||
"attacks": 95,
|
||||
"specialAttacks": 62,
|
||||
"nativeModels": 33,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -374,8 +390,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 7,
|
||||
"bosses": 4,
|
||||
"attacks": 21,
|
||||
"specialAttacks": 10,
|
||||
"attacks": 23,
|
||||
"specialAttacks": 12,
|
||||
"nativeModels": 11,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -386,8 +402,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 63,
|
||||
"bosses": 12,
|
||||
"attacks": 184,
|
||||
"specialAttacks": 109,
|
||||
"attacks": 242,
|
||||
"specialAttacks": 167,
|
||||
"nativeModels": 75,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -398,8 +414,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 29,
|
||||
"bosses": 3,
|
||||
"attacks": 74,
|
||||
"specialAttacks": 42,
|
||||
"attacks": 92,
|
||||
"specialAttacks": 60,
|
||||
"nativeModels": 32,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -410,8 +426,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 14,
|
||||
"bosses": 3,
|
||||
"attacks": 44,
|
||||
"specialAttacks": 27,
|
||||
"attacks": 53,
|
||||
"specialAttacks": 36,
|
||||
"nativeModels": 17,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -422,8 +438,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 10,
|
||||
"bosses": 2,
|
||||
"attacks": 36,
|
||||
"specialAttacks": 24,
|
||||
"attacks": 41,
|
||||
"specialAttacks": 29,
|
||||
"nativeModels": 12,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -434,8 +450,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 14,
|
||||
"bosses": 3,
|
||||
"attacks": 50,
|
||||
"specialAttacks": 33,
|
||||
"attacks": 63,
|
||||
"specialAttacks": 46,
|
||||
"nativeModels": 17,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -446,8 +462,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 18,
|
||||
"bosses": 4,
|
||||
"attacks": 59,
|
||||
"specialAttacks": 37,
|
||||
"attacks": 80,
|
||||
"specialAttacks": 58,
|
||||
"nativeModels": 22,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -458,8 +474,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 18,
|
||||
"bosses": 3,
|
||||
"attacks": 53,
|
||||
"specialAttacks": 32,
|
||||
"attacks": 60,
|
||||
"specialAttacks": 39,
|
||||
"nativeModels": 21,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -470,8 +486,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 25,
|
||||
"bosses": 4,
|
||||
"attacks": 73,
|
||||
"specialAttacks": 44,
|
||||
"attacks": 105,
|
||||
"specialAttacks": 76,
|
||||
"nativeModels": 29,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -482,8 +498,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 24,
|
||||
"bosses": 5,
|
||||
"attacks": 67,
|
||||
"specialAttacks": 38,
|
||||
"attacks": 85,
|
||||
"specialAttacks": 56,
|
||||
"nativeModels": 29,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -494,8 +510,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 14,
|
||||
"bosses": 3,
|
||||
"attacks": 45,
|
||||
"specialAttacks": 28,
|
||||
"attacks": 69,
|
||||
"specialAttacks": 52,
|
||||
"nativeModels": 17,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -506,8 +522,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 18,
|
||||
"bosses": 4,
|
||||
"attacks": 46,
|
||||
"specialAttacks": 24,
|
||||
"attacks": 67,
|
||||
"specialAttacks": 45,
|
||||
"nativeModels": 22,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -518,8 +534,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 14,
|
||||
"bosses": 2,
|
||||
"attacks": 39,
|
||||
"specialAttacks": 23,
|
||||
"attacks": 50,
|
||||
"specialAttacks": 34,
|
||||
"nativeModels": 16,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -530,8 +546,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 11,
|
||||
"bosses": 3,
|
||||
"attacks": 33,
|
||||
"specialAttacks": 19,
|
||||
"attacks": 46,
|
||||
"specialAttacks": 32,
|
||||
"nativeModels": 14,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -542,8 +558,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 12,
|
||||
"bosses": 2,
|
||||
"attacks": 30,
|
||||
"specialAttacks": 16,
|
||||
"attacks": 44,
|
||||
"specialAttacks": 30,
|
||||
"nativeModels": 14,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -554,8 +570,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 15,
|
||||
"bosses": 3,
|
||||
"attacks": 33,
|
||||
"specialAttacks": 15,
|
||||
"attacks": 57,
|
||||
"specialAttacks": 39,
|
||||
"nativeModels": 18,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -566,8 +582,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 4,
|
||||
"bosses": 3,
|
||||
"attacks": 20,
|
||||
"specialAttacks": 13,
|
||||
"attacks": 25,
|
||||
"specialAttacks": 18,
|
||||
"nativeModels": 7,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -578,8 +594,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 26,
|
||||
"bosses": 3,
|
||||
"attacks": 45,
|
||||
"specialAttacks": 16,
|
||||
"attacks": 53,
|
||||
"specialAttacks": 24,
|
||||
"nativeModels": 29,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -590,8 +606,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 19,
|
||||
"bosses": 3,
|
||||
"attacks": 35,
|
||||
"specialAttacks": 13,
|
||||
"attacks": 44,
|
||||
"specialAttacks": 22,
|
||||
"nativeModels": 22,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -602,8 +618,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 13,
|
||||
"bosses": 4,
|
||||
"attacks": 32,
|
||||
"specialAttacks": 15,
|
||||
"attacks": 44,
|
||||
"specialAttacks": 27,
|
||||
"nativeModels": 17,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -614,8 +630,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 21,
|
||||
"bosses": 5,
|
||||
"attacks": 50,
|
||||
"specialAttacks": 24,
|
||||
"attacks": 61,
|
||||
"specialAttacks": 35,
|
||||
"nativeModels": 26,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -626,8 +642,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 8,
|
||||
"bosses": 4,
|
||||
"attacks": 27,
|
||||
"specialAttacks": 15,
|
||||
"attacks": 33,
|
||||
"specialAttacks": 21,
|
||||
"nativeModels": 12,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -638,8 +654,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 16,
|
||||
"bosses": 4,
|
||||
"attacks": 41,
|
||||
"specialAttacks": 21,
|
||||
"attacks": 44,
|
||||
"specialAttacks": 24,
|
||||
"nativeModels": 20,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -650,8 +666,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 13,
|
||||
"bosses": 4,
|
||||
"attacks": 32,
|
||||
"specialAttacks": 15,
|
||||
"attacks": 42,
|
||||
"specialAttacks": 25,
|
||||
"nativeModels": 16,
|
||||
"proceduralModels": 1,
|
||||
"status": "green"
|
||||
@@ -662,8 +678,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 18,
|
||||
"bosses": 4,
|
||||
"attacks": 38,
|
||||
"specialAttacks": 16,
|
||||
"attacks": 45,
|
||||
"specialAttacks": 23,
|
||||
"nativeModels": 22,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -674,8 +690,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 10,
|
||||
"bosses": 3,
|
||||
"attacks": 25,
|
||||
"specialAttacks": 12,
|
||||
"attacks": 30,
|
||||
"specialAttacks": 17,
|
||||
"nativeModels": 13,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -686,8 +702,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 16,
|
||||
"bosses": 4,
|
||||
"attacks": 35,
|
||||
"specialAttacks": 15,
|
||||
"attacks": 49,
|
||||
"specialAttacks": 29,
|
||||
"nativeModels": 20,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -698,8 +714,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 15,
|
||||
"bosses": 5,
|
||||
"attacks": 43,
|
||||
"specialAttacks": 23,
|
||||
"attacks": 53,
|
||||
"specialAttacks": 33,
|
||||
"nativeModels": 20,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -710,8 +726,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 2,
|
||||
"bosses": 3,
|
||||
"attacks": 19,
|
||||
"specialAttacks": 14,
|
||||
"attacks": 24,
|
||||
"specialAttacks": 19,
|
||||
"nativeModels": 5,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -722,8 +738,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 19,
|
||||
"bosses": 5,
|
||||
"attacks": 43,
|
||||
"specialAttacks": 19,
|
||||
"attacks": 60,
|
||||
"specialAttacks": 36,
|
||||
"nativeModels": 24,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -734,8 +750,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 11,
|
||||
"bosses": 2,
|
||||
"attacks": 21,
|
||||
"specialAttacks": 8,
|
||||
"attacks": 23,
|
||||
"specialAttacks": 10,
|
||||
"nativeModels": 13,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -746,8 +762,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 0,
|
||||
"bosses": 5,
|
||||
"attacks": 22,
|
||||
"specialAttacks": 17,
|
||||
"attacks": 88,
|
||||
"specialAttacks": 83,
|
||||
"nativeModels": 5,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -758,8 +774,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 27,
|
||||
"bosses": 3,
|
||||
"attacks": 43,
|
||||
"specialAttacks": 13,
|
||||
"attacks": 49,
|
||||
"specialAttacks": 19,
|
||||
"nativeModels": 30,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -770,8 +786,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 8,
|
||||
"bosses": 2,
|
||||
"attacks": 26,
|
||||
"specialAttacks": 16,
|
||||
"attacks": 38,
|
||||
"specialAttacks": 28,
|
||||
"nativeModels": 10,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
|
||||
@@ -1,64 +1,66 @@
|
||||
# WoW dungeon content report
|
||||
# WoW dungeon combat and animation report
|
||||
|
||||
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 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| Shadowfang Keep | 24 | 7 | 58 | 27 | 31 | 0 | green |
|
||||
| Stormwind Stockade | 6 | 5 | 32 | 21 | 11 | 0 | green |
|
||||
| The Deadmines | 15 | 7 | 48 | 26 | 22 | 0 | green |
|
||||
| Shadowfang Keep | 24 | 7 | 65 | 34 | 31 | 0 | green |
|
||||
| Stormwind Stockade | 6 | 5 | 42 | 31 | 11 | 0 | green |
|
||||
| The Deadmines | 15 | 7 | 55 | 33 | 22 | 0 | green |
|
||||
| Wailing Caverns | 12 | 8 | 51 | 31 | 15 | 5 | green |
|
||||
| Razorfen Kraul | 31 | 7 | 78 | 40 | 38 | 0 | green |
|
||||
| Blackfathom Deeps | 21 | 6 | 58 | 31 | 27 | 0 | green |
|
||||
| Uldaman | 31 | 7 | 85 | 47 | 38 | 0 | green |
|
||||
| Gnomeregan | 27 | 4 | 68 | 37 | 31 | 0 | green |
|
||||
| Sunken Temple | 29 | 4 | 86 | 53 | 33 | 0 | green |
|
||||
| Razorfen Downs | 19 | 6 | 55 | 30 | 25 | 0 | green |
|
||||
| Scarlet Monastery | 32 | 5 | 89 | 52 | 37 | 0 | green |
|
||||
| Zul'Farrak | 16 | 5 | 53 | 32 | 21 | 0 | green |
|
||||
| Blackrock Spire | 63 | 15 | 210 | 132 | 78 | 0 | green |
|
||||
| Blackrock Depths | 48 | 13 | 145 | 84 | 61 | 0 | green |
|
||||
| Scholomance | 30 | 8 | 115 | 77 | 38 | 0 | green |
|
||||
| Stratholme | 41 | 9 | 131 | 81 | 50 | 0 | green |
|
||||
| Maraudon | 25 | 8 | 83 | 50 | 33 | 0 | green |
|
||||
| Ragefire Chasm | 7 | 4 | 21 | 10 | 11 | 0 | green |
|
||||
| Dire Maul | 63 | 12 | 184 | 109 | 75 | 0 | green |
|
||||
| Hellfire Citadel: The Shattered Halls | 29 | 3 | 74 | 42 | 32 | 0 | green |
|
||||
| Hellfire Citadel: The Blood Furnace | 14 | 3 | 44 | 27 | 17 | 0 | green |
|
||||
| Hellfire Citadel: Ramparts | 10 | 2 | 36 | 24 | 12 | 0 | green |
|
||||
| Coilfang: The Steamvault | 14 | 3 | 50 | 33 | 17 | 0 | green |
|
||||
| Coilfang: The Underbog | 18 | 4 | 59 | 37 | 22 | 0 | green |
|
||||
| Coilfang: The Slave Pens | 18 | 3 | 53 | 32 | 21 | 0 | green |
|
||||
| Tempest Keep: The Arcatraz | 25 | 4 | 73 | 44 | 29 | 0 | green |
|
||||
| Tempest Keep: The Botanica | 24 | 5 | 67 | 38 | 29 | 0 | green |
|
||||
| Tempest Keep: The Mechanar | 14 | 3 | 45 | 28 | 17 | 0 | green |
|
||||
| Auchindoun: Shadow Labyrinth | 18 | 4 | 46 | 24 | 22 | 0 | green |
|
||||
| Auchindoun: Sethekk Halls | 14 | 2 | 39 | 23 | 16 | 0 | green |
|
||||
| Auchindoun: Mana-Tombs | 11 | 3 | 33 | 19 | 14 | 0 | green |
|
||||
| Auchindoun: Auchenai Crypts | 12 | 2 | 30 | 16 | 14 | 0 | green |
|
||||
| Magister's Terrace | 15 | 3 | 33 | 15 | 18 | 0 | green |
|
||||
| The Black Morass | 4 | 3 | 20 | 13 | 7 | 0 | green |
|
||||
| The Escape From Durnholde | 26 | 3 | 45 | 16 | 29 | 0 | green |
|
||||
| Utgarde Keep | 19 | 3 | 35 | 13 | 22 | 0 | green |
|
||||
| Utgarde Pinnacle | 13 | 4 | 32 | 15 | 17 | 0 | green |
|
||||
| The Nexus | 21 | 5 | 50 | 24 | 26 | 0 | green |
|
||||
| The Oculus | 8 | 4 | 27 | 15 | 12 | 0 | green |
|
||||
| The Culling of Stratholme | 16 | 4 | 41 | 21 | 20 | 0 | green |
|
||||
| Halls of Stone | 13 | 4 | 32 | 15 | 16 | 1 | green |
|
||||
| Drak'Tharon Keep | 18 | 4 | 38 | 16 | 22 | 0 | green |
|
||||
| Azjol-Nerub | 10 | 3 | 25 | 12 | 13 | 0 | green |
|
||||
| Halls of Lightning | 16 | 4 | 35 | 15 | 20 | 0 | green |
|
||||
| Gundrak | 15 | 5 | 43 | 23 | 20 | 0 | green |
|
||||
| Violet Hold | 2 | 3 | 19 | 14 | 5 | 0 | green |
|
||||
| Ahn'kahet: The Old Kingdom | 19 | 5 | 43 | 19 | 24 | 0 | green |
|
||||
| The Forge of Souls | 11 | 2 | 21 | 8 | 13 | 0 | green |
|
||||
| Trial of the Champion | 0 | 5 | 22 | 17 | 5 | 0 | green |
|
||||
| Pit of Saron | 27 | 3 | 43 | 13 | 30 | 0 | green |
|
||||
| Halls of Reflection | 8 | 2 | 26 | 16 | 10 | 0 | green |
|
||||
| Razorfen Kraul | 31 | 7 | 87 | 49 | 38 | 0 | green |
|
||||
| Blackfathom Deeps | 21 | 6 | 65 | 38 | 27 | 0 | green |
|
||||
| Uldaman | 31 | 7 | 104 | 66 | 38 | 0 | green |
|
||||
| Gnomeregan | 27 | 4 | 82 | 51 | 31 | 0 | green |
|
||||
| Sunken Temple | 29 | 4 | 93 | 60 | 33 | 0 | green |
|
||||
| Razorfen Downs | 19 | 6 | 63 | 38 | 25 | 0 | green |
|
||||
| Scarlet Monastery | 32 | 5 | 107 | 70 | 37 | 0 | green |
|
||||
| Zul'Farrak | 16 | 5 | 69 | 48 | 21 | 0 | green |
|
||||
| Blackrock Spire | 63 | 15 | 273 | 195 | 78 | 0 | green |
|
||||
| Blackrock Depths | 48 | 13 | 182 | 121 | 61 | 0 | green |
|
||||
| Scholomance | 30 | 8 | 150 | 112 | 38 | 0 | green |
|
||||
| Stratholme | 41 | 9 | 176 | 126 | 50 | 0 | green |
|
||||
| Maraudon | 25 | 8 | 95 | 62 | 33 | 0 | green |
|
||||
| Ragefire Chasm | 7 | 4 | 23 | 12 | 11 | 0 | green |
|
||||
| Dire Maul | 63 | 12 | 242 | 167 | 75 | 0 | green |
|
||||
| Hellfire Citadel: The Shattered Halls | 29 | 3 | 92 | 60 | 32 | 0 | green |
|
||||
| Hellfire Citadel: The Blood Furnace | 14 | 3 | 53 | 36 | 17 | 0 | green |
|
||||
| Hellfire Citadel: Ramparts | 10 | 2 | 41 | 29 | 12 | 0 | green |
|
||||
| Coilfang: The Steamvault | 14 | 3 | 63 | 46 | 17 | 0 | green |
|
||||
| Coilfang: The Underbog | 18 | 4 | 80 | 58 | 22 | 0 | green |
|
||||
| Coilfang: The Slave Pens | 18 | 3 | 60 | 39 | 21 | 0 | green |
|
||||
| Tempest Keep: The Arcatraz | 25 | 4 | 105 | 76 | 29 | 0 | green |
|
||||
| Tempest Keep: The Botanica | 24 | 5 | 85 | 56 | 29 | 0 | green |
|
||||
| Tempest Keep: The Mechanar | 14 | 3 | 69 | 52 | 17 | 0 | green |
|
||||
| Auchindoun: Shadow Labyrinth | 18 | 4 | 67 | 45 | 22 | 0 | green |
|
||||
| Auchindoun: Sethekk Halls | 14 | 2 | 50 | 34 | 16 | 0 | green |
|
||||
| Auchindoun: Mana-Tombs | 11 | 3 | 46 | 32 | 14 | 0 | green |
|
||||
| Auchindoun: Auchenai Crypts | 12 | 2 | 44 | 30 | 14 | 0 | green |
|
||||
| Magister's Terrace | 15 | 3 | 57 | 39 | 18 | 0 | green |
|
||||
| The Black Morass | 4 | 3 | 25 | 18 | 7 | 0 | green |
|
||||
| The Escape From Durnholde | 26 | 3 | 53 | 24 | 29 | 0 | green |
|
||||
| Utgarde Keep | 19 | 3 | 44 | 22 | 22 | 0 | green |
|
||||
| Utgarde Pinnacle | 13 | 4 | 44 | 27 | 17 | 0 | green |
|
||||
| The Nexus | 21 | 5 | 61 | 35 | 26 | 0 | green |
|
||||
| The Oculus | 8 | 4 | 33 | 21 | 12 | 0 | green |
|
||||
| The Culling of Stratholme | 16 | 4 | 44 | 24 | 20 | 0 | green |
|
||||
| Halls of Stone | 13 | 4 | 42 | 25 | 16 | 1 | green |
|
||||
| Drak'Tharon Keep | 18 | 4 | 45 | 23 | 22 | 0 | green |
|
||||
| Azjol-Nerub | 10 | 3 | 30 | 17 | 13 | 0 | green |
|
||||
| Halls of Lightning | 16 | 4 | 49 | 29 | 20 | 0 | green |
|
||||
| Gundrak | 15 | 5 | 53 | 33 | 20 | 0 | green |
|
||||
| Violet Hold | 2 | 3 | 24 | 19 | 5 | 0 | green |
|
||||
| Ahn'kahet: The Old Kingdom | 19 | 5 | 60 | 36 | 24 | 0 | green |
|
||||
| The Forge of Souls | 11 | 2 | 23 | 10 | 13 | 0 | green |
|
||||
| Trial of the Champion | 0 | 5 | 88 | 83 | 5 | 0 | green |
|
||||
| Pit of Saron | 27 | 3 | 49 | 19 | 30 | 0 | green |
|
||||
| Halls of Reflection | 8 | 2 | 38 | 28 | 10 | 0 | green |
|
||||
| Tinkertech Showdown | 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 |
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "blades-edge-arena",
|
||||
"title": "Blade's Edge Arena",
|
||||
"mapId": 562,
|
||||
"lfgDungeonIds": [],
|
||||
"expansion": "the-burning-crusade",
|
||||
"clientBuild": "3.3.5a",
|
||||
"mapDirectory": "World/Maps/bladesedgearena",
|
||||
"environmentMode": "global-wmo",
|
||||
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
|
||||
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
|
||||
"exceptions": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "dalaran-sewers",
|
||||
"title": "Dalaran Sewers",
|
||||
"mapId": 617,
|
||||
"lfgDungeonIds": [],
|
||||
"expansion": "wrath-of-the-lich-king",
|
||||
"clientBuild": "3.3.5a",
|
||||
"mapDirectory": "World/Maps/DalaranArena",
|
||||
"environmentMode": "global-wmo",
|
||||
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
|
||||
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
|
||||
"exceptions": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "nagrand-arena",
|
||||
"title": "Nagrand Arena",
|
||||
"mapId": 559,
|
||||
"lfgDungeonIds": [],
|
||||
"expansion": "the-burning-crusade",
|
||||
"clientBuild": "3.3.5a",
|
||||
"mapDirectory": "World/Maps/PVPZone01",
|
||||
"environmentMode": "global-wmo",
|
||||
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
|
||||
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
|
||||
"exceptions": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "ring-of-valor",
|
||||
"title": "Ring of Valor",
|
||||
"mapId": 618,
|
||||
"lfgDungeonIds": [],
|
||||
"expansion": "wrath-of-the-lich-king",
|
||||
"clientBuild": "3.3.5a",
|
||||
"mapDirectory": "World/Maps/OrgrimmarArena",
|
||||
"environmentMode": "global-wmo",
|
||||
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
|
||||
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
|
||||
"exceptions": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "ruins-of-lordaeron",
|
||||
"title": "Ruins of Lordaeron",
|
||||
"mapId": 572,
|
||||
"lfgDungeonIds": [],
|
||||
"expansion": "the-burning-crusade",
|
||||
"clientBuild": "3.3.5a",
|
||||
"mapDirectory": "World/Maps/PVPLordaeron",
|
||||
"environmentMode": "global-wmo",
|
||||
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
|
||||
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
|
||||
"exceptions": []
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.21",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.21",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+9
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.21",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
@@ -63,6 +63,8 @@
|
||||
"dungeon:navmesh": "node scripts/dungeon-pipeline/cli.mjs navmesh",
|
||||
"dungeon:navmesh:conversion": "node scripts/dungeon-pipeline/cli.mjs navmesh-conversion",
|
||||
"dungeon:validate": "node scripts/dungeon-pipeline/cli.mjs validate",
|
||||
"arena:assets:build": "node scripts/arena-pipeline/build-wmo-arenas.mjs && node scripts/arena-pipeline/build-custom-arenas.mjs && node scripts/arena-pipeline/prepare-warnorken.mjs",
|
||||
"arena:assets:validate": "node scripts/arena-pipeline/validate-arenas.mjs",
|
||||
"dungeon:enable": "node scripts/dungeon-pipeline/cli.mjs enable",
|
||||
"runewaker:forsaken": "node \"%INIT_CWD%\\scripts\\runewaker-pipeline\\build-forsaken-abbey.mjs\"",
|
||||
"runewaker:forsaken:actors": "node scripts/runewaker-pipeline/build-forsaken-population.mjs --actors-only",
|
||||
@@ -74,6 +76,8 @@
|
||||
"runewaker:register:all": "node scripts/runewaker-pipeline/cli.mjs register-all",
|
||||
"runewaker:refresh:all": "node scripts/runewaker-pipeline/cli.mjs refresh-all",
|
||||
"runewaker:spells:refresh": "node scripts/runewaker-pipeline/export-spell-catalog.mjs",
|
||||
"runewaker: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: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",
|
||||
@@ -84,7 +88,9 @@
|
||||
"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: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",
|
||||
"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",
|
||||
@@ -98,7 +104,7 @@
|
||||
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
|
||||
"balance:benchmark": "vite-node scripts/combat-benchmark/run.ts",
|
||||
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
|
||||
"prebuild": "npm run loot:generate && npm run 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:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android",
|
||||
"android:sync": "npm run build:android:web && cap sync android",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"build": {
|
||||
"attempts": [
|
||||
{
|
||||
"failures": [],
|
||||
"tileCount": 1,
|
||||
"tileSize": null,
|
||||
"valid": true
|
||||
}
|
||||
],
|
||||
"gridCells": 258876,
|
||||
"gridHeight": 564,
|
||||
"gridWidth": 459,
|
||||
"ignoredEmptyTilePackingFailures": 0,
|
||||
"intermediateRetention": null,
|
||||
"mode": "solo",
|
||||
"partition": "watershed",
|
||||
"populatedTileCount": 1,
|
||||
"tileColumns": 1,
|
||||
"tileCount": 1,
|
||||
"tileRows": 1,
|
||||
"tileSize": null
|
||||
},
|
||||
"dungeonId": "ashamanes-fall",
|
||||
"generator": "@recast-navigation/three@0.43.1",
|
||||
"geometry": {
|
||||
"bounds": [
|
||||
[
|
||||
16.435361862182617,
|
||||
-2.676706314086914,
|
||||
4.506431579589844
|
||||
],
|
||||
[
|
||||
65.03536224365234,
|
||||
26.523292541503906,
|
||||
43.1064338684082
|
||||
]
|
||||
],
|
||||
"boundsTolerance": 0.4,
|
||||
"rejectedOutOfBoundsTriangles": 0,
|
||||
"sourceTriangles": 353,
|
||||
"sourceVertices": 1059,
|
||||
"triangles": 353,
|
||||
"vertices": 1059
|
||||
},
|
||||
"input": {
|
||||
"bounds": [
|
||||
[
|
||||
-6.364639759063721,
|
||||
-19.976707458496094,
|
||||
-59.093570709228516
|
||||
],
|
||||
[
|
||||
85.27696990966797,
|
||||
36.782005310058594,
|
||||
53.62468338012695
|
||||
]
|
||||
],
|
||||
"degenerateTriangleCount": 0,
|
||||
"invalidIndexCount": 0,
|
||||
"meshCount": 1,
|
||||
"nonFiniteCoordinateCount": 0,
|
||||
"triangleCount": 2783,
|
||||
"vertexCount": 8349
|
||||
},
|
||||
"inputChunkChecksums": {
|
||||
"public/assets/game/arenas/ashamanes-fall/navigation-collision.glb": "cdc39b903da6a6f9f21bd9e5e0b2ca61e76b09e13575b02349ca92d68815e5a8"
|
||||
},
|
||||
"inputChunks": [
|
||||
"public/assets/game/arenas/ashamanes-fall/navigation-collision.glb"
|
||||
],
|
||||
"output": "public/assets/game/arenas/ashamanes-fall/navigation-raw.glb",
|
||||
"schemaVersion": 1,
|
||||
"settings": {
|
||||
"agentHeight": 1.8,
|
||||
"agentMaxClimb": 0.5,
|
||||
"agentMaxSlope": 50,
|
||||
"agentRadius": 0.36,
|
||||
"cellHeight": 0.1,
|
||||
"cellSize": 0.2
|
||||
},
|
||||
"source": {
|
||||
"adtFiles": [
|
||||
"valsharaharena_20_24.adt",
|
||||
"valsharaharena_20_25.adt",
|
||||
"valsharaharena_20_26.adt",
|
||||
"valsharaharena_21_24.adt",
|
||||
"valsharaharena_21_25.adt",
|
||||
"valsharaharena_21_26.adt",
|
||||
"valsharaharena_22_24.adt",
|
||||
"valsharaharena_22_25.adt",
|
||||
"valsharaharena_22_26.adt"
|
||||
],
|
||||
"gameplayBounds": {
|
||||
"max": [
|
||||
58,
|
||||
16,
|
||||
36
|
||||
],
|
||||
"min": [
|
||||
14,
|
||||
-4,
|
||||
2
|
||||
]
|
||||
},
|
||||
"kind": "adt-placed-wmo-repair",
|
||||
"legacyCollision": "public/assets/game/manastorm/1785-valsharaharena/collision.glb",
|
||||
"placedWmos": [
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/VALSHARAH/7VS_CAVEMICRO01.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RUINPIECE06.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_TOWER01_NIGHTMARE.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01_NIGHTMARE.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RUINPIECE06.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_BRIDGESHORT.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/DUNGEON/PVP/CUSTOM_VALSHARAH_ARENA.WMO"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "ashamanes-fall",
|
||||
"title": "Ashamane's Fall",
|
||||
"status": "generated-review-required",
|
||||
"source": {
|
||||
"mapDirectory": "patch-WB2/world/maps/valsharaharena",
|
||||
"adtFiles": [
|
||||
"valsharaharena_20_24.adt",
|
||||
"valsharaharena_20_25.adt",
|
||||
"valsharaharena_20_26.adt",
|
||||
"valsharaharena_21_24.adt",
|
||||
"valsharaharena_21_25.adt",
|
||||
"valsharaharena_21_26.adt",
|
||||
"valsharaharena_22_24.adt",
|
||||
"valsharaharena_22_25.adt",
|
||||
"valsharaharena_22_26.adt"
|
||||
],
|
||||
"placementCount": 16,
|
||||
"placedWmos": [
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/VALSHARAH/7VS_CAVEMICRO01.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RUINPIECE06.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_TOWER01_NIGHTMARE.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01_NIGHTMARE.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RUINPIECE06.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_BRIDGESHORT.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/DUNGEON/PVP/CUSTOM_VALSHARAH_ARENA.WMO"
|
||||
]
|
||||
},
|
||||
"coordinateSystem": {
|
||||
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
|
||||
"rebasedOrigin": [
|
||||
6.0437164306640625,
|
||||
-37.49217987060547,
|
||||
30.740463256835938
|
||||
],
|
||||
"alignmentOffset": [
|
||||
34.174934,
|
||||
-32.347685,
|
||||
20.31486
|
||||
],
|
||||
"alignment": "primary WMO matched to the existing HealerMan stage coordinate frame"
|
||||
},
|
||||
"bounds": {
|
||||
"min": [
|
||||
-289.133544921875,
|
||||
-32.347686767578125,
|
||||
-200.8459930419922
|
||||
],
|
||||
"max": [
|
||||
357.4833984375,
|
||||
316.2064514160156,
|
||||
241.47572326660156
|
||||
]
|
||||
},
|
||||
"geometry": {
|
||||
"groups": 18,
|
||||
"triangles": 82815,
|
||||
"collisionTriangles": 82815,
|
||||
"collisionChunks": 1
|
||||
},
|
||||
"navigation": {
|
||||
"triangles": 209,
|
||||
"vertices": 627,
|
||||
"sourceComponents": 20,
|
||||
"spawnDistances": [
|
||||
0.5941920148506428,
|
||||
0.0212655626862784
|
||||
],
|
||||
"rawTriangles": 353,
|
||||
"report": "recast-report.json"
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"file": "visual.glb",
|
||||
"bytes": 2611788,
|
||||
"sha256": "25e8a0d63d420559a6bc965dc2b930039d951de8cdc189d785a00edc3610b112"
|
||||
},
|
||||
{
|
||||
"file": "collision.glb",
|
||||
"bytes": 1805536,
|
||||
"sha256": "48f45c434687f07969a618e1251e05e8672061d16a1f3a88338dbc667e33e78e"
|
||||
},
|
||||
{
|
||||
"file": "navigation.glb",
|
||||
"bytes": 10936,
|
||||
"sha256": "ac69ef1e845f632576598a8b2a5d0bbfa8086b21ed5d7fd17af06d5a565a5248"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"build": {
|
||||
"attempts": [
|
||||
{
|
||||
"failures": [],
|
||||
"tileCount": 1,
|
||||
"tileSize": null,
|
||||
"valid": true
|
||||
}
|
||||
],
|
||||
"gridCells": 2074478,
|
||||
"gridHeight": 994,
|
||||
"gridWidth": 2087,
|
||||
"ignoredEmptyTilePackingFailures": 0,
|
||||
"intermediateRetention": null,
|
||||
"mode": "solo",
|
||||
"partition": "watershed",
|
||||
"populatedTileCount": 1,
|
||||
"tileColumns": 1,
|
||||
"tileCount": 1,
|
||||
"tileRows": 1,
|
||||
"tileSize": null
|
||||
},
|
||||
"dungeonId": "black-rook-hold-arena",
|
||||
"generator": "@recast-navigation/three@0.43.1",
|
||||
"geometry": {
|
||||
"bounds": [
|
||||
[
|
||||
-92.08255767822266,
|
||||
-16.14961814880371,
|
||||
-279.4311218261719
|
||||
],
|
||||
[
|
||||
321.9174499511719,
|
||||
37.25038528442383,
|
||||
-82.83113098144531
|
||||
]
|
||||
],
|
||||
"boundsTolerance": 0.4,
|
||||
"rejectedOutOfBoundsTriangles": 0,
|
||||
"sourceTriangles": 7170,
|
||||
"sourceVertices": 21510,
|
||||
"triangles": 7170,
|
||||
"vertices": 21510
|
||||
},
|
||||
"input": {
|
||||
"bounds": [
|
||||
[
|
||||
-93.08255767822266,
|
||||
-39.549617767333984,
|
||||
-280.0311279296875
|
||||
],
|
||||
[
|
||||
324.1624450683594,
|
||||
69.1881103515625,
|
||||
-81.26593780517578
|
||||
]
|
||||
],
|
||||
"degenerateTriangleCount": 0,
|
||||
"invalidIndexCount": 0,
|
||||
"meshCount": 1,
|
||||
"nonFiniteCoordinateCount": 0,
|
||||
"triangleCount": 49821,
|
||||
"vertexCount": 149463
|
||||
},
|
||||
"inputChunkChecksums": {
|
||||
"public/assets/game/arenas/black-rook-hold-arena/navigation-collision.glb": "285bdce7d5b5085eef7d25f82a5b6a5b380605a310c539e8e94984182c835749"
|
||||
},
|
||||
"inputChunks": [
|
||||
"public/assets/game/arenas/black-rook-hold-arena/navigation-collision.glb"
|
||||
],
|
||||
"output": "public/assets/game/arenas/black-rook-hold-arena/navigation-raw.glb",
|
||||
"schemaVersion": 1,
|
||||
"settings": {
|
||||
"agentHeight": 1.8,
|
||||
"agentMaxClimb": 0.5,
|
||||
"agentMaxSlope": 50,
|
||||
"agentRadius": 0.36,
|
||||
"cellHeight": 0.1,
|
||||
"cellSize": 0.2
|
||||
},
|
||||
"source": {
|
||||
"adtFiles": [
|
||||
"blackrookholdarena_28_28.adt",
|
||||
"blackrookholdarena_28_29.adt",
|
||||
"blackrookholdarena_28_30.adt",
|
||||
"blackrookholdarena_29_28.adt",
|
||||
"blackrookholdarena_29_29.adt",
|
||||
"blackrookholdarena_29_30.adt",
|
||||
"blackrookholdarena_30_28.adt",
|
||||
"blackrookholdarena_30_29.adt",
|
||||
"blackrookholdarena_30_30.adt"
|
||||
],
|
||||
"gameplayBounds": {
|
||||
"max": [
|
||||
312,
|
||||
30,
|
||||
-110
|
||||
],
|
||||
"min": [
|
||||
-82,
|
||||
-10,
|
||||
-242
|
||||
]
|
||||
},
|
||||
"kind": "adt-placed-wmo-repair",
|
||||
"legacyCollision": "public/assets/game/manastorm/1784-blackrookholdarena/collision.glb",
|
||||
"placedWmos": [
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/VALSHARAH/7VS_BLACKROOKHOLD_MICRO01.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/PVP/BUILDINGS/BLACKROOKHOLD/7VS_BLACKROOKHOLD_ARENA.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/PVP/BUILDINGS/BLACKROOKHOLD/BLACKROOKHOLD_ARENA_PLATFORMTEST.WMO"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "black-rook-hold-arena",
|
||||
"title": "Black Rook Hold Arena",
|
||||
"status": "generated-review-required",
|
||||
"source": {
|
||||
"mapDirectory": "patch-WB1/world/maps/blackrookholdarena",
|
||||
"adtFiles": [
|
||||
"blackrookholdarena_28_28.adt",
|
||||
"blackrookholdarena_28_29.adt",
|
||||
"blackrookholdarena_28_30.adt",
|
||||
"blackrookholdarena_29_28.adt",
|
||||
"blackrookholdarena_29_29.adt",
|
||||
"blackrookholdarena_29_30.adt",
|
||||
"blackrookholdarena_30_28.adt",
|
||||
"blackrookholdarena_30_29.adt",
|
||||
"blackrookholdarena_30_30.adt"
|
||||
],
|
||||
"placementCount": 3,
|
||||
"placedWmos": [
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/VALSHARAH/7VS_BLACKROOKHOLD_MICRO01.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/PVP/BUILDINGS/BLACKROOKHOLD/7VS_BLACKROOKHOLD_ARENA.WMO",
|
||||
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/PVP/BUILDINGS/BLACKROOKHOLD/BLACKROOKHOLD_ARENA_PLATFORMTEST.WMO"
|
||||
]
|
||||
},
|
||||
"coordinateSystem": {
|
||||
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
|
||||
"rebasedOrigin": [
|
||||
-117.73179244995117,
|
||||
-104.28124237060547,
|
||||
60.68927764892578
|
||||
],
|
||||
"alignmentOffset": [
|
||||
117.542488,
|
||||
-102.29949,
|
||||
-224.463489
|
||||
],
|
||||
"alignment": "primary WMO matched to the existing HealerMan stage coordinate frame"
|
||||
},
|
||||
"bounds": {
|
||||
"min": [
|
||||
-110.3111343383789,
|
||||
-102.29949188232422,
|
||||
-519.4838256835938
|
||||
],
|
||||
"max": [
|
||||
345.3961181640625,
|
||||
410.11322021484375,
|
||||
70.55683898925781
|
||||
]
|
||||
},
|
||||
"geometry": {
|
||||
"groups": 20,
|
||||
"triangles": 298817,
|
||||
"collisionTriangles": 298817,
|
||||
"collisionChunks": 4
|
||||
},
|
||||
"navigation": {
|
||||
"triangles": 4948,
|
||||
"vertices": 14844,
|
||||
"sourceComponents": 805,
|
||||
"spawnDistances": [
|
||||
0.009180110009213026,
|
||||
0.01568599673120942
|
||||
],
|
||||
"rawTriangles": 7170,
|
||||
"report": "recast-report.json"
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"file": "visual.glb",
|
||||
"bytes": 9908648,
|
||||
"sha256": "622c0832eb5a3bca85837de418b02be6dc5dad54db3b05e69e740d93c870e319"
|
||||
},
|
||||
{
|
||||
"file": "collision.glb",
|
||||
"bytes": 2059780,
|
||||
"sha256": "27a698c59e08744bd7bf6cfe9585f643a28aea45850da85020e3b4bac45ce92f"
|
||||
},
|
||||
{
|
||||
"file": "collision-001.glb",
|
||||
"bytes": 2443032,
|
||||
"sha256": "914702646d8a6a657d677bd9f69b1b0aea13f4033952c143ff85d8932127a82f"
|
||||
},
|
||||
{
|
||||
"file": "collision-002.glb",
|
||||
"bytes": 2377504,
|
||||
"sha256": "33622cbbc5e82cebee74049a53cd9fbb4e1ca8196322697f751916a3dd9d30ec"
|
||||
},
|
||||
{
|
||||
"file": "collision-003.glb",
|
||||
"bytes": 916280,
|
||||
"sha256": "2f405e27efcf4e7b4d41d2286293dcbe700d854edf4d5aa276bf5b0523e9748a"
|
||||
},
|
||||
{
|
||||
"file": "navigation.glb",
|
||||
"bytes": 238420,
|
||||
"sha256": "e463a0220842a4793b2150e95f4fd704f575def288813c781792b021afe8d3f0"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"build": {
|
||||
"attempts": [
|
||||
{
|
||||
"failures": [],
|
||||
"tileCount": 1,
|
||||
"tileSize": null,
|
||||
"valid": true
|
||||
}
|
||||
],
|
||||
"gridCells": 652881,
|
||||
"gridHeight": 849,
|
||||
"gridWidth": 769,
|
||||
"ignoredEmptyTilePackingFailures": 0,
|
||||
"intermediateRetention": null,
|
||||
"mode": "solo",
|
||||
"partition": "watershed",
|
||||
"populatedTileCount": 1,
|
||||
"tileColumns": 1,
|
||||
"tileCount": 1,
|
||||
"tileRows": 1,
|
||||
"tileSize": null
|
||||
},
|
||||
"dungeonId": "blades-edge-arena",
|
||||
"generator": "@recast-navigation/three@0.43.1",
|
||||
"geometry": {
|
||||
"bounds": [
|
||||
[
|
||||
-75.85150146484375,
|
||||
6.099999904632568,
|
||||
-80.292236328125
|
||||
],
|
||||
[
|
||||
76.14849853515625,
|
||||
82.0999984741211,
|
||||
80.3077621459961
|
||||
]
|
||||
],
|
||||
"boundsTolerance": 0.4,
|
||||
"rejectedOutOfBoundsTriangles": 0,
|
||||
"sourceTriangles": 3932,
|
||||
"sourceVertices": 11796,
|
||||
"triangles": 3932,
|
||||
"vertices": 11796
|
||||
},
|
||||
"input": {
|
||||
"bounds": [
|
||||
[
|
||||
-76.85150146484375,
|
||||
0,
|
||||
-84.8922348022461
|
||||
],
|
||||
[
|
||||
76.85150146484375,
|
||||
82.12013244628906,
|
||||
84.8922348022461
|
||||
]
|
||||
],
|
||||
"degenerateTriangleCount": 0,
|
||||
"invalidIndexCount": 0,
|
||||
"meshCount": 1,
|
||||
"nonFiniteCoordinateCount": 0,
|
||||
"triangleCount": 7327,
|
||||
"vertexCount": 7086
|
||||
},
|
||||
"inputChunkChecksums": {
|
||||
"public/assets/game/arenas/blades-edge-arena/collision.glb": "96bb151cc43fcac887cee1e573878221e14d4690e023bc4cf7b5b40c2788333a"
|
||||
},
|
||||
"inputChunks": [
|
||||
"public/assets/game/arenas/blades-edge-arena/collision.glb"
|
||||
],
|
||||
"output": "public/assets/game/arenas/blades-edge-arena/navigation-raw.glb",
|
||||
"schemaVersion": 1,
|
||||
"settings": {
|
||||
"agentHeight": 1.8,
|
||||
"agentMaxClimb": 0.5,
|
||||
"agentMaxSlope": 50,
|
||||
"agentRadius": 0.36,
|
||||
"cellHeight": 0.1,
|
||||
"cellSize": 0.2
|
||||
},
|
||||
"source": {
|
||||
"files": [
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium_000.wmo"
|
||||
],
|
||||
"kind": "direct-wmo-parser"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "blades-edge-arena",
|
||||
"title": "Blade's Edge Arena",
|
||||
"status": "generated-review-required",
|
||||
"source": {
|
||||
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium.wmo",
|
||||
"groupCount": 1
|
||||
},
|
||||
"coordinateSystem": {
|
||||
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
|
||||
"rebasedOrigin": [
|
||||
-0.32387542724609375,
|
||||
-3.3863677978515625,
|
||||
-0.1770477294921875
|
||||
]
|
||||
},
|
||||
"sourceBounds": {
|
||||
"min": [
|
||||
-77.17537689208984,
|
||||
-3.3863677978515625,
|
||||
-85.06928253173828
|
||||
],
|
||||
"max": [
|
||||
76.52762603759766,
|
||||
78.7337646484375,
|
||||
84.7151870727539
|
||||
]
|
||||
},
|
||||
"bounds": {
|
||||
"min": [
|
||||
-76.85150146484375,
|
||||
0,
|
||||
-84.8922348022461
|
||||
],
|
||||
"max": [
|
||||
76.85150146484375,
|
||||
82.12013244628906,
|
||||
84.8922348022461
|
||||
]
|
||||
},
|
||||
"geometry": {
|
||||
"groups": 1,
|
||||
"vertices": 7086,
|
||||
"triangles": 7327,
|
||||
"collisionTriangles": 7327,
|
||||
"triangleFlags": [
|
||||
{
|
||||
"group": "group-000",
|
||||
"counts": {
|
||||
"8": 680,
|
||||
"36": 616,
|
||||
"38": 137,
|
||||
"72": 2729,
|
||||
"100": 2922,
|
||||
"102": 243
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"triangles": 465,
|
||||
"vertices": 1395,
|
||||
"sourceComponents": 134,
|
||||
"spawnDistances": [
|
||||
0.0026893801684426334,
|
||||
0.004853620635976984
|
||||
],
|
||||
"rawTriangles": 3932,
|
||||
"report": "recast-report.json"
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"role": "visual",
|
||||
"file": "visual.glb",
|
||||
"bytes": 259116,
|
||||
"sha256": "5dc8c6662bccf04892b059c819de91b1134cce6011a1f707c5ee9645491e5279"
|
||||
},
|
||||
{
|
||||
"role": "collision",
|
||||
"file": "collision.glb",
|
||||
"bytes": 173784,
|
||||
"sha256": "96bb151cc43fcac887cee1e573878221e14d4690e023bc4cf7b5b40c2788333a"
|
||||
},
|
||||
{
|
||||
"role": "navigation",
|
||||
"file": "navigation.glb",
|
||||
"bytes": 23228,
|
||||
"sha256": "d99822c69df3f6363c9fab1c6c67c70c3afa4e94ed0e66fa992adf8b6724cf2b"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"build": {
|
||||
"attempts": [
|
||||
{
|
||||
"failures": [],
|
||||
"tileCount": 1,
|
||||
"tileSize": null,
|
||||
"valid": true
|
||||
}
|
||||
],
|
||||
"gridCells": 529759,
|
||||
"gridHeight": 713,
|
||||
"gridWidth": 743,
|
||||
"ignoredEmptyTilePackingFailures": 0,
|
||||
"intermediateRetention": null,
|
||||
"mode": "solo",
|
||||
"partition": "watershed",
|
||||
"populatedTileCount": 1,
|
||||
"tileColumns": 1,
|
||||
"tileCount": 1,
|
||||
"tileRows": 1,
|
||||
"tileSize": null
|
||||
},
|
||||
"dungeonId": "dalaran-sewers",
|
||||
"generator": "@recast-navigation/three@0.43.1",
|
||||
"geometry": {
|
||||
"bounds": [
|
||||
[
|
||||
-73.60513305664062,
|
||||
0.30000001192092896,
|
||||
-69.85077667236328
|
||||
],
|
||||
[
|
||||
73.5948715209961,
|
||||
79,
|
||||
70.14923095703125
|
||||
]
|
||||
],
|
||||
"boundsTolerance": 0.4,
|
||||
"rejectedOutOfBoundsTriangles": 0,
|
||||
"sourceTriangles": 4054,
|
||||
"sourceVertices": 12162,
|
||||
"triangles": 4054,
|
||||
"vertices": 12162
|
||||
},
|
||||
"input": {
|
||||
"bounds": [
|
||||
[
|
||||
-74.20513153076172,
|
||||
0,
|
||||
-71.25077819824219
|
||||
],
|
||||
[
|
||||
74.20513153076172,
|
||||
92.40137481689453,
|
||||
71.25077819824219
|
||||
]
|
||||
],
|
||||
"degenerateTriangleCount": 0,
|
||||
"invalidIndexCount": 0,
|
||||
"meshCount": 2,
|
||||
"nonFiniteCoordinateCount": 0,
|
||||
"triangleCount": 15469,
|
||||
"vertexCount": 13094
|
||||
},
|
||||
"inputChunkChecksums": {
|
||||
"public/assets/game/arenas/dalaran-sewers/collision.glb": "88c889838b76365ee0541dcce567151419b3310a6dc1252934ffb997f31215bd"
|
||||
},
|
||||
"inputChunks": [
|
||||
"public/assets/game/arenas/dalaran-sewers/collision.glb"
|
||||
],
|
||||
"output": "public/assets/game/arenas/dalaran-sewers/navigation-raw.glb",
|
||||
"schemaVersion": 1,
|
||||
"settings": {
|
||||
"agentHeight": 1.8,
|
||||
"agentMaxClimb": 0.5,
|
||||
"agentMaxSlope": 50,
|
||||
"agentRadius": 0.36,
|
||||
"cellHeight": 0.1,
|
||||
"cellSize": 0.2
|
||||
},
|
||||
"source": {
|
||||
"files": [
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena_000.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena_001.wmo"
|
||||
],
|
||||
"kind": "direct-wmo-parser"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "dalaran-sewers",
|
||||
"title": "Dalaran Sewers",
|
||||
"status": "generated-review-required",
|
||||
"source": {
|
||||
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena.wmo",
|
||||
"groupCount": 2
|
||||
},
|
||||
"coordinateSystem": {
|
||||
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
|
||||
"rebasedOrigin": [
|
||||
-9.411468505859375,
|
||||
-16.876672744750977,
|
||||
10.746021270751953
|
||||
]
|
||||
},
|
||||
"sourceBounds": {
|
||||
"min": [
|
||||
-83.6166000366211,
|
||||
-16.876672744750977,
|
||||
-60.5047607421875
|
||||
],
|
||||
"max": [
|
||||
64.79366302490234,
|
||||
75.52470397949219,
|
||||
81.9968032836914
|
||||
]
|
||||
},
|
||||
"bounds": {
|
||||
"min": [
|
||||
-74.20513153076172,
|
||||
0,
|
||||
-71.25077819824219
|
||||
],
|
||||
"max": [
|
||||
74.20513153076172,
|
||||
92.40137481689453,
|
||||
71.25077819824219
|
||||
]
|
||||
},
|
||||
"geometry": {
|
||||
"groups": 2,
|
||||
"vertices": 13094,
|
||||
"triangles": 15469,
|
||||
"collisionTriangles": 15469,
|
||||
"triangleFlags": [
|
||||
{
|
||||
"group": "group-000",
|
||||
"counts": {
|
||||
"8": 85,
|
||||
"32": 545,
|
||||
"36": 573,
|
||||
"72": 40,
|
||||
"96": 2835,
|
||||
"100": 98
|
||||
}
|
||||
},
|
||||
{
|
||||
"group": "group-001",
|
||||
"counts": {
|
||||
"8": 165,
|
||||
"10": 480,
|
||||
"32": 766,
|
||||
"36": 2362,
|
||||
"72": 136,
|
||||
"74": 416,
|
||||
"96": 2053,
|
||||
"100": 4915
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"triangles": 458,
|
||||
"vertices": 1374,
|
||||
"sourceComponents": 104,
|
||||
"spawnDistances": [
|
||||
0.004929398584727517,
|
||||
0.0049300317713031425
|
||||
],
|
||||
"rawTriangles": 4054,
|
||||
"report": "recast-report.json"
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"role": "visual",
|
||||
"file": "visual.glb",
|
||||
"bytes": 501848,
|
||||
"sha256": "91b37f5df3c5c0d2ecad9e1096177abfe8eb36acc384df36a949ce246a0155ba"
|
||||
},
|
||||
{
|
||||
"role": "collision",
|
||||
"file": "collision.glb",
|
||||
"bytes": 344140,
|
||||
"sha256": "88c889838b76365ee0541dcce567151419b3310a6dc1252934ffb997f31215bd"
|
||||
},
|
||||
{
|
||||
"role": "navigation",
|
||||
"file": "navigation.glb",
|
||||
"bytes": 22896,
|
||||
"sha256": "2e1db39fe16bd3d4a747710e1da774d841bc9cd9ada446c666a1d95a4e1aafb2"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"build": {
|
||||
"attempts": [
|
||||
{
|
||||
"failures": [],
|
||||
"tileCount": 1,
|
||||
"tileSize": null,
|
||||
"valid": true
|
||||
}
|
||||
],
|
||||
"gridCells": 605259,
|
||||
"gridHeight": 783,
|
||||
"gridWidth": 773,
|
||||
"ignoredEmptyTilePackingFailures": 0,
|
||||
"intermediateRetention": null,
|
||||
"mode": "solo",
|
||||
"partition": "watershed",
|
||||
"populatedTileCount": 1,
|
||||
"tileColumns": 1,
|
||||
"tileCount": 1,
|
||||
"tileRows": 1,
|
||||
"tileSize": null
|
||||
},
|
||||
"dungeonId": "nagrand-arena",
|
||||
"generator": "@recast-navigation/three@0.43.1",
|
||||
"geometry": {
|
||||
"bounds": [
|
||||
[
|
||||
-75.41183471679688,
|
||||
1.2000000476837158,
|
||||
-74.84174346923828
|
||||
],
|
||||
[
|
||||
75.9881591796875,
|
||||
72.80000305175781,
|
||||
65.15826416015625
|
||||
]
|
||||
],
|
||||
"boundsTolerance": 0.4,
|
||||
"rejectedOutOfBoundsTriangles": 0,
|
||||
"sourceTriangles": 3536,
|
||||
"sourceVertices": 10608,
|
||||
"triangles": 3536,
|
||||
"vertices": 10608
|
||||
},
|
||||
"input": {
|
||||
"bounds": [
|
||||
[
|
||||
-77.21183776855469,
|
||||
0,
|
||||
-78.24174499511719
|
||||
],
|
||||
[
|
||||
77.21183776855469,
|
||||
82.11756134033203,
|
||||
78.24174499511719
|
||||
]
|
||||
],
|
||||
"degenerateTriangleCount": 0,
|
||||
"invalidIndexCount": 0,
|
||||
"meshCount": 1,
|
||||
"nonFiniteCoordinateCount": 0,
|
||||
"triangleCount": 7727,
|
||||
"vertexCount": 8197
|
||||
},
|
||||
"inputChunkChecksums": {
|
||||
"public/assets/game/arenas/nagrand-arena/collision.glb": "c5ec36344bc335aad5c63607a93d6cacb535d4055c2626fed7da250855f5fa74"
|
||||
},
|
||||
"inputChunks": [
|
||||
"public/assets/game/arenas/nagrand-arena/collision.glb"
|
||||
],
|
||||
"output": "public/assets/game/arenas/nagrand-arena/navigation-raw.glb",
|
||||
"schemaVersion": 1,
|
||||
"settings": {
|
||||
"agentHeight": 1.8,
|
||||
"agentMaxClimb": 0.5,
|
||||
"agentMaxSlope": 50,
|
||||
"agentRadius": 0.36,
|
||||
"cellHeight": 0.1,
|
||||
"cellSize": 0.2
|
||||
},
|
||||
"source": {
|
||||
"files": [
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01_000.wmo"
|
||||
],
|
||||
"kind": "direct-wmo-parser"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "nagrand-arena",
|
||||
"title": "Nagrand Arena",
|
||||
"status": "generated-review-required",
|
||||
"source": {
|
||||
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01.wmo",
|
||||
"groupCount": 1
|
||||
},
|
||||
"coordinateSystem": {
|
||||
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
|
||||
"rebasedOrigin": [
|
||||
49.19449806213379,
|
||||
-10.582855224609375,
|
||||
30.535707473754883
|
||||
]
|
||||
},
|
||||
"sourceBounds": {
|
||||
"min": [
|
||||
-28.017337799072266,
|
||||
-10.582855224609375,
|
||||
-47.70603561401367
|
||||
],
|
||||
"max": [
|
||||
126.40633392333984,
|
||||
71.53470611572266,
|
||||
108.77745056152344
|
||||
]
|
||||
},
|
||||
"bounds": {
|
||||
"min": [
|
||||
-77.21183776855469,
|
||||
0,
|
||||
-78.24174499511719
|
||||
],
|
||||
"max": [
|
||||
77.21183776855469,
|
||||
82.11756134033203,
|
||||
78.24174499511719
|
||||
]
|
||||
},
|
||||
"geometry": {
|
||||
"groups": 1,
|
||||
"vertices": 8197,
|
||||
"triangles": 7727,
|
||||
"collisionTriangles": 7727,
|
||||
"triangleFlags": [
|
||||
{
|
||||
"group": "group-000",
|
||||
"counts": {
|
||||
"8": 783,
|
||||
"32": 35,
|
||||
"36": 831,
|
||||
"38": 2,
|
||||
"72": 2230,
|
||||
"96": 502,
|
||||
"100": 3291,
|
||||
"102": 53
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"triangles": 1450,
|
||||
"vertices": 4350,
|
||||
"sourceComponents": 155,
|
||||
"spawnDistances": [
|
||||
0.0025342739142059146,
|
||||
0.0025336963502397706
|
||||
],
|
||||
"rawTriangles": 3536,
|
||||
"report": "recast-report.json"
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"role": "visual",
|
||||
"file": "visual.glb",
|
||||
"bytes": 290580,
|
||||
"sha256": "6730970440c31df26e4fe90a6eb3913e6402f0027a334d966fb2b57c2e7b3e7c"
|
||||
},
|
||||
{
|
||||
"role": "collision",
|
||||
"file": "collision.glb",
|
||||
"bytes": 191916,
|
||||
"sha256": "c5ec36344bc335aad5c63607a93d6cacb535d4055c2626fed7da250855f5fa74"
|
||||
},
|
||||
{
|
||||
"role": "navigation",
|
||||
"file": "navigation.glb",
|
||||
"bytes": 70512,
|
||||
"sha256": "cd9027c8d3cf175a8879efad1eb51584f3fbce1bc2eb9355959676471e06798a"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"build": {
|
||||
"attempts": [
|
||||
{
|
||||
"failures": [],
|
||||
"tileCount": 1,
|
||||
"tileSize": null,
|
||||
"valid": true
|
||||
}
|
||||
],
|
||||
"gridCells": 2185272,
|
||||
"gridHeight": 1208,
|
||||
"gridWidth": 1809,
|
||||
"ignoredEmptyTilePackingFailures": 0,
|
||||
"intermediateRetention": null,
|
||||
"mode": "solo",
|
||||
"partition": "watershed",
|
||||
"populatedTileCount": 1,
|
||||
"tileColumns": 1,
|
||||
"tileCount": 1,
|
||||
"tileRows": 1,
|
||||
"tileSize": null
|
||||
},
|
||||
"dungeonId": "ring-of-valor",
|
||||
"generator": "@recast-navigation/three@0.43.1",
|
||||
"geometry": {
|
||||
"bounds": [
|
||||
[
|
||||
-180.21835327148438,
|
||||
0.10000000149011612,
|
||||
-117.37078094482422
|
||||
],
|
||||
[
|
||||
180.181640625,
|
||||
100.80000305175781,
|
||||
120.02922058105469
|
||||
]
|
||||
],
|
||||
"boundsTolerance": 0.4,
|
||||
"rejectedOutOfBoundsTriangles": 0,
|
||||
"sourceTriangles": 4405,
|
||||
"sourceVertices": 13215,
|
||||
"triangles": 4405,
|
||||
"vertices": 13215
|
||||
},
|
||||
"input": {
|
||||
"bounds": [
|
||||
[
|
||||
-180.818359375,
|
||||
0,
|
||||
-120.77078247070312
|
||||
],
|
||||
[
|
||||
180.818359375,
|
||||
116.26216125488281,
|
||||
120.77078247070312
|
||||
]
|
||||
],
|
||||
"degenerateTriangleCount": 0,
|
||||
"invalidIndexCount": 0,
|
||||
"meshCount": 1,
|
||||
"nonFiniteCoordinateCount": 0,
|
||||
"triangleCount": 9713,
|
||||
"vertexCount": 12167
|
||||
},
|
||||
"inputChunkChecksums": {
|
||||
"public/assets/game/arenas/ring-of-valor/collision.glb": "25162e36ed6fea03d6de939cf4d742bc142fbebb2db1c9fbc6f2fcc9e15cffdf"
|
||||
},
|
||||
"inputChunks": [
|
||||
"public/assets/game/arenas/ring-of-valor/collision.glb"
|
||||
],
|
||||
"output": "public/assets/game/arenas/ring-of-valor/navigation-raw.glb",
|
||||
"schemaVersion": 1,
|
||||
"settings": {
|
||||
"agentHeight": 1.8,
|
||||
"agentMaxClimb": 0.5,
|
||||
"agentMaxSlope": 50,
|
||||
"agentRadius": 0.36,
|
||||
"cellHeight": 0.1,
|
||||
"cellSize": 0.2
|
||||
},
|
||||
"source": {
|
||||
"files": [
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena_000.wmo"
|
||||
],
|
||||
"kind": "direct-wmo-parser"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "ring-of-valor",
|
||||
"title": "Ring of Valor",
|
||||
"status": "generated-review-required",
|
||||
"source": {
|
||||
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena.wmo",
|
||||
"groupCount": 1
|
||||
},
|
||||
"coordinateSystem": {
|
||||
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
|
||||
"rebasedOrigin": [
|
||||
-0.44493865966796875,
|
||||
-32.01484298706055,
|
||||
0
|
||||
]
|
||||
},
|
||||
"sourceBounds": {
|
||||
"min": [
|
||||
-181.26329040527344,
|
||||
-32.01484298706055,
|
||||
-120.77078247070312
|
||||
],
|
||||
"max": [
|
||||
180.3734130859375,
|
||||
84.24732208251953,
|
||||
120.77078247070312
|
||||
]
|
||||
},
|
||||
"bounds": {
|
||||
"min": [
|
||||
-180.818359375,
|
||||
0,
|
||||
-120.77078247070312
|
||||
],
|
||||
"max": [
|
||||
180.818359375,
|
||||
116.26216125488281,
|
||||
120.77078247070312
|
||||
]
|
||||
},
|
||||
"geometry": {
|
||||
"groups": 1,
|
||||
"vertices": 12167,
|
||||
"triangles": 9713,
|
||||
"collisionTriangles": 9713,
|
||||
"triangleFlags": [
|
||||
{
|
||||
"group": "group-000",
|
||||
"counts": {
|
||||
"8": 18,
|
||||
"32": 1063,
|
||||
"36": 590,
|
||||
"38": 586,
|
||||
"72": 126,
|
||||
"96": 1307,
|
||||
"100": 5028,
|
||||
"102": 995
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"triangles": 702,
|
||||
"vertices": 2106,
|
||||
"sourceComponents": 61,
|
||||
"spawnDistances": [
|
||||
1.1157695927091633,
|
||||
1.258558140579426
|
||||
],
|
||||
"rawTriangles": 4405,
|
||||
"report": "recast-report.json"
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"role": "visual",
|
||||
"file": "visual.glb",
|
||||
"bytes": 409692,
|
||||
"sha256": "32d3dc93c8f5026d29f3e9bf2efb299145e8e191e358b201a62dc987e0fa3c46"
|
||||
},
|
||||
{
|
||||
"role": "collision",
|
||||
"file": "collision.glb",
|
||||
"bytes": 263388,
|
||||
"sha256": "25162e36ed6fea03d6de939cf4d742bc142fbebb2db1c9fbc6f2fcc9e15cffdf"
|
||||
},
|
||||
{
|
||||
"role": "navigation",
|
||||
"file": "navigation.glb",
|
||||
"bytes": 34608,
|
||||
"sha256": "db986ef21767aecf08edb435f11674a094996213685f83cd7d2172886949374d"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"build": {
|
||||
"attempts": [
|
||||
{
|
||||
"failures": [],
|
||||
"tileCount": 1,
|
||||
"tileSize": null,
|
||||
"valid": true
|
||||
}
|
||||
],
|
||||
"gridCells": 1226343,
|
||||
"gridHeight": 1213,
|
||||
"gridWidth": 1011,
|
||||
"ignoredEmptyTilePackingFailures": 0,
|
||||
"intermediateRetention": null,
|
||||
"mode": "solo",
|
||||
"partition": "watershed",
|
||||
"populatedTileCount": 1,
|
||||
"tileColumns": 1,
|
||||
"tileCount": 1,
|
||||
"tileRows": 1,
|
||||
"tileSize": null
|
||||
},
|
||||
"dungeonId": "ruins-of-lordaeron",
|
||||
"generator": "@recast-navigation/three@0.43.1",
|
||||
"geometry": {
|
||||
"bounds": [
|
||||
[
|
||||
-100.48125457763672,
|
||||
3.5,
|
||||
-119.45735168457031
|
||||
],
|
||||
[
|
||||
96.71875,
|
||||
77.70000457763672,
|
||||
120.54264831542969
|
||||
]
|
||||
],
|
||||
"boundsTolerance": 0.4,
|
||||
"rejectedOutOfBoundsTriangles": 0,
|
||||
"sourceTriangles": 7958,
|
||||
"sourceVertices": 23874,
|
||||
"triangles": 7958,
|
||||
"vertices": 23874
|
||||
},
|
||||
"input": {
|
||||
"bounds": [
|
||||
[
|
||||
-101.08125305175781,
|
||||
0,
|
||||
-121.25735473632812
|
||||
],
|
||||
[
|
||||
101.08125305175781,
|
||||
77.79415893554688,
|
||||
121.25735473632812
|
||||
]
|
||||
],
|
||||
"degenerateTriangleCount": 0,
|
||||
"invalidIndexCount": 0,
|
||||
"meshCount": 5,
|
||||
"nonFiniteCoordinateCount": 0,
|
||||
"triangleCount": 23642,
|
||||
"vertexCount": 26192
|
||||
},
|
||||
"inputChunkChecksums": {
|
||||
"public/assets/game/arenas/ruins-of-lordaeron/collision.glb": "a844092b66cbfcc8ae430879de3ce7a1e6441c2cfc69328dc2cb259e4cb6c645"
|
||||
},
|
||||
"inputChunks": [
|
||||
"public/assets/game/arenas/ruins-of-lordaeron/collision.glb"
|
||||
],
|
||||
"output": "public/assets/game/arenas/ruins-of-lordaeron/navigation-raw.glb",
|
||||
"schemaVersion": 1,
|
||||
"settings": {
|
||||
"agentHeight": 1.8,
|
||||
"agentMaxClimb": 0.5,
|
||||
"agentMaxSlope": 50,
|
||||
"agentRadius": 0.36,
|
||||
"cellHeight": 0.1,
|
||||
"cellSize": 0.2
|
||||
},
|
||||
"source": {
|
||||
"files": [
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_000.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_001.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_002.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_003.wmo",
|
||||
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_004.wmo"
|
||||
],
|
||||
"kind": "direct-wmo-parser"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "ruins-of-lordaeron",
|
||||
"title": "Ruins of Lordaeron",
|
||||
"status": "generated-review-required",
|
||||
"source": {
|
||||
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena.wmo",
|
||||
"groupCount": 5
|
||||
},
|
||||
"coordinateSystem": {
|
||||
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
|
||||
"rebasedOrigin": [
|
||||
4.71783447265625,
|
||||
-31.21632957458496,
|
||||
15.956310272216797
|
||||
]
|
||||
},
|
||||
"sourceBounds": {
|
||||
"min": [
|
||||
-96.36341857910156,
|
||||
-31.21632957458496,
|
||||
-105.3010482788086
|
||||
],
|
||||
"max": [
|
||||
105.79908752441406,
|
||||
46.57782745361328,
|
||||
137.2136688232422
|
||||
]
|
||||
},
|
||||
"bounds": {
|
||||
"min": [
|
||||
-101.08125305175781,
|
||||
0,
|
||||
-121.25735473632812
|
||||
],
|
||||
"max": [
|
||||
101.08125305175781,
|
||||
77.79415893554688,
|
||||
121.25735473632812
|
||||
]
|
||||
},
|
||||
"geometry": {
|
||||
"groups": 5,
|
||||
"vertices": 26192,
|
||||
"triangles": 23642,
|
||||
"collisionTriangles": 23642,
|
||||
"triangleFlags": [
|
||||
{
|
||||
"group": "group-000",
|
||||
"counts": {
|
||||
"8": 1135,
|
||||
"36": 3730,
|
||||
"72": 730,
|
||||
"100": 10270
|
||||
}
|
||||
},
|
||||
{
|
||||
"group": "group-001",
|
||||
"counts": {
|
||||
"8": 1,
|
||||
"9": 2,
|
||||
"32": 106,
|
||||
"33": 22,
|
||||
"36": 21,
|
||||
"37": 14,
|
||||
"72": 275,
|
||||
"73": 47,
|
||||
"96": 225,
|
||||
"97": 236,
|
||||
"100": 2550,
|
||||
"101": 117
|
||||
}
|
||||
},
|
||||
{
|
||||
"group": "group-002",
|
||||
"counts": {
|
||||
"32": 21,
|
||||
"96": 132
|
||||
}
|
||||
},
|
||||
{
|
||||
"group": "group-003",
|
||||
"counts": {
|
||||
"8": 13,
|
||||
"9": 2,
|
||||
"32": 185,
|
||||
"33": 22,
|
||||
"36": 53,
|
||||
"37": 14,
|
||||
"72": 252,
|
||||
"73": 56,
|
||||
"96": 276,
|
||||
"97": 229,
|
||||
"100": 2579,
|
||||
"101": 174
|
||||
}
|
||||
},
|
||||
{
|
||||
"group": "group-004",
|
||||
"counts": {
|
||||
"36": 21,
|
||||
"100": 132
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"triangles": 2085,
|
||||
"vertices": 6255,
|
||||
"sourceComponents": 157,
|
||||
"spawnDistances": [
|
||||
0.00292914523132256,
|
||||
0.004205306248988376
|
||||
],
|
||||
"rawTriangles": 7958,
|
||||
"report": "recast-report.json"
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"role": "visual",
|
||||
"file": "visual.glb",
|
||||
"bytes": 916788,
|
||||
"sha256": "b00bcd87bd72bbabcf3e7e7edab99e8f7ddbe820b849a40df90510096e9f2f4e"
|
||||
},
|
||||
{
|
||||
"role": "collision",
|
||||
"file": "collision.glb",
|
||||
"bytes": 601048,
|
||||
"sha256": "a844092b66cbfcc8ae430879de3ce7a1e6441c2cfc69328dc2cb259e4cb6c645"
|
||||
},
|
||||
{
|
||||
"role": "navigation",
|
||||
"file": "navigation.glb",
|
||||
"bytes": 100996,
|
||||
"sha256": "be7aa6c4822f0baecafe4eb148a5952d3ce8001c7617f351e7ab257d61e7cb59"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "warnorken-arena",
|
||||
"title": "Warnorken Arena",
|
||||
"status": "validated",
|
||||
"source": {
|
||||
"kind": "validated-runewaker-package",
|
||||
"navigationSource": "navigation-source.glb"
|
||||
},
|
||||
"navigation": {
|
||||
"sourceComponents": 96,
|
||||
"triangles": 365,
|
||||
"vertices": 1095,
|
||||
"spawnDistances": [
|
||||
0.00402499431590021,
|
||||
0.004012153291366225
|
||||
]
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"file": "visual.glb",
|
||||
"bytes": 3752136,
|
||||
"triangles": 55200,
|
||||
"sha256": "c452a843c2e4123d4fe0d20da41558f5b5a1906548b3d091dc691ffc42ed19d9"
|
||||
},
|
||||
{
|
||||
"file": "collision.glb",
|
||||
"bytes": 3005760,
|
||||
"triangles": 60751,
|
||||
"sha256": "0b1ce7555da0d2900465e3539e1bf17ac244d23a0f5eb57a2106f29570757c5c"
|
||||
},
|
||||
{
|
||||
"file": "navigation.glb",
|
||||
"bytes": 18436,
|
||||
"triangles": 365,
|
||||
"sha256": "c6085ffc96b40b1f437b8bb7e111604c135a856b02fd9b6ccae20243183cf417"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.15",
|
||||
"versionCode": 1015
|
||||
"versionName": "0.1.21",
|
||||
"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"),
|
||||
logicalPrefix: "assets/creatures/wailing-caverns",
|
||||
},
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
|
||||
logicalPrefix: "assets/game/arenas",
|
||||
},
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
|
||||
logicalPrefix: "assets/game/dungeons/wailing-caverns",
|
||||
|
||||
@@ -33,6 +33,10 @@ const sources = [
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
|
||||
logicalPrefix: "assets/creatures/wailing-caverns",
|
||||
},
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
|
||||
logicalPrefix: "assets/game/arenas",
|
||||
},
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
|
||||
logicalPrefix: "assets/game/dungeons/wailing-caverns",
|
||||
|
||||
@@ -20,6 +20,7 @@ export const BUNDLED_ANDROID_PACKS = Object.freeze([
|
||||
"core:equipment",
|
||||
"core:character-basic",
|
||||
"core:runtime",
|
||||
"core:arenas",
|
||||
"dungeon:wailing-caverns",
|
||||
]);
|
||||
|
||||
@@ -79,6 +80,7 @@ function packLabel(packId) {
|
||||
if (packId === "core:equipment") return "Starter Equipment";
|
||||
if (packId === "core:character-basic") return "Basic Human Character Model";
|
||||
if (packId === "core:runtime") return "Game Runtime";
|
||||
if (packId === "core:arenas") return "Rated Arenas";
|
||||
if (packId === "core:combat-media") return "Original Spell Effects & Sounds";
|
||||
return name;
|
||||
}
|
||||
@@ -105,6 +107,7 @@ export function contentPackForPath(logicalPath) {
|
||||
if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) {
|
||||
return `dungeon:${segments[3]}`;
|
||||
}
|
||||
if (segments[1] === "game" && segments[2] === "arenas") return "core:arenas";
|
||||
if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) {
|
||||
return `manastorm:${segments[3]}`;
|
||||
}
|
||||
|
||||
@@ -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/orc/male/rig.glb"), "characters:wow335a-v1");
|
||||
assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1");
|
||||
assert.equal(contentPackForPath("assets/game/arenas/nagrand-arena/visual.glb"), "core:arenas");
|
||||
assert.equal(BUNDLED_ANDROID_PACKS.includes("core:arenas"), true);
|
||||
assert.equal(contentPackForPath("assets/game/combat-effects/audio/smite.wav"), "core:combat-media");
|
||||
assert.equal(
|
||||
contentPackForPath("assets/game/combat-effects/audio/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"),
|
||||
|
||||
@@ -116,10 +116,12 @@ function reportMarkdown(report) {
|
||||
dungeon.proceduralModels, "|", dungeon.status, "|",
|
||||
].join(" "));
|
||||
return [
|
||||
"# WoW dungeon content report",
|
||||
"# WoW dungeon combat and animation report",
|
||||
"",
|
||||
"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
|
||||
+ " mobs, " + report.summary.bosses + " bosses, " + report.summary.attacks
|
||||
+ " executable attacks, and " + report.summary.specialAttacks + " special attacks/mechanics.",
|
||||
@@ -177,6 +179,10 @@ for (const dungeon of definitions) {
|
||||
let specialAttacks = 0;
|
||||
let nativeModels = 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 (!bosses.length) dungeonIssues.push("has no bosses.");
|
||||
@@ -203,7 +209,7 @@ for (const dungeon of definitions) {
|
||||
attacks += entityAttacks.length;
|
||||
const specials = entityAttacks.filter((attack) => attack.id !== "basic-attack");
|
||||
specialAttacks += specials.length;
|
||||
if (entity.kind === "boss" && !specials.length) {
|
||||
if (entity.kind === "boss" && !specials.length && !optionalBossEntityIds.has(entityId)) {
|
||||
const exceptionKey = dungeon.id + ":" + entityId;
|
||||
if (!basicOnlyBossExceptions.has(exceptionKey)) {
|
||||
dungeonIssues.push(entityId + " has no boss mechanic beyond its basic attack.");
|
||||
@@ -297,7 +303,7 @@ const summary = dungeons.reduce((result, dungeon) => ({
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
status: issues.length ? "blocked" : "green",
|
||||
scope: "HealerMan WoW five-player campaign",
|
||||
scope: "HealerMan WoW five-player combat and animation coverage",
|
||||
summary,
|
||||
documentedExceptions,
|
||||
animationFallbacks,
|
||||
@@ -317,4 +323,4 @@ console.log(JSON.stringify({
|
||||
documentedExceptions: report.documentedExceptions,
|
||||
issues: report.issues,
|
||||
}, 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;
|
||||
}
|
||||
|
||||
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) {
|
||||
const seenIds = new Set();
|
||||
const seenSpellIds = new Set();
|
||||
@@ -1233,6 +1245,7 @@ async function compile() {
|
||||
const id = encounter.eventOnly
|
||||
? "mutanus-spawn"
|
||||
: candidate?.id ?? `encounter-${encounter.encounterId}`;
|
||||
const spawnChance = initializationSpawnChance(encounter.creatureId, azerothCoreWorldAi);
|
||||
usedBossSpawnIds.add(id);
|
||||
return {
|
||||
id,
|
||||
@@ -1240,6 +1253,7 @@ async function compile() {
|
||||
name: encounter.name,
|
||||
position,
|
||||
yaw: candidate?.yaw ?? 0,
|
||||
...(spawnChance < 1 ? { optional: true } : {}),
|
||||
...(encounter.eventOnly ? { eventOnly: true } : {}),
|
||||
};
|
||||
});
|
||||
@@ -1248,6 +1262,7 @@ async function compile() {
|
||||
const roamingPacks = [];
|
||||
for (const spawn of sourceSpawns) {
|
||||
const entry = Number(spawn.entityId.replace("entry-", ""));
|
||||
const spawnChance = initializationSpawnChance(entry, azerothCoreWorldAi);
|
||||
const isBoss = bossEntries.has(entry);
|
||||
const transformedPosition = mapPosition(spawn.position);
|
||||
if (!isBoss && spawn.waypoints?.length >= 2) {
|
||||
@@ -1269,6 +1284,7 @@ async function compile() {
|
||||
pathId: spawn.pathId,
|
||||
formationSource: "database-solo",
|
||||
spawnMask: spawn.spawnMask,
|
||||
...(spawnChance < 1 ? { spawnChance } : {}),
|
||||
waypoints,
|
||||
members: [{
|
||||
id: "member",
|
||||
@@ -1286,6 +1302,7 @@ async function compile() {
|
||||
position: transformedPosition,
|
||||
yaw: spawn.yaw,
|
||||
spawnMask: spawn.spawnMask,
|
||||
...(spawnChance < 1 ? { spawnChance } : {}),
|
||||
});
|
||||
}
|
||||
for (const boss of bossObjectives) {
|
||||
@@ -1337,10 +1354,11 @@ async function compile() {
|
||||
...roamingPacks.flatMap((pack) => pack.waypoints),
|
||||
];
|
||||
const bounds = finiteBounds(allPoints);
|
||||
const bosses = bossObjectives.map(({ id, name, position, eventOnly }) => ({
|
||||
const bosses = bossObjectives.map(({ id, name, position, optional, eventOnly }) => ({
|
||||
id,
|
||||
name,
|
||||
position,
|
||||
...(optional ? { optional: true } : {}),
|
||||
...(eventOnly ? { eventOnly: true } : {}),
|
||||
}));
|
||||
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 { Vector3 } from "three";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
import {
|
||||
exists,
|
||||
fixturesRoot,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
import {
|
||||
nextTiledNavMeshSize,
|
||||
planNavigationBuild,
|
||||
shouldRetryFailedTiledBuildWithMonotone,
|
||||
shouldUseMonotonePartition,
|
||||
validateTiledBuild,
|
||||
} from "./recast-policy.mjs";
|
||||
@@ -39,9 +41,10 @@ globalThis.ProgressEvent ??= class ProgressEvent {
|
||||
globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} });
|
||||
|
||||
async function loadScene(file) {
|
||||
await MeshoptDecoder.ready;
|
||||
const bytes = await readFile(file);
|
||||
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
return new Promise((resolve, reject) => new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject));
|
||||
return new Promise((resolve, reject) => new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).parse(buffer, "", (gltf) => resolve(gltf.scene), reject));
|
||||
}
|
||||
|
||||
function meshesIn(scene) {
|
||||
@@ -273,9 +276,11 @@ export async function bakeNavigationFromCollisionAssets({
|
||||
const meshes = scenes.flatMap(meshesIn);
|
||||
if (!meshes.length) throw new Error(`${slug}: collision chunks contain no triangle meshes.`);
|
||||
const input = inspectNavigationInput(meshes);
|
||||
let build = planNavigationBuild(input.bounds, settings.cellSize, {
|
||||
tileSize: settings.tileSize,
|
||||
const navigationPlan = (tileSize) => planNavigationBuild(input.bounds, settings.cellSize, {
|
||||
tileSize,
|
||||
...(settings.forceTiled === true ? { maxSoloAxis: 0, maxSoloCells: 0 } : {}),
|
||||
});
|
||||
let build = navigationPlan(settings.tileSize);
|
||||
const generatorConfig = {
|
||||
cs: settings.cellSize,
|
||||
ch: settings.cellHeight,
|
||||
@@ -323,7 +328,20 @@ export async function bakeNavigationFromCollisionAssets({
|
||||
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, {
|
||||
errorCategory: Recast.RC_LOG_ERROR,
|
||||
warningCategory: Recast.RC_LOG_WARNING,
|
||||
@@ -349,9 +367,7 @@ export async function bakeNavigationFromCollisionAssets({
|
||||
releaseTiledIntermediates(generated.intermediates);
|
||||
generated.navMesh.destroy();
|
||||
generated = null;
|
||||
build = planNavigationBuild(input.bounds, settings.cellSize, {
|
||||
tileSize: nextTileSize,
|
||||
});
|
||||
build = navigationPlan(nextTileSize);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
@@ -362,9 +378,7 @@ export async function bakeNavigationFromCollisionAssets({
|
||||
generated.navMesh.destroy();
|
||||
generated = null;
|
||||
tiledPartition = "monotone";
|
||||
build = planNavigationBuild(input.bounds, settings.cellSize, {
|
||||
tileSize: settings.tileSize,
|
||||
});
|
||||
build = navigationPlan(settings.tileSize);
|
||||
continue;
|
||||
}
|
||||
const sample = tiledValidation.failures
|
||||
|
||||
@@ -146,3 +146,8 @@ export function shouldUseMonotonePartition(validation) {
|
||||
(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)),
|
||||
{ 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({
|
||||
file: file.file,
|
||||
errors: report.issues.numErrors,
|
||||
warnings: report.issues.numWarnings,
|
||||
errors: actionable.filter((issue) => issue.severity === 0).length,
|
||||
warnings: actionable.filter((issue) => issue.severity === 1).length,
|
||||
ignoredKnownToolingIssues: validatorMessages.length - actionable.length,
|
||||
});
|
||||
}
|
||||
const result = {
|
||||
files: rows.length,
|
||||
errors: rows.reduce((sum, row) => sum + row.errors, 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),
|
||||
};
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
@@ -47,15 +47,23 @@ reviewed mechanics passes.
|
||||
|
||||
## Coordinate conversion
|
||||
|
||||
RuneWaker server positions are world coordinates. The environment ROS is
|
||||
placed by one WDB descriptor. Spawns must be moved into that ROS's local frame
|
||||
before the RuneWaker-to-Three coordinate conversion:
|
||||
RuneWaker server positions are world coordinates. The reviewed primary WDB
|
||||
descriptor remains the coordinate anchor, while every static and reviewed
|
||||
dynamic WDB resource is assembled relative to that anchor. Spawns use:
|
||||
|
||||
```text
|
||||
sourceLocal = inverse(primaryWdbPlacement) * serverPosition
|
||||
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
|
||||
authoritative test-dungeon table uses 360 degrees per turn:
|
||||
|
||||
@@ -283,12 +291,17 @@ Review:
|
||||
- collision chunks for decorative blockers;
|
||||
- Recast report and entrance projection;
|
||||
- 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.
|
||||
|
||||
Secondary WDB doodads/effects remain audited. Repeated primary room placements
|
||||
and documented source-coordinate support/fallback geometry are automated; a
|
||||
complete arbitrary multi-ROS WDB scene still requires explicit placement,
|
||||
material, collision, and performance review.
|
||||
Every valid WDB ROS resource is exported once into
|
||||
`runewaker-export-work/resource-cache/<resource-key>`, then reused for all of its
|
||||
placements and across dungeon builds. Missing ROS files, non-ROS descriptors,
|
||||
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
|
||||
|
||||
@@ -401,6 +414,13 @@ Each mechanic must specify trigger, targets, timing, reset behavior, persistence
|
||||
offline behavior, and multiplayer synchronization boundary before being marked
|
||||
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
|
||||
|
||||
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. |
|
||||
| `src/game/generated/*Population.generated.ts` | Runtime entities/spawns/objectives. |
|
||||
| `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
|
||||
without SQL or network access.
|
||||
@@ -462,13 +484,13 @@ without SQL or network access.
|
||||
|
||||
## 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
|
||||
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
|
||||
code can be safely translated without review. Disconnected navmesh islands,
|
||||
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
|
||||
implemented and playtested.
|
||||
@@ -3,11 +3,12 @@
|
||||
This pipeline converts preserved RuneWaker `.ros` dungeon assets into the same
|
||||
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;
|
||||
2. Microsoft `texconv` decodes the legacy DDS textures to PNG;
|
||||
3. Blender converts the Y-up source to textured visual and chunked collision GLBs;
|
||||
4. glTF Transform optimizes the visual asset with meshopt;
|
||||
5. HealerMan's Recast stage bakes navigation and the Khronos validator gates every GLB.
|
||||
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 each cached resource's legacy DDS textures to PNG;
|
||||
3. Blender converts the Y-up resources to reusable visual and collision GLBs;
|
||||
4. the assembler applies every placement's full translation/quaternion/scale transform relative to the reviewed primary WDB anchor;
|
||||
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:
|
||||
|
||||
@@ -24,10 +25,29 @@ Optional environment variables:
|
||||
|
||||
The shipping environment is written to
|
||||
`src/assets/game/dungeons/forsaken-abbey` and registered as a playable
|
||||
dungeon. The build reads all 184 WDB entity descriptors,
|
||||
uses the primary ROS placement as the server-to-local anchor, reads the entrance
|
||||
from `Resource/luascript/03242.lua`, and requires that entrance to project within
|
||||
2 meters of the generated navmesh.
|
||||
dungeon. The build reads all 184 WDB entity descriptors, uses the primary ROS
|
||||
placement only as the server-to-local coordinate anchor, reads the entrance from
|
||||
`Resource/luascript/03242.lua`, and requires that entrance to project within 2
|
||||
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:
|
||||
|
||||
@@ -36,6 +56,15 @@ npm run runewaker:forsaken:actors
|
||||
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
|
||||
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
|
||||
attack. Buffs, healing, summons, plot hooks, and unknown effects remain
|
||||
evidence-only. The portable mapping preserves source spell identity, name,
|
||||
damage family, school, range, and cooldown. The current 0.85 damage coefficient
|
||||
is HealerMan-authored and is not represented as the original RuneWaker formula.
|
||||
damage family, school, range, cooldown, source power fields, and source-code
|
||||
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
|
||||
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
|
||||
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:
|
||||
|
||||
~~~powershell
|
||||
|
||||
@@ -13,7 +13,7 @@ Required top-level fields:
|
||||
- `title`: display/source title.
|
||||
- `source`: preserved WDB, primary ROS, config, and entrance evidence.
|
||||
- `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.
|
||||
- `exceptions`: known limitations retained in metadata.
|
||||
|
||||
@@ -27,6 +27,25 @@ Required top-level fields:
|
||||
- `entryLua` / `entryConstantPrefix`: authoritative entry constants; or
|
||||
- `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
|
||||
|
||||
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",
|
||||
"clustering/aggro",
|
||||
"combat/death state",
|
||||
"loot policy",
|
||||
"loot item stats/slots",
|
||||
"objective ordering",
|
||||
"procedural animation"
|
||||
],
|
||||
@@ -74,7 +74,8 @@
|
||||
"patrols",
|
||||
"interactions",
|
||||
"encounter resets",
|
||||
"source loot"
|
||||
"blocked source-loot predicates",
|
||||
"source item stat/slot mapping"
|
||||
],
|
||||
"blockerGroups": {
|
||||
"navigation": {
|
||||
@@ -245,10 +246,20 @@
|
||||
"safety": {
|
||||
"runtimeSqlDependency": 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."
|
||||
},
|
||||
"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": [
|
||||
{
|
||||
"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.",
|
||||
"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.",
|
||||
"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."
|
||||
],
|
||||
"dungeons": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# RuneWaker gameplay content audit
|
||||
|
||||
Structural validation: **green**
|
||||
Structural validation: **green**
|
||||
Original-mechanics fidelity: **review-required**
|
||||
|
||||
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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -30,10 +31,12 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
|
||||
- 436 entities use generated Basic Attack/Corrosive Slam.
|
||||
- 0 attacks are builder exceptions outside the modular boss catalog.
|
||||
- 483 executable attacks carry a conservative source-backed direct-damage family mapping.
|
||||
- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;
|
||||
HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.
|
||||
- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.
|
||||
- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.
|
||||
- Source-backed mapping preserves spell identity, damage family, school, range, cooldown,
|
||||
source power fields, and the exact native formula shape/provenance.
|
||||
- Damage execution uses a labeled HealerMan baseline adapter because native ATK/MATK,
|
||||
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,
|
||||
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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -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
|
||||
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 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
@@ -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 | 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 |
|
||||
| raksha-temple | entrance -> Shadoj | missing-primary-environment-geometry-source-support-pad | 41/0m | 165/0m | 0.203m -> 200.079m |
|
||||
| the-origin | entrance -> Life Leecher | missing-primary-environment-geometry-source-support-pad | 106/0m | 302/0m | 466.405m -> 234.143m |
|
||||
| the-origin | Life Leecher -> Holy Tree Falynum | missing-primary-environment-geometry-source-support-pad | 302/0m | 305/0m | 234.143m -> 361.263m |
|
||||
| raksha-temple | entrance -> Shadoj | objective-or-entrance-outside-all-navigation | 6069/0m | 2982/7.159m | 578.214m -> 699.329m |
|
||||
| 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 | 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 | 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 |
|
||||
@@ -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.
|
||||
- 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.
|
||||
- raksha-temple: 167 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 1262 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.
|
||||
- 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: 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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:21
|
||||
- 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.
|
||||
- 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.
|
||||
- Remaining requirement: source-objective-review.
|
||||
- Remaining requirement: navigation-geometry-review.
|
||||
- Evidence: scripts/runewaker-pipeline/build-population.mjs:634
|
||||
- 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.
|
||||
- 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.
|
||||
- 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:36
|
||||
- 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.
|
||||
- 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: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
|
||||
|
||||
- Disposition: missing-modular-environment-assembly.
|
||||
- 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.
|
||||
- Remaining requirement: modular-environment-assembly.
|
||||
- Evidence: scripts/runewaker-pipeline/recipes/raksha-temple.json:16
|
||||
|
||||
### the-origin
|
||||
|
||||
- Disposition: missing-modular-environment-assembly.
|
||||
- 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.
|
||||
- Remaining requirement: modular-environment-assembly.
|
||||
- Remaining requirement: source-objective-review.
|
||||
- Evidence: scripts/runewaker-pipeline/recipes/the-origin.json:16
|
||||
|
||||
### treasure-trove
|
||||
|
||||
- Disposition: missing-modular-environment-plus-bridge-door-mechanics.
|
||||
- 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.
|
||||
- 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: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01159.lua:1
|
||||
- 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.
|
||||
- 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.
|
||||
- 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: ../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: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": {
|
||||
disposition: "gated-wave-and-door-review",
|
||||
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.",
|
||||
evidence: [
|
||||
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:1",
|
||||
@@ -39,6 +41,8 @@ const reviewedFindings = {
|
||||
hoto: {
|
||||
disposition: "source-order-required-before-linking",
|
||||
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.",
|
||||
evidence: [
|
||||
"scripts/runewaker-pipeline/build-population.mjs:634",
|
||||
@@ -48,6 +52,8 @@ const reviewedFindings = {
|
||||
"ice-dwarf-kingdom": {
|
||||
disposition: "door-gated-room-review",
|
||||
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.",
|
||||
evidence: [
|
||||
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:16",
|
||||
@@ -59,28 +65,41 @@ const reviewedFindings = {
|
||||
"kalins-shrine": {
|
||||
disposition: "teleport-room-mechanic",
|
||||
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: [
|
||||
"../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:1363",
|
||||
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1354",
|
||||
],
|
||||
},
|
||||
"raksha-temple": {
|
||||
disposition: "missing-modular-environment-assembly",
|
||||
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.",
|
||||
evidence: ["scripts/runewaker-pipeline/recipes/raksha-temple.json:16"],
|
||||
},
|
||||
"the-origin": {
|
||||
disposition: "missing-modular-environment-assembly",
|
||||
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.",
|
||||
evidence: ["scripts/runewaker-pipeline/recipes/the-origin.json:16"],
|
||||
},
|
||||
"treasure-trove": {
|
||||
disposition: "missing-modular-environment-plus-bridge-door-mechanics",
|
||||
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.",
|
||||
evidence: [
|
||||
"scripts/runewaker-pipeline/recipes/treasure-trove.json:16",
|
||||
@@ -92,6 +111,8 @@ const reviewedFindings = {
|
||||
"ystra-labyrinth-boss1": {
|
||||
disposition: "separate-authored-room-placements",
|
||||
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.",
|
||||
evidence: [
|
||||
"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",
|
||||
"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 |",
|
||||
"| --- | --- | --- | --- | --- | --- |",
|
||||
@@ -442,9 +465,21 @@ function renderMarkdown(report) {
|
||||
const finding = dungeon.reviewedFinding;
|
||||
lines.push(`### ${dungeon.id}`, "", `- Disposition: ${finding.disposition}.`,
|
||||
`- 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.requirements.map((entry) => `- Remaining requirement: ${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("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -474,6 +509,11 @@ for (const recipeName of recipeNames) {
|
||||
constantCase(recipe.dungeonId) + "_BOSSES",
|
||||
" as const satisfies readonly DungeonBossObjective[];",
|
||||
);
|
||||
const populationProvenance = generatedJson(
|
||||
source,
|
||||
constantCase(recipe.dungeonId) + "_POPULATION_PROVENANCE",
|
||||
" as const;",
|
||||
);
|
||||
const graph = buildPartyNavigationGraph(geometryFromPositions(positions));
|
||||
const retainedTriangleComponents = triangleComponents(graph);
|
||||
const raw = analyzeRawComponents(positions);
|
||||
@@ -519,9 +559,12 @@ for (const recipeName of recipeNames) {
|
||||
reviewedFinding: reviewedFindings[recipe.dungeonId] ?? {
|
||||
disposition: "manual-review-required",
|
||||
safeAutomaticLink: false,
|
||||
objectiveOrderStatus: "source-objective-review-required",
|
||||
requirements: ["source-objective-review"],
|
||||
finding: "No reviewed source-backed navigation disposition is recorded.",
|
||||
evidence: [],
|
||||
},
|
||||
objectiveOrdering: populationProvenance.objectiveOrder,
|
||||
rawComponents: raw.components.map((component) => ({
|
||||
id: component.id,
|
||||
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 = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
schemaVersion: 2,
|
||||
generator: "scripts/runewaker-pipeline/analyze-navigation-gaps.mjs",
|
||||
methodology: {
|
||||
weldTolerance,
|
||||
liveStartSnapDistance: 3,
|
||||
liveObjectiveSnapDistance: 6,
|
||||
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,
|
||||
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 {
|
||||
buildPartyNavigationGraph,
|
||||
} from "../../src/game/partyNavigation.ts";
|
||||
import { generateEncounterImplementationReport } from "./lib/encounter-implementation-report.mjs";
|
||||
import { auditOrderedObjectiveRoutes } from "./lib/objective-route-audit.mjs";
|
||||
|
||||
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) {
|
||||
const rows = report.dungeons.map((dungeon) => [
|
||||
"|", dungeon.id, "|", dungeon.sourceRows, "|", dungeon.runtimeSpawns, "|",
|
||||
@@ -171,7 +185,7 @@ function renderMarkdown(report) {
|
||||
return [
|
||||
"# RuneWaker gameplay content audit",
|
||||
"",
|
||||
"Structural validation: **" + report.validationStatus + "** ",
|
||||
"Structural validation: **" + report.validationStatus + "**",
|
||||
"Original-mechanics fidelity: **" + report.fidelityStatus + "**",
|
||||
"",
|
||||
"This offline audit reads packaged recipes, snapshots, generated TypeScript, reports,",
|
||||
@@ -200,6 +214,10 @@ function renderMarkdown(report) {
|
||||
"- Boss signaling includes " + summary.telegraphedBossMechanics
|
||||
+ " spatial telegraphs and " + summary.healthTriggeredBossMechanics
|
||||
+ " 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",
|
||||
"",
|
||||
@@ -215,10 +233,12 @@ function renderMarkdown(report) {
|
||||
"- " + summary.builderAuthoredAttacks + " attacks are builder exceptions outside the modular boss catalog.",
|
||||
"- " + summary.sourceBackedDamageFamilyAttacks
|
||||
+ " executable attacks carry a conservative source-backed direct-damage family mapping.",
|
||||
"- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;",
|
||||
" HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.",
|
||||
"- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.",
|
||||
"- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.",
|
||||
"- Source-backed mapping preserves spell identity, damage family, school, range, cooldown,",
|
||||
" source power fields, and the exact native formula shape/provenance.",
|
||||
"- Damage execution uses a labeled HealerMan baseline adapter because native ATK/MATK,",
|
||||
" 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,",
|
||||
" attack, cast, wound, and death families.",
|
||||
"",
|
||||
@@ -530,11 +550,16 @@ for (const recipeName of recipeNames) {
|
||||
+ executableSpellId + ".");
|
||||
} else {
|
||||
counts.sourceBackedDamageFamilyAttacks += 1;
|
||||
if (attack.spellId && [
|
||||
const runtimeMappingDrift = !catalogAuthored && [
|
||||
"name", "school", "animation", "cooldownMs", "damageMultiplier",
|
||||
].some((field) => attack[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
|
||||
+ " drifted from the portable source-backed runtime mapping.");
|
||||
}
|
||||
@@ -749,8 +774,8 @@ summary.portableDamageFamilyRecords = spellCatalogSnapshot?.damageFamilyCount ??
|
||||
summary.portableEvidenceOnlyRecords = spellCatalogSnapshot?.evidenceOnlyCount ?? 0;
|
||||
reviewNotes.push(summary.sourceGapBossMechanics
|
||||
+ " boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.");
|
||||
reviewNotes.push("Source-backed spell coefficients are HealerMan-generic; the original RuneWaker "
|
||||
+ "formula/stat scaling has not been reconstructed.");
|
||||
reviewNotes.push("Source formula structure is preserved, but live damage uses a labeled HealerMan "
|
||||
+ "baseline adapter because the required native combat-stat inputs are not packaged.");
|
||||
const navigationBlockerEntries = dungeons.flatMap((dungeon) => (
|
||||
dungeon.objectiveRouteEvidence
|
||||
.filter((route) => route.orderAuthoritative && !route.reachable)
|
||||
@@ -784,6 +809,12 @@ if (summary.derivedObjectiveRoutes) {
|
||||
+ 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 = {
|
||||
schemaVersion: 1,
|
||||
validationStatus: issues.length ? "blocked" : "green",
|
||||
@@ -797,11 +828,11 @@ const report = {
|
||||
],
|
||||
genericRuntimeSystems: [
|
||||
"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: [
|
||||
"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: {
|
||||
navigation: {
|
||||
@@ -829,6 +860,14 @@ const report = {
|
||||
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: encounterImplementation.reportJson,
|
||||
markdown: encounterImplementation.reportMarkdown,
|
||||
validationStatus: encounterImplementation.report.validationStatus,
|
||||
completionStatus: encounterImplementation.report.completionStatus,
|
||||
deferredSourceRows: encounterImplementation.report.summary.deferredSourceRows,
|
||||
uniqueMechanicsEvidence: encounterImplementation.report.summary.uniqueMechanicsEvidence,
|
||||
},
|
||||
resolvedRuntimeObservations,
|
||||
documentedExceptions,
|
||||
issues,
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
projectRoot,
|
||||
sourceRootFor,
|
||||
} from "./lib/recipe.mjs";
|
||||
import {
|
||||
assembleInstancedGlb,
|
||||
buildWdbPlacementPlan,
|
||||
normalizeWdbResourcePath,
|
||||
} from "./lib/environment-assembly.mjs";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
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 preparedDirectory = path.join(workDirectory, "prepared");
|
||||
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 sourceRoot = sourceRootFor(recipe);
|
||||
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 ktx2Compressor = path.join(projectRoot, "scripts", "asset-pipeline", "compress-ktx2.mjs");
|
||||
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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -67,6 +77,15 @@ async function requirePath(file, guidance) {
|
||||
catch { throw new Error(guidance + "\nMissing: " + file); }
|
||||
}
|
||||
|
||||
async function exists(file) {
|
||||
try {
|
||||
await stat(file);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256(file) {
|
||||
return createHash("sha256").update(await readFile(file)).digest("hex");
|
||||
}
|
||||
@@ -148,7 +167,7 @@ function sourcePointToPrimaryLocal(sourcePosition, placement) {
|
||||
|
||||
async function appendSourceAnchorSupport() {
|
||||
const support = recipe.source.sourceAnchorSupport;
|
||||
if (!support) return;
|
||||
if (!support || fullWdbAssembly) return;
|
||||
const { placement } = await primaryPlacement();
|
||||
const half = Number(support.halfExtentSourceUnits ?? 60);
|
||||
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() {
|
||||
const sourceTextures = path.join(rawDirectory, "textures");
|
||||
const preparedTextures = path.join(preparedDirectory, "textures");
|
||||
@@ -252,8 +393,208 @@ function cloneNodeTree(document, node) {
|
||||
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() {
|
||||
const translations = assemblyTranslations();
|
||||
const translations = fullWdbAssembly ? [] : assemblyTranslations();
|
||||
if (!translations.length) return;
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||
const visualFile = path.join(convertedDirectory, slug + "-visual.glb");
|
||||
@@ -385,7 +726,7 @@ async function bakeNavigation() {
|
||||
|
||||
async function appendSourceAnchorNavigation(navigationFile, reportFile) {
|
||||
const support = recipe.source.sourceAnchorSupport;
|
||||
if (!support) return;
|
||||
if (!support || fullWdbAssembly) return;
|
||||
const { placement } = await primaryPlacement();
|
||||
const half = Number(support.halfExtentSourceUnits ?? 60);
|
||||
const meters = recipe.coordinateSystem.metersPerSourceUnit;
|
||||
@@ -556,13 +897,29 @@ async function stageShippingPackage() {
|
||||
const files = [
|
||||
{ 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.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") },
|
||||
];
|
||||
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"]) {
|
||||
await copyFile(path.join(convertedDirectory, report), path.join(shippingDirectory, report));
|
||||
}
|
||||
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 { report, placement } = await primaryPlacement();
|
||||
const sourceEntrance = [server.playerSpawn.x, server.playerSpawn.y, server.playerSpawn.z];
|
||||
@@ -604,12 +961,21 @@ async function stageShippingPackage() {
|
||||
} : {}),
|
||||
server,
|
||||
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,
|
||||
entrance,
|
||||
assets: await Promise.all(files.map(async (entry) => ({
|
||||
role: entry.role,
|
||||
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,
|
||||
sha256: await sha256(entry.target),
|
||||
}))),
|
||||
@@ -634,7 +1000,8 @@ await buildNativeExporterIfNeeded();
|
||||
await exportSource();
|
||||
await prepareTextures();
|
||||
await convertEnvironment();
|
||||
await assembleRepeatedPlacements();
|
||||
if (fullWdbAssembly) await assembleAllWdbPlacements();
|
||||
else await assembleRepeatedPlacements();
|
||||
await optimizeVisual();
|
||||
await bakeNavigation();
|
||||
await stageShippingPackage();
|
||||
|
||||
@@ -460,6 +460,12 @@ function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
|
||||
initialCooldownMs: [spell.runtime.cooldownMs, spell.runtime.cooldownMs],
|
||||
repeatCooldownMs: [spell.runtime.cooldownMs, spell.runtime.cooldownMs],
|
||||
damageMultiplier: spell.runtime.damageMultiplier,
|
||||
...(spell.runtime.formula ? {
|
||||
runewakerFormula: {
|
||||
...spell.runtime.formula,
|
||||
spellLevel: Number(sourceSpell.level ?? 0),
|
||||
},
|
||||
} : {}),
|
||||
}];
|
||||
});
|
||||
const recipeAttacks = Array.isArray(template.attacks)
|
||||
@@ -471,7 +477,34 @@ function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
|
||||
const mechanics = authored?.mode === "replace"
|
||||
? authored.mechanics ?? []
|
||||
: [...(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];
|
||||
}
|
||||
|
||||
@@ -706,7 +739,7 @@ async function generatePopulation() {
|
||||
serverEntryPolicy: "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
sourceSpellCatalog: projectPath(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);
|
||||
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";
|
||||
}
|
||||
|
||||
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();
|
||||
for (const recipe of recipes) {
|
||||
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
|
||||
? sourceCooldownSeconds * 1000
|
||||
: 6500)),
|
||||
damageMultiplier: 0.85,
|
||||
damageMultiplier: formulaFor(spellId, primaryDamage).runtimeBaselineMultiplier,
|
||||
formula: formulaFor(spellId, primaryDamage),
|
||||
},
|
||||
} : {}),
|
||||
components,
|
||||
@@ -298,6 +328,8 @@ const report = {
|
||||
runtimeSqlDependency: false,
|
||||
unknownEffectsExecute: false,
|
||||
coefficientsAreSourceExact: false,
|
||||
unavailableNativeInputsDefaulted: false,
|
||||
pointValuedDamageUsesLabeledScaledAdapter: true,
|
||||
},
|
||||
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": {
|
||||
"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",
|
||||
"bossAnchors": "source-population",
|
||||
"wdbPlacements": "unique-primary-model"
|
||||
"wdbPlacements": "all-static-ros-source-transform"
|
||||
},
|
||||
"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.",
|
||||
"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 327 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user