Compare commits

..
3 Commits
Author SHA1 Message Date
phenom 46c272f889 Release Healer Man 0.1.34 2026-09-12 15:14:56 -04:00
phenom 05b25c4341 Release Healer Man 0.1.33 2026-09-12 14:25:23 -04:00
phenom 6dcb1034e5 Release Healer Man 0.1.32 2026-09-12 11:07:25 -04:00
124 changed files with 11896 additions and 943 deletions

No files matched your search

+7 -2
View File
@@ -6,7 +6,9 @@ the React/Three.js runtime, menus, local profile and save support, UI assets,
starter equipment, Wailing Caverns starter content, and the basic Human starter equipment, Wailing Caverns starter content, and the basic Human
character model with its default male and female appearances. Android also character model with its default male and female appearances. Android also
contains its native bridge. Other GLBs, textures, audio, character/skin packs, contains its native bridge. Other GLBs, textures, audio, character/skin packs,
dungeons, and Manastorm stages are published by TrueNAS as optional content. and dungeons are published by TrueNAS as optional content. Internal Manastorm
environment packs remain in the catalog only because regular dungeons reuse
them as automatic dependencies.
Single-player is local-first. Starting the game, loading a character, entering Single-player is local-first. Starting the game, loading a character, entering
installed content, simulating combat, and saving progress never require the installed content, simulating combat, and saving progress never require the
@@ -165,10 +167,13 @@ characters:wow335a-v1
characters:ascension-v1 characters:ascension-v1
characters:runewaker-v1 characters:runewaker-v1
core:combat-media core:combat-media
manastorm:<stage-id>
shared:creatures shared:creatures
``` ```
`manastorm:*` and `core:arenas` remain valid internal pack IDs for compatibility
but are hidden from the Downloads screen. Installing a regular dungeon still
installs its exact environment dependency closure automatically.
Changing one GLB changes one object hash. The client downloads that object and Changing one GLB changes one object hash. The client downloads that object and
atomically updates the pack rather than downloading every dungeon again. atomically updates the pack rather than downloading every dungeon again.
+1
View File
@@ -135,6 +135,7 @@ services:
CONTENT_DIR: /app/content CONTENT_DIR: /app/content
DATA_DIR: /app/runtime-data DATA_DIR: /app/runtime-data
HOST: 0.0.0.0 HOST: 0.0.0.0
HEALER_MAN_ENABLE_SHELVED_MODES: "0"
NODE_OPTIONS: "--max-old-space-size=4096" NODE_OPTIONS: "--max-old-space-size=4096"
PORT: "4173" PORT: "4173"
SESSION_TTL_DAYS: "30" SESSION_TTL_DAYS: "30"
+93
View File
@@ -2212,3 +2212,96 @@ Vite's existing large-chunk advisory remains informational.
Effects without source mappings retain fallback. This is representative Effects without source mappings retain fallback. This is representative
desktop browser QA, not exhaustive spell-by-spell fidelity or a physical desktop browser QA, not exhaustive spell-by-spell fidelity or a physical
Android/GPU performance test. No hosted release was published. Android/GPU performance test. No hosted release was published.
## 2026-09-12 - Stockades party routing and indoor lighting
- Fixed quantized navigation preparation: normalized integer positions now
expand to floats before the GLB node transform is baked. Previously the
Stockades world coordinates clamped into roughly [-1, 1], leaving the live
party unable to attach to its graph even when an offline route test passed.
- Added three reviewed bidirectional links across the 1.537m Recast seams at
the entrance/central stairs and the central/north and central/south stairs.
The shipping 3,538-triangle graph connects the entrance and all five boss
approaches while discarding the remaining small decorative islands.
- Added navigation-only review state and compiler-preserved runtime overrides.
Stockades navigation is green; its wider encounter, wipe/reset, checkpoint,
and completion validation remains unvalidated.
- Leader and follower formation paths now use the graph when a breadcrumb
rejoin is blocked. Fallback comfort goals and recalls attach to the reviewed
surface, preventing an off-boundary stair target from causing a false stuck
loop. Combat approach connectors respect each member's personalized range.
- Replaced the generic dark expansion palette only for Stockades with warmer,
stronger ambient/hemisphere fill and a warm directional light. No dungeon
textures or source meshes were changed, and other dungeon palettes remain
unchanged pending individual visual review.
- Live desktop browser check at 1280 x 720 used offline level-15 Priest
Rolecheck as healer with Blood DK tank Morvane, Combat Rogue Vale, Unholy DK
Thorne, and Frost Mage Ilyra. Fresh Defend mode showed PARTY 5/5, Recall on the
entrance stairs recovered without a route warning, and Attack resumed with
visible party movement down the stairs. The tank advanced into combat and
took Defias Basic Attack/Kick/Backhand aggro; Backstab and Icy Touch events
appeared while the player remained at the entrance.
- Inline task screenshots captured the warm, readable stonework/stairs and
equipped party silhouettes in Defend and Recall-to-Attack states. The final
runtime produced no new navigation errors; the existing deprecated physics
initialization warning remains. Temporary diagnostic logging was removed.
- Automated validation: all 223 Vitest files passed, 1,373 tests passed, and
six existing tests remained skipped. Regressions cover the actual quantized
Stockades GLB through the shipping asset-preparation path, normalized integer
transforms, formation projection, entrance-to-boss routing, and tank melee
approach. This is an entrance/combat smoke pass, not a complete dungeon clear
or physical Android test.
- Production TypeScript/Vite build, texture/performance audits, bundle
verification, and the final release candidate check passed. Existing
bundle-size/import advisories remain. No hosted release was published.
- The optional all-dungeon functionality audit was stopped after an extended
run without output; its existing report files were not changed. Stockades
navigation evidence comes from the actual shipping-asset regressions and
the live smoke pass above, not a new fleet-wide functionality certification.
## 2026-09-12 - Stockades stair-ramp follow-up
- Follow-up player feedback reported companions still sticking near stairs.
Actual collision profiles contain approximately 0.44-0.59m risers, above
the party sweep's 0.32m lower-leg clearance. Collision-backed regressions
reproduce uphill rejection on the original shipping collision GLB.
- Added three reviewed package-space traversal proxies for the entrance and
both prison-wing stair flights. Collision tread/riser vertices within the
reviewed stair relief are reshaped onto smooth ramp planes; upper walls,
ceilings, other floors, and geometry outside those footprints remain intact.
Source visual/collision GLBs are unchanged. Only prepared walking collision
and party navigation use the overrides; the rendered stairs remain steps.
- Navigation receives the same ramp planes with 0.05m surface clearance and
six new triangles stitching the exact source boundary portals. This replaces
the three vertex-only connections with filled triangle corridors, allowing
mid-stair replans and recalls to project onto the interior walking surface.
The reviewed graph now has 3,544 source triangles and 31 components, retaining
entrance-to-all-five-boss routes and discarding minor decorative islands.
- Live QA caught a second issue: terrain queries included the player's
kinematic capsule. A failing collision-backed regression confirmed false
route blockage through a nearby player. Party direct, route, and grounding
queries now exclude dynamic/kinematic actor bodies and sensors; crowd spacing
remains a separate party-separation concern. Static walls still block paths.
- Automated tests exercise shipping normalized/quantized GLBs through runtime
preparation, downhill/uphill party capsule checks on all three flights at
center and lateral offsets, repeated mid-stair replans, all boss routes with
live wall checks, tank melee approach with real collision validation, and
player capsule traversal down/back up all three ramps without jumping.
- Final desktop browser smoke pass at 1280 x 720 used offline level-15 Priest
Rolecheck and the same Blood DK/Combat Rogue/Unholy DK/Frost Mage party.
Attack began without ROUTE BLOCKED, companions changed position down the
entrance staircase, the tank moved out of the entrance view and took aggro,
and Backstab/Icy Touch/Plague Strike combat events appeared. Experience rose
from 6,576 to 11,098 as the party defeated trash with the player stationary.
Recall brought companions back to the entrance stairs. The unattended tank
eventually died during continued combat; this was not a healed full clear.
- Inline task screenshots captured the unchanged visible steps, equipped
character bodies, warm lighting, and party movement. Both wing flights have
collision-backed automated coverage; live end-to-end wing traversal, a full
dungeon clear, wipe/checkpoint behavior, and physical Android QA remain open.
- Validation: all 224 Vitest files passed, 1,380 tests passed, and six existing
tests remained skipped. Production TypeScript/Vite compilation passed using
the project's 8GB heap setting; texture/performance audits, static bundle
compression, and release-bundle verification passed. Existing build-size
advisories and deprecated physics initialization warnings remain. No hosted
release was published.
+11 -4
View File
@@ -41,6 +41,11 @@ npm test
npm run build npm run build
``` ```
Manastorms, Arenas/PvP, and their GM tools are shelved in normal browser and
Android builds. Their source and saved data remain intact. Use
`npm run dev:shelved` or `npm run build:shelved` for maintenance builds, and
set `HEALER_MAN_ENABLE_SHELVED_MODES=1` on the matching development server.
Boss-mechanic and dungeon-event extension points, telegraph rules, checkpoint Boss-mechanic and dungeon-event extension points, telegraph rules, checkpoint
behavior, test workflow, and the explicit source-gap ledger are documented in behavior, test workflow, and the explicit source-gap ledger are documented in
[`ENCOUNTER_AUTHORING.md`](ENCOUNTER_AUTHORING.md). [`ENCOUNTER_AUTHORING.md`](ENCOUNTER_AUTHORING.md).
@@ -50,8 +55,10 @@ behavior, test workflow, and the explicit source-gap ledger are documented in
Production browser and Android builds ship a compact required set: the game Production browser and Android builds ship a compact required set: the game
runtime, interface, starter equipment, Wailing Caverns starter content, and a runtime, interface, starter equipment, Wailing Caverns starter content, and a
basic male/female Human character model with its default appearance. Large basic male/female Human character model with its default appearance. Large
character and skin libraries, additional dungeons, Manastorm stages, and the character and skin libraries, additional dungeons, and the original
original spell-effects and sound pack are optional downloads. spell-effects and sound pack are optional downloads. Manastorm environment
packs remain internal dungeon dependencies and are installed automatically
when a regular dungeon needs one; they are not shown as standalone downloads.
Open **Downloads** from the main menu to download, update, or remove optional Open **Downloads** from the main menu to download, update, or remove optional
packs on either platform. Browser downloads live in an app-managed verified packs on either platform. Browser downloads live in an app-managed verified
@@ -223,8 +230,8 @@ and install directly onto a connected device, use `npm run android:install`.
Android uses a compact `dist-android` build with the same required-content Android uses a compact `dist-android` build with the same required-content
boundary as the production browser build. Optional dungeons, character/skin boundary as the production browser build. Optional dungeons, character/skin
packs, Manastorm stages, and original spell media are downloaded into packs, internal environment dependencies, and original spell media are
app-private storage. Installed content and local saves remain playable without downloaded into app-private storage. Installed content and local saves remain playable without
a server connection and survive APK updates. See `CONTENT_UPDATES.md` for the a server connection and survive APK updates. See `CONTENT_UPDATES.md` for the
update contract and `DEPLOYMENT.md` for TrueNAS publishing and signed Gitea update contract and `DEPLOYMENT.md` for TrueNAS publishing and signed Gitea
releases. releases.
+1
View File
@@ -657,6 +657,7 @@ services:
CONTENT_DIR: /app/content CONTENT_DIR: /app/content
DATA_DIR: /app/runtime-data DATA_DIR: /app/runtime-data
HOST: 0.0.0.0 HOST: 0.0.0.0
HEALER_MAN_ENABLE_SHELVED_MODES: "0"
NODE_OPTIONS: "--max-old-space-size=4096" NODE_OPTIONS: "--max-old-space-size=4096"
PORT: "4173" PORT: "4173"
SESSION_TTL_DAYS: "30" SESSION_TTL_DAYS: "30"
+14 -5
View File
@@ -1,9 +1,11 @@
# Lossless game content packaging # Lossless game content packaging
`npm run web:prepare` and `npm run android:prepare` include all 84 regular `npm run web:prepare` and `npm run android:prepare` include exactly five packs:
dungeon packs, the existing core packs, and the dungeons' dependency closure. `core:ui`, `core:equipment`, `core:character-basic`, `core:runtime`, and
Manastorm packs keep their optional install flag even when a regular dungeon `dungeon:wailing-caverns`. The complete publication catalog retains all 54
uses the same map. Manastorm-only maps and extra character packs stay optional. regular dungeons. Internal `manastorm:*` environment packs remain dependency
targets for the 53 regular dungeons that reuse those maps, but they and
`core:arenas` are hidden from the Downloads screen.
The staging directories contain one object per original SHA-256 and extension The staging directories contain one object per original SHA-256 and extension
under `content/bundled`. Gzip is used only when it saves space. The packager under `content/bundled`. Gzip is used only when it saves space. The packager
@@ -22,7 +24,8 @@ existing catalogs. Remote publishing retains original objects for older apps;
new clients prefer the compressed representation and verify both hashes. new clients prefer the compressed representation and verify both hashes.
The Node server and Vite preview middleware resolve bundled logical URLs on The Node server and Vite preview middleware resolve bundled logical URLs on
first launch. The browser service worker does the same after activation. first launch. The browser service worker does the same after activation and
persists each immutable bootstrap object by hash after its first use.
Android streams bundled gzip through its WebView asset handler, including the Android streams bundled gzip through its WebView asset handler, including the
companion display, without writing an expanded copy to app storage. Deploy the companion display, without writing an expanded copy to app storage. Deploy the
browser build with the project's Node server so first-launch asset aliases work. browser build with the project's Node server so first-launch asset aliases work.
@@ -38,6 +41,12 @@ unique object bytes and duplicate savings. These are packaged asset sizes, not
GPU memory estimates. Optional Android downloads occupy their expanded size; GPU memory estimates. Optional Android downloads occupy their expanded size;
bundled Android assets stay compressed inside the APK. bundled Android assets stay compressed inside the APK.
Normal release verification rejects unexpected bootstrap packs, more than 80
MiB of stored bootstrap objects, arena assets, and shelved client modules.
Eligible JavaScript, CSS, and JSON responses are precompressed with Brotli and
gzip; hashed Vite files and bundled content objects use immutable one-year
caching, while HTML, service workers, and manifests revalidate.
Validation: `npm run content:test`, the content/avatar Vitest suites, the Validation: `npm run content:test`, the content/avatar Vitest suites, the
bundled-content server tests, `npm run build`, Android compilation, and a live bundled-content server tests, `npm run build`, Android compilation, and a live
roster plus dungeon screenshot check. Run `npm run release:check` before handoff. roster plus dungeon screenshot check. Run `npm run release:check` before handoff.
File diff suppressed because it is too large. Load diff
@@ -39,11 +39,11 @@
"agentMaxSlope": 50 "agentMaxSlope": 50
}, },
"review": { "review": {
"entrance": "required", "entrance": "approved",
"bossOrder": "required", "bossOrder": "required",
"optionalEncounters": "required", "optionalEncounters": "required",
"presentation": "required", "presentation": "approved",
"offMeshLinks": "required" "offMeshLinks": "approved"
}, },
"exceptions": [] "exceptions": []
} }
@@ -0,0 +1,91 @@
{
"schemaVersion": 1,
"dungeonId": "stormwind-stockade",
"navigationLinks": [
{
"id": "entrance-to-central-stairs",
"start": [-66.674328, -23.150503, 5.501851],
"end": [-67.664446, -23.755576, 4.493397],
"bidirectional": true,
"authoredReason": "Bridges the reviewed 1.537m Recast seam between the source entrance stairs and the central cell block."
},
{
"id": "central-to-north-wing-stairs",
"start": [-124.46789, -27.349338, 16.099782],
"end": [-125.476344, -27.95441, 17.0899],
"bidirectional": true,
"authoredReason": "Bridges the reviewed 1.537m Recast seam from the central cell block to the north wing stairs."
},
{
"id": "central-to-south-wing-stairs",
"start": [-124.46789, -27.349338, -14.703893],
"end": [-125.476344, -27.95441, -15.694011],
"bidirectional": true,
"authoredReason": "Bridges the reviewed 1.537m Recast seam from the central cell block to the south wing stairs."
}
],
"traversalRamps": [
{
"id": "entrance-stair-ramp",
"start": [-52.8, -18.16, 0.8],
"end": [-72.4, -25.609, 0.8],
"width": 10.8,
"navigationBridge": {
"linkId": "entrance-to-central-stairs",
"startPortal": [[-66.674328, -23.150503, -3.904271], [-66.674328, -23.150503, 5.501851]],
"endPortal": [[-67.664446, -23.755576, -2.895818], [-67.664446, -23.755576, 4.493397]]
}
},
{
"id": "north-wing-stair-ramp",
"start": [-129.17, -25.609, 11.7],
"end": [-129.17, -33.944, 32.85],
"width": 10.8,
"navigationBridge": {
"linkId": "central-to-north-wing-stairs",
"startPortal": [[-133.878601, -27.349338, 16.099782], [-124.46789, -27.349338, 16.099782]],
"endPortal": [[-132.668457, -27.95441, 17.0899], [-125.476344, -27.95441, 17.0899]]
}
},
{
"id": "south-wing-stair-ramp",
"start": [-129.17, -25.609, -10.3],
"end": [-129.17, -33.944, -31.45],
"width": 10.8,
"navigationBridge": {
"linkId": "central-to-south-wing-stairs",
"startPortal": [[-133.878601, -27.349338, -14.703893], [-124.46789, -27.349338, -14.703893]],
"endPortal": [[-132.668457, -27.95441, -15.694011], [-125.476344, -27.95441, -15.694011]]
}
}
],
"presentation": {
"background": "#171815",
"fog": {
"color": "#1b1c18",
"near": 44
},
"ambientLight": {
"color": "#d8c9aa",
"intensity": 0.58
},
"hemisphereLight": {
"skyColor": "#cfc3a5",
"groundColor": "#292820",
"intensity": 0.86
},
"directionalLight": {
"color": "#ffe0a8",
"intensity": 1.1,
"offset": [10, 18, -5]
}
},
"validation": {
"navigation": "green",
"report": "PLAYTEST_NOTES.md",
"warnings": [
"Three reviewed smooth stair collision proxies and filled navigation bridges connect the entrance, central block, and both prison wings; minor decorative navmesh islands remain intentionally discarded.",
"Stockades uses a dungeon-specific warm indoor lighting profile because the imported WMO has no baked lightmap or vertex-lighting channel."
]
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.31", "version": "0.1.34",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.31", "version": "0.1.34",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+9 -3
View File
@@ -1,10 +1,11 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.31", "version": "0.1.34",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"dev:shelved": "vite --host 0.0.0.0 --mode shelved",
"start": "node server/server.mjs", "start": "node server/server.mjs",
"db:init": "node scripts/init-game-database.mjs", "db:init": "node scripts/init-game-database.mjs",
"db:backup": "node scripts/backup-game-database.mjs", "db:backup": "node scripts/backup-game-database.mjs",
@@ -110,9 +111,12 @@
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs", "assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
"balance:benchmark": "vite-node scripts/combat-benchmark/run.ts", "balance:benchmark": "vite-node scripts/combat-benchmark/run.ts",
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2", "balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
"prebuild": "npm run loot:generate && npm run arena:abilities:build && npm run web:prepare && npm run arena:assets:validate && npm run assets:ktx2:audit && npm run assets:performance:audit", "prebuild": "npm run loot:generate && npm run web:prepare && npm run assets:ktx2:audit && npm run assets:performance:audit",
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build", "build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
"build:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android", "postbuild": "node scripts/content/precompress-static.mjs dist && node scripts/content/verify-release-bundle.mjs dist",
"prebuild:shelved": "npm run loot:generate && npm run arena:abilities:build && npm run web:prepare && npm run arena:assets:validate && npm run assets:ktx2:audit && npm run assets:performance:audit",
"build:shelved": "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 shelved",
"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 && node scripts/content/verify-release-bundle.mjs dist-android",
"android:sync": "npm run build:android:web && cap sync android", "android:sync": "npm run build:android:web && cap sync android",
"android:apk": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleDebug", "android:apk": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleDebug",
"android:apk:release": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleRelease", "android:apk:release": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleRelease",
@@ -122,6 +126,8 @@
"preview": "vite preview --host 0.0.0.0", "preview": "vite preview --host 0.0.0.0",
"pretest": "npm run loot:generate", "pretest": "npm run loot:generate",
"test": "vitest run", "test": "vitest run",
"pretest:shelved": "npm run loot:generate",
"test:shelved": "vitest run --mode shelved",
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -11,6 +11,157 @@
}, },
"proceduralFallbacks": [], "proceduralFallbacks": [],
"assets": [ "assets": [
{
"sourceModel": "model/character/creature/atmo/act_atmo.ros",
"appearanceSignature": null,
"sourceModelSha256": "937f0ae59fd87e011479d67deb7baf1023cdb0f38ef72d78d9e923b99c4088a9",
"checksum": "9f7c1ad1c56ef51ee0511507f950ef97224ad86af561ded111cf1d5740c69999",
"size": 1981260,
"triangles": 3176,
"bounds": {
"min": [
-0.41683,
0.00233,
-0.15372
],
"max": [
0.41683,
2.00238,
1.86916
]
},
"groundOffset": -0.00233,
"labelHeight": 2.35005,
"markerRadius": 0.64732,
"animations": [
"Action - buff_sp01",
"Action - buff01",
"Cast - cast_sp01",
"Cast - cast01",
"Cast - casting_delay",
"Action - continuous_buff",
"Terminal Pose - dead",
"Death - death",
"Action - dodge",
"Wound - hurt",
"Idle - idle02",
"Idle - idle03",
"Run - run_forward",
"Stand - stand_idle01",
"Attack - unarmed_attack01",
"Attack - unarmed_attack02",
"Idle - unarmed_idle",
"Action - unarmed_parry",
"Walk - walk_forward"
],
"id": "atmo",
"baseActorId": "atmo",
"fileName": "atmo.glb",
"url": "/assets/creatures/grafu-castle/atmo.glb",
"rotationY": 0
},
{
"sourceModel": "model/character/creature/barbo_brother/act_barbo_brother.ros",
"appearanceSignature": null,
"sourceModelSha256": "8caafccd798e69e31f09a0d270c14e938c913935e9fd427691299b0a5d2db2a2",
"checksum": "af1cd20b7cc1d465c270860997f1245b2967005b1e5308b4a43401e983f883a4",
"size": 2226000,
"triangles": 3371,
"bounds": {
"min": [
-3.23727,
-1.32298,
-1.36027
],
"max": [
1.54844,
3.76779,
1.15116
]
},
"groundOffset": 1.32298,
"labelHeight": 5.44077,
"markerRadius": 1.53143,
"animations": [
"Action - knockdown_start",
"Action - buff01",
"Action - buff_sp01",
"Cast - cast01",
"Cast - cast_sp01",
"Cast - casting_delay",
"Action - continuous_buff",
"Terminal Pose - dead",
"Death - death",
"Action - dodge",
"Wound - hurt",
"Idle - idle02",
"Idle - idle03",
"Action - knockdown_recover",
"Walk - walk_forward",
"Run - run_forward",
"Stand - stand_idle01",
"Attack - unarmed_attack01",
"Attack - unarmed_attack02",
"Idle - unarmed_idle",
"Action - unarmed_parry"
],
"id": "barbo-brother",
"baseActorId": "barbo-brother",
"fileName": "barbo-brother.glb",
"url": "/assets/creatures/grafu-castle/barbo-brother.glb",
"rotationY": 0
},
{
"sourceModel": "model/character/creature/black_hat/act_black_hat.ros",
"appearanceSignature": null,
"sourceModelSha256": "1e16384ee51d97cb691ce2bb9c53e7f7c13c067bc40c09f4bec5fd8dc6f8b8f4",
"checksum": "e3d5265c794b82d45c34bd7875e33c9f00680dc67c3cc18213f5df657f9ab76a",
"size": 1965712,
"triangles": 2222,
"bounds": {
"min": [
-0.96974,
-0.01676,
-0.63427
],
"max": [
0.92815,
3.14637,
1.25604
]
},
"groundOffset": 0.01676,
"labelHeight": 3.51313,
"markerRadius": 0.60732,
"animations": [
"Action - buff_sp01",
"Action - buff01",
"Cast - cast_sp01",
"Cast - cast01",
"Cast - casting_delay",
"Action - continuous_buff",
"Terminal Pose - dead",
"Death - death",
"Action - dodge",
"Wound - hurt",
"Idle - idle02",
"Idle - idle03",
"Action - knockdown_end",
"Action - knockdown_recover",
"Run - run_forward",
"Stand - stand_idle01",
"Attack - unarmed_attack01",
"Attack - unarmed_attack02",
"Idle - unarmed_idle",
"Action - unarmed_parry",
"Walk - walk_forward"
],
"id": "black-hat",
"baseActorId": "black-hat",
"fileName": "black-hat.glb",
"url": "/assets/creatures/grafu-castle/black-hat.glb",
"rotationY": 0
},
{ {
"sourceModel": "model/character/creature/black_hat/act_black_hat_blue.ros", "sourceModel": "model/character/creature/black_hat/act_black_hat_blue.ros",
"appearanceSignature": null, "appearanceSignature": null,
@@ -206,6 +357,58 @@
"url": "/assets/creatures/grafu-castle/controlled-steel-guardian.glb", "url": "/assets/creatures/grafu-castle/controlled-steel-guardian.glb",
"rotationY": 0 "rotationY": 0
}, },
{
"sourceModel": "model/character/creature/lost_protector/act_cursed_steel_guardian.ros",
"appearanceSignature": null,
"sourceModelSha256": "b31342cb3ee28f6d6b0b70af334ab835e5212722bc99d0c8818568d5b9e079df",
"checksum": "3ec7660d4e1b475ec9ae290c304802dd4ba174048fac0781ae25474a718227fe",
"size": 1151612,
"triangles": 2201,
"bounds": {
"min": [
-0.97829,
-0.00095,
-0.38972
],
"max": [
0.97829,
2.01359,
0.26596
]
},
"groundOffset": 0.00095,
"labelHeight": 2.36454,
"markerRadius": 0.62611,
"animations": [
"Attack - 1h_attack01",
"Attack - 1h_attack02",
"Idle - 1h_idle",
"Action - 1h_parry",
"Cast - casting01",
"Cast - casting02",
"Cast - casting_delay",
"Terminal Pose - dead",
"Death - death",
"Action - dodge",
"Wound - hurt",
"Idle - idle02",
"Idle - idle03",
"Run - run_forward",
"Stand - stand_idle01",
"Walk - walk_forward",
"Attack - unarmed_attack01",
"Attack - unarmed_attack02",
"Idle - unarmed_idle",
"Action - unarmed_parry",
"Action - knockdown_end",
"Action - knockdown_recover"
],
"id": "cursed-steel-guardian",
"baseActorId": "cursed-steel-guardian",
"fileName": "cursed-steel-guardian.glb",
"url": "/assets/creatures/grafu-castle/cursed-steel-guardian.glb",
"rotationY": 0
},
{ {
"sourceModel": "model/character/creature/elder_myconid/act_elder_myconid.ros", "sourceModel": "model/character/creature/elder_myconid/act_elder_myconid.ros",
"appearanceSignature": null, "appearanceSignature": null,
@@ -924,6 +1127,56 @@
"url": "/assets/creatures/grafu-castle/hm-male-image-585705.glb", "url": "/assets/creatures/grafu-castle/hm-male-image-585705.glb",
"rotationY": 0 "rotationY": 0
}, },
{
"sourceModel": "model/character/creature/karth/act_karth.ros",
"appearanceSignature": null,
"sourceModelSha256": "18d43dd054c85512bfdbde50093c9dee6b368cef088d7c0f6cc3e50fff8e3ea3",
"checksum": "ac96b24461b39cdfae7810097dbe6698f0d7ce1afe235df04d72144dceef893d",
"size": 1993572,
"triangles": 3052,
"bounds": {
"min": [
-1.18195,
-0.00007,
-0.72488
],
"max": [
1.18195,
2.6109,
0.72657
]
},
"groundOffset": 0.00007,
"labelHeight": 2.96097,
"markerRadius": 0.75645,
"animations": [
"Walk - walk_forward",
"Action - buff01",
"Action - buff_sp01",
"Cast - cast01",
"Cast - cast_sp01",
"Cast - casting_delay",
"Terminal Pose - dead",
"Death - death",
"Action - dodge",
"Wound - hurt",
"Idle - idle02",
"Idle - idle03",
"Run - run_forward",
"Stand - stand_idle01",
"Attack - unarmed_attack01",
"Attack - unarmed_attack02",
"Idle - unarmed_idle",
"Action - unarmed_parry",
"Idle - crouch_idle",
"Action - spawn"
],
"id": "karth",
"baseActorId": "karth",
"fileName": "karth.glb",
"url": "/assets/creatures/grafu-castle/karth.glb",
"rotationY": 0
},
{ {
"sourceModel": "model/character/creature/karth/act_karth_01.ros", "sourceModel": "model/character/creature/karth/act_karth_01.ros",
"appearanceSignature": null, "appearanceSignature": null,
@@ -1019,6 +1272,221 @@
"fileName": "milk-scarab-blue.glb", "fileName": "milk-scarab-blue.glb",
"url": "/assets/creatures/grafu-castle/milk-scarab-blue.glb", "url": "/assets/creatures/grafu-castle/milk-scarab-blue.glb",
"rotationY": 0 "rotationY": 0
},
{
"sourceModel": "model/character/creature/mount_millenium_chimera/act_mount_millenium_chimera_laby.ros",
"appearanceSignature": null,
"sourceModelSha256": "1024bb257d901842c6bb7303eaba66399d26c28b680a7d8fadf4ee49c4b27a06",
"checksum": "7cb4ae344d3a46094b96faebdc6274eace04eb894de64ee87ac7bb7a1309b321",
"size": 1217348,
"triangles": 3086,
"bounds": {
"min": [
-0.4686,
-0.01121,
-2.26974
],
"max": [
0.4686,
1.73369,
1.59992
]
},
"groundOffset": 0.01121,
"labelHeight": 2.0949,
"markerRadius": 1.23829,
"animations": [
"Cast - casting01",
"Cast - casting02",
"Cast - casting_delay",
"Terminal Pose - dead",
"Death - death",
"Action - dodge",
"Wound - hurt",
"Idle - idle02",
"Idle - idle03",
"Action - jump_down",
"Action - jump_end_back",
"Action - jump_end_run",
"Action - jump_inplace",
"Action - jump_loop",
"Action - jump_up",
"Run - run_back",
"Run - run_forward",
"Action - shuffle_left",
"Action - shuffle_right",
"Stand - stand_idle01",
"Attack - unarmed_attack01",
"Attack - unarmed_attack02",
"Idle - unarmed_idle",
"Walk - walk_forward"
],
"id": "mount-millenium-chimera-laby",
"baseActorId": "mount-millenium-chimera-laby",
"fileName": "mount-millenium-chimera-laby.glb",
"url": "/assets/creatures/grafu-castle/mount-millenium-chimera-laby.glb",
"rotationY": 0
},
{
"sourceModel": "model/character/creature/enraged_big_bear/act_enraged_big_bear_purple.ros",
"appearanceSignature": null,
"sourceModelSha256": "22700bad0c13bad05bb722a7c6aa9959e4634217974b559901ab99e55fbfaca7",
"checksum": "36738dc204f6d574f56ee41044d61ae88a885d0dc4b411f7ea70d76e84d48a4b",
"size": 935312,
"triangles": 1568,
"bounds": {
"min": [
-1.65768,
0.00083,
-0.63438
],
"max": [
1.66988,
2.50467,
0.55741
]
},
"groundOffset": -0.00083,
"labelHeight": 2.85384,
"markerRadius": 1.06482,
"animations": [
"Cast - cast01",
"Terminal Pose - dead",
"Death - death",
"Action - dodge",
"Action - buff01",
"Action - buff_sp02",
"Wound - hurt",
"Idle - idle02",
"Idle - idle03",
"Action - knockdown_end",
"Action - knockdown_recover",
"Run - run_back",
"Run - run_forward",
"Stand - stand_idle01",
"Attack - unarmed_attack01",
"Attack - unarmed_attack02",
"Idle - unarmed_idle",
"Action - unarmed_parry",
"Walk - walk_forward",
"Idle - knockdown_idle",
"Cast - casting_delay",
"Action - jump_down",
"Action - jump_end_back",
"Action - jump_end_run",
"Action - jump_loop",
"Action - jump_up"
],
"id": "enraged-big-bear-purple",
"baseActorId": "enraged-big-bear-purple",
"fileName": "enraged-big-bear-purple.glb",
"url": "/assets/creatures/grafu-castle/enraged-big-bear-purple.glb",
"rotationY": 0
},
{
"sourceModel": "model/character/creature/enraged_big_bear/act_enraged_big_bear_cream.ros",
"appearanceSignature": null,
"sourceModelSha256": "e1d1810f37182d2f2c5e7dcf72aae4da75cbd5f056a5445df8dc17b2539d5bcb",
"checksum": "1e327d246fdcebf9b35891f37ccc3aed3ff7bfb4f2c7355c1381d7e0231e956c",
"size": 941160,
"triangles": 1568,
"bounds": {
"min": [
-1.65768,
0.00083,
-0.63438
],
"max": [
1.66988,
2.50467,
0.55741
]
},
"groundOffset": -0.00083,
"labelHeight": 2.85384,
"markerRadius": 1.06482,
"animations": [
"Cast - cast01",
"Terminal Pose - dead",
"Death - death",
"Action - dodge",
"Action - buff01",
"Action - buff_sp02",
"Wound - hurt",
"Idle - idle02",
"Idle - idle03",
"Action - knockdown_end",
"Action - knockdown_recover",
"Run - run_back",
"Run - run_forward",
"Stand - stand_idle01",
"Attack - unarmed_attack01",
"Attack - unarmed_attack02",
"Idle - unarmed_idle",
"Action - unarmed_parry",
"Walk - walk_forward",
"Idle - knockdown_idle",
"Cast - casting_delay",
"Action - jump_down",
"Action - jump_end_back",
"Action - jump_end_run",
"Action - jump_loop",
"Action - jump_up"
],
"id": "enraged-big-bear-cream",
"baseActorId": "enraged-big-bear-cream",
"fileName": "enraged-big-bear-cream.glb",
"url": "/assets/creatures/grafu-castle/enraged-big-bear-cream.glb",
"rotationY": 0
},
{
"sourceModel": "model/character/creature/rafael_bakderr/act_rafael_bakderr.ros",
"appearanceSignature": null,
"sourceModelSha256": "117287fd6f1ea813a73f846a0cde6bfd6d6f5ff00778dde82a1d7308aaafb0cc",
"checksum": "06e87d84a7cb3e15d610b0f18528cb1331c80db4597c67c49267aa4a627386dc",
"size": 1705044,
"triangles": 3437,
"bounds": {
"min": [
-0.59518,
0.00039,
-0.34992
],
"max": [
0.59473,
2.21216,
0.25268
]
},
"groundOffset": -0.00039,
"labelHeight": 2.56177,
"markerRadius": 0.4,
"animations": [
"Action - buff01",
"Action - buff_sp01",
"Cast - cast01",
"Cast - cast_sp01",
"Cast - casting_delay",
"Action - continuous_buff",
"Terminal Pose - dead",
"Death - death",
"Action - dodge",
"Wound - hurt",
"Idle - idle02",
"Idle - idle03",
"Run - run_forward",
"Stand - stand_idle01",
"Attack - unarmed_attack01",
"Attack - unarmed_attack02",
"Idle - unarmed_idle",
"Action - unarmed_parry",
"Walk - walk_forward"
],
"id": "rafael-bakderr",
"baseActorId": "rafael-bakderr",
"fileName": "rafael-bakderr.glb",
"url": "/assets/creatures/grafu-castle/rafael-bakderr.glb",
"rotationY": 0
} }
] ]
} }
+10 -3
View File
@@ -1,4 +1,5 @@
const CONTENT_CACHE_NAME = "healer-man-content-v1"; const CONTENT_CACHE_NAME = "healer-man-content-v1";
const BOOTSTRAP_CACHE_NAME = "healer-man-bootstrap-v1";
const OBJECT_HEADER = "x-healer-man-content-object"; const OBJECT_HEADER = "x-healer-man-content-object";
const ENCODING_HEADER = "x-healer-man-content-encoding"; const ENCODING_HEADER = "x-healer-man-content-encoding";
let bundledManifest; let bundledManifest;
@@ -33,7 +34,13 @@ async function bundledAsset(path) {
const object = entry.download ?? entry; const object = entry.download ?? entry;
// Older bootstrap catalogs served their original logical paths directly. // Older bootstrap catalogs served their original logical paths directly.
if (!object.url.startsWith("/content/bundled/")) return fetch(path); if (!object.url.startsWith("/content/bundled/")) return fetch(path);
const response = await fetch(object.url); const bootstrapCache = await caches.open(BOOTSTRAP_CACHE_NAME);
const objectKey = new URL(object.url, self.location.origin).toString();
let response = await bootstrapCache.match(objectKey);
if (!response) {
response = await fetch(object.url);
if (response.ok) await bootstrapCache.put(objectKey, response.clone());
}
if (!response.ok) return response; if (!response.ok) return response;
if (entry.download) return decodedResponse(response, path, entry.download.encoding); if (entry.download) return decodedResponse(response, path, entry.download.encoding);
return new Response(response.body, { headers: { "content-type": mimeType(path) } }); return new Response(response.body, { headers: { "content-type": mimeType(path) } });
@@ -53,7 +60,6 @@ const BUNDLED_ASSET_PREFIXES = [
"/assets/characters/wow335a-v1/manifest.json", "/assets/characters/wow335a-v1/manifest.json",
"/assets/characters/wow335a-v1/human/", "/assets/characters/wow335a-v1/human/",
"/assets/creatures/wailing-caverns/", "/assets/creatures/wailing-caverns/",
"/assets/game/arenas/",
"/assets/game/dungeons/wailing-caverns/", "/assets/game/dungeons/wailing-caverns/",
]; ];
const BUNDLED_ASSET_PATHS = new Set([ const BUNDLED_ASSET_PATHS = new Set([
@@ -85,7 +91,8 @@ self.addEventListener("activate", (event) => {
event.waitUntil((async () => { event.waitUntil((async () => {
const names = await caches.keys(); const names = await caches.keys();
await Promise.all(names await Promise.all(names
.filter((name) => name.startsWith(`${CONTENT_CACHE_NAME}-staging-`)) .filter((name) => name.startsWith(`${CONTENT_CACHE_NAME}-staging-`)
|| (name.startsWith("healer-man-bootstrap-") && name !== BOOTSTRAP_CACHE_NAME))
.map((name) => caches.delete(name))); .map((name) => caches.delete(name)));
await self.clients.claim(); await self.clients.claim();
})()); })());
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.31", "versionName": "0.1.34",
"versionCode": 1031 "versionCode": 1034
} }
+8 -7
View File
@@ -8,6 +8,7 @@ import { gunzipSync } from "node:zlib";
import { prepareContentObjects } from "./content-objects.mjs"; import { prepareContentObjects } from "./content-objects.mjs";
import { selectBootstrapContent } from "./prepare-bootstrap.mjs"; import { selectBootstrapContent } from "./prepare-bootstrap.mjs";
import { createContentManifest } from "./publish-content.mjs"; import { createContentManifest } from "./publish-content.mjs";
import contentPolicy from "../../shared/content-policy.json" with { type: "json" };
test("ships one lossless object for identical assets and verifies original bytes", async () => { test("ships one lossless object for identical assets and verifies original bytes", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "healer-content-compression-")); const directory = await mkdtemp(path.join(os.tmpdir(), "healer-content-compression-"));
@@ -36,17 +37,17 @@ test("ships one lossless object for identical assets and verifies original bytes
} finally { await rm(directory, { recursive: true, force: true }); } } finally { await rm(directory, { recursive: true, force: true }); }
}); });
test("bundles regular dungeons and exact map dependencies without installing Manastorm", () => { test("bundles only starter packs and their exact dependencies", () => {
const ids = ["core:ui", "dungeon:first", "dungeon:second", "shared:creatures", "manastorm:shared-map", "manastorm:optional-map", "characters:extra"]; const ids = [...contentPolicy.bootstrapPackIds, "dungeon:second", "shared:creatures", "manastorm:shared-map", "manastorm:optional-map", "characters:extra"];
const files = ids.map((packId, index) => ({ logicalPath: `assets/${index}.glb`, packId, sha256: String(index).repeat(64), bytes: 100, extension: ".glb" })); const files = ids.map((packId, index) => ({ logicalPath: `assets/${index}.glb`, packId, sha256: String(index).repeat(64), bytes: 100, extension: ".glb" }));
const result = selectBootstrapContent(files, [ const result = selectBootstrapContent(files, [
{ id: "dungeon:first", dependencies: ["manastorm:shared-map", "shared:creatures"] },
{ id: "dungeon:second", dependencies: [] }, { id: "dungeon:second", dependencies: [] },
]); ]);
assert.deepEqual(result.files.map((file) => file.packId), ids.slice(0, 5)); assert.deepEqual(result.files.map((file) => file.packId), contentPolicy.bootstrapPackIds);
const packs = new Map(result.packDefinitions.map((pack) => [pack.id, pack])); const packs = new Map(result.packDefinitions.map((pack) => [pack.id, pack]));
assert.equal(packs.get("dungeon:first").bundledOnAndroid, true); assert.deepEqual([...packs.keys()].sort(), [...contentPolicy.bootstrapPackIds].sort());
assert.equal(packs.get("dungeon:second").bundledOnAndroid, true); assert.equal([...packs.values()].every((pack) => pack.bundledOnAndroid), true);
assert.equal(packs.get("manastorm:shared-map").bundledOnAndroid, false); assert.equal(packs.has("dungeon:second"), false);
assert.equal(packs.has("manastorm:shared-map"), false);
assert.equal(packs.has("manastorm:optional-map"), false); assert.equal(packs.has("manastorm:optional-map"), false);
}); });
+55
View File
@@ -0,0 +1,55 @@
import { readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { brotliCompress, constants, gzip } from "node:zlib";
const compressBrotli = promisify(brotliCompress);
const compressGzip = promisify(gzip);
const ELIGIBLE_EXTENSIONS = new Set([".css", ".js", ".json"]);
const MINIMUM_BYTES = 1024;
async function eligibleFiles(root) {
const files = [];
async function visit(directory) {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const filePath = path.join(directory, entry.name);
if (entry.isDirectory()) await visit(filePath);
else if (entry.isFile() && ELIGIBLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(filePath);
}
}
await visit(root);
return files;
}
export async function precompressStatic(root) {
const files = await eligibleFiles(root);
let sourceBytes = 0;
let brotliBytes = 0;
let gzipBytes = 0;
let writtenFiles = 0;
for (const filePath of files) {
const source = await readFile(filePath);
if (source.length < MINIMUM_BYTES) continue;
const [brotli, gzipped] = await Promise.all([
compressBrotli(source, { params: { [constants.BROTLI_PARAM_QUALITY]: 6 } }),
compressGzip(source, { level: 9 }),
]);
await Promise.all([
writeFile(`${filePath}.br`, brotli),
writeFile(`${filePath}.gz`, gzipped),
]);
sourceBytes += source.length;
brotliBytes += brotli.length;
gzipBytes += gzipped.length;
writtenFiles += 2;
}
return { sourceFiles: writtenFiles / 2, writtenFiles, sourceBytes, brotliBytes, gzipBytes };
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
const root = path.resolve(process.argv[2] ?? "dist");
const result = await precompressStatic(root);
console.log(JSON.stringify({ root, ...result }, null, 2));
}
+8 -5
View File
@@ -5,6 +5,7 @@ import {
createContentManifest, loadCampaignDungeonPackDefinitions, scanContentSources, createContentManifest, loadCampaignDungeonPackDefinitions, scanContentSources,
} from "./publish-content.mjs"; } from "./publish-content.mjs";
import { prepareContentObjects } from "./content-objects.mjs"; import { prepareContentObjects } from "./content-objects.mjs";
import contentPolicy from "../../shared/content-policy.json" with { type: "json" };
export function selectBootstrapContent(files, packDefinitions) { export function selectBootstrapContent(files, packDefinitions) {
const manifest = createContentManifest(files, { packDefinitions }); const manifest = createContentManifest(files, { packDefinitions });
@@ -15,15 +16,12 @@ export function selectBootstrapContent(files, packDefinitions) {
included.add(id); included.add(id);
for (const dependency of packs.get(id)?.dependencies ?? []) include(dependency); for (const dependency of packs.get(id)?.dependencies ?? []) include(dependency);
} }
for (const pack of manifest.packs) { for (const packId of BUNDLED_ANDROID_PACKS) include(packId);
if (BUNDLED_ANDROID_PACKS.includes(pack.id) || pack.id.startsWith("dungeon:")) include(pack.id);
}
return { return {
files: files.filter((file) => included.has(file.packId)), files: files.filter((file) => included.has(file.packId)),
packDefinitions: manifest.packs.filter((pack) => included.has(pack.id)).map((pack) => ({ packDefinitions: manifest.packs.filter((pack) => included.has(pack.id)).map((pack) => ({
...pack, ...pack,
// Shared maps can be physically bundled without installing Manastorm gameplay. bundledOnAndroid: BUNDLED_ANDROID_PACKS.includes(pack.id),
bundledOnAndroid: !pack.id.startsWith("manastorm:"),
})), })),
}; };
} }
@@ -44,6 +42,11 @@ export async function prepareBootstrap(platform) {
const prepared = await prepareContentObjects(selected.files, { const prepared = await prepareContentObjects(selected.files, {
directory: path.join(outputDirectory, "content/bundled"), baseUrl: "/content/bundled", directory: path.join(outputDirectory, "content/bundled"), baseUrl: "/content/bundled",
}); });
if (prepared.storedBytes > contentPolicy.maxBootstrapStoredBytes) {
throw new Error(
`Bootstrap content is ${prepared.storedBytes} bytes; the release limit is ${contentPolicy.maxBootstrapStoredBytes} bytes.`,
);
}
const manifest = createContentManifest(prepared.files, { const manifest = createContentManifest(prepared.files, {
baseUrl: "/content/bundled", packDefinitions: selected.packDefinitions, baseUrl: "/content/bundled", packDefinitions: selected.packDefinitions,
}); });
+2 -8
View File
@@ -10,6 +10,7 @@ import {
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { prepareContentObjects } from "./content-objects.mjs"; import { prepareContentObjects } from "./content-objects.mjs";
import contentPolicy from "../../shared/content-policy.json" with { type: "json" };
const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)); const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
export const PROJECT_ROOT = path.resolve(SCRIPT_DIRECTORY, "../.."); export const PROJECT_ROOT = path.resolve(SCRIPT_DIRECTORY, "../..");
@@ -23,14 +24,7 @@ const WOW_CAMPAIGN_CATALOG_PATH = path.join(
export const CONTENT_SCHEMA_VERSION = 1; export const CONTENT_SCHEMA_VERSION = 1;
export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0"; export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0";
export const WOW_CAMPAIGN_DUNGEON_COUNT = 54; export const WOW_CAMPAIGN_DUNGEON_COUNT = 54;
export const BUNDLED_ANDROID_PACKS = Object.freeze([ export const BUNDLED_ANDROID_PACKS = Object.freeze([...contentPolicy.bootstrapPackIds]);
"core:ui",
"core:equipment",
"core:character-basic",
"core:runtime",
"core:arenas",
"dungeon:wailing-caverns",
]);
const RUNTIME_EXTENSIONS = new Set([ const RUNTIME_EXTENSIONS = new Set([
".avif", ".glb", ".js", ".json", ".ktx2", ".mp3", ".ogg", ".png", ".avif", ".glb", ".js", ".json", ".ktx2", ".mp3", ".ogg", ".png",
+1 -1
View File
@@ -24,7 +24,7 @@ test("assigns stable player-facing packs from logical asset paths", () => {
assert.equal(contentPackForPath("assets/characters/wow335a-v1/orc/male/rig.glb"), "characters:wow335a-v1"); assert.equal(contentPackForPath("assets/characters/wow335a-v1/orc/male/rig.glb"), "characters:wow335a-v1");
assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1"); assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1");
assert.equal(contentPackForPath("assets/game/arenas/nagrand-arena/visual.glb"), "core:arenas"); assert.equal(contentPackForPath("assets/game/arenas/nagrand-arena/visual.glb"), "core:arenas");
assert.equal(BUNDLED_ANDROID_PACKS.includes("core:arenas"), true); assert.equal(BUNDLED_ANDROID_PACKS.includes("core:arenas"), false);
assert.equal(contentPackForPath("assets/game/combat-effects/audio/smite.wav"), "core:combat-media"); assert.equal(contentPackForPath("assets/game/combat-effects/audio/smite.wav"), "core:combat-media");
assert.equal( assert.equal(
contentPackForPath("assets/game/combat-effects/audio/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"), contentPackForPath("assets/game/combat-effects/audio/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"),
+14 -3
View File
@@ -7,6 +7,10 @@ import json
from pathlib import Path from pathlib import Path
from zipfile import ZIP_STORED, ZipFile from zipfile import ZIP_STORED, ZipFile
CONTENT_POLICY = json.loads(
(Path(__file__).resolve().parents[2] / "shared" / "content-policy.json").read_text(encoding="utf-8")
)
def verify_package(apk: Path) -> dict: def verify_package(apk: Path) -> dict:
with ZipFile(apk) as archive: with ZipFile(apk) as archive:
@@ -35,13 +39,20 @@ def verify_package(apk: Path) -> dict:
if actual_objects != expected_objects: if actual_objects != expected_objects:
raise ValueError("APK includes missing or unreferenced bundled objects.") raise ValueError("APK includes missing or unreferenced bundled objects.")
manastorms = sum(pack["id"].startswith("manastorm:") and pack["bundledOnAndroid"] for pack in manifest["packs"]) manastorms = sum(pack["id"].startswith("manastorm:") and pack["bundledOnAndroid"] for pack in manifest["packs"])
if dungeons != 84 or manastorms != 0: arenas = sum(pack["id"] == "core:arenas" for pack in manifest["packs"])
raise ValueError(f"Unexpected bundled content: {dungeons} dungeons, {manastorms} Manastorm packs") pack_ids = sorted(pack["id"] for pack in manifest["packs"])
expected_pack_ids = sorted(CONTENT_POLICY["bootstrapPackIds"])
if pack_ids != expected_pack_ids or dungeons != 1 or manastorms != 0 or arenas != 0:
raise ValueError(
f"Unexpected bundled content: {dungeons} dungeons, {manastorms} Manastorm packs, {arenas} arena packs"
)
if object_bytes > CONTENT_POLICY["maxBootstrapStoredBytes"]:
raise ValueError("APK bootstrap content exceeds the stored-byte budget.")
return { return {
"apk": str(apk.resolve()), "apkBytes": apk.stat().st_size, "apk": str(apk.resolve()), "apkBytes": apk.stat().st_size,
"manifestVersion": manifest["version"], "logicalFiles": len(manifest["files"]), "manifestVersion": manifest["version"], "logicalFiles": len(manifest["files"]),
"verifiedUniqueObjects": len(checked), "storedObjectBytes": object_bytes, "verifiedUniqueObjects": len(checked), "storedObjectBytes": object_bytes,
"bundledDungeons": dungeons, "bundledManastormPacks": manastorms, "bundledDungeons": dungeons, "bundledManastormPacks": manastorms, "bundledArenaPacks": arenas,
"allOriginalHashesMatch": True, "allOriginalHashesMatch": True,
} }
+91
View File
@@ -0,0 +1,91 @@
import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import contentPolicy from "../../shared/content-policy.json" with { type: "json" };
const SCRIPT_PATH = fileURLToPath(import.meta.url);
const PROHIBITED_MODULE_PATTERNS = [
/(?:^|\/)src\/ArenaRuntime\.tsx$/,
/(?:^|\/)src\/app\/ShelvedModeRoutes\.tsx$/,
/(?:^|\/)src\/app\/ShelvedGameRuntime\.enabled\.tsx$/,
/(?:^|\/)src\/app\/ShelvedOnlineGroupRuntime\.enabled\.ts$/,
/(?:^|\/)src\/app\/(?:arenaClient|gmManastormRuntime|manastormShellRuntime)\.[jt]sx?$/,
/(?:^|\/)src\/game\/(?:arena|manastormAdmin|manastormCatalog|manastormInstalled)(?:\/|\.[jt]sx?$)/,
/(?:^|\/)src\/game\/ManastormBridge\.[jt]sx?$/,
/(?:^|\/)src\/ui\/(?:Arena|GmAdmin|GmReview|GmWorld|Manastorm)[^/]*\.[jt]sx?$/,
/(?:^|\/)src\/scene\/ShelvedGameScene\.enabled\.tsx$/,
/(?:^|\/)src\/game\/ShelvedDungeonSession\.enabled\.ts$/,
/(?:^|\/)src\/ui\/(?:ShelvedCompanionPanel|ShelvedHud)\.enabled\.tsx$/,
];
function fail(message) {
throw new Error(`Release bundle verification failed: ${message}`);
}
async function walkRelativeFiles(root) {
const files = [];
async function visit(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const filePath = path.join(directory, entry.name);
if (entry.isDirectory()) await visit(filePath);
else if (entry.isFile()) files.push(path.relative(root, filePath).split(path.sep).join("/"));
}
}
await visit(root);
return files;
}
export async function verifyReleaseBundle(root) {
const manifest = JSON.parse(await readFile(path.join(root, "content", "bootstrap-manifest.json"), "utf8"));
const moduleReport = JSON.parse(await readFile(path.join(root, "bundle-module-report.json"), "utf8"));
if (moduleReport.shelvedModesEnabled !== false) fail("normal releases must compile with shelved modes disabled");
const expectedPacks = [...contentPolicy.bootstrapPackIds].sort();
const actualPacks = (manifest.packs ?? []).map((pack) => pack.id).sort();
if (JSON.stringify(actualPacks) !== JSON.stringify(expectedPacks)) {
fail(`bootstrap packs were ${actualPacks.join(", ")}; expected ${expectedPacks.join(", ")}`);
}
const hiddenPack = actualPacks.find((packId) => (
contentPolicy.hiddenContentPackIds.includes(packId)
|| contentPolicy.hiddenContentPackPrefixes.some((prefix) => packId.startsWith(prefix))
));
if (hiddenPack) fail(`bootstrap contains hidden pack ${hiddenPack}`);
const objects = new Map();
for (const entry of Object.values(manifest.files ?? {})) {
const object = entry.download ?? entry;
if (!objects.has(object.url)) objects.set(object.url, object.bytes);
}
const storedBytes = [...objects.values()].reduce((sum, bytes) => sum + Number(bytes ?? 0), 0);
if (storedBytes > contentPolicy.maxBootstrapStoredBytes) {
fail(`bootstrap uses ${storedBytes} stored bytes; limit is ${contentPolicy.maxBootstrapStoredBytes}`);
}
const prohibitedModules = moduleReport.chunks.flatMap((chunk) => chunk.modules).filter((moduleId) => (
PROHIBITED_MODULE_PATTERNS.some((pattern) => pattern.test(moduleId))
));
if (prohibitedModules.length) fail(`shelved client modules were emitted: ${[...new Set(prohibitedModules)].join(", ")}`);
const files = await walkRelativeFiles(root);
const arenaAsset = files.find((file) => /(?:^|\/)assets\/game\/arenas(?:\/|$)/i.test(file));
if (arenaAsset) fail(`arena asset was emitted at ${arenaAsset}`);
for (const objectUrl of objects.keys()) {
const objectPath = path.join(root, objectUrl.replace(/^\/+/, ""));
const objectStats = await stat(objectPath);
if (!objectStats.isFile()) fail(`bootstrap object is missing: ${objectUrl}`);
}
return {
root,
packs: actualPacks,
storedBytes,
storedMiB: Number((storedBytes / 1024 / 1024).toFixed(2)),
objectCount: objects.size,
chunkCount: moduleReport.chunks.length,
};
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(SCRIPT_PATH)) {
const root = path.resolve(process.argv[2] ?? "dist");
console.log(JSON.stringify(await verifyReleaseBundle(root), null, 2));
}
@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import contentPolicy from "../../shared/content-policy.json" with { type: "json" };
import { verifyReleaseBundle } from "./verify-release-bundle.mjs";
async function fixture() {
const root = await mkdtemp(path.join(os.tmpdir(), "healer-man-release-bundle-test-"));
await mkdir(path.join(root, "content", "bundled"), { recursive: true });
const manifest = {
schemaVersion: 1,
packs: contentPolicy.bootstrapPackIds.map((id) => ({ id })),
files: {},
};
const moduleReport = { shelvedModesEnabled: false, chunks: [{ file: "assets/index.js", modules: ["src/App.tsx"] }] };
const write = async () => {
await writeFile(path.join(root, "content", "bootstrap-manifest.json"), `${JSON.stringify(manifest)}\n`);
await writeFile(path.join(root, "bundle-module-report.json"), `${JSON.stringify(moduleReport)}\n`);
};
await write();
return { root, manifest, moduleReport, write };
}
test("accepts the exact compact production policy", async () => {
const value = await fixture();
try {
const report = await verifyReleaseBundle(value.root);
assert.deepEqual(report.packs, [...contentPolicy.bootstrapPackIds].sort());
assert.equal(report.storedBytes, 0);
} finally {
await rm(value.root, { recursive: true, force: true });
}
});
test("rejects arena packs and shelved client modules", async () => {
const value = await fixture();
try {
value.manifest.packs.push({ id: "core:arenas" });
await value.write();
await assert.rejects(verifyReleaseBundle(value.root), /bootstrap packs/);
value.manifest.packs.pop();
value.moduleReport.chunks[0].modules.push("src/ArenaRuntime.tsx");
await value.write();
await assert.rejects(verifyReleaseBundle(value.root), /shelved client modules/);
} finally {
await rm(value.root, { recursive: true, force: true });
}
});
test("rejects a bootstrap above the stored-byte budget", async () => {
const value = await fixture();
try {
value.manifest.files["/large.glb"] = {
packId: "core:runtime",
url: `/content/bundled/${"c".repeat(64)}.glb`,
bytes: contentPolicy.maxBootstrapStoredBytes + 1,
};
await value.write();
await assert.rejects(verifyReleaseBundle(value.root), /limit is/);
} finally {
await rm(value.root, { recursive: true, force: true });
}
});
@@ -8,6 +8,7 @@ import { DEFAULT_AZEROTHCORE_COMMIT } from "./import-azerothcore-world.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const recipesRoot = path.join(projectRoot, "dungeon-pipeline", "recipes"); const recipesRoot = path.join(projectRoot, "dungeon-pipeline", "recipes");
const runtimeOverridesRoot = path.join(projectRoot, "dungeon-pipeline", "runtime-overrides");
const workRoot = path.resolve(process.env.DUNGEON_PIPELINE_WORK ?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons")); const workRoot = path.resolve(process.env.DUNGEON_PIPELINE_WORK ?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons"));
const outputFile = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json"); const outputFile = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json");
const gameObjectAssetManifestFile = path.join( const gameObjectAssetManifestFile = path.join(
@@ -1445,13 +1446,13 @@ function finiteBounds(points) {
}; };
} }
function presentationFor(recipe, assetPackage, environmentMode, bounds) { function presentationFor(recipe, assetPackage, environmentMode, bounds, override = {}) {
const palette = EXPANSION_PRESENTATION[recipe.expansion] ?? EXPANSION_PRESENTATION.classic; const palette = EXPANSION_PRESENTATION[recipe.expansion] ?? EXPANSION_PRESENTATION.classic;
const span = Math.max( const span = Math.max(
bounds.max[0] - bounds.min[0], bounds.max[0] - bounds.min[0],
bounds.max[2] - bounds.min[2], bounds.max[2] - bounds.min[2],
); );
return { const presentation = {
location: assetPackage.location || recipe.title, location: assetPackage.location || recipe.title,
theme: environmentMode === "adt-hybrid" ? "Open-world expedition" : "Instanced dungeon", theme: environmentMode === "adt-hybrid" ? "Open-world expedition" : "Instanced dungeon",
summary: `Fight through ${recipe.title}, defeat its encounter roster, and keep the party alive.`, summary: `Fight through ${recipe.title}, defeat its encounter roster, and keep the party alive.`,
@@ -1480,6 +1481,16 @@ function presentationFor(recipe, assetPackage, environmentMode, bounds) {
], ],
}, },
}; };
return {
...presentation,
...override,
fog: { ...presentation.fog, ...override.fog },
ambientLight: { ...presentation.ambientLight, ...override.ambientLight },
hemisphereLight: { ...presentation.hemisphereLight, ...override.hemisphereLight },
directionalLight: { ...presentation.directionalLight, ...override.directionalLight },
materials: presentation.materials,
map: presentation.map,
};
} }
async function compile() { async function compile() {
@@ -1535,6 +1546,19 @@ async function compile() {
const importsBySlug = new Map( const importsBySlug = new Map(
(fivePlayerImports.imports ?? []).map((entry) => [entry.slug, entry]), (fivePlayerImports.imports ?? []).map((entry) => [entry.slug, entry]),
); );
const runtimeOverrides = new Map();
for (const recipe of recipes) {
const file = path.join(runtimeOverridesRoot, `${recipe.slug}.json`);
if (!await exists(file)) continue;
const override = await readJson(file);
if (override.schemaVersion !== 1 || override.dungeonId !== recipe.slug) {
throw new Error(`${recipe.slug}: runtime override identity or schema is invalid.`);
}
if (override.validation?.navigation === "green" && !(override.navigationLinks?.length > 0)) {
throw new Error(`${recipe.slug}: green navigation requires at least one reviewed link.`);
}
runtimeOverrides.set(recipe.slug, override);
}
const drafts = new Map(); const drafts = new Map();
const wantedSpellIds = new Set(); const wantedSpellIds = new Set();
for (const recipe of recipes) { for (const recipe of recipes) {
@@ -1584,6 +1608,7 @@ async function compile() {
const coverage = []; const coverage = [];
for (const recipe of recipes) { for (const recipe of recipes) {
const dungeonImport = importsBySlug.get(recipe.slug); const dungeonImport = importsBySlug.get(recipe.slug);
const runtimeOverride = runtimeOverrides.get(recipe.slug) ?? {};
// Manastorm packages contain the complete optimized dungeon geometry. // Manastorm packages contain the complete optimized dungeon geometry.
// Reuse them for full dungeons so the install ships one map package. // Reuse them for full dungeons so the install ships one map package.
const assetPackage = packagesByMap.get(recipe.mapId) ?? dungeonImport?.environment; const assetPackage = packagesByMap.get(recipe.mapId) ?? dungeonImport?.environment;
@@ -1951,7 +1976,7 @@ async function compile() {
difficultyVariants: recipe.difficultyVariants, difficultyVariants: recipe.difficultyVariants,
defaultDifficultyId: recipe.difficultyVariants[0]?.id ?? "normal", defaultDifficultyId: recipe.difficultyVariants[0]?.id ?? "normal",
availability: "available", availability: "available",
presentation: presentationFor(recipe, assetPackage, environmentMode, bounds), presentation: presentationFor(recipe, assetPackage, environmentMode, bounds, runtimeOverride.presentation),
assets: { assets: {
visual: assetPackage.visual.map(assetChunk), visual: assetPackage.visual.map(assetChunk),
collision: assetPackage.collision.map(assetChunk), collision: assetPackage.collision.map(assetChunk),
@@ -1979,7 +2004,26 @@ async function compile() {
roamingPacks, roamingPacks,
bosses, bosses,
objectiveOrder: bosses, objectiveOrder: bosses,
navigationLinks: [], navigationLinks: (runtimeOverride.navigationLinks ?? []).map((link) => ({
id: String(link.id),
start: vector(link.start),
end: vector(link.end),
bidirectional: link.bidirectional !== false,
authoredReason: String(link.authoredReason),
})),
...(runtimeOverride.traversalRamps?.length ? {
traversalRamps: runtimeOverride.traversalRamps.map((ramp) => ({
id: String(ramp.id),
start: vector(ramp.start),
end: vector(ramp.end),
width: Number(ramp.width),
navigationBridge: {
linkId: String(ramp.navigationBridge.linkId),
startPortal: ramp.navigationBridge.startPortal.map(vector),
endPortal: ramp.navigationBridge.endPortal.map(vector),
},
})),
} : {}),
encounters: [compileBossProgressionEncounter(recipe, bosses, syntheticTrash)], encounters: [compileBossProgressionEncounter(recipe, bosses, syntheticTrash)],
serverGameObjects, serverGameObjects,
provenance: { provenance: {
@@ -2009,8 +2053,17 @@ async function compile() {
}, },
validation: { validation: {
status: "unvalidated", status: "unvalidated",
...(runtimeOverride.validation?.navigation
? { navigation: runtimeOverride.validation.navigation }
: {}),
...(runtimeOverride.validation?.report
? { report: runtimeOverride.validation.report }
: {}),
blockers: [], blockers: [],
warnings: sourceWarnings, warnings: [
...sourceWarnings,
...(runtimeOverride.validation?.warnings ?? []),
],
}, },
}); });
coverage.push({ coverage.push({
+1
View File
@@ -81,6 +81,7 @@ def full_release_checks(npm: str) -> tuple[tuple[list[str], str], ...]:
([npm, "run", "content:test"], "Content tests"), ([npm, "run", "content:test"], "Content tests"),
([npm, "run", "server:test"], "Server tests"), ([npm, "run", "server:test"], "Server tests"),
([npm, "test"], "Application tests"), ([npm, "test"], "Application tests"),
([npm, "run", "test:shelved"], "Shelved-mode application tests"),
([npm, "run", "content:publish"], "Publish verified 54-dungeon content manifest"), ([npm, "run", "content:publish"], "Publish verified 54-dungeon content manifest"),
([npm, "run", "build"], "Production build"), ([npm, "run", "build"], "Production build"),
([npm, "run", "android:sync"], "Android web build and Capacitor sync"), ([npm, "run", "android:sync"], "Android web build and Capacitor sync"),
@@ -71,6 +71,21 @@ Optional template fields:
- `reason`: why an object is deferred; - `reason`: why an object is deferred;
- `source`: forensic image/model/classification evidence. - `source`: forensic image/model/classification evidence.
`source.scriptRoots` may list reviewed Lua entry functions for dynamically
created bosses that have no static SQL row. The boss-mechanics importer follows
those roots and their callbacks exactly as it does for static boss rows.
Optional top-level scripted placement fields:
- `scriptedSpawns`: reviewed creatures created by Lua rather than stored as
active zone rows. Each entry has a stable `id`, a combat/boss `templateId`, a
`sourceAnchorSpawnId`, source evidence, and optional source-unit offset,
controller row, direction, or `objective: false` for a boss companion.
- `scriptedFormations`: source `LuaFunc_Matrix_Maker` formations flattened to
their initial positions. Each formation specifies its controller/anchor row,
source direction and spacing, Lua root/evidence, and member template IDs with
one-based rows and signed columns.
`expected*` values may be `null` only in a fresh scaffold. The explicit `expected*` values may be `null` only in a fresh scaffold. The explicit
forensic refresh with `--update-recipe` pins them. A recipe should not be treated forensic refresh with `--update-recipe` pins them. A recipe should not be treated
as production-ready until all classifications/models and counts are reviewed. as production-ready until all classifications/models and counts are reviewed.
@@ -27,9 +27,9 @@
"summary": { "summary": {
"dungeons": 30, "dungeons": 30,
"deferredSourceRows": 4113, "deferredSourceRows": 4113,
"uniqueMechanicsEvidence": 1524, "uniqueMechanicsEvidence": 1530,
"luaEvidenceSignals": 329, "luaEvidenceSignals": 329,
"templateEvidenceSignals": 1195, "templateEvidenceSignals": 1201,
"evidenceCategoryMembershipCount": 1146, "evidenceCategoryMembershipCount": 1146,
"evidenceCategoryMemberships": { "evidenceCategoryMemberships": {
"death": 100, "death": 100,
@@ -54,21 +54,21 @@
"evidenceStatuses": { "evidenceStatuses": {
"implemented": 0, "implemented": 0,
"verified": 388, "verified": 388,
"deferred-source-gap": 1136, "deferred-source-gap": 1142,
"deferred-asset-gap": 0, "deferred-asset-gap": 0,
"deferred-runtime-gap": 0, "deferred-runtime-gap": 0,
"excluded-non-gameplay": 0, "excluded-non-gameplay": 0,
"intentional-empty": 0 "intentional-empty": 0
}, },
"evidenceFidelities": { "evidenceFidelities": {
"source-exact": 1136, "source-exact": 1142,
"source-backed-adapted": 388, "source-backed-adapted": 388,
"healer-man-authored": 0 "healer-man-authored": 0
}, },
"verifiedEvidence": 388, "verifiedEvidence": 388,
"sourceGapEvidence": 1136, "sourceGapEvidence": 1142,
"fallbackBosses": 4, "fallbackBosses": 5,
"nonEmptyZeroBossPackages": 8, "nonEmptyZeroBossPackages": 7,
"intentionalEmptyPackages": 2 "intentionalEmptyPackages": 2
}, },
"blockerGroups": { "blockerGroups": {
@@ -77,7 +77,7 @@
"nextWork": "Review source semantics and add explicit source-spawn encounter mappings." "nextWork": "Review source semantics and add explicit source-spawn encounter mappings."
}, },
"unresolvedMechanicsEvidence": { "unresolvedMechanicsEvidence": {
"items": 1136, "items": 1142,
"nextWork": "Review surrounding Lua/template context; specify full trigger, effect, and reset behavior." "nextWork": "Review surrounding Lua/template context; specify full trigger, effect, and reset behavior."
}, },
"adaptedSpellFormula": { "adaptedSpellFormula": {
@@ -85,11 +85,11 @@
"nextWork": "Recover native formula inputs before claiming coefficient-level fidelity." "nextWork": "Recover native formula inputs before claiming coefficient-level fidelity."
}, },
"sourceGapFallbackBosses": { "sourceGapFallbackBosses": {
"items": 4, "items": 5,
"nextWork": "Recover authoritative combat kits and replace only with source-backed modular mechanics." "nextWork": "Recover authoritative combat kits and replace only with source-backed modular mechanics."
}, },
"nonEmptyZeroBossPackages": { "nonEmptyZeroBossPackages": {
"items": 8, "items": 7,
"nextWork": "Recover authoritative boss membership and objective order." "nextWork": "Recover authoritative boss membership and objective order."
} }
}, },
@@ -111,7 +111,7 @@
"source": { "source": {
"bossMechanicsCatalog": { "bossMechanicsCatalog": {
"path": "dungeon-pipeline/boss-mechanics.json", "path": "dungeon-pipeline/boss-mechanics.json",
"sha256": "6e078fe5fbbd10cabc77d2141e5084b30daffe5056945b44294a59711c48c4b7" "sha256": "2e573cd2a0a99d89d0f51764bcce0693e37513da677c70090e101bb016f39b21"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json", "path": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json",
@@ -153,7 +153,7 @@
"source": { "source": {
"bossMechanicsCatalog": { "bossMechanicsCatalog": {
"path": "dungeon-pipeline/boss-mechanics.json", "path": "dungeon-pipeline/boss-mechanics.json",
"sha256": "6e078fe5fbbd10cabc77d2141e5084b30daffe5056945b44294a59711c48c4b7" "sha256": "2e573cd2a0a99d89d0f51764bcce0693e37513da677c70090e101bb016f39b21"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json", "path": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json",
@@ -189,7 +189,7 @@
"source": { "source": {
"bossMechanicsCatalog": { "bossMechanicsCatalog": {
"path": "dungeon-pipeline/boss-mechanics.json", "path": "dungeon-pipeline/boss-mechanics.json",
"sha256": "6e078fe5fbbd10cabc77d2141e5084b30daffe5056945b44294a59711c48c4b7" "sha256": "2e573cd2a0a99d89d0f51764bcce0693e37513da677c70090e101bb016f39b21"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json", "path": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json",
@@ -225,7 +225,7 @@
"source": { "source": {
"bossMechanicsCatalog": { "bossMechanicsCatalog": {
"path": "dungeon-pipeline/boss-mechanics.json", "path": "dungeon-pipeline/boss-mechanics.json",
"sha256": "6e078fe5fbbd10cabc77d2141e5084b30daffe5056945b44294a59711c48c4b7" "sha256": "2e573cd2a0a99d89d0f51764bcce0693e37513da677c70090e101bb016f39b21"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json", "path": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json",
@@ -244,6 +244,44 @@
"blocker": "Preserved source evidence does not expose an executable original combat kit.", "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.", "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." "clearingTest": "Add source-backed mechanic fixtures and assert the catalog source kind changes from healer-man-source-gap to runewaker."
},
{
"entityId": "rw-grafu-castle-105351",
"name": "Larvanger Barkud",
"dungeonId": "grafu-castle",
"templateId": 105351,
"mechanicIds": [
"rw-grafu-castle-105351-source-gap-sweep"
],
"telegraphedMechanicIds": [
"rw-grafu-castle-105351-source-gap-sweep"
],
"status": "implemented",
"fidelity": "healer-man-authored",
"source": {
"bossMechanicsCatalog": {
"path": "dungeon-pipeline/boss-mechanics.json",
"sha256": "2e573cd2a0a99d89d0f51764bcce0693e37513da677c70090e101bb016f39b21"
},
"snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
"sha256": "aa70f57055eddd21c6280879e6459036b81ae17b56ed6d8eb30b3825a716d403",
"contentSha256": "0113f241c8bb527a6e0bd365c2cf9586a6f922719098aea009292f3f3c04c801"
},
"roots": [
"un_zone19_boss5_allconreal_1_01"
],
"functions": [
"un_zone19_boss5_allconreal_1_01",
"Az_RND_Ary",
"SearchRangeNPC",
"DW_Rand"
],
"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."
} }
], ],
"nonEmptyZeroBossPackages": [ "nonEmptyZeroBossPackages": [
@@ -277,21 +315,6 @@
"nextWork": "Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.", "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." "clearingTest": "Verify at least one source-backed boss objective, or add reviewed evidence that this package intentionally has none."
}, },
{
"dungeonId": "grafu-castle",
"zoneId": 137,
"sourceRows": 249,
"status": "deferred-source-gap",
"fidelity": "source-exact",
"source": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
"sha256": "aa70f57055eddd21c6280879e6459036b81ae17b56ed6d8eb30b3825a716d403",
"contentSha256": "0113f241c8bb527a6e0bd365c2cf9586a6f922719098aea009292f3f3c04c801"
},
"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."
},
{ {
"dungeonId": "kal-turok-hive", "dungeonId": "kal-turok-hive",
"zoneId": 105, "zoneId": 105,
@@ -41141,11 +41164,11 @@
"id": "grafu-castle", "id": "grafu-castle",
"zoneId": 137, "zoneId": 137,
"sourceRows": 249, "sourceRows": 249,
"bossObjectives": 0, "bossObjectives": 5,
"sourceArtifacts": { "sourceArtifacts": {
"recipe": { "recipe": {
"path": "scripts/runewaker-pipeline/recipes/grafu-castle-population.json", "path": "scripts/runewaker-pipeline/recipes/grafu-castle-population.json",
"sha256": "b65eb823f8d1a7306dbafb17e4c4ed5e11899b9439af72b4d24b18ddbfd07bf8" "sha256": "29b4798fb43dc6c0ef8b5ec5dd1cc6007c4b84cc2753c4d38b47d005ff82b4cb"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -41154,11 +41177,11 @@
}, },
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"generatedRuntime": { "generatedRuntime": {
"path": "src/game/generated/grafuCastlePopulation.generated.ts", "path": "src/game/generated/grafuCastlePopulation.generated.ts",
"sha256": "ed998e499878f675d1223ea24c966816ed22dae1beccfd7ae6e3f701ff06efd6" "sha256": "e3678e53911590bcf2fbde56272330d903b76a3f8a84a96f8bef43f671c0a07d"
}, },
"upstreamHashes": { "upstreamHashes": {
"backups": { "backups": {
@@ -41173,11 +41196,11 @@
} }
}, },
"packageAssessment": { "packageAssessment": {
"status": "deferred-source-gap", "status": "verified",
"fidelity": "source-exact", "fidelity": "source-exact",
"blocker": "The package is non-empty, but its preserved source classification confirms no boss objectives.", "blocker": "No package-level population blocker is recorded.",
"nextWork": "Recover authoritative boss membership and encounter order before creating boss objectives.", "nextWork": "Re-run the ledger whenever portable source content changes.",
"clearingTest": "Add a source-backed boss-membership fixture and verify the generated objective list." "clearingTest": "npm run runewaker:audit:gameplay"
}, },
"deferredRowStatusCounts": { "deferredRowStatusCounts": {
"implemented": 0, "implemented": 0,
@@ -41191,14 +41214,14 @@
"evidenceStatusCounts": { "evidenceStatusCounts": {
"implemented": 0, "implemented": 0,
"verified": 10, "verified": 10,
"deferred-source-gap": 40, "deferred-source-gap": 46,
"deferred-asset-gap": 0, "deferred-asset-gap": 0,
"deferred-runtime-gap": 0, "deferred-runtime-gap": 0,
"excluded-non-gameplay": 0, "excluded-non-gameplay": 0,
"intentional-empty": 0 "intentional-empty": 0
}, },
"evidenceFidelityCounts": { "evidenceFidelityCounts": {
"source-exact": 40, "source-exact": 46,
"source-backed-adapted": 10, "source-backed-adapted": 10,
"healer-man-authored": 0 "healer-man-authored": 0
}, },
@@ -47823,7 +47846,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -47851,7 +47874,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -47879,7 +47902,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -47907,7 +47930,35 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
},
"snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
"sha256": "aa70f57055eddd21c6280879e6459036b81ae17b56ed6d8eb30b3825a716d403",
"contentSha256": "0113f241c8bb527a6e0bd365c2cf9586a6f922719098aea009292f3f3c04c801"
}
},
"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."
},
{
"id": "signal:grafu-castle:template:105204:autoplot:8695922bb376",
"kind": "mechanics-evidence",
"evidenceKind": "auto-plot",
"dungeonId": "grafu-castle",
"zoneId": 137,
"sourceIds": {
"templateId": 105204
},
"categories": [],
"value": "cl_136_105205_N",
"status": "deferred-source-gap",
"fidelity": "source-exact",
"source": {
"mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -47935,7 +47986,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -47963,7 +48014,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -47993,7 +48044,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48021,7 +48072,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48052,7 +48103,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48082,7 +48133,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48110,7 +48161,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48141,7 +48192,35 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
},
"snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
"sha256": "aa70f57055eddd21c6280879e6459036b81ae17b56ed6d8eb30b3825a716d403",
"contentSha256": "0113f241c8bb527a6e0bd365c2cf9586a6f922719098aea009292f3f3c04c801"
}
},
"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."
},
{
"id": "signal:grafu-castle:template:105224:autoplot:91708b8323d7",
"kind": "mechanics-evidence",
"evidenceKind": "auto-plot",
"dungeonId": "grafu-castle",
"zoneId": 137,
"sourceIds": {
"templateId": 105224
},
"categories": [],
"value": "ic_105224_01",
"status": "deferred-source-gap",
"fidelity": "source-exact",
"source": {
"mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48169,7 +48248,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48197,7 +48276,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48225,7 +48304,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48253,7 +48332,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48281,7 +48360,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48309,7 +48388,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48337,7 +48416,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48365,7 +48444,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48393,7 +48472,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48421,7 +48500,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48449,7 +48528,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48477,7 +48556,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48505,7 +48584,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48533,7 +48612,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48561,7 +48640,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48589,7 +48668,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48617,7 +48696,91 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
},
"snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
"sha256": "aa70f57055eddd21c6280879e6459036b81ae17b56ed6d8eb30b3825a716d403",
"contentSha256": "0113f241c8bb527a6e0bd365c2cf9586a6f922719098aea009292f3f3c04c801"
}
},
"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."
},
{
"id": "signal:grafu-castle:template:105351:autoplot:cb2a2839d2c8",
"kind": "mechanics-evidence",
"evidenceKind": "auto-plot",
"dungeonId": "grafu-castle",
"zoneId": 137,
"sourceIds": {
"templateId": 105351
},
"categories": [],
"value": "un_zone19_boss5_allconreal_1_01",
"status": "deferred-source-gap",
"fidelity": "source-exact",
"source": {
"mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
},
"snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
"sha256": "aa70f57055eddd21c6280879e6459036b81ae17b56ed6d8eb30b3825a716d403",
"contentSha256": "0113f241c8bb527a6e0bd365c2cf9586a6f922719098aea009292f3f3c04c801"
}
},
"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."
},
{
"id": "signal:grafu-castle:template:105542:autoplot:5ae3fcd9ed91",
"kind": "mechanics-evidence",
"evidenceKind": "auto-plot",
"dungeonId": "grafu-castle",
"zoneId": 137,
"sourceIds": {
"templateId": 105542
},
"categories": [],
"value": "kg_105542_01",
"status": "deferred-source-gap",
"fidelity": "source-exact",
"source": {
"mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
},
"snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
"sha256": "aa70f57055eddd21c6280879e6459036b81ae17b56ed6d8eb30b3825a716d403",
"contentSha256": "0113f241c8bb527a6e0bd365c2cf9586a6f922719098aea009292f3f3c04c801"
}
},
"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."
},
{
"id": "signal:grafu-castle:template:105543:autoplot:5ae3fcd9ed91",
"kind": "mechanics-evidence",
"evidenceKind": "auto-plot",
"dungeonId": "grafu-castle",
"zoneId": 137,
"sourceIds": {
"templateId": 105543
},
"categories": [],
"value": "kg_105542_01",
"status": "deferred-source-gap",
"fidelity": "source-exact",
"source": {
"mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48650,7 +48813,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48691,7 +48854,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48724,7 +48887,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48760,7 +48923,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48793,7 +48956,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48834,7 +48997,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48875,7 +49038,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48908,7 +49071,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48949,7 +49112,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -48990,7 +49153,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -49031,7 +49194,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -49072,7 +49235,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -49100,7 +49263,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -49128,7 +49291,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -49159,7 +49322,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -49192,7 +49355,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -49225,7 +49388,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -49266,7 +49429,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -49307,7 +49470,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -49340,7 +49503,7 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -49373,7 +49536,35 @@
"source": { "source": {
"mechanicsAudit": { "mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json", "path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348" "sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
},
"snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
"sha256": "aa70f57055eddd21c6280879e6459036b81ae17b56ed6d8eb30b3825a716d403",
"contentSha256": "0113f241c8bb527a6e0bd365c2cf9586a6f922719098aea009292f3f3c04c801"
}
},
"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."
},
{
"id": "signal:grafu-castle:template:105616:autoplot:6b24b50e07a5",
"kind": "mechanics-evidence",
"evidenceKind": "auto-plot",
"dungeonId": "grafu-castle",
"zoneId": 137,
"sourceIds": {
"templateId": 105616
},
"categories": [],
"value": "Dy_Z19_BOSSN_AIC",
"status": "deferred-source-gap",
"fidelity": "source-exact",
"source": {
"mechanicsAudit": {
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
"sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
}, },
"snapshot": { "snapshot": {
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -11,10 +11,10 @@ is a review label, not a unique mechanic; one evidence record may belong to seve
- 30 dungeon packages. - 30 dungeon packages.
- 4113 deferred source rows with stable spawn IDs. - 4113 deferred source rows with stable spawn IDs.
- 1524 unique mechanics evidence records: 329 Lua lines and 1195 template references. - 1530 unique mechanics evidence records: 329 Lua lines and 1201 template references.
- 1146 overlapping category memberships; this number is intentionally not reported as mechanics. - 1146 overlapping category memberships; this number is intentionally not reported as mechanics.
- 388 evidence records have an exact executable source-spell mapping. - 388 evidence records have an exact executable source-spell mapping.
- 1136 evidence records remain source-review gaps. - 1142 evidence records remain source-review gaps.
## Status definitions ## Status definitions
@@ -37,7 +37,7 @@ is a review label, not a unique mechanic; one evidence record may belong to seve
| demon-stronghold-125 | 0 | 0 | 0 | 0 | 0 | 0 | intentional-empty | | demon-stronghold-125 | 0 | 0 | 0 | 0 | 0 | 0 | intentional-empty |
| dungeon-of-dalanis | 229 | 162 | 26 | 5 | 21 | 0 | deferred-source-gap | | dungeon-of-dalanis | 229 | 162 | 26 | 5 | 21 | 0 | deferred-source-gap |
| forsaken-abbey | 251 | 101 | 77 | 0 | 77 | 8 | verified | | forsaken-abbey | 251 | 101 | 77 | 0 | 77 | 8 | verified |
| grafu-castle | 249 | 193 | 50 | 10 | 40 | 0 | deferred-source-gap | | grafu-castle | 249 | 193 | 56 | 10 | 46 | 5 | verified |
| hall-of-survivors | 363 | 201 | 84 | 34 | 50 | 9 | verified | | hall-of-survivors | 363 | 201 | 84 | 34 | 50 | 9 | verified |
| hoto | 401 | 94 | 95 | 28 | 67 | 5 | verified | | hoto | 401 | 94 | 95 | 28 | 67 | 5 | verified |
| ice-dwarf-kingdom | 651 | 52 | 87 | 36 | 51 | 5 | verified | | ice-dwarf-kingdom | 651 | 52 | 87 | 36 | 51 | 5 | verified |
@@ -69,6 +69,7 @@ is a review label, not a unique mechanic; one evidence record may belong to seve
| Flowing Chaos | `rw-fa-100561` | 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. | | 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. | | Hollow Shell | `rw-fa-100563` | implemented | healer-man-authored | Preserved source evidence does not expose an executable original combat kit. |
| Larvanger Barkud | `rw-grafu-castle-105351` | 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 These attacks are intentionally playable and telegraphed HealerMan behavior. They are not
presented as original RuneWaker mechanics until authoritative source behavior is recovered. presented as original RuneWaker mechanics until authoritative source behavior is recovered.
@@ -77,7 +78,6 @@ presented as original RuneWaker mechanics until authoritative source behavior is
- `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. - `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. - `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. - `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. - `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. - `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.
@@ -108,10 +108,10 @@ presented as original RuneWaker mechanics until authoritative source behavior is
| Blocker group | Items | Clearing work | | Blocker group | Items | Clearing work |
| --- | ---: | --- | | --- | ---: | --- |
| deferredSourceRows | 4113 | Review source semantics and add explicit source-spawn encounter mappings. | | 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. | | unresolvedMechanicsEvidence | 1142 | Review surrounding Lua/template context; specify full trigger, effect, and reset behavior. |
| adaptedSpellFormula | 388 | Recover native formula inputs before claiming coefficient-level fidelity. | | 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. | | sourceGapFallbackBosses | 5 | Recover authoritative combat kits and replace only with source-backed modular mechanics. |
| nonEmptyZeroBossPackages | 8 | Recover authoritative boss membership and objective order. | | nonEmptyZeroBossPackages | 7 | Recover authoritative boss membership and objective order. |
The exhaustive per-item blocker, next-work instruction, clearing test, source IDs, source 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`. files, and SHA-256 hashes are in `RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json`.
@@ -1,36 +1,36 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"validationStatus": "green", "validationStatus": "blocked",
"fidelityStatus": "review-required", "fidelityStatus": "review-required",
"scope": "All configured offline RuneWaker dungeon packages", "scope": "All configured offline RuneWaker dungeon packages",
"summary": { "summary": {
"sourceRows": 8199, "sourceRows": 8199,
"runtimeSpawns": 4086, "runtimeSpawns": 4114,
"runtimeEntities": 436, "runtimeEntities": 446,
"bossObjectives": 61, "bossObjectives": 66,
"runtimeAttacks": 1068, "runtimeAttacks": 1133,
"genericDefaultAttacks": 436, "genericDefaultAttacks": 446,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"catalogBossMechanics": 279, "catalogBossMechanics": 334,
"sourceBackedBossMechanics": 275, "sourceBackedBossMechanics": 329,
"sourceGapBossMechanics": 4, "sourceGapBossMechanics": 5,
"telegraphedBossMechanics": 111, "telegraphedBossMechanics": 143,
"healthTriggeredBossMechanics": 28, "healthTriggeredBossMechanics": 28,
"sourceBackedDamageFamilyAttacks": 483, "sourceBackedDamageFamilyAttacks": 514,
"sourceSpellTemplates": 293, "sourceSpellTemplates": 293,
"sourceSpellAssignments": 659, "sourceSpellAssignments": 659,
"scriptedTemplates": 357, "scriptedTemplates": 363,
"nativeBindings": 435, "nativeBindings": 445,
"proceduralBindings": 1, "proceduralBindings": 1,
"proxyBindings": 0, "proxyBindings": 0,
"objectiveRoutes": 61, "objectiveRoutes": 66,
"blockedObjectiveRoutes": 0, "blockedObjectiveRoutes": 3,
"sourceOrderedObjectiveRoutes": 0, "sourceOrderedObjectiveRoutes": 5,
"derivedObjectiveRoutes": 61, "derivedObjectiveRoutes": 61,
"unreachableDerivedObjectiveRoutes": 19, "unreachableDerivedObjectiveRoutes": 19,
"dungeons": 30, "dungeons": 30,
"uniqueNativeModels": 321, "uniqueNativeModels": 328,
"uniqueSourceSpellIds": 411, "uniqueSourceSpellIds": 411,
"portableSpellCatalogRecords": 3199, "portableSpellCatalogRecords": 3199,
"portableDamageFamilyRecords": 1342, "portableDamageFamilyRecords": 1342,
@@ -79,9 +79,28 @@
], ],
"blockerGroups": { "blockerGroups": {
"navigation": { "navigation": {
"blockedLegs": 0, "blockedLegs": 3,
"affectedDungeons": 0, "affectedDungeons": 1,
"entries": [], "entries": [
{
"dungeonId": "grafu-castle",
"from": "entrance",
"to": "Cayus",
"objectiveId": "rw-grafu-castle-scripted-cayus"
},
{
"dungeonId": "grafu-castle",
"from": "Cayus",
"to": "Ardmond and Issac",
"objectiveId": "rw-grafu-castle-scripted-ardmond"
},
{
"dungeonId": "grafu-castle",
"from": "Ardmond and Issac",
"to": "Boro-Boro Brothers",
"objectiveId": "rw-grafu-castle-scripted-boro-boro-brothers"
}
],
"unreachableDerivedLegs": 19, "unreachableDerivedLegs": 19,
"derivedAffectedDungeons": 8, "derivedAffectedDungeons": 8,
"derivedEntries": [ "derivedEntries": [
@@ -223,10 +242,10 @@
"sourceMechanics": { "sourceMechanics": {
"status": "review-required", "status": "review-required",
"executableBossMechanicsGenerated": true, "executableBossMechanicsGenerated": true,
"catalogBossMechanics": 279, "catalogBossMechanics": 334,
"sourceBackedBossMechanics": 275, "sourceBackedBossMechanics": 329,
"sourceGapBossMechanics": 4, "sourceGapBossMechanics": 5,
"scriptedTemplates": 357, "scriptedTemplates": 363,
"evidenceCounts": { "evidenceCounts": {
"death": 75, "death": 75,
"door": 22, "door": 22,
@@ -258,7 +277,7 @@
"validationStatus": "green", "validationStatus": "green",
"completionStatus": "review-required", "completionStatus": "review-required",
"deferredSourceRows": 4113, "deferredSourceRows": 4113,
"uniqueMechanicsEvidence": 1524 "uniqueMechanicsEvidence": 1530
}, },
"resolvedRuntimeObservations": [ "resolvedRuntimeObservations": [
{ {
@@ -272,11 +291,15 @@
"documentedExceptions": [ "documentedExceptions": [
"/assets/creatures/sardo-castle/rune-device-red.glb: move exempt - Rune Device is a source-authored stationary encounter device; its native GLB retains stand, attack/activation, cast, wound, and death clips." "/assets/creatures/sardo-castle/rune-device-red.glb: move exempt - Rune Device is a source-authored stationary encounter device; its native GLB retains stand, attack/activation, cast, wound, and death clips."
], ],
"issues": [], "issues": [
"grafu-castle: packaged navigation cannot route entrance -> Cayus with the live 3m/6m endpoint limits.",
"grafu-castle: packaged navigation cannot route Cayus -> Ardmond and Issac with the live 3m/6m endpoint limits.",
"grafu-castle: packaged navigation cannot route Ardmond and Issac -> Boro-Boro Brothers with the live 3m/6m endpoint limits."
],
"reviewNotes": [ "reviewNotes": [
"demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.", "demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.",
"zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.", "zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.",
"4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.", "5 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.",
"Source formula structure is preserved, but live damage uses a labeled HealerMan baseline adapter because the required native combat-stat inputs are not packaged.", "Source formula structure is preserved, but live damage uses a labeled HealerMan baseline adapter because the required native combat-stat inputs are not packaged.",
"61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence." "61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence."
], ],
@@ -774,38 +797,145 @@
"id": "grafu-castle", "id": "grafu-castle",
"zoneId": 137, "zoneId": 137,
"sourceRows": 249, "sourceRows": 249,
"runtimeSpawns": 56, "runtimeSpawns": 84,
"runtimeEntities": 11, "runtimeEntities": 21,
"bossObjectives": 0, "bossObjectives": 5,
"mechanicsStatus": "review-required", "mechanicsStatus": "review-required",
"objectiveOrderAuthoritative": false, "objectiveOrderAuthoritative": true,
"objectiveOrderProvenance": { "objectiveOrderProvenance": {
"authoritative": false, "authoritative": true,
"kind": "healerMan-distance-derived", "kind": "source-authored",
"evidence": [ "evidence": [
"scripts/runewaker-pipeline/lib/objective-order.mjs" "Resource/luascript/02345.lua:Dy_Z19_BOSSN_AIC",
"Resource/luascript/02324.lua:kg_105542_01",
"Resource/luascript/02231.lua:ic_105224_01",
"Resource/luascript/02330.lua:cl_136_105205_N",
"Resource/luascript/02297.lua:un_zone19_boss5_allconreal_1_01"
] ]
}, },
"objectiveRouteEvidence": [], "objectiveRouteEvidence": [
"runtimeAttacks": 21, {
"genericDefaultAttacks": 11, "from": "entrance",
"to": "Cayus",
"objectiveId": "rw-grafu-castle-scripted-cayus",
"approachPosition": null,
"objectiveProjectionDistance": null,
"reachable": false,
"waypointCount": 0,
"orderAuthoritative": true,
"orderKind": "source-authored",
"orderEvidence": [
"Resource/luascript/02345.lua:Dy_Z19_BOSSN_AIC",
"Resource/luascript/02324.lua:kg_105542_01",
"Resource/luascript/02231.lua:ic_105224_01",
"Resource/luascript/02330.lua:cl_136_105205_N",
"Resource/luascript/02297.lua:un_zone19_boss5_allconreal_1_01"
]
},
{
"from": "Cayus",
"to": "Ardmond and Issac",
"objectiveId": "rw-grafu-castle-scripted-ardmond",
"approachPosition": null,
"objectiveProjectionDistance": null,
"reachable": false,
"waypointCount": 0,
"orderAuthoritative": true,
"orderKind": "source-authored",
"orderEvidence": [
"Resource/luascript/02345.lua:Dy_Z19_BOSSN_AIC",
"Resource/luascript/02324.lua:kg_105542_01",
"Resource/luascript/02231.lua:ic_105224_01",
"Resource/luascript/02330.lua:cl_136_105205_N",
"Resource/luascript/02297.lua:un_zone19_boss5_allconreal_1_01"
]
},
{
"from": "Ardmond and Issac",
"to": "Boro-Boro Brothers",
"objectiveId": "rw-grafu-castle-scripted-boro-boro-brothers",
"approachPosition": [
-176.317575,
-17.93685769127901,
-4.85641198856605
],
"objectiveProjectionDistance": 3.0893265615044496e-7,
"reachable": false,
"waypointCount": 0,
"orderAuthoritative": true,
"orderKind": "source-authored",
"orderEvidence": [
"Resource/luascript/02345.lua:Dy_Z19_BOSSN_AIC",
"Resource/luascript/02324.lua:kg_105542_01",
"Resource/luascript/02231.lua:ic_105224_01",
"Resource/luascript/02330.lua:cl_136_105205_N",
"Resource/luascript/02297.lua:un_zone19_boss5_allconreal_1_01"
]
},
{
"from": "Boro-Boro Brothers",
"to": "Conjurer Chapeaunoir",
"objectiveId": "rw-grafu-castle-scripted-conjurer-chapeaunoir",
"approachPosition": [
-132.39614900162002,
-35.924047364518444,
-126.16280800000898
],
"objectiveProjectionDistance": 3.6452204204830595e-7,
"reachable": true,
"waypointCount": 113,
"orderAuthoritative": true,
"orderKind": "source-authored",
"orderEvidence": [
"Resource/luascript/02345.lua:Dy_Z19_BOSSN_AIC",
"Resource/luascript/02324.lua:kg_105542_01",
"Resource/luascript/02231.lua:ic_105224_01",
"Resource/luascript/02330.lua:cl_136_105205_N",
"Resource/luascript/02297.lua:un_zone19_boss5_allconreal_1_01"
]
},
{
"from": "Conjurer Chapeaunoir",
"to": "Larvanger Barkud",
"objectiveId": "rw-grafu-castle-scripted-larvanger-barkud",
"approachPosition": [
10.319202,
-37.197608947753906,
-101.219726
],
"objectiveProjectionDistance": 5.2246093673602445e-8,
"reachable": true,
"waypointCount": 105,
"orderAuthoritative": true,
"orderKind": "source-authored",
"orderEvidence": [
"Resource/luascript/02345.lua:Dy_Z19_BOSSN_AIC",
"Resource/luascript/02324.lua:kg_105542_01",
"Resource/luascript/02231.lua:ic_105224_01",
"Resource/luascript/02330.lua:cl_136_105205_N",
"Resource/luascript/02297.lua:un_zone19_boss5_allconreal_1_01"
]
}
],
"runtimeAttacks": 86,
"genericDefaultAttacks": 21,
"builderAuthoredAttacks": 0, "builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0, "recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0, "catalogBossMechanics": 55,
"sourceBackedBossMechanics": 0, "sourceBackedBossMechanics": 54,
"sourceGapBossMechanics": 0, "sourceGapBossMechanics": 1,
"telegraphedBossMechanics": 0, "telegraphedBossMechanics": 32,
"healthTriggeredBossMechanics": 0, "healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 10, "sourceBackedDamageFamilyAttacks": 41,
"nativeBindings": 11, "nativeBindings": 21,
"proceduralBindings": 0, "proceduralBindings": 0,
"proxyBindings": 0, "proxyBindings": 0,
"sourceSpellTemplates": 6, "sourceSpellTemplates": 6,
"sourceSpellAssignments": 17, "sourceSpellAssignments": 17,
"scriptedTemplates": 15, "scriptedTemplates": 21,
"objectiveRoutes": 0, "objectiveRoutes": 5,
"blockedObjectiveRoutes": 0, "blockedObjectiveRoutes": 3,
"sourceOrderedObjectiveRoutes": 0, "sourceOrderedObjectiveRoutes": 5,
"derivedObjectiveRoutes": 0, "derivedObjectiveRoutes": 0,
"unreachableDerivedObjectiveRoutes": 0 "unreachableDerivedObjectiveRoutes": 0
}, },
@@ -1,6 +1,6 @@
# RuneWaker gameplay content audit # RuneWaker gameplay content audit
Structural validation: **green** Structural validation: **blocked**
Original-mechanics fidelity: **review-required** Original-mechanics fidelity: **review-required**
This offline audit reads packaged recipes, snapshots, generated TypeScript, reports, This offline audit reads packaged recipes, snapshots, generated TypeScript, reports,
@@ -8,15 +8,15 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
## Coverage ## Coverage
- 30 dungeons, 8199 source rows, 4086 static spawns, and 436 combat entities. - 30 dungeons, 8199 source rows, 4114 static spawns, and 446 combat entities.
- 61 boss objectives and 1068 executable attacks. - 66 boss objectives and 1133 executable attacks.
- Source-authored objective navigation: 0/0 ordered entrance/boss legs are mesh-reachable. - Source-authored objective navigation: 2/5 ordered entrance/boss legs are mesh-reachable.
- HealerMan-derived objective diagnostics: 42/61 distance-ordered legs are mesh-reachable; unreachable derived legs are review evidence, not structural blockers. - HealerMan-derived objective diagnostics: 42/61 distance-ordered legs are mesh-reachable; unreachable derived legs are review evidence, not structural blockers.
- Animation entity bindings: 435 native, 1 static procedural, and 0 proxy. - Animation entity bindings: 445 native, 1 static procedural, and 0 proxy.
- 293 templates preserve source spell evidence (659 assignments; 411 unique IDs); 357 preserve AutoPlot/Lua hook evidence. - 293 templates preserve source spell evidence (659 assignments; 411 unique IDs); 363 preserve AutoPlot/Lua hook evidence.
- 279 modular boss mechanics are active: 275 source-backed and 4 explicitly flagged source-gap fallbacks. - 334 modular boss mechanics are active: 329 source-backed and 5 explicitly flagged source-gap fallbacks.
- Boss signaling includes 111 spatial telegraphs and 28 visible health-triggered mechanics. - Boss signaling includes 143 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. - The exhaustive encounter implementation ledger tracks 4113 deferred source rows and 1530 unique mechanics evidence records without inflating overlapping regex labels.
## Authentic source data currently used ## Authentic source data currently used
@@ -28,9 +28,9 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
## HealerMan-generic or deferred ## HealerMan-generic or deferred
- 436 entities use generated Basic Attack/Corrosive Slam. - 446 entities use generated Basic Attack/Corrosive Slam.
- 0 attacks are builder exceptions outside the modular boss catalog. - 0 attacks are builder exceptions outside the modular boss catalog.
- 483 executable attacks carry a conservative source-backed direct-damage family mapping. - 514 executable attacks carry a conservative source-backed direct-damage family mapping.
- Source-backed mapping preserves spell identity, damage family, school, range, cooldown, - Source-backed mapping preserves spell identity, damage family, school, range, cooldown,
source power fields, and the exact native formula shape/provenance. source power fields, and the exact native formula shape/provenance.
- Damage execution uses a labeled HealerMan baseline adapter because native ATK/MATK, - Damage execution uses a labeled HealerMan baseline adapter because native ATK/MATK,
@@ -53,7 +53,7 @@ Animation columns are native/procedural/proxy entity bindings.
| demon-stronghold-125 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0/0/0 | 0/0 derived | review-required | | demon-stronghold-125 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0/0/0 | 0/0 derived | review-required |
| dungeon-of-dalanis | 229 | 67 | 17 | 0 | 22 | 9 | 13 | 17/0/0 | 0/0 derived | review-required | | dungeon-of-dalanis | 229 | 67 | 17 | 0 | 22 | 9 | 13 | 17/0/0 | 0/0 derived | review-required |
| forsaken-abbey | 251 | 150 | 15 | 8 | 26 | 0 | 11 | 15/0/0 | 8/8 derived | review-required | | forsaken-abbey | 251 | 150 | 15 | 8 | 26 | 0 | 11 | 15/0/0 | 8/8 derived | review-required |
| grafu-castle | 249 | 56 | 11 | 0 | 21 | 6 | 15 | 11/0/0 | 0/0 derived | review-required | | grafu-castle | 249 | 84 | 21 | 5 | 86 | 6 | 21 | 21/0/0 | 2/5 source | review-required |
| hall-of-survivors | 363 | 162 | 40 | 9 | 122 | 23 | 25 | 39/1/0 | 9/9 derived | review-required | | hall-of-survivors | 363 | 162 | 40 | 9 | 122 | 23 | 25 | 39/1/0 | 9/9 derived | review-required |
| hoto | 401 | 307 | 26 | 5 | 75 | 19 | 15 | 26/0/0 | 2/5 derived | review-required | | hoto | 401 | 307 | 26 | 5 | 75 | 19 | 15 | 26/0/0 | 2/5 derived | review-required |
| ice-dwarf-kingdom | 651 | 599 | 32 | 5 | 80 | 25 | 11 | 32/0/0 | 3/5 derived | review-required | | ice-dwarf-kingdom | 651 | 599 | 32 | 5 | 80 | 25 | 11 | 32/0/0 | 3/5 derived | review-required |
@@ -91,8 +91,11 @@ Animation columns are native/procedural/proxy entity bindings.
## Remaining blocker groups ## Remaining blocker groups
Navigation: 0 blocked source-authored ordered legs across 0 dungeons. Navigation: 3 blocked source-authored ordered legs across 1 dungeons.
- grafu-castle: entrance -> Cayus
- grafu-castle: Cayus -> Ardmond and Issac
- grafu-castle: Ardmond and Issac -> Boro-Boro Brothers
Derived-order navigation review: 19 unreachable heuristic legs across 8 dungeons. Derived-order navigation review: 19 unreachable heuristic legs across 8 dungeons.
@@ -116,7 +119,7 @@ Derived-order navigation review: 19 unreachable heuristic legs across 8 dungeons
- ystra-labyrinth-boss1: Specter Guardian Creator -> Raichika the Destroyer (healerMan-distance-derived) - ystra-labyrinth-boss1: Specter Guardian Creator -> Raichika the Destroyer (healerMan-distance-derived)
- ystra-labyrinth-boss1: Raichika the Destroyer -> Rune Guardian Leader (healerMan-distance-derived) - ystra-labyrinth-boss1: Raichika the Destroyer -> Rune Guardian Leader (healerMan-distance-derived)
Source-mechanics review: 357 templates preserve AutoPlot/Lua evidence; supported boss combat behavior is compiled into the modular catalog. Source-mechanics review: 363 templates preserve AutoPlot/Lua evidence; supported boss combat behavior is compiled into the modular catalog.
- death: 75 - death: 75
- door: 22 - door: 22
@@ -130,7 +133,9 @@ Source-mechanics review: 357 templates preserve AutoPlot/Lua evidence; supported
## Validation issues ## Validation issues
- None. - grafu-castle: packaged navigation cannot route entrance -> Cayus with the live 3m/6m endpoint limits.
- grafu-castle: packaged navigation cannot route Cayus -> Ardmond and Issac with the live 3m/6m endpoint limits.
- grafu-castle: packaged navigation cannot route Ardmond and Issac -> Boro-Boro Brothers with the live 3m/6m endpoint limits.
## Documented exceptions ## Documented exceptions
@@ -144,6 +149,6 @@ Source-mechanics review: 357 templates preserve AutoPlot/Lua evidence; supported
- demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population. - demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.
- zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population. - zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.
- 4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell. - 5 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.
- Source formula structure is preserved, but live damage uses a labeled HealerMan baseline adapter because the required native combat-stat inputs are not packaged. - Source formula structure is preserved, but live damage uses a labeled HealerMan baseline adapter because the required native combat-stat inputs are not packaged.
- 61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence. - 61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence.
@@ -437,7 +437,8 @@ for (const recipeName of recipeNames) {
sourceTemplateIds.add(row.templateId); sourceTemplateIds.add(row.templateId);
const template = templateById.get(row.templateId); const template = templateById.get(row.templateId);
if (!template) issue(issues, dungeonId, "source template " + row.templateId + " is unmapped."); if (!template) issue(issues, dungeonId, "source template " + row.templateId + " is unmapped.");
else if (template.classification !== row.classification) { else if (template.classification !== row.classification
&& template.source?.classificationRule !== "reviewed Lua controller override") {
issue(issues, dungeonId, "classification drift for template " + row.templateId + "."); issue(issues, dungeonId, "classification drift for template " + row.templateId + ".");
} }
} }
@@ -651,8 +652,13 @@ for (const recipeName of recipeNames) {
} }
if (!boss.name?.trim()) issue(issues, dungeonId, boss.id + " objective has no name."); if (!boss.name?.trim()) issue(issues, dungeonId, boss.id + " objective has no name.");
} }
const runtimePrefix = recipe.runtimeIdPrefix ?? "rw-" + dungeonId;
const nonObjectiveBossIds = new Set((recipe.scriptedSpawns ?? [])
.filter((placement) => placement.objective === false)
.map((placement) => runtimePrefix + "-scripted-" + placement.id));
for (const spawn of spawns) { for (const spawn of spawns) {
if (entities[spawn.entityId]?.kind === "boss" && !bossIds.has(spawn.id)) { if (entities[spawn.entityId]?.kind === "boss" && !bossIds.has(spawn.id)
&& !nonObjectiveBossIds.has(spawn.id)) {
issue(issues, dungeonId, spawn.id + " boss has no objective."); issue(issues, dungeonId, spawn.id + " boss has no objective.");
} }
} }
@@ -702,8 +708,15 @@ for (const recipeName of recipeNames) {
!route.orderAuthoritative && !route.reachable !route.orderAuthoritative && !route.reachable
)).length; )).length;
const scriptedTemplateIds = new Set([
...(recipe.scriptedSpawns ?? []).map((placement) => placement.templateId),
...(recipe.scriptedFormations ?? []).flatMap((formation) => (
formation.members.map((member) => member.templateId)
)),
]);
const expectedEntities = recipe.templates.filter((template) => ( const expectedEntities = recipe.templates.filter((template) => (
["combat", "boss"].includes(template.classification) && sourceTemplateIds.has(template.id) ["combat", "boss"].includes(template.classification)
&& (sourceTemplateIds.has(template.id) || scriptedTemplateIds.has(template.id))
)).length; )).length;
if (expectedEntities !== Object.keys(entities).length) { if (expectedEntities !== Object.keys(entities).length) {
issue(issues, dungeonId, "combat source templates and entity counts disagree."); issue(issues, dungeonId, "combat source templates and entity counts disagree.");
+102 -2
View File
@@ -431,6 +431,35 @@ function yawFromDirection(direction) {
return Number(((((direction % turn) + turn) % turn) * Math.PI * 2 / turn).toFixed(9)); return Number(((((direction % turn) + turn) % turn) * Math.PI * 2 / turn).toFixed(9));
} }
function scriptedPlacements() {
const placements = [...(recipe.scriptedSpawns ?? [])];
for (const formation of recipe.scriptedFormations ?? []) {
const radians = formation.direction * Math.PI / 180;
for (const [index, member] of formation.members.entries()) {
const rowDistance = (member.row - 1) * formation.spacingSourceUnits;
const columnDistance = member.column * formation.spacingSourceUnits;
placements.push({
id: formation.id + "-" + (index + 1),
templateId: member.templateId,
sourceAnchorSpawnId: formation.sourceAnchorSpawnId,
controllerSpawnId: formation.controllerSpawnId ?? formation.sourceAnchorSpawnId,
sourceOffset: [
-rowDistance * Math.cos(radians) - columnDistance * Math.sin(radians),
0,
rowDistance * Math.sin(radians) - columnDistance * Math.cos(radians),
],
direction: formation.direction,
sourceScriptRoot: formation.sourceScriptRoot,
evidence: formation.evidence,
});
}
}
if (new Set(placements.map((placement) => placement.id)).size !== placements.length) {
throw new Error("Scripted placement ids must be unique after formation expansion.");
}
return placements;
}
function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) { function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
const ooze = template.archetype === "ooze"; const ooze = template.archetype === "ooze";
const basicAttack = { const basicAttack = {
@@ -564,7 +593,8 @@ function validateSnapshot(snapshot, templates) {
const template = templates.get(row.templateId); const template = templates.get(row.templateId);
if (!template) throw new Error("Unmapped RuneWaker template " + row.templateId + "."); if (!template) throw new Error("Unmapped RuneWaker template " + row.templateId + ".");
if (template.classification === "unclassified") throw new Error("Unreviewed template " + row.templateId + "."); if (template.classification === "unclassified") throw new Error("Unreviewed template " + row.templateId + ".");
if (row.classification !== template.classification) { if (row.classification !== template.classification
&& template.source?.classificationRule !== "reviewed Lua controller override") {
throw new Error("Classification drift for template " + row.templateId + "."); throw new Error("Classification drift for template " + row.templateId + ".");
} }
const actor = recipe.actors.find((entry) => entry.id === template.actor); const actor = recipe.actors.find((entry) => entry.id === template.actor);
@@ -636,6 +666,8 @@ async function generatePopulation() {
const records = []; const records = [];
const rawBosses = []; const rawBosses = [];
const countsByTemplate = {}; const countsByTemplate = {};
const rowsBySpawnId = new Map(snapshot.rows.map((row) => [row.spawnId, row]));
const scripted = scriptedPlacements();
try { try {
for (const row of snapshot.rows) { for (const row of snapshot.rows) {
const template = templates.get(row.templateId); const template = templates.get(row.templateId);
@@ -648,7 +680,8 @@ async function generatePopulation() {
sourceSpawnId: row.spawnId, sourceSpawnId: row.spawnId,
sourceTemplateId: row.templateId, sourceTemplateId: row.templateId,
name: row.name, name: row.name,
classification: row.classification, classification: template.classification,
sourceClassification: row.classification,
sourcePosition: row.sourcePosition, sourcePosition: row.sourcePosition,
sourceDirection: row.direction, sourceDirection: row.direction,
localPosition: local.map((value) => Number(value.toFixed(6))), localPosition: local.map((value) => Number(value.toFixed(6))),
@@ -687,6 +720,72 @@ async function generatePopulation() {
}); });
} }
} }
for (const placementDefinition of scripted) {
const anchor = rowsBySpawnId.get(placementDefinition.sourceAnchorSpawnId);
if (!anchor) {
throw new Error(
"Scripted placement " + placementDefinition.id + " has no source anchor row "
+ placementDefinition.sourceAnchorSpawnId + ".",
);
}
if (placementDefinition.controllerSpawnId !== undefined
&& !rowsBySpawnId.has(placementDefinition.controllerSpawnId)) {
throw new Error("Scripted placement " + placementDefinition.id + " has no controller row " + placementDefinition.controllerSpawnId + ".");
}
const template = templates.get(placementDefinition.templateId);
const offset = placementDefinition.sourceOffset ?? [0, 0, 0];
const sourcePosition = anchor.sourcePosition.map((value, index) => value + offset[index]);
const direction = placementDefinition.direction ?? anchor.direction;
const local = sourceToLocal(sourcePosition, placement);
const projected = projector.project(local);
if (projected.distance > recipe.coordinateSystem.maximumProjectionDistance) {
throw new Error("Scripted placement " + placementDefinition.id + " is " + projected.distance + "m from navigation.");
}
const entityId = runtimePrefix + "-" + template.id;
if (!entities[entityId]) {
entities[entityId] = entityFor(
template,
actorForTemplate(template),
[...(sourceSpellsByTemplate.get(template.id)?.values() ?? [])],
spellCatalog,
bossMechanics,
);
}
const spawn = {
id: runtimePrefix + "-scripted-" + placementDefinition.id,
entityId,
position: projected.position,
yaw: yawFromDirection(direction),
spawnMask: template.spawnMask ?? 1,
};
staticSpawns.push(spawn);
records.push({
sourceSpawnId: anchor.spawnId,
sourceTemplateId: template.id,
scriptedPlacementId: placementDefinition.id,
sourceControllerSpawnId: placementDefinition.controllerSpawnId ?? null,
sourceScriptRoot: placementDefinition.sourceScriptRoot ?? null,
evidence: placementDefinition.evidence,
name: template.name,
classification: template.classification,
sourcePosition,
sourceDirection: direction,
localPosition: local.map((value) => Number(value.toFixed(6))),
projectedPosition: projected.position,
projectionDistance: projected.distance,
modelPath: template.source?.modelPath ?? null,
});
countsByTemplate[template.id] = (countsByTemplate[template.id] ?? 0) + 1;
if (template.classification === "boss" && placementDefinition.objective !== false) {
rawBosses.push({
id: spawn.id,
sourceSpawnId: anchor.spawnId,
templateId: template.id,
name: template.name,
position: spawn.position,
});
}
}
} finally { } finally {
projector.dispose(); projector.dispose();
} }
@@ -720,6 +819,7 @@ async function generatePopulation() {
zoneId: recipe.zoneId, zoneId: recipe.zoneId,
sourceRowCount: snapshot.rows.length, sourceRowCount: snapshot.rows.length,
importedSpawnCount: staticSpawns.length, importedSpawnCount: staticSpawns.length,
scriptedSpawnCount: scripted.length,
excludedRowCount: excluded.length, excludedRowCount: excluded.length,
bossSpawnCount: bosses.length, bossSpawnCount: bosses.length,
objectiveOrder: objectiveOrder.provenance, objectiveOrder: objectiveOrder.provenance,
@@ -12,9 +12,9 @@ test("encounter ledger exhaustively classifies portable RuneWaker evidence", asy
assert.equal(report.validationStatus, "green", report.issues.join("\n")); assert.equal(report.validationStatus, "green", report.issues.join("\n"));
assert.equal(report.summary.dungeons, 30); assert.equal(report.summary.dungeons, 30);
assert.equal(report.summary.deferredSourceRows, 4_113); assert.equal(report.summary.deferredSourceRows, 4_113);
assert.equal(report.summary.uniqueMechanicsEvidence, 1_524); assert.equal(report.summary.uniqueMechanicsEvidence, 1_530);
assert.equal(report.summary.luaEvidenceSignals, 329); assert.equal(report.summary.luaEvidenceSignals, 329);
assert.equal(report.summary.templateEvidenceSignals, 1_195); assert.equal(report.summary.templateEvidenceSignals, 1_201);
const inventory = report.dungeons.flatMap((dungeon) => [ const inventory = report.dungeons.flatMap((dungeon) => [
...dungeon.deferredSourceRows, ...dungeon.deferredSourceRows,
@@ -54,7 +54,13 @@ test("source gaps, fallbacks, zero-boss packages, and empty variants stay explic
const report = await buildEncounterImplementationReport(); const report = await buildEncounterImplementationReport();
assert.deepEqual( assert.deepEqual(
report.specialCases.fallbackBosses.map((boss) => boss.entityId), report.specialCases.fallbackBosses.map((boss) => boss.entityId),
["rw-fa-100070", "rw-fa-100561", "rw-fa-100562", "rw-fa-100563"], [
"rw-fa-100070",
"rw-fa-100561",
"rw-fa-100562",
"rw-fa-100563",
"rw-grafu-castle-105351",
],
); );
assert.ok(report.specialCases.fallbackBosses.every((boss) => ( assert.ok(report.specialCases.fallbackBosses.every((boss) => (
boss.status === "implemented" && boss.fidelity === "healer-man-authored" boss.status === "implemented" && boss.fidelity === "healer-man-authored"
@@ -68,7 +74,6 @@ test("source gaps, fallbacks, zero-boss packages, and empty variants stay explic
[ [
"aeternal-circle", "aeternal-circle",
"dungeon-of-dalanis", "dungeon-of-dalanis",
"grafu-castle",
"kal-turok-hive", "kal-turok-hive",
"lair-demon-dragon", "lair-demon-dragon",
"tomb-seven-heroes", "tomb-seven-heroes",
@@ -44,6 +44,8 @@ const config = await readJson(configFile);
const dungeon = (config.dungeons ?? []).find((entry) => entry.id === dungeonConfigId); const dungeon = (config.dungeons ?? []).find((entry) => entry.id === dungeonConfigId);
if (!dungeon) throw new Error("Missing dungeon_config entry " + dungeonConfigId + "."); if (!dungeon) throw new Error("Missing dungeon_config entry " + dungeonConfigId + ".");
const snapshot = await readJson(snapshotFile); const snapshot = await readJson(snapshotFile);
const templatesById = new Map(recipe.templates.map((template) => [template.id, template]));
const rowsBySpawnId = new Map((snapshot.rows ?? []).map((row) => [row.spawnId, row]));
const SIGNALS = [ const SIGNALS = [
{ kind: "summon", pattern: /summon|spawn.*npc|create.*npc|create.*monster|add.*npc/i }, { kind: "summon", pattern: /summon|spawn.*npc|create.*npc|create.*monster|add.*npc/i },
@@ -115,10 +117,11 @@ for (const fileName of dungeon.lua_files ?? []) {
const templateMetadata = new Map(); const templateMetadata = new Map();
for (const row of snapshot.rows ?? []) { for (const row of snapshot.rows ?? []) {
const template = templatesById.get(row.templateId);
const metadata = templateMetadata.get(row.templateId) ?? { const metadata = templateMetadata.get(row.templateId) ?? {
templateId: row.templateId, templateId: row.templateId,
name: row.name, name: template?.name ?? row.name,
classification: row.classification, classification: template?.classification ?? row.classification,
spawnCount: 0, spawnCount: 0,
autoPlots: new Set(), autoPlots: new Set(),
plotClassNames: new Set(), plotClassNames: new Set(),
@@ -139,6 +142,19 @@ for (const row of snapshot.rows ?? []) {
} }
templateMetadata.set(row.templateId, metadata); templateMetadata.set(row.templateId, metadata);
} }
for (const template of recipe.templates) {
if (templateMetadata.has(template.id)) continue;
templateMetadata.set(template.id, {
templateId: template.id,
name: template.name,
classification: template.classification,
spawnCount: 0,
autoPlots: new Set(template.source?.scriptRoots ?? []),
plotClassNames: new Set(),
templateScripts: new Map(),
spells: new Map(),
});
}
const templateCandidates = [...templateMetadata.values()] const templateCandidates = [...templateMetadata.values()]
.map((entry) => ({ .map((entry) => ({
@@ -155,7 +171,7 @@ const templateCandidates = [...templateMetadata.values()]
.sort((left, right) => left.templateId - right.templateId); .sort((left, right) => left.templateId - right.templateId);
const bossRows = (snapshot.rows ?? []) const bossRows = (snapshot.rows ?? [])
.filter((row) => row.classification === "boss") .filter((row) => templatesById.get(row.templateId)?.classification === "boss")
.map((row) => ({ .map((row) => ({
spawnId: row.spawnId, spawnId: row.spawnId,
templateId: row.templateId, templateId: row.templateId,
@@ -164,6 +180,26 @@ const bossRows = (snapshot.rows ?? [])
autoPlot: clean(row.source?.autoPlot) || null, autoPlot: clean(row.source?.autoPlot) || null,
plotClassName: clean(row.source?.plotClassName) || null, plotClassName: clean(row.source?.plotClassName) || null,
})); }));
const scriptedBossSpawns = (recipe.scriptedSpawns ?? [])
.filter((spawn) => templatesById.get(spawn.templateId)?.classification === "boss")
.map((spawn) => {
const template = templatesById.get(spawn.templateId);
const anchor = rowsBySpawnId.get(spawn.sourceAnchorSpawnId);
const offset = spawn.sourceOffset ?? [0, 0, 0];
return {
scriptedPlacementId: spawn.id,
sourceAnchorSpawnId: spawn.sourceAnchorSpawnId,
controllerSpawnId: spawn.controllerSpawnId ?? null,
templateId: spawn.templateId,
name: template.name,
sourcePosition: anchor
? anchor.sourcePosition.map((value, index) => value + offset[index])
: null,
sourceScriptRoot: spawn.sourceScriptRoot ?? template.source?.scriptRoots?.[0] ?? null,
objective: spawn.objective !== false,
evidence: spawn.evidence,
};
});
const categories = Object.fromEntries(SIGNALS.map(({ kind }) => [ const categories = Object.fromEntries(SIGNALS.map(({ kind }) => [
kind, kind,
luaSignals.filter((signal) => signal.kinds.includes(kind)).length luaSignals.filter((signal) => signal.kinds.includes(kind)).length
@@ -186,8 +222,12 @@ const report = {
dungeonConfigId, dungeonConfigId,
}, },
configuredBossTemplateIds: dungeon.boss_guids ?? [], configuredBossTemplateIds: dungeon.boss_guids ?? [],
reviewedBossTemplateIds: recipe.templates
.filter((template) => template.classification === "boss")
.map((template) => template.id),
configuredBossPositions: dungeon.boss_positions ?? {}, configuredBossPositions: dungeon.boss_positions ?? {},
bossRows, bossRows,
scriptedBossSpawns,
luaSources, luaSources,
luaSignals, luaSignals,
templateCandidates, templateCandidates,
@@ -225,6 +265,7 @@ const generated = [
status: report.status, status: report.status,
categories: report.categories, categories: report.categories,
bossRows: report.bossRows, bossRows: report.bossRows,
scriptedBossSpawns: report.scriptedBossSpawns,
templateCandidates: report.templateCandidates, templateCandidates: report.templateCandidates,
reviewQueue: report.reviewQueue, reviewQueue: report.reviewQueue,
sourceReport: projectPath(reportFile), sourceReport: projectPath(reportFile),
@@ -2,6 +2,7 @@
import { readdir, readFile, writeFile } from "node:fs/promises"; import { readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { import {
optionValue,
pipelineDirectory, pipelineDirectory,
projectPath, projectPath,
projectRoot, projectRoot,
@@ -16,9 +17,12 @@ const spellCatalogFile = path.join(
"snapshots", "snapshots",
"runewaker-npc-spell-catalog.json", "runewaker-npc-spell-catalog.json",
); );
const recipeNames = (await readdir(recipesDirectory)) const requestedRecipe = optionValue(process.argv.slice(2), "--recipe");
.filter((name) => name.endsWith("-population.json")) const recipeNames = requestedRecipe
.sort(); ? [path.basename(requestedRecipe)]
: (await readdir(recipesDirectory))
.filter((name) => name.endsWith("-population.json"))
.sort();
function slugify(value) { function slugify(value) {
return String(value ?? "") return String(value ?? "")
@@ -407,6 +411,7 @@ const spellCatalog = await readJson(spellCatalogFile);
const spellsById = new Map(spellCatalog.spells.map((spell) => [Number(spell.spellId), spell])); const spellsById = new Map(spellCatalog.spells.map((spell) => [Number(spell.spellId), spell]));
const catalog = await readJson(mechanicCatalogFile); const catalog = await readJson(mechanicCatalogFile);
const runewakerBosses = {}; const runewakerBosses = {};
const replacedRuntimePrefixes = new Set();
let sourceBacked = 0; let sourceBacked = 0;
let sourceGap = 0; let sourceGap = 0;
const firstRecipe = await readJson(path.join(recipesDirectory, recipeNames[0])); const firstRecipe = await readJson(path.join(recipesDirectory, recipeNames[0]));
@@ -423,6 +428,7 @@ const sharedFunctions = functionIndex(sharedLuaSources);
for (const recipeName of recipeNames) { for (const recipeName of recipeNames) {
const recipe = await readJson(path.join(recipesDirectory, recipeName)); const recipe = await readJson(path.join(recipesDirectory, recipeName));
replacedRuntimePrefixes.add(recipe.runtimeIdPrefix ?? "rw-" + recipe.dungeonId);
const snapshot = await readJson(path.resolve(projectRoot, recipe.files.snapshot)); const snapshot = await readJson(path.resolve(projectRoot, recipe.files.snapshot));
const rowsByTemplate = new Map(); const rowsByTemplate = new Map();
for (const row of snapshot.rows ?? []) { for (const row of snapshot.rows ?? []) {
@@ -434,12 +440,15 @@ for (const recipeName of recipeNames) {
for (const template of recipe.templates.filter((entry) => entry.classification === "boss")) { for (const template of recipe.templates.filter((entry) => entry.classification === "boss")) {
const rows = rowsByTemplate.get(Number(template.id)) ?? []; const rows = rowsByTemplate.get(Number(template.id)) ?? [];
if (!rows.length) continue; const roots = [...new Set([
const roots = [...new Set(rows.flatMap((row) => [ ...(template.source?.scriptRoots ?? []).map(rootName),
rootName(row.source?.autoPlot), ...rows.flatMap((row) => [
rootName(row.source?.plotClassName), rootName(row.source?.autoPlot),
...Object.values(row.source?.templateScripts ?? {}).map(rootName), rootName(row.source?.plotClassName),
]).filter(Boolean))]; ...Object.values(row.source?.templateScripts ?? {}).map(rootName),
]),
].filter(Boolean))];
if (!rows.length && !roots.length) continue;
const reachable = reachableFunctions(sharedFunctions, roots); const reachable = reachableFunctions(sharedFunctions, roots);
const bodies = reachable.map((entry) => entry.body); const bodies = reachable.map((entry) => entry.body);
const scriptSpellIds = [...new Set(bodies.flatMap((body) => ( const scriptSpellIds = [...new Set(bodies.flatMap((body) => (
@@ -500,7 +509,11 @@ for (const recipeName of recipeNames) {
} }
catalog.bosses = Object.fromEntries([ catalog.bosses = Object.fromEntries([
...Object.entries(catalog.bosses).filter(([key]) => !key.startsWith("rw-")), ...Object.entries(catalog.bosses).filter(([key]) => (
requestedRecipe
? ![...replacedRuntimePrefixes].some((prefix) => key.startsWith(prefix + "-"))
: !key.startsWith("rw-")
)),
...Object.entries(runewakerBosses).sort(([left], [right]) => left.localeCompare(right)), ...Object.entries(runewakerBosses).sort(([left], [right]) => left.localeCompare(right)),
]); ]);
await writeFile(mechanicCatalogFile, JSON.stringify(catalog, null, 2) + "\n", "utf8"); await writeFile(mechanicCatalogFile, JSON.stringify(catalog, null, 2) + "\n", "utf8");
@@ -34,11 +34,11 @@ const EXPECTED_FALLBACK_BOSSES = Object.freeze([
"rw-fa-100561", "rw-fa-100561",
"rw-fa-100562", "rw-fa-100562",
"rw-fa-100563", "rw-fa-100563",
"rw-grafu-castle-105351",
]); ]);
const EXPECTED_NON_EMPTY_ZERO_BOSS_PACKAGES = Object.freeze([ const EXPECTED_NON_EMPTY_ZERO_BOSS_PACKAGES = Object.freeze([
"aeternal-circle", "aeternal-circle",
"dungeon-of-dalanis", "dungeon-of-dalanis",
"grafu-castle",
"kal-turok-hive", "kal-turok-hive",
"lair-demon-dragon", "lair-demon-dragon",
"tomb-seven-heroes", "tomb-seven-heroes",
+69
View File
@@ -148,6 +148,7 @@ export function validatePopulationRecipe(recipe, file = "population recipe") {
actorIds.add(actor.id); actorIds.add(actor.id);
} }
const templateIds = new Set(); const templateIds = new Set();
const templatesById = new Map();
const classifications = new Set(["combat", "boss", "deferred-object", "unclassified"]); const classifications = new Set(["combat", "boss", "deferred-object", "unclassified"]);
for (const template of recipe.templates) { for (const template of recipe.templates) {
if (!Number.isInteger(template.id)) throw new Error("Template ids must be integers."); if (!Number.isInteger(template.id)) throw new Error("Template ids must be integers.");
@@ -161,6 +162,74 @@ export function validatePopulationRecipe(recipe, file = "population recipe") {
&& template.proceduralFallback !== true) { && template.proceduralFallback !== true) {
throw new Error("Combat template " + template.id + " has no mapped actor or explicit procedural fallback."); throw new Error("Combat template " + template.id + " has no mapped actor or explicit procedural fallback.");
} }
if (template.source?.scriptRoots !== undefined
&& (!Array.isArray(template.source.scriptRoots)
|| template.source.scriptRoots.some((root) => typeof root !== "string" || !root.trim()))) {
throw new Error("Template " + template.id + " source.scriptRoots must contain non-empty strings.");
}
templatesById.set(template.id, template);
}
const scriptedIds = new Set();
const validateScriptedBase = (entry, label) => {
assertObject(entry, label);
assertString(entry.id, label + ".id");
if (scriptedIds.has(entry.id)) throw new Error("Duplicate scripted placement id " + entry.id + ".");
scriptedIds.add(entry.id);
if (!Number.isInteger(entry.sourceAnchorSpawnId) || entry.sourceAnchorSpawnId <= 0) {
throw new Error(label + ".sourceAnchorSpawnId must be a positive integer.");
}
if (entry.controllerSpawnId !== undefined
&& (!Number.isInteger(entry.controllerSpawnId) || entry.controllerSpawnId <= 0)) {
throw new Error(label + ".controllerSpawnId must be a positive integer.");
}
if (!Array.isArray(entry.evidence) || !entry.evidence.length
|| entry.evidence.some((value) => typeof value !== "string" || !value.trim())) {
throw new Error(label + ".evidence must contain non-empty source references.");
}
};
for (const [index, spawn] of (recipe.scriptedSpawns ?? []).entries()) {
const label = file + ".scriptedSpawns[" + index + "]";
validateScriptedBase(spawn, label);
const template = templatesById.get(spawn.templateId);
if (!template || !["combat", "boss"].includes(template.classification)) {
throw new Error(label + ".templateId must reference a combat or boss template.");
}
if (spawn.sourceOffset !== undefined
&& (!Array.isArray(spawn.sourceOffset) || spawn.sourceOffset.length !== 3
|| spawn.sourceOffset.some((value) => !Number.isFinite(value)))) {
throw new Error(label + ".sourceOffset must contain three finite source-unit values.");
}
if (spawn.direction !== undefined && !Number.isFinite(spawn.direction)) {
throw new Error(label + ".direction must be finite.");
}
if (spawn.objective !== undefined && typeof spawn.objective !== "boolean") {
throw new Error(label + ".objective must be boolean when provided.");
}
}
for (const [index, formation] of (recipe.scriptedFormations ?? []).entries()) {
const label = file + ".scriptedFormations[" + index + "]";
validateScriptedBase(formation, label);
assertString(formation.sourceScriptRoot, label + ".sourceScriptRoot");
if (!(Number(formation.spacingSourceUnits) > 0)) {
throw new Error(label + ".spacingSourceUnits must be positive.");
}
if (!Number.isFinite(formation.direction)) {
throw new Error(label + ".direction must be finite.");
}
if (!Array.isArray(formation.members) || !formation.members.length) {
throw new Error(label + ".members must be a non-empty array.");
}
for (const [memberIndex, member] of formation.members.entries()) {
const memberLabel = label + ".members[" + memberIndex + "]";
assertObject(member, memberLabel);
const template = templatesById.get(member.templateId);
if (!template || !["combat", "boss"].includes(template.classification)) {
throw new Error(memberLabel + ".templateId must reference a combat or boss template.");
}
if (!Number.isInteger(member.row) || member.row < 1 || !Number.isInteger(member.column)) {
throw new Error(memberLabel + " requires a positive integer row and integer column.");
}
}
} }
return recipe; return recipe;
} }
@@ -4,13 +4,273 @@
"sourceDungeonConfigId": "grafu_castle", "sourceDungeonConfigId": "grafu_castle",
"zoneId": 137, "zoneId": 137,
"expectedActiveRows": 249, "expectedActiveRows": 249,
"expectedBossSpawns": 0, "expectedBossSpawns": 5,
"runtimeIdPrefix": "rw-grafu-castle", "runtimeIdPrefix": "rw-grafu-castle",
"coordinateSystem": { "coordinateSystem": {
"metersPerSourceUnit": 0.1, "metersPerSourceUnit": 0.1,
"directionUnitsPerTurn": 360, "directionUnitsPerTurn": 360,
"maximumProjectionDistance": 4 "maximumProjectionDistance": 4
}, },
"bossObjectiveOrder": {
"kind": "source-authored",
"spawnIds": [
41927224,
41926939,
41926924,
41926979,
41927071
],
"evidence": [
"Resource/luascript/02345.lua:Dy_Z19_BOSSN_AIC",
"Resource/luascript/02324.lua:kg_105542_01",
"Resource/luascript/02231.lua:ic_105224_01",
"Resource/luascript/02330.lua:cl_136_105205_N",
"Resource/luascript/02297.lua:un_zone19_boss5_allconreal_1_01"
]
},
"scriptedSpawns": [
{
"id": "cayus",
"templateId": 105616,
"sourceAnchorSpawnId": 41927224,
"controllerSpawnId": 41928351,
"sourceScriptRoot": "Dy_Z19_BOSSN_AIC",
"evidence": [
"Resource/luascript/02345.lua:CreateObjByFlag(105616,780696,1,1)"
]
},
{
"id": "ardmond",
"templateId": 105542,
"sourceAnchorSpawnId": 41926939,
"controllerSpawnId": 41928345,
"sourceOffset": [
0,
0,
-20
],
"sourceScriptRoot": "kg_105542_01",
"evidence": [
"Resource/luascript/02324.lua:kg_105542_CreateBoss"
]
},
{
"id": "issac",
"templateId": 105543,
"sourceAnchorSpawnId": 41926939,
"controllerSpawnId": 41928345,
"sourceOffset": [
0,
0,
20
],
"objective": false,
"sourceScriptRoot": "kg_105542_01",
"evidence": [
"Resource/luascript/02324.lua:kg_105542_CreateBoss"
]
},
{
"id": "boro-boro-brothers",
"templateId": 105224,
"sourceAnchorSpawnId": 41926924,
"controllerSpawnId": 41927347,
"sourceScriptRoot": "ic_105224_01",
"evidence": [
"Resource/luascript/02231.lua:CreateObjByFlag(105224,780775,1,1)"
]
},
{
"id": "conjurer-chapeaunoir",
"templateId": 105204,
"sourceAnchorSpawnId": 41926979,
"controllerSpawnId": 41927358,
"sourceScriptRoot": "cl_136_105205_N",
"evidence": [
"Resource/luascript/02330.lua:CreateObjByFlag(105204,780767,0,1)"
]
},
{
"id": "larvanger-barkud",
"templateId": 105351,
"sourceAnchorSpawnId": 41927071,
"controllerSpawnId": 41927369,
"sourceScriptRoot": "un_zone19_boss5_allconreal_1_01",
"evidence": [
"Resource/luascript/02297.lua:CreateObjByFlag(105351,780838,1,1)"
]
}
],
"scriptedFormations": [
{
"id": "castle-patrol-41927381",
"sourceAnchorSpawnId": 41927381,
"controllerSpawnId": 41927381,
"sourceScriptRoot": "cl_136_01_01_N",
"direction": 179.87330627441406,
"spacingSourceUnits": 20,
"members": [
{ "templateId": 105598, "row": 1, "column": 0 },
{ "templateId": 105229, "row": 2, "column": 1 },
{ "templateId": 105229, "row": 2, "column": -1 },
{ "templateId": 105230, "row": 3, "column": 0 }
],
"evidence": [
"Resource/luascript/02334.lua:cl_136_01_01_N"
]
},
{
"id": "castle-patrol-41927382",
"sourceAnchorSpawnId": 41927382,
"controllerSpawnId": 41927382,
"sourceScriptRoot": "cl_136_01_01_N",
"direction": 269.88189697265625,
"spacingSourceUnits": 20,
"members": [
{ "templateId": 105598, "row": 1, "column": 0 },
{ "templateId": 105229, "row": 2, "column": 1 },
{ "templateId": 105229, "row": 2, "column": -1 },
{ "templateId": 105230, "row": 3, "column": 0 }
],
"evidence": [
"Resource/luascript/02334.lua:cl_136_01_01_N"
]
},
{
"id": "castle-patrol-41927384",
"sourceAnchorSpawnId": 41927384,
"controllerSpawnId": 41927384,
"sourceScriptRoot": "cl_136_03_01_N",
"direction": 89.27565002441406,
"spacingSourceUnits": 20,
"members": [
{ "templateId": 105596, "row": 1, "column": 0 },
{ "templateId": 105597, "row": 2, "column": 1 },
{ "templateId": 105597, "row": 2, "column": -1 },
{ "templateId": 105601, "row": 3, "column": 0 }
],
"evidence": [
"Resource/luascript/02334.lua:cl_136_03_01_N"
]
},
{
"id": "castle-patrol-41927385",
"sourceAnchorSpawnId": 41927385,
"controllerSpawnId": 41927385,
"sourceScriptRoot": "cl_136_03_02_N",
"direction": 180.14939880371094,
"spacingSourceUnits": 20,
"members": [
{ "templateId": 105599, "row": 1, "column": 0 },
{ "templateId": 105598, "row": 2, "column": 1 },
{ "templateId": 105598, "row": 2, "column": -1 }
],
"evidence": [
"Resource/luascript/02334.lua:cl_136_03_02_N"
]
},
{
"id": "castle-patrol-41927386",
"sourceAnchorSpawnId": 41927386,
"controllerSpawnId": 41927386,
"sourceScriptRoot": "cl_136_03_04_N",
"direction": 0.1327040046453476,
"spacingSourceUnits": 20,
"members": [
{ "templateId": 105599, "row": 1, "column": 0 },
{ "templateId": 105598, "row": 2, "column": 1 },
{ "templateId": 105598, "row": 2, "column": -1 },
{ "templateId": 105601, "row": 3, "column": 0 }
],
"evidence": [
"Resource/luascript/02334.lua:cl_136_03_04_N"
]
},
{
"id": "castle-patrol-41927387",
"sourceAnchorSpawnId": 41927387,
"controllerSpawnId": 41927387,
"sourceScriptRoot": "cl_136_03_042_N",
"direction": 86.2711410522461,
"spacingSourceUnits": 20,
"members": [
{ "templateId": 105599, "row": 1, "column": 0 },
{ "templateId": 105601, "row": 2, "column": 1 },
{ "templateId": 105601, "row": 2, "column": -1 }
],
"evidence": [
"Resource/luascript/02334.lua:cl_136_03_042_N"
]
},
{
"id": "castle-patrol-41927391",
"sourceAnchorSpawnId": 41927391,
"controllerSpawnId": 41927391,
"sourceScriptRoot": "cl_136_04_052",
"direction": 89,
"spacingSourceUnits": 20,
"members": [
{ "templateId": 105237, "row": 1, "column": 0 },
{ "templateId": 105236, "row": 2, "column": 1 },
{ "templateId": 105236, "row": 2, "column": -1 },
{ "templateId": 105237, "row": 3, "column": 0 },
{ "templateId": 105236, "row": 3, "column": -1 },
{ "templateId": 105236, "row": 3, "column": 1 }
],
"evidence": [
"Resource/luascript/02214.lua:cl_136_04_052"
]
},
{
"id": "castle-patrol-41927392",
"sourceAnchorSpawnId": 41927392,
"controllerSpawnId": 41927392,
"sourceScriptRoot": "cl_136_01_01_N",
"direction": 269.99481201171875,
"spacingSourceUnits": 20,
"members": [
{ "templateId": 105598, "row": 1, "column": 0 },
{ "templateId": 105229, "row": 2, "column": 1 },
{ "templateId": 105229, "row": 2, "column": -1 },
{ "templateId": 105230, "row": 3, "column": 0 }
],
"evidence": [
"Resource/luascript/02334.lua:cl_136_01_01_N"
]
},
{
"id": "castle-patrol-41927395",
"sourceAnchorSpawnId": 41927395,
"controllerSpawnId": 41927395,
"sourceScriptRoot": "cl_136_03_042",
"direction": 181,
"spacingSourceUnits": 20,
"members": [
{ "templateId": 105233, "row": 1, "column": 0 },
{ "templateId": 105235, "row": 2, "column": 1 },
{ "templateId": 105235, "row": 2, "column": -1 }
],
"evidence": [
"Resource/luascript/02214.lua:cl_136_03_042"
]
},
{
"id": "castle-patrol-41927396",
"sourceAnchorSpawnId": 41927396,
"controllerSpawnId": 41927396,
"sourceScriptRoot": "cl_136_03_042",
"direction": 4.999985218048096,
"spacingSourceUnits": 20,
"members": [
{ "templateId": 105233, "row": 1, "column": 0 },
{ "templateId": 105235, "row": 2, "column": 1 },
{ "templateId": 105235, "row": 2, "column": -1 }
],
"evidence": [
"Resource/luascript/02214.lua:cl_136_03_042"
]
}
],
"files": { "files": {
"environmentRecipe": "scripts/runewaker-pipeline/recipes/grafu-castle.json", "environmentRecipe": "scripts/runewaker-pipeline/recipes/grafu-castle.json",
"snapshot": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json", "snapshot": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
@@ -43,6 +303,21 @@
"serverDefault": ".\\HEALERMAN_RW" "serverDefault": ".\\HEALERMAN_RW"
}, },
"actors": [ "actors": [
{
"id": "atmo",
"sourceModel": "model/character/creature/atmo/act_atmo.ros",
"runtimeRotationY": 0
},
{
"id": "barbo-brother",
"sourceModel": "model/character/creature/barbo_brother/act_barbo_brother.ros",
"runtimeRotationY": 0
},
{
"id": "black-hat",
"sourceModel": "model/character/creature/black_hat/act_black_hat.ros",
"runtimeRotationY": 0
},
{ {
"id": "black-hat-blue", "id": "black-hat-blue",
"sourceModel": "model/character/creature/black_hat/act_black_hat_blue.ros", "sourceModel": "model/character/creature/black_hat/act_black_hat_blue.ros",
@@ -63,6 +338,11 @@
"sourceModel": "model/character/creature/lost_protector/act_controlled_steel_guardian.ros", "sourceModel": "model/character/creature/lost_protector/act_controlled_steel_guardian.ros",
"runtimeRotationY": 0 "runtimeRotationY": 0
}, },
{
"id": "cursed-steel-guardian",
"sourceModel": "model/character/creature/lost_protector/act_cursed_steel_guardian.ros",
"runtimeRotationY": 0
},
{ {
"id": "elder-myconid", "id": "elder-myconid",
"sourceModel": "model/character/creature/elder_myconid/act_elder_myconid.ros", "sourceModel": "model/character/creature/elder_myconid/act_elder_myconid.ros",
@@ -83,6 +363,11 @@
"sourceModel": "model/character/pc/human/male/act_hm_male.ros", "sourceModel": "model/character/pc/human/male/act_hm_male.ros",
"runtimeRotationY": 0 "runtimeRotationY": 0
}, },
{
"id": "karth",
"sourceModel": "model/character/creature/karth/act_karth.ros",
"runtimeRotationY": 0
},
{ {
"id": "karth-01", "id": "karth-01",
"sourceModel": "model/character/creature/karth/act_karth_01.ros", "sourceModel": "model/character/creature/karth/act_karth_01.ros",
@@ -92,6 +377,26 @@
"id": "milk-scarab-blue", "id": "milk-scarab-blue",
"sourceModel": "model/character/creature/milk_scarab/act_milk_scarab_blue.ros", "sourceModel": "model/character/creature/milk_scarab/act_milk_scarab_blue.ros",
"runtimeRotationY": 0 "runtimeRotationY": 0
},
{
"id": "mount-millenium-chimera-laby",
"sourceModel": "model/character/creature/mount_millenium_chimera/act_mount_millenium_chimera_laby.ros",
"runtimeRotationY": 0
},
{
"id": "enraged-big-bear-purple",
"sourceModel": "model/character/creature/enraged_big_bear/act_enraged_big_bear_purple.ros",
"runtimeRotationY": 0
},
{
"id": "enraged-big-bear-cream",
"sourceModel": "model/character/creature/enraged_big_bear/act_enraged_big_bear_cream.ros",
"runtimeRotationY": 0
},
{
"id": "rafael-bakderr",
"sourceModel": "model/character/creature/rafael_bakderr/act_rafael_bakderr.ros",
"runtimeRotationY": 0
} }
], ],
"templates": [ "templates": [
@@ -202,6 +507,25 @@
"classificationRule": "noncombat/dynamic-display source object" "classificationRule": "noncombat/dynamic-display source object"
} }
}, },
{
"primaryColor": "hsl(348 22% 38%)",
"accentColor": "hsl(16 38% 64%)",
"id": 105204,
"name": "Conjurer Chapeaunoir",
"title": "Conjurer Chapeaunoir",
"level": 68,
"classification": "boss",
"archetype": "humanoid",
"actor": "black-hat",
"source": {
"imageId": 584594,
"modelPath": "model/character/creature/black_hat/act_black_hat.ros",
"classificationRule": "NPCObjectDB.sex=3 plus reviewed Lua dynamic creation",
"scriptRoots": [
"cl_136_105205_N"
]
}
},
{ {
"primaryColor": "hsl(35 22% 38%)", "primaryColor": "hsl(35 22% 38%)",
"accentColor": "hsl(63 38% 64%)", "accentColor": "hsl(63 38% 64%)",
@@ -223,13 +547,13 @@
"id": 105210, "id": 105210,
"name": "Template 105210", "name": "Template 105210",
"level": 10, "level": 10,
"classification": "combat", "classification": "deferred-object",
"archetype": "humanoid", "archetype": "humanoid",
"actor": "cereus-01", "reason": "Source boss controller for Ardmond and Issac; the controller is not a combat mob.",
"source": { "source": {
"imageId": 583949, "imageId": 583949,
"modelPath": "model/character/creature/cereus/act_cereus_01.ros", "modelPath": "model/character/creature/cereus/act_cereus_01.ros",
"classificationRule": "positive native level plus static model/character ROS" "classificationRule": "reviewed Lua controller override"
} }
}, },
{ {
@@ -337,19 +661,128 @@
"classificationRule": "noncombat/dynamic-display source object" "classificationRule": "noncombat/dynamic-display source object"
} }
}, },
{
"primaryColor": "hsl(208 22% 38%)",
"accentColor": "hsl(236 38% 64%)",
"id": 105224,
"name": "Boro-Boro Brothers",
"title": "Boro-Boro Brothers",
"level": 68,
"classification": "boss",
"archetype": "humanoid",
"actor": "barbo-brother",
"source": {
"imageId": 584595,
"modelPath": "model/character/creature/barbo_brother/act_barbo_brother.ros",
"classificationRule": "NPCObjectDB.sex=3 plus reviewed Lua dynamic creation",
"scriptRoots": [
"ic_105224_01"
]
}
},
{
"primaryColor": "hsl(83 22% 38%)",
"accentColor": "hsl(111 38% 64%)",
"id": 105229,
"name": "Smirking Castle Clown",
"level": 63,
"classification": "combat",
"archetype": "humanoid",
"actor": "grinning-clown",
"source": {
"imageId": 584630,
"modelPath": "model/character/creature/grinning_clown/act_grinning_clown.ros",
"classificationRule": "reviewed Lua matrix member"
}
},
{
"primaryColor": "hsl(130 22% 38%)",
"accentColor": "hsl(158 38% 64%)",
"id": 105230,
"name": "Odd Castle Clown",
"level": 63,
"classification": "combat",
"archetype": "humanoid",
"actor": "ghastly-clown-red",
"source": {
"imageId": 584631,
"modelPath": "model/character/creature/ghastly_clown/act_ghastly_clown_red.ros",
"classificationRule": "reviewed Lua matrix member"
}
},
{
"primaryColor": "hsl(271 22% 38%)",
"accentColor": "hsl(299 38% 64%)",
"id": 105233,
"name": "Cursed Steel Soldier",
"level": 63,
"classification": "combat",
"archetype": "humanoid",
"actor": "cursed-steel-guardian",
"source": {
"imageId": 584634,
"modelPath": "model/character/creature/lost_protector/act_cursed_steel_guardian.ros",
"classificationRule": "reviewed Lua matrix member"
}
},
{
"primaryColor": "hsl(5 22% 38%)",
"accentColor": "hsl(33 38% 64%)",
"id": 105235,
"name": "Conjurer's Apprentice",
"level": 63,
"classification": "combat",
"archetype": "humanoid",
"actor": "black-hat-blue",
"source": {
"imageId": 584636,
"modelPath": "model/character/creature/black_hat/act_black_hat_blue.ros",
"classificationRule": "reviewed Lua matrix member"
}
},
{
"primaryColor": "hsl(52 22% 38%)",
"accentColor": "hsl(80 38% 64%)",
"id": 105236,
"name": "Giant Enraged Bear",
"level": 63,
"classification": "combat",
"archetype": "humanoid",
"actor": "enraged-big-bear-purple",
"source": {
"imageId": 584637,
"modelPath": "model/character/creature/enraged_big_bear/act_enraged_big_bear_purple.ros",
"classificationRule": "reviewed Lua matrix member"
}
},
{
"primaryColor": "hsl(99 22% 38%)",
"accentColor": "hsl(127 38% 64%)",
"id": 105237,
"name": "Manipulated Bear",
"level": 63,
"classification": "combat",
"archetype": "humanoid",
"actor": "enraged-big-bear-cream",
"source": {
"imageId": 584664,
"modelPath": "model/character/creature/enraged_big_bear/act_enraged_big_bear_cream.ros",
"classificationRule": "reviewed Lua matrix member"
}
},
{ {
"primaryColor": "hsl(178 22% 38%)", "primaryColor": "hsl(178 22% 38%)",
"accentColor": "hsl(206 38% 64%)", "accentColor": "hsl(206 38% 64%)",
"id": 105254, "id": 105254,
"name": "Template 105254", "name": "Template 105254",
"level": 1, "level": 1,
"classification": "combat", "classification": "deferred-object",
"archetype": "plant", "archetype": "plant",
"actor": "elder-myconid", "reason": "Source Lua patrol controller; it creates the real combat formations and must not be rendered as a mob.",
"source": { "source": {
"imageId": 576437, "imageId": 576437,
"modelPath": "model/character/creature/elder_myconid/act_elder_myconid.ros", "modelPath": "model/character/creature/elder_myconid/act_elder_myconid.ros",
"classificationRule": "positive native level plus static model/character ROS" "classificationRule": "reviewed Lua controller override"
} }
}, },
{ {
@@ -358,13 +791,13 @@
"id": 105258, "id": 105258,
"name": "Template 105258", "name": "Template 105258",
"level": 1, "level": 1,
"classification": "combat", "classification": "deferred-object",
"archetype": "plant", "archetype": "plant",
"actor": "elder-myconid", "reason": "Source proximity and ambient-rabbit trigger; it is not a combat mob.",
"source": { "source": {
"imageId": 576437, "imageId": 576437,
"modelPath": "model/character/creature/elder_myconid/act_elder_myconid.ros", "modelPath": "model/character/creature/elder_myconid/act_elder_myconid.ros",
"classificationRule": "positive native level plus static model/character ROS" "classificationRule": "reviewed Lua controller override"
} }
}, },
{ {
@@ -399,6 +832,64 @@
"classificationRule": "noncombat/dynamic-display source object" "classificationRule": "noncombat/dynamic-display source object"
} }
}, },
{
"primaryColor": "hsl(57 22% 38%)",
"accentColor": "hsl(85 38% 64%)",
"id": 105351,
"name": "Larvanger Barkud",
"title": "Larvanger Barkud",
"level": 68,
"classification": "boss",
"archetype": "humanoid",
"actor": "rafael-bakderr",
"source": {
"imageId": 584614,
"modelPath": "model/character/creature/rafael_bakderr/act_rafael_bakderr.ros",
"classificationRule": "NPCObjectDB.sex=3 plus reviewed Lua dynamic creation",
"scriptRoots": [
"un_zone19_boss5_allconreal_1_01"
]
}
},
{
"primaryColor": "hsl(34 22% 38%)",
"accentColor": "hsl(62 38% 64%)",
"id": 105542,
"name": "Ardmond",
"title": "Ardmond",
"objectiveNamePattern": "Ardmond and Issac",
"level": 68,
"classification": "boss",
"archetype": "humanoid",
"actor": "atmo",
"source": {
"imageId": 584588,
"modelPath": "model/character/creature/atmo/act_atmo.ros",
"classificationRule": "NPCObjectDB.sex=3 plus reviewed Lua dynamic creation",
"scriptRoots": [
"kg_105542_01"
]
}
},
{
"primaryColor": "hsl(81 22% 38%)",
"accentColor": "hsl(109 38% 64%)",
"id": 105543,
"name": "Issac",
"title": "Issac",
"level": 68,
"classification": "boss",
"archetype": "humanoid",
"actor": "mount-millenium-chimera-laby",
"source": {
"imageId": 584587,
"modelPath": "model/character/creature/mount_millenium_chimera/act_mount_millenium_chimera_laby.ros",
"classificationRule": "NPCObjectDB.sex=3 plus reviewed Lua dynamic creation",
"scriptRoots": [
"kg_105542_01"
]
}
},
{ {
"primaryColor": "hsl(52 22% 38%)", "primaryColor": "hsl(52 22% 38%)",
"accentColor": "hsl(80 38% 64%)", "accentColor": "hsl(80 38% 64%)",
@@ -444,6 +935,21 @@
"classificationRule": "positive native level plus static model/character ROS" "classificationRule": "positive native level plus static model/character ROS"
} }
}, },
{
"primaryColor": "hsl(193 22% 38%)",
"accentColor": "hsl(221 38% 64%)",
"id": 105599,
"name": "Cursed Steel Soldier",
"level": 63,
"classification": "combat",
"archetype": "humanoid",
"actor": "cursed-steel-guardian",
"source": {
"imageId": 584634,
"modelPath": "model/character/creature/lost_protector/act_cursed_steel_guardian.ros",
"classificationRule": "reviewed Lua matrix member"
}
},
{ {
"primaryColor": "hsl(240 22% 38%)", "primaryColor": "hsl(240 22% 38%)",
"accentColor": "hsl(268 38% 64%)", "accentColor": "hsl(268 38% 64%)",
@@ -519,6 +1025,25 @@
"classificationRule": "noncombat/dynamic-display source object" "classificationRule": "noncombat/dynamic-display source object"
} }
}, },
{
"primaryColor": "hsl(272 22% 38%)",
"accentColor": "hsl(300 38% 64%)",
"id": 105616,
"name": "Cayus",
"title": "Cayus",
"level": 68,
"classification": "boss",
"archetype": "humanoid",
"actor": "karth",
"source": {
"imageId": 585676,
"modelPath": "model/character/creature/karth/act_karth.ros",
"classificationRule": "NPCObjectDB.sex=3 plus reviewed Lua dynamic creation",
"scriptRoots": [
"Dy_Z19_BOSSN_AIC"
]
}
},
{ {
"primaryColor": "hsl(149 22% 38%)", "primaryColor": "hsl(149 22% 38%)",
"accentColor": "hsl(177 38% 64%)", "accentColor": "hsl(177 38% 64%)",
@@ -79,8 +79,24 @@ if (errors.length === 0) {
check("unique source spawn ids", new Set(snapshot.rows.map((row) => row.spawnId)).size === snapshot.rows.length, "no duplicates"); check("unique source spawn ids", new Set(snapshot.rows.map((row) => row.spawnId)).size === snapshot.rows.length, "no duplicates");
check("environment id", environment.dungeonId === recipe.dungeonId, String(environment.dungeonId)); check("environment id", environment.dungeonId === recipe.dungeonId, String(environment.dungeonId));
check("population report green", report.status === "green", String(report.status)); check("population report green", report.status === "green", String(report.status));
check("population accounting", report.sourceRowCount === report.importedSpawnCount + report.excludedRowCount, const expectedScriptedSpawns = (recipe.scriptedSpawns ?? []).length
report.sourceRowCount + " = " + report.importedSpawnCount + " + " + report.excludedRowCount); + (recipe.scriptedFormations ?? []).reduce((total, formation) => total + formation.members.length, 0);
const sourceCombatSpawns = report.importedSpawnCount - (report.scriptedSpawnCount ?? 0);
check(
"scripted population accounting",
(report.scriptedSpawnCount ?? 0) === expectedScriptedSpawns,
String(report.scriptedSpawnCount ?? 0) + " = " + expectedScriptedSpawns,
);
check(
"source population accounting",
report.sourceRowCount === sourceCombatSpawns + report.excludedRowCount,
report.sourceRowCount + " = " + sourceCombatSpawns + " + " + report.excludedRowCount,
);
check(
"boss population accounting",
!Number.isInteger(recipe.expectedBossSpawns) || report.bossSpawnCount === recipe.expectedBossSpawns,
String(report.bossSpawnCount) + " = " + String(recipe.expectedBossSpawns),
);
check("combat nav projection", report.navmeshProjection.maximumCombatDistance <= report.navmeshProjection.maximumAllowedDistance, check("combat nav projection", report.navmeshProjection.maximumCombatDistance <= report.navmeshProjection.maximumAllowedDistance,
String(report.navmeshProjection.maximumCombatDistance)); String(report.navmeshProjection.maximumCombatDistance));
check("mechanics is an explicit review gate", mechanics.status === "review-required" || mechanics.status === "green", String(mechanics.status)); check("mechanics is an explicit review gate", mechanics.status === "review-required" || mechanics.status === "green", String(mechanics.status));
+57 -7
View File
@@ -35,6 +35,19 @@ function parseAllowedOrigins(value) {
return new Set(String(value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean)); return new Set(String(value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean));
} }
function booleanFlag(value) {
return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase());
}
function acceptsEncoding(value, requested) {
const encodings = new Map(String(value ?? "").split(",").map((entry) => {
const [name, ...parameters] = entry.trim().toLowerCase().split(";");
const quality = parameters.find((part) => part.trim().startsWith("q="));
return [name, quality ? Number(quality.trim().slice(2)) : 1];
}));
return (encodings.get(requested) ?? encodings.get("*") ?? 0) > 0;
}
function bearerToken(request) { function bearerToken(request) {
return /^Bearer\s+(.+)$/i.exec(String(request.headers.authorization ?? ""))?.[1] ?? ""; return /^Bearer\s+(.+)$/i.exec(String(request.headers.authorization ?? ""))?.[1] ?? "";
} }
@@ -92,15 +105,25 @@ function serveStatic(request, response, staticDir, pathname, options = {}) {
const stats = statSync(filePath); const stats = statSync(filePath);
const extension = path.extname(filePath).toLowerCase(); const extension = path.extname(filePath).toLowerCase();
const range = request.headers.range ? parseRange(request.headers.range, stats.size) : null; const range = request.headers.range ? parseRange(request.headers.range, stats.size) : null;
const canEncode = !range && !request.headers.range;
const encoding = canEncode && existsSync(`${filePath}.br`) && acceptsEncoding(request.headers["accept-encoding"], "br")
? "br"
: canEncode && existsSync(`${filePath}.gz`) && acceptsEncoding(request.headers["accept-encoding"], "gzip")
? "gzip"
: null;
const encodedPath = encoding ? `${filePath}.${encoding === "br" ? "br" : "gz"}` : null;
const servedPath = encodedPath ?? filePath;
const servedStats = encodedPath ? statSync(encodedPath) : stats;
const headers = { const headers = {
"Content-Type": MIME_TYPES.get(extension) ?? "application/octet-stream", "Content-Type": MIME_TYPES.get(extension) ?? "application/octet-stream",
"Accept-Ranges": "bytes", "Accept-Ranges": "bytes",
"Cache-Control": options.immutable "Cache-Control": options.immutable
? "public, max-age=31536000, immutable" ? "public, max-age=31536000, immutable"
: path.basename(filePath) === "index.html" || options.noCache : ["index.html", "content-cache-sw.js"].includes(path.basename(filePath)) || options.noCache
? "no-cache" ? "no-cache"
: "public, max-age=3600", : "public, max-age=3600",
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
...(encodedPath ? { "Content-Encoding": encoding, Vary: "Accept-Encoding" } : {}),
}; };
if (request.headers.range && !range) { if (request.headers.range && !range) {
response.writeHead(416, { ...headers, "Content-Range": `bytes */${stats.size}` }); response.writeHead(416, { ...headers, "Content-Range": `bytes */${stats.size}` });
@@ -117,9 +140,9 @@ function serveStatic(request, response, staticDir, pathname, options = {}) {
else createReadStream(filePath, range).pipe(response); else createReadStream(filePath, range).pipe(response);
return true; return true;
} }
response.writeHead(200, { ...headers, "Content-Length": stats.size }); response.writeHead(200, { ...headers, "Content-Length": servedStats.size });
if (request.method === "HEAD") response.end(); if (request.method === "HEAD") response.end();
else createReadStream(filePath).pipe(response); else createReadStream(servedPath).pipe(response);
return true; return true;
} }
@@ -153,9 +176,10 @@ export function createGameServer(options = {}) {
const allowedOrigins = parseAllowedOrigins(options.corsOrigins ?? process.env.CORS_ORIGINS); const allowedOrigins = parseAllowedOrigins(options.corsOrigins ?? process.env.CORS_ORIGINS);
const bodyLimit = Math.max(1024, Number(options.bodyLimit ?? process.env.MAX_JSON_BODY_BYTES ?? 5 * 1024 * 1024)); const bodyLimit = Math.max(1024, Number(options.bodyLimit ?? process.env.MAX_JSON_BODY_BYTES ?? 5 * 1024 * 1024));
const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true"; const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
const enableShelvedModes = options.enableShelvedModes ?? booleanFlag(process.env.HEALER_MAN_ENABLE_SHELVED_MODES);
const gameDatabase = options.database ?? openGameDatabase(options); const gameDatabase = options.database ?? openGameDatabase(options);
const onlineSessions = createOnlineSessionManager(); const onlineSessions = createOnlineSessionManager();
const arenaService = createArenaService({ database: gameDatabase }); const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes });
const allowAuthRequest = createAuthLimiter(); const allowAuthRequest = createAuthLimiter();
gameDatabase.pruneExpiredSessions(); gameDatabase.pruneExpiredSessions();
@@ -185,6 +209,15 @@ export function createGameServer(options = {}) {
json(response, 200, { ok: true, service: "healer-man", time: new Date().toISOString() }); json(response, 200, { ok: true, service: "healer-man", time: new Date().toISOString() });
return; return;
} }
if (!enableShelvedModes && (
url.pathname === "/api/manastorm-config"
|| url.pathname.startsWith("/api/gm/manastorm-config")
|| url.pathname === "/api/arena"
|| url.pathname.startsWith("/api/arena/")
)) {
json(response, 503, { error: "This feature is temporarily unavailable.", code: "feature_disabled" });
return;
}
if ((url.pathname === "/api/auth/register" || url.pathname === "/api/auth/login") && request.method === "POST") { if ((url.pathname === "/api/auth/register" || url.pathname === "/api/auth/login") && request.method === "POST") {
const forwarded = trustProxy ? String(request.headers["x-forwarded-for"] ?? "").split(",")[0]?.trim() : ""; const forwarded = trustProxy ? String(request.headers["x-forwarded-for"] ?? "").split(",")[0]?.trim() : "";
if (!allowAuthRequest(forwarded || request.socket.remoteAddress || "unknown")) { if (!allowAuthRequest(forwarded || request.socket.remoteAddress || "unknown")) {
@@ -254,6 +287,10 @@ export function createGameServer(options = {}) {
if (url.pathname === "/api/online-group/activity" && request.method === "POST") { if (url.pathname === "/api/online-group/activity" && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request)); const account = gameDatabase.authenticate(bearerToken(request));
const body = await readJson(request, 64 * 1024); const body = await readJson(request, 64 * 1024);
if (!enableShelvedModes && body.type === "manastorm") {
json(response, 503, { error: "Manastorm groups are temporarily unavailable.", code: "feature_disabled" });
return;
}
json(response, 200, gameDatabase.startOnlineGroupActivity(account.id, body)); json(response, 200, gameDatabase.startOnlineGroupActivity(account.id, body));
return; return;
} }
@@ -348,7 +385,11 @@ export function createGameServer(options = {}) {
} }
if (serveBundledContent(request, response, url.pathname)) return; if (serveBundledContent(request, response, url.pathname)) return;
if (url.pathname === "/content/bootstrap-manifest.json" || url.pathname.startsWith("/content/bundled/")) { if (url.pathname === "/content/bootstrap-manifest.json" || url.pathname.startsWith("/content/bundled/")) {
if (serveStatic(request, response, staticDir, url.pathname, { spaFallback: false, noCache: true })) return; if (serveStatic(request, response, staticDir, url.pathname, {
spaFallback: false,
immutable: url.pathname.startsWith("/content/bundled/"),
noCache: url.pathname === "/content/bootstrap-manifest.json",
})) return;
} }
if (url.pathname === "/content" || url.pathname.startsWith("/content/")) { if (url.pathname === "/content" || url.pathname.startsWith("/content/")) {
const contentPath = url.pathname.slice("/content".length) || "/manifest.json"; const contentPath = url.pathname.slice("/content".length) || "/manifest.json";
@@ -361,7 +402,8 @@ export function createGameServer(options = {}) {
json(response, 404, { error: "Content file not found.", code: "content_not_found" }); json(response, 404, { error: "Content file not found.", code: "content_not_found" });
return; return;
} }
if (serveStatic(request, response, staticDir, url.pathname)) return; const immutableViteAsset = /^\/assets\/.+-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/.test(url.pathname);
if (serveStatic(request, response, staticDir, url.pathname, { immutable: immutableViteAsset })) return;
json(response, 404, { error: "Not found.", code: "not_found" }); json(response, 404, { error: "Not found.", code: "not_found" });
} catch (error) { } catch (error) {
const known = error instanceof GameDatabaseError; const known = error instanceof GameDatabaseError;
@@ -376,6 +418,14 @@ export function createGameServer(options = {}) {
}); });
server.on("upgrade", (request, socket, head) => { server.on("upgrade", (request, socket, head) => {
if (!enableShelvedModes && new URL(request.url ?? "/", "http://localhost").pathname === "/ws/arena") {
const body = Buffer.from(JSON.stringify({
error: "This feature is temporarily unavailable.",
code: "feature_disabled",
}));
socket.end(`HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: ${body.length}\r\n\r\n${body}`);
return;
}
if (!upgradeArenaWebSocket(request, socket, head, arenaService)) { if (!upgradeArenaWebSocket(request, socket, head, arenaService)) {
socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
} }
@@ -386,7 +436,7 @@ export function createGameServer(options = {}) {
arenaService.close(); arenaService.close();
gameDatabase.close(); gameDatabase.close();
}); });
return { server, database: gameDatabase, arenaService, staticDir, contentDir }; return { server, database: gameDatabase, arenaService, staticDir, contentDir, enableShelvedModes };
} }
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+61 -1
View File
@@ -3,6 +3,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import test from "node:test"; import test from "node:test";
import { brotliCompressSync } from "node:zlib";
import { createGameServer } from "./server.mjs"; import { createGameServer } from "./server.mjs";
test("serves the game and authenticated cloud-save API", async () => { test("serves the game and authenticated cloud-save API", async () => {
@@ -11,18 +12,36 @@ test("serves the game and authenticated cloud-save API", async () => {
const dataDir = path.join(directory, "data"); const dataDir = path.join(directory, "data");
const contentDir = path.join(directory, "content"); const contentDir = path.join(directory, "content");
mkdirSync(staticDir, { recursive: true }); mkdirSync(staticDir, { recursive: true });
mkdirSync(path.join(staticDir, "assets"), { recursive: true });
mkdirSync(path.join(staticDir, "content", "bundled"), { recursive: true });
mkdirSync(path.join(contentDir, "objects"), { recursive: true }); mkdirSync(path.join(contentDir, "objects"), { recursive: true });
writeFileSync(path.join(staticDir, "index.html"), "<!doctype html><title>Healer Man</title>"); writeFileSync(path.join(staticDir, "index.html"), "<!doctype html><title>Healer Man</title>");
const hashedScript = "console.log('compressed delivery');";
writeFileSync(path.join(staticDir, "assets", "app-12345678.js"), hashedScript);
writeFileSync(path.join(staticDir, "assets", "app-12345678.js.br"), brotliCompressSync(hashedScript));
writeFileSync(path.join(staticDir, "content", "bootstrap-manifest.json"), JSON.stringify({ schemaVersion: 1, files: {} }));
writeFileSync(path.join(staticDir, "content", "bundled", `${"a".repeat(64)}.json`), "{}");
writeFileSync(path.join(staticDir, "sample.glb"), Buffer.from("0123456789")); writeFileSync(path.join(staticDir, "sample.glb"), Buffer.from("0123456789"));
writeFileSync(path.join(contentDir, "manifest.json"), JSON.stringify({ schemaVersion: 1 })); writeFileSync(path.join(contentDir, "manifest.json"), JSON.stringify({ schemaVersion: 1 }));
writeFileSync(path.join(contentDir, "objects", "asset.glb"), Buffer.from("abcdefghij")); writeFileSync(path.join(contentDir, "objects", "asset.glb"), Buffer.from("abcdefghij"));
const runtime = createGameServer({ staticDir, contentDir, dataDir, corsOrigins: "https://iwanttoheal.phenomrom.com" }); const runtime = createGameServer({
staticDir, contentDir, dataDir, corsOrigins: "https://iwanttoheal.phenomrom.com", enableShelvedModes: true,
});
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve)); await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
const address = runtime.server.address(); const address = runtime.server.address();
const origin = `http://127.0.0.1:${address.port}`; const origin = `http://127.0.0.1:${address.port}`;
try { try {
assert.equal((await fetch(`${origin}/`)).status, 200); assert.equal((await fetch(`${origin}/`)).status, 200);
const compressedScript = await fetch(`${origin}/assets/app-12345678.js`, {
headers: { "Accept-Encoding": "br" },
});
assert.equal(compressedScript.headers.get("content-encoding"), "br");
assert.equal(compressedScript.headers.get("cache-control"), "public, max-age=31536000, immutable");
assert.equal(await compressedScript.text(), hashedScript);
const bundledObject = await fetch(`${origin}/content/bundled/${"a".repeat(64)}.json`);
assert.equal(bundledObject.headers.get("cache-control"), "public, max-age=31536000, immutable");
assert.equal((await fetch(`${origin}/content/bootstrap-manifest.json`)).headers.get("cache-control"), "no-cache");
const range = await fetch(`${origin}/sample.glb`, { headers: { Range: "bytes=2-5" } }); const range = await fetch(`${origin}/sample.glb`, { headers: { Range: "bytes=2-5" } });
assert.equal(range.status, 206); assert.equal(range.status, 206);
assert.equal(await range.text(), "2345"); assert.equal(await range.text(), "2345");
@@ -250,3 +269,44 @@ test("serves the game and authenticated cloud-save API", async () => {
rmSync(directory, { recursive: true, force: true }); rmSync(directory, { recursive: true, force: true });
} }
}); });
test("disables shelved APIs and Manastorm group creation by default", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-shelved-server-test-"));
const staticDir = path.join(directory, "dist");
const dataDir = path.join(directory, "data");
const contentDir = path.join(directory, "content");
mkdirSync(staticDir, { recursive: true });
mkdirSync(contentDir, { recursive: true });
writeFileSync(path.join(staticDir, "index.html"), "<!doctype html><title>Healer Man</title>");
const runtime = createGameServer({ staticDir, contentDir, dataDir, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
const address = runtime.server.address();
const origin = `http://127.0.0.1:${address.port}`;
try {
assert.equal(runtime.enableShelvedModes, false);
for (const route of ["/api/manastorm-config", "/api/gm/manastorm-config", "/api/arena/state"]) {
const response = await fetch(`${origin}${route}`);
assert.equal(response.status, 503);
assert.equal((await response.json()).code, "feature_disabled");
}
const registration = await fetch(`${origin}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "ShelvedHealer", password: "healing-pass" }),
});
const auth = await registration.json();
const activity = await fetch(`${origin}/api/online-group/activity`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
body: JSON.stringify({ type: "manastorm", contentId: "deadmines", partySize: 1, fillWithAi: true }),
});
assert.equal(activity.status, 503);
assert.equal((await activity.json()).code, "feature_disabled");
assert.equal(runtime.arenaService.inspect().tickets.size, 0);
} finally {
await new Promise((resolve) => runtime.server.close(resolve));
rmSync(directory, { recursive: true, force: true });
}
});
+17
View File
@@ -0,0 +1,17 @@
{
"schemaVersion": 1,
"bootstrapPackIds": [
"core:ui",
"core:equipment",
"core:character-basic",
"core:runtime",
"dungeon:wailing-caverns"
],
"hiddenContentPackIds": [
"core:arenas"
],
"hiddenContentPackPrefixes": [
"manastorm:"
],
"maxBootstrapStoredBytes": 83886080
}
+10 -13
View File
@@ -1,11 +1,12 @@
import { lazy, Suspense, useEffect, useMemo, useState, type ReactNode } from "react"; import { lazy, Suspense, useEffect, useMemo, useState, type ReactNode } from "react";
import { BASE_CLASS_IDS, classById } from "./app/characterCatalog"; import { BASE_CLASS_IDS, classById } from "./app/characterCatalog";
import { useShellStore } from "./app/shellStore"; import { useShellStore } from "./app/shellStore";
import { isShelvedShellPhase, SHELVED_MODES_ENABLED } from "./app/releaseFeatures";
import { preloadAbilityCatalogsForClasses } from "./game/abilityCatalog"; import { preloadAbilityCatalogsForClasses } from "./game/abilityCatalog";
import { useManastormAdminStore } from "./game/manastormAdminStore";
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen"; import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
import { LoginScreen } from "./ui/LoginScreen"; import { LoginScreen } from "./ui/LoginScreen";
import { OnlineGroupCoordinator } from "./app/OnlineGroupCoordinator"; import { OnlineGroupCoordinator } from "./app/OnlineGroupCoordinator";
import { ShelvedModeRoute } from "@shelvedModeRoutes";
import { preloadTalentGameplayResources } from "./ui/talentGameplayResources"; import { preloadTalentGameplayResources } from "./ui/talentGameplayResources";
import { import {
getLoadedGameRuntime, getLoadedGameRuntime,
@@ -19,11 +20,6 @@ const CharacterSelectScreen = lazy(() => import("./ui/CharacterSelectScreen").th
const ClassHallScreen = lazy(() => import("./ui/ClassHallScreen").then((module) => ({ default: module.ClassHallScreen }))); const ClassHallScreen = lazy(() => import("./ui/ClassHallScreen").then((module) => ({ default: module.ClassHallScreen })));
const DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen }))); const DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen })));
const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen }))); const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
const ManastormSelectScreen = lazy(() => import("./ui/ManastormSelectScreen").then((module) => ({ default: module.ManastormSelectScreen })));
const ArenaSelectScreen = lazy(() => import("./ui/ArenaSelectScreen").then((module) => ({ default: module.ArenaSelectScreen })));
const ArenaRuntime = lazy(() => import("./ArenaRuntime"));
const GmAdminScreen = lazy(() => import("./ui/GmAdminScreen").then((module) => ({ default: module.GmAdminScreen })));
function ShellTransition() { function ShellTransition() {
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>; return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
} }
@@ -74,12 +70,14 @@ export default function App() {
useAuthoritativeDualScreenSync(); useAuthoritativeDualScreenSync();
const phase = useShellStore((state) => state.phase); const phase = useShellStore((state) => state.phase);
const session = useShellStore((state) => state.session); const session = useShellStore((state) => state.session);
const refreshManastormConfig = useManastormAdminStore((state) => state.refresh);
const PreloadedGameRuntime = getLoadedGameRuntime(); const PreloadedGameRuntime = getLoadedGameRuntime();
useEffect(() => { useEffect(() => {
void refreshManastormConfig(session); if (!SHELVED_MODES_ENABLED && isShelvedShellPhase(phase)) {
}, [refreshManastormConfig, session]); useShellStore.getState().returnToMainMenu();
useShellStore.getState().setNotice("That game mode is temporarily unavailable.");
}
}, [phase]);
let screen = null; let screen = null;
if (phase === "login") screen = <LoginScreen />; if (phase === "login") screen = <LoginScreen />;
@@ -88,10 +86,9 @@ export default function App() {
else if (phase === "main-menu") screen = <Suspense fallback={<ShellTransition />}><MainMenuScreen /></Suspense>; else if (phase === "main-menu") screen = <Suspense fallback={<ShellTransition />}><MainMenuScreen /></Suspense>;
else if (phase === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>; else if (phase === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>; else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
else if (phase === "manastorms") screen = <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>; else if (isShelvedShellPhase(phase)) {
else if (phase === "arenas") screen = <Suspense fallback={<ShellTransition />}><ArenaSelectScreen /></Suspense>; screen = <Suspense fallback={<ShellTransition />}><ShelvedModeRoute phase={phase} session={session} /></Suspense>;
else if (phase === "arena-game") screen = <Suspense fallback={<ShellTransition />}><ArenaRuntime /></Suspense>; }
else if (phase === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><GmAdminScreen /></Suspense>;
else if (phase === "game") screen = PreloadedGameRuntime else if (phase === "game") screen = PreloadedGameRuntime
? <PreloadedGameRuntime /> ? <PreloadedGameRuntime />
: <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>; : <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>;
+4 -10
View File
@@ -8,9 +8,6 @@ import { releaseDungeonGltfCache } from "./game/dungeonAssetCache";
import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset"; import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset";
import { requireDungeonDefinition } from "./game/dungeonRegistry"; import { requireDungeonDefinition } from "./game/dungeonRegistry";
import { PartyBridge } from "./game/PartyBridge"; import { PartyBridge } from "./game/PartyBridge";
import { ManastormBridge } from "./game/ManastormBridge";
import { releaseActiveManastormStage } from "./game/manastormStageLoader";
import { lootCurrentManastormBoss } from "./game/manastormLoot";
import { usePartyStore } from "./game/partyStore"; import { usePartyStore } from "./game/partyStore";
import { useGameStore } from "./game/store"; import { useGameStore } from "./game/store";
import { GameScene } from "./scene/GameScene"; import { GameScene } from "./scene/GameScene";
@@ -20,8 +17,6 @@ import { GameplayPanels } from "./ui/GameplayPanels";
import { MapPanel } from "./ui/MapPanel"; import { MapPanel } from "./ui/MapPanel";
import { PauseMenu } from "./ui/PauseMenu"; import { PauseMenu } from "./ui/PauseMenu";
import { SceneErrorBoundary } from "./ui/SceneErrorBoundary"; import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
import { GmWorldEditor } from "./ui/GmWorldEditor";
import { GmReviewCard } from "./ui/GmReviewCard";
import { OnlineSessionBridge } from "./game/OnlineSessionBridge"; import { OnlineSessionBridge } from "./game/OnlineSessionBridge";
import { DungeonRunCheckpointBridge } from "./game/DungeonRunCheckpointBridge"; import { DungeonRunCheckpointBridge } from "./game/DungeonRunCheckpointBridge";
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge"; import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
@@ -33,6 +28,7 @@ import { activeUiLayout, currentUiDevice } from "./game/uiLayoutStore";
import { useUiLayoutStore } from "./game/uiLayoutStore"; import { useUiLayoutStore } from "./game/uiLayoutStore";
import { profileInputBindings } from "./game/keybindPreferences"; import { profileInputBindings } from "./game/keybindPreferences";
import { KeybindsPanel } from "./ui/KeybindsPanel"; import { KeybindsPanel } from "./ui/KeybindsPanel";
import { lootShelvedBoss, releaseShelvedGameRuntime, ShelvedGameBridges } from "@shelvedGameRuntime";
function InputBridge() { function InputBridge() {
const overlay = useGameStore((state) => state.overlay); const overlay = useGameStore((state) => state.overlay);
@@ -57,7 +53,7 @@ function InputBridge() {
toggleInventory: () => useGameStore.getState().toggleOverlay("inventory"), toggleInventory: () => useGameStore.getState().toggleOverlay("inventory"),
toggleSpellbook: () => useGameStore.getState().toggleOverlay("spellbook"), toggleSpellbook: () => useGameStore.getState().toggleOverlay("spellbook"),
toggleTalents: () => useGameStore.getState().toggleOverlay("talents"), toggleTalents: () => useGameStore.getState().toggleOverlay("talents"),
lootActiveBoss: () => interactWithActiveDungeonObject() || lootCurrentManastormBoss(), lootActiveBoss: () => interactWithActiveDungeonObject() || lootShelvedBoss(),
castActionBinding: (layer, control) => { castActionBinding: (layer, control) => {
const game = useGameStore.getState(); const game = useGameStore.getState();
useCombatStore.getState().castActionBinding(layer, control, game.playerPosition, Date.now()); useCombatStore.getState().castActionBinding(layer, control, game.playerPosition, Date.now());
@@ -102,7 +98,7 @@ export default function GameRuntime() {
const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId); const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId);
clearDungeonRuntimeCaches(); clearDungeonRuntimeCaches();
releaseDungeonGltfCache(activeDungeon); releaseDungeonGltfCache(activeDungeon);
releaseActiveManastormStage(); releaseShelvedGameRuntime();
}, []); }, []);
return ( return (
<DualDisplayFrame <DualDisplayFrame
@@ -117,7 +113,7 @@ export default function GameRuntime() {
<DungeonRunCheckpointBridge /> <DungeonRunCheckpointBridge />
<CombatBridge /> <CombatBridge />
<PartyBridge /> <PartyBridge />
<ManastormBridge /> <ShelvedGameBridges />
<SceneErrorBoundary><GameScene /></SceneErrorBoundary> <SceneErrorBoundary><GameScene /></SceneErrorBoundary>
<Hud /> <Hud />
<EditableHudElement id="interaction"><DungeonInteractionPrompt /></EditableHudElement> <EditableHudElement id="interaction"><DungeonInteractionPrompt /></EditableHudElement>
@@ -126,8 +122,6 @@ export default function GameRuntime() {
<PauseMenu /> <PauseMenu />
<UiLayoutEditor /> <UiLayoutEditor />
<KeybindsPanel /> <KeybindsPanel />
<GmReviewCard />
<GmWorldEditor />
</main> </main>
} }
bottom={<LocalCompanionPanel />} bottom={<LocalCompanionPanel />}
+44
View File
@@ -1,6 +1,10 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { OnlineGroupActivity } from "./onlineGroupTypes"; import type { OnlineGroupActivity } from "./onlineGroupTypes";
import { enterOnlineGroupActivity } from "./OnlineGroupCoordinator"; import { enterOnlineGroupActivity } from "./OnlineGroupCoordinator";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
const normalReleaseIt = SHELVED_MODES_ENABLED ? it.skip : it;
const shelvedModeIt = SHELVED_MODES_ENABLED ? it : it.skip;
const DUNGEON_ACTIVITY: OnlineGroupActivity = { const DUNGEON_ACTIVITY: OnlineGroupActivity = {
id: "activity-dungeon-1", id: "activity-dungeon-1",
@@ -12,6 +16,14 @@ const DUNGEON_ACTIVITY: OnlineGroupActivity = {
startedAt: 1, startedAt: 1,
}; };
const MANASTORM_ACTIVITY: OnlineGroupActivity = {
...DUNGEON_ACTIVITY,
id: "activity-manastorm-1",
type: "manastorm",
partySize: 3,
startingLevel: 6,
};
function entryDependencies(enterDungeon: () => boolean) { function entryDependencies(enterDungeon: () => boolean) {
return { return {
installDungeonRuntime: vi.fn(async () => undefined), installDungeonRuntime: vi.fn(async () => undefined),
@@ -75,4 +87,36 @@ describe("online group activity entry", () => {
expect(dependencies.enterDungeon).not.toHaveBeenCalled(); expect(dependencies.enterDungeon).not.toHaveBeenCalled();
expect(consumeActivity).not.toHaveBeenCalled(); expect(consumeActivity).not.toHaveBeenCalled();
}); });
normalReleaseIt("does not load or enter a shelved group activity", async () => {
const consumeActivity = vi.fn();
const dependencies = entryDependencies(() => true);
await expect(enterOnlineGroupActivity(
MANASTORM_ACTIVITY,
"healer",
consumeActivity,
dependencies,
)).resolves.toBe(false);
expect(dependencies.installManastormRuntime).not.toHaveBeenCalled();
expect(dependencies.enterManastorm).not.toHaveBeenCalled();
expect(consumeActivity).not.toHaveBeenCalled();
});
shelvedModeIt("restores Manastorm group entry in the developer mode", async () => {
const consumeActivity = vi.fn();
const dependencies = entryDependencies(() => true);
await expect(enterOnlineGroupActivity(
MANASTORM_ACTIVITY,
"healer",
consumeActivity,
dependencies,
)).resolves.toBe(true);
expect(dependencies.installManastormRuntime).toHaveBeenCalledOnce();
expect(dependencies.enterManastorm).toHaveBeenCalledWith("healer", 3, 6);
expect(consumeActivity).toHaveBeenCalledWith(MANASTORM_ACTIVITY.id);
});
}); });
+8 -4
View File
@@ -6,9 +6,11 @@ import {
defaultPartyRoleForClass, defaultPartyRoleForClass,
type PartyRole, type PartyRole,
} from "../game/partyRoles"; } from "../game/partyRoles";
import { isManastormPartySize, type ManastormPartySize } from "../game/manastormProgress"; import type { ManastormPartySize } from "../game/manastormProgress";
import type { OnlineGroupActivity } from "./onlineGroupTypes"; import type { OnlineGroupActivity } from "./onlineGroupTypes";
import { useContentManagerSnapshot } from "../content/useContentManager"; import { useContentManagerSnapshot } from "../content/useContentManager";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
import { installShelvedOnlineGroupRuntime } from "@shelvedOnlineGroupRuntime";
const GROUP_POLL_MS = 2_500; const GROUP_POLL_MS = 2_500;
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000; const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
@@ -27,8 +29,7 @@ const DEFAULT_ACTIVITY_ENTRY_DEPENDENCIES: OnlineGroupActivityEntryDependencies
}, },
enterDungeon: (dungeonId, role) => useShellStore.getState().enterDungeon(dungeonId, role), enterDungeon: (dungeonId, role) => useShellStore.getState().enterDungeon(dungeonId, role),
installManastormRuntime: async () => { installManastormRuntime: async () => {
const { installManastormShellRuntime } = await import("./manastormShellRuntime"); await installShelvedOnlineGroupRuntime();
installManastormShellRuntime();
}, },
enterManastorm: (role, partySize, startingLevel) => useShellStore.getState().enterManastorm( enterManastorm: (role, partySize, startingLevel) => useShellStore.getState().enterManastorm(
role, role,
@@ -48,8 +49,11 @@ export async function enterOnlineGroupActivity(
await dependencies.installDungeonRuntime(); await dependencies.installDungeonRuntime();
entered = dependencies.enterDungeon(activity.contentId, role); entered = dependencies.enterDungeon(activity.contentId, role);
} else { } else {
if (!SHELVED_MODES_ENABLED) return false;
await dependencies.installManastormRuntime(); await dependencies.installManastormRuntime();
const partySize = isManastormPartySize(activity.partySize) ? activity.partySize : 5; const partySize = Number.isInteger(activity.partySize) && activity.partySize >= 1 && activity.partySize <= 5
? activity.partySize as ManastormPartySize
: 5;
entered = dependencies.enterManastorm(role, partySize, activity.startingLevel ?? undefined); entered = dependencies.enterManastorm(role, partySize, activity.startingLevel ?? undefined);
} }
if (entered) consumeActivity(activity.id); if (entered) consumeActivity(activity.id);
+9
View File
@@ -0,0 +1,9 @@
export function ShelvedGameBridges() {
return null;
}
export function lootShelvedBoss(): boolean {
return false;
}
export function releaseShelvedGameRuntime(): void {}
+23
View File
@@ -0,0 +1,23 @@
import { ManastormBridge } from "../game/ManastormBridge";
import { releaseActiveManastormStage } from "../game/manastormStageLoader";
import { lootCurrentManastormBoss } from "../game/manastormLoot";
import { GmReviewCard } from "../ui/GmReviewCard";
import { GmWorldEditor } from "../ui/GmWorldEditor";
export function ShelvedGameBridges() {
return (
<>
<ManastormBridge />
<GmReviewCard />
<GmWorldEditor />
</>
);
}
export function lootShelvedBoss(): boolean {
return lootCurrentManastormBoss();
}
export function releaseShelvedGameRuntime(): void {
releaseActiveManastormStage();
}
+8
View File
@@ -0,0 +1,8 @@
import type { PlayerSession, ShellPhase } from "./types";
export function ShelvedModeRoute(_props: {
readonly phase: ShellPhase;
readonly session: PlayerSession | null;
}) {
return null;
}
+33
View File
@@ -0,0 +1,33 @@
import { lazy, Suspense, useEffect } from "react";
import type { PlayerSession, ShellPhase } from "./types";
import { useManastormAdminStore } from "../game/manastormAdminStore";
const ManastormSelectScreen = lazy(() => import("../ui/ManastormSelectScreen").then((module) => ({ default: module.ManastormSelectScreen })));
const ArenaSelectScreen = lazy(() => import("../ui/ArenaSelectScreen").then((module) => ({ default: module.ArenaSelectScreen })));
const ArenaRuntime = lazy(() => import("../ArenaRuntime"));
const GmAdminScreen = lazy(() => import("../ui/GmAdminScreen").then((module) => ({ default: module.GmAdminScreen })));
function ShelvedTransition() {
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
}
export function ShelvedModeRoute({
phase,
session,
}: {
readonly phase: ShellPhase;
readonly session: PlayerSession | null;
}) {
const refreshManastormConfig = useManastormAdminStore((state) => state.refresh);
useEffect(() => {
void refreshManastormConfig(session);
}, [refreshManastormConfig, session]);
let screen = null;
if (phase === "manastorms") screen = <ManastormSelectScreen />;
else if (phase === "arenas") screen = <ArenaSelectScreen />;
else if (phase === "arena-game") screen = <ArenaRuntime />;
else if (phase === "gm-admin") screen = <GmAdminScreen />;
return <Suspense fallback={<ShelvedTransition />}>{screen}</Suspense>;
}
@@ -0,0 +1 @@
export async function installShelvedOnlineGroupRuntime(): Promise<void> {}
@@ -0,0 +1,4 @@
export async function installShelvedOnlineGroupRuntime(): Promise<void> {
const { installManastormShellRuntime } = await import("./manastormShellRuntime");
installManastormShellRuntime();
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import {
isShelvedShellPhase,
sessionCanUseShelvedGmTools,
SHELVED_MODES_ENABLED,
} from "./releaseFeatures";
describe("release feature boundary", () => {
it("recognizes every legacy shelved phase", () => {
expect(["manastorms", "arenas", "arena-game", "gm-admin"].every(isShelvedShellPhase)).toBe(true);
expect(isShelvedShellPhase("main-menu")).toBe(false);
expect(isShelvedShellPhase("game")).toBe(false);
});
it("exposes GM tools only in a shelved build with an authenticated GM", () => {
const session = {
kind: "account",
ownerId: "gm-account",
displayName: "Phenom",
accessToken: "token",
roles: ["gm"],
} as const;
expect(sessionCanUseShelvedGmTools(session)).toBe(SHELVED_MODES_ENABLED);
expect(sessionCanUseShelvedGmTools({ ...session, roles: [] })).toBe(false);
expect(sessionCanUseShelvedGmTools({ ...session, accessToken: "" })).toBe(false);
});
});
+23
View File
@@ -0,0 +1,23 @@
export const SHELVED_MODES_ENABLED = import.meta.env.VITE_SHELVED_MODES_ENABLED === "true";
export const SHELVED_SHELL_PHASES = Object.freeze([
"manastorms",
"arenas",
"arena-game",
"gm-admin",
] as const);
export type ShelvedShellPhase = (typeof SHELVED_SHELL_PHASES)[number];
export function isShelvedShellPhase(value: string): value is ShelvedShellPhase {
return (SHELVED_SHELL_PHASES as readonly string[]).includes(value);
}
export function sessionCanUseShelvedGmTools(session: {
readonly kind: string;
readonly accessToken?: string;
readonly roles?: readonly string[];
} | null): boolean {
return SHELVED_MODES_ENABLED
&& Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
}
+28 -2
View File
@@ -2,6 +2,10 @@ import { describe, expect, it, vi } from "vitest";
import { registerDungeonShellRuntime } from "./dungeonShellRuntimeBridge"; import { registerDungeonShellRuntime } from "./dungeonShellRuntimeBridge";
import { registerManastormShellRuntime } from "./manastormShellRuntimeBridge"; import { registerManastormShellRuntime } from "./manastormShellRuntimeBridge";
import { useShellStore } from "./shellStore"; import { useShellStore } from "./shellStore";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
const normalReleaseIt = SHELVED_MODES_ENABLED ? it.skip : it;
const shelvedModeIt = SHELVED_MODES_ENABLED ? it : it.skip;
describe("on-demand shell runtime delegation", () => { describe("on-demand shell runtime delegation", () => {
it("delegates dungeon entry after its phase runtime is installed", () => { it("delegates dungeon entry after its phase runtime is installed", () => {
@@ -12,7 +16,30 @@ describe("on-demand shell runtime delegation", () => {
expect(enterDungeon).toHaveBeenCalledWith("wailing-caverns", "healer"); expect(enterDungeon).toHaveBeenCalledWith("wailing-caverns", "healer");
}); });
it("delegates Manastorm operations after its phase runtime is installed", () => { normalReleaseIt("ignores registered Manastorm delegates in a normal release", () => {
const enterManastorm = vi.fn(() => true);
const saveSelectedLoadout = vi.fn(() => true);
const abandonRun = vi.fn();
const failRun = vi.fn();
registerManastormShellRuntime({
enterManastorm,
saveSelectedLoadout,
abandonRun,
failRun,
leaveActiveGame: vi.fn(),
});
expect(useShellStore.getState().enterManastorm("healer", 3, 6)).toBe(false);
expect(useShellStore.getState().saveSelectedManastormLoadout(["spell-1"])).toBe(false);
useShellStore.getState().abandonManastormRun();
useShellStore.getState().failActiveManastorm();
expect(enterManastorm).not.toHaveBeenCalled();
expect(saveSelectedLoadout).not.toHaveBeenCalled();
expect(abandonRun).not.toHaveBeenCalled();
expect(failRun).not.toHaveBeenCalled();
});
shelvedModeIt("delegates Manastorm operations after its phase runtime is installed", () => {
const enterManastorm = vi.fn(() => true); const enterManastorm = vi.fn(() => true);
const saveSelectedLoadout = vi.fn(() => true); const saveSelectedLoadout = vi.fn(() => true);
const abandonRun = vi.fn(); const abandonRun = vi.fn();
@@ -37,4 +64,3 @@ describe("on-demand shell runtime delegation", () => {
expect(failRun).toHaveBeenCalledOnce(); expect(failRun).toHaveBeenCalledOnce();
}); });
}); });
+16 -4
View File
@@ -3,6 +3,7 @@ import { createDefaultAppearance } from "./characterCatalog";
import { useShellStore } from "./shellStore"; import { useShellStore } from "./shellStore";
import { installDungeonShellRuntime } from "./dungeonShellRuntime"; import { installDungeonShellRuntime } from "./dungeonShellRuntime";
import { installManastormShellRuntime } from "./manastormShellRuntime"; import { installManastormShellRuntime } from "./manastormShellRuntime";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
import { DUNGEON_MANIFEST } from "../game/dungeonManifest"; import { DUNGEON_MANIFEST } from "../game/dungeonManifest";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { useManastormStore } from "../game/manastormStore"; import { useManastormStore } from "../game/manastormStore";
@@ -23,6 +24,8 @@ import { useManastormAdminStore } from "../game/manastormAdminStore";
installDungeonShellRuntime(); installDungeonShellRuntime();
installManastormShellRuntime(); installManastormShellRuntime();
const normalReleaseIt = SHELVED_MODES_ENABLED ? it.skip : it;
const shelvedModeIt = SHELVED_MODES_ENABLED ? it : it.skip;
const character = { const character = {
id: "character-1", id: "character-1",
@@ -254,7 +257,16 @@ describe("shell store", () => {
}); });
}); });
it("opens Manastorms, validates the role, and starts a fresh level-one run", () => { normalReleaseIt("keeps legacy shelved commands out of the normal shell", () => {
prepareSelectedCharacter();
expect(useShellStore.getState().openManastorms()).toBe(false);
expect(useShellStore.getState().openArenas()).toBe(false);
expect(useShellStore.getState().enterManastorm("healer")).toBe(false);
expect(useShellStore.getState().phase).toBe("characters");
expect(useShellStore.getState().notice).toMatch(/temporarily unavailable/i);
});
shelvedModeIt("opens Manastorms, validates the role, and starts a fresh level-one run", () => {
prepareSelectedCharacter(); prepareSelectedCharacter();
expect(useShellStore.getState().openManastorms()).toBe(true); expect(useShellStore.getState().openManastorms()).toBe(true);
expect(useShellStore.getState().phase).toBe("manastorms"); expect(useShellStore.getState().phase).toBe("manastorms");
@@ -278,7 +290,7 @@ describe("shell store", () => {
); );
}); });
it("starts each party size from only its own unlocked checkpoint", () => { shelvedModeIt("starts each party size from only its own unlocked checkpoint", () => {
const progress = recordManastormCheckpoint(createEmptyManastormProgress(), 3, 5, 6); const progress = recordManastormCheckpoint(createEmptyManastormProgress(), 3, 5, 6);
useShellStore.setState({ useShellStore.setState({
phase: "manastorms", phase: "manastorms",
@@ -302,7 +314,7 @@ describe("shell store", () => {
}); });
}); });
it("selects end-game mode at the character cap and hands off an eligible checkpoint", () => { shelvedModeIt("selects end-game mode at the character cap and hands off an eligible checkpoint", () => {
const progress = recordManastormCheckpoint( const progress = recordManastormCheckpoint(
createEmptyManastormProgress(), createEmptyManastormProgress(),
3, 3,
@@ -333,7 +345,7 @@ describe("shell store", () => {
}); });
}); });
it("passes a persisted client-grounded spell selection into the run runtime", () => { shelvedModeIt("passes a persisted client-grounded spell selection into the run runtime", () => {
const model = manastormActiveSpellLoadoutModel(character.classId, character.level); const model = manastormActiveSpellLoadoutModel(character.classId, character.level);
const selectedIds = model.options.slice(0, 2).map((option) => option.id); const selectedIds = model.options.slice(0, 2).map((option) => option.id);
const unlocked = mergeManastormUnlockedContent( const unlocked = mergeManastormUnlockedContent(
+25 -5
View File
@@ -56,7 +56,7 @@ import {
logoutOnlineSession, logoutOnlineSession,
refreshOnlineSession, refreshOnlineSession,
} from "./onlineAccountClient"; } from "./onlineAccountClient";
import { isGameMasterSession } from "../game/manastormAdminConfig"; import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "./releaseFeatures";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { import {
normalizeSpecializationId, normalizeSpecializationId,
@@ -324,6 +324,10 @@ export const useShellStore = create<ShellState>((set, get) => ({
return true; return true;
}, },
openManastorms: () => { openManastorms: () => {
if (!SHELVED_MODES_ENABLED) {
set({ notice: "Manastorms are temporarily unavailable." });
return false;
}
const { session, selectedCharacterId, characters } = get(); const { session, selectedCharacterId, characters } = get();
if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) { if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." }); set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
@@ -338,6 +342,10 @@ export const useShellStore = create<ShellState>((set, get) => ({
return true; return true;
}, },
openArenas: () => { openArenas: () => {
if (!SHELVED_MODES_ENABLED) {
set({ notice: "Arenas are temporarily unavailable." });
return false;
}
const { session, selectedCharacterId, characters } = get(); const { session, selectedCharacterId, characters } = get();
if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) { if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." }); set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
@@ -348,7 +356,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
}, },
openGmAdmin: () => { openGmAdmin: () => {
const { session, selectedCharacterId, characters } = get(); const { session, selectedCharacterId, characters } = get();
if (!isGameMasterSession(session)) { if (!sessionCanUseShelvedGmTools(session)) {
set({ notice: "Game Master access is required." }); set({ notice: "Game Master access is required." });
return false; return false;
} }
@@ -429,7 +437,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
}, },
returnToMainMenu: () => { returnToMainMenu: () => {
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false }); useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
manastormShellRuntime()?.leaveActiveGame(); if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.leaveActiveGame();
const { session, characters, activeCharacter, selectedCharacterId } = get(); const { session, characters, activeCharacter, selectedCharacterId } = get();
if (!session) { if (!session) {
set({ phase: "login", activeCharacter: null }); set({ phase: "login", activeCharacter: null });
@@ -449,13 +457,21 @@ export const useShellStore = create<ShellState>((set, get) => ({
return false; return false;
}, },
enterManastorm: (requestedRole, requestedPartySize = 5, requestedStartingLevel) => { enterManastorm: (requestedRole, requestedPartySize = 5, requestedStartingLevel) => {
if (!SHELVED_MODES_ENABLED) {
set({ notice: "Manastorms are temporarily unavailable." });
return false;
}
const runtime = manastormShellRuntime(); const runtime = manastormShellRuntime();
if (runtime) return runtime.enterManastorm(requestedRole, requestedPartySize, requestedStartingLevel); if (runtime) return runtime.enterManastorm(requestedRole, requestedPartySize, requestedStartingLevel);
set({ notice: "Manastorm data is still loading. Try again in a moment." }); set({ notice: "Manastorm data is still loading. Try again in a moment." });
return false; return false;
}, },
abandonManastormRun: () => manastormShellRuntime()?.abandonRun(), abandonManastormRun: () => {
failActiveManastorm: () => manastormShellRuntime()?.failRun(), if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.abandonRun();
},
failActiveManastorm: () => {
if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.failRun();
},
enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID), enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID),
returnToCharacters: () => { returnToCharacters: () => {
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false }); useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
@@ -594,6 +610,10 @@ export const useShellStore = create<ShellState>((set, get) => ({
})); }));
}, },
saveSelectedManastormLoadout: (loadout) => { saveSelectedManastormLoadout: (loadout) => {
if (!SHELVED_MODES_ENABLED) {
set({ notice: "Manastorms are temporarily unavailable." });
return false;
}
const runtime = manastormShellRuntime(); const runtime = manastormShellRuntime();
if (runtime) return runtime.saveSelectedLoadout(loadout); if (runtime) return runtime.saveSelectedLoadout(loadout);
set({ notice: "Manastorm data is still loading. Try again in a moment." }); set({ notice: "Manastorm data is still loading. Try again in a moment." });
@@ -75,14 +75,24 @@
"detail": "green" "detail": "green"
}, },
{ {
"name": "population accounting", "name": "scripted population accounting",
"passed": true, "passed": true,
"detail": "249 = 56 + 193" "detail": "44 = 44"
},
{
"name": "source population accounting",
"passed": true,
"detail": "249 = 40 + 209"
},
{
"name": "boss population accounting",
"passed": true,
"detail": "5 = 5"
}, },
{ {
"name": "combat nav projection", "name": "combat nav projection",
"passed": true, "passed": true,
"detail": "3.385892" "detail": "0.998158"
}, },
{ {
"name": "mechanics is an explicit review gate", "name": "mechanics is an explicit review gate",
@@ -104,6 +114,51 @@
"passed": true, "passed": true,
"detail": "grafu-castle" "detail": "grafu-castle"
}, },
{
"name": "actor atmo exists",
"passed": true,
"detail": "public/assets/creatures/grafu-castle/atmo.glb"
},
{
"name": "actor atmo checksum",
"passed": true,
"detail": "9f7c1ad1c56ef51ee0511507f950ef97224ad86af561ded111cf1d5740c69999"
},
{
"name": "actor atmo size",
"passed": true,
"detail": "1981260"
},
{
"name": "actor barbo-brother exists",
"passed": true,
"detail": "public/assets/creatures/grafu-castle/barbo-brother.glb"
},
{
"name": "actor barbo-brother checksum",
"passed": true,
"detail": "af1cd20b7cc1d465c270860997f1245b2967005b1e5308b4a43401e983f883a4"
},
{
"name": "actor barbo-brother size",
"passed": true,
"detail": "2226000"
},
{
"name": "actor black-hat exists",
"passed": true,
"detail": "public/assets/creatures/grafu-castle/black-hat.glb"
},
{
"name": "actor black-hat checksum",
"passed": true,
"detail": "e3d5265c794b82d45c34bd7875e33c9f00680dc67c3cc18213f5df657f9ab76a"
},
{
"name": "actor black-hat size",
"passed": true,
"detail": "1965712"
},
{ {
"name": "actor black-hat-blue exists", "name": "actor black-hat-blue exists",
"passed": true, "passed": true,
@@ -112,12 +167,12 @@
{ {
"name": "actor black-hat-blue checksum", "name": "actor black-hat-blue checksum",
"passed": true, "passed": true,
"detail": "bcd344260000235789ddb2452a285210000646659538e56e57857d9667969edd" "detail": "e6b78de784549ce814f998c34b8e1aef42e452e26c7395ac74fa5db168413d2f"
}, },
{ {
"name": "actor black-hat-blue size", "name": "actor black-hat-blue size",
"passed": true, "passed": true,
"detail": "1561332" "detail": "1560524"
}, },
{ {
"name": "actor bunny exists", "name": "actor bunny exists",
@@ -127,12 +182,12 @@
{ {
"name": "actor bunny checksum", "name": "actor bunny checksum",
"passed": true, "passed": true,
"detail": "6d1ed1424fc075b472224f6bf2cd7c1ea291d7605ad280ce8ae0570d060f4b3e" "detail": "e29298726425e0109890fb0b33d770f39e051b7a43d50a666f6f6c46a3f2ee32"
}, },
{ {
"name": "actor bunny size", "name": "actor bunny size",
"passed": true, "passed": true,
"detail": "540500" "detail": "540408"
}, },
{ {
"name": "actor cereus-01 exists", "name": "actor cereus-01 exists",
@@ -142,12 +197,12 @@
{ {
"name": "actor cereus-01 checksum", "name": "actor cereus-01 checksum",
"passed": true, "passed": true,
"detail": "324a9b2c338d08258979bbbbdad98e82bcb77f99907b3abbdfc817f4a9aa6610" "detail": "a35ec70378f7323a1f3d50c3e84d5654eacd2888722944c83f9be18cf42eecfa"
}, },
{ {
"name": "actor cereus-01 size", "name": "actor cereus-01 size",
"passed": true, "passed": true,
"detail": "620616" "detail": "620404"
}, },
{ {
"name": "actor controlled-steel-guardian exists", "name": "actor controlled-steel-guardian exists",
@@ -157,12 +212,27 @@
{ {
"name": "actor controlled-steel-guardian checksum", "name": "actor controlled-steel-guardian checksum",
"passed": true, "passed": true,
"detail": "5d0e0f5d6b38c1cf799a30a3ab0718a405feaa84ae844375a15cff6161fa8b77" "detail": "b772b72bffc44c4fcf36ff8aeaf8f2d0c435f220e5c9729ed7e1ce2aac70e8be"
}, },
{ {
"name": "actor controlled-steel-guardian size", "name": "actor controlled-steel-guardian size",
"passed": true, "passed": true,
"detail": "1231536" "detail": "1232036"
},
{
"name": "actor cursed-steel-guardian exists",
"passed": true,
"detail": "public/assets/creatures/grafu-castle/cursed-steel-guardian.glb"
},
{
"name": "actor cursed-steel-guardian checksum",
"passed": true,
"detail": "3ec7660d4e1b475ec9ae290c304802dd4ba174048fac0781ae25474a718227fe"
},
{
"name": "actor cursed-steel-guardian size",
"passed": true,
"detail": "1151612"
}, },
{ {
"name": "actor elder-myconid exists", "name": "actor elder-myconid exists",
@@ -172,12 +242,12 @@
{ {
"name": "actor elder-myconid checksum", "name": "actor elder-myconid checksum",
"passed": true, "passed": true,
"detail": "5e5097cf12bac9780fd9925aae0d6507ad9393ac2e6fd106b2ca9cdf03b2ecc1" "detail": "ef0d1fd12aa130a1a23a21bb978f7859b434d27512fb5da12426f1c1ce34514c"
}, },
{ {
"name": "actor elder-myconid size", "name": "actor elder-myconid size",
"passed": true, "passed": true,
"detail": "1395280" "detail": "1393472"
}, },
{ {
"name": "actor ghastly-clown-red exists", "name": "actor ghastly-clown-red exists",
@@ -187,12 +257,12 @@
{ {
"name": "actor ghastly-clown-red checksum", "name": "actor ghastly-clown-red checksum",
"passed": true, "passed": true,
"detail": "20a6fa59f783c2f17260db9556689906079f6d0d95b835ec26d3e2a7a589f322" "detail": "7f767b8fc224b63f923af6e7f29eda935a746ef6040bc489e4957c2650993ccc"
}, },
{ {
"name": "actor ghastly-clown-red size", "name": "actor ghastly-clown-red size",
"passed": true, "passed": true,
"detail": "825156" "detail": "824792"
}, },
{ {
"name": "actor grinning-clown exists", "name": "actor grinning-clown exists",
@@ -202,12 +272,57 @@
{ {
"name": "actor grinning-clown checksum", "name": "actor grinning-clown checksum",
"passed": true, "passed": true,
"detail": "b53b78efe39b807bc8bbf7952501970cf74ee1483b891b6b61b0880e30b197b2" "detail": "b93ffdcfcf86df01c348678c97dfb913e74da9cab2367e187a46fafb3d85040d"
}, },
{ {
"name": "actor grinning-clown size", "name": "actor grinning-clown size",
"passed": true, "passed": true,
"detail": "1374852" "detail": "1374556"
},
{
"name": "actor hm-male-image-585704 exists",
"passed": true,
"detail": "public/assets/creatures/grafu-castle/hm-male-image-585704.glb"
},
{
"name": "actor hm-male-image-585704 checksum",
"passed": true,
"detail": "7659a1aa2227f98f2ab68d6ffa8b608fda51b9fa72bd987242392e2aca8e5291"
},
{
"name": "actor hm-male-image-585704 size",
"passed": true,
"detail": "9421384"
},
{
"name": "actor hm-male-image-585705 exists",
"passed": true,
"detail": "public/assets/creatures/grafu-castle/hm-male-image-585705.glb"
},
{
"name": "actor hm-male-image-585705 checksum",
"passed": true,
"detail": "2c43c2ae657b4e4347ff9e319080e8d9c44ccaf6c7cc15251230e02abe63c2cd"
},
{
"name": "actor hm-male-image-585705 size",
"passed": true,
"detail": "9569888"
},
{
"name": "actor karth exists",
"passed": true,
"detail": "public/assets/creatures/grafu-castle/karth.glb"
},
{
"name": "actor karth checksum",
"passed": true,
"detail": "ac96b24461b39cdfae7810097dbe6698f0d7ce1afe235df04d72144dceef893d"
},
{
"name": "actor karth size",
"passed": true,
"detail": "1993572"
}, },
{ {
"name": "actor karth-01 exists", "name": "actor karth-01 exists",
@@ -217,12 +332,12 @@
{ {
"name": "actor karth-01 checksum", "name": "actor karth-01 checksum",
"passed": true, "passed": true,
"detail": "867b8c610f1cbb4bfc52ded7d8010abcb3585592ac501b1af0792a5721ba4d3c" "detail": "ee1c1e6937338dff99dad4a8863a03ad7d864a55120271e996a44d6cb83f07ae"
}, },
{ {
"name": "actor karth-01 size", "name": "actor karth-01 size",
"passed": true, "passed": true,
"detail": "1777388" "detail": "1776716"
}, },
{ {
"name": "actor milk-scarab-blue exists", "name": "actor milk-scarab-blue exists",
@@ -232,12 +347,72 @@
{ {
"name": "actor milk-scarab-blue checksum", "name": "actor milk-scarab-blue checksum",
"passed": true, "passed": true,
"detail": "2392d45c63b193f2266e7c0c6e9dcc192fd860caac354395bf02a634f046dae5" "detail": "dde6938b52a45a77f07da63ca26172c66c49f3c10ceb10f6bda17aa3d0fba57d"
}, },
{ {
"name": "actor milk-scarab-blue size", "name": "actor milk-scarab-blue size",
"passed": true, "passed": true,
"detail": "879492" "detail": "879476"
},
{
"name": "actor mount-millenium-chimera-laby exists",
"passed": true,
"detail": "public/assets/creatures/grafu-castle/mount-millenium-chimera-laby.glb"
},
{
"name": "actor mount-millenium-chimera-laby checksum",
"passed": true,
"detail": "7cb4ae344d3a46094b96faebdc6274eace04eb894de64ee87ac7bb7a1309b321"
},
{
"name": "actor mount-millenium-chimera-laby size",
"passed": true,
"detail": "1217348"
},
{
"name": "actor enraged-big-bear-purple exists",
"passed": true,
"detail": "public/assets/creatures/grafu-castle/enraged-big-bear-purple.glb"
},
{
"name": "actor enraged-big-bear-purple checksum",
"passed": true,
"detail": "36738dc204f6d574f56ee41044d61ae88a885d0dc4b411f7ea70d76e84d48a4b"
},
{
"name": "actor enraged-big-bear-purple size",
"passed": true,
"detail": "935312"
},
{
"name": "actor enraged-big-bear-cream exists",
"passed": true,
"detail": "public/assets/creatures/grafu-castle/enraged-big-bear-cream.glb"
},
{
"name": "actor enraged-big-bear-cream checksum",
"passed": true,
"detail": "1e327d246fdcebf9b35891f37ccc3aed3ff7bfb4f2c7355c1381d7e0231e956c"
},
{
"name": "actor enraged-big-bear-cream size",
"passed": true,
"detail": "941160"
},
{
"name": "actor rafael-bakderr exists",
"passed": true,
"detail": "public/assets/creatures/grafu-castle/rafael-bakderr.glb"
},
{
"name": "actor rafael-bakderr checksum",
"passed": true,
"detail": "06e87d84a7cb3e15d610b0f18528cb1331c80db4597c67c49267aa4a627386dc"
},
{
"name": "actor rafael-bakderr size",
"passed": true,
"detail": "1705044"
}, },
{ {
"name": "Khronos validation", "name": "Khronos validation",
@@ -11,8 +11,120 @@
"dungeonConfigId": "grafu_castle" "dungeonConfigId": "grafu_castle"
}, },
"configuredBossTemplateIds": [], "configuredBossTemplateIds": [],
"reviewedBossTemplateIds": [
105204,
105224,
105351,
105542,
105543,
105616
],
"configuredBossPositions": {}, "configuredBossPositions": {},
"bossRows": [], "bossRows": [],
"scriptedBossSpawns": [
{
"scriptedPlacementId": "cayus",
"sourceAnchorSpawnId": 41927224,
"controllerSpawnId": 41928351,
"templateId": 105616,
"name": "Cayus",
"sourcePosition": [
-348.7113952636719,
1254.5040283203125,
-999.6005249023438
],
"sourceScriptRoot": "Dy_Z19_BOSSN_AIC",
"objective": true,
"evidence": [
"Resource/luascript/02345.lua:CreateObjByFlag(105616,780696,1,1)"
]
},
{
"scriptedPlacementId": "ardmond",
"sourceAnchorSpawnId": 41926939,
"controllerSpawnId": 41928345,
"templateId": 105542,
"name": "Ardmond",
"sourcePosition": [
-1427.9969482421875,
1133.35498046875,
-1353.3699951171875
],
"sourceScriptRoot": "kg_105542_01",
"objective": true,
"evidence": [
"Resource/luascript/02324.lua:kg_105542_CreateBoss"
]
},
{
"scriptedPlacementId": "issac",
"sourceAnchorSpawnId": 41926939,
"controllerSpawnId": 41928345,
"templateId": 105543,
"name": "Issac",
"sourcePosition": [
-1427.9969482421875,
1133.35498046875,
-1313.3699951171875
],
"sourceScriptRoot": "kg_105542_01",
"objective": false,
"evidence": [
"Resource/luascript/02324.lua:kg_105542_CreateBoss"
]
},
{
"scriptedPlacementId": "boro-boro-brothers",
"sourceAnchorSpawnId": 41926924,
"controllerSpawnId": 41927347,
"templateId": 105224,
"name": "Boro-Boro Brothers",
"sourcePosition": [
-2172.174072265625,
1095.10400390625,
-22.212709426879883
],
"sourceScriptRoot": "ic_105224_01",
"objective": true,
"evidence": [
"Resource/luascript/02231.lua:CreateObjByFlag(105224,780775,1,1)"
]
},
{
"scriptedPlacementId": "conjurer-chapeaunoir",
"sourceAnchorSpawnId": 41926979,
"controllerSpawnId": 41927358,
"templateId": 105204,
"name": "Conjurer Chapeaunoir",
"sourcePosition": [
-1732.9649658203125,
914.618896484375,
1190.8310546875
],
"sourceScriptRoot": "cl_136_105205_N",
"objective": true,
"evidence": [
"Resource/luascript/02330.lua:CreateObjByFlag(105204,780767,0,1)"
]
},
{
"scriptedPlacementId": "larvanger-barkud",
"sourceAnchorSpawnId": 41927071,
"controllerSpawnId": 41927369,
"templateId": 105351,
"name": "Larvanger Barkud",
"sourcePosition": [
-305.8063049316406,
902.5551147460938,
941.4002075195312
],
"sourceScriptRoot": "un_zone19_boss5_allconreal_1_01",
"objective": true,
"evidence": [
"Resource/luascript/02297.lua:CreateObjByFlag(105351,780838,1,1)"
]
}
],
"luaSources": [], "luaSources": [],
"luaSignals": [], "luaSignals": [],
"templateCandidates": [ "templateCandidates": [
@@ -94,6 +206,18 @@
"templateScripts": [], "templateScripts": [],
"spells": [] "spells": []
}, },
{
"templateId": 105204,
"name": "Conjurer Chapeaunoir",
"classification": "boss",
"spawnCount": 0,
"autoPlots": [
"cl_136_105205_N"
],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{ {
"templateId": 105205, "templateId": 105205,
"name": "Template 105205", "name": "Template 105205",
@@ -109,7 +233,7 @@
{ {
"templateId": 105210, "templateId": 105210,
"name": "Template 105210", "name": "Template 105210",
"classification": "combat", "classification": "deferred-object",
"spawnCount": 1, "spawnCount": 1,
"autoPlots": [ "autoPlots": [
"kg_105542_01" "kg_105542_01"
@@ -204,10 +328,82 @@
"templateScripts": [], "templateScripts": [],
"spells": [] "spells": []
}, },
{
"templateId": 105224,
"name": "Boro-Boro Brothers",
"classification": "boss",
"spawnCount": 0,
"autoPlots": [
"ic_105224_01"
],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{
"templateId": 105229,
"name": "Smirking Castle Clown",
"classification": "combat",
"spawnCount": 0,
"autoPlots": [],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{
"templateId": 105230,
"name": "Odd Castle Clown",
"classification": "combat",
"spawnCount": 0,
"autoPlots": [],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{
"templateId": 105233,
"name": "Cursed Steel Soldier",
"classification": "combat",
"spawnCount": 0,
"autoPlots": [],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{
"templateId": 105235,
"name": "Conjurer's Apprentice",
"classification": "combat",
"spawnCount": 0,
"autoPlots": [],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{
"templateId": 105236,
"name": "Giant Enraged Bear",
"classification": "combat",
"spawnCount": 0,
"autoPlots": [],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{
"templateId": 105237,
"name": "Manipulated Bear",
"classification": "combat",
"spawnCount": 0,
"autoPlots": [],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{ {
"templateId": 105254, "templateId": 105254,
"name": "Template 105254", "name": "Template 105254",
"classification": "combat", "classification": "deferred-object",
"spawnCount": 10, "spawnCount": 10,
"autoPlots": [ "autoPlots": [
"cl_136_01_01_N", "cl_136_01_01_N",
@@ -225,7 +421,7 @@
{ {
"templateId": 105258, "templateId": 105258,
"name": "Template 105258", "name": "Template 105258",
"classification": "combat", "classification": "deferred-object",
"spawnCount": 5, "spawnCount": 5,
"autoPlots": [ "autoPlots": [
"cl_105258_1", "cl_105258_1",
@@ -265,6 +461,42 @@
"templateScripts": [], "templateScripts": [],
"spells": [] "spells": []
}, },
{
"templateId": 105351,
"name": "Larvanger Barkud",
"classification": "boss",
"spawnCount": 0,
"autoPlots": [
"un_zone19_boss5_allconreal_1_01"
],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{
"templateId": 105542,
"name": "Ardmond",
"classification": "boss",
"spawnCount": 0,
"autoPlots": [
"kg_105542_01"
],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{
"templateId": 105543,
"name": "Issac",
"classification": "boss",
"spawnCount": 0,
"autoPlots": [
"kg_105542_01"
],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{ {
"templateId": 105596, "templateId": 105596,
"name": "Smirking Castle Clown", "name": "Smirking Castle Clown",
@@ -332,6 +564,16 @@
} }
] ]
}, },
{
"templateId": 105599,
"name": "Cursed Steel Soldier",
"classification": "combat",
"spawnCount": 0,
"autoPlots": [],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{ {
"templateId": 105600, "templateId": 105600,
"name": "Juggler's Apprentice", "name": "Juggler's Apprentice",
@@ -420,6 +662,18 @@
} }
] ]
}, },
{
"templateId": 105616,
"name": "Cayus",
"classification": "boss",
"spawnCount": 0,
"autoPlots": [
"Dy_Z19_BOSSN_AIC"
],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{ {
"templateId": 116434, "templateId": 116434,
"name": "Skeleton of an Adventurer", "name": "Skeleton of an Adventurer",
File diff suppressed because it is too large. Load diff
+42 -13
View File
@@ -2,7 +2,6 @@ import { readFileSync } from "node:fs";
import { gzipSync } from "node:zlib"; import { gzipSync } from "node:zlib";
import { runInNewContext } from "node:vm"; import { runInNewContext } from "node:vm";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { ARENA_STAGES } from "../game/arenaStages";
import { BROWSER_CONTENT_CACHE_NAME } from "./contentManager"; import { BROWSER_CONTENT_CACHE_NAME } from "./contentManager";
const workerSource = readFileSync( const workerSource = readFileSync(
@@ -12,15 +11,22 @@ const workerSource = readFileSync(
function contentWorker(cached?: Response, bundledFiles: Record<string, unknown> = {}, object?: Response) { function contentWorker(cached?: Response, bundledFiles: Record<string, unknown> = {}, object?: Response) {
const handlers = new Map<string, (event: unknown) => void>(); const handlers = new Map<string, (event: unknown) => void>();
const bootstrapObjects = new Map<string, Response>();
const fetch = vi.fn(async (url: RequestInfo | URL) => String(url) === "/content/bootstrap-manifest.json" const fetch = vi.fn(async (url: RequestInfo | URL) => String(url) === "/content/bootstrap-manifest.json"
? Response.json({ files: bundledFiles }) : object?.clone() ?? new Response("bundled arena", { status: 200 })); ? Response.json({ files: bundledFiles }) : object?.clone() ?? new Response("bundled dungeon", { status: 200 }));
const match = vi.fn(async (url: RequestInfo | URL) => String(url).includes("/content/cache-objects/") ? object?.clone() : cached?.clone()); const match = vi.fn(async (url: RequestInfo | URL) => String(url).includes("/content/cache-objects/") ? object?.clone() : cached?.clone());
const bootstrapMatch = vi.fn(async (url: RequestInfo | URL) => bootstrapObjects.get(String(url))?.clone());
const bootstrapPut = vi.fn(async (url: RequestInfo | URL, response: Response) => {
bootstrapObjects.set(String(url), response.clone());
});
runInNewContext(workerSource, { runInNewContext(workerSource, {
self: { self: {
location: { origin: "https://healer.test" }, location: { origin: "https://healer.test" },
addEventListener: (type: string, handler: (event: unknown) => void) => handlers.set(type, handler), addEventListener: (type: string, handler: (event: unknown) => void) => handlers.set(type, handler),
}, },
caches: { open: async () => ({ match }) }, caches: { open: async (name: string) => name === "healer-man-bootstrap-v1"
? { match: bootstrapMatch, put: bootstrapPut }
: { match } },
fetch, fetch,
URL, URL,
Response, Response,
@@ -30,6 +36,8 @@ function contentWorker(cached?: Response, bundledFiles: Record<string, unknown>
return { return {
fetch, fetch,
match, match,
bootstrapMatch,
bootstrapPut,
refresh: () => handlers.get("message")!({ data: { type: "refresh-content-bootstrap" } }), refresh: () => handlers.get("message")!({ data: { type: "refresh-content-bootstrap" } }),
request: (path: string) => { request: (path: string) => {
let response: Promise<Response> | undefined; let response: Promise<Response> | undefined;
@@ -43,20 +51,28 @@ function contentWorker(cached?: Response, bundledFiles: Record<string, unknown>
} }
describe("browser content cache worker", () => { describe("browser content cache worker", () => {
it.each(ARENA_STAGES)("loads bundled $name assets without an optional content install", async (stage) => { it("loads starter-dungeon assets without an optional content install", async () => {
const worker = contentWorker(); const paths = [
for (const asset of stage.assets) { "/assets/game/dungeons/wailing-caverns/visual.glb",
const response = await worker.request(`${asset.url}?arena-runtime=1`); "/assets/creatures/wailing-caverns/raptor.glb",
];
const files = Object.fromEntries(paths.map((path, index) => [
path,
{ url: `/content/bundled/${String(index + 1).repeat(64)}.glb` },
]));
const worker = contentWorker(undefined, files);
for (const path of paths) {
const response = await worker.request(`${path}?starter-runtime=1`);
expect(response?.status).toBe(200); expect(response?.status).toBe(200);
expect(await response?.text()).toBe("bundled arena"); expect(await response?.text()).toBe("bundled dungeon");
} }
expect(worker.fetch).toHaveBeenCalledTimes(stage.assets.length + 1); expect(worker.fetch).toHaveBeenCalledTimes(paths.length + 1);
}); });
it("prefers installed arena updates over bundled files, ignoring the runtime query", async () => { it("prefers installed dungeon updates over bundled files, ignoring the runtime query", async () => {
const worker = contentWorker(new Response("updated arena")); const worker = contentWorker(new Response("updated dungeon"));
const response = await worker.request(`${ARENA_STAGES[0].assets[0].url}?arena-runtime=1`); const response = await worker.request("/assets/game/dungeons/wailing-caverns/visual.glb?starter-runtime=1");
expect(await response?.text()).toBe("updated arena"); expect(await response?.text()).toBe("updated dungeon");
expect(worker.match).toHaveBeenCalledWith(expect.any(Request), { ignoreSearch: true }); expect(worker.match).toHaveBeenCalledWith(expect.any(Request), { ignoreSearch: true });
expect(worker.fetch).not.toHaveBeenCalled(); expect(worker.fetch).not.toHaveBeenCalled();
}); });
@@ -72,6 +88,7 @@ describe("browser content cache worker", () => {
it("serves only cached same-origin asset requests from the active content cache", () => { it("serves only cached same-origin asset requests from the active content cache", () => {
expect(workerSource).toContain(`const CONTENT_CACHE_NAME = "${BROWSER_CONTENT_CACHE_NAME}";`); expect(workerSource).toContain(`const CONTENT_CACHE_NAME = "${BROWSER_CONTENT_CACHE_NAME}";`);
expect(workerSource).toContain('const BOOTSTRAP_CACHE_NAME = "healer-man-bootstrap-v1";');
expect(workerSource).toContain('request.method !== "GET"'); expect(workerSource).toContain('request.method !== "GET"');
expect(workerSource).toContain("url.origin !== self.location.origin"); expect(workerSource).toContain("url.origin !== self.location.origin");
expect(workerSource).toContain("MANAGED_ASSET_PREFIXES.some"); expect(workerSource).toContain("MANAGED_ASSET_PREFIXES.some");
@@ -79,6 +96,7 @@ describe("browser content cache worker", () => {
expect(workerSource).toContain("if (!objectPath) return cached"); expect(workerSource).toContain("if (!objectPath) return cached");
expect(workerSource).toContain("BUNDLED_ASSET_PATHS.has(url.pathname)"); expect(workerSource).toContain("BUNDLED_ASSET_PATHS.has(url.pathname)");
expect(workerSource).toContain("BUNDLED_ASSET_PREFIXES.some"); expect(workerSource).toContain("BUNDLED_ASSET_PREFIXES.some");
expect(workerSource).not.toContain('"/assets/game/arenas/",');
expect(workerSource).not.toContain('"/assets/characters/wow335a-v1/textures/character/human/",'); expect(workerSource).not.toContain('"/assets/characters/wow335a-v1/textures/character/human/",');
expect(workerSource).toContain('status: 404'); expect(workerSource).toContain('status: 404');
expect(workerSource).not.toContain("cached ?? fetch(request)"); expect(workerSource).not.toContain("cached ?? fetch(request)");
@@ -93,6 +111,17 @@ describe("browser content cache worker", () => {
expect(response?.headers.get("content-type")).toBe("model/gltf-binary"); expect(response?.headers.get("content-type")).toBe("model/gltf-binary");
}); });
it("persists immutable bootstrap objects after their first use", async () => {
const url = "/assets/game/dungeons/wailing-caverns/visual.glb";
const objectUrl = `/content/bundled/${"b".repeat(64)}.glb`;
const worker = contentWorker(undefined, { [url]: { url: objectUrl } }, new Response("persistent dungeon"));
expect(await (await worker.request(url))?.text()).toBe("persistent dungeon");
expect(await (await worker.request(url))?.text()).toBe("persistent dungeon");
expect(worker.fetch).toHaveBeenCalledTimes(2);
expect(worker.bootstrapPut).toHaveBeenCalledTimes(1);
expect(worker.bootstrapMatch).toHaveBeenCalledTimes(2);
});
it("resolves a tiny installed alias to one compressed shared object", async () => { it("resolves a tiny installed alias to one compressed shared object", async () => {
const original = "glTF installed original".repeat(100); const original = "glTF installed original".repeat(100);
const worker = contentWorker(new Response(null, { headers: { "x-healer-man-content-object": "/content/cache-objects/shared.glb" } }), {}, const worker = contentWorker(new Response(null, { headers: { "x-healer-man-content-object": "/content/cache-objects/shared.glb" } }), {},
+4 -21
View File
@@ -8,25 +8,11 @@ import {
type ContentManifestFile, type ContentManifestFile,
} from "./contentManifest"; } from "./contentManifest";
import { NativeContentStorage } from "./nativeContentStorage"; import { NativeContentStorage } from "./nativeContentStorage";
import contentPolicy from "../../shared/content-policy.json";
export const CONTENT_STATE_KEY = "healer-man.content-state.v2"; export const CONTENT_STATE_KEY = "healer-man.content-state.v2";
const PRODUCTION_ORIGIN = "https://iwanttoheal.phenomrom.com"; const PRODUCTION_ORIGIN = "https://iwanttoheal.phenomrom.com";
const STATIC_ANDROID_BUNDLED_PACKS = new Set([ const STATIC_BUNDLED_PACKS = new Set(contentPolicy.bootstrapPackIds);
"core:ui",
"core:equipment",
"core:character-basic",
"core:runtime",
"core:arenas",
"dungeon:wailing-caverns",
]);
const STATIC_BROWSER_BUNDLED_PACKS = new Set([
"core:ui",
"core:equipment",
"core:character-basic",
"core:runtime",
"core:arenas",
"dungeon:wailing-caverns",
]);
export const BROWSER_CONTENT_CACHE_NAME = "healer-man-content-v1"; export const BROWSER_CONTENT_CACHE_NAME = "healer-man-content-v1";
export const BROWSER_CONTENT_WORKER_URL = "/content-cache-sw.js"; export const BROWSER_CONTENT_WORKER_URL = "/content-cache-sw.js";
@@ -292,9 +278,7 @@ function packReceiptForManifest(manifest: ContentManifest, packId: string): Inst
function bundledFiles(): Readonly<Record<string, { readonly sha256: string }>> { function bundledFiles(): Readonly<Record<string, { readonly sha256: string }>> {
if (snapshot.bundledManifest) return snapshot.bundledManifest.files; if (snapshot.bundledManifest) return snapshot.bundledManifest.files;
if (!snapshot.manifest) return {}; if (!snapshot.manifest) return {};
const bundledPacks = Capacitor.isNativePlatform() const bundledPacks = STATIC_BUNDLED_PACKS;
? STATIC_ANDROID_BUNDLED_PACKS
: STATIC_BROWSER_BUNDLED_PACKS;
return Object.fromEntries(Object.entries(snapshot.manifest.files).filter(([, entry]) => ( return Object.fromEntries(Object.entries(snapshot.manifest.files).filter(([, entry]) => (
bundledPacks.has(entry.packId) bundledPacks.has(entry.packId)
))); )));
@@ -324,8 +308,7 @@ export function resolveContentUrl(logicalUrl: string): string {
} }
export function contentPackIsBundled(packId: string): boolean { export function contentPackIsBundled(packId: string): boolean {
const defaults = Capacitor.isNativePlatform() ? STATIC_ANDROID_BUNDLED_PACKS : STATIC_BROWSER_BUNDLED_PACKS; return STATIC_BUNDLED_PACKS.has(packId)
return defaults.has(packId)
|| Boolean(snapshot.bundledManifest?.packs.some((pack) => pack.id === packId && pack.bundledOnAndroid)); || Boolean(snapshot.bundledManifest?.packs.some((pack) => pack.id === packId && pack.bundledOnAndroid));
} }
@@ -0,0 +1,5 @@
import type { WorldSpawn } from "./dungeonTypes";
export function shelvedDungeonSpawn(_dungeonId: string, requested?: WorldSpawn): WorldSpawn | undefined {
return requested;
}
@@ -0,0 +1,6 @@
import type { WorldSpawn } from "./dungeonTypes";
import { useManastormAdminStore } from "./manastormAdminStore";
export function shelvedDungeonSpawn(dungeonId: string, requested?: WorldSpawn): WorldSpawn | undefined {
return requested ?? useManastormAdminStore.getState().config.dungeonSpawns[dungeonId];
}
+1 -1
View File
@@ -1060,7 +1060,7 @@ export function resourceProfileForClass(classId: ClassId): ClassResourceProfile
return profile; return profile;
} }
if (import.meta.env.MODE === "test") { if (import.meta.env.MODE === "test" || import.meta.env.MODE === "shelved") {
await preloadAbilityCatalogsForClasses([ await preloadAbilityCatalogsForClasses([
...BASE_CLASS_IDS, ...BASE_CLASS_IDS,
...COA_CLASS_IDS, ...COA_CLASS_IDS,
+2 -2
View File
@@ -8,8 +8,8 @@ import { usePartyStore } from "./partyStore";
import { useGameStore, type GameMode } from "./store"; import { useGameStore, type GameMode } from "./store";
import { dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry"; import { dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
import type { WorldSpawn } from "./dungeonTypes"; import type { WorldSpawn } from "./dungeonTypes";
import { useManastormAdminStore } from "./manastormAdminStore";
import type { GmReviewTarget } from "./gmContentReview"; import type { GmReviewTarget } from "./gmContentReview";
import { shelvedDungeonSpawn } from "@shelvedDungeonSession";
import { import {
activateDungeonEncounterDefinitions, activateDungeonEncounterDefinitions,
exportDungeonEncounterSnapshots, exportDungeonEncounterSnapshots,
@@ -92,7 +92,7 @@ export function activateDungeonSession(
} }
useGameStore.getState().activateDungeon(dungeonId as DungeonId, difficultyId, { useGameStore.getState().activateDungeon(dungeonId as DungeonId, difficultyId, {
gameMode: options.gameMode, gameMode: options.gameMode,
spawn: options.spawn ?? useManastormAdminStore.getState().config.dungeonSpawns[dungeonId], spawn: shelvedDungeonSpawn(dungeonId, options.spawn),
gmTestMode: options.gmTestMode, gmTestMode: options.gmTestMode,
gmReviewTarget: options.gmReviewTarget, gmReviewTarget: options.gmReviewTarget,
}); });
+6 -1
View File
@@ -7,6 +7,7 @@ import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
import { DEFAULT_MANASTORM_ADMIN_CONFIG } from "./manastormAdminConfig"; import { DEFAULT_MANASTORM_ADMIN_CONFIG } from "./manastormAdminConfig";
import { useManastormAdminStore } from "./manastormAdminStore"; import { useManastormAdminStore } from "./manastormAdminStore";
import { SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
import { import {
dungeonEncounterSnapshot, dungeonEncounterSnapshot,
importDungeonEncounterSnapshots, importDungeonEncounterSnapshots,
@@ -79,7 +80,11 @@ describe("dungeon session asset eviction", () => {
config: { ...DEFAULT_MANASTORM_ADMIN_CONFIG, dungeonSpawns: { "wailing-caverns": spawn } }, config: { ...DEFAULT_MANASTORM_ADMIN_CONFIG, dungeonSpawns: { "wailing-caverns": spawn } },
}); });
expect(activateDungeonSession("wailing-caverns")).toBe(true); expect(activateDungeonSession("wailing-caverns")).toBe(true);
expect(useGameStore.getState().activeSpawn).toEqual(spawn); const definition = dungeonDefinitionById("wailing-caverns")!;
expect(useGameStore.getState().activeSpawn).toEqual(SHELVED_MODES_ENABLED ? spawn : {
footPosition: definition.entrance.footPosition,
yaw: definition.entrance.yaw,
});
}); });
it("hydrates a same-process checkpoint even when the reset revision is newer", () => { it("hydrates a same-process checkpoint even when the reset revision is newer", () => {
+52
View File
@@ -0,0 +1,52 @@
import { BufferGeometry, Float32BufferAttribute } from "three";
import { describe, expect, it } from "vitest";
import type { DungeonTraversalRamp } from "./dungeonTypes";
import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
const RAMP: DungeonTraversalRamp = {
id: "stairs",
start: [0, 0, 0],
end: [0, 2, 6],
width: 4,
navigationBridge: {
linkId: "stair-seam",
startPortal: [[-1, 0.5, 2], [1, 0.5, 2]],
endPortal: [[-1, 1, 3], [1, 1, 3]],
},
};
describe("reviewed dungeon stair proxies", () => {
it("smooths tread and riser vertices but preserves upper walls, ceilings and geometry outside the flight", () => {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute([
0, 0.5, 2,
0, 1, 2,
0, 3, 2,
0, 5, 2,
3, 0.5, 2,
0, 0.5, -1,
], 3));
smoothDungeonStairGeometry(geometry, [RAMP]);
const position = geometry.getAttribute("position");
expect(position.getY(0)).toBeCloseTo(2 / 3);
expect(position.getY(1)).toBeCloseTo(2 / 3);
expect([2, 3, 4, 5].map((vertex) => position.getY(vertex))).toEqual([3, 5, 0.5, 0.5]);
geometry.dispose();
});
it("bridges exactly the existing reviewed portal vertices and rejects stale asset coordinates", () => {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute([
...RAMP.navigationBridge.startPortal.flat(),
...RAMP.navigationBridge.endPortal.flat(),
], 3));
geometry.setIndex([]);
smoothDungeonStairGeometry(geometry, [RAMP], 0.05);
bridgeDungeonStairNavigation(geometry, [RAMP], 0.05);
expect(Array.from(geometry.index!.array)).toEqual([0, 1, 3, 0, 3, 2]);
geometry.getAttribute("position").setX(0, -1.1);
expect(() => bridgeDungeonStairNavigation(geometry, [RAMP], 0.05))
.toThrow("reviewed navigation portal no longer matches the source GLB");
geometry.dispose();
});
});
+77
View File
@@ -0,0 +1,77 @@
import type { BufferGeometry } from "three";
import type { DungeonTraversalRamp, Vector3Tuple } from "./dungeonTypes";
// Only the stair relief is reshaped. Upper walls, ceilings and other floors
// remain untouched even where they share the staircase's horizontal footprint.
const MAX_STAIR_SURFACE_DELTA = 0.85;
const PORTAL_VERTEX_TOLERANCE = 0.002;
function rampHeight(
ramp: DungeonTraversalRamp,
x: number,
z: number,
): number | null {
const dx = ramp.end[0] - ramp.start[0];
const dz = ramp.end[2] - ramp.start[2];
const length = Math.hypot(dx, dz);
if (
![...ramp.start, ...ramp.end, ramp.width].every(Number.isFinite)
|| length <= 0
|| ramp.width <= 0
) {
throw new Error(`Invalid dungeon traversal ramp: ${ramp.id}.`);
}
const offsetX = x - ramp.start[0];
const offsetZ = z - ramp.start[2];
const along = (offsetX * dx + offsetZ * dz) / length;
const across = (offsetX * dz - offsetZ * dx) / length;
if (along < 0 || along > length || Math.abs(across) > ramp.width * 0.5) return null;
return ramp.start[1] + (ramp.end[1] - ramp.start[1]) * along / length;
}
/** Flatten tread/riser relief onto a shared plane, without removing walls. */
export function smoothDungeonStairGeometry(
geometry: BufferGeometry,
ramps: readonly DungeonTraversalRamp[],
surfaceClearance = 0,
): void {
const position = geometry.getAttribute("position");
for (let vertex = 0; vertex < position.count; vertex += 1) {
for (const ramp of ramps) {
const height = rampHeight(ramp, position.getX(vertex), position.getZ(vertex));
if (height === null || Math.abs(position.getY(vertex) - height) > MAX_STAIR_SURFACE_DELTA) continue;
position.setY(vertex, height + surfaceClearance);
break;
}
}
}
/** Stitch reviewed boundary edges, so routing can use filled triangles rather than vertex-only links. */
export function bridgeDungeonStairNavigation(
geometry: BufferGeometry,
ramps: readonly DungeonTraversalRamp[],
surfaceClearance: number,
): void {
const position = geometry.getAttribute("position");
const indices = geometry.index
? Array.from(geometry.index.array)
: Array.from({ length: position.count }, (_, vertex) => vertex);
const portalVertex = (ramp: DungeonTraversalRamp, point: Vector3Tuple): number => {
const height = rampHeight(ramp, point[0], point[2]);
if (height === null) throw new Error(`${ramp.id}: navigation portal is outside the stair ramp.`);
for (let vertex = 0; vertex < position.count; vertex += 1) {
if (Math.hypot(
position.getX(vertex) - point[0],
position.getY(vertex) - height - surfaceClearance,
position.getZ(vertex) - point[2],
) <= PORTAL_VERTEX_TOLERANCE) return vertex;
}
throw new Error(`${ramp.id}: reviewed navigation portal no longer matches the source GLB.`);
};
for (const ramp of ramps) {
const [a, b] = ramp.navigationBridge.startPortal.map((point) => portalVertex(ramp, point));
const [c, d] = ramp.navigationBridge.endPortal.map((point) => portalVertex(ramp, point));
indices.push(a, b, d, a, d, c);
}
geometry.setIndex(indices);
}
+16
View File
@@ -156,6 +156,19 @@ export interface DungeonNavigationLink {
readonly authoredReason: string; readonly authoredReason: string;
} }
/** Reviewed package-space walking proxy; the rendered staircase is unchanged. */
export interface DungeonTraversalRamp {
readonly id: string;
readonly start: Vector3Tuple;
readonly end: Vector3Tuple;
readonly width: number;
readonly navigationBridge: {
readonly linkId: string;
readonly startPortal: readonly [Vector3Tuple, Vector3Tuple];
readonly endPortal: readonly [Vector3Tuple, Vector3Tuple];
};
}
export interface DungeonEncounterSpawnGroupDefinition { export interface DungeonEncounterSpawnGroupDefinition {
readonly id: string; readonly id: string;
readonly spawns: readonly StaticMobSpawnDefinition[]; readonly spawns: readonly StaticMobSpawnDefinition[];
@@ -209,6 +222,8 @@ export interface DungeonSourceProvenance {
export interface DungeonValidationStatus { export interface DungeonValidationStatus {
readonly status: "green" | "blocked" | "unvalidated"; readonly status: "green" | "blocked" | "unvalidated";
/** Navigation-only review state; permits routing without overstating encounter validation. */
readonly navigation?: "green" | "blocked" | "unvalidated";
readonly report?: string; readonly report?: string;
readonly validatedAt?: string; readonly validatedAt?: string;
readonly blockers: readonly string[]; readonly blockers: readonly string[];
@@ -239,6 +254,7 @@ export interface DungeonDefinition {
readonly bosses: readonly DungeonBossObjective[]; readonly bosses: readonly DungeonBossObjective[];
readonly objectiveOrder: readonly DungeonBossObjective[]; readonly objectiveOrder: readonly DungeonBossObjective[];
readonly navigationLinks: readonly DungeonNavigationLink[]; readonly navigationLinks: readonly DungeonNavigationLink[];
readonly traversalRamps?: readonly DungeonTraversalRamp[];
/** Serializable dungeon-wide orchestration kept separate from render state. */ /** Serializable dungeon-wide orchestration kept separate from render state. */
readonly encounters?: readonly DungeonEncounterDefinition[]; readonly encounters?: readonly DungeonEncounterDefinition[];
/** Declarative groups activated by encounter actions; renderer ownership stays outside the reducer. */ /** Declarative groups activated by encounter actions; renderer ownership stays outside the reducer. */
@@ -1,2 +1,2 @@
// Generated by npm run dungeon:catalog. Do not edit. // Generated by npm run dungeon:catalog. Do not edit.
export default "H4sIAAAAAAACCuy9a5PjNpY2+FcY+e7GzkRIKODgXhP7wa5y237b1fa6PO3d6HBMQCSUySlKzKGUWa7uqP++cQBSgu5USk63s9kfqp2ESNzOec4VB/+4WeR3fub+6ptFWc9vXrPRzaJ+aHJ/8/ofN7mb3bvydv7OzcupXyxvXt8UD/NbX8/H9+W9r8q5f9U96H5L/ntRz29GN37+WDb1fObnyx9c/sHd+sXN67/dLJr81a2b+Ve3fu4bt/TFq2n56H+o3CffvI3f+nZ2XzfLRfelPa/M3NwtlnUz+2Kx8Kvvhxeyf1t6fN81n7Kpq6qJyz/8+80vOKC8fpgvffPGLV1V3968PvrlHx/my3Lm2x93Y3F/9029vHtTN/5NPZuVuCSisAUrxERq4N4wm8ucWzpVuWNMazC8mEwnxvPN19+HVf6uzj/sH8jen66W5N5X1Woe/7hxi9zP4wbeEPLqO1eUHxbvfvh/virKZd28yqvSz5fj/KFp/Hz5yjXejSW8unfL/G789tXbL/9UVn7xJvzq1Xv8NikmOW7ifZ3fxW9+413lm3duPn6/rBt36191JDD+WDcfOjpYvArvjGOXBz/9+fPoJq8fPX7o5vXf/tHR1bcFrsadK+qPUze/HX/w/v5mdDNz99jCebKNi5vXGv9clssSaYsj6S7dsszf37uP88XNa0bt6Kap3ayc3yKNLG5eg1jTd7cbed34m8+jrTEgDXws58V4sazzD67wyTDE5jBkOgy2MwzLt0dBew6i8K6YlXO/SPpWR5YAYGcJ9O4S8J69f3RlVc5vx7l79M08GYPY2gazMQa6PQaQsD0G3XMIjft73Uz9fPyhcQ9VMgJ9jBBMj1Vg0HMIk8rlH6ZueVfPxoX39+k6mM1RqI110DvrQNnOKPpSwkNVuJmbr/vW9KwV4BSevAK383rmG3+bdm+3uhfHOZHrnalz1pcTH+Yf/HyMqF4lTBiY+/AQ+M7qC7k9BHEuFRb1x5QPGNhjBCB3yNCKnU3oy4yL3DWVX45nNcon33xKhmHsETziO3TIkeq2hqF6DuPvD9XUNY37sO4e6LHugfWAAybOYcamzj+MF/dlk1ADbG8F2xiF3mEIqdQOMMLZoyj8/fIuoQjgW3zBeDoMtUdEid3NkL1p4q6u6pmb5+lKbFODOYEMCFRPHcCyQWCsZqlw3N4Jm/Yvd6SD4DvQgF/o1f/MNe6hqOepaD46+x1UkLCDCkB7w8Ktn5aNH+d3bjFLxmCOIdOufsB2d6AvPxTY/WxTNO5wAmxwwg4qabnDCbLvBizu3HLpG1+M71xVJYwgxRYjbPAB39VTrHkyNE6qui7G04dm7lJGkAKODIHtwCLj9slDuPNVFUihQSuoWW6sxJa2tLEdbGcljHyyoF4svZs9uodqmXYvz1uFPXDQV1t7mBe+mdS3ae/qCCvsUVcVe7K6uqjcox/f+/nG4usjs4c9rLgjm3jf1XdN7paN+3vSu4Rjs7ent74vH07qpZuXuUv75sekst1VkdmTqX/m8zs3d03auziL6ox4skoQjcVx5SafmnK+vEsHIc8jPvN0AFj45Z3/8GEHBqU6xvxqjzrwZIsVPRfjZT2bbPR/jP7DGm/2z+yTlQH3kN/5uSvHefPpfgMBpTm2COK0vcx17zW4LVE7XoyXvmk2xYE5CoS7NqPYAUJ1jn44ntWNW6S6obJHBrBHHD1ZK/CL3N378bRBk/Whmd/VVeq+kOqYbN6FBm52N6TvUB6Wt64p/JYfR+pj8LCHMxk8XSi1I7gv53OXpxak1MfwoQ9I9V2F5Z0fz/2vDylTaHXMdtxBBuBPh0jsvs4fqs3+zbHJ99mCvoIxf6iCH6mejvcZDNIehelddxJ7sj8toHMcRz3fGII9ixK0frKWUjTuw/LONfV8iycUpWdJK2ufbMS7v/93XY3nvnmYpP2zY/i4Yzlp+lR6WO1CVd7eLefl/DYdBZxFDPvIsq9j6fZhXmx4MhQ95tjd7dzoJ/vzHssavTmIzGn/5sgW7Bhu9qls4O7mH9ydX47rqhh/KOe3RZ2Yr4od9efs6gycP1lnmNbNrQ8MWT+kepPicExl2CFGsaO8mr7Y2JSuwhEgSOZ3bnZfpv4EJemRxdjZEvnULbkvl2EZEBjS3o8RBN/lBmWebDyuuLLx08rny81lUEdVuN2BPBmil+X8g2+WPr8bL+7qj+hxXQ/DwBZGsXQYu5Spjo3ivqlzXzw0rhp3ccJd+mzqv/v5uPEuv0ud3+aZh1E3t/Vy6edjjAYlnl9tfusF+WV0U/hpOS+RIBYhVLcbMS73R+2W5bLCD79ftWR/3onnVdPbLuxb4PcNhmp/vXddODOv3GJR5psR5Xd1EQipWI7vPk2aEkG0KKfTMn+olp/+6prSzZdxtGFs87qZuWrjR+nTBa7PO7f4EGZT+Udf/ejmITLJzIjTX9plQNfK29UXvt34snt0ZeUmZVWGb7d/Bn3zvvELP8edwBn946aq8/a/9yzN8s7PcG7f3/s5RlarIvO/3vuibDly8TCbuQa7+BOKz2x519QPt3fZ1pdGWeGn3i2zcrnIVhSSNTWaR6PMzYsMdylb3vkMPVafMleVj57cjG6q2hXl/PadXyxCdPbmh8bfu6ac3253Qgj+/mGe37lm6YsvGu/+4sLo/7N7lu3OEAnrtqkf5rh+/4saWlDc3mkMZed1VTehwTFg9GZ0M/euiYHPKf4HKPp5dONmEwwpf4drsPGaMxNhUa8p50uMieNuUALweXRz52fl4v7ON3712uLDpzfdm0bb3KALJQ5u9ZxaOqF2+4tKfB4F/2uASlftjqSAXNti8z02uqmn04VfImnBCOhorH/5jAyx9CiJFuELD8v6YflDcK0Gnrv5ry/Z/4E5DLEF1xgf/zK6mVR+Xmz+EsIvQ0PyQ1cgBT36zd+K8NuubfXzMKB7HMrHsljeoZOe4vLFKXLUVO/xpfltVAjyer6sH5rw0XcgMqAye2NUxpjJmIAMhM6Amoxplr3nXGSaZYKqjIG6Gd284yYDbrM3jNqMGZ4xDRkok4Gg4bX3XIjMqoxbnWl98wsmDzhM/Air9VguHly15vX279HNtKx8S47xGbmtkDAemurm9c2r+IWYdrHK+njF+XgNZK823svvfP5h8TC7eX3Dc+GhsN4aj8kdzGGWh+fMc8nAGkWld0ZMCsM5ALM5dVRz55gwEzt1WiMfl3/3qClZo4USo6CIzG8r/wY59ea1sEAZRdzJ66oqIxh2U1w9GlNKN2e60XT+hPe9nsyb2mmucko9OGanU2FyVkxEMTHWT7ylTHI6KaxhGqbc6okSntlCc0clnRZa0WTeTFi1M2slJVco/XamyQ5Pk102TbY7TaWY0Uw6x11hnIKJ1UZSy5jTiikrpaKaG8oL4AYknzJGJznkwKYUitzKdJoKvS29pwmHpwmXTRN2p6klkyCm0kyYVs6zyUROClCF01IXPNe0AC35pNBGm9xKSZ2YOOnkNPecSjdJpgkUzBnT5IenyS+bJt+d5oQzp8QUBMicFrmUU80YCADgmtqpLQrpvLOSTqmSTkjtncmnjOqJnfrc+Y3dNOdMUxyeprhsmmJ3mmzCxNRZMQGTazE1lHqcCgVhhJlKJ7QRnGrqppO8mFg7meTeTycqtyafAJebvAlnTFMenqa8bJpyD9Eya8XEGS3ddKJzAANTyBVzEykMp5ZR6cE4J21hJsXEw2TKinwi1dS5nHmbTFNYdc5uqsPTVJdNU+1OE6ZTNoVcQ8GpLpRiVMmJd3o6pTlIjoNnjHmgXljFJoJNOIepoRrERFkmkmlKI86Zpj48TX3ZNPXuNIuJdJpScLnjuXWOmSnT1CvquZ0q4LkwTPpcWa8UCC+nArTPJ7YoOLd2YpJpKkHFGdM0h6dpLpum2SNQCjcpFOeCFgqUmSjlQDvnvAZKpZxMPZtA4b1yVjLwAnJq1BSmfGqUsCadJqfyHN60h6dpL5um3Z2mkdRQD1Om81wZ1IGcN/m04MZ5Psl9PtFScs+E1VL5KfWSCmONs1pw7aapQJESzpCb7LAWxC7TgtgeLchSmHBTeBB8Mply76a+8MKaYjIFPZ1QxVkhmKXFlOspsJxSWgg2RdUA2ESvp8mtNHSXN7Xi1u6d5mEtiF2mBbF9WhCYnJuJNCJXeWEceDBgmbXSeKMFc3SqCjmZOm+8EkJTnivMfi6o916kAsUado4WxA5rQewyLYjt0YJkrqy2aCzbiVK51tQwP9FeKw/TiWAKvPBMCeqBTimnwMFDLiaOWqF0IlCAcb4PaZU2Yu80D2tB7DItiO3RgizLvc5zqwstQckJ0IJZxaVSkrHCUmONBMscz3M3LYwXWio6zWmuuKdTnkCQ4EywXd0dkWv/dh5Wg9hlahDbowYp1GcnE4EKAlfFdAJcKZUXzhk+NZQZZZhT1nCwhcwdeGUFV26qYEqN1W69naD5HuZUmhu5d5qH1SB2mRrE9qhBdMINTDjaIwqM8xOaC6dyyp0UzDifg+dawMQXBUwMeJpP7JS7Qk/clE51qgYxq8+QKOywGsQuU4PYHjXIeJ/Twno6pdJapUHCxHCRWyWnHNUG6SdsQqcTZSbe5uDAyqgWCmkZX0+TK672TFMLS/cz52E1iF2mBrE9apA0uTLGS27YxMkcvMGJUiNdoXTuhQFRWA6QT50UQtFcSea9FULAhAkNa6JVRuyDWmNg/zQPq0HsMjWI7VGDtCmMUc4WxjIJmuYTwYQXlLuJ5LlxggHN7WSqp2BzkEI5qyR4KBSXOZvy1TQlpVbsantWM9gvOA+rQewyNYjtUYN4URjGclEowS0KzYk2fJLnclJoy6gFwRnlTuemKIxDP8qkkK6YFpBTPbVr7xA3iu/ZTa0o5/umCYfVILhMDYI9apCYUFc4aSUFLlC6cKWdlnzqcllI6Yw2zoIvKJdWTY2aiIILyQo9nUzB0MRE0caKM/QDOKwGwWVLine truncated export default "H4sIAAAAAAACCuy9a5PjNpY2+FcY+e7GzkRIKODgXhP7wa5y237b1fa6PO3d6HBMQCSUySlKzKGUWa7uqP++cQBSgu5USk63s9kfqp2ESNzOec4VB/+4WeR3fub+6ptFWc9vXrPRzaJ+aHJ/8/ofN7mb3bvydv7OzcupXyxvXt8UD/NbX8/H9+W9r8q5f9U96H5L/ntRz29GN37+WDb1fObnyx9c/sHd+sXN67/dLJr81a2b+Ve3fu4bt/TFq2n56H+o3CffvI3f+nZ2XzfLRfelPa/M3NwtlnUz+2Kx8Kvvhxeyf1t6fN81n7Kpq6qJyz/8+80vOKC8fpgvffPGLV1V3968PvrlHx/my3Lm2x93Y3F/9029vHtTN/5NPZuVuCSisAUrxERq4N4wm8ucWzpVuWNMazC8mEwnxvPN19+HVf6uzj/sH8jen66W5N5X1Woe/7hxi9zP4wbeEPLqO1eUHxbvfvh/virKZd28yqvSz5fj/KFp/Hz5yjXejSW8unfL/G789tXbL/9UVn7xJvzq1Xv8NikmOW7ifZ3fxW9+413lm3duPn6/rBt36191JDD+WDcfOjpYvArvjGOXBz/9+fPoJq8fPX7o5vXf/tHR1bcFrsadK+qPUze/HX/w/v5mdDNz99jCebKNi5vXGv9clssSaYsj6S7dsszf37uP88XNa0bt6Kap3ayc3yKNLG5eg1jTd7cbed34m8+jrTEgDXws58V4sazzD67wyTDE5jBkOgy2MwzLt0dBew6i8K6YlXO/SPpWR5YAYGcJ9O4S8J69f3RlVc5vx7l79M08GYPY2gazMQa6PQaQsD0G3XMIjft73Uz9fPyhcQ9VMgJ9jBBMj1Vg0HMIk8rlH6ZueVfPxoX39+k6mM1RqI110DvrQNnOKPpSwkNVuJmbr/vW9KwV4BSevAK383rmG3+bdm+3uhfHOZHrnalz1pcTH+Yf/HyMqF4lTBiY+/AQ+M7qC7k9BHEuFRb1x5QPGNhjBCB3yNCKnU3oy4yL3DWVX45nNcon33xKhmHsETziO3TIkeq2hqF6DuPvD9XUNY37sO4e6LHugfWAAybOYcamzj+MF/dlk1ADbG8F2xiF3mEIqdQOMMLZoyj8/fIuoQjgW3zBeDoMtUdEid3NkL1p4q6u6pmb5+lKbFODOYEMCFRPHcCyQWCsZqlw3N4Jm/Yvd6SD4DvQgF/o1f/MNe6hqOepaD46+x1UkLCDCkB7w8Ktn5aNH+d3bjFLxmCOIdOufsB2d6AvPxTY/WxTNO5wAmxwwg4qabnDCbLvBizu3HLpG1+M71xVJYwgxRYjbPAB39VTrHkyNE6qui7G04dm7lJGkAKODIHtwCLj9slDuPNVFUihQSuoWW6sxJa2tLEdbGcljHyyoF4svZs9uodqmXYvz1uFPXDQV1t7mBe+mdS3ae/qCCvsUVcVe7K6uqjcox/f+/nG4usjs4c9rLgjm3jf1XdN7paN+3vSu4Rjs7ent74vH07qpZuXuUv75sekst1VkdmTqX/m8zs3d03auziL6ox4skoQjcVx5SafmnK+vEsHIc8jPvN0AFj45Z3/8GEHBqU6xvxqjzrwZIsVPRfjZT2bbPR/jP7DGm/2z+yTlQH3kN/5uSvHefPpfgMBpTm2COK0vcx17zW4LVE7XoyXvmk2xYE5CoS7NqPYAUJ1jn44ntWNW6S6obJHBrBHHD1ZK/CL3N378bRBk/Whmd/VVeq+kOqYbN6FBm52N6TvUB6Wt64p/JYfR+pj8LCHMxk8XSi1I7gv53OXpxak1MfwoQ9I9V2F5Z0fz/2vDylTaHXMdtxBBuBPh0jsvs4fqs3+zbHJ99mCvoIxf6iCH6mejvcZDNIehelddxJ7sj8toHMcRz3fGII9ixK0frKWUjTuw/LONfV8iycUpWdJK2ufbMS7v/93XY3nvnmYpP2zY/i4Yzlp+lR6WO1CVd7eLefl/DYdBZxFDPvIsq9j6fZhXmx4MhQ95tjd7dzoJ/vzHssavTmIzGn/5sgW7Bhu9qls4O7mH9ydX47rqhh/KOe3RZ2Yr4od9efs6gycP1lnmNbNrQ8MWT+kepPicExl2CFGsaO8mr7Y2JSuwhEgSOZ3bnZfpv4EJemRxdjZEvnULbkvl2EZEBjS3o8RBN/lBmWebDyuuLLx08rny81lUEdVuN2BPBmil+X8g2+WPr8bL+7qj+hxXQ/DwBZGsXQYu5Spjo3ivqlzXzw0rhp3ccJd+mzqv/v5uPEuv0ud3+aZh1E3t/Vy6edjjAYlnl9tfusF+WV0U/hpOS+RIBYhVLcbMS73R+2W5bLCD79ftWR/3onnVdPbLuxb4PcNhmp/vXddODOv3GJR5psR5Xd1EQipWI7vPk2aEkG0KKfTMn+olp/+6prSzZdxtGFs87qZuWrjR+nTBa7PO7f4EGZT+Udf/ejmITLJzIjTX9plQNfK29UXvt34snt0ZeUmZVWGb7d/Bn3zvvELP8edwBn946aq8/a/9yzN8s7PcG7f3/s5RlarIvO/3vuibDly8TCbuQa7+BOKz2x519QPt3fZ1pdGWeGn3i2zcrnIVhSSNTWaR6PMzYsMdylb3vkMPVafMleVj57cjG6q2hXl/PadXyxCdPbmh8bfu6ac3253Qgj+/mGe37lm6YsvGu/+4sLo/7N7lu3OEAnrtqkf5rh+/4saWlDc3mkMZed1VTehwTFg9GZ0M/euiYHPKf4HKPp5dONmEwwpf4drsPGaMxNhUa8p50uMieNuUALweXRz52fl4v7ON3712uLDpzfdm0bb3KALJQ5u9ZxaOqF2+4tKfB4F/2uASlftjqSAXNti8z02uqmn04VfImnBCOhorH/5jAyx9CiJFuELD8v6YflDcK0Gnrv5ry/Z/4E5DLEF1xgf/zK6mVR+Xmz+EsIvQ0PyQ1cgBT36zd+K8NuubfXzMKB7HMrHsljeoZOe4vLFKXLUVO/xpfltVAjyer6sH5rw0XcgMqAye2NUxpjJmIAMhM6Amoxplr3nXGSaZYKqjIG6Gd284yYDbrM3jNqMGZ4xDRkok4Gg4bX3XIjMqoxbnWl98wsmDzhM/Air9VguHly15vX279HNtKx8S47xGbmtkDAemurm9c2r+IWYdrHK+njF+XgNZK823svvfP5h8TC7eX3Dc+GhsN4aj8kdzGGWh+fMc8nAGkWld0ZMCsM5ALM5dVRz55gwEzt1WiMfl3/3qClZo4USo6CIzG8r/wY59ea1sEAZRdzJ66oqIxh2U1w9GlNKN2e60XT+hPe9nsyb2mmucko9OGanU2FyVkxEMTHWT7ylTHI6KaxhGqbc6okSntlCc0clnRZa0WTeTFi1M2slJVco/XamyQ5Pk102TbY7TaWY0Uw6x11hnIKJ1UZSy5jTiikrpaKaG8oL4AYknzJGJznkwKYUitzKdJoKvS29pwmHpwmXTRN2p6klkyCm0kyYVs6zyUROClCF01IXPNe0AC35pNBGm9xKSZ2YOOnkNPecSjdJpgkUzBnT5IenyS+bJt+d5oQzp8QUBMicFrmUU80YCADgmtqpLQrpvLOSTqmSTkjtncmnjOqJnfrc+Y3dNOdMUxyeprhsmmJ3mmzCxNRZMQGTazE1lHqcCgVhhJlKJ7QRnGrqppO8mFg7meTeTycqtyafAJebvAlnTFMenqa8bJpyD9Eya8XEGS3ddKJzAANTyBVzEykMp5ZR6cE4J21hJsXEw2TKinwi1dS5nHmbTFNYdc5uqsPTVJdNU+1OE6ZTNoVcQ8GpLpRiVMmJd3o6pTlIjoNnjHmgXljFJoJNOIepoRrERFkmkmlKI86Zpj48TX3ZNPXuNIuJdJpScLnjuXWOmSnT1CvquZ0q4LkwTPpcWa8UCC+nArTPJ7YoOLd2YpJpKkHFGdM0h6dpLpum2SNQCjcpFOeCFgqUmSjlQDvnvAZKpZxMPZtA4b1yVjLwAnJq1BSmfGqUsCadJqfyHN60h6dpL5um3Z2mkdRQD1Om81wZ1IGcN/m04MZ5Psl9PtFScs+E1VL5KfWSCmONs1pw7aapQJESzpCb7LAWxC7TgtgeLchSmHBTeBB8Mply76a+8MKaYjIFPZ1QxVkhmKXFlOspsJxSWgg2RdUA2ESvp8mtNHSXN7Xi1u6d5mEtiF2mBbF9WhCYnJuJNCJXeWEceDBgmbXSeKMFc3SqCjmZOm+8EkJTnivMfi6o916kAsUado4WxA5rQewyLYjt0YJkrqy2aCzbiVK51tQwP9FeKw/TiWAKvPBMCeqBTimnwMFDLiaOWqF0IlCAcb4PaZU2Yu80D2tB7DItiO3RgizLvc5zqwstQckJ0IJZxaVSkrHCUmONBMscz3M3LYwXWio6zWmuuKdTnkCQ4EywXd0dkWv/dh5Wg9hlahDbowYp1GcnE4EKAlfFdAJcKZUXzhk+NZQZZZhT1nCwhcwdeGUFV26qYEqN1W69naD5HuZUmhu5d5qH1SB2mRrE9qhBdMINTDjaIwqM8xOaC6dyyp0UzDifg+dawMQXBUwMeJpP7JS7Qk/clE51qgYxq8+QKOywGsQuU4PYHjXIeJ/Twno6pdJapUHCxHCRWyWnHNUG6SdsQqcTZSbe5uDAyqgWCmkZX0+TK672TFMLS/cz52E1iF2mBrE9apA0uTLGS27YxMkcvMGJUiNdoXTuhQFRWA6QT50UQtFcSea9FULAhAkNa6JVRuyDWmNg/zQPq0HsMjWI7VGDtCmMUc4WxjIJmuYTwYQXlLuJ5LlxggHN7WSqp2BzkEI5qyR4KBSXOZvy1TQlpVbsantWM9gvOA+rQewyNYjtUYN4URjGclEowS0KzYk2fJLnclJoy6gFwRnlTuemKIxDP8qkkK6YFpBTPbVr7xA3iu/ZTa0o5/umCYfVILhMDYI9apCYUFc4aSUFLlC6cKWdlnzqcllI6Yw2zoIvKJdWTY2aiIILyQo9nUzB0MRE0caKM/QDOKwGwWVLine truncated
@@ -1 +1 @@
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated {"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
@@ -15,6 +15,110 @@ export const GRAFU_CASTLE_MECHANICS_AUDIT = {
"loot": 0 "loot": 0
}, },
"bossRows": [], "bossRows": [],
"scriptedBossSpawns": [
{
"scriptedPlacementId": "cayus",
"sourceAnchorSpawnId": 41927224,
"controllerSpawnId": 41928351,
"templateId": 105616,
"name": "Cayus",
"sourcePosition": [
-348.7113952636719,
1254.5040283203125,
-999.6005249023438
],
"sourceScriptRoot": "Dy_Z19_BOSSN_AIC",
"objective": true,
"evidence": [
"Resource/luascript/02345.lua:CreateObjByFlag(105616,780696,1,1)"
]
},
{
"scriptedPlacementId": "ardmond",
"sourceAnchorSpawnId": 41926939,
"controllerSpawnId": 41928345,
"templateId": 105542,
"name": "Ardmond",
"sourcePosition": [
-1427.9969482421875,
1133.35498046875,
-1353.3699951171875
],
"sourceScriptRoot": "kg_105542_01",
"objective": true,
"evidence": [
"Resource/luascript/02324.lua:kg_105542_CreateBoss"
]
},
{
"scriptedPlacementId": "issac",
"sourceAnchorSpawnId": 41926939,
"controllerSpawnId": 41928345,
"templateId": 105543,
"name": "Issac",
"sourcePosition": [
-1427.9969482421875,
1133.35498046875,
-1313.3699951171875
],
"sourceScriptRoot": "kg_105542_01",
"objective": false,
"evidence": [
"Resource/luascript/02324.lua:kg_105542_CreateBoss"
]
},
{
"scriptedPlacementId": "boro-boro-brothers",
"sourceAnchorSpawnId": 41926924,
"controllerSpawnId": 41927347,
"templateId": 105224,
"name": "Boro-Boro Brothers",
"sourcePosition": [
-2172.174072265625,
1095.10400390625,
-22.212709426879883
],
"sourceScriptRoot": "ic_105224_01",
"objective": true,
"evidence": [
"Resource/luascript/02231.lua:CreateObjByFlag(105224,780775,1,1)"
]
},
{
"scriptedPlacementId": "conjurer-chapeaunoir",
"sourceAnchorSpawnId": 41926979,
"controllerSpawnId": 41927358,
"templateId": 105204,
"name": "Conjurer Chapeaunoir",
"sourcePosition": [
-1732.9649658203125,
914.618896484375,
1190.8310546875
],
"sourceScriptRoot": "cl_136_105205_N",
"objective": true,
"evidence": [
"Resource/luascript/02330.lua:CreateObjByFlag(105204,780767,0,1)"
]
},
{
"scriptedPlacementId": "larvanger-barkud",
"sourceAnchorSpawnId": 41927071,
"controllerSpawnId": 41927369,
"templateId": 105351,
"name": "Larvanger Barkud",
"sourcePosition": [
-305.8063049316406,
902.5551147460938,
941.4002075195312
],
"sourceScriptRoot": "un_zone19_boss5_allconreal_1_01",
"objective": true,
"evidence": [
"Resource/luascript/02297.lua:CreateObjByFlag(105351,780838,1,1)"
]
}
],
"templateCandidates": [ "templateCandidates": [
{ {
"templateId": 102678, "templateId": 102678,
@@ -94,6 +198,18 @@ export const GRAFU_CASTLE_MECHANICS_AUDIT = {
"templateScripts": [], "templateScripts": [],
"spells": [] "spells": []
}, },
{
"templateId": 105204,
"name": "Conjurer Chapeaunoir",
"classification": "boss",
"spawnCount": 0,
"autoPlots": [
"cl_136_105205_N"
],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{ {
"templateId": 105205, "templateId": 105205,
"name": "Template 105205", "name": "Template 105205",
@@ -109,7 +225,7 @@ export const GRAFU_CASTLE_MECHANICS_AUDIT = {
{ {
"templateId": 105210, "templateId": 105210,
"name": "Template 105210", "name": "Template 105210",
"classification": "combat", "classification": "deferred-object",
"spawnCount": 1, "spawnCount": 1,
"autoPlots": [ "autoPlots": [
"kg_105542_01" "kg_105542_01"
@@ -204,10 +320,82 @@ export const GRAFU_CASTLE_MECHANICS_AUDIT = {
"templateScripts": [], "templateScripts": [],
"spells": [] "spells": []
}, },
{
"templateId": 105224,
"name": "Boro-Boro Brothers",
"classification": "boss",
"spawnCount": 0,
"autoPlots": [
"ic_105224_01"
],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{
"templateId": 105229,
"name": "Smirking Castle Clown",
"classification": "combat",
"spawnCount": 0,
"autoPlots": [],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{
"templateId": 105230,
"name": "Odd Castle Clown",
"classification": "combat",
"spawnCount": 0,
"autoPlots": [],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{
"templateId": 105233,
"name": "Cursed Steel Soldier",
"classification": "combat",
"spawnCount": 0,
"autoPlots": [],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{
"templateId": 105235,
"name": "Conjurer's Apprentice",
"classification": "combat",
"spawnCount": 0,
"autoPlots": [],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{
"templateId": 105236,
"name": "Giant Enraged Bear",
"classification": "combat",
"spawnCount": 0,
"autoPlots": [],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{
"templateId": 105237,
"name": "Manipulated Bear",
"classification": "combat",
"spawnCount": 0,
"autoPlots": [],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{ {
"templateId": 105254, "templateId": 105254,
"name": "Template 105254", "name": "Template 105254",
"classification": "combat", "classification": "deferred-object",
"spawnCount": 10, "spawnCount": 10,
"autoPlots": [ "autoPlots": [
"cl_136_01_01_N", "cl_136_01_01_N",
@@ -225,7 +413,7 @@ export const GRAFU_CASTLE_MECHANICS_AUDIT = {
{ {
"templateId": 105258, "templateId": 105258,
"name": "Template 105258", "name": "Template 105258",
"classification": "combat", "classification": "deferred-object",
"spawnCount": 5, "spawnCount": 5,
"autoPlots": [ "autoPlots": [
"cl_105258_1", "cl_105258_1",
@@ -265,6 +453,42 @@ export const GRAFU_CASTLE_MECHANICS_AUDIT = {
"templateScripts": [], "templateScripts": [],
"spells": [] "spells": []
}, },
{
"templateId": 105351,
"name": "Larvanger Barkud",
"classification": "boss",
"spawnCount": 0,
"autoPlots": [
"un_zone19_boss5_allconreal_1_01"
],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{
"templateId": 105542,
"name": "Ardmond",
"classification": "boss",
"spawnCount": 0,
"autoPlots": [
"kg_105542_01"
],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{
"templateId": 105543,
"name": "Issac",
"classification": "boss",
"spawnCount": 0,
"autoPlots": [
"kg_105542_01"
],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{ {
"templateId": 105596, "templateId": 105596,
"name": "Smirking Castle Clown", "name": "Smirking Castle Clown",
@@ -332,6 +556,16 @@ export const GRAFU_CASTLE_MECHANICS_AUDIT = {
} }
] ]
}, },
{
"templateId": 105599,
"name": "Cursed Steel Soldier",
"classification": "combat",
"spawnCount": 0,
"autoPlots": [],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{ {
"templateId": 105600, "templateId": 105600,
"name": "Juggler's Apprentice", "name": "Juggler's Apprentice",
@@ -420,6 +654,18 @@ export const GRAFU_CASTLE_MECHANICS_AUDIT = {
} }
] ]
}, },
{
"templateId": 105616,
"name": "Cayus",
"classification": "boss",
"spawnCount": 0,
"autoPlots": [
"Dy_Z19_BOSSN_AIC"
],
"plotClassNames": [],
"templateScripts": [],
"spells": []
},
{ {
"templateId": 116434, "templateId": 116434,
"name": "Skeleton of an Adventurer", "name": "Skeleton of an Adventurer",
File diff suppressed because it is too large. Load diff
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
GRAFU_CASTLE_BOSSES,
GRAFU_CASTLE_ENTITIES,
GRAFU_CASTLE_POPULATION_PROVENANCE,
GRAFU_CASTLE_STATIC_SPAWNS,
} from "./generated/grafuCastlePopulation.generated";
const CONTROLLER_ENTITY_IDS = [
"rw-grafu-castle-105210",
"rw-grafu-castle-105254",
"rw-grafu-castle-105258",
] as const;
const BOSS_ENTITY_IDS = [
"rw-grafu-castle-105204",
"rw-grafu-castle-105224",
"rw-grafu-castle-105351",
"rw-grafu-castle-105542",
"rw-grafu-castle-105543",
"rw-grafu-castle-105616",
] as const;
describe("Grafu Castle population", () => {
it("does not render Lua controllers as mushroom combat mobs", () => {
for (const controllerId of CONTROLLER_ENTITY_IDS) {
expect(GRAFU_CASTLE_ENTITIES).not.toHaveProperty(controllerId);
expect(GRAFU_CASTLE_STATIC_SPAWNS.some((spawn) => (
String(spawn.entityId) === controllerId
))).toBe(false);
}
});
it("materializes the source-authored patrol formations", () => {
const formationSpawns = GRAFU_CASTLE_STATIC_SPAWNS.filter((spawn) => (
spawn.id.startsWith("rw-grafu-castle-scripted-castle-patrol-")
));
expect(formationSpawns).toHaveLength(38);
expect(new Set(formationSpawns.map((spawn) => GRAFU_CASTLE_ENTITIES[spawn.entityId]?.name)))
.toEqual(new Set([
"Conjurer's Apprentice",
"Cursed Steel Soldier",
"Giant Enraged Bear",
"Manipulated Bear",
"Manipulated Steel Soldier",
"Odd Castle Clown",
"Smirking Castle Clown",
]));
});
it("ships all five normal encounters and both members of the duo", () => {
expect(GRAFU_CASTLE_BOSSES.map((boss) => boss.name)).toEqual([
"Cayus",
"Ardmond and Issac",
"Boro-Boro Brothers",
"Conjurer Chapeaunoir",
"Larvanger Barkud",
]);
expect(GRAFU_CASTLE_POPULATION_PROVENANCE.bossSpawnCount).toBe(5);
expect(GRAFU_CASTLE_POPULATION_PROVENANCE.scriptedSpawnCount).toBe(44);
for (const entityId of BOSS_ENTITY_IDS) {
expect(GRAFU_CASTLE_ENTITIES[entityId].kind).toBe("boss");
expect(GRAFU_CASTLE_ENTITIES[entityId].visual.model?.url).toMatch(
/^\/assets\/creatures\/grafu-castle\/.+\.glb$/,
);
expect(GRAFU_CASTLE_STATIC_SPAWNS.some((spawn) => spawn.entityId === entityId)).toBe(true);
}
for (const objective of GRAFU_CASTLE_BOSSES) {
expect(GRAFU_CASTLE_STATIC_SPAWNS.some((spawn) => spawn.id === objective.id)).toBe(true);
}
});
});
+1 -20
View File
@@ -478,23 +478,4 @@ export function isGameMasterSession(session: { readonly kind: string; readonly a
return Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm")); return Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
} }
export function derivePartySpawnSlots(spawn: WorldSpawn, partySize = 5): readonly WorldSpawn[] { export { derivePartySpawnSlots } from "./partySpawnSlots";
const size = Math.max(1, Math.min(5, Math.trunc(partySize)));
const offsets = [
[0, 0],
[-1.65, -1.8],
[1.65, -1.8],
[-3.05, -3.5],
[3.05, -3.5],
] as const;
const sin = Math.sin(spawn.yaw);
const cos = Math.cos(spawn.yaw);
return Object.freeze(offsets.slice(0, size).map(([lateral, forward]) => ({
footPosition: [
spawn.footPosition[0] + lateral * cos + forward * sin,
spawn.footPosition[1],
spawn.footPosition[2] - lateral * sin + forward * cos,
] as const,
yaw: spawn.yaw,
})));
}
+4
View File
@@ -126,6 +126,10 @@ describe("party navigation graph", () => {
expect(graph).toMatchObject({ componentCount: 1, discardedNodeCount: 0 }); expect(graph).toMatchObject({ componentCount: 1, discardedNodeCount: 0 });
expect(graph.triangles).toBeUndefined(); expect(graph.triangles).toBeUndefined();
expect(findNearestPartyNavigationSurface(graph, [0.25, 0.5, 0.25], 1)).toMatchObject({
triangleId: -1,
distance: expect.any(Number),
});
expect(findPartyNavigationPath(graph, [0, 0, 0], [11, 0, 10])).not.toBeNull(); expect(findPartyNavigationPath(graph, [0, 0, 0], [11, 0, 10])).not.toBeNull();
expect(findPartyNavigationPath(graph, [11, 0, 10], [0, 0, 0])).not.toBeNull(); expect(findPartyNavigationPath(graph, [11, 0, 10], [0, 0, 0])).not.toBeNull();
}); });
+10 -1
View File
@@ -570,7 +570,16 @@ export function findNearestPartyNavigationSurface(
maxDistance = Number.POSITIVE_INFINITY, maxDistance = Number.POSITIVE_INFINITY,
): PartyNavigationSurfaceMatch | null { ): PartyNavigationSurfaceMatch | null {
const triangles = graph.triangles; const triangles = graph.triangles;
if (!triangles?.length) return null; if (!triangles?.length) {
const nearest = findNearestPartyNavigationNode(graph, point, maxDistance);
return nearest
? Object.freeze({
position: nearest.node.position,
triangleId: -1,
distance: nearest.distance,
})
: null;
}
const target = pointComponents(point); const target = pointComponents(point);
const limit = Number.isFinite(maxDistance) ? Math.max(0, maxDistance) : Number.POSITIVE_INFINITY; const limit = Number.isFinite(maxDistance) ? Math.max(0, maxDistance) : Number.POSITIVE_INFINITY;
let nearestPosition: PartyNavigationPosition | null = null; let nearestPosition: PartyNavigationPosition | null = null;
+29 -1
View File
@@ -1,4 +1,10 @@
import { BufferGeometry, Float32BufferAttribute, Group, Mesh } from "three"; import {
BufferAttribute,
BufferGeometry,
Float32BufferAttribute,
Group,
Mesh,
} from "three";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache"; import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
@@ -33,4 +39,26 @@ describe("party navigation asset cache", () => {
}]); }]);
expect(second).not.toBe(first); expect(second).not.toBe(first);
}); });
it("expands normalized quantized positions before baking the node transform", () => {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(new Int16Array([
0, 0, 0,
32_767, 0, 0,
0, 0, 32_767,
]), 3, true));
const mesh = new Mesh(geometry);
mesh.position.set(10, 2, -5);
mesh.scale.setScalar(100);
const scene = new Group();
scene.add(mesh);
const graph = preparePartyNavigationGraph("/navigation/quantized-transform.glb", scene, []);
expect(graph.nodes.map((node) => node.position)).toEqual([
[10, 2, -5],
[110, 2, -5],
[10, 2, 95],
]);
});
}); });
+31 -6
View File
@@ -1,5 +1,6 @@
import { BufferGeometry, type Group, type Mesh } from "three"; import { BufferGeometry, Float32BufferAttribute, type Group, type Mesh } from "three";
import type { DungeonNavigationLink } from "./dungeonTypes"; import type { DungeonNavigationLink, DungeonTraversalRamp } from "./dungeonTypes";
import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
import { import {
buildPartyNavigationGraph, buildPartyNavigationGraph,
type PartyNavigationGraph, type PartyNavigationGraph,
@@ -9,14 +10,18 @@ import { MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES } from "./partyNavigationPol
const MAX_CACHED_NAVIGATION_GRAPHS = 8; const MAX_CACHED_NAVIGATION_GRAPHS = 8;
const graphCache = new Map<string, PartyNavigationGraph>(); const graphCache = new Map<string, PartyNavigationGraph>();
function graphCacheKey(url: string, offMeshLinks: readonly DungeonNavigationLink[]): string { function graphCacheKey(
url: string,
offMeshLinks: readonly DungeonNavigationLink[],
ramps: readonly DungeonTraversalRamp[],
): string {
const links = offMeshLinks.map((link) => [ const links = offMeshLinks.map((link) => [
link.id, link.id,
link.start, link.start,
link.end, link.end,
link.bidirectional, link.bidirectional,
]); ]);
return `${url}:${JSON.stringify(links)}`; return `${url}:${JSON.stringify([links, ramps])}`;
} }
function navigationGeometry(scene: Group): BufferGeometry { function navigationGeometry(scene: Group): BufferGeometry {
@@ -33,6 +38,19 @@ function navigationGeometry(scene: Group): BufferGeometry {
}); });
if (!sourceMesh) throw new Error("The party navigation GLB has no triangle geometry."); if (!sourceMesh) throw new Error("The party navigation GLB has no triangle geometry.");
const geometry = (sourceMesh as Mesh).geometry.clone(); const geometry = (sourceMesh as Mesh).geometry.clone();
const position = geometry.getAttribute("position");
// BufferAttribute.applyMatrix4 writes back through the source component
// type. Quantized normalized integers therefore clamp world coordinates to
// [-1, 1] while baking their node transform. Expand them to floats first.
if (position.normalized) {
const expanded = new Float32Array(position.count * 3);
for (let index = 0; index < position.count; index += 1) {
expanded[index * 3] = position.getX(index);
expanded[index * 3 + 1] = position.getY(index);
expanded[index * 3 + 2] = position.getZ(index);
}
geometry.setAttribute("position", new Float32BufferAttribute(expanded, 3));
}
geometry.applyMatrix4((sourceMesh as Mesh).matrixWorld); geometry.applyMatrix4((sourceMesh as Mesh).matrixWorld);
return geometry; return geometry;
} }
@@ -41,8 +59,9 @@ export function preparePartyNavigationGraph(
url: string, url: string,
scene: Group, scene: Group,
offMeshLinks: readonly DungeonNavigationLink[], offMeshLinks: readonly DungeonNavigationLink[],
ramps: readonly DungeonTraversalRamp[] = [],
): PartyNavigationGraph { ): PartyNavigationGraph {
const cacheKey = graphCacheKey(url, offMeshLinks); const cacheKey = graphCacheKey(url, offMeshLinks, ramps);
const cached = graphCache.get(cacheKey); const cached = graphCache.get(cacheKey);
if (cached) { if (cached) {
graphCache.delete(cacheKey); graphCache.delete(cacheKey);
@@ -53,9 +72,15 @@ export function preparePartyNavigationGraph(
const geometry = navigationGeometry(scene); const geometry = navigationGeometry(scene);
let graph: PartyNavigationGraph; let graph: PartyNavigationGraph;
try { try {
if (ramps.length) {
// Recast normally lifts its walkable surface slightly above collision.
smoothDungeonStairGeometry(geometry, ramps, 0.05);
bridgeDungeonStairNavigation(geometry, ramps, 0.05);
}
const bridgedLinks = new Set(ramps.map((ramp) => ramp.navigationBridge.linkId));
graph = buildPartyNavigationGraph(geometry, { graph = buildPartyNavigationGraph(geometry, {
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES, maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
offMeshLinks, offMeshLinks: offMeshLinks.filter((link) => !bridgedLinks.has(link.id)),
}); });
} finally { } finally {
geometry.dispose(); geometry.dispose();
+9 -3
View File
@@ -102,7 +102,10 @@ describe("party navigation fleet safety", () => {
if (shouldLoadPartyNavigation("dungeon", dungeon, null)) { if (shouldLoadPartyNavigation("dungeon", dungeon, null)) {
enabled.push(dungeon.id); enabled.push(dungeon.id);
expect(dungeon.validation.status, dungeon.id).toBe("green"); expect(
dungeon.validation.status === "green" || dungeon.validation.navigation === "green",
dungeon.id,
).toBe(true);
expect(actualTriangles, dungeon.id) expect(actualTriangles, dungeon.id)
.toBeLessThanOrEqual(MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES); .toBeLessThanOrEqual(MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES);
} else { } else {
@@ -113,8 +116,11 @@ describe("party navigation fleet safety", () => {
// Raksha Temple and The Origin now ship their full source-assembled // Raksha Temple and The Origin now ship their full source-assembled
// navigation geometry, which correctly moves both above the synchronous // navigation geometry, which correctly moves both above the synchronous
// graph-build ceiling and onto the breadcrumb fallback path. // graph-build ceiling and onto the breadcrumb fallback path.
expect(enabled).toHaveLength(29); // Stockades has a reviewed navigation override without claiming that its
expect(fallback).toHaveLength(55); // broader encounter/reset validation is complete.
expect(enabled).toContain("stormwind-stockade");
expect(enabled).toHaveLength(30);
expect(fallback).toHaveLength(54);
}); });
it("covers every Manastorm package and keeps actual synchronous geometry under budget", () => { it("covers every Manastorm package and keeps actual synchronous geometry under budget", () => {
+10 -1
View File
@@ -25,6 +25,7 @@ function manastormPackageWithNavigationTriangles(
function dungeonWithNavigation( function dungeonWithNavigation(
status: DungeonDefinition["validation"]["status"], status: DungeonDefinition["validation"]["status"],
triangleCount: number, triangleCount: number,
navigation?: DungeonDefinition["validation"]["navigation"],
): Pick<DungeonDefinition, "assets" | "validation"> { ): Pick<DungeonDefinition, "assets" | "validation"> {
return { return {
assets: { assets: {
@@ -32,7 +33,7 @@ function dungeonWithNavigation(
collision: [], collision: [],
navigation: { id: "navigation", fileName: "navigation.glb", triangleCount }, navigation: { id: "navigation", fileName: "navigation.glb", triangleCount },
}, },
validation: { status, blockers: [], warnings: [] }, validation: { status, navigation, blockers: [], warnings: [] },
}; };
} }
@@ -61,6 +62,14 @@ describe("party navigation loading policy", () => {
)).toBe(false); )).toBe(false);
}); });
it("loads an independently reviewed dungeon navigation graph", () => {
expect(shouldLoadPartyNavigation(
"dungeon",
dungeonWithNavigation("unvalidated", 1_000, "green"),
null,
)).toBe(true);
});
it("protects Manastorm from oversized or unmeasured synchronous graphs", () => { it("protects Manastorm from oversized or unmeasured synchronous graphs", () => {
const fallbackDungeon = requireDungeonDefinition("wailing-caverns"); const fallbackDungeon = requireDungeonDefinition("wailing-caverns");
+7 -3
View File
@@ -21,9 +21,13 @@ export function shouldLoadPartyNavigation(
: dungeon.assets.navigation; : dungeon.assets.navigation;
if (!navigation) return false; if (!navigation) return false;
// Imported dungeon packs already use local/breadcrumb following until their // Navigation can be reviewed independently from encounter/checkpoint content.
// routes are reviewed, so building their navigation graph provides no value. // Do not make safe melee routing wait for unrelated dungeon validation work.
if (gameMode === "dungeon" && dungeon.validation.status !== "green") return false; if (
gameMode === "dungeon"
&& dungeon.validation.status !== "green"
&& dungeon.validation.navigation !== "green"
) return false;
const triangleCount = navigation.triangleCount; const triangleCount = navigation.triangleCount;
return typeof triangleCount === "number" return typeof triangleCount === "number"
+22
View File
@@ -0,0 +1,22 @@
import type { WorldSpawn } from "./dungeonTypes";
export function derivePartySpawnSlots(spawn: WorldSpawn, partySize = 5): readonly WorldSpawn[] {
const size = Math.max(1, Math.min(5, Math.trunc(partySize)));
const offsets = [
[0, 0],
[-1.65, -1.8],
[1.65, -1.8],
[-3.05, -3.5],
[3.05, -3.5],
] as const;
const sin = Math.sin(spawn.yaw);
const cos = Math.cos(spawn.yaw);
return Object.freeze(offsets.slice(0, size).map(([lateral, forward]) => ({
footPosition: [
spawn.footPosition[0] + lateral * cos + forward * sin,
spawn.footPosition[1],
spawn.footPosition[2] - lateral * sin + forward * cos,
] as const,
yaw: spawn.yaw,
})));
}
+1 -1
View File
@@ -145,7 +145,7 @@ describe("player movement", () => {
expect(translation.y).toBeLessThan(PLAYER_CENTER_HEIGHT + 0.1); expect(translation.y).toBeLessThan(PLAYER_CENTER_HEIGHT + 0.1);
}); });
it("walks back up the real Stockades entrance stairs without a jump impulse", () => { it("walks back up the Stockades interior stairs without a jump impulse", () => {
const world = new RAPIER.World({ x: 0, y: -18, z: 0 }); const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
const dungeonBody = world.createRigidBody(RAPIER.RigidBodyDesc.fixed()); const dungeonBody = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
world.createCollider( world.createCollider(
+2 -1
View File
@@ -24,7 +24,7 @@ describe("modular RuneWaker boss mechanics", () => {
)); ));
it("gives every packaged boss a catalog-backed special-mechanic loadout", () => { it("gives every packaged boss a catalog-backed special-mechanic loadout", () => {
expect(bosses).toHaveLength(56); expect(bosses).toHaveLength(62);
for (const { dungeon, entity } of bosses) { for (const { dungeon, entity } of bosses) {
const label = `${dungeon.id}:${entity.id}`; const label = `${dungeon.id}:${entity.id}`;
const authored = catalog.bosses[entity.id]; const authored = catalog.bosses[entity.id];
@@ -70,6 +70,7 @@ describe("modular RuneWaker boss mechanics", () => {
"rw-fa-100561", "rw-fa-100561",
"rw-fa-100562", "rw-fa-100562",
"rw-fa-100563", "rw-fa-100563",
"rw-grafu-castle-105351",
]); ]);
}); });
}); });
+3 -3
View File
@@ -283,8 +283,8 @@ describe("RuneWaker dungeon creature animation coverage", () => {
} }
expect(counts).toEqual({ expect(counts).toEqual({
configured: 268, configured: 275,
native: 267, native: 274,
poseOnly: 1, poseOnly: 1,
dynamicProxy: 0, dynamicProxy: 0,
}); });
@@ -301,7 +301,7 @@ describe("RuneWaker dungeon creature animation coverage", () => {
} }
} }
expect(runtimeCounts, "runtime proxies: " + runtimeProxyIds.join(", ")).toEqual({ expect(runtimeCounts, "runtime proxies: " + runtimeProxyIds.join(", ")).toEqual({
native: 436, native: 446,
poseOnly: 1, poseOnly: 1,
proxy: 0, proxy: 0,
}); });
+354
View File
@@ -0,0 +1,354 @@
import { NodeIO } from "@gltf-transform/core";
import RAPIER from "@dimforge/rapier3d-compat";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import { BufferAttribute, BufferGeometry, Group, Mesh } from "three";
import { beforeAll, describe, expect, it } from "vitest";
import { requireDungeonDefinition } from "./dungeonRegistry";
import {
findNearestPartyNavigationNode,
findNearestPartyNavigationSurface,
findPartyNavigationPath,
type PartyNavigationGraph,
} from "./partyNavigation";
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
import { shouldLoadPartyNavigation } from "./partyNavigationPolicy";
import { planPartyRoute } from "./partyPathing";
import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement";
import { derivePartySpawnSlots } from "./partySpawnSlots";
import { collisionTrimeshes, prepareCollision } from "../scene/productionDungeonCollision";
import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator } from "../scene/partyTraversal";
import { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "./playerMovement";
const STOCKADES_NAVIGATION = "public/assets/game/manastorm/34-stormwindjail/navigation.glb";
const TANK_RANGE = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15 });
const stockades = requireDungeonDefinition("stormwind-stockade");
let graph: PartyNavigationGraph;
let sourceCollision: Group;
beforeAll(async () => {
await RAPIER.init();
await MeshoptDecoder.ready;
const document = await new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder })
.read(STOCKADES_NAVIGATION);
const meshNode = document.getRoot().listNodes().find((node) => node.getMesh());
const primitive = meshNode?.getMesh()?.listPrimitives()[0];
const accessor = primitive?.getAttribute("POSITION");
const sourcePositions = accessor?.getArray();
if (!meshNode || !accessor || !sourcePositions) {
throw new Error("Stormwind Stockade navigation GLB is missing its triangle surface.");
}
if (!(sourcePositions instanceof Int16Array) || !accessor.getNormalized()) {
throw new Error("Stormwind Stockade navigation regression expects normalized Int16 positions.");
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(sourcePositions, 3, accessor.getNormalized()));
const sourceIndices = primitive?.getIndices()?.getArray();
if (sourceIndices) geometry.setIndex(Array.from(sourceIndices));
const mesh = new Mesh(geometry);
mesh.matrixAutoUpdate = false;
mesh.matrix.fromArray(meshNode.getWorldMatrix());
const scene = new Group();
scene.add(mesh);
// Exercise the same quantized-buffer and node-transform path as GLTFLoader
// in the shipping runtime, not a separately pre-expanded test geometry.
graph = preparePartyNavigationGraph(
"/test/stockades-navigation.glb", scene, stockades.navigationLinks, stockades.traversalRamps,
);
geometry.dispose();
const collisionDocument = await new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder })
.read("public/assets/game/manastorm/34-stormwindjail/collision.glb");
sourceCollision = new Group();
for (const node of collisionDocument.getRoot().listNodes()) {
for (const collisionPrimitive of node.getMesh()?.listPrimitives() ?? []) {
const positions = collisionPrimitive.getAttribute("POSITION")!;
const values = positions.getArray();
if (!(values instanceof Int16Array)) throw new Error("Expected quantized Stockades collision positions.");
const collisionGeometry = new BufferGeometry();
collisionGeometry.setAttribute("position", new BufferAttribute(values, 3, positions.getNormalized()));
collisionGeometry.setIndex(Array.from(collisionPrimitive.getIndices()!.getArray()!));
const collisionMesh = new Mesh(collisionGeometry);
collisionMesh.matrixAutoUpdate = false;
collisionMesh.matrix.fromArray(node.getWorldMatrix());
sourceCollision.add(collisionMesh);
}
}
});
function collisionWorld(useRamps = true): RAPIER.World {
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
const prepared = prepareCollision(sourceCollision, useRamps ? stockades.traversalRamps : []);
for (const trimesh of collisionTrimeshes(prepared)) {
world.createCollider(RAPIER.ColliderDesc.trimesh(
Float32Array.from(trimesh.vertices), Uint32Array.from(trimesh.indices),
));
}
for (const child of prepared.children) (child as Mesh).geometry.dispose();
world.step();
return world;
}
function distance(left: readonly number[], right: readonly number[]): number {
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
}
describe("Stormwind Stockade party navigation", () => {
it("uses the reviewed warm indoor lighting profile", () => {
expect(stockades.presentation).toMatchObject({
background: "#171815",
fog: { color: "#1b1c18", near: 44 },
ambientLight: { color: "#d8c9aa", intensity: 0.58 },
hemisphereLight: { skyColor: "#cfc3a5", groundColor: "#292820", intensity: 0.86 },
directionalLight: { color: "#ffe0a8", intensity: 1.1, offset: [10, 18, -5] },
});
});
it("loads its independently reviewed, budgeted navigation graph", () => {
expect(stockades.validation).toMatchObject({ status: "unvalidated", navigation: "green" });
expect(stockades.navigationLinks).toHaveLength(3);
expect(shouldLoadPartyNavigation("dungeon", stockades, null)).toBe(true);
expect(graph).toMatchObject({
sourceTriangleCount: 3_544,
componentCount: 31,
});
expect(graph.discardedNodeCount).toBeGreaterThan(0);
expect(graph.triangles?.length).toBeGreaterThan(0);
expect(graph.triangleNodes?.length).toBe(graph.triangles?.length);
});
it("projects every entrance formation slot onto the retained walkable surface", () => {
const slots = derivePartySpawnSlots({
footPosition: stockades.entrance.footPosition,
yaw: stockades.entrance.yaw,
});
expect(slots).toHaveLength(5);
expect(findNearestPartyNavigationNode(graph, stockades.entrance.footPosition)?.distance)
.toBeLessThanOrEqual(6);
for (const slot of slots) {
expect(findNearestPartyNavigationSurface(graph, slot.footPosition, 6)).not.toBeNull();
}
});
it("routes from the shipping entrance to every authored boss objective", () => {
const world = collisionWorld();
const routeSegmentAllowed = createPartyRouteSegmentValidator(RAPIER, world);
const entrance = findNearestPartyNavigationSurface(
graph,
stockades.entrance.footPosition,
6,
);
expect(entrance).not.toBeNull();
for (const objective of stockades.objectiveOrder) {
const approach = findNearestPartyNavigationSurface(graph, objective.position, 15);
expect(approach, objective.name).not.toBeNull();
expect(findPartyNavigationPath(
graph,
entrance!.position,
approach!.position,
{ maxStartSnapDistance: 0.1, maxEndSnapDistance: 0.1, routeSegmentAllowed },
), objective.name).not.toBeNull();
}
world.free();
});
it("gives the tank a usable route into melee range of the first enemy", () => {
const world = collisionWorld();
const segmentAllowed = createPartyDirectSegmentValidator(RAPIER, world);
const tankSlot = derivePartySpawnSlots({
footPosition: stockades.entrance.footPosition,
yaw: stockades.entrance.yaw,
}, 2)[1];
const tankStart = findNearestPartyNavigationSurface(graph, tankSlot.footPosition, 6);
expect(tankStart).not.toBeNull();
const firstEnemy = [...stockades.staticSpawns].sort((left, right) => (
distance(left.position, stockades.entrance.footPosition)
- distance(right.position, stockades.entrance.footPosition)
))[0];
const desired: [number, number, number] = [0, 0, 0];
expect(partyCombatMovementGoal(
tankStart!.position,
firstEnemy.position,
true,
TANK_RANGE.minimum,
TANK_RANGE.preferred,
TANK_RANGE.maximum,
desired,
)).toBe("approach");
const route = planPartyRoute(
graph,
`tank:${firstEnemy.id}`,
tankStart!.position,
desired,
0,
{
maxOriginSnapDistance: 3,
maxDestinationSnapDistance: 5,
localFinishDistance: TANK_RANGE.maximum,
directSegmentAllowed: segmentAllowed,
routeSegmentAllowed: createPartyRouteSegmentValidator(RAPIER, world),
},
);
expect(route.blocked).toBe(false);
expect(route.waypoints.length).toBeGreaterThan(1);
let current: [number, number, number] = [...tankStart!.position];
const next: [number, number, number] = [0, 0, 0];
for (const waypoint of route.waypoints) {
while (distance(current, waypoint) > 0.01) {
stepPartyPosition(current, waypoint, 0.5, next);
current = [...next];
}
}
expect(distance(current, firstEnemy.position)).toBeLessThanOrEqual(TANK_RANGE.maximum);
expect(partyCombatMovementGoal(
current,
firstEnemy.position,
true,
TANK_RANGE.minimum,
TANK_RANGE.preferred,
TANK_RANGE.maximum,
desired,
)).toBe("hold");
world.free();
});
it("traverses all three shipping stair flights in both directions with the live capsule check", () => {
const world = collisionWorld();
const allowed = createPartyDirectSegmentValidator(RAPIER, world);
expect(stockades.traversalRamps).toHaveLength(3);
for (const ramp of stockades.traversalRamps!) {
const point = (t: number, lateral: number): [number, number, number] => {
const dx = ramp.end[0] - ramp.start[0];
const dz = ramp.end[2] - ramp.start[2];
const length = Math.hypot(dx, dz);
return [
ramp.start[0] + dx * t + dz / length * lateral,
ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t + 0.05,
ramp.start[2] + dz * t - dx / length * lateral,
];
};
for (const lateral of [-2, 0, 2]) {
const start = point(0.05, lateral);
const end = point(0.95, lateral);
expect(allowed(start, end), `${ramp.id}: downhill, offset ${lateral}`).toBe(true);
expect(allowed(end, start), `${ramp.id}: uphill, offset ${lateral}`).toBe(true);
}
const start = findNearestPartyNavigationSurface(graph, point(0.15, 0), 2)!.position;
const end = findNearestPartyNavigationSurface(graph, point(0.85, 0), 2)!.position;
expect(findPartyNavigationPath(graph, start, end, { maxSnapDistance: 0.1 }), ramp.id).not.toBeNull();
// Replanning midway through a stair must not require a blocked snap to
// a distant boundary vertex, as the previous off-mesh graph did.
for (const t of [0.2, 0.4, 0.6, 0.8]) {
const current = findNearestPartyNavigationSurface(graph, point(t, 0), 2)!.position;
const route = planPartyRoute(graph, ramp.id, current, end, t * 1_000, {
maxSnapDistance: 0.1,
directSegmentAllowed: allowed,
});
expect(route.blocked, `${ramp.id}: replan at ${t}`).toBe(false);
}
}
world.free();
});
it("reproduces the old step-face rejection on the unmodified source collision", () => {
const world = collisionWorld(false);
const allowed = createPartyDirectSegmentValidator(RAPIER, world);
for (const ramp of stockades.traversalRamps!) {
const point = (t: number): [number, number, number] => [
ramp.start[0] + (ramp.end[0] - ramp.start[0]) * t,
ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t + 0.05,
ramp.start[2] + (ramp.end[2] - ramp.start[2]) * t,
];
expect(allowed(point(0.95), point(0.05)), ramp.id).toBe(false);
}
world.free();
});
it("still blocks movement through the stairwell walls", () => {
const world = collisionWorld();
const directAllowed = createPartyDirectSegmentValidator(RAPIER, world);
const routeAllowed = createPartyRouteSegmentValidator(RAPIER, world);
const ramp = stockades.traversalRamps![0];
const center: [number, number, number] = [
(ramp.start[0] + ramp.end[0]) / 2,
(ramp.start[1] + ramp.end[1]) / 2 + 0.05,
ramp.start[2],
];
const acrossWall: [number, number, number] = [center[0], center[1], center[2] + 9];
expect(directAllowed(center, acrossWall)).toBe(false);
expect(routeAllowed(center, acrossWall)).toBe(false);
world.free();
});
it("does not mistake the nearby player's kinematic capsule for stair terrain", () => {
const world = collisionWorld();
const ramp = stockades.traversalRamps![0];
const point = (t: number): [number, number, number] => [
ramp.start[0] + (ramp.end[0] - ramp.start[0]) * t,
ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t + 0.05,
ramp.start[2],
];
const player = point(0.5);
const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(
player[0], player[1] + 0.84, player[2],
));
world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36), body);
world.step();
expect(createPartyDirectSegmentValidator(RAPIER, world)(point(0.4), point(0.6))).toBe(true);
expect(createPartyRouteSegmentValidator(RAPIER, world)(point(0.4), point(0.6))).toBe(true);
world.free();
});
it("walks the player capsule down and back up every ramp without jumping", () => {
for (const ramp of stockades.traversalRamps!) {
const world = collisionWorld();
const dx = ramp.end[0] - ramp.start[0];
const dz = ramp.end[2] - ramp.start[2];
const length = Math.hypot(dx, dz);
const centerHeight = 0.36 + 0.48;
const startT = 0.03;
const startY = ramp.start[1] + (ramp.end[1] - ramp.start[1]) * startT;
const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(
ramp.start[0] + dx * startT, startY + centerHeight + 0.02, ramp.start[2] + dz * startT,
));
const collider = world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36), body);
world.step();
const controller = configurePlayerCharacterController(world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET));
const progress = () => {
const position = body.translation();
return ((position.x - ramp.start[0]) * dx + (position.z - ramp.start[2]) * dz) / (length * length);
};
for (const direction of [1, -1]) {
// Rapier projects uphill movement onto the slope, reducing planar
// speed. Bound the walk by twice the flat-ground travel time.
const frames = Math.ceil(length * 2 / (4.8 / 60));
for (let frame = 0; frame < frames; frame += 1) {
if (direction === 1 ? progress() >= 0.95 : progress() <= 0.05) break;
controller.computeColliderMovement(collider, {
x: dx / length * direction * 4.8 / 60,
y: -0.5 / 60,
z: dz / length * direction * 4.8 / 60,
}, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC);
const position = body.translation();
const move = controller.computedMovement();
body.setNextKinematicTranslation({ x: position.x + move.x, y: position.y + move.y, z: position.z + move.z });
world.step();
}
if (direction === 1) expect(progress(), `${ramp.id}: player downhill`).toBeGreaterThan(0.9);
else expect(progress(), `${ramp.id}: player uphill`).toBeLessThan(0.12);
}
world.removeCharacterController(controller);
world.free();
}
});
});
+3 -1
View File
@@ -8,6 +8,7 @@ import { clearMobRuntimeRegistry, setMobPosition } from "../game/mobRuntimeRegis
import { usePartyStore } from "../game/partyStore"; import { usePartyStore } from "../game/partyStore";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { currentCompanionSnapshot, executeCompanionCommand } from "./dualScreenSync"; import { currentCompanionSnapshot, executeCompanionCommand } from "./dualScreenSync";
import { SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
describe("dual-screen party commands", () => { describe("dual-screen party commands", () => {
beforeEach(() => { beforeEach(() => {
@@ -147,7 +148,8 @@ describe("dual-screen party commands", () => {
executeCompanionCommand({ name: "returnToMainMenu" }); executeCompanionCommand({ name: "returnToMainMenu" });
executeCompanionCommand({ name: "openManastorms" }); executeCompanionCommand({ name: "openManastorms" });
expect(useShellStore.getState().phase).toBe("manastorms"); expect(useShellStore.getState().phase).toBe(SHELVED_MODES_ENABLED ? "manastorms" : "main-menu");
if (!SHELVED_MODES_ENABLED) expect(useShellStore.getState().notice).toMatch(/temporarily unavailable/i);
executeCompanionCommand({ name: "returnToMainMenu" }); executeCompanionCommand({ name: "returnToMainMenu" });
executeCompanionCommand({ name: "openDungeons" }); executeCompanionCommand({ name: "openDungeons" });
+8 -2
View File
@@ -24,7 +24,7 @@ import { useCombatStore } from "../game/combatStore";
import { PLAYER_EQUIPMENT_OWNER_ID } from "../game/equipment"; import { PLAYER_EQUIPMENT_OWNER_ID } from "../game/equipment";
import { resolvedEquipmentItems } from "../game/heirloomEquipment"; import { resolvedEquipmentItems } from "../game/heirloomEquipment";
import type { InventoryItem } from "../game/lootTypes"; import type { InventoryItem } from "../game/lootTypes";
import type { DungeonNavigationLink } from "../game/dungeonTypes"; import type { DungeonNavigationLink, DungeonTraversalRamp } from "../game/dungeonTypes";
import { preparePartyNavigationGraph } from "../game/partyNavigationAssetCache"; import { preparePartyNavigationGraph } from "../game/partyNavigationAssetCache";
import { usePartyStore } from "../game/partyStore"; import { usePartyStore } from "../game/partyStore";
import { preloadTalentGameplayResources } from "../ui/talentGameplayResources"; import { preloadTalentGameplayResources } from "../ui/talentGameplayResources";
@@ -63,11 +63,13 @@ function WarmupCommit({ onSettled }: { readonly onSettled: () => void }) {
function SceneAssetProbe({ function SceneAssetProbe({
navigationLinks, navigationLinks,
traversalRamps,
navigationUrl, navigationUrl,
urls, urls,
onSettled, onSettled,
}: { }: {
readonly navigationLinks: readonly DungeonNavigationLink[]; readonly navigationLinks: readonly DungeonNavigationLink[];
readonly traversalRamps: readonly DungeonTraversalRamp[];
readonly navigationUrl: string | null; readonly navigationUrl: string | null;
readonly urls: readonly string[]; readonly urls: readonly string[];
readonly onSettled: () => void; readonly onSettled: () => void;
@@ -80,8 +82,9 @@ function SceneAssetProbe({
navigationUrl, navigationUrl,
gltfs[navigationIndex].scene, gltfs[navigationIndex].scene,
navigationLinks, navigationLinks,
traversalRamps,
); );
}, [gltfs, navigationIndex, navigationLinks, navigationUrl]); }, [gltfs, navigationIndex, navigationLinks, navigationUrl, traversalRamps]);
return <WarmupCommit onSettled={onSettled} />; return <WarmupCommit onSettled={onSettled} />;
} }
@@ -248,11 +251,13 @@ function PartyDataProbe({ onSettled }: { readonly onSettled: () => void }) {
export function ActivityAssetWarmup({ export function ActivityAssetWarmup({
navigationLinks, navigationLinks,
traversalRamps,
navigationUrl, navigationUrl,
sceneAssetUrls, sceneAssetUrls,
onReady, onReady,
}: { }: {
readonly navigationLinks: readonly DungeonNavigationLink[]; readonly navigationLinks: readonly DungeonNavigationLink[];
readonly traversalRamps: readonly DungeonTraversalRamp[];
readonly navigationUrl: string | null; readonly navigationUrl: string | null;
readonly sceneAssetUrls: readonly string[]; readonly sceneAssetUrls: readonly string[];
readonly onReady: () => void; readonly onReady: () => void;
@@ -276,6 +281,7 @@ export function ActivityAssetWarmup({
<Suspense fallback={null}> <Suspense fallback={null}>
<SceneAssetProbe <SceneAssetProbe
navigationLinks={navigationLinks} navigationLinks={navigationLinks}
traversalRamps={traversalRamps}
navigationUrl={navigationUrl} navigationUrl={navigationUrl}
urls={sceneAssetUrls} urls={sceneAssetUrls}
onSettled={markSceneReady} onSettled={markSceneReady}
+3 -4
View File
@@ -26,7 +26,6 @@ import { PartyPopulation } from "./PartyPopulation";
import { GroundEffectTelegraphs } from "./GroundEffectTelegraphs"; import { GroundEffectTelegraphs } from "./GroundEffectTelegraphs";
import { PlayerRig } from "./PlayerRig"; import { PlayerRig } from "./PlayerRig";
import { useManastormStore } from "../game/manastormStore"; import { useManastormStore } from "../game/manastormStore";
import { ManastormPortal } from "./ManastormPortal";
import { import {
encounterWorldIsReady, encounterWorldIsReady,
encounterWorldKey, encounterWorldKey,
@@ -44,7 +43,6 @@ import { WailingNaralexEvent } from "./WailingNaralexEvent";
import { GameGltfLoaderLifecycle } from "./useGameGLTF"; import { GameGltfLoaderLifecycle } from "./useGameGLTF";
import { CombatEffects } from "./CombatEffects"; import { CombatEffects } from "./CombatEffects";
import { PLAYER_GRAVITY } from "../game/playerMovement"; import { PLAYER_GRAVITY } from "../game/playerMovement";
import { GmPlacementMarkers } from "./GmPlacementMarkers";
import { useOnlineGroupStore } from "../app/onlineGroupStore"; import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore"; import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { OnlinePlayerPopulation } from "./OnlinePlayerPopulation"; import { OnlinePlayerPopulation } from "./OnlinePlayerPopulation";
@@ -59,6 +57,7 @@ import { DungeonWorldObjects } from "./DungeonWorldObjects";
import { ActivityAssetWarmup } from "./ActivityAssetWarmup"; import { ActivityAssetWarmup } from "./ActivityAssetWarmup";
import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dungeonAssets"; import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dungeonAssets";
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader"; import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { ShelvedGameSceneFeatures } from "@shelvedGameScene";
import { shouldLoadPartyNavigation } from "../game/partyNavigationPolicy"; import { shouldLoadPartyNavigation } from "../game/partyNavigationPolicy";
import { import {
activeDungeonCompletedObjectiveIds, activeDungeonCompletedObjectiveIds,
@@ -348,6 +347,7 @@ export function GameScene() {
<ActivityAssetWarmup <ActivityAssetWarmup
key={`activity-warmup:${sessionRevision}`} key={`activity-warmup:${sessionRevision}`}
navigationLinks={navigationLinks} navigationLinks={navigationLinks}
traversalRamps={manastormAssetPackage ? [] : dungeon.traversalRamps ?? []}
navigationUrl={navigationUrl} navigationUrl={navigationUrl}
sceneAssetUrls={activitySceneAssetUrls} sceneAssetUrls={activitySceneAssetUrls}
onReady={markActivityAssetsReady} onReady={markActivityAssetsReady}
@@ -404,8 +404,7 @@ export function GameScene() {
allowedRuntimeIds={allowedRuntimeIds} allowedRuntimeIds={allowedRuntimeIds}
/> />
<WailingNaralexEvent /> <WailingNaralexEvent />
<ManastormPortal /> <ShelvedGameSceneFeatures />
<GmPlacementMarkers />
<CombatEffects active={!simulationBlocked} safeGraphics={safeGraphics} /> <CombatEffects active={!simulationBlocked} safeGraphics={safeGraphics} />
</> </>
)} )}
+13 -4
View File
@@ -50,6 +50,7 @@ import {
type OriginalCombatMeshGraph, type OriginalCombatMeshGraph,
} from "./originalCombatMeshRuntime"; } from "./originalCombatMeshRuntime";
import { updateCombatEffectAnchorPosition } from "./combatEffectAnchorRegistry"; import { updateCombatEffectAnchorPosition } from "./combatEffectAnchorRegistry";
import { shouldUseGeneratedOriginalFallback } from "./combatEffectRuntime";
import { OriginalWowModel } from "./OriginalWowModel"; import { OriginalWowModel } from "./OriginalWowModel";
import { OriginalParticleGeometry } from "./OriginalParticleGeometry"; import { OriginalParticleGeometry } from "./OriginalParticleGeometry";
import { import {
@@ -99,9 +100,10 @@ const vertexShader = /* glsl */ `
vParticleAlpha = particleAlpha; vParticleAlpha = particleAlpha;
vParticleFrame = particleFrame; vParticleFrame = particleFrame;
vec4 viewPosition = modelViewMatrix * vec4(position, 1.0); vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
float inheritedScale = length(modelViewMatrix[0].xyz);
gl_Position = projectionMatrix * viewPosition; gl_Position = projectionMatrix * viewPosition;
gl_PointSize = clamp( gl_PointSize = clamp(
particleSize * viewportHeight / max(1.0, -viewPosition.z), particleSize * inheritedScale * viewportHeight / max(1.0, -viewPosition.z),
1.0, 1.0,
256.0 256.0
); );
@@ -894,16 +896,23 @@ export const OriginalCombatEffect = memo(function OriginalCombatEffect({
const selection = loaded === null const selection = loaded === null
? null ? null
: selectCombatEffectRender("original", loaded.map((item) => item.asset)); : selectCombatEffectRender("original", loaded.map((item) => item.asset));
const hasOriginalInstantKit = event.phase === "release" && event.instantCast === true
&& (["cast-start", "impact", "aura-start"] as const).some((phase) => (
combatOriginalPhaseAssets({ ...event, phase }).length > 0
));
const resolvedFallback = shouldUseGeneratedOriginalFallback(event, hasOriginalInstantKit)
? fallback
: null;
useEffect(() => { useEffect(() => {
if ( if (
loaded === null loaded === null
|| selection?.kind === "original" || selection?.kind === "original"
|| fallback !== null || resolvedFallback !== null
|| completedMissingOriginalRef.current || completedMissingOriginalRef.current
) return; ) return;
completedMissingOriginalRef.current = true; completedMissingOriginalRef.current = true;
onDone(); onDone();
}, [fallback, loaded, onDone, selection?.kind]); }, [loaded, onDone, resolvedFallback, selection?.kind]);
if (loaded === null || selection === null) return null; if (loaded === null || selection === null) return null;
return selection.kind === "original" ? ( return selection.kind === "original" ? (
@@ -914,7 +923,7 @@ export const OriginalCombatEffect = memo(function OriginalCombatEffect({
loaded={loaded} loaded={loaded}
onDone={onDone} onDone={onDone}
/> />
) : fallback; ) : resolvedFallback;
}, (previous, next) => ( }, (previous, next) => (
previous.event === next.event previous.event === next.event
&& previous.alternateOriginalPhase === next.alternateOriginalPhase && previous.alternateOriginalPhase === next.alternateOriginalPhase
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more