Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46c272f889 | ||
|
|
05b25c4341 | ||
|
|
6dcb1034e5 |
No files matched your search
+7
-2
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -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
@@ -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.
Binary file not shown.
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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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();
|
||||||
})());
|
})());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.31",
|
"versionName": "0.1.34",
|
||||||
"versionCode": 1031
|
"versionCode": 1034
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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 />}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function ShelvedGameBridges() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lootShelvedBoss(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseShelvedGameRuntime(): void {}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { PlayerSession, ShellPhase } from "./types";
|
||||||
|
|
||||||
|
export function ShelvedModeRoute(_props: {
|
||||||
|
readonly phase: ShellPhase;
|
||||||
|
readonly session: PlayerSession | null;
|
||||||
|
}) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -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" } }), {},
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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],
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})));
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user