forked from phenom/healer-man
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed0d2fea3d | ||
|
|
b417b7c3c3 | ||
|
|
361734bc41 | ||
|
|
03a4e3f51c | ||
|
|
40d9f346d8 | ||
|
|
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
|
||||
character model with its default male and female appearances. Android also
|
||||
contains its native bridge. Other GLBs, textures, audio, character/skin packs,
|
||||
dungeons, and Manastorm stages are published by TrueNAS as optional content.
|
||||
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
|
||||
installed content, simulating combat, and saving progress never require the
|
||||
@@ -165,10 +167,13 @@ characters:wow335a-v1
|
||||
characters:ascension-v1
|
||||
characters:runewaker-v1
|
||||
core:combat-media
|
||||
manastorm:<stage-id>
|
||||
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
|
||||
atomically updates the pack rather than downloading every dungeon again.
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ services:
|
||||
CONTENT_DIR: /app/content
|
||||
DATA_DIR: /app/runtime-data
|
||||
HOST: 0.0.0.0
|
||||
HEALER_MAN_ENABLE_SHELVED_MODES: "0"
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
PORT: "4173"
|
||||
SESSION_TTL_DAYS: "30"
|
||||
|
||||
@@ -2212,3 +2212,670 @@ Vite's existing large-chunk advisory remains informational.
|
||||
Effects without source mappings retain fallback. This is representative
|
||||
desktop browser QA, not exhaustive spell-by-spell fidelity or a physical
|
||||
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.
|
||||
|
||||
## 2026-09-12 - Non-blocking players and combat actors
|
||||
|
||||
- Removed party soft separation and crowd-triggered combat repositioning.
|
||||
Companions may overlap or cross one another without push-apart corrections
|
||||
changing their routes. Normal follow formations and role-specific attack
|
||||
ranges remain; a tank already in melee range does not move just because an
|
||||
ally occupies the same position.
|
||||
- Added a shared actor collision layer that interacts only with physical
|
||||
terrain. The player capsule uses this layer. Player controller movement,
|
||||
ground probes, camera sweeps, party direct/route validation, party grounding,
|
||||
and combat ground-position queries use terrain-only filters. Actor body type
|
||||
cannot turn an actor into a floor or movement obstruction. Party members,
|
||||
remote players, mobs, and bosses currently render without physical colliders;
|
||||
any future actor capsule must use the shared actor layer.
|
||||
- Walls, floors, ceilings, dungeon objects, and the Stockades walking ramps
|
||||
retain their terrain collision. Target-selection meshes, combat visibility,
|
||||
damage, and attack-range checks were not removed.
|
||||
- Ten new Rapier regressions cover overlapping actors with no contacts or
|
||||
displacement, movement through fixed/kinematic/dynamic actors (including an
|
||||
overlapped starting position), continued wall/floor collision, and ignoring
|
||||
actors in ground, party-route, and camera queries. A terrain-layer control
|
||||
confirms real actor/terrain contacts are still detected. Stockades shipping-
|
||||
asset regressions now use the live player collision policy and still pass
|
||||
down/up all three ramps without jumping.
|
||||
- Browser smoke pass at 1280 x 720 used offline level-15 Priest Rolecheck with
|
||||
Blood DK, Combat Rogue, Unholy DK, and Frost Mage companions. Inline roster
|
||||
and dungeon screenshots show character bodies, weapons, unchanged visible
|
||||
stairs, and the existing warm lighting. Attack moved companions down from
|
||||
the entrance and out of its view without ROUTE BLOCKED; the tank took aggro
|
||||
and melee/caster combat events continued. Experience rose from 11,098 to
|
||||
13,359. Stop followed by Recall returned the party to the nearby player.
|
||||
Selecting Morvane and casting Power Word: Shield applied the buff successfully.
|
||||
- Live QA was an entrance/trash/recall smoke pass, not a full dungeon clear or
|
||||
a manual traversal of both wings. Actor overlap and passage guarantees are
|
||||
backed by the physics regressions; physical Android and multiplayer QA remain
|
||||
open.
|
||||
- Validation: all 225 Vitest files passed, 1,390 tests passed, and six existing
|
||||
tests remained skipped. The final focused checks passed again after cleanup.
|
||||
Production TypeScript/Vite build, texture/performance audits, static bundle
|
||||
compression, and release-bundle verification passed. Existing chunk-size
|
||||
advisories and deprecated physics initialization warnings remain. No hosted
|
||||
release was published.
|
||||
|
||||
## 2026-09-12 - Individual companion navigation recovery
|
||||
|
||||
- Replaced the old distant, whole-party stuck Recall with an individual
|
||||
navigation watchdog in a separately tested game module. Two seconds without
|
||||
meaningful waypoint progress invalidates that companion's route; five seconds
|
||||
requests a safe recovery landing. Failed replans do not restart the timer,
|
||||
back-and-forth movement cancels forward progress, and dense waypoint chains
|
||||
still count genuine movement around corners.
|
||||
- Recovery prefers a recently visited party/player trail point near the group
|
||||
on the same floor, then falls back to a validated player position. Actual
|
||||
terrain checks require walkable ground, clear companion body space, and a
|
||||
route back to the group. Blocked or invalid landings are not applied. Each
|
||||
companion has an independent ten-second teleport cooldown.
|
||||
- Intentional attack-range holds, casting/channeling, crowd control, Stop,
|
||||
death, inactive simulation, and UI pauses do not accumulate stuck time.
|
||||
Automatic recovery changes only the affected companion's position and routing
|
||||
state; it does not heal, resurrect, reset threat, or teleport other companions.
|
||||
Manual Recall keeps its existing whole-party behavior and party order.
|
||||
- Added regressions for recovery timing, oscillation, replanning, independent
|
||||
cooldowns and member isolation, exclusions, safe-trail/player selection, wall
|
||||
and ceiling rejection, missing floors, and actor-independent landing checks.
|
||||
Shipping Stockades navigation/collision assets validate recovery landings on
|
||||
all three ramps and along approaches to all five boss objectives.
|
||||
- Browser smoke pass at 1280 x 720 used offline Priest Rolecheck and Blood DK,
|
||||
Combat Rogue, Unholy DK, and Frost Mage companions. Inline roster and dungeon
|
||||
screenshots show unchanged character bodies, equipment, stairs, and warm
|
||||
lighting. The first catalog load needed a retry. Attack moved companions down
|
||||
from the entrance without ROUTE BLOCKED, and combat continued with the player
|
||||
stationary. The unattended party eventually died; Stop remained selected
|
||||
after Recall returned the fallen companions without resurrecting them.
|
||||
- The intermittent side-room trap and its automatic recovery were not reproduced
|
||||
live. Recovery triggers and terrain safety are backed by automated tests;
|
||||
extended side-room playtesting, a healed full clear, and physical Android QA
|
||||
remain open. This pass did not modify existing ramp or actor-collision policy.
|
||||
- Validation: all 227 Vitest files passed, 1,416 tests passed, and six existing
|
||||
tests remained skipped. The final 37 focused checks passed after correcting a
|
||||
test-helper type annotation. Production TypeScript/Vite compilation, texture
|
||||
and performance audits, static compression, and bundle verification passed.
|
||||
Existing chunk-size advisories and deprecated physics initialization warnings
|
||||
remain. No hosted release was published.
|
||||
|
||||
## 2026-09-12 - Wall-aware combat pursuit and individual mob recovery
|
||||
|
||||
- Hostile mob actions now require both attack range and terrain line of sight.
|
||||
Losing either cancels a pending hostile cast so pursuit can resume; companion
|
||||
hostile casts also cancel when sight is lost. Self buffs and friendly heals
|
||||
cannot make an otherwise obstructed mob hold an attack position. Proximity
|
||||
aggro also checks sight instead of pulling enemies through nearby walls.
|
||||
- Static enemies, roaming packs, and summoned enemies share separately tested
|
||||
imperative pursuit rules over the already-loaded dungeon navigation surface.
|
||||
Blocked direct approaches use approved routes through doorways. Routes may
|
||||
finish at a reachable attack position only when that position is both in
|
||||
range and visible; off-surface connectors still require body clearance.
|
||||
- Two seconds without meaningful waypoint progress requests a replan. After
|
||||
five seconds, a stuck mob within ten meters of its target may recover to a
|
||||
validated nearby landing on the same floor and within its home leash. Ground,
|
||||
body clearance, route connectivity, attack range, and sight must all pass.
|
||||
This teleport preserves health and threat and has a ten-second cooldown.
|
||||
- Distant, wrong-floor, out-of-leash, or unsafe near-target recovery requests
|
||||
instead reset only that mob's combat state and restore its health. A validated
|
||||
home landing is preferred; otherwise it evades and navigates home without a
|
||||
blind teleport. Ten seconds of evade prevents immediate proximity, damage,
|
||||
or taunt re-engagement. Dead enemies are never resurrected. Casting, control,
|
||||
pauses, inactive simulation, and non-authoritative online clients do not
|
||||
accumulate navigation failures or trigger automatic recovery.
|
||||
- Regression coverage includes range-plus-sight attack/cast gating, immediate
|
||||
cast cancellation, doorway detours, recovery timing and safety, reset/member
|
||||
isolation, cooldown bypass protection, and near-teleport health/threat
|
||||
preservation. Shipping Stockades meshes independently verify real wall
|
||||
occlusion in both directions and clear sight across all three stair ramps.
|
||||
Pursuing mobs traverse each actual ramp uphill and downhill without false
|
||||
teleport/reset recovery. Existing party and player ramp checks still pass.
|
||||
- Browser smoke pass at 1280 x 720 used offline level-16 Priest Rolecheck and
|
||||
Blood DK, Combat Rogue, Unholy DK, and Frost Mage companions. Power Word:
|
||||
Shield applied to Morvane; Attack moved companions down from the entrance
|
||||
and out of its view without ROUTE BLOCKED, and Defias melee/cast events and
|
||||
tank damage followed. Stop plus Recall returned all four living companions
|
||||
to the nearby player while retaining Stop. Inline screenshots show character
|
||||
bodies, equipment, unchanged visible steps, and the existing warm lighting.
|
||||
The browser console had no errors and the existing physics initialization
|
||||
deprecation warning only.
|
||||
- Live QA was a short entrance/combat/recall smoke test, not a healed full clear
|
||||
or a manually reproduced side-room wall trap. Wall-blocked pursuit and mob
|
||||
recovery triggers are backed by automated tests; extended side-room combat,
|
||||
physical Android, and live multiplayer QA remain open. Existing lighting,
|
||||
ramp assets, and non-blocking actor collision policy were not changed.
|
||||
- Validation: all 229 Vitest files passed, 1,444 tests passed, and six existing
|
||||
tests remained skipped. Final production TypeScript/Vite compilation, texture
|
||||
and performance audits, static compression, and bundle verification passed.
|
||||
Existing chunk-size advisories remain. No hosted release was published.
|
||||
|
||||
## 2026-09-12 - HoTs and regeneration cannot heal corpses
|
||||
|
||||
- Reproduced the reported death-with-HoT state through lethal damage followed
|
||||
by overdue Rejuvenation ticks; the original implementation restored health
|
||||
while retaining the death animation. Separate failures confirmed passive
|
||||
spirit/equipment regeneration, ground healing, triggered healing procs, and
|
||||
a lingering damage tick's level-up refill could also restore corpse health.
|
||||
- Kept the fix in combat simulation rather than scene movement. Ordinary
|
||||
player healing in mutable combat work now uses a shared living-target guard;
|
||||
passive regeneration preserves zero health. Timed effects validate their
|
||||
target before ticks and procs, discard HoTs on dead targets even before the
|
||||
next tick, and retain HoTs on living allies when their caster dies. Newly
|
||||
applied actor HoTs also reject dead targets. Ground healing skips corpses
|
||||
while continuing to heal living teammates. Experience may still be awarded
|
||||
after death, but its level-up refill no longer implicitly resurrects.
|
||||
- Explicit player and actor-based party resurrection remain available and
|
||||
synchronize restored actor health/alive state. Party resurrection also
|
||||
clears the death animation using the existing ability-cancel event. Normal
|
||||
checkpoint respawn still restores health and clears lingering effects.
|
||||
The existing player movement gate remains unchanged and blocks zero health.
|
||||
- Fifteen new regressions cover the original self-HoT failure, actual Priest
|
||||
Renew, overdue/future ticks, explicit companion-to-player HoTs and their
|
||||
healing logs, dead allies, regeneration, ground healing, proc healing,
|
||||
post-death level rewards, living-target healing, resurrection, and respawn.
|
||||
The final focused combat/death/support checks passed 101 tests; the full
|
||||
suite passed all 230 files and 1,459 tests with six existing skips.
|
||||
- No live corpse-movement replay or physical Android check was performed in
|
||||
this pass. Evidence is combat-state regression coverage, not a new visual
|
||||
claim. Character rendering, camera/input, dungeon assets, lighting, and
|
||||
navigation policy were not modified. No hosted release was published.
|
||||
|
||||
## 2026-09-12 - Selectable resurrection checkpoints and fallen NPC recovery
|
||||
|
||||
- Normal dungeons now offer the entrance plus every defeated boss as named
|
||||
resurrection destinations. Choices use authored boss locations, not corpse
|
||||
coordinates, and unlock from actual defeated actors or durable encounter
|
||||
objective completion. Alive/script-despawned bosses and invalid destination
|
||||
IDs cannot grant a checkpoint. Boss-wipe resets retain completed checkpoints;
|
||||
a fresh dungeon run clears its progress.
|
||||
- The player defeat prompt includes a destination chooser. Respawning at the
|
||||
entrance or a cleared boss retains defeated enemies and run progress while
|
||||
resetting the current pull and restoring the party through the existing
|
||||
respawn lifecycle. Legacy callers without a choice retain saved-spawn behavior.
|
||||
- A contextual `Rez dead members` button and matching destination chooser appear
|
||||
below the party frames whenever an NPC teammate is dead. Only fallen NPCs are
|
||||
revived, at full health, into their formation slots at the selected checkpoint.
|
||||
Living NPCs, the player's health/location, current enemies, and party orders
|
||||
are not reset. Lingering timed effects on revived NPCs are removed without
|
||||
removing living allies' HoTs. Manastorm charges and remote human resurrection
|
||||
behavior are unchanged; non-authority online clients cannot mutate NPC revival.
|
||||
- Living, engaged bosses disable the NPC chooser/button with an explanation;
|
||||
the same restriction is checked again when the action executes. Trash combat
|
||||
does not block it. Individual return tasks use approved navigation toward the
|
||||
player's current location, including while the party is on Stop, then resume
|
||||
the current order. Returning members do not cast/taunt from the checkpoint or
|
||||
skew the engaged squad's target-selection center. Existing validated stuck
|
||||
recovery remains available. Recall, death, actor unmount, and run reset cancel
|
||||
pending return tasks. Scene relocation clears the corpse animation explicitly.
|
||||
- Live localhost Stockades screenshots at 1280x720 showed the panel with a dead
|
||||
Morvane tank, an enabled entrance chooser/button during trash combat, and the
|
||||
tank restored to 1707 health at the entrance after clicking while Stop remained
|
||||
selected. The button disappeared when no NPC was dead; living Thorne continued
|
||||
taking damage rather than receiving a party-wide refill. At 384x844 both native
|
||||
chooser and button remained within the viewport, about 176px wide and 44px high.
|
||||
The narrow screenshot capture was backend-scaled, so DOM bounds supplemented
|
||||
the visual check. Normal viewport sizing was restored and the temporary tab
|
||||
and development server were closed.
|
||||
- Twenty-nine new regressions cover checkpoint unlocking/retention, invalid and
|
||||
locked choices, player checkpoint respawn, boss/trash restrictions, selective
|
||||
NPC revival/effect cleanup, authority/Manastorm isolation, Stop/rejoin behavior,
|
||||
wall-aware return navigation, registry lifecycle, and UI visibility/placement.
|
||||
The shipping Stockades navigation/collision test also simulates all four NPC
|
||||
formation slots walking from the entrance and every boss checkpoint back to
|
||||
the entrance through approved corridors, without requiring stuck recovery.
|
||||
Final Vitest validation passed all 234 files and 1,488 tests, with six existing
|
||||
skips. No live boss-kill destination-selection, distant-player return replay,
|
||||
multiplayer session, or physical Android check is claimed in this pass.
|
||||
- Browser logs contained no errors; Rapier initialization and KTX2-loader warnings
|
||||
occurred during development hot reloads. Character assets, camera/input,
|
||||
dungeon geometry, and lighting were not edited. Final production TypeScript/Vite
|
||||
compilation, static compression, and release-bundle verification passed; texture
|
||||
and environment-performance audits also passed during prebuild. Existing build
|
||||
chunk advisories remain. No hosted release was published.
|
||||
|
||||
## Optional DPS meter — September 12, 2026
|
||||
|
||||
- Added a compact party DPS meter with current/last-fight damage, class-colored
|
||||
rankings, and a shared fight timer. It consumes the lossless combat event
|
||||
stream and counts effective damage, including pets/summons under their owner.
|
||||
Completed fights remain visible until the next pull; encounter resets clear
|
||||
the meter. Hiding it does not stop recording. Local menu/background pauses
|
||||
do not dilute DPS.
|
||||
- Automatic placement uses the PC lower-left corner and the Thor companion
|
||||
display. The UI editor supports visibility, dragging/keyboard nudges, width,
|
||||
and screen selection. These settings participate in saved layouts, all six
|
||||
template slots, and export/import, including migration of older UI files.
|
||||
Dedicated companion snapshots carry meter data and draft placement; lower
|
||||
display movement commands update the authoritative editor draft.
|
||||
- Live localhost Stockades checks at 1280x720 showed increasing party damage
|
||||
and readable bars in the PC lower-left corner. Resizing from 230 to 240 pixels
|
||||
worked; dragging moved the handle from approximately (9, 509) to (269, 400).
|
||||
Saving visibility off hid the meter, and reopening the editor retained that
|
||||
setting while damage continued accumulating. Default position/width and
|
||||
visibility were restored after this check.
|
||||
- In Thor preview, the meter appeared only inside the 620x540 companion surface.
|
||||
Populated bars remained clear of the party strip, vital controls, and action
|
||||
bar. With the main editor open, dragging on the bottom screen moved its handle
|
||||
from approximately (526, 322) to (661, 369) within companion bounds. Those
|
||||
preview movement edits were canceled. Screenshots are saved in
|
||||
`artifacts/dps-meter/pc-default.png`, `thor-default.png`, and
|
||||
`thor-editor-moved.png`. Full-page capture scaled incorrectly in the browser
|
||||
backend, so the Thor screenshots use the scrolled normal viewport.
|
||||
- All 47 targeted tests passed across seven files, including damage accounting,
|
||||
ownership, reset/pause behavior, UI migration/persistence, and dedicated
|
||||
companion publication/unsubscription. The production build and release
|
||||
checks passed. Browser error logs were empty. Physical Thor hardware and a
|
||||
multiplayer session were not exercised; the new per-party totals consume
|
||||
this client's combat stream. No hosted release was published.
|
||||
|
||||
## Player-only revival during ongoing party combat — September 12, 2026
|
||||
|
||||
- In normal offline dungeons, a defeated player with living companions and an
|
||||
engaged, living enemy can revive at the entrance or an unlocked boss checkpoint
|
||||
without resetting the pull or relocating/restoring companions. The player-only
|
||||
path advances the relocation revision, not the party or encounter reset signals.
|
||||
Recall remains an explicit, optional command. Full-wipe recovery is unchanged;
|
||||
Manastorm's shared-charge resurrection remains separate.
|
||||
- Player recovery restores health/resources, clears corpse animation, casts,
|
||||
control, shields, harmful player auras, and player-targeted periodic effects.
|
||||
It preserves NPC/enemy actor snapshots, positions, health, targets, casts,
|
||||
periodic effects on other actors, and ongoing cooldowns. The existing checkpoint
|
||||
chooser is retained; its defeat-prompt copy explains that companions keep fighting.
|
||||
- Nine dedicated regressions cover trash/boss combat, selected checkpoints,
|
||||
unchanged companion/enemy snapshots and Recall revisions, continued simulation,
|
||||
periodic-effect isolation, full-wipe fallback, canonical actor health, and invalid
|
||||
requests. A prompt-rendering regression checks the optional-Recall explanation.
|
||||
Final Vitest validation passed 239 files and 1,511 tests, with six existing skips.
|
||||
Test fixtures use a fixed combat clock and respect surviving crowd-control timers.
|
||||
- Localhost Stockades smoke testing at 1280x720 showed the rendered dungeon,
|
||||
party frames, unchanged Recall control, and an active party fight with increasing
|
||||
damage and declining tank health. Session screenshots captured the entrance and
|
||||
combat HUD. Startup navigation initially timed out and recovered after reload;
|
||||
browser error logs were empty after recovery. A live player-death/mid-fight
|
||||
checkpoint-revival replay was not achieved, so spatial proof of that exact
|
||||
sequence is not claimed. Multiplayer and physical Thor hardware were not tested.
|
||||
- Production TypeScript/Vite compilation, static compression, release-bundle
|
||||
verification, and prebuild texture/performance audits passed. Existing chunk
|
||||
advisories remain. No hosted release was published. Character rendering,
|
||||
camera/input, dungeon geometry, lighting, and unrelated in-progress party damage
|
||||
changes were not edited for this request.
|
||||
|
||||
## RoM dungeon entry and level-up crash — September 12, 2026
|
||||
|
||||
- Reproduced in the local PC browser with an offline RoM Human Priest. Stockades
|
||||
loaded at level 1, then the first level-up unmounted the entire app. The console
|
||||
recorded `Unsupported party specialization: rom-champion:Primary` from
|
||||
`partyStore.syncLevel` through `PartyBridge`. Initial party creation already
|
||||
skipped WoW talents for RoM classes; level synchronization did not. The same
|
||||
call also runs during entry when the saved character level replaces the
|
||||
previous combat level.
|
||||
- Level synchronization now retains empty, frozen talent ranks for RoM
|
||||
companions, matching party creation. Other classes still receive their
|
||||
existing talent plans. Two regressions failed with the original exception
|
||||
before the fix, then passed. They cover all ten RoM companion classes, all
|
||||
three player roles, levels 1/2/10/70 and downward synchronization, stable
|
||||
identities, fallen companions, and living health/resource ratios.
|
||||
- After reload, the same saved level 2 Priest entered Stockades successfully,
|
||||
fought with the RoM party, and reached level 3 without losing the scene. The
|
||||
same character then entered Forsaken Abbey and progressed from level 3 to 6.
|
||||
At the 74-second check after Attack, the scene and HUD remained present,
|
||||
combat continued, and the renderer reported about 60 FPS. No new browser
|
||||
errors or context-loss messages appeared. Rapier initialization and multiple
|
||||
active KTX2-loader warnings occurred during these development sessions.
|
||||
- Screenshots and timestamped browser evidence are in
|
||||
`artifacts/rom-dungeon-level-sync/`. Screenshot capture retained the browser
|
||||
backend's existing scaling issue; DOM progression and renderer counters
|
||||
supplemented the rendered dungeon/body/equipment checks. This was an offline
|
||||
PC smoke test, not full dungeon completion, multiplayer, or physical Android
|
||||
validation. The temporary desktop viewport was restored and test tab closed.
|
||||
- All 54 focused party, talent-plan, and RoM combat tests passed. The production
|
||||
TypeScript/Vite build, prebuild texture/performance audits, compression, and
|
||||
release-bundle verification passed with existing chunk advisories. The final
|
||||
release-candidate check includes untracked changes. No hosted release was
|
||||
published. Existing combat, death/respawn, AI, and HUD work was preserved.
|
||||
|
||||
## NPC party damage — September 12, 2026
|
||||
|
||||
- Reproduced Garrick, Arms, and Fury dealing zero damage in level 5/20
|
||||
sustained fights: Rage starts at zero, companion Rage regeneration is
|
||||
disabled, and the ability loop had no regular weapon swings. Priests,
|
||||
hunters, and warlocks also dealt zero because they repeatedly refreshed
|
||||
their DoTs before the first tick could fire.
|
||||
- Added independent weapon swings for melee companions and hunters, with
|
||||
Rage earned from successful hits. Swings respect range, visibility, line
|
||||
of sight, facing, control, casting, rejoining, and party orders. Maintained
|
||||
DoTs and debuffs now allow other abilities to run between applications;
|
||||
utility taunts/interrupts/control require a useful combat context.
|
||||
- Self-centered area attacks now validate their radius and hit around the
|
||||
caster. Companion direct and periodic amounts use the existing spell
|
||||
scaling calculation, including spell base damage and equipment bonuses.
|
||||
- The new audit runs 30-second fights for all 26 WoW party templates at
|
||||
levels 1, 3, 5, 20, and 80. Every template produces positive damage from
|
||||
actual abilities, beyond weapon swings, with resources within bounds.
|
||||
Separate regressions cover zero-Rage short pulls, swings during the GCD,
|
||||
Stop, Thunder Clap targeting, and zero-coefficient spell base damage.
|
||||
- In an offline PC Stockades run, the saved level 3 Priest commanded Attack
|
||||
without player attacks. Garrick dealt 6 damage in the first three seconds
|
||||
and 80 damage by approximately 20 seconds; Kael dealt 147, Korra 220, and
|
||||
Ash 146 at that check. The party defeated enemies and the player reached
|
||||
level 4. The saved party's Garrick is a Tauren; the class/role fix also
|
||||
applies to Dwarf Garrick. This was a PC combat smoke test, not physical
|
||||
Thor, multiplayer, or a full dungeon completion test.
|
||||
- `artifacts/npc-damage/stockades-warrior-damage.png` shows the rendered
|
||||
dungeon, player body/equipment, and active DPS rows for both warriors.
|
||||
A paused accessibility snapshot records totals. No browser errors were
|
||||
reported. The temporary browser tab was closed after verification.
|
||||
- All 310 focused combat, party, death/respawn, and DPS tests passed. The
|
||||
production build, texture/performance audits, compression, bundle
|
||||
verification, and release candidate check passed. Existing concurrent
|
||||
party-level synchronization, death/respawn, catalog, and server changes
|
||||
were preserved.
|
||||
|
||||
## Dungeon release stages and issue reports — September 12, 2026
|
||||
|
||||
- The complete 84-dungeon browser now sorts Full Release, Beta, then Alpha,
|
||||
alphabetically within each stage. Stormwind Stockade is the only Beta;
|
||||
the other 83 entries are Alpha. WoW/RoM category tabs remain available.
|
||||
- Dungeon pause menus can submit a nonempty issue comment of up to 500
|
||||
characters to the shared SQLite report database. Connected guest players
|
||||
can submit reports too. New reports are stamped Submitted by the server;
|
||||
comments remain in the form after a failed network request.
|
||||
- View Reports on the main menu shows all reports, including Closed, by
|
||||
default. Status and Dungeon filters combine, and Clear filters restores
|
||||
the complete list. GM accounts can select Submitted, In-Progress,
|
||||
Testing Fixes, or Closed, use Close report, and reopen closed reports.
|
||||
The server rejects non-GM updates and stale revisions.
|
||||
- Dungeon rows with Submitted, In-Progress, or Testing Fixes reports have
|
||||
an orange border and a top-right open-report count. Closing the final
|
||||
open report removes both treatments. Counts refresh when opening the
|
||||
browser, returning to the app, and every 30 seconds while visible.
|
||||
- A headless Edge browser exercised an actual Stockades entry, its pause
|
||||
report form, a 500-character submission, whitespace rejection, failed
|
||||
submission/retry, the shared player report list, all GM status changes,
|
||||
combined filters, and badge removal. Keyboard Back and controller filter
|
||||
navigation/GM status changes passed. No browser page errors occurred.
|
||||
- Visually inspected the form, orange dungeon badge, player report list,
|
||||
and GM report screens at 1280×800 and 390×844. Long comments wrap and the
|
||||
mobile report screen has no horizontal overflow. Evidence and the test
|
||||
result are in `playtest-artifacts/dungeon-reports/`. This validates the
|
||||
reporting flow; it is not an exhaustive dungeon completion or physical
|
||||
Android/Thor playtest.
|
||||
- All 43 focused report, catalog, store, shell, and display tests passed
|
||||
(five existing shelved-mode tests skipped), along with all 61 server tests.
|
||||
The 32 party AI/store/sustained-damage checks also passed after preserving
|
||||
immutable cast metadata across companion channel updates to resolve three
|
||||
concurrent combat type-check errors. A separate, in-progress expanded
|
||||
class-damage audit failed its level-1 CoA player case for Witch Doctor
|
||||
and Sun Cleric; those combat behaviors are outside the reporting change.
|
||||
- The final production TypeScript/Vite build, prebuild texture/performance
|
||||
audits, compression, and release-bundle verification passed with existing
|
||||
chunk advisories. `npm run release:check` passed after a transient Windows
|
||||
Git crash, including untracked files. No hosted release was published.
|
||||
|
||||
## CoA and RoM class damage audit — September 12, 2026
|
||||
|
||||
- Ran 342 sustained, 30-second combat scenarios: all 21 CoA classes as player
|
||||
characters and synthetic NPC slots at levels 1, 2, 4, 9, 20, and 80; all 10
|
||||
RoM classes in both runtimes at levels 1, 20, and 70; and the actual shipped
|
||||
RoM companion templates with their assigned roles and starting resources at
|
||||
those three levels. All scenarios passed. CoA expeditions currently use WoW
|
||||
companion templates, so the synthetic CoA slots exercise class execution
|
||||
without implying that a CoA companion roster has been added.
|
||||
- Found and fixed companion spacing based only on WoW class names. RoM and
|
||||
CoA casters now use their class metadata and learned offensive spell ranges;
|
||||
short-range attacks remain reachable. A thrown summon such as Caltrops does
|
||||
not force an early Witch Hunter away from its melee attacks. Hunter aliases
|
||||
also use ranged weapon stats and projectile presentations for ranged swings.
|
||||
- Fixed Dawn Blade's melee classification: a five-yard hunter-archetype attack
|
||||
previously counted as ranged and could not initiate the player's weapon
|
||||
swings needed to generate Rage. The companion AI now rejects unlearned CoA
|
||||
tree entries and resolves the rank actually learned.
|
||||
- Fixed channel execution in both runtimes. Normal amounts are distributed
|
||||
across five pulses, while guaranteed periodic trigger payloads fire per
|
||||
pulse. Radiant Flame's imported periodic damage now happens during the
|
||||
channel. Focused tests verify five damaging pulses and no duplicate amount
|
||||
on completion. Conditional proc payloads are not treated as guaranteed hits.
|
||||
- Corrected the RoM vocation import to use the player's `FixArgTableID = 1`
|
||||
record, as selected in the source client's level-up calculation. The previous
|
||||
adjacent table made Champion and Warlock stats grow exponentially; a level-20
|
||||
Champion weapon hit reached a raw 704 billion damage in the initial audit.
|
||||
The corrected Champion instead sustained attacks and damaging abilities for
|
||||
the full test. Source record sentinels and all ten classes' growth bounds are
|
||||
covered by regression checks; this is a source-table correction, not a balance
|
||||
multiplier adjustment.
|
||||
- Corrected tooltip fallback damage on retaliation auras. Drakkari Barrier
|
||||
stays friendly and deals its authored damage through its damage-taken proc.
|
||||
The imported first offensive unlocks are Witch Doctor 9, Sun Cleric 4, and
|
||||
Primalist 2. The player audit requires offensive ability damage once unlocked;
|
||||
these classes have no learned offensive cast at level 1. Their NPC weapon
|
||||
attacks deal damage at level 1. This supersedes the preliminary player
|
||||
failures mentioned in the reporting notes above.
|
||||
- Live PC check: created the isolated offline test character Romaudit, entered
|
||||
Forsaken Abbey as a healer, and issued Attack. At 26 seconds the meter showed
|
||||
Ilyen (Mage) 492 damage, Morgha (Warlock) 370, Veya (Rogue) 217, and Bronn
|
||||
(Champion tank) 148. All four NPCs dealt damage; visible enemy effects also
|
||||
confirmed Heavy Bash, Electrocution, Weakening Weave Curse, and Shadowstab.
|
||||
No player healing was applied during this bounded damage check, and the tank
|
||||
eventually died. This was not a dungeon-completion test.
|
||||
- Visually verified the loaded roster body/staff and in-dungeon player
|
||||
body/staff. The DPS meter remained readable at the PC bottom-left default.
|
||||
The first attempt used the downloadable shelved preview without its content
|
||||
server; the final captures use the full bundled development asset set and
|
||||
recorded no browser errors. No physical Ayn Thor device was tested here.
|
||||
- Evidence: `artifacts/class-damage-audit/report.json`,
|
||||
`level-20-damage.csv`, `rom-roster.png`, `rom-dungeon-start.png`,
|
||||
`rom-party-damage.png`, and `rom-browser-errors.json`. Training-target totals
|
||||
demonstrate execution and sustained damage; they are not class-balance
|
||||
rankings or exhaustive coverage of every talent build and dual-class pair.
|
||||
- All 434 focused combat, party, CoA, RoM, DPS-meter, stat, and avatar tests
|
||||
passed, along with the native vocation-import regression. The production
|
||||
TypeScript/Vite build and release-bundle verification passed. The final
|
||||
release candidate check includes the new tests and generated catalogs.
|
||||
|
||||
## RoM dungeon enemy durability — September 13, 2026
|
||||
|
||||
- Reproduced Barren Caves' low health with a RoM Priest and the four shipped
|
||||
companions. Tested 108 combinations: 12 party seeds at levels 1, 2, 4, 8, 9,
|
||||
20, 40, 70, and 80. The old fallback had 94 health at level 1 and 1,832 at
|
||||
level 80; kills took 0–1.9 seconds from the first damaging NPC hit.
|
||||
- Normal RoM dungeons now use a party-health baseline of
|
||||
`1000 + 100 * enemyLevel + 8 * enemyLevel * enemyLevel`, with authored
|
||||
creature/boss multipliers applied separately. This is explicit Healer Man
|
||||
balancing because native RoM creature health tables are not packaged.
|
||||
Barren Caves' Anglo retains his 7.5 multiplier and one-level advantage.
|
||||
- Loaded RoM creatures receive the new health when the character levels up.
|
||||
Re-registration preserves wounds as a health percentage and keeps corpses
|
||||
dead. Enemy attack damage also rebases on a level change instead of retaining
|
||||
its original low-level value. Regression checks cover these transitions.
|
||||
- With the new health, the 108 isolated party-damage pulls lasted 3–17.5
|
||||
seconds. A separate pull exercised actual enemy attacks and Priest healing
|
||||
before the enemy died, with all companions surviving. These are opening
|
||||
pulls with baseline equipment, rather than an audit of every geared build.
|
||||
- Live PC check at 1280×720: created the offline Human Priest Barrenaudit and
|
||||
entered Barren Caves as Healer. A level-1 Cave Sand Bug had 1,108 health.
|
||||
At 10 seconds it still had 374 health, with 734 party damage on the meter.
|
||||
The completed first fight showed 14 seconds and 1,108 total damage:
|
||||
Morgha 370, Ilyen 369, Maerwen 289, and Cael 80. Urgent Heal then restored
|
||||
Maerwen to 407/407 while the next pull was active. No browser errors were
|
||||
captured during that bounded combat check.
|
||||
- Concurrent navigation edits triggered development hot reloads around the
|
||||
playtest; the captures above were taken during uninterrupted combat. This
|
||||
check did not complete the dungeon or use physical Ayn Thor hardware.
|
||||
Restored the original Hudtest roster selection, closed the test tab, and
|
||||
stopped the temporary development servers.
|
||||
- Evidence: `artifacts/rom-dungeon-health/report.json`,
|
||||
`barren-caves-health.png`, `barren-caves-combat.png`, `barren-caves-healed.png`,
|
||||
`live-combat-dom.txt`, and `browser-errors.json`.
|
||||
- Validation: all 407 focused combat, party, CoA, RoM, DPS-meter, stat, and
|
||||
dungeon-scaling tests passed across 52 files. The final production
|
||||
TypeScript/Vite build and release-bundle verification passed.
|
||||
|
||||
## 2026-09-13 - WoW dungeon lighting and stair fleet pass
|
||||
|
||||
- Applied environment-specific readable lighting to all 54 WoW dungeon entries,
|
||||
using the reviewed Stockades ambient/hemisphere fill as the baseline. Stone,
|
||||
cavern, fire, arcane, crypt, and outdoor profiles preserve distinct coloration.
|
||||
Stockades' existing presentation/three ramps and RuneWaker lighting are unchanged.
|
||||
- Scanned every shipped WoW collision package, including authored Wailing
|
||||
Caverns' actual source assets. Added 347 verified walking proxies across 22
|
||||
other dungeons and eight filled navigation portal bridges in Uldaman. Rendered
|
||||
steps and source visual/collision/navigation GLBs remain unchanged.
|
||||
- Added package-identity gates and a compiler-independent shared runtime adapter.
|
||||
Collision preparation spatially indexes flights; rejected candidates cannot
|
||||
supply geometry needed by accepted proxies. Existing navigation validation,
|
||||
content, encounter, and availability gates were not promoted to green.
|
||||
- Shipping-asset regressions independently pass all 347 new proxies through
|
||||
prepared collision and the live NPC segment validator uphill/downhill at the
|
||||
center and two interior lateral lanes. Exact portal matching and all existing
|
||||
Stockades player/NPC/wall tests pass.
|
||||
- The game-playtest visual viewer uses shipped meshes and runtime lighting and
|
||||
material policies without touching saved character/party state. At 1280x720,
|
||||
Uldaman's old/new-fill comparison showed clearer stonework; Wailing Caverns
|
||||
retained green, readable rock; Mechanar retained cool arcane interiors;
|
||||
Scholomance's visible entrance steps/masonry remained intact and readable.
|
||||
Blood Furnace's dark source textures remained visible at its wall-facing
|
||||
entrance viewpoint. Durnholde's visual load completed without console errors.
|
||||
- Zul'Farrak entrance/boss views and Durnholde's exterior boss view showed
|
||||
geometry/terrain/viewpoint gaps, recorded for separate investigation. This is
|
||||
not exhaustive staircase certification: curved/short/heavily-decimated flights,
|
||||
rejected candidates, outer stair edges/landing lips, complete dungeon routes,
|
||||
full clears, and physical Android QA still require individual review.
|
||||
- Evidence/coverage: `dungeon-pipeline/WOW_ENVIRONMENT_REVIEW.md`,
|
||||
`src/game/generated/wowDungeonStairs.generated.json`, and screenshots in
|
||||
`artifacts/wow-dungeon-environment/`. No hosted release was published.
|
||||
- Validation: 247 Vitest files passed, 1,599 tests passed, six existing tests
|
||||
skipped. Production TypeScript/Vite build, texture/performance audits, bundle
|
||||
compression/verification, and release candidate check passed. Existing physics
|
||||
initialization and bundle-size/import advisories remain.
|
||||
|
||||
## 2026-09-13 - Dungeon completion results and run leaderboards
|
||||
|
||||
- Fixed missing RuneWaker completion bookkeeping for the 21 dungeons with
|
||||
configured bosses. Barren Caves now awards Anglo's objective on his actual
|
||||
death and announces the completed run. Bossless placeholder entries do not
|
||||
fabricate a completion.
|
||||
- Run timing starts when the dungeon is playable, survives wipes, pauses, and
|
||||
saved resumes, and freezes at the final-boss objective. Older checkpoints
|
||||
without a recorded start time still announce completion but remain unranked.
|
||||
- The results dialog shows the final-boss time, final-clear and full-clear
|
||||
placement, party classes/levels, and defeated bosses. Stay and loot, reopening
|
||||
results, and Return to main menu were verified through the live UI. Returning
|
||||
clears the completed checkpoint.
|
||||
- Replaced the dungeon picker's minimap with persistent completion-time
|
||||
leaderboards. Final Boss clears includes all qualifying runs; Full dungeon
|
||||
clears requires every available boss for the selected difficulty. Expanded
|
||||
entries show each party member and boss status. Normal/heroic rankings are
|
||||
separate; disconnected results use an explicitly labeled device leaderboard.
|
||||
- An actual combat integration check fought Barren Caves' level-2 Anglo with
|
||||
9,240 health using NPC ability selection, enemy attacks, and Priest healing.
|
||||
NPCs dealt 9,239 damage after a one-damage player tag; healing totaled 441.
|
||||
The final death awarded completion at 84.1 seconds with all companions alive.
|
||||
- Manual browser combat confirmed NPC damage and healing on Barren Caves
|
||||
trash. The live results check restored the integration fight's completed
|
||||
checkpoint through an isolated local QA account: 01:24.1 ranked #1 of three
|
||||
seeded local-server entries in both filters. This was not a manual browser
|
||||
full clear and no production leaderboard entries were published.
|
||||
- PC results and expanded final/full leaderboard layouts were visually
|
||||
reviewed at 1280x720. The built-in Thor preview displayed the leaderboard,
|
||||
filters, and role-selection control within its 960x540 main surface. Compact
|
||||
480x360 DOM bounds kept the dialog footer inside the viewport; the in-app
|
||||
browser's viewport-override screenshot scaled incorrectly, so that capture
|
||||
does not establish compact visual quality. Physical Thor hardware was not used.
|
||||
- Evidence: `artifacts/dungeon-runs/combat-completion.json`, `results-world.png`,
|
||||
`leaderboard-final-expanded.png`, `leaderboard-full-expanded.png`, and
|
||||
`leaderboard-thor.png`. The bounded final browser check captured no errors.
|
||||
- Validation: 70 completion, checkpoint, encounter, catalog, client, and UI
|
||||
tests plus the real-combat integration test passed. The 432-test focused
|
||||
combat/party/class/health/input regression suite and 18 server tests passed.
|
||||
Production build, final TypeScript check, and release candidate check passed.
|
||||
@@ -41,6 +41,11 @@ npm test
|
||||
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
|
||||
behavior, test workflow, and the explicit source-gap ledger are documented in
|
||||
[`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
|
||||
runtime, interface, starter equipment, Wailing Caverns starter content, and a
|
||||
basic male/female Human character model with its default appearance. Large
|
||||
character and skin libraries, additional dungeons, Manastorm stages, and the
|
||||
original spell-effects and sound pack are optional downloads.
|
||||
character and skin libraries, additional dungeons, and the original
|
||||
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
|
||||
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
|
||||
boundary as the production browser build. Optional dungeons, character/skin
|
||||
packs, Manastorm stages, and original spell media are downloaded into
|
||||
app-private storage. Installed content and local saves remain playable without
|
||||
packs, internal environment dependencies, and original spell media are
|
||||
downloaded into app-private storage. Installed content and local saves remain playable without
|
||||
a server connection and survive APK updates. See `CONTENT_UPDATES.md` for the
|
||||
update contract and `DEPLOYMENT.md` for TrueNAS publishing and signed Gitea
|
||||
releases.
|
||||
|
||||
@@ -657,6 +657,7 @@ services:
|
||||
CONTENT_DIR: /app/content
|
||||
DATA_DIR: /app/runtime-data
|
||||
HOST: 0.0.0.0
|
||||
HEALER_MAN_ENABLE_SHELVED_MODES: "0"
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
PORT: "4173"
|
||||
SESSION_TTL_DAYS: "30"
|
||||
|
||||
+14
-5
@@ -1,9 +1,11 @@
|
||||
# Lossless game content packaging
|
||||
|
||||
`npm run web:prepare` and `npm run android:prepare` include all 84 regular
|
||||
dungeon packs, the existing core packs, and the dungeons' dependency closure.
|
||||
Manastorm packs keep their optional install flag even when a regular dungeon
|
||||
uses the same map. Manastorm-only maps and extra character packs stay optional.
|
||||
`npm run web:prepare` and `npm run android:prepare` include exactly five packs:
|
||||
`core:ui`, `core:equipment`, `core:character-basic`, `core:runtime`, and
|
||||
`dungeon:wailing-caverns`. The complete publication catalog retains all 54
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
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.
|
||||
@@ -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;
|
||||
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
|
||||
bundled-content server tests, `npm run build`, Android compilation, and a live
|
||||
roster plus dungeon screenshot check. Run `npm run release:check` before handoff.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# WoW dungeon lighting and stair review - 2026-09-13
|
||||
|
||||
## Implemented coverage
|
||||
|
||||
All 54 campaign WoW dungeons now receive environment-specific lighting using
|
||||
Stockades' reviewed ambient/hemisphere fill as the baseline. Stone, cavern,
|
||||
fire, arcane, crypt, and outdoor profiles preserve atmosphere without applying
|
||||
the old dark expansion palettes. Stockades' existing presentation and three
|
||||
reviewed stair proxies remain unchanged. RuneWaker environments are excluded.
|
||||
|
||||
The fleet scan produced 347 additional collision-verified walking proxies in
|
||||
22 dungeons, and eight filled Recast boundary bridges in Uldaman. This makes
|
||||
350 walking proxies including the three existing Stockades flights. Visual
|
||||
stairs remain steps: source visual/collision/navigation GLBs were not modified.
|
||||
The shared prepared collision is used by players, companions, and enemies.
|
||||
|
||||
| Dungeon | Additional walking proxies | Additional navigation bridges |
|
||||
| --- | ---: | ---: |
|
||||
| Shadowfang Keep | 41 | 0 |
|
||||
| Blackfathom Deeps | 5 | 0 |
|
||||
| Uldaman | 5 | 8 |
|
||||
| Gnomeregan | 4 | 0 |
|
||||
| Sunken Temple | 2 | 0 |
|
||||
| Scarlet Monastery | 37 | 0 |
|
||||
| Zul'Farrak | 1 | 0 |
|
||||
| Scholomance | 36 | 0 |
|
||||
| Stratholme | 12 | 0 |
|
||||
| Blood Furnace | 1 | 0 |
|
||||
| Shadow Labyrinth | 1 | 0 |
|
||||
| Mana-Tombs | 3 | 0 |
|
||||
| Auchenai Crypts | 5 | 0 |
|
||||
| Magister's Terrace | 10 | 0 |
|
||||
| Escape From Durnholde | 120 | 0 |
|
||||
| Utgarde Keep | 8 | 0 |
|
||||
| Utgarde Pinnacle | 32 | 0 |
|
||||
| Culling of Stratholme | 11 | 0 |
|
||||
| Halls of Stone | 2 | 0 |
|
||||
| Halls of Lightning | 1 | 0 |
|
||||
| Pit of Saron | 1 | 0 |
|
||||
| Halls of Reflection | 9 | 0 |
|
||||
|
||||
The machine-readable `src/game/generated/wowDungeonStairs.generated.json`
|
||||
records a scan for every WoW dungeon, including entries with no accepted new
|
||||
proxy, collision package identities, candidate/rejection counts, and exact
|
||||
navigation portals. Authored Wailing Caverns is scanned against its actual
|
||||
source assets, rather than the campaign's separate canonical package.
|
||||
|
||||
## Safety and verification
|
||||
|
||||
- Require repeated short risers with consistent rise/run and overlapping width.
|
||||
- Merge compatible continuous flights; independently verify short fallbacks
|
||||
when a merged flight is unsafe.
|
||||
- Rebuild after rejected proxies are removed, so accepted paths do not depend
|
||||
on rejected geometry edits.
|
||||
- Check real terrain support and capsule sweeps uphill/downhill along the center
|
||||
and two interior lateral lanes at approximately 0.35m intervals.
|
||||
- Fill only separate existing boundary portals wholly within a verified flight.
|
||||
Do not invent off-mesh links across unrelated floors or walls.
|
||||
- Gate generated coordinates by collision package URL/checksum identity, and
|
||||
navigation identity where exact portal bridges are present.
|
||||
- Keep existing validation/availability gates unchanged. Stair checks do not
|
||||
certify boss routes, encounters, resets, checkpoints, or complete runs.
|
||||
- Spatially index ramp footprints during preparation to avoid checking every
|
||||
ramp against every vertex in large, multi-chunk environments.
|
||||
|
||||
The shipping-asset regression tests independently exercise all 347 new proxies
|
||||
through `prepareCollision` and the live NPC direct-segment validator in both
|
||||
directions and all three tested lanes. All generated portals match the actual
|
||||
shipping navigation vertices. Existing Stockades player/NPC/wall regressions pass.
|
||||
|
||||
Full validation: 247 Vitest files passed, 1,599 tests passed, six existing tests
|
||||
skipped. Production TypeScript/Vite build, texture/performance audits, bundle
|
||||
compression/verification, and release candidate checking passed. Existing
|
||||
physics initialization and bundle-size/import advisories remain.
|
||||
|
||||
## Visual evidence and remaining QA
|
||||
|
||||
The local visual viewer uses the runtime definitions/material policy, shipped
|
||||
GLBs, Meshopt/KTX2 decoding, ACES tone mapping, and 1280x720 screenshots. It does
|
||||
not modify saved characters or party state. Evidence is under
|
||||
`artifacts/wow-dungeon-environment/` (ignored QA artifacts).
|
||||
|
||||
Uldaman's new/old-fill comparison shows clearer stone walls, columns, and floor
|
||||
tiles. Wailing Caverns retains green coloration with readable rock formations.
|
||||
Mechanar retains cool purple/blue interiors and unchanged visible steps.
|
||||
Scholomance's entrance stairs and masonry remain visible with the crypt profile.
|
||||
Blood Furnace's dark source textures remain atmospheric; the entrance viewpoint
|
||||
faces a nearby wall, so this is not a full-room traversal review.
|
||||
|
||||
Zul'Farrak showed a geometry/viewpoint gap in the standalone entrance and boss
|
||||
views. Do not treat its lighting profile or passing isolated proxy sweep as an
|
||||
end-to-end visual/playability pass. Durnholde's large package completed loading
|
||||
without console errors; its cavern entrance and outdoor fort masonry/trees were
|
||||
readable, but the boss viewpoint also showed missing terrain/viewpoint coverage.
|
||||
That environment needs a separate terrain/spawn review and live traversal check.
|
||||
|
||||
This is not an exhaustive certification that every staircase is repaired.
|
||||
Conservative repeated-riser detection can miss curved flights, one/two-step
|
||||
transitions, and heavily decimated source geometry. A rejected candidate may be
|
||||
decorative, lack headroom/ground, or require an individually reviewed footprint.
|
||||
An entry with no new proxy is not proof that it contains no stairs. Full-dungeon
|
||||
actor traversal, entrance/exit lips, outer stair edges, manual review of rejected
|
||||
or undetected flights, full clears, and physical Android QA remain open.
|
||||
|
||||
## Repeating the checks
|
||||
|
||||
Requires the project's Node 24 runtime for native TypeScript pipeline execution.
|
||||
|
||||
```text
|
||||
npm run dungeon:wow:stairs:audit
|
||||
npm run dungeon:wow:stairs:refresh
|
||||
npm run dungeon:wow:stairs:audit -- scholomance --diagnostics
|
||||
node scripts/dungeon-pipeline/audit-wow-stairs.ts wailing-caverns --update
|
||||
npm test -- --run src/game/wowDungeonStairAssets.test.ts
|
||||
```
|
||||
|
||||
The audit is read-only by default. Refresh writes the full generated catalog;
|
||||
`--update` replaces only the named entry while retaining the other entries.
|
||||
Campaign regeneration does not erase the runtime lighting/stair adapter.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -39,11 +39,11 @@
|
||||
"agentMaxSlope": 50
|
||||
},
|
||||
"review": {
|
||||
"entrance": "required",
|
||||
"entrance": "approved",
|
||||
"bossOrder": "required",
|
||||
"optionalEncounters": "required",
|
||||
"presentation": "required",
|
||||
"offMeshLinks": "required"
|
||||
"presentation": "approved",
|
||||
"offMeshLinks": "approved"
|
||||
},
|
||||
"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",
|
||||
"version": "0.1.31",
|
||||
"version": "0.1.39",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.31",
|
||||
"version": "0.1.39",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+11
-3
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.31",
|
||||
"version": "0.1.39",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"dev:shelved": "vite --host 0.0.0.0 --mode shelved",
|
||||
"start": "node server/server.mjs",
|
||||
"db:init": "node scripts/init-game-database.mjs",
|
||||
"db:backup": "node scripts/backup-game-database.mjs",
|
||||
@@ -55,6 +56,8 @@
|
||||
"dungeon:five-player:verify": "node scripts/dungeon-pipeline/verify-five-player-dungeon-imports.mjs",
|
||||
"dungeon:wow:audit": "vite-node scripts/dungeon-pipeline/audit-wow-dungeon-content.mjs",
|
||||
"dungeon:wow:functionality:audit": "vite-node scripts/dungeon-pipeline/audit-wow-dungeon-functionality.ts",
|
||||
"dungeon:wow:stairs:audit": "node --max-old-space-size=8192 scripts/dungeon-pipeline/audit-wow-stairs.ts",
|
||||
"dungeon:wow:stairs:refresh": "node --max-old-space-size=8192 scripts/dungeon-pipeline/audit-wow-stairs.ts --write",
|
||||
"dungeon:wow:test": "vitest run src/game/wowDungeonContent.test.ts",
|
||||
"dungeon:wow:export-tool:test": "node --test scripts/dungeon-pipeline/wow-export-tool.test.mjs",
|
||||
"dungeon:ascension:recovery:validate": "node scripts/dungeon-pipeline/validate-ascension-custom-dungeon-recovery.mjs --skip-dbc",
|
||||
@@ -110,9 +113,12 @@
|
||||
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
|
||||
"balance:benchmark": "vite-node scripts/combat-benchmark/run.ts",
|
||||
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
|
||||
"prebuild": "npm run loot:generate && npm run 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: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: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",
|
||||
@@ -122,6 +128,8 @@
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"pretest": "npm run loot:generate",
|
||||
"test": "vitest run",
|
||||
"pretest:shelved": "npm run loot:generate",
|
||||
"test:shelved": "vitest run --mode shelved",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"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": [],
|
||||
"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",
|
||||
"appearanceSignature": null,
|
||||
@@ -206,6 +357,58 @@
|
||||
"url": "/assets/creatures/grafu-castle/controlled-steel-guardian.glb",
|
||||
"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",
|
||||
"appearanceSignature": null,
|
||||
@@ -924,6 +1127,56 @@
|
||||
"url": "/assets/creatures/grafu-castle/hm-male-image-585705.glb",
|
||||
"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",
|
||||
"appearanceSignature": null,
|
||||
@@ -1019,6 +1272,221 @@
|
||||
"fileName": "milk-scarab-blue.glb",
|
||||
"url": "/assets/creatures/grafu-castle/milk-scarab-blue.glb",
|
||||
"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 BOOTSTRAP_CACHE_NAME = "healer-man-bootstrap-v1";
|
||||
const OBJECT_HEADER = "x-healer-man-content-object";
|
||||
const ENCODING_HEADER = "x-healer-man-content-encoding";
|
||||
let bundledManifest;
|
||||
@@ -33,7 +34,13 @@ async function bundledAsset(path) {
|
||||
const object = entry.download ?? entry;
|
||||
// Older bootstrap catalogs served their original logical paths directly.
|
||||
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 (entry.download) return decodedResponse(response, path, entry.download.encoding);
|
||||
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/human/",
|
||||
"/assets/creatures/wailing-caverns/",
|
||||
"/assets/game/arenas/",
|
||||
"/assets/game/dungeons/wailing-caverns/",
|
||||
];
|
||||
const BUNDLED_ASSET_PATHS = new Set([
|
||||
@@ -85,7 +91,8 @@ self.addEventListener("activate", (event) => {
|
||||
event.waitUntil((async () => {
|
||||
const names = await caches.keys();
|
||||
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)));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.31",
|
||||
"versionCode": 1031
|
||||
"versionName": "0.1.39",
|
||||
"versionCode": 1039
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { gunzipSync } from "node:zlib";
|
||||
import { prepareContentObjects } from "./content-objects.mjs";
|
||||
import { selectBootstrapContent } from "./prepare-bootstrap.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 () => {
|
||||
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 }); }
|
||||
});
|
||||
|
||||
test("bundles regular dungeons and exact map dependencies without installing Manastorm", () => {
|
||||
const ids = ["core:ui", "dungeon:first", "dungeon:second", "shared:creatures", "manastorm:shared-map", "manastorm:optional-map", "characters:extra"];
|
||||
test("bundles only starter packs and their exact dependencies", () => {
|
||||
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 result = selectBootstrapContent(files, [
|
||||
{ id: "dungeon:first", dependencies: ["manastorm:shared-map", "shared:creatures"] },
|
||||
{ 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]));
|
||||
assert.equal(packs.get("dungeon:first").bundledOnAndroid, true);
|
||||
assert.equal(packs.get("dungeon:second").bundledOnAndroid, true);
|
||||
assert.equal(packs.get("manastorm:shared-map").bundledOnAndroid, false);
|
||||
assert.deepEqual([...packs.keys()].sort(), [...contentPolicy.bootstrapPackIds].sort());
|
||||
assert.equal([...packs.values()].every((pack) => pack.bundledOnAndroid), true);
|
||||
assert.equal(packs.has("dungeon:second"), false);
|
||||
assert.equal(packs.has("manastorm:shared-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,
|
||||
} from "./publish-content.mjs";
|
||||
import { prepareContentObjects } from "./content-objects.mjs";
|
||||
import contentPolicy from "../../shared/content-policy.json" with { type: "json" };
|
||||
|
||||
export function selectBootstrapContent(files, packDefinitions) {
|
||||
const manifest = createContentManifest(files, { packDefinitions });
|
||||
@@ -15,15 +16,12 @@ export function selectBootstrapContent(files, packDefinitions) {
|
||||
included.add(id);
|
||||
for (const dependency of packs.get(id)?.dependencies ?? []) include(dependency);
|
||||
}
|
||||
for (const pack of manifest.packs) {
|
||||
if (BUNDLED_ANDROID_PACKS.includes(pack.id) || pack.id.startsWith("dungeon:")) include(pack.id);
|
||||
}
|
||||
for (const packId of BUNDLED_ANDROID_PACKS) include(packId);
|
||||
return {
|
||||
files: files.filter((file) => included.has(file.packId)),
|
||||
packDefinitions: manifest.packs.filter((pack) => included.has(pack.id)).map((pack) => ({
|
||||
...pack,
|
||||
// Shared maps can be physically bundled without installing Manastorm gameplay.
|
||||
bundledOnAndroid: !pack.id.startsWith("manastorm:"),
|
||||
bundledOnAndroid: BUNDLED_ANDROID_PACKS.includes(pack.id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -44,6 +42,11 @@ export async function prepareBootstrap(platform) {
|
||||
const prepared = await prepareContentObjects(selected.files, {
|
||||
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, {
|
||||
baseUrl: "/content/bundled", packDefinitions: selected.packDefinitions,
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
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));
|
||||
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 DEFAULT_MINIMUM_APP_VERSION = "0.1.0";
|
||||
export const WOW_CAMPAIGN_DUNGEON_COUNT = 54;
|
||||
export const BUNDLED_ANDROID_PACKS = Object.freeze([
|
||||
"core:ui",
|
||||
"core:equipment",
|
||||
"core:character-basic",
|
||||
"core:runtime",
|
||||
"core:arenas",
|
||||
"dungeon:wailing-caverns",
|
||||
]);
|
||||
export const BUNDLED_ANDROID_PACKS = Object.freeze([...contentPolicy.bootstrapPackIds]);
|
||||
|
||||
const RUNTIME_EXTENSIONS = new Set([
|
||||
".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/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1");
|
||||
assert.equal(contentPackForPath("assets/game/arenas/nagrand-arena/visual.glb"), "core:arenas");
|
||||
assert.equal(BUNDLED_ANDROID_PACKS.includes("core:arenas"), true);
|
||||
assert.equal(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/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"),
|
||||
|
||||
@@ -7,6 +7,10 @@ import json
|
||||
from pathlib import Path
|
||||
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:
|
||||
with ZipFile(apk) as archive:
|
||||
@@ -35,13 +39,20 @@ def verify_package(apk: Path) -> dict:
|
||||
if actual_objects != expected_objects:
|
||||
raise ValueError("APK includes missing or unreferenced bundled objects.")
|
||||
manastorms = sum(pack["id"].startswith("manastorm:") and pack["bundledOnAndroid"] for pack in manifest["packs"])
|
||||
if dungeons != 84 or manastorms != 0:
|
||||
raise ValueError(f"Unexpected bundled content: {dungeons} dungeons, {manastorms} Manastorm packs")
|
||||
arenas = sum(pack["id"] == "core:arenas" for pack in manifest["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 {
|
||||
"apk": str(apk.resolve()), "apkBytes": apk.stat().st_size,
|
||||
"manifestVersion": manifest["version"], "logicalFiles": len(manifest["files"]),
|
||||
"verifiedUniqueObjects": len(checked), "storedObjectBytes": object_bytes,
|
||||
"bundledDungeons": dungeons, "bundledManastormPacks": manastorms,
|
||||
"bundledDungeons": dungeons, "bundledManastormPacks": manastorms, "bundledArenaPacks": arenas,
|
||||
"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 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { DungeonDefinition } from "../../src/game/dungeonTypes";
|
||||
import { detectDungeonStairRamps, dungeonStairNavigationBridges, dungeonStairRisers, readDungeonGeometry } from "./wow-stair-geometry.ts";
|
||||
import RAPIER from "@dimforge/rapier3d-compat";
|
||||
import { dungeonRampHeight, smoothDungeonStairGeometry } from "../../src/game/dungeonTraversalRamps.ts";
|
||||
import type { DungeonTraversalRamp } from "../../src/game/dungeonTypes";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const catalog = JSON.parse(readFileSync(path.join(root, "src/game/generated/dungeonCampaignCatalog.json"), "utf8"));
|
||||
const outputFile = path.join(root, "src/game/generated/wowDungeonStairs.generated.json");
|
||||
const output: Record<string, unknown> = process.argv.includes("--update")
|
||||
? JSON.parse(readFileSync(outputFile, "utf8")).dungeons : {};
|
||||
const requested = process.argv.slice(2).find((arg) => !arg.startsWith("--"));
|
||||
if (requested && !catalog.definitions.some((dungeon: DungeonDefinition) => dungeon.id === requested)) {
|
||||
throw new Error(`Unknown WoW dungeon: ${requested}`);
|
||||
}
|
||||
await RAPIER.init();
|
||||
|
||||
function rampTraversable(world: RAPIER.World, ramp: DungeonTraversalRamp): boolean {
|
||||
const rejected = (reason: string, detail: unknown) => {
|
||||
if (process.argv.includes("--diagnostics")) console.log(ramp.id, reason, JSON.stringify(detail));
|
||||
return false;
|
||||
};
|
||||
const dx = ramp.end[0] - ramp.start[0];
|
||||
const dz = ramp.end[2] - ramp.start[2];
|
||||
const length = Math.hypot(dx, dz);
|
||||
const shape = new RAPIER.Capsule(0.32, 0.34);
|
||||
const rotation = { x: 0, y: 0, z: 0, w: 1 };
|
||||
// Test both directions across the center and two capsule-safe lateral lanes.
|
||||
const lateral = Math.max(0, Math.min(ramp.width / 4, ramp.width / 2 - 0.45));
|
||||
for (const lane of [0, -lateral, lateral]) {
|
||||
const count = Math.max(3, Math.ceil(length / 0.35) + 1);
|
||||
const points = Array.from({ length: count }, (_, index) => {
|
||||
const t = index / (count - 1);
|
||||
return {
|
||||
x: ramp.start[0] + dx * t - dz / length * lane,
|
||||
y: ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t,
|
||||
z: ramp.start[2] + dz * t + dx / length * lane,
|
||||
};
|
||||
});
|
||||
for (const point of points) {
|
||||
const floor = world.castRayAndGetNormal(new RAPIER.Ray({ ...point, y: point.y + 1.25 }, { x: 0, y: -1, z: 0 }), 2, true);
|
||||
if (!floor || floor.normal.y < 0.65 || Math.abs(1.25 - floor.timeOfImpact) > 0.55) return rejected("floor", { lane, point, normal: floor?.normal, heightDelta: floor ? 1.25 - floor.timeOfImpact : null });
|
||||
}
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
for (const [start, end] of [[points[index - 1], points[index]], [points[index], points[index - 1]]]) {
|
||||
const delta = { x: end.x - start.x, y: end.y - start.y, z: end.z - start.z };
|
||||
const distance = Math.hypot(delta.x, delta.y, delta.z);
|
||||
const hit = world.castShape({ ...start, y: start.y + 1.02 }, rotation, delta, shape, 0.025, 1, false);
|
||||
if (hit && hit.time_of_impact < 1 - 0.04 / distance) return rejected("sweep", { lane, start, end, hit: hit.time_of_impact });
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const dungeon of catalog.definitions as DungeonDefinition[]) {
|
||||
if (requested && dungeon.id !== requested) continue;
|
||||
if (dungeon.id === "stormwind-stockade") {
|
||||
output[dungeon.id] = { collision: dungeon.assets.collision, ramps: [], reviewedOverride: true };
|
||||
console.log(`${dungeon.id}: preserving three reviewed ramps`);
|
||||
continue;
|
||||
}
|
||||
const risers = [];
|
||||
const geometries = [];
|
||||
// Wailing Caverns retains its authored environment rather than the campaign's
|
||||
// canonical Manastorm package. Audit the assets actually used by that entry.
|
||||
const collisionAssets = dungeon.id === "wailing-caverns" ? [{
|
||||
fileName: "wailing-caverns-collision.glb", url: undefined,
|
||||
checksum: createHash("sha256").update(readFileSync(path.join(root, "src/assets/game/dungeons/wailing-caverns/wailing-caverns-collision.glb"))).digest("hex"),
|
||||
}] : dungeon.assets.collision;
|
||||
const assetFile = (asset: { url?: string; fileName: string }) => asset.url
|
||||
? path.join(root, "public", asset.url)
|
||||
: path.join(root, "src/assets/game/dungeons", dungeon.id, asset.fileName);
|
||||
const navigationAsset = dungeon.id === "wailing-caverns" ? {
|
||||
fileName: "wailing-caverns-navigation.glb", url: undefined,
|
||||
checksum: createHash("sha256").update(readFileSync(path.join(root, "src/assets/game/dungeons/wailing-caverns/wailing-caverns-navigation.glb"))).digest("hex"),
|
||||
} : dungeon.assets.navigation;
|
||||
for (const asset of collisionAssets) {
|
||||
for (const geometry of await readDungeonGeometry(assetFile(asset))) {
|
||||
risers.push(...dungeonStairRisers(geometry));
|
||||
geometries.push(geometry);
|
||||
}
|
||||
}
|
||||
const candidates = detectDungeonStairRamps(risers);
|
||||
const verify = (input: DungeonTraversalRamp[]): DungeonTraversalRamp[] => {
|
||||
let ramps = input;
|
||||
// Rebuild after rejection: accepted ramps must not depend on rejected geometry changes.
|
||||
while (ramps.length) {
|
||||
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
||||
try {
|
||||
for (const source of geometries) {
|
||||
const geometry = source.clone();
|
||||
smoothDungeonStairGeometry(geometry, ramps);
|
||||
world.createCollider(RAPIER.ColliderDesc.trimesh(
|
||||
Float32Array.from(geometry.getAttribute("position").array),
|
||||
Uint32Array.from(geometry.index?.array ?? Array.from({ length: geometry.getAttribute("position").count }, (_, index) => index)),
|
||||
));
|
||||
geometry.dispose();
|
||||
}
|
||||
world.step();
|
||||
const accepted = ramps.filter((ramp) => rampTraversable(world, ramp));
|
||||
if (accepted.length === ramps.length) break;
|
||||
ramps = accepted;
|
||||
} finally { world.free(); }
|
||||
}
|
||||
return ramps;
|
||||
};
|
||||
let ramps = verify(candidates);
|
||||
// If a long merged flight was unsafe, retain independently verified shorter
|
||||
// flights instead. Do not let fallback fragments reshape an accepted plane.
|
||||
const fallback = detectDungeonStairRamps(risers, false).filter((ramp) => !ramps.some((accepted) => {
|
||||
const within = (a: DungeonTraversalRamp, b: DungeonTraversalRamp) => [b.start, b.end,
|
||||
b.start.map((v, i) => (v + b.end[i]) / 2)].some((point) => {
|
||||
const height = dungeonRampHeight(a, point[0], point[2]);
|
||||
return height !== null && Math.abs(point[1] - height) <= 0.85;
|
||||
});
|
||||
return within(accepted, ramp) || within(ramp, accepted);
|
||||
})).map((ramp, index) => ({ ...ramp, id: `stair-fallback-${index + 1}` }));
|
||||
ramps = verify([...ramps, ...fallback]);
|
||||
output[dungeon.id] = {
|
||||
collision: collisionAssets.map(({ fileName, url, checksum }) => ({ fileName, url, checksum })),
|
||||
navigation: navigationAsset,
|
||||
ramps, candidateCount: candidates.length + fallback.length,
|
||||
rejectedCount: candidates.length + fallback.length - ramps.length,
|
||||
};
|
||||
if (ramps.length && navigationAsset) {
|
||||
const navigation = await readDungeonGeometry(assetFile(navigationAsset));
|
||||
const largest = navigation.sort((a, b) => b.getAttribute("position").count - a.getAttribute("position").count)[0];
|
||||
(output[dungeon.id] as { ramps: DungeonTraversalRamp[] }).ramps = dungeonStairNavigationBridges(largest, ramps);
|
||||
navigation.forEach((geometry) => geometry.dispose());
|
||||
}
|
||||
console.log(`${dungeon.id}: ${ramps.length}/${candidates.length + fallback.length} collision-verified stair proxies`);
|
||||
geometries.forEach((geometry) => geometry.dispose());
|
||||
}
|
||||
if (process.argv.includes("--write") || process.argv.includes("--update")) {
|
||||
if (requested && !process.argv.includes("--update")) throw new Error("Refusing to replace the fleet catalog with a single-dungeon scan.");
|
||||
writeFileSync(outputFile, `${JSON.stringify({ schemaVersion: 1, dungeons: output }, null, 2)}\n`);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { DEFAULT_AZEROTHCORE_COMMIT } from "./import-azerothcore-world.mjs";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
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 outputFile = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json");
|
||||
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 span = Math.max(
|
||||
bounds.max[0] - bounds.min[0],
|
||||
bounds.max[2] - bounds.min[2],
|
||||
);
|
||||
return {
|
||||
const presentation = {
|
||||
location: assetPackage.location || recipe.title,
|
||||
theme: environmentMode === "adt-hybrid" ? "Open-world expedition" : "Instanced dungeon",
|
||||
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() {
|
||||
@@ -1535,6 +1546,19 @@ async function compile() {
|
||||
const importsBySlug = new Map(
|
||||
(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 wantedSpellIds = new Set();
|
||||
for (const recipe of recipes) {
|
||||
@@ -1584,6 +1608,7 @@ async function compile() {
|
||||
const coverage = [];
|
||||
for (const recipe of recipes) {
|
||||
const dungeonImport = importsBySlug.get(recipe.slug);
|
||||
const runtimeOverride = runtimeOverrides.get(recipe.slug) ?? {};
|
||||
// Manastorm packages contain the complete optimized dungeon geometry.
|
||||
// Reuse them for full dungeons so the install ships one map package.
|
||||
const assetPackage = packagesByMap.get(recipe.mapId) ?? dungeonImport?.environment;
|
||||
@@ -1951,7 +1976,7 @@ async function compile() {
|
||||
difficultyVariants: recipe.difficultyVariants,
|
||||
defaultDifficultyId: recipe.difficultyVariants[0]?.id ?? "normal",
|
||||
availability: "available",
|
||||
presentation: presentationFor(recipe, assetPackage, environmentMode, bounds),
|
||||
presentation: presentationFor(recipe, assetPackage, environmentMode, bounds, runtimeOverride.presentation),
|
||||
assets: {
|
||||
visual: assetPackage.visual.map(assetChunk),
|
||||
collision: assetPackage.collision.map(assetChunk),
|
||||
@@ -1979,7 +2004,26 @@ async function compile() {
|
||||
roamingPacks,
|
||||
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)],
|
||||
serverGameObjects,
|
||||
provenance: {
|
||||
@@ -2009,8 +2053,17 @@ async function compile() {
|
||||
},
|
||||
validation: {
|
||||
status: "unvalidated",
|
||||
...(runtimeOverride.validation?.navigation
|
||||
? { navigation: runtimeOverride.validation.navigation }
|
||||
: {}),
|
||||
...(runtimeOverride.validation?.report
|
||||
? { report: runtimeOverride.validation.report }
|
||||
: {}),
|
||||
blockers: [],
|
||||
warnings: sourceWarnings,
|
||||
warnings: [
|
||||
...sourceWarnings,
|
||||
...(runtimeOverride.validation?.warnings ?? []),
|
||||
],
|
||||
},
|
||||
});
|
||||
coverage.push({
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { NodeIO } from "@gltf-transform/core";
|
||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
import { BufferAttribute, BufferGeometry, Float32BufferAttribute, Matrix4 } from "three";
|
||||
import type { DungeonTraversalRamp } from "../../src/game/dungeonTypes";
|
||||
import { dungeonRampHeight } from "../../src/game/dungeonTraversalRamps.ts";
|
||||
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS)
|
||||
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
|
||||
|
||||
/** Decode exactly as GLTFLoader does, expanding quantized coordinates before baking transforms. */
|
||||
export async function readDungeonGeometry(file: string): Promise<BufferGeometry[]> {
|
||||
await MeshoptDecoder.ready;
|
||||
const document = await io.read(file);
|
||||
const geometries: BufferGeometry[] = [];
|
||||
for (const node of document.getRoot().listNodes()) {
|
||||
for (const primitive of node.getMesh()?.listPrimitives() ?? []) {
|
||||
const accessor = primitive.getAttribute("POSITION");
|
||||
if (!accessor?.getArray()) continue;
|
||||
const source = new BufferAttribute(accessor.getArray()!, 3, accessor.getNormalized());
|
||||
const positions = new Float32Array(source.count * 3);
|
||||
for (let index = 0; index < source.count; index += 1) {
|
||||
positions.set([source.getX(index), source.getY(index), source.getZ(index)], index * 3);
|
||||
}
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
|
||||
const indices = primitive.getIndices()?.getArray();
|
||||
if (indices) geometry.setIndex(Array.from(indices));
|
||||
geometry.applyMatrix4(new Matrix4().fromArray(node.getWorldMatrix()));
|
||||
geometries.push(geometry);
|
||||
}
|
||||
}
|
||||
return geometries;
|
||||
}
|
||||
|
||||
interface Riser {
|
||||
direction: number;
|
||||
along: number;
|
||||
low: number;
|
||||
high: number;
|
||||
left: number;
|
||||
right: number;
|
||||
}
|
||||
|
||||
/** Repeated short vertical faces, not arbitrary slopes or nearby disconnected floors. */
|
||||
export function dungeonStairRisers(geometry: BufferGeometry): Riser[] {
|
||||
const p = geometry.getAttribute("position");
|
||||
const indices = geometry.index?.array ?? Array.from({ length: p.count }, (_, i) => i);
|
||||
const faces = new Map<string, Riser>();
|
||||
for (let offset = 0; offset + 2 < indices.length; offset += 3) {
|
||||
const vertices = [indices[offset], indices[offset + 1], indices[offset + 2]];
|
||||
const points = vertices.map((i) => [p.getX(i), p.getY(i), p.getZ(i)]);
|
||||
const [a, b, c] = points;
|
||||
const ab = b.map((v, i) => v - a[i]);
|
||||
const ac = c.map((v, i) => v - a[i]);
|
||||
const nx = ab[1] * ac[2] - ab[2] * ac[1];
|
||||
const ny = ab[2] * ac[0] - ab[0] * ac[2];
|
||||
const nz = ab[0] * ac[1] - ab[1] * ac[0];
|
||||
if (Math.hypot(nx, nz) < 0.1 || Math.abs(ny) / Math.hypot(nx, ny, nz) > 0.025) continue;
|
||||
const low = Math.min(...points.map((v) => v[1]));
|
||||
const high = Math.max(...points.map((v) => v[1]));
|
||||
if (high - low < 0.08 || high - low > 0.82) continue;
|
||||
// Opposite winding still describes the same plane. Quantization permits ~1 degree noise.
|
||||
const direction = ((Math.round(Math.atan2(nz, nx) / Math.PI * 180) % 180) + 180) % 180;
|
||||
const angle = direction * Math.PI / 180;
|
||||
const along = points.reduce((sum, v) => sum + v[0] * Math.cos(angle) + v[2] * Math.sin(angle), 0) / 3;
|
||||
const across = points.map((v) => -v[0] * Math.sin(angle) + v[2] * Math.cos(angle));
|
||||
const left = Math.min(...across);
|
||||
const right = Math.max(...across);
|
||||
if (right - left < 0.9) continue;
|
||||
const key = [direction, Math.round(along / 0.04), Math.round(low / 0.04), Math.round(high / 0.04)].join(":");
|
||||
const previous = faces.get(key);
|
||||
if (previous && Math.min(previous.right, right) - Math.max(previous.left, left) >= -0.04) {
|
||||
previous.left = Math.min(previous.left, left);
|
||||
previous.right = Math.max(previous.right, right);
|
||||
} else faces.set(`${key}${previous ? `:${Math.round(left / 0.04)}` : ""}`, { direction, along, low, high, left, right });
|
||||
}
|
||||
return [...faces.values()];
|
||||
}
|
||||
|
||||
export function detectDungeonStairRamps(risers: Riser[], merge = true): DungeonTraversalRamp[] {
|
||||
const ramps: DungeonTraversalRamp[] = [];
|
||||
const used = new Set<Riser>();
|
||||
const sorted = [...risers].sort((a, b) => a.direction - b.direction || a.along - b.along || a.low - b.low);
|
||||
for (const first of sorted) {
|
||||
if (used.has(first)) continue;
|
||||
const neighbors = sorted.filter((r) => Math.min(Math.abs(r.direction - first.direction), 180 - Math.abs(r.direction - first.direction)) <= 2)
|
||||
.map((source) => {
|
||||
const difference = (source.direction - first.direction) * Math.PI / 180;
|
||||
const across = (source.left + source.right) / 2;
|
||||
const halfWidth = (source.right - source.left) / 2 * Math.abs(Math.cos(difference));
|
||||
const center = source.along * Math.sin(difference) + across * Math.cos(difference);
|
||||
return { source, ...source, along: source.along * Math.cos(difference) - across * Math.sin(difference), left: center - halfWidth, right: center + halfWidth };
|
||||
}).sort((a, b) => a.along - b.along || a.low - b.low);
|
||||
const chain = [first];
|
||||
const chainSources = [first];
|
||||
let sign = 0;
|
||||
while (true) {
|
||||
const current = chain[chain.length - 1];
|
||||
const next = neighbors.find((candidate) => {
|
||||
if (used.has(candidate.source)) return false;
|
||||
const run = candidate.along - current.along;
|
||||
const rise = candidate.low - current.low;
|
||||
const overlap = Math.min(...chain.map((r) => r.right), candidate.right)
|
||||
- Math.max(...chain.map((r) => r.left), candidate.left);
|
||||
return run > 0.12 && run < 2.5 && Math.abs(rise) >= 0.08 && Math.abs(rise) <= 0.82
|
||||
&& Math.abs(rise) / run < 0.85 && overlap >= 1
|
||||
&& (!sign || Math.sign(rise) === sign)
|
||||
&& Math.min(Math.abs(candidate.low - current.high), Math.abs(candidate.high - current.low)) < 0.22;
|
||||
});
|
||||
if (!next) break;
|
||||
if (chain.length > 1) {
|
||||
const run = chain[1].along - first.along;
|
||||
const rise = chain[1].low - first.low;
|
||||
if (Math.abs(next.low - (first.low + rise / run * (next.along - first.along))) > 0.1) break;
|
||||
}
|
||||
chain.push(next);
|
||||
chainSources.push(next.source);
|
||||
sign = Math.sign(next.low - current.low);
|
||||
}
|
||||
if (chain.length < 3) continue;
|
||||
const last = chain[chain.length - 1];
|
||||
const run = (last.along - first.along) / (chain.length - 1);
|
||||
const rise = (last.low - first.low) / (chain.length - 1);
|
||||
const left = Math.max(...chain.map((r) => r.left));
|
||||
const right = Math.min(...chain.map((r) => r.right));
|
||||
const across = (left + right) / 2;
|
||||
const angle = first.direction * Math.PI / 180;
|
||||
const point = (along: number, y: number): [number, number, number] => [
|
||||
along * Math.cos(angle) - across * Math.sin(angle), y,
|
||||
along * Math.sin(angle) + across * Math.cos(angle),
|
||||
];
|
||||
// Meet the lower/upper landings, including the first/last riser, without a lip.
|
||||
ramps.push({
|
||||
id: `stair-flight-${ramps.length + 1}`,
|
||||
start: point(first.along - (sign < 0 ? run : 0.04), sign < 0 ? first.high : first.low),
|
||||
end: point(last.along + (sign > 0 ? run : 0.04), sign > 0 ? last.high : last.low),
|
||||
width: right - left + 0.02,
|
||||
});
|
||||
chainSources.forEach((r) => used.add(r));
|
||||
}
|
||||
return merge ? mergeDungeonStairRamps(ramps) : ramps;
|
||||
}
|
||||
|
||||
/** Keep one plane per continuous flight; independent overlapping fragments introduce new lips. */
|
||||
export function mergeDungeonStairRamps(input: DungeonTraversalRamp[]): DungeonTraversalRamp[] {
|
||||
const ramps = [...input].sort((a, b) => Math.hypot(b.end[0] - b.start[0], b.end[2] - b.start[2])
|
||||
- Math.hypot(a.end[0] - a.start[0], a.end[2] - a.start[2]));
|
||||
const result: DungeonTraversalRamp[] = [];
|
||||
for (const ramp of ramps) {
|
||||
let combined = false;
|
||||
for (let index = 0; index < result.length; index += 1) {
|
||||
const previous = result[index];
|
||||
const dx = previous.end[0] - previous.start[0]; const dz = previous.end[2] - previous.start[2];
|
||||
const length = Math.hypot(dx, dz);
|
||||
const rdX = ramp.end[0] - ramp.start[0]; const rdZ = ramp.end[2] - ramp.start[2];
|
||||
const rLength = Math.hypot(rdX, rdZ);
|
||||
if ((dx * rdX + dz * rdZ) / length / rLength < 0.999) continue;
|
||||
const along = (p: readonly number[]) => ((p[0] - previous.start[0]) * dx + (p[2] - previous.start[2]) * dz) / length;
|
||||
const across = (p: readonly number[]) => (-(p[0] - previous.start[0]) * dz + (p[2] - previous.start[2]) * dx) / length;
|
||||
const start = along(ramp.start); const end = along(ramp.end);
|
||||
const acrossOffset = (across(ramp.start) + across(ramp.end)) / 2;
|
||||
const left = Math.max(-previous.width / 2, acrossOffset - ramp.width / 2);
|
||||
const right = Math.min(previous.width / 2, acrossOffset + ramp.width / 2);
|
||||
if (right - left < 1 || start > length + 1.5 || end < -1.5) continue;
|
||||
const slope = (previous.end[1] - previous.start[1]) / length;
|
||||
const rSlope = (ramp.end[1] - ramp.start[1]) / rLength;
|
||||
if (Math.abs(slope - rSlope) > 0.12
|
||||
|| Math.abs(ramp.start[1] - (previous.start[1] + start * slope)) > 0.32
|
||||
|| Math.abs(ramp.end[1] - (previous.start[1] + end * slope)) > 0.32) continue;
|
||||
const low = Math.min(0, start); const high = Math.max(length, end);
|
||||
const center = (left + right) / 2;
|
||||
const point = (t: number, y: number): [number, number, number] => [
|
||||
previous.start[0] + dx / length * t - dz / length * center, y,
|
||||
previous.start[2] + dz / length * t + dx / length * center,
|
||||
];
|
||||
result[index] = {
|
||||
id: previous.id,
|
||||
start: point(low, start < 0 ? ramp.start[1] : previous.start[1]),
|
||||
end: point(high, end > length ? ramp.end[1] : previous.end[1]),
|
||||
width: right - left,
|
||||
};
|
||||
combined = true;
|
||||
break;
|
||||
}
|
||||
if (!combined) result.push(ramp);
|
||||
}
|
||||
return result.map((ramp, index) => ({ ...ramp, id: `stair-flight-${index + 1}` }));
|
||||
}
|
||||
|
||||
/** Fill only separate Recast boundary portals wholly inside a verified stair footprint. */
|
||||
export function dungeonStairNavigationBridges(geometry: BufferGeometry, ramps: DungeonTraversalRamp[]): DungeonTraversalRamp[] {
|
||||
const p = geometry.getAttribute("position");
|
||||
const indices = geometry.index?.array ?? Array.from({ length: p.count }, (_, i) => i);
|
||||
const parents = Array.from({ length: p.count }, (_, i) => i);
|
||||
const root = (i: number): number => {
|
||||
while (parents[i] !== i) { parents[i] = parents[parents[i]]; i = parents[i]; }
|
||||
return i;
|
||||
};
|
||||
const weld = new Map<string, number>();
|
||||
const point = (i: number): [number, number, number] => [p.getX(i), p.getY(i), p.getZ(i)];
|
||||
for (let i = 0; i < p.count; i += 1) {
|
||||
const key = point(i).map((v) => Math.round(v / 0.002)).join(":");
|
||||
const previous = weld.get(key);
|
||||
if (previous !== undefined) parents[i] = previous;
|
||||
else weld.set(key, i);
|
||||
}
|
||||
const edges = new Map<string, { a: number; b: number; count: number }>();
|
||||
for (let offset = 0; offset + 2 < indices.length; offset += 3) {
|
||||
const triangle = [indices[offset], indices[offset + 1], indices[offset + 2]];
|
||||
for (let side = 0; side < 3; side += 1) {
|
||||
const a = triangle[side]; const b = triangle[(side + 1) % 3];
|
||||
const wa = weld.get(point(a).map((v) => Math.round(v / 0.002)).join(":"))!;
|
||||
const wb = weld.get(point(b).map((v) => Math.round(v / 0.002)).join(":"))!;
|
||||
parents[root(a)] = root(b);
|
||||
const key = wa < wb ? `${wa}:${wb}` : `${wb}:${wa}`;
|
||||
const edge = edges.get(key);
|
||||
if (edge) edge.count += 1;
|
||||
else edges.set(key, { a, b, count: 1 });
|
||||
}
|
||||
}
|
||||
const boundary = [...edges.values()].filter((e) => e.count === 1);
|
||||
return ramps.map((ramp) => {
|
||||
const dx = ramp.end[0] - ramp.start[0]; const dz = ramp.end[2] - ramp.start[2];
|
||||
const length = Math.hypot(dx, dz);
|
||||
const along = (v: number[]) => ((v[0] - ramp.start[0]) * dx + (v[2] - ramp.start[2]) * dz) / length;
|
||||
const across = (v: number[]) => (-(v[0] - ramp.start[0]) * dz + (v[2] - ramp.start[2]) * dx) / length;
|
||||
const portals = boundary.flatMap((e) => {
|
||||
const points = [point(e.a), point(e.b)].sort((a, b) => across(a) - across(b));
|
||||
if (points.some((v) => {
|
||||
const y = dungeonRampHeight(ramp, v[0], v[2]);
|
||||
return y === null || Math.abs(v[1] - y) > 0.8;
|
||||
})) return [];
|
||||
if (Math.abs(along(points[0]) - along(points[1])) > 0.15 || across(points[1]) - across(points[0]) < 0.7) return [];
|
||||
return [{ ...e, points: points as [[number, number, number], [number, number, number]], along: (along(points[0]) + along(points[1])) / 2 }];
|
||||
}).sort((a, b) => a.along - b.along);
|
||||
const bridges = [];
|
||||
for (let index = 1; index < portals.length; index += 1) {
|
||||
const a = portals[index - 1]; const b = portals[index];
|
||||
const gap = b.along - a.along;
|
||||
if (gap < 0.15 || gap > 2.5 || root(a.a) === root(b.a)) continue;
|
||||
const overlap = Math.min(across(a.points[1]), across(b.points[1])) - Math.max(across(a.points[0]), across(b.points[0]));
|
||||
if (overlap < 0.7) continue;
|
||||
bridges.push({ linkId: `${ramp.id}-seam-${bridges.length + 1}`, startPortal: a.points, endPortal: b.points });
|
||||
parents[root(a.a)] = root(b.a);
|
||||
}
|
||||
return bridges.length ? { ...ramp, navigationBridges: bridges } : ramp;
|
||||
});
|
||||
}
|
||||
@@ -81,6 +81,7 @@ def full_release_checks(npm: str) -> tuple[tuple[list[str], str], ...]:
|
||||
([npm, "run", "content:test"], "Content tests"),
|
||||
([npm, "run", "server:test"], "Server 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", "build"], "Production build"),
|
||||
([npm, "run", "android:sync"], "Android web build and Capacitor sync"),
|
||||
|
||||
@@ -71,6 +71,21 @@ Optional template fields:
|
||||
- `reason`: why an object is deferred;
|
||||
- `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
|
||||
forensic refresh with `--update-recipe` pins them. A recipe should not be treated
|
||||
as production-ready until all classifications/models and counts are reviewed.
|
||||
@@ -27,9 +27,9 @@
|
||||
"summary": {
|
||||
"dungeons": 30,
|
||||
"deferredSourceRows": 4113,
|
||||
"uniqueMechanicsEvidence": 1524,
|
||||
"uniqueMechanicsEvidence": 1530,
|
||||
"luaEvidenceSignals": 329,
|
||||
"templateEvidenceSignals": 1195,
|
||||
"templateEvidenceSignals": 1201,
|
||||
"evidenceCategoryMembershipCount": 1146,
|
||||
"evidenceCategoryMemberships": {
|
||||
"death": 100,
|
||||
@@ -54,21 +54,21 @@
|
||||
"evidenceStatuses": {
|
||||
"implemented": 0,
|
||||
"verified": 388,
|
||||
"deferred-source-gap": 1136,
|
||||
"deferred-source-gap": 1142,
|
||||
"deferred-asset-gap": 0,
|
||||
"deferred-runtime-gap": 0,
|
||||
"excluded-non-gameplay": 0,
|
||||
"intentional-empty": 0
|
||||
},
|
||||
"evidenceFidelities": {
|
||||
"source-exact": 1136,
|
||||
"source-exact": 1142,
|
||||
"source-backed-adapted": 388,
|
||||
"healer-man-authored": 0
|
||||
},
|
||||
"verifiedEvidence": 388,
|
||||
"sourceGapEvidence": 1136,
|
||||
"fallbackBosses": 4,
|
||||
"nonEmptyZeroBossPackages": 8,
|
||||
"sourceGapEvidence": 1142,
|
||||
"fallbackBosses": 5,
|
||||
"nonEmptyZeroBossPackages": 7,
|
||||
"intentionalEmptyPackages": 2
|
||||
},
|
||||
"blockerGroups": {
|
||||
@@ -77,7 +77,7 @@
|
||||
"nextWork": "Review source semantics and add explicit source-spawn encounter mappings."
|
||||
},
|
||||
"unresolvedMechanicsEvidence": {
|
||||
"items": 1136,
|
||||
"items": 1142,
|
||||
"nextWork": "Review surrounding Lua/template context; specify full trigger, effect, and reset behavior."
|
||||
},
|
||||
"adaptedSpellFormula": {
|
||||
@@ -85,11 +85,11 @@
|
||||
"nextWork": "Recover native formula inputs before claiming coefficient-level fidelity."
|
||||
},
|
||||
"sourceGapFallbackBosses": {
|
||||
"items": 4,
|
||||
"items": 5,
|
||||
"nextWork": "Recover authoritative combat kits and replace only with source-backed modular mechanics."
|
||||
},
|
||||
"nonEmptyZeroBossPackages": {
|
||||
"items": 8,
|
||||
"items": 7,
|
||||
"nextWork": "Recover authoritative boss membership and objective order."
|
||||
}
|
||||
},
|
||||
@@ -111,7 +111,7 @@
|
||||
"source": {
|
||||
"bossMechanicsCatalog": {
|
||||
"path": "dungeon-pipeline/boss-mechanics.json",
|
||||
"sha256": "6e078fe5fbbd10cabc77d2141e5084b30daffe5056945b44294a59711c48c4b7"
|
||||
"sha256": "2e573cd2a0a99d89d0f51764bcce0693e37513da677c70090e101bb016f39b21"
|
||||
},
|
||||
"snapshot": {
|
||||
"path": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json",
|
||||
@@ -153,7 +153,7 @@
|
||||
"source": {
|
||||
"bossMechanicsCatalog": {
|
||||
"path": "dungeon-pipeline/boss-mechanics.json",
|
||||
"sha256": "6e078fe5fbbd10cabc77d2141e5084b30daffe5056945b44294a59711c48c4b7"
|
||||
"sha256": "2e573cd2a0a99d89d0f51764bcce0693e37513da677c70090e101bb016f39b21"
|
||||
},
|
||||
"snapshot": {
|
||||
"path": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json",
|
||||
@@ -189,7 +189,7 @@
|
||||
"source": {
|
||||
"bossMechanicsCatalog": {
|
||||
"path": "dungeon-pipeline/boss-mechanics.json",
|
||||
"sha256": "6e078fe5fbbd10cabc77d2141e5084b30daffe5056945b44294a59711c48c4b7"
|
||||
"sha256": "2e573cd2a0a99d89d0f51764bcce0693e37513da677c70090e101bb016f39b21"
|
||||
},
|
||||
"snapshot": {
|
||||
"path": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json",
|
||||
@@ -225,7 +225,7 @@
|
||||
"source": {
|
||||
"bossMechanicsCatalog": {
|
||||
"path": "dungeon-pipeline/boss-mechanics.json",
|
||||
"sha256": "6e078fe5fbbd10cabc77d2141e5084b30daffe5056945b44294a59711c48c4b7"
|
||||
"sha256": "2e573cd2a0a99d89d0f51764bcce0693e37513da677c70090e101bb016f39b21"
|
||||
},
|
||||
"snapshot": {
|
||||
"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.",
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
@@ -277,21 +315,6 @@
|
||||
"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": "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",
|
||||
"zoneId": 105,
|
||||
@@ -41141,11 +41164,11 @@
|
||||
"id": "grafu-castle",
|
||||
"zoneId": 137,
|
||||
"sourceRows": 249,
|
||||
"bossObjectives": 0,
|
||||
"bossObjectives": 5,
|
||||
"sourceArtifacts": {
|
||||
"recipe": {
|
||||
"path": "scripts/runewaker-pipeline/recipes/grafu-castle-population.json",
|
||||
"sha256": "b65eb823f8d1a7306dbafb17e4c4ed5e11899b9439af72b4d24b18ddbfd07bf8"
|
||||
"sha256": "29b4798fb43dc6c0ef8b5ec5dd1cc6007c4b84cc2753c4d38b47d005ff82b4cb"
|
||||
},
|
||||
"snapshot": {
|
||||
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
|
||||
@@ -41154,11 +41177,11 @@
|
||||
},
|
||||
"mechanicsAudit": {
|
||||
"path": "src/assets/game/dungeons/grafu-castle/mechanics-audit.json",
|
||||
"sha256": "c97474a819d21bef2b7790f4c8be5510f540a4ff4b82511bb936b30ffe1ba348"
|
||||
"sha256": "806a106123e1fd85aadcd2c41066efdcfb032d9a4dde1add0ff2d59fcda060cd"
|
||||
},
|
||||
"generatedRuntime": {
|
||||
"path": "src/game/generated/grafuCastlePopulation.generated.ts",
|
||||
"sha256": "ed998e499878f675d1223ea24c966816ed22dae1beccfd7ae6e3f701ff06efd6"
|
||||
"sha256": "e3678e53911590bcf2fbde56272330d903b76a3f8a84a96f8bef43f671c0a07d"
|
||||
},
|
||||
"upstreamHashes": {
|
||||
"backups": {
|
||||
@@ -41173,11 +41196,11 @@
|
||||
}
|
||||
},
|
||||
"packageAssessment": {
|
||||
"status": "deferred-source-gap",
|
||||
"status": "verified",
|
||||
"fidelity": "source-exact",
|
||||
"blocker": "The package is non-empty, but its preserved source classification confirms no boss objectives.",
|
||||
"nextWork": "Recover authoritative boss membership and encounter order before creating boss objectives.",
|
||||
"clearingTest": "Add a source-backed boss-membership fixture and verify the generated objective list."
|
||||
"blocker": "No package-level population blocker is recorded.",
|
||||
"nextWork": "Re-run the ledger whenever portable source content changes.",
|
||||
"clearingTest": "npm run runewaker:audit:gameplay"
|
||||
},
|
||||
"deferredRowStatusCounts": {
|
||||
"implemented": 0,
|
||||
@@ -41191,14 +41214,14 @@
|
||||
"evidenceStatusCounts": {
|
||||
"implemented": 0,
|
||||
"verified": 10,
|
||||
"deferred-source-gap": 40,
|
||||
"deferred-source-gap": 46,
|
||||
"deferred-asset-gap": 0,
|
||||
"deferred-runtime-gap": 0,
|
||||
"excluded-non-gameplay": 0,
|
||||
"intentional-empty": 0
|
||||
},
|
||||
"evidenceFidelityCounts": {
|
||||
"source-exact": 40,
|
||||
"source-exact": 46,
|
||||
"source-backed-adapted": 10,
|
||||
"healer-man-authored": 0
|
||||
},
|
||||
@@ -47823,7 +47846,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -47851,7 +47874,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -47879,7 +47902,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -47907,7 +47930,35 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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": {
|
||||
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
|
||||
@@ -47935,7 +47986,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -47963,7 +48014,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -47993,7 +48044,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48021,7 +48072,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48052,7 +48103,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48082,7 +48133,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48110,7 +48161,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48141,7 +48192,35 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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": {
|
||||
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
|
||||
@@ -48169,7 +48248,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48197,7 +48276,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48225,7 +48304,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48253,7 +48332,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48281,7 +48360,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48309,7 +48388,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48337,7 +48416,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48365,7 +48444,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48393,7 +48472,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48421,7 +48500,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48449,7 +48528,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48477,7 +48556,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48505,7 +48584,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48533,7 +48612,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48561,7 +48640,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48589,7 +48668,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48617,7 +48696,91 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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": {
|
||||
"path": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
|
||||
@@ -48650,7 +48813,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48691,7 +48854,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48724,7 +48887,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48760,7 +48923,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48793,7 +48956,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48834,7 +48997,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48875,7 +49038,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48908,7 +49071,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48949,7 +49112,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -48990,7 +49153,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -49031,7 +49194,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -49072,7 +49235,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -49100,7 +49263,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -49128,7 +49291,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -49159,7 +49322,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -49192,7 +49355,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -49225,7 +49388,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -49266,7 +49429,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -49307,7 +49470,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -49340,7 +49503,7 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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",
|
||||
@@ -49373,7 +49536,35 @@
|
||||
"source": {
|
||||
"mechanicsAudit": {
|
||||
"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": {
|
||||
"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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -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 |
|
||||
| dungeon-of-dalanis | 229 | 162 | 26 | 5 | 21 | 0 | deferred-source-gap |
|
||||
| forsaken-abbey | 251 | 101 | 77 | 0 | 77 | 8 | verified |
|
||||
| grafu-castle | 249 | 193 | 50 | 10 | 40 | 0 | deferred-source-gap |
|
||||
| grafu-castle | 249 | 193 | 56 | 10 | 46 | 5 | verified |
|
||||
| hall-of-survivors | 363 | 201 | 84 | 34 | 50 | 9 | verified |
|
||||
| hoto | 401 | 94 | 95 | 28 | 67 | 5 | verified |
|
||||
| ice-dwarf-kingdom | 651 | 52 | 87 | 36 | 51 | 5 | verified |
|
||||
@@ -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. |
|
||||
| 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. |
|
||||
| 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
|
||||
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.
|
||||
- `dungeon-of-dalanis`: 229 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||
- `grafu-castle`: 249 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||
- `kal-turok-hive`: 4 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||
- `lair-demon-dragon`: 532 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||
- `tomb-seven-heroes`: 439 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||
@@ -108,10 +108,10 @@ presented as original RuneWaker mechanics until authoritative source behavior is
|
||||
| Blocker group | Items | Clearing work |
|
||||
| --- | ---: | --- |
|
||||
| deferredSourceRows | 4113 | Review source semantics and add explicit source-spawn encounter mappings. |
|
||||
| unresolvedMechanicsEvidence | 1136 | Review surrounding Lua/template context; specify full trigger, effect, and reset behavior. |
|
||||
| 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. |
|
||||
| sourceGapFallbackBosses | 4 | Recover authoritative combat kits and replace only with source-backed modular mechanics. |
|
||||
| nonEmptyZeroBossPackages | 8 | Recover authoritative boss membership and objective order. |
|
||||
| sourceGapFallbackBosses | 5 | Recover authoritative combat kits and replace only with source-backed modular mechanics. |
|
||||
| nonEmptyZeroBossPackages | 7 | Recover authoritative boss membership and objective order. |
|
||||
|
||||
The exhaustive per-item blocker, next-work instruction, clearing test, source IDs, source
|
||||
files, and SHA-256 hashes are in `RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json`.
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"validationStatus": "green",
|
||||
"validationStatus": "blocked",
|
||||
"fidelityStatus": "review-required",
|
||||
"scope": "All configured offline RuneWaker dungeon packages",
|
||||
"summary": {
|
||||
"sourceRows": 8199,
|
||||
"runtimeSpawns": 4086,
|
||||
"runtimeEntities": 436,
|
||||
"bossObjectives": 61,
|
||||
"runtimeAttacks": 1068,
|
||||
"genericDefaultAttacks": 436,
|
||||
"runtimeSpawns": 4114,
|
||||
"runtimeEntities": 446,
|
||||
"bossObjectives": 66,
|
||||
"runtimeAttacks": 1133,
|
||||
"genericDefaultAttacks": 446,
|
||||
"builderAuthoredAttacks": 0,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 279,
|
||||
"sourceBackedBossMechanics": 275,
|
||||
"sourceGapBossMechanics": 4,
|
||||
"telegraphedBossMechanics": 111,
|
||||
"catalogBossMechanics": 334,
|
||||
"sourceBackedBossMechanics": 329,
|
||||
"sourceGapBossMechanics": 5,
|
||||
"telegraphedBossMechanics": 143,
|
||||
"healthTriggeredBossMechanics": 28,
|
||||
"sourceBackedDamageFamilyAttacks": 483,
|
||||
"sourceBackedDamageFamilyAttacks": 514,
|
||||
"sourceSpellTemplates": 293,
|
||||
"sourceSpellAssignments": 659,
|
||||
"scriptedTemplates": 357,
|
||||
"nativeBindings": 435,
|
||||
"scriptedTemplates": 363,
|
||||
"nativeBindings": 445,
|
||||
"proceduralBindings": 1,
|
||||
"proxyBindings": 0,
|
||||
"objectiveRoutes": 61,
|
||||
"blockedObjectiveRoutes": 0,
|
||||
"sourceOrderedObjectiveRoutes": 0,
|
||||
"objectiveRoutes": 66,
|
||||
"blockedObjectiveRoutes": 3,
|
||||
"sourceOrderedObjectiveRoutes": 5,
|
||||
"derivedObjectiveRoutes": 61,
|
||||
"unreachableDerivedObjectiveRoutes": 19,
|
||||
"dungeons": 30,
|
||||
"uniqueNativeModels": 321,
|
||||
"uniqueNativeModels": 328,
|
||||
"uniqueSourceSpellIds": 411,
|
||||
"portableSpellCatalogRecords": 3199,
|
||||
"portableDamageFamilyRecords": 1342,
|
||||
@@ -79,9 +79,28 @@
|
||||
],
|
||||
"blockerGroups": {
|
||||
"navigation": {
|
||||
"blockedLegs": 0,
|
||||
"affectedDungeons": 0,
|
||||
"entries": [],
|
||||
"blockedLegs": 3,
|
||||
"affectedDungeons": 1,
|
||||
"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,
|
||||
"derivedAffectedDungeons": 8,
|
||||
"derivedEntries": [
|
||||
@@ -223,10 +242,10 @@
|
||||
"sourceMechanics": {
|
||||
"status": "review-required",
|
||||
"executableBossMechanicsGenerated": true,
|
||||
"catalogBossMechanics": 279,
|
||||
"sourceBackedBossMechanics": 275,
|
||||
"sourceGapBossMechanics": 4,
|
||||
"scriptedTemplates": 357,
|
||||
"catalogBossMechanics": 334,
|
||||
"sourceBackedBossMechanics": 329,
|
||||
"sourceGapBossMechanics": 5,
|
||||
"scriptedTemplates": 363,
|
||||
"evidenceCounts": {
|
||||
"death": 75,
|
||||
"door": 22,
|
||||
@@ -258,7 +277,7 @@
|
||||
"validationStatus": "green",
|
||||
"completionStatus": "review-required",
|
||||
"deferredSourceRows": 4113,
|
||||
"uniqueMechanicsEvidence": 1524
|
||||
"uniqueMechanicsEvidence": 1530
|
||||
},
|
||||
"resolvedRuntimeObservations": [
|
||||
{
|
||||
@@ -272,11 +291,15 @@
|
||||
"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."
|
||||
],
|
||||
"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": [
|
||||
"demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.",
|
||||
"zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.",
|
||||
"4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.",
|
||||
"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.",
|
||||
"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",
|
||||
"zoneId": 137,
|
||||
"sourceRows": 249,
|
||||
"runtimeSpawns": 56,
|
||||
"runtimeEntities": 11,
|
||||
"bossObjectives": 0,
|
||||
"runtimeSpawns": 84,
|
||||
"runtimeEntities": 21,
|
||||
"bossObjectives": 5,
|
||||
"mechanicsStatus": "review-required",
|
||||
"objectiveOrderAuthoritative": false,
|
||||
"objectiveOrderAuthoritative": true,
|
||||
"objectiveOrderProvenance": {
|
||||
"authoritative": false,
|
||||
"kind": "healerMan-distance-derived",
|
||||
"authoritative": true,
|
||||
"kind": "source-authored",
|
||||
"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": [],
|
||||
"runtimeAttacks": 21,
|
||||
"genericDefaultAttacks": 11,
|
||||
"objectiveRouteEvidence": [
|
||||
{
|
||||
"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,
|
||||
"recipeAuthoredAttacks": 0,
|
||||
"catalogBossMechanics": 0,
|
||||
"sourceBackedBossMechanics": 0,
|
||||
"sourceGapBossMechanics": 0,
|
||||
"telegraphedBossMechanics": 0,
|
||||
"catalogBossMechanics": 55,
|
||||
"sourceBackedBossMechanics": 54,
|
||||
"sourceGapBossMechanics": 1,
|
||||
"telegraphedBossMechanics": 32,
|
||||
"healthTriggeredBossMechanics": 0,
|
||||
"sourceBackedDamageFamilyAttacks": 10,
|
||||
"nativeBindings": 11,
|
||||
"sourceBackedDamageFamilyAttacks": 41,
|
||||
"nativeBindings": 21,
|
||||
"proceduralBindings": 0,
|
||||
"proxyBindings": 0,
|
||||
"sourceSpellTemplates": 6,
|
||||
"sourceSpellAssignments": 17,
|
||||
"scriptedTemplates": 15,
|
||||
"objectiveRoutes": 0,
|
||||
"blockedObjectiveRoutes": 0,
|
||||
"sourceOrderedObjectiveRoutes": 0,
|
||||
"scriptedTemplates": 21,
|
||||
"objectiveRoutes": 5,
|
||||
"blockedObjectiveRoutes": 3,
|
||||
"sourceOrderedObjectiveRoutes": 5,
|
||||
"derivedObjectiveRoutes": 0,
|
||||
"unreachableDerivedObjectiveRoutes": 0
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# RuneWaker gameplay content audit
|
||||
|
||||
Structural validation: **green**
|
||||
Structural validation: **blocked**
|
||||
Original-mechanics fidelity: **review-required**
|
||||
|
||||
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
|
||||
|
||||
- 30 dungeons, 8199 source rows, 4086 static spawns, and 436 combat entities.
|
||||
- 61 boss objectives and 1068 executable attacks.
|
||||
- Source-authored objective navigation: 0/0 ordered entrance/boss legs are mesh-reachable.
|
||||
- 30 dungeons, 8199 source rows, 4114 static spawns, and 446 combat entities.
|
||||
- 66 boss objectives and 1133 executable attacks.
|
||||
- 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.
|
||||
- Animation entity bindings: 435 native, 1 static procedural, and 0 proxy.
|
||||
- 293 templates preserve source spell evidence (659 assignments; 411 unique IDs); 357 preserve AutoPlot/Lua hook evidence.
|
||||
- 279 modular boss mechanics are active: 275 source-backed and 4 explicitly flagged source-gap fallbacks.
|
||||
- Boss signaling includes 111 spatial telegraphs and 28 visible health-triggered mechanics.
|
||||
- The exhaustive encounter implementation ledger tracks 4113 deferred source rows and 1524 unique mechanics evidence records without inflating overlapping regex labels.
|
||||
- Animation entity bindings: 445 native, 1 static procedural, and 0 proxy.
|
||||
- 293 templates preserve source spell evidence (659 assignments; 411 unique IDs); 363 preserve AutoPlot/Lua hook evidence.
|
||||
- 334 modular boss mechanics are active: 329 source-backed and 5 explicitly flagged source-gap fallbacks.
|
||||
- Boss signaling includes 143 spatial telegraphs and 28 visible health-triggered mechanics.
|
||||
- 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
|
||||
|
||||
@@ -28,9 +28,9 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
|
||||
|
||||
## 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.
|
||||
- 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 power fields, and the exact native formula shape/provenance.
|
||||
- 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -91,8 +91,11 @@ Animation columns are native/procedural/proxy entity bindings.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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: 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
|
||||
- door: 22
|
||||
@@ -130,7 +133,9 @@ Source-mechanics review: 357 templates preserve AutoPlot/Lua evidence; supported
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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);
|
||||
const template = templateById.get(row.templateId);
|
||||
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 + ".");
|
||||
}
|
||||
}
|
||||
@@ -651,8 +652,13 @@ for (const recipeName of recipeNames) {
|
||||
}
|
||||
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) {
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -702,8 +708,15 @@ for (const recipeName of recipeNames) {
|
||||
!route.orderAuthoritative && !route.reachable
|
||||
)).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) => (
|
||||
["combat", "boss"].includes(template.classification) && sourceTemplateIds.has(template.id)
|
||||
["combat", "boss"].includes(template.classification)
|
||||
&& (sourceTemplateIds.has(template.id) || scriptedTemplateIds.has(template.id))
|
||||
)).length;
|
||||
if (expectedEntities !== Object.keys(entities).length) {
|
||||
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));
|
||||
}
|
||||
|
||||
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) {
|
||||
const ooze = template.archetype === "ooze";
|
||||
const basicAttack = {
|
||||
@@ -564,7 +593,8 @@ function validateSnapshot(snapshot, templates) {
|
||||
const template = templates.get(row.templateId);
|
||||
if (!template) throw new Error("Unmapped RuneWaker 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 + ".");
|
||||
}
|
||||
const actor = recipe.actors.find((entry) => entry.id === template.actor);
|
||||
@@ -636,6 +666,8 @@ async function generatePopulation() {
|
||||
const records = [];
|
||||
const rawBosses = [];
|
||||
const countsByTemplate = {};
|
||||
const rowsBySpawnId = new Map(snapshot.rows.map((row) => [row.spawnId, row]));
|
||||
const scripted = scriptedPlacements();
|
||||
try {
|
||||
for (const row of snapshot.rows) {
|
||||
const template = templates.get(row.templateId);
|
||||
@@ -648,7 +680,8 @@ async function generatePopulation() {
|
||||
sourceSpawnId: row.spawnId,
|
||||
sourceTemplateId: row.templateId,
|
||||
name: row.name,
|
||||
classification: row.classification,
|
||||
classification: template.classification,
|
||||
sourceClassification: row.classification,
|
||||
sourcePosition: row.sourcePosition,
|
||||
sourceDirection: row.direction,
|
||||
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 {
|
||||
projector.dispose();
|
||||
}
|
||||
@@ -720,6 +819,7 @@ async function generatePopulation() {
|
||||
zoneId: recipe.zoneId,
|
||||
sourceRowCount: snapshot.rows.length,
|
||||
importedSpawnCount: staticSpawns.length,
|
||||
scriptedSpawnCount: scripted.length,
|
||||
excludedRowCount: excluded.length,
|
||||
bossSpawnCount: bosses.length,
|
||||
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.summary.dungeons, 30);
|
||||
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.templateEvidenceSignals, 1_195);
|
||||
assert.equal(report.summary.templateEvidenceSignals, 1_201);
|
||||
|
||||
const inventory = report.dungeons.flatMap((dungeon) => [
|
||||
...dungeon.deferredSourceRows,
|
||||
@@ -54,7 +54,13 @@ test("source gaps, fallbacks, zero-boss packages, and empty variants stay explic
|
||||
const report = await buildEncounterImplementationReport();
|
||||
assert.deepEqual(
|
||||
report.specialCases.fallbackBosses.map((boss) => boss.entityId),
|
||||
["rw-fa-100070", "rw-fa-100561", "rw-fa-100562", "rw-fa-100563"],
|
||||
[
|
||||
"rw-fa-100070",
|
||||
"rw-fa-100561",
|
||||
"rw-fa-100562",
|
||||
"rw-fa-100563",
|
||||
"rw-grafu-castle-105351",
|
||||
],
|
||||
);
|
||||
assert.ok(report.specialCases.fallbackBosses.every((boss) => (
|
||||
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",
|
||||
"dungeon-of-dalanis",
|
||||
"grafu-castle",
|
||||
"kal-turok-hive",
|
||||
"lair-demon-dragon",
|
||||
"tomb-seven-heroes",
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { inflateSync } from "node:zlib";
|
||||
import sharp from "sharp";
|
||||
import { vocationGrowth } from "./vocation-growth.mjs";
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(scriptDirectory, "../..");
|
||||
@@ -38,17 +39,6 @@ const RACE_MATRIX = {
|
||||
"rom-dwarf": [1, 3, 4, 5, 9, 10],
|
||||
};
|
||||
|
||||
function vocationGrowth(classIndex) {
|
||||
const recordSize = 816;
|
||||
const offset = DATA_START + classIndex * recordSize + 8;
|
||||
const values = Array.from({ length: 21 }, (_, index) => cleanFloat(float32(buffers.vocationTable, offset + index * 4)));
|
||||
const keys = ["strength", "stamina", "intellect", "wisdom", "agility", "health", "mana"];
|
||||
return Object.fromEntries(keys.map((key, index) => [key, {
|
||||
levelOne: values[index],
|
||||
flatGrowth: values[index + 7],
|
||||
percentGrowth: values[index + 14],
|
||||
}]));
|
||||
}
|
||||
const DATA_START = 140;
|
||||
const LEARN_RECORD_SIZE = 12184;
|
||||
const LEARN_ENTRY_SIZE = 24;
|
||||
@@ -578,7 +568,7 @@ const catalog = {
|
||||
schemaVersion: 1,
|
||||
source: { rootHint: "RuneWaker source snapshot", hashes: sourceHashes },
|
||||
canonicalClasses: CLASS_IDS.map((id, index) => ({ id, sourceClassId: index + 1, name: CLASS_NAMES[index], resource: RESOURCES[index] })),
|
||||
baseStatGrowth: Object.fromEntries(CLASS_IDS.map((id, index) => [id, vocationGrowth(index + 1)])),
|
||||
baseStatGrowth: Object.fromEntries(CLASS_IDS.map((id, index) => [id, vocationGrowth(buffers.vocationTable, index + 1)])),
|
||||
raceMatrix: Object.fromEntries(Object.entries(RACE_MATRIX).map(([raceId, classes]) => [raceId, classes.map((index) => CLASS_IDS[index - 1])])),
|
||||
eliteLevels: ELITE_LEVELS,
|
||||
skills,
|
||||
|
||||
@@ -44,6 +44,8 @@ const config = await readJson(configFile);
|
||||
const dungeon = (config.dungeons ?? []).find((entry) => entry.id === dungeonConfigId);
|
||||
if (!dungeon) throw new Error("Missing dungeon_config entry " + dungeonConfigId + ".");
|
||||
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 = [
|
||||
{ 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();
|
||||
for (const row of snapshot.rows ?? []) {
|
||||
const template = templatesById.get(row.templateId);
|
||||
const metadata = templateMetadata.get(row.templateId) ?? {
|
||||
templateId: row.templateId,
|
||||
name: row.name,
|
||||
classification: row.classification,
|
||||
name: template?.name ?? row.name,
|
||||
classification: template?.classification ?? row.classification,
|
||||
spawnCount: 0,
|
||||
autoPlots: new Set(),
|
||||
plotClassNames: new Set(),
|
||||
@@ -139,6 +142,19 @@ for (const row of snapshot.rows ?? []) {
|
||||
}
|
||||
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()]
|
||||
.map((entry) => ({
|
||||
@@ -155,7 +171,7 @@ const templateCandidates = [...templateMetadata.values()]
|
||||
.sort((left, right) => left.templateId - right.templateId);
|
||||
|
||||
const bossRows = (snapshot.rows ?? [])
|
||||
.filter((row) => row.classification === "boss")
|
||||
.filter((row) => templatesById.get(row.templateId)?.classification === "boss")
|
||||
.map((row) => ({
|
||||
spawnId: row.spawnId,
|
||||
templateId: row.templateId,
|
||||
@@ -164,6 +180,26 @@ const bossRows = (snapshot.rows ?? [])
|
||||
autoPlot: clean(row.source?.autoPlot) || 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 }) => [
|
||||
kind,
|
||||
luaSignals.filter((signal) => signal.kinds.includes(kind)).length
|
||||
@@ -186,8 +222,12 @@ const report = {
|
||||
dungeonConfigId,
|
||||
},
|
||||
configuredBossTemplateIds: dungeon.boss_guids ?? [],
|
||||
reviewedBossTemplateIds: recipe.templates
|
||||
.filter((template) => template.classification === "boss")
|
||||
.map((template) => template.id),
|
||||
configuredBossPositions: dungeon.boss_positions ?? {},
|
||||
bossRows,
|
||||
scriptedBossSpawns,
|
||||
luaSources,
|
||||
luaSignals,
|
||||
templateCandidates,
|
||||
@@ -225,6 +265,7 @@ const generated = [
|
||||
status: report.status,
|
||||
categories: report.categories,
|
||||
bossRows: report.bossRows,
|
||||
scriptedBossSpawns: report.scriptedBossSpawns,
|
||||
templateCandidates: report.templateCandidates,
|
||||
reviewQueue: report.reviewQueue,
|
||||
sourceReport: projectPath(reportFile),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
optionValue,
|
||||
pipelineDirectory,
|
||||
projectPath,
|
||||
projectRoot,
|
||||
@@ -16,9 +17,12 @@ const spellCatalogFile = path.join(
|
||||
"snapshots",
|
||||
"runewaker-npc-spell-catalog.json",
|
||||
);
|
||||
const recipeNames = (await readdir(recipesDirectory))
|
||||
.filter((name) => name.endsWith("-population.json"))
|
||||
.sort();
|
||||
const requestedRecipe = optionValue(process.argv.slice(2), "--recipe");
|
||||
const recipeNames = requestedRecipe
|
||||
? [path.basename(requestedRecipe)]
|
||||
: (await readdir(recipesDirectory))
|
||||
.filter((name) => name.endsWith("-population.json"))
|
||||
.sort();
|
||||
|
||||
function slugify(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 catalog = await readJson(mechanicCatalogFile);
|
||||
const runewakerBosses = {};
|
||||
const replacedRuntimePrefixes = new Set();
|
||||
let sourceBacked = 0;
|
||||
let sourceGap = 0;
|
||||
const firstRecipe = await readJson(path.join(recipesDirectory, recipeNames[0]));
|
||||
@@ -423,6 +428,7 @@ const sharedFunctions = functionIndex(sharedLuaSources);
|
||||
|
||||
for (const recipeName of recipeNames) {
|
||||
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 rowsByTemplate = new Map();
|
||||
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")) {
|
||||
const rows = rowsByTemplate.get(Number(template.id)) ?? [];
|
||||
if (!rows.length) continue;
|
||||
const roots = [...new Set(rows.flatMap((row) => [
|
||||
rootName(row.source?.autoPlot),
|
||||
rootName(row.source?.plotClassName),
|
||||
...Object.values(row.source?.templateScripts ?? {}).map(rootName),
|
||||
]).filter(Boolean))];
|
||||
const roots = [...new Set([
|
||||
...(template.source?.scriptRoots ?? []).map(rootName),
|
||||
...rows.flatMap((row) => [
|
||||
rootName(row.source?.autoPlot),
|
||||
rootName(row.source?.plotClassName),
|
||||
...Object.values(row.source?.templateScripts ?? {}).map(rootName),
|
||||
]),
|
||||
].filter(Boolean))];
|
||||
if (!rows.length && !roots.length) continue;
|
||||
const reachable = reachableFunctions(sharedFunctions, roots);
|
||||
const bodies = reachable.map((entry) => entry.body);
|
||||
const scriptSpellIds = [...new Set(bodies.flatMap((body) => (
|
||||
@@ -500,7 +509,11 @@ for (const recipeName of recipeNames) {
|
||||
}
|
||||
|
||||
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)),
|
||||
]);
|
||||
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-100562",
|
||||
"rw-fa-100563",
|
||||
"rw-grafu-castle-105351",
|
||||
]);
|
||||
const EXPECTED_NON_EMPTY_ZERO_BOSS_PACKAGES = Object.freeze([
|
||||
"aeternal-circle",
|
||||
"dungeon-of-dalanis",
|
||||
"grafu-castle",
|
||||
"kal-turok-hive",
|
||||
"lair-demon-dragon",
|
||||
"tomb-seven-heroes",
|
||||
|
||||
@@ -148,6 +148,7 @@ export function validatePopulationRecipe(recipe, file = "population recipe") {
|
||||
actorIds.add(actor.id);
|
||||
}
|
||||
const templateIds = new Set();
|
||||
const templatesById = new Map();
|
||||
const classifications = new Set(["combat", "boss", "deferred-object", "unclassified"]);
|
||||
for (const template of recipe.templates) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,273 @@
|
||||
"sourceDungeonConfigId": "grafu_castle",
|
||||
"zoneId": 137,
|
||||
"expectedActiveRows": 249,
|
||||
"expectedBossSpawns": 0,
|
||||
"expectedBossSpawns": 5,
|
||||
"runtimeIdPrefix": "rw-grafu-castle",
|
||||
"coordinateSystem": {
|
||||
"metersPerSourceUnit": 0.1,
|
||||
"directionUnitsPerTurn": 360,
|
||||
"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": {
|
||||
"environmentRecipe": "scripts/runewaker-pipeline/recipes/grafu-castle.json",
|
||||
"snapshot": "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json",
|
||||
@@ -43,6 +303,21 @@
|
||||
"serverDefault": ".\\HEALERMAN_RW"
|
||||
},
|
||||
"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",
|
||||
"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",
|
||||
"runtimeRotationY": 0
|
||||
},
|
||||
{
|
||||
"id": "cursed-steel-guardian",
|
||||
"sourceModel": "model/character/creature/lost_protector/act_cursed_steel_guardian.ros",
|
||||
"runtimeRotationY": 0
|
||||
},
|
||||
{
|
||||
"id": "elder-myconid",
|
||||
"sourceModel": "model/character/creature/elder_myconid/act_elder_myconid.ros",
|
||||
@@ -83,6 +363,11 @@
|
||||
"sourceModel": "model/character/pc/human/male/act_hm_male.ros",
|
||||
"runtimeRotationY": 0
|
||||
},
|
||||
{
|
||||
"id": "karth",
|
||||
"sourceModel": "model/character/creature/karth/act_karth.ros",
|
||||
"runtimeRotationY": 0
|
||||
},
|
||||
{
|
||||
"id": "karth-01",
|
||||
"sourceModel": "model/character/creature/karth/act_karth_01.ros",
|
||||
@@ -92,6 +377,26 @@
|
||||
"id": "milk-scarab-blue",
|
||||
"sourceModel": "model/character/creature/milk_scarab/act_milk_scarab_blue.ros",
|
||||
"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": [
|
||||
@@ -202,6 +507,25 @@
|
||||
"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%)",
|
||||
"accentColor": "hsl(63 38% 64%)",
|
||||
@@ -223,13 +547,13 @@
|
||||
"id": 105210,
|
||||
"name": "Template 105210",
|
||||
"level": 10,
|
||||
"classification": "combat",
|
||||
"classification": "deferred-object",
|
||||
"archetype": "humanoid",
|
||||
"actor": "cereus-01",
|
||||
"reason": "Source boss controller for Ardmond and Issac; the controller is not a combat mob.",
|
||||
"source": {
|
||||
"imageId": 583949,
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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%)",
|
||||
"accentColor": "hsl(206 38% 64%)",
|
||||
"id": 105254,
|
||||
"name": "Template 105254",
|
||||
"level": 1,
|
||||
"classification": "combat",
|
||||
"classification": "deferred-object",
|
||||
"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": {
|
||||
"imageId": 576437,
|
||||
"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,
|
||||
"name": "Template 105258",
|
||||
"level": 1,
|
||||
"classification": "combat",
|
||||
"classification": "deferred-object",
|
||||
"archetype": "plant",
|
||||
"actor": "elder-myconid",
|
||||
"reason": "Source proximity and ambient-rabbit trigger; it is not a combat mob.",
|
||||
"source": {
|
||||
"imageId": 576437,
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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%)",
|
||||
"accentColor": "hsl(80 38% 64%)",
|
||||
@@ -444,6 +935,21 @@
|
||||
"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%)",
|
||||
"accentColor": "hsl(268 38% 64%)",
|
||||
@@ -519,6 +1025,25 @@
|
||||
"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%)",
|
||||
"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("environment id", environment.dungeonId === recipe.dungeonId, String(environment.dungeonId));
|
||||
check("population report green", report.status === "green", String(report.status));
|
||||
check("population accounting", report.sourceRowCount === report.importedSpawnCount + report.excludedRowCount,
|
||||
report.sourceRowCount + " = " + report.importedSpawnCount + " + " + report.excludedRowCount);
|
||||
const expectedScriptedSpawns = (recipe.scriptedSpawns ?? []).length
|
||||
+ (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,
|
||||
String(report.navmeshProjection.maximumCombatDistance));
|
||||
check("mechanics is an explicit review gate", mechanics.status === "review-required" || mechanics.status === "green", String(mechanics.status));
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const DATA_START = 140;
|
||||
const RECORD_SIZE = 408;
|
||||
const STAT_KEYS = ["strength", "stamina", "intellect", "wisdom", "agility", "health", "mana"];
|
||||
|
||||
/** Players use FixArgTableID = 1; each vocation has two adjacent source records. */
|
||||
export function vocationGrowth(buffer, classIndex) {
|
||||
const recordId = 301000 + classIndex * 2 + 1;
|
||||
const offset = DATA_START + (classIndex * 2 + 1) * RECORD_SIZE;
|
||||
if (offset + RECORD_SIZE > buffer.length
|
||||
|| buffer.readInt32LE(offset) !== recordId || buffer.readInt32LE(offset + 4) !== recordId) {
|
||||
throw new Error(`Player vocation ${classIndex} record sentinel drifted.`);
|
||||
}
|
||||
const values = Array.from({ length: 21 }, (_, index) => {
|
||||
const value = buffer.readFloatLE(offset + 8 + index * 4);
|
||||
if (!Number.isFinite(value)) throw new Error(`Player vocation ${classIndex} has invalid growth.`);
|
||||
return Number(value.toFixed(5));
|
||||
});
|
||||
return Object.fromEntries(STAT_KEYS.map((key, index) => [key, {
|
||||
levelOne: values[index],
|
||||
flatGrowth: values[index + 7],
|
||||
percentGrowth: values[index + 14],
|
||||
}]));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { vocationGrowth } from "./vocation-growth.mjs";
|
||||
|
||||
test("reads the player table instead of the adjacent alternate growth table", () => {
|
||||
const buffer = Buffer.alloc(140 + 22 * 408);
|
||||
const offset = 140 + 21 * 408;
|
||||
buffer.writeInt32LE(301021, offset);
|
||||
buffer.writeInt32LE(301021, offset + 4);
|
||||
const values = [14, 13, 5, 6, 12, 60, 20, 5, 4.5, 1.9, 2.1, 4.25, 2, 2, ...Array(7).fill(0.02)];
|
||||
values.forEach((value, index) => buffer.writeFloatLE(value, offset + 8 + index * 4));
|
||||
buffer.writeFloatLE(2.8, offset - 408 + 8 + 14 * 4);
|
||||
assert.deepEqual(vocationGrowth(buffer, 10).strength, { levelOne: 14, flatGrowth: 5, percentGrowth: 0.02 });
|
||||
buffer.writeInt32LE(301020, offset);
|
||||
assert.throws(() => vocationGrowth(buffer, 10), /sentinel drifted/);
|
||||
});
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from "./manastorm-config.mjs";
|
||||
import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena-rules.mjs";
|
||||
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
|
||||
import { createDungeonReportRepository } from "./dungeon-reports.mjs";
|
||||
import { createDungeonLeaderboardRepository } from "./dungeon-leaderboards.mjs";
|
||||
|
||||
const scrypt = promisify(scryptCallback);
|
||||
|
||||
@@ -895,6 +897,8 @@ export function openGameDatabase(options = {}) {
|
||||
|
||||
return {
|
||||
databasePath,
|
||||
...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
|
||||
...createDungeonLeaderboardRepository(database, { ErrorType: GameDatabaseError }),
|
||||
register,
|
||||
login,
|
||||
authenticate,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
|
||||
|
||||
const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry]));
|
||||
const classes = new Set(catalog.classes.map((entry) => entry.id));
|
||||
|
||||
export function createDungeonLeaderboardRepository(database, { ErrorType }) {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dungeon_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
dungeon_id TEXT NOT NULL,
|
||||
difficulty_id TEXT NOT NULL,
|
||||
duration_ms INTEGER NOT NULL CHECK (duration_ms > 0),
|
||||
full_clear INTEGER NOT NULL CHECK (full_clear IN (0, 1)),
|
||||
finished_at INTEGER NOT NULL,
|
||||
party_json TEXT NOT NULL,
|
||||
bosses_json TEXT NOT NULL,
|
||||
submitted_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
|
||||
received_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS dungeon_runs_times_idx
|
||||
ON dungeon_runs(dungeon_id, difficulty_id, duration_ms, finished_at, id);
|
||||
CREATE INDEX IF NOT EXISTS dungeon_runs_full_times_idx
|
||||
ON dungeon_runs(dungeon_id, difficulty_id, full_clear, duration_ms);
|
||||
`);
|
||||
const byId = database.prepare("SELECT * FROM dungeon_runs WHERE id = ?");
|
||||
const insert = database.prepare(`INSERT INTO dungeon_runs
|
||||
(id, dungeon_id, difficulty_id, duration_ms, full_clear, finished_at, party_json, bosses_json, submitted_by_account_id, received_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
const fail = (message) => { throw new ErrorType(message, 400, "invalid_dungeon_run"); };
|
||||
const record = (row) => ({ id: row.id, dungeonId: row.dungeon_id, difficultyId: row.difficulty_id,
|
||||
durationMs: Number(row.duration_ms), finishedAt: Number(row.finished_at), fullClear: row.full_clear === 1,
|
||||
party: JSON.parse(row.party_json), killedBossIds: JSON.parse(row.bosses_json) });
|
||||
function selection(input) {
|
||||
const dungeon = dungeons.get(input.dungeonId);
|
||||
if (!dungeon) fail("Choose a known dungeon.");
|
||||
const difficultyId = input.difficultyId || dungeon.defaultDifficultyId;
|
||||
if (!dungeon.difficulties.some((entry) => entry.id === difficultyId)) fail("Choose a supported dungeon difficulty.");
|
||||
const difficulty = dungeon.difficulties.find((entry) => entry.id === difficultyId);
|
||||
return { dungeon, difficultyId, difficulty };
|
||||
}
|
||||
function placement(run) {
|
||||
const counts = database.prepare(`SELECT COUNT(*) AS total,
|
||||
COALESCE(SUM(CASE WHEN duration_ms < ? THEN 1 ELSE 0 END), 0) AS faster,
|
||||
COALESCE(SUM(full_clear), 0) AS full_total,
|
||||
COALESCE(SUM(CASE WHEN full_clear = 1 AND duration_ms < ? THEN 1 ELSE 0 END), 0) AS full_faster
|
||||
FROM dungeon_runs WHERE dungeon_id = ? AND difficulty_id = ?`)
|
||||
.get(run.durationMs, run.durationMs, run.dungeonId, run.difficultyId);
|
||||
return { finalRank: Number(counts.faster) + 1, finalTotal: Number(counts.total),
|
||||
fullRank: run.fullClear ? Number(counts.full_faster) + 1 : null, fullTotal: Number(counts.full_total) };
|
||||
}
|
||||
return {
|
||||
submitDungeonRun(account, input) {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) fail("Provide a completed dungeon run.");
|
||||
const { dungeon, difficultyId, difficulty } = selection(input);
|
||||
if (!difficulty.finalBossId) fail("This dungeon does not have a configured final boss yet.");
|
||||
if (typeof input.id !== "string" || !/^[A-Za-z0-9_-]{8,100}$/.test(input.id)) fail("Provide a valid run id.");
|
||||
if (!Number.isSafeInteger(input.durationMs) || input.durationMs < 1 || input.durationMs > 7 * 24 * 60 * 60 * 1000)
|
||||
fail("Provide a valid completion time.");
|
||||
if (!Number.isSafeInteger(input.finishedAt) || input.finishedAt < 0 || input.finishedAt > Date.now() + 60_000)
|
||||
fail("Provide a valid finish timestamp.");
|
||||
if (!Array.isArray(input.killedBossIds) || input.killedBossIds.length > 100
|
||||
|| input.killedBossIds.some((id) => !difficulty.bossIds.includes(id))) fail("Provide bosses from this dungeon and difficulty.");
|
||||
const killedBossIds = [...new Set(input.killedBossIds)].sort();
|
||||
if (!killedBossIds.includes(difficulty.finalBossId)) fail("The final boss must be defeated before recording a run.");
|
||||
if (!Array.isArray(input.party) || input.party.length < 1 || input.party.length > 5) fail("Provide the run's party.");
|
||||
const party = input.party.map((member) => {
|
||||
if (!member || typeof member !== "object" || typeof member.name !== "string" || !member.name.trim()
|
||||
|| member.name.length > 40 || !classes.has(member.classId)
|
||||
|| !Number.isInteger(member.level) || member.level < 1 || member.level > 80
|
||||
|| !["tank", "healer", "damage"].includes(member.role) || typeof member.npc !== "boolean"
|
||||
|| (member.secondaryClassId !== undefined && !classes.has(member.secondaryClassId))) fail("Provide valid party classes and levels.");
|
||||
return { name: member.name.trim(), classId: member.classId, level: member.level,
|
||||
role: member.role, npc: member.npc,
|
||||
...(member.secondaryClassId ? { secondaryClassId: member.secondaryClassId } : {}) };
|
||||
});
|
||||
const fullClear = difficulty.bossIds.every((id) => killedBossIds.includes(id));
|
||||
const run = { id: input.id, dungeonId: dungeon.id, difficultyId, durationMs: input.durationMs,
|
||||
finishedAt: input.finishedAt, killedBossIds, party, fullClear };
|
||||
const existing = byId.get(run.id);
|
||||
if (existing) {
|
||||
if (JSON.stringify(record(existing)) !== JSON.stringify({ id: run.id, dungeonId: run.dungeonId,
|
||||
difficultyId: run.difficultyId, durationMs: run.durationMs, finishedAt: run.finishedAt,
|
||||
fullClear: run.fullClear, party: run.party, killedBossIds: run.killedBossIds }))
|
||||
throw new ErrorType("That run id already belongs to a different result.", 409, "run_conflict");
|
||||
} else insert.run(run.id, run.dungeonId, run.difficultyId, run.durationMs, fullClear ? 1 : 0,
|
||||
run.finishedAt, JSON.stringify(party), JSON.stringify(killedBossIds), account?.id ?? null, Date.now());
|
||||
return { run, placement: placement(run) };
|
||||
},
|
||||
getDungeonRun(id) {
|
||||
const row = byId.get(id);
|
||||
if (!row) throw new ErrorType("That dungeon run is not recorded yet.", 404, "run_not_found");
|
||||
const run = record(row);
|
||||
return { run, placement: placement(run) };
|
||||
},
|
||||
listDungeonLeaderboard(input) {
|
||||
const { dungeon, difficultyId } = selection(input);
|
||||
const filter = input.clear || "final";
|
||||
if (!["full", "final"].includes(filter)) fail("Choose full dungeon clears or final boss clears.");
|
||||
const offset = Number(input.offset ?? 0);
|
||||
const limit = Number(input.limit ?? 20);
|
||||
if (!Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000
|
||||
|| !Number.isSafeInteger(limit) || limit < 1 || limit > 100) fail("Choose a valid leaderboard page.");
|
||||
const condition = filter === "full" ? "AND full_clear = 1" : "";
|
||||
const rows = database.prepare(`SELECT *, RANK() OVER (ORDER BY duration_ms) AS place FROM dungeon_runs
|
||||
WHERE dungeon_id = ? AND difficulty_id = ? ${condition}
|
||||
ORDER BY duration_ms, finished_at, id LIMIT ? OFFSET ?`).all(dungeon.id, difficultyId, limit, offset);
|
||||
const total = database.prepare(`SELECT COUNT(*) AS total FROM dungeon_runs
|
||||
WHERE dungeon_id = ? AND difficulty_id = ? ${condition}`).get(dungeon.id, difficultyId).total;
|
||||
return { entries: rows.map((row) => ({ ...record(row), rank: Number(row.place) })), total: Number(total) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { openGameDatabase } from "./database.mjs";
|
||||
import { createGameServer } from "./server.mjs";
|
||||
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
|
||||
|
||||
function run(id, durationMs, fullClear = true, dungeonId = "deadmines", difficultyId) {
|
||||
const dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId);
|
||||
return { id, dungeonId, difficultyId: difficultyId ?? dungeon.defaultDifficultyId, durationMs,
|
||||
finishedAt: 1700000000000, killedBossIds: fullClear ? dungeon.bosses.map((boss) => boss.id) : [dungeon.finalBossId],
|
||||
party: [{ name: "Tester", classId: "priest", level: 20, role: "healer", npc: false },
|
||||
{ name: "Garrick", classId: "warrior", level: 20, role: "tank", npc: true }] };
|
||||
}
|
||||
async function withDatabase(callback) {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-leaderboard-test-"));
|
||||
let database = openGameDatabase({ dataDir: directory });
|
||||
try { await callback(database, () => { database.close(); database = openGameDatabase({ dataDir: directory }); return database; }); }
|
||||
finally { database.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||
}
|
||||
test("persists ranked party/boss records, derives all-boss eligibility, handles ties, and retries without duplicates", () => withDatabase((database, reopen) => {
|
||||
database.submitDungeonRun(null, run("run-fastest", 1000, false));
|
||||
database.submitDungeonRun(null, run("run-full-one", 2000));
|
||||
database.submitDungeonRun(null, run("run-full-tie", 2000));
|
||||
const last = run("run-slowest", 3000);
|
||||
const result = database.submitDungeonRun(null, last);
|
||||
assert.deepEqual(result.placement, { finalRank: 4, finalTotal: 4, fullRank: 3, fullTotal: 3 });
|
||||
assert.deepEqual(database.submitDungeonRun(null, last), result);
|
||||
assert.throws(() => database.submitDungeonRun(null, { ...last, durationMs: 5 }), { status: 409 });
|
||||
const final = database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "final" });
|
||||
assert.deepEqual(final.entries.map((entry) => entry.rank), [1, 2, 2, 4]);
|
||||
const full = database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "full" });
|
||||
assert.deepEqual(full.entries.map((entry) => entry.rank), [1, 1, 3]);
|
||||
assert.deepEqual(full.entries[0].party, last.party);
|
||||
assert.equal(full.entries[0].killedBossIds.length, last.killedBossIds.length);
|
||||
const page = database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "final", limit: 2, offset: 2 });
|
||||
assert.equal(page.total, 4); assert.deepEqual(page.entries.map((entry) => entry.rank), [2, 4]);
|
||||
database = reopen();
|
||||
assert.deepEqual(database.getDungeonRun(last.id), result);
|
||||
}));
|
||||
test("validates final boss, dungeon/difficulty, duration and party without trusting client full-clear flags", () => withDatabase((database) => {
|
||||
const valid = run("valid-run-id", 1000, false);
|
||||
for (const invalid of [{ ...valid, durationMs: 0 }, { ...valid, durationMs: Infinity },
|
||||
{ ...valid, killedBossIds: [] }, { ...valid, killedBossIds: ["fake-boss"] },
|
||||
{ ...valid, dungeonId: "unknown" }, { ...valid, difficultyId: "fake" },
|
||||
{ ...valid, party: [] }, { ...valid, party: [{ ...valid.party[0], classId: "fake" }] },
|
||||
{ ...valid, party: [{ ...valid.party[0], level: 81 }] }])
|
||||
assert.throws(() => database.submitDungeonRun(null, invalid), { code: "invalid_dungeon_run" });
|
||||
assert.equal(database.submitDungeonRun(null, { ...valid, fullClear: true }).run.fullClear, false);
|
||||
const caves = run("barren-caves-test", 2000, true, "barren-caves");
|
||||
caves.party[0].classId = "rom-priest";
|
||||
assert.equal(database.submitDungeonRun(null, caves).placement.fullRank, 1);
|
||||
assert.equal(database.listDungeonLeaderboard({ dungeonId: "deadmines" }).total, 1);
|
||||
assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "bad" }), { status: 400 });
|
||||
assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", offset: -1 }), { status: 400 });
|
||||
}));
|
||||
test("public HTTP leaderboards accept completion records and return results with rank while rejecting unfinished runs", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-leaderboard-http-"));
|
||||
const { server } = createGameServer({ dataDir: directory, enableShelvedModes: false });
|
||||
try {
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const payload = run("http-caves-clear", 15555, true, "barren-caves");
|
||||
const response = await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
|
||||
assert.equal(response.status, 201);
|
||||
const result = await response.json(); assert.equal(result.placement.finalRank, 1);
|
||||
const leaderboard = await fetch(`${base}/api/dungeon-leaderboards?dungeon=barren-caves&clear=full`);
|
||||
assert.equal(leaderboard.status, 200); assert.equal((await leaderboard.json()).entries[0].durationMs, 15555);
|
||||
assert.deepEqual(await (await fetch(`${base}/api/dungeon-runs/${payload.id}`)).json(), result);
|
||||
const bad = await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...payload, id: "unfinished-run", killedBossIds: [] }) });
|
||||
assert.equal(bad.status, 400);
|
||||
const unauthorized = await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { Authorization: "Bearer invalid", "Content-Type": "application/json" }, body: JSON.stringify(payload) });
|
||||
assert.equal(unauthorized.status, 401);
|
||||
} finally { await new Promise((resolve) => server.close(resolve)); rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("separates normal and heroic times and counts only bosses that exist on each difficulty", () => withDatabase((database) => {
|
||||
const dungeon = catalog.dungeons.find((entry) => entry.difficulties.length > 1 && entry.difficulties.every((difficulty) => difficulty.finalBossId));
|
||||
for (const difficulty of dungeon.difficulties) {
|
||||
const payload = run(`difficulty-${difficulty.id}-test`, 1000, true, dungeon.id, difficulty.id);
|
||||
payload.killedBossIds = difficulty.bossIds;
|
||||
const result = database.submitDungeonRun(null, payload);
|
||||
assert.equal(result.run.fullClear, true);
|
||||
assert.equal(result.placement.finalRank, 1); assert.equal(result.placement.finalTotal, 1);
|
||||
assert.equal(database.listDungeonLeaderboard({ dungeonId: dungeon.id, difficultyId: difficulty.id, clear: "full" }).total, 1);
|
||||
}
|
||||
}));
|
||||
@@ -0,0 +1,94 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import availability from "../src/game/generated/dungeonAvailability.json" with { type: "json" };
|
||||
import policy from "../shared/dungeon-report-policy.json" with { type: "json" };
|
||||
|
||||
export const REPORT_DUNGEON_IDS = new Set(["forsaken-abbey", ...Object.keys(availability.enabled)]);
|
||||
|
||||
export function createDungeonReportRepository(database, { isGameMaster, ErrorType }) {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dungeon_reports (
|
||||
id TEXT PRIMARY KEY,
|
||||
dungeon_id TEXT NOT NULL,
|
||||
comment TEXT NOT NULL CHECK (length(comment) BETWEEN 1 AND 500),
|
||||
status TEXT NOT NULL CHECK (status IN ('Submitted', 'In-Progress', 'Testing Fixes', 'Closed')),
|
||||
submitted_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
|
||||
submitted_by_username TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
updated_by_username TEXT,
|
||||
revision INTEGER NOT NULL DEFAULT 0
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS dungeon_reports_dungeon_status_idx ON dungeon_reports(dungeon_id, status);
|
||||
`);
|
||||
const insert = database.prepare(`
|
||||
INSERT INTO dungeon_reports (
|
||||
id, dungeon_id, comment, status, submitted_by_account_id, submitted_by_username, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 'Submitted', ?, ?, ?, ?)
|
||||
`);
|
||||
const byId = database.prepare("SELECT * FROM dungeon_reports WHERE id = ?");
|
||||
const list = database.prepare(`
|
||||
SELECT * FROM dungeon_reports
|
||||
WHERE (? IS NULL OR status = ?) AND (? IS NULL OR dungeon_id = ?)
|
||||
ORDER BY created_at DESC, id ASC
|
||||
`);
|
||||
const counts = database.prepare(`
|
||||
SELECT dungeon_id, COUNT(*) AS count FROM dungeon_reports WHERE status != 'Closed' GROUP BY dungeon_id
|
||||
`);
|
||||
const update = database.prepare(`
|
||||
UPDATE dungeon_reports SET status = ?, updated_at = ?, updated_by_username = ?, revision = revision + 1
|
||||
WHERE id = ? AND revision = ? RETURNING *
|
||||
`);
|
||||
|
||||
function validateDungeon(id) {
|
||||
if (!REPORT_DUNGEON_IDS.has(id)) throw new ErrorType("Choose a valid dungeon.", 400, "invalid_dungeon");
|
||||
}
|
||||
function validateStatus(status) {
|
||||
if (!policy.statuses.includes(status)) throw new ErrorType("Choose a valid report status.", 400, "invalid_report_status");
|
||||
}
|
||||
function publicReport(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
dungeonId: row.dungeon_id,
|
||||
comment: row.comment,
|
||||
status: row.status,
|
||||
submittedBy: row.submitted_by_username,
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
updatedBy: row.updated_by_username,
|
||||
revision: Number(row.revision),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
listDungeonReports({ status = null, dungeonId = null } = {}) {
|
||||
if (status !== null) validateStatus(status);
|
||||
if (dungeonId !== null) validateDungeon(dungeonId);
|
||||
return {
|
||||
reports: list.all(status, status, dungeonId, dungeonId).map(publicReport),
|
||||
openCounts: Object.fromEntries(counts.all().map((row) => [row.dungeon_id, Number(row.count)])),
|
||||
};
|
||||
},
|
||||
submitDungeonReport(account, input) {
|
||||
validateDungeon(input?.dungeonId);
|
||||
if (typeof input?.comment !== "string" || !input.comment.trim() || input.comment.length > policy.commentMaxLength) {
|
||||
throw new ErrorType("Describe the issue in 1-500 characters.", 400, "invalid_report_comment");
|
||||
}
|
||||
const id = randomUUID();
|
||||
const now = Date.now();
|
||||
insert.run(id, input.dungeonId, input.comment.replace(/\r\n?/g, "\n").trim(), account?.id ?? null,
|
||||
account?.username ?? "Guest player", now, now);
|
||||
return publicReport(byId.get(id));
|
||||
},
|
||||
updateDungeonReport(account, id, input) {
|
||||
if (!isGameMaster(account)) throw new ErrorType("Game Master access is required.", 403, "gm_required");
|
||||
validateStatus(input?.status);
|
||||
if (!Number.isInteger(input?.expectedRevision) || input.expectedRevision < 0) {
|
||||
throw new ErrorType("A valid expected revision is required.", 400, "invalid_revision");
|
||||
}
|
||||
if (!byId.get(id)) throw new ErrorType("Report not found.", 404, "report_not_found");
|
||||
const row = update.get(input.status, Date.now(), account.username, id, input.expectedRevision);
|
||||
if (!row) throw new ErrorType("This report has changed. Refresh reports and try again.", 409, "report_conflict");
|
||||
return publicReport(row);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { openGameDatabase } from "./database.mjs";
|
||||
import { createGameServer } from "./server.mjs";
|
||||
|
||||
async function withDatabase(run) {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-reports-test-"));
|
||||
const database = openGameDatabase({ dataDir: directory });
|
||||
try { await run(database); } finally { database.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
test("validates comments and dungeon IDs and stamps new reports as Submitted", () => withDatabase(async (database) => {
|
||||
assert.deepEqual(database.listDungeonReports(), { reports: [], openCounts: {} });
|
||||
for (const comment of ["", " \n ", "x".repeat(501), null, 123]) {
|
||||
assert.throws(() => database.submitDungeonReport(null, { dungeonId: "stormwind-stockade", comment }), { code: "invalid_report_comment" });
|
||||
}
|
||||
assert.throws(() => database.submitDungeonReport(null, { dungeonId: "fake", comment: "Broken" }), { code: "invalid_dungeon" });
|
||||
const account = (await database.register("Reporter", "report-pass")).account;
|
||||
const report = database.submitDungeonReport(account, {
|
||||
dungeonId: "stormwind-stockade", comment: "x".repeat(500), status: "Closed", submittedBy: "Phenom",
|
||||
});
|
||||
assert.equal(report.comment.length, 500);
|
||||
assert.equal(report.status, "Submitted");
|
||||
assert.equal(report.submittedBy, "Reporter");
|
||||
assert.equal(database.submitDungeonReport(null, { dungeonId: "forsaken-abbey", comment: " Door is stuck\r\nAfter a wipe " }).comment, "Door is stuck\nAfter a wipe");
|
||||
assert.deepEqual(database.listDungeonReports().openCounts, { "forsaken-abbey": 1, "stormwind-stockade": 1 });
|
||||
}));
|
||||
|
||||
test("enforces GM updates, counts all open statuses, supports reopening, and prevents stale changes", () => withDatabase(async (database) => {
|
||||
const gm = (await database.register("Phenom", "report-admin")).account;
|
||||
const player = (await database.register("Tester", "report-player")).account;
|
||||
let report = database.submitDungeonReport(player, { dungeonId: "stormwind-stockade", comment: "Gate is stuck" });
|
||||
database.submitDungeonReport(player, { dungeonId: "wailing-caverns", comment: "Boss stuck" });
|
||||
assert.throws(() => database.updateDungeonReport({ ...player, roles: ["gm"] }, report.id, { status: "Closed", expectedRevision: 0 }), { status: 403 });
|
||||
assert.throws(() => database.updateDungeonReport(gm, report.id, { status: "Other", expectedRevision: 0 }), { code: "invalid_report_status" });
|
||||
assert.throws(() => database.updateDungeonReport(gm, report.id, { status: "Closed" }), { code: "invalid_revision" });
|
||||
assert.throws(() => database.updateDungeonReport(gm, "missing", { status: "Closed", expectedRevision: 0 }), { status: 404 });
|
||||
for (const status of ["In-Progress", "Testing Fixes"]) {
|
||||
report = database.updateDungeonReport(gm, report.id, { status, expectedRevision: report.revision });
|
||||
assert.equal(report.updatedBy, "Phenom");
|
||||
assert.equal(database.listDungeonReports().openCounts[report.dungeonId], 1);
|
||||
}
|
||||
assert.throws(() => database.updateDungeonReport(gm, report.id, { status: "Closed", expectedRevision: 0 }), { status: 409 });
|
||||
report = database.updateDungeonReport(gm, report.id, { status: "Closed", expectedRevision: report.revision });
|
||||
assert.deepEqual(database.listDungeonReports().openCounts, { "wailing-caverns": 1 });
|
||||
assert.equal(database.listDungeonReports().reports.length, 2);
|
||||
const filtered = database.listDungeonReports({ status: "Closed", dungeonId: report.dungeonId });
|
||||
assert.equal(filtered.reports.length, 1);
|
||||
assert.deepEqual(filtered.openCounts, { "wailing-caverns": 1 });
|
||||
report = database.updateDungeonReport(gm, report.id, { status: "Submitted", expectedRevision: report.revision });
|
||||
assert.equal(database.listDungeonReports().openCounts[report.dungeonId], 1);
|
||||
}));
|
||||
|
||||
test("persists reports and status changes across database restarts", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-report-restart-"));
|
||||
let database;
|
||||
try {
|
||||
database = openGameDatabase({ dataDir: directory });
|
||||
const gm = (await database.register("Phenom", "report-admin")).account;
|
||||
const report = database.submitDungeonReport(null, { dungeonId: "stormwind-stockade", comment: "Entrance issue" });
|
||||
const updated = database.updateDungeonReport(gm, report.id, { status: "Testing Fixes", expectedRevision: 0 });
|
||||
database.close();
|
||||
database = openGameDatabase({ dataDir: directory });
|
||||
assert.deepEqual(database.listDungeonReports().reports, [updated]);
|
||||
assert.equal(database.listDungeonReports().openCounts[report.dungeonId], 1);
|
||||
} finally { database?.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("serves public reports and GM-only status API with shelved modes disabled", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-report-api-"));
|
||||
const runtime = createGameServer({ dataDir: directory, enableShelvedModes: false });
|
||||
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
|
||||
const origin = `http://127.0.0.1:${runtime.server.address().port}`;
|
||||
const request = (route, method = "GET", body, token) => fetch(`${origin}/api/dungeon-reports${route}`, {
|
||||
method, headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
try {
|
||||
const player = await runtime.database.register("ApiPlayer", "report-player");
|
||||
const gm = await runtime.database.register("Phenom", "report-admin");
|
||||
const created = await request("", "POST", { dungeonId: "stormwind-stockade", comment: "Issue via API" }, player.token);
|
||||
assert.equal(created.status, 201);
|
||||
const { report } = await created.json();
|
||||
assert.equal((await request("", "POST", { dungeonId: "wailing-caverns", comment: "Guest issue" })).status, 201);
|
||||
assert.equal((await request(`/${report.id}`, "PATCH", { status: "Closed", expectedRevision: 0 })).status, 401);
|
||||
assert.equal((await request(`/${report.id}`, "PATCH", { status: "Closed", expectedRevision: 0 }, player.token)).status, 403);
|
||||
assert.equal((await request("", "POST", { dungeonId: "stormwind-stockade", comment: "x".repeat(501) })).status, 400);
|
||||
const closed = await request(`/${report.id}`, "PATCH", { status: "Closed", expectedRevision: 0 }, gm.token);
|
||||
assert.equal(closed.status, 200);
|
||||
assert.equal((await closed.json()).report.status, "Closed");
|
||||
const all = await (await request("")).json();
|
||||
assert.equal(all.reports.length, 2);
|
||||
assert.deepEqual(all.openCounts, { "wailing-caverns": 1 });
|
||||
assert.equal((await (await request("?status=Closed&dungeon=stormwind-stockade")).json()).reports.length, 1);
|
||||
assert.equal((await request("?status=Bad")).status, 400);
|
||||
assert.equal((await request("?dungeon=Bad")).status, 400);
|
||||
} finally {
|
||||
await new Promise((resolve) => runtime.server.close(resolve));
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+102
-7
@@ -35,6 +35,19 @@ function parseAllowedOrigins(value) {
|
||||
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) {
|
||||
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 extension = path.extname(filePath).toLowerCase();
|
||||
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 = {
|
||||
"Content-Type": MIME_TYPES.get(extension) ?? "application/octet-stream",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": options.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"
|
||||
: "public, max-age=3600",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
...(encodedPath ? { "Content-Encoding": encoding, Vary: "Accept-Encoding" } : {}),
|
||||
};
|
||||
if (request.headers.range && !range) {
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
response.writeHead(200, { ...headers, "Content-Length": stats.size });
|
||||
response.writeHead(200, { ...headers, "Content-Length": servedStats.size });
|
||||
if (request.method === "HEAD") response.end();
|
||||
else createReadStream(filePath).pipe(response);
|
||||
else createReadStream(servedPath).pipe(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -153,10 +176,13 @@ export function createGameServer(options = {}) {
|
||||
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 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 onlineSessions = createOnlineSessionManager();
|
||||
const arenaService = createArenaService({ database: gameDatabase });
|
||||
const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes });
|
||||
const allowAuthRequest = createAuthLimiter();
|
||||
const allowReportRequest = createAuthLimiter();
|
||||
const allowDungeonRunRequest = createAuthLimiter();
|
||||
gameDatabase.pruneExpiredSessions();
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
@@ -185,6 +211,15 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, { ok: true, service: "healer-man", time: new Date().toISOString() });
|
||||
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") {
|
||||
const forwarded = trustProxy ? String(request.headers["x-forwarded-for"] ?? "").split(",")[0]?.trim() : "";
|
||||
if (!allowAuthRequest(forwarded || request.socket.remoteAddress || "unknown")) {
|
||||
@@ -206,6 +241,49 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, { account: gameDatabase.authenticate(bearerToken(request)) });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/dungeon-leaderboards" && request.method === "GET") {
|
||||
json(response, 200, gameDatabase.listDungeonLeaderboard({ dungeonId: url.searchParams.get("dungeon"),
|
||||
difficultyId: url.searchParams.get("difficulty"), clear: url.searchParams.get("clear"),
|
||||
offset: url.searchParams.get("offset"), limit: url.searchParams.get("limit") }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/dungeon-runs" && request.method === "POST") {
|
||||
const token = bearerToken(request);
|
||||
const account = token ? gameDatabase.authenticate(token) : null;
|
||||
if (!allowDungeonRunRequest(account?.id ?? request.socket.remoteAddress))
|
||||
throw new GameDatabaseError("Too many run submissions. Please try again shortly.", 429, "rate_limited");
|
||||
json(response, 201, gameDatabase.submitDungeonRun(account, await readJson(request, 32 * 1024)));
|
||||
return;
|
||||
}
|
||||
const dungeonRunMatch = /^\/api\/dungeon-runs\/([A-Za-z0-9_-]{8,100})$/.exec(url.pathname);
|
||||
if (dungeonRunMatch && request.method === "GET") {
|
||||
json(response, 200, gameDatabase.getDungeonRun(dungeonRunMatch[1]));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/dungeon-reports" && request.method === "GET") {
|
||||
json(response, 200, gameDatabase.listDungeonReports({
|
||||
status: url.searchParams.get("status"),
|
||||
dungeonId: url.searchParams.get("dungeon"),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/dungeon-reports" && request.method === "POST") {
|
||||
const token = bearerToken(request);
|
||||
const account = token ? gameDatabase.authenticate(token) : null;
|
||||
if (!allowReportRequest(account?.id ?? request.socket.remoteAddress)) {
|
||||
throw new GameDatabaseError("Too many reports. Please wait a minute and try again.", 429, "rate_limited");
|
||||
}
|
||||
const body = await readJson(request, 8 * 1024);
|
||||
json(response, 201, { report: gameDatabase.submitDungeonReport(account, body) });
|
||||
return;
|
||||
}
|
||||
const reportMatch = /^\/api\/dungeon-reports\/([^/]+)$/.exec(url.pathname);
|
||||
if (reportMatch && request.method === "PATCH") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
const body = await readJson(request, 8 * 1024);
|
||||
json(response, 200, { report: gameDatabase.updateDungeonReport(account, reportMatch[1], body) });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/cloud-save" && request.method === "GET") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
json(response, 200, gameDatabase.getCloudSave(account.id));
|
||||
@@ -254,6 +332,10 @@ export function createGameServer(options = {}) {
|
||||
if (url.pathname === "/api/online-group/activity" && request.method === "POST") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
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));
|
||||
return;
|
||||
}
|
||||
@@ -348,7 +430,11 @@ export function createGameServer(options = {}) {
|
||||
}
|
||||
if (serveBundledContent(request, response, url.pathname)) return;
|
||||
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/")) {
|
||||
const contentPath = url.pathname.slice("/content".length) || "/manifest.json";
|
||||
@@ -361,7 +447,8 @@ export function createGameServer(options = {}) {
|
||||
json(response, 404, { error: "Content file not found.", code: "content_not_found" });
|
||||
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" });
|
||||
} catch (error) {
|
||||
const known = error instanceof GameDatabaseError;
|
||||
@@ -376,6 +463,14 @@ export function createGameServer(options = {}) {
|
||||
});
|
||||
|
||||
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)) {
|
||||
socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
||||
}
|
||||
@@ -386,7 +481,7 @@ export function createGameServer(options = {}) {
|
||||
arenaService.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)) {
|
||||
|
||||
+61
-1
@@ -3,6 +3,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { brotliCompressSync } from "node:zlib";
|
||||
import { createGameServer } from "./server.mjs";
|
||||
|
||||
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 contentDir = path.join(directory, "content");
|
||||
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 });
|
||||
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(contentDir, "manifest.json"), JSON.stringify({ schemaVersion: 1 }));
|
||||
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));
|
||||
const address = runtime.server.address();
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
try {
|
||||
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" } });
|
||||
assert.equal(range.status, 206);
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"commentMaxLength": 500,
|
||||
"statuses": ["Submitted", "In-Progress", "Testing Fixes", "Closed"]
|
||||
}
|
||||
+10
-13
@@ -1,11 +1,12 @@
|
||||
import { lazy, Suspense, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { BASE_CLASS_IDS, classById } from "./app/characterCatalog";
|
||||
import { useShellStore } from "./app/shellStore";
|
||||
import { isShelvedShellPhase, SHELVED_MODES_ENABLED } from "./app/releaseFeatures";
|
||||
import { preloadAbilityCatalogsForClasses } from "./game/abilityCatalog";
|
||||
import { useManastormAdminStore } from "./game/manastormAdminStore";
|
||||
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
||||
import { LoginScreen } from "./ui/LoginScreen";
|
||||
import { OnlineGroupCoordinator } from "./app/OnlineGroupCoordinator";
|
||||
import { ShelvedModeRoute } from "@shelvedModeRoutes";
|
||||
import { preloadTalentGameplayResources } from "./ui/talentGameplayResources";
|
||||
import {
|
||||
getLoadedGameRuntime,
|
||||
@@ -19,11 +20,6 @@ const CharacterSelectScreen = lazy(() => import("./ui/CharacterSelectScreen").th
|
||||
const ClassHallScreen = lazy(() => import("./ui/ClassHallScreen").then((module) => ({ default: module.ClassHallScreen })));
|
||||
const DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen })));
|
||||
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() {
|
||||
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();
|
||||
const phase = useShellStore((state) => state.phase);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const refreshManastormConfig = useManastormAdminStore((state) => state.refresh);
|
||||
const PreloadedGameRuntime = getLoadedGameRuntime();
|
||||
|
||||
useEffect(() => {
|
||||
void refreshManastormConfig(session);
|
||||
}, [refreshManastormConfig, session]);
|
||||
if (!SHELVED_MODES_ENABLED && isShelvedShellPhase(phase)) {
|
||||
useShellStore.getState().returnToMainMenu();
|
||||
useShellStore.getState().setNotice("That game mode is temporarily unavailable.");
|
||||
}
|
||||
}, [phase]);
|
||||
|
||||
let screen = null;
|
||||
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 === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
|
||||
else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
|
||||
else if (phase === "manastorms") screen = <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
|
||||
else if (phase === "arenas") screen = <Suspense fallback={<ShellTransition />}><ArenaSelectScreen /></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 (isShelvedShellPhase(phase)) {
|
||||
screen = <Suspense fallback={<ShellTransition />}><ShelvedModeRoute phase={phase} session={session} /></Suspense>;
|
||||
}
|
||||
else if (phase === "game") screen = PreloadedGameRuntime
|
||||
? <PreloadedGameRuntime />
|
||||
: <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>;
|
||||
|
||||
+11
-11
@@ -3,14 +3,12 @@ import "./ui/uiLayout.css";
|
||||
import { DualDisplayFrame } from "./components/DualDisplayFrame";
|
||||
import { installInput, releasePointerLock } from "./game/inputManager";
|
||||
import { CombatBridge } from "./game/CombatBridge";
|
||||
import { connectDpsMeter } from "./game/dpsMeterStore";
|
||||
import { useCombatStore } from "./game/combatStore";
|
||||
import { releaseDungeonGltfCache } from "./game/dungeonAssetCache";
|
||||
import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset";
|
||||
import { requireDungeonDefinition } from "./game/dungeonRegistry";
|
||||
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 { useGameStore } from "./game/store";
|
||||
import { GameScene } from "./scene/GameScene";
|
||||
@@ -20,10 +18,10 @@ import { GameplayPanels } from "./ui/GameplayPanels";
|
||||
import { MapPanel } from "./ui/MapPanel";
|
||||
import { PauseMenu } from "./ui/PauseMenu";
|
||||
import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
|
||||
import { GmWorldEditor } from "./ui/GmWorldEditor";
|
||||
import { GmReviewCard } from "./ui/GmReviewCard";
|
||||
import { OnlineSessionBridge } from "./game/OnlineSessionBridge";
|
||||
import { DungeonRunCheckpointBridge } from "./game/DungeonRunCheckpointBridge";
|
||||
import { DungeonRunBridge } from "./game/DungeonRunBridge";
|
||||
import { DungeonRunResults } from "./ui/DungeonRunResults";
|
||||
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
|
||||
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
|
||||
import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt";
|
||||
@@ -33,6 +31,7 @@ import { activeUiLayout, currentUiDevice } from "./game/uiLayoutStore";
|
||||
import { useUiLayoutStore } from "./game/uiLayoutStore";
|
||||
import { profileInputBindings } from "./game/keybindPreferences";
|
||||
import { KeybindsPanel } from "./ui/KeybindsPanel";
|
||||
import { lootShelvedBoss, releaseShelvedGameRuntime, ShelvedGameBridges } from "@shelvedGameRuntime";
|
||||
|
||||
function InputBridge() {
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
@@ -40,7 +39,7 @@ function InputBridge() {
|
||||
|
||||
useEffect(() => installInput({
|
||||
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
|
||||
inputSuspended: () => ["ui-layout", "keybinds"].includes(useGameStore.getState().overlay ?? ""),
|
||||
inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results"].includes(useGameStore.getState().overlay ?? ""),
|
||||
getBindings: (device) => {
|
||||
const preferences = useUiLayoutStore.getState().preferences;
|
||||
const bindings = profileInputBindings(preferences, device);
|
||||
@@ -57,7 +56,7 @@ function InputBridge() {
|
||||
toggleInventory: () => useGameStore.getState().toggleOverlay("inventory"),
|
||||
toggleSpellbook: () => useGameStore.getState().toggleOverlay("spellbook"),
|
||||
toggleTalents: () => useGameStore.getState().toggleOverlay("talents"),
|
||||
lootActiveBoss: () => interactWithActiveDungeonObject() || lootCurrentManastormBoss(),
|
||||
lootActiveBoss: () => interactWithActiveDungeonObject() || lootShelvedBoss(),
|
||||
castActionBinding: (layer, control) => {
|
||||
const game = useGameStore.getState();
|
||||
useCombatStore.getState().castActionBinding(layer, control, game.playerPosition, Date.now());
|
||||
@@ -97,12 +96,13 @@ function InputBridge() {
|
||||
}
|
||||
|
||||
export default function GameRuntime() {
|
||||
useEffect(connectDpsMeter, []);
|
||||
const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout");
|
||||
useEffect(() => () => {
|
||||
const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId);
|
||||
clearDungeonRuntimeCaches();
|
||||
releaseDungeonGltfCache(activeDungeon);
|
||||
releaseActiveManastormStage();
|
||||
releaseShelvedGameRuntime();
|
||||
}, []);
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
@@ -117,17 +117,17 @@ export default function GameRuntime() {
|
||||
<DungeonRunCheckpointBridge />
|
||||
<CombatBridge />
|
||||
<PartyBridge />
|
||||
<ManastormBridge />
|
||||
<DungeonRunBridge />
|
||||
<ShelvedGameBridges />
|
||||
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
|
||||
<Hud />
|
||||
<EditableHudElement id="interaction"><DungeonInteractionPrompt /></EditableHudElement>
|
||||
<MapPanel />
|
||||
<GameplayPanels />
|
||||
<PauseMenu />
|
||||
<DungeonRunResults />
|
||||
<UiLayoutEditor />
|
||||
<KeybindsPanel />
|
||||
<GmReviewCard />
|
||||
<GmWorldEditor />
|
||||
</main>
|
||||
}
|
||||
bottom={<LocalCompanionPanel />}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OnlineGroupActivity } from "./onlineGroupTypes";
|
||||
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 = {
|
||||
id: "activity-dungeon-1",
|
||||
@@ -12,6 +16,14 @@ const DUNGEON_ACTIVITY: OnlineGroupActivity = {
|
||||
startedAt: 1,
|
||||
};
|
||||
|
||||
const MANASTORM_ACTIVITY: OnlineGroupActivity = {
|
||||
...DUNGEON_ACTIVITY,
|
||||
id: "activity-manastorm-1",
|
||||
type: "manastorm",
|
||||
partySize: 3,
|
||||
startingLevel: 6,
|
||||
};
|
||||
|
||||
function entryDependencies(enterDungeon: () => boolean) {
|
||||
return {
|
||||
installDungeonRuntime: vi.fn(async () => undefined),
|
||||
@@ -75,4 +87,36 @@ describe("online group activity entry", () => {
|
||||
expect(dependencies.enterDungeon).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,
|
||||
type PartyRole,
|
||||
} from "../game/partyRoles";
|
||||
import { isManastormPartySize, type ManastormPartySize } from "../game/manastormProgress";
|
||||
import type { ManastormPartySize } from "../game/manastormProgress";
|
||||
import type { OnlineGroupActivity } from "./onlineGroupTypes";
|
||||
import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
|
||||
import { installShelvedOnlineGroupRuntime } from "@shelvedOnlineGroupRuntime";
|
||||
|
||||
const GROUP_POLL_MS = 2_500;
|
||||
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),
|
||||
installManastormRuntime: async () => {
|
||||
const { installManastormShellRuntime } = await import("./manastormShellRuntime");
|
||||
installManastormShellRuntime();
|
||||
await installShelvedOnlineGroupRuntime();
|
||||
},
|
||||
enterManastorm: (role, partySize, startingLevel) => useShellStore.getState().enterManastorm(
|
||||
role,
|
||||
@@ -48,8 +49,11 @@ export async function enterOnlineGroupActivity(
|
||||
await dependencies.installDungeonRuntime();
|
||||
entered = dependencies.enterDungeon(activity.contentId, role);
|
||||
} else {
|
||||
if (!SHELVED_MODES_ENABLED) return false;
|
||||
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);
|
||||
}
|
||||
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,44 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { localDungeonLeaderboard, localDungeonRunPlacement, saveLocalDungeonRun, submitDungeonRun } from "./dungeonLeaderboardClient";
|
||||
import type { DungeonRunRecord } from "../game/dungeonRunTypes";
|
||||
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
|
||||
|
||||
const dungeon = catalog.dungeons.find((entry) => entry.id === "deadmines")!;
|
||||
const record = (id: string, durationMs: number, fullClear = true): DungeonRunRecord => ({
|
||||
id, dungeonId: dungeon.id, difficultyId: dungeon.defaultDifficultyId, durationMs, finishedAt: 100000,
|
||||
party: [{ name: "Tester", classId: "priest", level: 20, role: "healer", npc: false }],
|
||||
killedBossIds: fullClear ? dungeon.bosses.map((boss) => boss.id) : [dungeon.finalBossId], fullClear,
|
||||
});
|
||||
beforeEach(() => {
|
||||
const values = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", { getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value) });
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("offline dungeon leaderboard", () => {
|
||||
it("persists full/final filters, ties, pages, and the placement of a run outside the first page", () => {
|
||||
for (let index = 0; index < 25; index += 1) saveLocalDungeonRun(record(`offline-${index}`, 1000 + index * 100));
|
||||
const fastest = record("offline-fastest", 500, false);
|
||||
saveLocalDungeonRun(fastest); saveLocalDungeonRun(fastest);
|
||||
saveLocalDungeonRun(record("offline-tie", 1000));
|
||||
const all = localDungeonLeaderboard(dungeon.id, dungeon.defaultDifficultyId, "final");
|
||||
expect(all.total).toBe(27); expect(all.entries[0].id).toBe(fastest.id);
|
||||
expect(all.entries.slice(0, 3).map((entry) => entry.rank)).toEqual([1, 2, 2]);
|
||||
expect(localDungeonLeaderboard(dungeon.id, dungeon.defaultDifficultyId, "full").total).toBe(26);
|
||||
const page = localDungeonLeaderboard(dungeon.id, dungeon.defaultDifficultyId, "final", 20);
|
||||
expect(page.entries).toHaveLength(7);
|
||||
expect(localDungeonRunPlacement(record("offline-24", 3400))).toEqual({ finalRank: 27, finalTotal: 27, fullRank: 26, fullTotal: 26 });
|
||||
});
|
||||
it("retains the local time after a failed upload and uses the same run id when retried", async () => {
|
||||
const run = record("offline-retry-test", 4321);
|
||||
saveLocalDungeonRun(run);
|
||||
const fetch = vi.fn().mockRejectedValueOnce(new Error("offline"))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ placement: { finalRank: 2, finalTotal: 3, fullRank: 1, fullTotal: 2 } }), { status: 201 }));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
await expect(submitDungeonRun(run, null)).rejects.toThrow();
|
||||
expect(localDungeonLeaderboard(dungeon.id, dungeon.defaultDifficultyId, "final").entries[0].durationMs).toBe(4321);
|
||||
await expect(submitDungeonRun(run, null)).resolves.toMatchObject({ finalRank: 2 });
|
||||
expect(fetch.mock.calls.map((call) => JSON.parse(call[1].body).id)).toEqual([run.id, run.id]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { requestOnlineJson } from "./onlineAccountClient";
|
||||
import type { PlayerSession } from "./types";
|
||||
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
|
||||
import { normalizeDungeonRunProgress, type DungeonClearFilter, type DungeonLeaderboard,
|
||||
type DungeonRunPlacement, type DungeonRunRecord } from "../game/dungeonRunTypes";
|
||||
|
||||
const STORAGE_KEY = "healer-man:dungeon-runs:v1";
|
||||
const listeners = new Set<() => void>();
|
||||
let memory: DungeonRunRecord[] = [];
|
||||
export const DUNGEON_LEADERBOARD_PAGE_SIZE = 20;
|
||||
export type LeaderboardScope = "world" | "device";
|
||||
|
||||
export function subscribeDungeonLeaderboard(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
function localRuns(): DungeonRunRecord[] {
|
||||
try {
|
||||
const input: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
|
||||
if (Array.isArray(input)) memory = input.flatMap((value: DungeonRunRecord) => {
|
||||
if (!value || typeof value !== "object") return [];
|
||||
const progress = normalizeDungeonRunProgress({ ...value, startedAt: value.finishedAt - value.durationMs, timed: true });
|
||||
const dungeon = catalog.dungeons.find((entry) => entry.id === progress?.dungeonId);
|
||||
const difficulty = dungeon?.difficulties.find((entry) => entry.id === progress?.difficultyId);
|
||||
if (!progress || progress.finishedAt === null || progress.durationMs === null || !dungeon
|
||||
|| !difficulty || !progress.killedBossIds.includes(difficulty.finalBossId)
|
||||
|| progress.killedBossIds.some((id) => !difficulty.bossIds.includes(id))) return [];
|
||||
return [{ ...value, ...progress, durationMs: progress.durationMs, finishedAt: progress.finishedAt,
|
||||
fullClear: difficulty.bossIds.every((id) => progress.killedBossIds.includes(id)) }];
|
||||
});
|
||||
} catch { /* In-memory results remain available if storage is disabled. */ }
|
||||
return memory;
|
||||
}
|
||||
|
||||
export function saveLocalDungeonRun(record: DungeonRunRecord): void {
|
||||
memory = [record, ...localRuns().filter((entry) => entry.id !== record.id)]
|
||||
.sort((a, b) => b.finishedAt - a.finishedAt).slice(0, 1000);
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(memory)); } catch { /* Keep the current session's results. */ }
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function localDungeonLeaderboard(dungeonId: string, difficultyId: string,
|
||||
filter: DungeonClearFilter, offset = 0): DungeonLeaderboard {
|
||||
const runs = localRuns().filter((entry) => entry.dungeonId === dungeonId && entry.difficultyId === difficultyId
|
||||
&& (filter === "final" || entry.fullClear)).sort((a, b) => a.durationMs - b.durationMs
|
||||
|| a.finishedAt - b.finishedAt || a.id.localeCompare(b.id));
|
||||
return { total: runs.length, entries: runs.slice(offset, offset + DUNGEON_LEADERBOARD_PAGE_SIZE)
|
||||
.map((entry) => ({ ...entry, rank: 1 + runs.filter((other) => other.durationMs < entry.durationMs).length })) };
|
||||
}
|
||||
|
||||
export function localDungeonRunPlacement(record: DungeonRunRecord): DungeonRunPlacement {
|
||||
const final = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "final");
|
||||
const full = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "full");
|
||||
const runs = localRuns().filter((entry) => entry.dungeonId === record.dungeonId && entry.difficultyId === record.difficultyId);
|
||||
return { finalRank: 1 + runs.filter((entry) => entry.durationMs < record.durationMs).length,
|
||||
finalTotal: final.total, fullTotal: full.total,
|
||||
fullRank: record.fullClear ? 1 + runs.filter((entry) => entry.fullClear && entry.durationMs < record.durationMs).length : null };
|
||||
}
|
||||
|
||||
export async function fetchDungeonLeaderboard(dungeonId: string, difficultyId: string,
|
||||
filter: DungeonClearFilter, offset: number, signal?: AbortSignal): Promise<DungeonLeaderboard> {
|
||||
const query = new URLSearchParams({ dungeon: dungeonId, difficulty: difficultyId, clear: filter,
|
||||
offset: String(offset), limit: String(DUNGEON_LEADERBOARD_PAGE_SIZE) });
|
||||
return requestOnlineJson(`/api/dungeon-leaderboards?${query}`, { signal });
|
||||
}
|
||||
|
||||
const submissions = new Map<string, Promise<DungeonRunPlacement>>();
|
||||
export function submitDungeonRun(record: DungeonRunRecord, session: PlayerSession | null): Promise<DungeonRunPlacement> {
|
||||
const existing = submissions.get(record.id);
|
||||
if (existing) return existing;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
const submission = requestOnlineJson<{ placement: DungeonRunPlacement }>("/api/dungeon-runs", {
|
||||
method: "POST", body: JSON.stringify(record), signal: controller.signal,
|
||||
}, session?.accessToken).then((result) => {
|
||||
listeners.forEach((listener) => listener());
|
||||
return result.placement;
|
||||
}).catch((error: unknown) => { submissions.delete(record.id); throw error; })
|
||||
.finally(() => clearTimeout(timeout));
|
||||
submissions.set(record.id, submission);
|
||||
return submission;
|
||||
}
|
||||
|
||||
/** Group followers read the leader's single shared run instead of submitting duplicate entries. */
|
||||
export async function awaitSharedDungeonRunPlacement(id: string): Promise<DungeonRunPlacement> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
try {
|
||||
const response = await requestOnlineJson<{ placement: DungeonRunPlacement }>(`/api/dungeon-runs/${encodeURIComponent(id)}`,
|
||||
{ signal: controller.signal });
|
||||
return response.placement;
|
||||
} catch (error) {
|
||||
if (attempt === 7 || controller.signal.aborted) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
throw new Error("The shared run is not recorded yet.");
|
||||
} finally { clearTimeout(timeout); }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DUNGEON_REPORT_STATUSES, fetchDungeonReports, filterDungeonReports, sessionCanManageDungeonReports,
|
||||
submitDungeonReport, updateDungeonReport, type DungeonReport,
|
||||
} from "./dungeonReports";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
function report(id: string, dungeonId: string, status: DungeonReport["status"]): DungeonReport {
|
||||
return { id, dungeonId, status, comment: "Door does not open", submittedBy: "Player", createdAt: 1, updatedAt: 1, updatedBy: null, revision: 0 };
|
||||
}
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals(); useGameStore.getState().closeOverlay(); });
|
||||
|
||||
describe("dungeon reports", () => {
|
||||
it("shows all statuses by default, including Closed, and combines status and dungeon filters", () => {
|
||||
const reports = DUNGEON_REPORT_STATUSES.flatMap((status, index) => [
|
||||
report(`stock-${index}`, "stormwind-stockade", status), report(`cave-${index}`, "wailing-caverns", status),
|
||||
]);
|
||||
expect(filterDungeonReports(reports, "all", "all")).toEqual(reports);
|
||||
expect(filterDungeonReports(reports, "Closed", "all")).toHaveLength(2);
|
||||
expect(filterDungeonReports(reports, "all", "wailing-caverns")).toHaveLength(4);
|
||||
expect(filterDungeonReports(reports, "Testing Fixes", "stormwind-stockade").map((item) => item.id)).toEqual(["stock-2"]);
|
||||
expect(filterDungeonReports(reports, "Submitted", "missing")).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows GM report management in the ordinary release and rejects offline or local-only authority", () => {
|
||||
const gm = { kind: "account" as const, ownerId: "gm", displayName: "Phenom", accessToken: "token", roles: ["gm" as const] };
|
||||
expect(sessionCanManageDungeonReports(gm)).toBe(true);
|
||||
expect(sessionCanManageDungeonReports({ ...gm, roles: [] })).toBe(false);
|
||||
expect(sessionCanManageDungeonReports({ ...gm, kind: "offline" })).toBe(false);
|
||||
expect(sessionCanManageDungeonReports({ ...gm, accessToken: undefined })).toBe(false);
|
||||
expect(sessionCanManageDungeonReports(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("sends the active dungeon and preserves the expected report revision for GM updates", async () => {
|
||||
const item = report("report-id", "stormwind-stockade", "Submitted");
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ report: item }) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const gm = { kind: "account" as const, ownerId: "gm", displayName: "Phenom", accessToken: "token", roles: ["gm" as const] };
|
||||
await submitDungeonReport(gm, item.dungeonId, "Door does not open");
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ dungeonId: item.dungeonId, comment: "Door does not open" });
|
||||
expect(fetchMock.mock.calls[0][1].headers.get("Authorization")).toBe("Bearer token");
|
||||
await updateDungeonReport(gm, { ...item, revision: 3 }, "Closed");
|
||||
expect(fetchMock.mock.calls[1][1].method).toBe("PATCH");
|
||||
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ status: "Closed", expectedRevision: 3 });
|
||||
await fetchDungeonReports();
|
||||
expect(fetchMock.mock.calls[2][0]).toBe("/api/dungeon-reports");
|
||||
});
|
||||
|
||||
it("keeps the game paused and camera look stopped while reporting and returning to pause", () => {
|
||||
const game = useGameStore.getState();
|
||||
game.setCameraLookActive(true);
|
||||
game.openOverlay("report-issue");
|
||||
expect(useGameStore.getState()).toMatchObject({ paused: true, overlay: "report-issue", cameraLookActive: false });
|
||||
game.openOverlay("pause");
|
||||
expect(useGameStore.getState()).toMatchObject({ paused: true, overlay: "pause", cameraLookActive: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { PlayerSession } from "./types";
|
||||
import { requestOnlineJson } from "./onlineAccountClient";
|
||||
import policy from "../../shared/dungeon-report-policy.json";
|
||||
|
||||
export type DungeonReportStatus = "Submitted" | "In-Progress" | "Testing Fixes" | "Closed";
|
||||
export const DUNGEON_REPORT_STATUSES = policy.statuses as readonly DungeonReportStatus[];
|
||||
export const DUNGEON_REPORT_COMMENT_MAX_LENGTH = policy.commentMaxLength;
|
||||
|
||||
export interface DungeonReport {
|
||||
readonly id: string;
|
||||
readonly dungeonId: string;
|
||||
readonly comment: string;
|
||||
readonly status: DungeonReportStatus;
|
||||
readonly submittedBy: string;
|
||||
readonly createdAt: number;
|
||||
readonly updatedAt: number;
|
||||
readonly updatedBy: string | null;
|
||||
readonly revision: number;
|
||||
}
|
||||
|
||||
export interface DungeonReportSnapshot {
|
||||
readonly reports: readonly DungeonReport[];
|
||||
readonly openCounts: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
export function sessionCanManageDungeonReports(session: PlayerSession | null): boolean {
|
||||
return Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
|
||||
}
|
||||
|
||||
export function filterDungeonReports(
|
||||
reports: readonly DungeonReport[],
|
||||
status: DungeonReportStatus | "all",
|
||||
dungeonId: string,
|
||||
): readonly DungeonReport[] {
|
||||
return reports.filter((report) => (status === "all" || report.status === status)
|
||||
&& (dungeonId === "all" || report.dungeonId === dungeonId));
|
||||
}
|
||||
|
||||
export function fetchDungeonReports(): Promise<DungeonReportSnapshot> {
|
||||
return requestOnlineJson<DungeonReportSnapshot>("/api/dungeon-reports");
|
||||
}
|
||||
|
||||
export async function submitDungeonReport(session: PlayerSession | null, dungeonId: string, comment: string): Promise<DungeonReport> {
|
||||
const response = await requestOnlineJson<{ report: DungeonReport }>("/api/dungeon-reports", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ dungeonId, comment }),
|
||||
}, session?.kind === "account" ? session.accessToken : undefined);
|
||||
return response.report;
|
||||
}
|
||||
|
||||
export async function updateDungeonReport(
|
||||
session: PlayerSession,
|
||||
report: DungeonReport,
|
||||
status: DungeonReportStatus,
|
||||
): Promise<DungeonReport> {
|
||||
const response = await requestOnlineJson<{ report: DungeonReport }>(`/api/dungeon-reports/${encodeURIComponent(report.id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status, expectedRevision: report.revision }),
|
||||
}, session.accessToken);
|
||||
return response.report;
|
||||
}
|
||||
@@ -61,6 +61,8 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
|
||||
if (!activateDungeonSession(dungeonId, resume?.difficultyId, {
|
||||
spawn: resume?.spawn,
|
||||
encounterSnapshots: resume?.encounters,
|
||||
runProgress: resume?.run,
|
||||
legacyResume: Boolean(resume && !resume.run),
|
||||
})) {
|
||||
useShellStore.setState({
|
||||
notice: dungeon.availabilityReason ?? "That dungeon failed its latest validation gate.",
|
||||
|
||||
@@ -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 { registerManastormShellRuntime } from "./manastormShellRuntimeBridge";
|
||||
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", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
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 saveSelectedLoadout = vi.fn(() => true);
|
||||
const abandonRun = vi.fn();
|
||||
@@ -37,4 +64,3 @@ describe("on-demand shell runtime delegation", () => {
|
||||
expect(failRun).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createDefaultAppearance } from "./characterCatalog";
|
||||
import { useShellStore } from "./shellStore";
|
||||
import { installDungeonShellRuntime } from "./dungeonShellRuntime";
|
||||
import { installManastormShellRuntime } from "./manastormShellRuntime";
|
||||
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
|
||||
import { DUNGEON_MANIFEST } from "../game/dungeonManifest";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
@@ -23,6 +24,8 @@ import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||
|
||||
installDungeonShellRuntime();
|
||||
installManastormShellRuntime();
|
||||
const normalReleaseIt = SHELVED_MODES_ENABLED ? it.skip : it;
|
||||
const shelvedModeIt = SHELVED_MODES_ENABLED ? it : it.skip;
|
||||
|
||||
const character = {
|
||||
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();
|
||||
expect(useShellStore.getState().openManastorms()).toBe(true);
|
||||
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);
|
||||
useShellStore.setState({
|
||||
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(
|
||||
createEmptyManastormProgress(),
|
||||
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 selectedIds = model.options.slice(0, 2).map((option) => option.id);
|
||||
const unlocked = mergeManastormUnlockedContent(
|
||||
|
||||
+25
-5
@@ -56,7 +56,7 @@ import {
|
||||
logoutOnlineSession,
|
||||
refreshOnlineSession,
|
||||
} from "./onlineAccountClient";
|
||||
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
||||
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "./releaseFeatures";
|
||||
import { useGameStore } from "../game/store";
|
||||
import {
|
||||
normalizeSpecializationId,
|
||||
@@ -324,6 +324,10 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
openManastorms: () => {
|
||||
if (!SHELVED_MODES_ENABLED) {
|
||||
set({ notice: "Manastorms are temporarily unavailable." });
|
||||
return false;
|
||||
}
|
||||
const { session, selectedCharacterId, characters } = get();
|
||||
if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
|
||||
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;
|
||||
},
|
||||
openArenas: () => {
|
||||
if (!SHELVED_MODES_ENABLED) {
|
||||
set({ notice: "Arenas are temporarily unavailable." });
|
||||
return false;
|
||||
}
|
||||
const { session, selectedCharacterId, characters } = get();
|
||||
if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
|
||||
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
|
||||
@@ -348,7 +356,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
},
|
||||
openGmAdmin: () => {
|
||||
const { session, selectedCharacterId, characters } = get();
|
||||
if (!isGameMasterSession(session)) {
|
||||
if (!sessionCanUseShelvedGmTools(session)) {
|
||||
set({ notice: "Game Master access is required." });
|
||||
return false;
|
||||
}
|
||||
@@ -429,7 +437,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
},
|
||||
returnToMainMenu: () => {
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
manastormShellRuntime()?.leaveActiveGame();
|
||||
if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.leaveActiveGame();
|
||||
const { session, characters, activeCharacter, selectedCharacterId } = get();
|
||||
if (!session) {
|
||||
set({ phase: "login", activeCharacter: null });
|
||||
@@ -449,13 +457,21 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
return false;
|
||||
},
|
||||
enterManastorm: (requestedRole, requestedPartySize = 5, requestedStartingLevel) => {
|
||||
if (!SHELVED_MODES_ENABLED) {
|
||||
set({ notice: "Manastorms are temporarily unavailable." });
|
||||
return false;
|
||||
}
|
||||
const runtime = manastormShellRuntime();
|
||||
if (runtime) return runtime.enterManastorm(requestedRole, requestedPartySize, requestedStartingLevel);
|
||||
set({ notice: "Manastorm data is still loading. Try again in a moment." });
|
||||
return false;
|
||||
},
|
||||
abandonManastormRun: () => manastormShellRuntime()?.abandonRun(),
|
||||
failActiveManastorm: () => manastormShellRuntime()?.failRun(),
|
||||
abandonManastormRun: () => {
|
||||
if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.abandonRun();
|
||||
},
|
||||
failActiveManastorm: () => {
|
||||
if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.failRun();
|
||||
},
|
||||
enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID),
|
||||
returnToCharacters: () => {
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
@@ -594,6 +610,10 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
}));
|
||||
},
|
||||
saveSelectedManastormLoadout: (loadout) => {
|
||||
if (!SHELVED_MODES_ENABLED) {
|
||||
set({ notice: "Manastorms are temporarily unavailable." });
|
||||
return false;
|
||||
}
|
||||
const runtime = manastormShellRuntime();
|
||||
if (runtime) return runtime.saveSelectedLoadout(loadout);
|
||||
set({ notice: "Manastorm data is still loading. Try again in a moment." });
|
||||
|
||||
@@ -75,14 +75,24 @@
|
||||
"detail": "green"
|
||||
},
|
||||
{
|
||||
"name": "population accounting",
|
||||
"name": "scripted population accounting",
|
||||
"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",
|
||||
"passed": true,
|
||||
"detail": "3.385892"
|
||||
"detail": "0.998158"
|
||||
},
|
||||
{
|
||||
"name": "mechanics is an explicit review gate",
|
||||
@@ -104,6 +114,51 @@
|
||||
"passed": true,
|
||||
"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",
|
||||
"passed": true,
|
||||
@@ -112,12 +167,12 @@
|
||||
{
|
||||
"name": "actor black-hat-blue checksum",
|
||||
"passed": true,
|
||||
"detail": "bcd344260000235789ddb2452a285210000646659538e56e57857d9667969edd"
|
||||
"detail": "e6b78de784549ce814f998c34b8e1aef42e452e26c7395ac74fa5db168413d2f"
|
||||
},
|
||||
{
|
||||
"name": "actor black-hat-blue size",
|
||||
"passed": true,
|
||||
"detail": "1561332"
|
||||
"detail": "1560524"
|
||||
},
|
||||
{
|
||||
"name": "actor bunny exists",
|
||||
@@ -127,12 +182,12 @@
|
||||
{
|
||||
"name": "actor bunny checksum",
|
||||
"passed": true,
|
||||
"detail": "6d1ed1424fc075b472224f6bf2cd7c1ea291d7605ad280ce8ae0570d060f4b3e"
|
||||
"detail": "e29298726425e0109890fb0b33d770f39e051b7a43d50a666f6f6c46a3f2ee32"
|
||||
},
|
||||
{
|
||||
"name": "actor bunny size",
|
||||
"passed": true,
|
||||
"detail": "540500"
|
||||
"detail": "540408"
|
||||
},
|
||||
{
|
||||
"name": "actor cereus-01 exists",
|
||||
@@ -142,12 +197,12 @@
|
||||
{
|
||||
"name": "actor cereus-01 checksum",
|
||||
"passed": true,
|
||||
"detail": "324a9b2c338d08258979bbbbdad98e82bcb77f99907b3abbdfc817f4a9aa6610"
|
||||
"detail": "a35ec70378f7323a1f3d50c3e84d5654eacd2888722944c83f9be18cf42eecfa"
|
||||
},
|
||||
{
|
||||
"name": "actor cereus-01 size",
|
||||
"passed": true,
|
||||
"detail": "620616"
|
||||
"detail": "620404"
|
||||
},
|
||||
{
|
||||
"name": "actor controlled-steel-guardian exists",
|
||||
@@ -157,12 +212,27 @@
|
||||
{
|
||||
"name": "actor controlled-steel-guardian checksum",
|
||||
"passed": true,
|
||||
"detail": "5d0e0f5d6b38c1cf799a30a3ab0718a405feaa84ae844375a15cff6161fa8b77"
|
||||
"detail": "b772b72bffc44c4fcf36ff8aeaf8f2d0c435f220e5c9729ed7e1ce2aac70e8be"
|
||||
},
|
||||
{
|
||||
"name": "actor controlled-steel-guardian size",
|
||||
"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",
|
||||
@@ -172,12 +242,12 @@
|
||||
{
|
||||
"name": "actor elder-myconid checksum",
|
||||
"passed": true,
|
||||
"detail": "5e5097cf12bac9780fd9925aae0d6507ad9393ac2e6fd106b2ca9cdf03b2ecc1"
|
||||
"detail": "ef0d1fd12aa130a1a23a21bb978f7859b434d27512fb5da12426f1c1ce34514c"
|
||||
},
|
||||
{
|
||||
"name": "actor elder-myconid size",
|
||||
"passed": true,
|
||||
"detail": "1395280"
|
||||
"detail": "1393472"
|
||||
},
|
||||
{
|
||||
"name": "actor ghastly-clown-red exists",
|
||||
@@ -187,12 +257,12 @@
|
||||
{
|
||||
"name": "actor ghastly-clown-red checksum",
|
||||
"passed": true,
|
||||
"detail": "20a6fa59f783c2f17260db9556689906079f6d0d95b835ec26d3e2a7a589f322"
|
||||
"detail": "7f767b8fc224b63f923af6e7f29eda935a746ef6040bc489e4957c2650993ccc"
|
||||
},
|
||||
{
|
||||
"name": "actor ghastly-clown-red size",
|
||||
"passed": true,
|
||||
"detail": "825156"
|
||||
"detail": "824792"
|
||||
},
|
||||
{
|
||||
"name": "actor grinning-clown exists",
|
||||
@@ -202,12 +272,57 @@
|
||||
{
|
||||
"name": "actor grinning-clown checksum",
|
||||
"passed": true,
|
||||
"detail": "b53b78efe39b807bc8bbf7952501970cf74ee1483b891b6b61b0880e30b197b2"
|
||||
"detail": "b93ffdcfcf86df01c348678c97dfb913e74da9cab2367e187a46fafb3d85040d"
|
||||
},
|
||||
{
|
||||
"name": "actor grinning-clown size",
|
||||
"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",
|
||||
@@ -217,12 +332,12 @@
|
||||
{
|
||||
"name": "actor karth-01 checksum",
|
||||
"passed": true,
|
||||
"detail": "867b8c610f1cbb4bfc52ded7d8010abcb3585592ac501b1af0792a5721ba4d3c"
|
||||
"detail": "ee1c1e6937338dff99dad4a8863a03ad7d864a55120271e996a44d6cb83f07ae"
|
||||
},
|
||||
{
|
||||
"name": "actor karth-01 size",
|
||||
"passed": true,
|
||||
"detail": "1777388"
|
||||
"detail": "1776716"
|
||||
},
|
||||
{
|
||||
"name": "actor milk-scarab-blue exists",
|
||||
@@ -232,12 +347,72 @@
|
||||
{
|
||||
"name": "actor milk-scarab-blue checksum",
|
||||
"passed": true,
|
||||
"detail": "2392d45c63b193f2266e7c0c6e9dcc192fd860caac354395bf02a634f046dae5"
|
||||
"detail": "dde6938b52a45a77f07da63ca26172c66c49f3c10ceb10f6bda17aa3d0fba57d"
|
||||
},
|
||||
{
|
||||
"name": "actor milk-scarab-blue size",
|
||||
"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",
|
||||
|
||||
@@ -11,8 +11,120 @@
|
||||
"dungeonConfigId": "grafu_castle"
|
||||
},
|
||||
"configuredBossTemplateIds": [],
|
||||
"reviewedBossTemplateIds": [
|
||||
105204,
|
||||
105224,
|
||||
105351,
|
||||
105542,
|
||||
105543,
|
||||
105616
|
||||
],
|
||||
"configuredBossPositions": {},
|
||||
"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": [],
|
||||
"luaSignals": [],
|
||||
"templateCandidates": [
|
||||
@@ -94,6 +206,18 @@
|
||||
"templateScripts": [],
|
||||
"spells": []
|
||||
},
|
||||
{
|
||||
"templateId": 105204,
|
||||
"name": "Conjurer Chapeaunoir",
|
||||
"classification": "boss",
|
||||
"spawnCount": 0,
|
||||
"autoPlots": [
|
||||
"cl_136_105205_N"
|
||||
],
|
||||
"plotClassNames": [],
|
||||
"templateScripts": [],
|
||||
"spells": []
|
||||
},
|
||||
{
|
||||
"templateId": 105205,
|
||||
"name": "Template 105205",
|
||||
@@ -109,7 +233,7 @@
|
||||
{
|
||||
"templateId": 105210,
|
||||
"name": "Template 105210",
|
||||
"classification": "combat",
|
||||
"classification": "deferred-object",
|
||||
"spawnCount": 1,
|
||||
"autoPlots": [
|
||||
"kg_105542_01"
|
||||
@@ -204,10 +328,82 @@
|
||||
"templateScripts": [],
|
||||
"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,
|
||||
"name": "Template 105254",
|
||||
"classification": "combat",
|
||||
"classification": "deferred-object",
|
||||
"spawnCount": 10,
|
||||
"autoPlots": [
|
||||
"cl_136_01_01_N",
|
||||
@@ -225,7 +421,7 @@
|
||||
{
|
||||
"templateId": 105258,
|
||||
"name": "Template 105258",
|
||||
"classification": "combat",
|
||||
"classification": "deferred-object",
|
||||
"spawnCount": 5,
|
||||
"autoPlots": [
|
||||
"cl_105258_1",
|
||||
@@ -265,6 +461,42 @@
|
||||
"templateScripts": [],
|
||||
"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,
|
||||
"name": "Smirking Castle Clown",
|
||||
@@ -332,6 +564,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"templateId": 105599,
|
||||
"name": "Cursed Steel Soldier",
|
||||
"classification": "combat",
|
||||
"spawnCount": 0,
|
||||
"autoPlots": [],
|
||||
"plotClassNames": [],
|
||||
"templateScripts": [],
|
||||
"spells": []
|
||||
},
|
||||
{
|
||||
"templateId": 105600,
|
||||
"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,
|
||||
"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 { runInNewContext } from "node:vm";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ARENA_STAGES } from "../game/arenaStages";
|
||||
import { BROWSER_CONTENT_CACHE_NAME } from "./contentManager";
|
||||
|
||||
const workerSource = readFileSync(
|
||||
@@ -12,15 +11,22 @@ const workerSource = readFileSync(
|
||||
|
||||
function contentWorker(cached?: Response, bundledFiles: Record<string, unknown> = {}, object?: Response) {
|
||||
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"
|
||||
? 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 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, {
|
||||
self: {
|
||||
location: { origin: "https://healer.test" },
|
||||
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,
|
||||
URL,
|
||||
Response,
|
||||
@@ -30,6 +36,8 @@ function contentWorker(cached?: Response, bundledFiles: Record<string, unknown>
|
||||
return {
|
||||
fetch,
|
||||
match,
|
||||
bootstrapMatch,
|
||||
bootstrapPut,
|
||||
refresh: () => handlers.get("message")!({ data: { type: "refresh-content-bootstrap" } }),
|
||||
request: (path: string) => {
|
||||
let response: Promise<Response> | undefined;
|
||||
@@ -43,20 +51,28 @@ function contentWorker(cached?: Response, bundledFiles: Record<string, unknown>
|
||||
}
|
||||
|
||||
describe("browser content cache worker", () => {
|
||||
it.each(ARENA_STAGES)("loads bundled $name assets without an optional content install", async (stage) => {
|
||||
const worker = contentWorker();
|
||||
for (const asset of stage.assets) {
|
||||
const response = await worker.request(`${asset.url}?arena-runtime=1`);
|
||||
it("loads starter-dungeon assets without an optional content install", async () => {
|
||||
const paths = [
|
||||
"/assets/game/dungeons/wailing-caverns/visual.glb",
|
||||
"/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(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 () => {
|
||||
const worker = contentWorker(new Response("updated arena"));
|
||||
const response = await worker.request(`${ARENA_STAGES[0].assets[0].url}?arena-runtime=1`);
|
||||
expect(await response?.text()).toBe("updated arena");
|
||||
it("prefers installed dungeon updates over bundled files, ignoring the runtime query", async () => {
|
||||
const worker = contentWorker(new Response("updated dungeon"));
|
||||
const response = await worker.request("/assets/game/dungeons/wailing-caverns/visual.glb?starter-runtime=1");
|
||||
expect(await response?.text()).toBe("updated dungeon");
|
||||
expect(worker.match).toHaveBeenCalledWith(expect.any(Request), { ignoreSearch: true });
|
||||
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", () => {
|
||||
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("url.origin !== self.location.origin");
|
||||
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("BUNDLED_ASSET_PATHS.has(url.pathname)");
|
||||
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).toContain('status: 404');
|
||||
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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const original = "glTF installed original".repeat(100);
|
||||
const worker = contentWorker(new Response(null, { headers: { "x-healer-man-content-object": "/content/cache-objects/shared.glb" } }), {},
|
||||
|
||||
@@ -8,25 +8,11 @@ import {
|
||||
type ContentManifestFile,
|
||||
} from "./contentManifest";
|
||||
import { NativeContentStorage } from "./nativeContentStorage";
|
||||
import contentPolicy from "../../shared/content-policy.json";
|
||||
|
||||
export const CONTENT_STATE_KEY = "healer-man.content-state.v2";
|
||||
const PRODUCTION_ORIGIN = "https://iwanttoheal.phenomrom.com";
|
||||
const STATIC_ANDROID_BUNDLED_PACKS = new Set([
|
||||
"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",
|
||||
]);
|
||||
const STATIC_BUNDLED_PACKS = new Set(contentPolicy.bootstrapPackIds);
|
||||
|
||||
export const BROWSER_CONTENT_CACHE_NAME = "healer-man-content-v1";
|
||||
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 }>> {
|
||||
if (snapshot.bundledManifest) return snapshot.bundledManifest.files;
|
||||
if (!snapshot.manifest) return {};
|
||||
const bundledPacks = Capacitor.isNativePlatform()
|
||||
? STATIC_ANDROID_BUNDLED_PACKS
|
||||
: STATIC_BROWSER_BUNDLED_PACKS;
|
||||
const bundledPacks = STATIC_BUNDLED_PACKS;
|
||||
return Object.fromEntries(Object.entries(snapshot.manifest.files).filter(([, entry]) => (
|
||||
bundledPacks.has(entry.packId)
|
||||
)));
|
||||
@@ -324,8 +308,7 @@ export function resolveContentUrl(logicalUrl: string): string {
|
||||
}
|
||||
|
||||
export function contentPackIsBundled(packId: string): boolean {
|
||||
const defaults = Capacitor.isNativePlatform() ? STATIC_ANDROID_BUNDLED_PACKS : STATIC_BROWSER_BUNDLED_PACKS;
|
||||
return defaults.has(packId)
|
||||
return STATIC_BUNDLED_PACKS.has(packId)
|
||||
|| Boolean(snapshot.bundledManifest?.packs.some((pack) => pack.id === packId && pack.bundledOnAndroid));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { activeDungeonCompletedObjectiveIds, subscribeDungeonEncounterSnapshots } from "./dungeonEncounterRuntime";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { useDungeonRunStore } from "./dungeonRunStore";
|
||||
import type { DungeonRunMember } from "./dungeonRunTypes";
|
||||
|
||||
function currentRunParty(): readonly DungeonRunMember[] {
|
||||
const shell = useShellStore.getState();
|
||||
const character = shell.activeCharacter;
|
||||
if (!character) return [];
|
||||
const members: DungeonRunMember[] = [{ name: character.name, classId: character.classId,
|
||||
level: useCombatStore.getState().level, role: shell.activeDungeonRole, npc: false,
|
||||
...(character.secondaryClassId ? { secondaryClassId: character.secondaryClassId } : {}) }];
|
||||
const online = useOnlineSessionStore.getState().snapshot;
|
||||
for (const player of online?.players ?? []) {
|
||||
if (player.accountId === shell.session?.ownerId || !player.character) continue;
|
||||
members.push({ name: player.character.name, classId: player.character.classId,
|
||||
level: player.character.level, role: player.role ?? "damage", npc: false });
|
||||
}
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
members.push({ name: member.name, classId: member.classId, level: member.level, role: member.role, npc: true });
|
||||
}
|
||||
return members.slice(0, 5);
|
||||
}
|
||||
|
||||
/** Wall-clock run timing survives wipes, pauses, checkpoints, and renderer retries. */
|
||||
export function DungeonRunBridge() {
|
||||
useEffect(() => {
|
||||
let announcedId: string | null = null;
|
||||
const advance = () => {
|
||||
const game = useGameStore.getState();
|
||||
const shell = useShellStore.getState();
|
||||
if (shell.phase !== "game" || game.gameMode !== "dungeon" || game.gmTestMode
|
||||
|| !["ready", "proxy"].includes(game.assetStatus)) return;
|
||||
const run = useDungeonRunStore.getState();
|
||||
if (run.progress?.dungeonId !== game.activeDungeonId) return;
|
||||
const party = currentRunParty();
|
||||
const activity = useOnlineGroupStore.getState().activeActivity;
|
||||
if (activity?.type === "dungeon" && activity.contentId === game.activeDungeonId
|
||||
&& run.progress.finishedAt === null && run.progress.id !== activity.id) {
|
||||
useDungeonRunStore.setState({ progress: { ...run.progress, id: activity.id,
|
||||
startedAt: activity.startedAt, party } });
|
||||
}
|
||||
run.start(Date.now(), party);
|
||||
run.advance(activeDungeonCompletedObjectiveIds(game.activeDungeonId),
|
||||
Object.values(useCombatStore.getState().mobs).filter((mob) => mob.dead && !mob.despawned).map((mob) => mob.id),
|
||||
Date.now(), party);
|
||||
const progress = useDungeonRunStore.getState().progress;
|
||||
if (progress?.finishedAt !== null && progress && announcedId !== progress.id) {
|
||||
announcedId = progress.id;
|
||||
useGameStore.getState().openOverlay("dungeon-results");
|
||||
}
|
||||
};
|
||||
const unsubscribeEncounters = subscribeDungeonEncounterSnapshots(advance);
|
||||
const unsubscribeGame = useGameStore.subscribe(advance);
|
||||
const interval = setInterval(advance, 250);
|
||||
advance();
|
||||
return () => { unsubscribeEncounters(); unsubscribeGame(); clearInterval(interval); };
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import { DUNGEON_RUN_CHECKPOINT_VERSION } from "./dungeonRunCheckpoint";
|
||||
import { useGameStore } from "./store";
|
||||
import { useDungeonRunStore } from "./dungeonRunStore";
|
||||
|
||||
function checkpointSignature(dungeonId: string): string {
|
||||
const game = useGameStore.getState();
|
||||
@@ -15,6 +16,7 @@ function checkpointSignature(dungeonId: string): string {
|
||||
difficultyId: game.activeDifficultyId,
|
||||
spawn: game.activeSpawn,
|
||||
encounters,
|
||||
run: useDungeonRunStore.getState().progress,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,10 +43,13 @@ export function DungeonRunCheckpointBridge() {
|
||||
spawn: game.activeSpawn,
|
||||
encounters,
|
||||
savedAt: Date.now(),
|
||||
run: useDungeonRunStore.getState().progress ?? undefined,
|
||||
});
|
||||
};
|
||||
save();
|
||||
return subscribeDungeonEncounterSnapshots(save);
|
||||
const unsubscribeEncounters = subscribeDungeonEncounterSnapshots(save);
|
||||
const unsubscribeRun = useDungeonRunStore.subscribe(save);
|
||||
return () => { unsubscribeEncounters(); unsubscribeRun(); };
|
||||
}, [activeCharacterId, phase]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
if (import.meta.env.MODE === "test") {
|
||||
if (import.meta.env.MODE === "test" || import.meta.env.MODE === "shelved") {
|
||||
await preloadAbilityCatalogsForClasses([
|
||||
...BASE_CLASS_IDS,
|
||||
...COA_CLASS_IDS,
|
||||
|
||||
@@ -14,6 +14,16 @@ import {
|
||||
} from "./coaLiveCatalog";
|
||||
|
||||
const abilities = Object.values(COA_ABILITIES_BY_CLASS).flat();
|
||||
|
||||
it("keeps Drakkari Barrier friendly and its retaliation in the damage-taken proc", () => {
|
||||
const barrier = COA_ABILITIES_BY_CLASS["witch-doctor"].find((ability) => ability.name === "Drakkari Barrier")!;
|
||||
expect(barrier.target).toBe("friendly");
|
||||
for (const rank of barrier.ranks ?? []) {
|
||||
expect(rank.effects.some((effect) => effect.kind === "damage")).toBe(false);
|
||||
expect(rank.effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.procs?.some((proc) => proc.triggers === "damage-taken" && proc.action.kind === "damage"))).toBe(true);
|
||||
}
|
||||
});
|
||||
const ranksFor = (ability: AbilityDefinition): readonly AbilityRankDefinition[] => ability.ranks ?? [{
|
||||
rank: 1,
|
||||
spellId: ability.dbcSpellId,
|
||||
|
||||
@@ -987,7 +987,8 @@ function effectsForSpell(
|
||||
}
|
||||
|
||||
const hasHealing = effects.some((effect) => ["heal", "hot"].includes(effect.kind));
|
||||
const hasDamage = effects.some((effect) => ["damage", "dot"].includes(effect.kind));
|
||||
const hasDamage = effects.some((effect) => ["damage", "dot"].includes(effect.kind)
|
||||
|| (effect.kind === "apply-aura" && effect.aura.procs?.some((proc) => proc.action.kind === "damage")));
|
||||
const describesHealing = /\b(?:heal(?:s|ing|ed)?|restores? .*health|mends?|rejuvenat\w*)\b/i.test(description);
|
||||
const describesDamage = /\b(?:deal(?:s|ing)?|inflict(?:s|ing)?|caus(?:e|es|ing)|damag(?:e|es|ing))\b[^.]*\bdamage\b/i.test(description);
|
||||
if (!hasHealing && describesHealing) effects.unshift({ kind: "heal", coefficient: 1 });
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface CombatActorCastState {
|
||||
/** Simulation-space origin used for movement cancellation and completion validation. */
|
||||
readonly origin?: CombatActorPosition;
|
||||
readonly targetId?: string | null;
|
||||
readonly channelTicksCompleted?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { abilitiesForClass } from "./abilityCatalog";
|
||||
import { channelAmountEffects } from "./combatChannelAmounts";
|
||||
|
||||
describe("channel amount expansion", () => {
|
||||
it("spreads a normal channel's full amount across its pulses", () => {
|
||||
const ability = abilitiesForClass("mage")[0];
|
||||
const effects = channelAmountEffects({ ...ability, effects: [
|
||||
{ kind: "damage", coefficient: 1, basePoints: 100 },
|
||||
{ kind: "dot", coefficient: 1, ticks: 5, intervalMs: 1_000, tag: "channel" },
|
||||
] }, 5);
|
||||
expect(effects.map(({ multiplier }) => multiplier)).toEqual([0.2, 1]);
|
||||
});
|
||||
|
||||
it("uses the authored periodic payload for Radiant Flame", () => {
|
||||
const ability = abilitiesForClass("sun-cleric").find((candidate) => candidate.name === "Radiant Flame")!;
|
||||
const amounts = channelAmountEffects(ability, 5);
|
||||
expect(amounts).toHaveLength(1);
|
||||
expect(amounts[0]).toMatchObject({ effect: { kind: "damage", basePoints: 1 }, multiplier: 1 });
|
||||
const conditional = { ...ability, effects: ability.effects.map((effect) => effect.kind === "apply-aura"
|
||||
? { ...effect, aura: { ...effect.aura, procs: effect.aura.procs?.map((proc) => ({ ...proc, chance: 0.5 })) } }
|
||||
: effect) };
|
||||
expect(channelAmountEffects(conditional, 5)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { triggeredAbilityBySpellId, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
|
||||
|
||||
type ChannelAmountEffect = Extract<AbilityEffect, { kind: "damage" | "dot" | "heal" | "hot" }>;
|
||||
|
||||
/** Direct channel amounts span the cast; periodic trigger payloads fire each pulse. */
|
||||
export function channelAmountEffects(ability: AbilityDefinition, totalTicks: number): readonly {
|
||||
effect: ChannelAmountEffect;
|
||||
multiplier: number;
|
||||
}[] {
|
||||
const ticks = Math.max(1, totalTicks);
|
||||
const amounts: { effect: ChannelAmountEffect; multiplier: number }[] = [];
|
||||
for (const effect of ability.effects) {
|
||||
if (effect.kind === "damage" || effect.kind === "heal" || effect.kind === "dot" || effect.kind === "hot") {
|
||||
amounts.push({ effect, multiplier: (effect.kind === "dot" || effect.kind === "hot" ? effect.ticks : 1) / ticks });
|
||||
} else if (effect.kind === "apply-aura") {
|
||||
for (const proc of effect.aura.procs ?? []) {
|
||||
const triggers = Array.isArray(proc.triggers) ? proc.triggers : [proc.triggers];
|
||||
const action = proc.action;
|
||||
if (!triggers.includes("periodic") || proc.chance !== 1 || proc.internalCooldownMs || proc.charges !== undefined
|
||||
|| action.kind !== "custom"
|
||||
|| !["wow335-trigger-spell", "rom-trigger-skill"].includes(action.id)
|
||||
|| typeof action.data?.spellId !== "number") continue;
|
||||
const payload = triggeredAbilityBySpellId(action.data.spellId);
|
||||
for (const payloadEffect of payload?.effects ?? []) {
|
||||
if (payloadEffect.kind === "damage" || payloadEffect.kind === "heal") {
|
||||
amounts.push({ effect: payloadEffect, multiplier: 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return amounts;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PartyNavigationGraph } from "./partyNavigation";
|
||||
|
||||
export type CombatSpatialPosition = readonly [x: number, y: number, z: number];
|
||||
|
||||
export interface CombatSpatialActorSnapshot {
|
||||
@@ -8,6 +10,8 @@ export interface CombatSpatialActorSnapshot {
|
||||
}
|
||||
|
||||
export interface CombatSpatialProvider {
|
||||
/** The already-loaded, reviewed dungeon surface shared by actor navigation. */
|
||||
readonly navigationGraph?: () => PartyNavigationGraph | null;
|
||||
readonly actor: (actorId: string) => CombatSpatialActorSnapshot | null;
|
||||
readonly hasLineOfSight: (
|
||||
source: CombatSpatialPosition,
|
||||
@@ -45,6 +49,10 @@ export function combatHasLineOfSight(
|
||||
return provider?.hasLineOfSight(source, target) ?? true;
|
||||
}
|
||||
|
||||
export function combatNavigationGraph(): PartyNavigationGraph | null {
|
||||
return provider?.navigationGraph?.() ?? null;
|
||||
}
|
||||
|
||||
export function projectCombatGroundPoint(
|
||||
requested: CombatSpatialPosition,
|
||||
maximumDistance: number,
|
||||
|
||||
+208
-89
@@ -129,6 +129,7 @@ import {
|
||||
} from "./combatAuras";
|
||||
import { rollBossLoot } from "./lootCatalog";
|
||||
import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula";
|
||||
import { channelAmountEffects } from "./combatChannelAmounts";
|
||||
import { normalizeInventory, type InventoryItem } from "./lootTypes";
|
||||
import {
|
||||
PLAYER_EQUIPMENT_OWNER_ID,
|
||||
@@ -372,6 +373,7 @@ export interface MobCombatState {
|
||||
readonly lastDamageAt: number;
|
||||
readonly engaged: boolean;
|
||||
readonly combatPhase: "idle" | "chasing" | "attacking" | "returning";
|
||||
readonly navigationEvadeUntil?: number;
|
||||
readonly homePosition: CombatPosition | null;
|
||||
readonly nextAttackAt: number;
|
||||
readonly abilityReadyAt: Readonly<Record<string, number>>;
|
||||
@@ -720,9 +722,12 @@ export interface CombatState {
|
||||
damageSummon: (id: string, amount: number) => number;
|
||||
healPlayer: (amount: number) => number;
|
||||
revivePlayer: (percentMaxHealth?: number) => number;
|
||||
/** Checkpoint recovery of the player only; keeps the party and current pull intact. */
|
||||
respawnPlayerOnly: (position: CombatPosition, now?: number) => boolean;
|
||||
cancelCast: (message?: string) => boolean;
|
||||
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
|
||||
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
|
||||
resetMobForNavigation: (id: string, atHome: boolean, now?: number) => boolean;
|
||||
tick: (deltaSeconds: number, now?: number) => void;
|
||||
resetEncounter: (options?: {
|
||||
readonly preserveDefeated?: boolean;
|
||||
@@ -1181,7 +1186,7 @@ function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkA
|
||||
const physicalKeyword = /\b(?:strike|slash|stab|shot|arrow|mangle|claw|bite|weapon|melee)\b/.test(searchable);
|
||||
const rangedPhysicalKeyword = /\b(?:shot|arrow|bow|gun|bullet|javelin)\b/.test(searchable);
|
||||
if (healing) return "spell";
|
||||
if (ability.range.max > 4 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged";
|
||||
if (ability.range.max > 5 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged";
|
||||
if (["warrior", "rogue", "death-knight"].includes(baseClassFor(classId)) || physicalKeyword) return "melee";
|
||||
return "spell";
|
||||
}
|
||||
@@ -1200,7 +1205,7 @@ export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition
|
||||
return "arcane";
|
||||
}
|
||||
|
||||
function equipmentAdjustedCombatAmount(
|
||||
export function equipmentAdjustedCombatAmount(
|
||||
scaling: number | (AbilityAmountScaling & { readonly coefficient: number }),
|
||||
level: number,
|
||||
gearStats: ItemStats,
|
||||
@@ -2173,10 +2178,7 @@ function executeTriggeredAuraProcsInWork(
|
||||
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
|
||||
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + action.amount, 0, maximum);
|
||||
} else if (action.kind === "heal" && actionTarget === PLAYER_AURA_ENTITY_ID) {
|
||||
const healed = Math.min(Math.max(0, action.amount), work.maxHealth - work.health);
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
work.totalHealing += healed;
|
||||
work.totalHealing += healPlayerInWork(work, action.amount);
|
||||
} else if (action.kind === "heal" && actionTarget && !work.mobs[actionTarget]) {
|
||||
work.totalHealing += healCompanionActorInWork(work, actionTarget, Math.max(0, action.amount));
|
||||
} else if (action.kind === "damage" && actionTarget && work.mobs[actionTarget]) {
|
||||
@@ -2377,7 +2379,8 @@ function grantMobExperience(
|
||||
work.secondaryClassId,
|
||||
work.auras,
|
||||
);
|
||||
progression.health = progression.maxHealth;
|
||||
// A lingering DoT may grant experience after death, but leveling is not resurrection.
|
||||
progression.health = work.health > 0 ? progression.maxHealth : 0;
|
||||
work.health = progression.health;
|
||||
work.maxHealth = progression.maxHealth;
|
||||
work.maxResource = talentAdjustedMaxResource(
|
||||
@@ -2418,6 +2421,7 @@ function engageMobGroup(
|
||||
let changed = false;
|
||||
for (const [id, mob] of Object.entries(mobs)) {
|
||||
if (mob.dead || mob.despawned || mobGroupId(id) !== groupId) continue;
|
||||
if (mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now) continue;
|
||||
const threatByActor = groupWasEngaged
|
||||
? mob.threatByActor
|
||||
: seedActorThreat(mob.threatByActor, sourceActorId);
|
||||
@@ -2459,7 +2463,7 @@ function nearestAggroActorId(
|
||||
let nearestDistanceSquared = aggroRange * aggroRange;
|
||||
if (state.health > 0) {
|
||||
const playerDistanceSquared = distanceSquared(mobPosition, state.playerPosition);
|
||||
if (playerDistanceSquared <= nearestDistanceSquared) {
|
||||
if (playerDistanceSquared <= nearestDistanceSquared && combatHasLineOfSight(mobPosition, state.playerPosition)) {
|
||||
nearestId = PLAYER_AGGRO_ID;
|
||||
nearestDistanceSquared = playerDistanceSquared;
|
||||
}
|
||||
@@ -2469,7 +2473,7 @@ function nearestAggroActorId(
|
||||
const position = getPartyRuntimePosition(member.id);
|
||||
if (!position) continue;
|
||||
const memberDistanceSquared = distanceSquared(mobPosition, position);
|
||||
if (memberDistanceSquared < nearestDistanceSquared) {
|
||||
if (memberDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, position)) {
|
||||
nearestId = member.id;
|
||||
nearestDistanceSquared = memberDistanceSquared;
|
||||
}
|
||||
@@ -2479,7 +2483,7 @@ function nearestAggroActorId(
|
||||
const position = getPartyRuntimePosition(actor.id);
|
||||
if (!position) continue;
|
||||
const actorDistanceSquared = distanceSquared(mobPosition, position);
|
||||
if (actorDistanceSquared < nearestDistanceSquared) {
|
||||
if (actorDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, position)) {
|
||||
nearestId = actor.id;
|
||||
nearestDistanceSquared = actorDistanceSquared;
|
||||
}
|
||||
@@ -2487,7 +2491,7 @@ function nearestAggroActorId(
|
||||
for (const summon of state.summons) {
|
||||
if (summon.health <= 0) continue;
|
||||
const summonDistanceSquared = distanceSquared(mobPosition, summon.position);
|
||||
if (summonDistanceSquared < nearestDistanceSquared) {
|
||||
if (summonDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, summon.position)) {
|
||||
nearestId = summon.id;
|
||||
nearestDistanceSquared = summonDistanceSquared;
|
||||
}
|
||||
@@ -2533,7 +2537,9 @@ function damageMobInWork(
|
||||
combatResult?: CombatResult,
|
||||
): DamageResult {
|
||||
const mob = work.mobs[id];
|
||||
if (!mob || mob.dead || amount <= 0) return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
||||
if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
|
||||
return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
||||
}
|
||||
const mitigatedBeforeAuras = combatResult?.finalDamage ?? damageAfterMobResistance(
|
||||
amount,
|
||||
mob,
|
||||
@@ -2678,7 +2684,7 @@ function resolveAndDamageMobInWork(
|
||||
rules: CombatResultRules = {},
|
||||
): DamageResult {
|
||||
const mob = work.mobs[id];
|
||||
if (!mob || mob.dead || amount <= 0) {
|
||||
if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
|
||||
return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
||||
}
|
||||
const sourceAuras = aurasForEntity(work.auras, source.actorId);
|
||||
@@ -2872,10 +2878,7 @@ function executeTriggeredSpellInWork(
|
||||
"taken",
|
||||
));
|
||||
if (recipient === PLAYER_AURA_ENTITY_ID) {
|
||||
const healed = Math.min(amount, work.maxHealth - work.health);
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
work.totalHealing += healed;
|
||||
work.totalHealing += healPlayerInWork(work, amount);
|
||||
} else {
|
||||
const healed = healCompanionActorInWork(work, recipient, amount);
|
||||
work.totalHealing += healed;
|
||||
@@ -3183,6 +3186,18 @@ function applyAuraHealthTransitionInWork(
|
||||
mirrorCompanionActor(nextActor);
|
||||
}
|
||||
|
||||
/** Ordinary healing never transitions a corpse back to a living actor. */
|
||||
function healPlayerInWork(work: MutableCastWork, amount: number): number {
|
||||
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
||||
if (work.health <= 0 || requested <= 0) return 0;
|
||||
const healed = Math.min(requested, Math.max(0, work.maxHealth - work.health));
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
const actor = work.actors[PLAYER_AURA_ENTITY_ID];
|
||||
if (actor) work.actors[PLAYER_AURA_ENTITY_ID] = { ...actor, health: work.health, alive: true };
|
||||
return healed;
|
||||
}
|
||||
|
||||
function healCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number {
|
||||
const actor = ensureCompanionActorInWork(work, id);
|
||||
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
||||
@@ -3574,11 +3589,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
0,
|
||||
registration.moveSpeed ?? existing?.moveSpeed ?? (boss ? 3.15 : 3.5),
|
||||
);
|
||||
const attackDamage = existing && registration.damageMultiplier !== undefined
|
||||
const attackDamage = existing && level === existing.level && registration.damageMultiplier !== undefined
|
||||
? Math.max(1, Math.round(
|
||||
existing.attackDamage * (damageMultiplier / Math.max(0.01, existing.damageMultiplier)),
|
||||
))
|
||||
: existing?.attackDamage ?? Math.max(1, Math.round(
|
||||
: (existing && level === existing.level ? existing.attackDamage : undefined) ?? Math.max(1, Math.round(
|
||||
defaults.attackDamage * damageMultiplier,
|
||||
));
|
||||
const mob: MobCombatState = existing
|
||||
@@ -3606,7 +3621,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
leashRange,
|
||||
moveSpeed,
|
||||
attackDamage,
|
||||
health: Math.min(existing.health, maxHealth),
|
||||
health: existing.dead || existing.health <= 0
|
||||
? 0
|
||||
: clamp(Math.round(existing.health / Math.max(1, existing.maxHealth) * maxHealth), 1, maxHealth),
|
||||
xpReward,
|
||||
hasLoot,
|
||||
}
|
||||
@@ -4695,7 +4712,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
? Math.min(resolution.rawAmount, friendlySummon.maxHealth - friendlySummon.health)
|
||||
: friendlyOnline
|
||||
? Math.min(resolution.rawAmount, friendlyOnline.maxHealth - friendlyOnline.health)
|
||||
: resolution.effectiveAmount;
|
||||
: healPlayerInWork(work, resolution.rawAmount);
|
||||
if (friendlySummon && healed > 0) {
|
||||
work.summons = work.summons.map((summon) => summon.id === healingTargetId
|
||||
? { ...summon, health: summon.health + healed }
|
||||
@@ -4705,9 +4722,6 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
} else if (friendlyOnline && healed > 0) {
|
||||
const actor = work.actors[healingTargetId] ?? friendlyOnline;
|
||||
work.actors[healingTargetId] = { ...actor, health: actor.health + healed, alive: true };
|
||||
} else if (!friendlyTarget) {
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
}
|
||||
recordCombatEventInWork(work, {
|
||||
occurredAt: now,
|
||||
@@ -4739,10 +4753,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
&& aura.definition.utilityTags?.includes("beacon-of-light")
|
||||
));
|
||||
if (beacon?.targetId === PLAYER_AURA_ENTITY_ID) {
|
||||
const beaconHealing = Math.min(directHealing, work.maxHealth - work.health);
|
||||
work.health += beaconHealing;
|
||||
work.progression.health = work.health;
|
||||
work.totalHealing += beaconHealing;
|
||||
work.totalHealing += healPlayerInWork(work, directHealing);
|
||||
} else if (beacon) {
|
||||
work.totalHealing += healCompanionActorInWork(work, beacon.targetId, directHealing);
|
||||
}
|
||||
@@ -5326,10 +5337,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
work.resource = clamp(work.resource + restored, 0, work.maxResource);
|
||||
work.resourcePools[activeResourceType] = work.resource;
|
||||
} else if (/food|refreshment|health|firestone/i.test(ability.name)) {
|
||||
const healed = Math.min(Math.max(1, Math.round(work.maxHealth * 0.25)), work.maxHealth - work.health);
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
work.totalHealing += healed;
|
||||
work.totalHealing += healPlayerInWork(work, Math.max(1, Math.round(work.maxHealth * 0.25)));
|
||||
}
|
||||
} else if (effect.action === "portal") {
|
||||
summonSequence += 1;
|
||||
@@ -5358,13 +5366,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("rom-revival-charge")
|
||||
));
|
||||
const restored = Math.min(
|
||||
work.maxHealth - work.health,
|
||||
Math.round(work.maxHealth * 0.05 * (revival?.stacks ?? 0)),
|
||||
);
|
||||
work.health += restored;
|
||||
work.progression.health = work.health;
|
||||
work.totalHealing += restored;
|
||||
work.totalHealing += healPlayerInWork(work, Math.round(work.maxHealth * 0.05 * (revival?.stacks ?? 0)));
|
||||
if (revival) work.auras = work.auras.filter((aura) => aura.instanceId !== revival.instanceId);
|
||||
}
|
||||
}
|
||||
@@ -5407,9 +5409,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|
||||
// Judgement of Light restores 2% health when the marked target is struck.
|
||||
if (work.totalDamage > 0 && targetId && work.mobs[targetId]?.statuses.some((entry) => entry.kind === "marked")) {
|
||||
const healed = Math.min(talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability), work.maxHealth - work.health);
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
const healed = healPlayerInWork(work,
|
||||
talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability));
|
||||
work.totalHealing += healed;
|
||||
addHealingThreatInWork(work, castThreatSource, healed);
|
||||
}
|
||||
@@ -5656,7 +5657,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|
||||
tauntMob: (id, source, durationMs, now = combatNow()) => {
|
||||
const target = get().mobs[id];
|
||||
if (!target || target.dead || durationMs <= 0) return false;
|
||||
if (!target || target.dead || durationMs <= 0 || (target.navigationEvadeUntil ?? 0) > now) return false;
|
||||
const previous = target.tauntDiminishingReturns;
|
||||
const applications = target.boss && previous && previous.resetAt > now ? previous.applications : 0;
|
||||
const multiplier = target.boss ? [1, 0.65, 0.4225, 0][Math.min(3, applications)] : 1;
|
||||
@@ -5676,6 +5677,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
));
|
||||
for (const [mobId, mob] of Object.entries(mobs)) {
|
||||
if (mob.dead || mobGroupId(mobId) !== groupId) continue;
|
||||
if ((mob.navigationEvadeUntil ?? 0) > now) continue;
|
||||
const seeded = groupWasEngaged
|
||||
? mob.threatByActor
|
||||
: seedActorThreat(mob.threatByActor, source.actorId);
|
||||
@@ -5868,10 +5870,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
if (id === PLAYER_AURA_ENTITY_ID) {
|
||||
const restored = state.health > 0 ? 0 : Math.max(1, Math.round(state.maxHealth * fraction));
|
||||
if (restored <= 0) return 0;
|
||||
const playerActor = state.actors[id];
|
||||
set({
|
||||
health: restored,
|
||||
controlledUntil: 0,
|
||||
controlMechanic: null,
|
||||
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
||||
actors: playerActor ? { ...state.actors, [id]: {
|
||||
...playerActor, health: restored, alive: true, controlledUntil: 0, cast: null,
|
||||
} } : state.actors,
|
||||
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored, abilityId }),
|
||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: now,
|
||||
@@ -6095,16 +6102,16 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const state = get();
|
||||
if (!state.actors[sourceActorId]) return false;
|
||||
if (effect.kind === "dot" && (!state.mobs[targetActorId] || state.mobs[targetActorId].dead)) return false;
|
||||
if (effect.kind === "hot" && !state.actors[targetActorId]) return false;
|
||||
if (effect.kind === "hot") {
|
||||
const targetHealth = targetActorId === PLAYER_AURA_ENTITY_ID ? state.health
|
||||
: state.summons.find((summon) => summon.id === targetActorId)?.health
|
||||
?? state.actors[targetActorId]?.health ?? 0;
|
||||
if (!state.actors[targetActorId] || targetHealth <= 0) return false;
|
||||
}
|
||||
const sourceActor = state.actors[sourceActorId];
|
||||
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
|
||||
const amount = sourceMember
|
||||
? Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * (
|
||||
sourceMember.level * 4
|
||||
+ sourceMember.gearStats.spellPower
|
||||
+ sourceMember.gearStats.attackPower * 0.35
|
||||
+ sourceMember.gearStats.rangedAttackPower * 0.35
|
||||
)))
|
||||
? equipmentAdjustedCombatAmount(effect, sourceMember.level, sourceMember.gearStats, sourceMember.classId, ability)
|
||||
: Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * sourceActor.level * 4));
|
||||
const next = state.effects.filter((entry) => !(
|
||||
entry.kind === effect.kind
|
||||
@@ -6396,16 +6403,65 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const restored = Math.max(1, Math.round(
|
||||
state.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)),
|
||||
));
|
||||
const playerActor = state.actors[PLAYER_AURA_ENTITY_ID];
|
||||
set({
|
||||
health: restored,
|
||||
controlledUntil: 0,
|
||||
controlMechanic: null,
|
||||
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
||||
actors: playerActor ? { ...state.actors, [PLAYER_AURA_ENTITY_ID]: {
|
||||
...playerActor, health: restored, alive: true, controlledUntil: 0, cast: null,
|
||||
} } : state.actors,
|
||||
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored }),
|
||||
});
|
||||
return restored;
|
||||
},
|
||||
|
||||
respawnPlayerOnly: (position, now = combatNow()) => {
|
||||
const state = get();
|
||||
if (state.health > 0 || state.maxHealth <= 0 || !Number.isFinite(now)
|
||||
|| position.length !== 3 || !position.every(Number.isFinite)) return false;
|
||||
const auras = syncPassiveAuras(state.auras.filter((aura) => (
|
||||
aura.targetId !== PLAYER_AURA_ENTITY_ID || aura.definition.disposition === "buff"
|
||||
)), state.abilities, state.level, state.talentRanks, now);
|
||||
const maxHealth = auraAdjustedPlayerMaxHealth(state.classId, state.level, state.talentModifiers,
|
||||
state.gearStats, state.raceId, state.secondaryClassId, auras);
|
||||
const patch: Partial<CombatState> = {
|
||||
health: maxHealth,
|
||||
maxHealth,
|
||||
resource: initialResourceForMaximum(state.classId, state.maxResource),
|
||||
resourcePools: Object.fromEntries(Object.entries(state.maxResourcePools).map(([type, maximum]) => [
|
||||
type, type === resourceProfileForClass(state.classId).type
|
||||
? initialResourceForMaximum(state.classId, maximum ?? state.maxResource) : 0,
|
||||
])),
|
||||
controlledUntil: 0,
|
||||
controlMechanic: null,
|
||||
schoolLockedUntil: {},
|
||||
shield: 0,
|
||||
shieldExpiresAt: 0,
|
||||
activeCast: null,
|
||||
pendingGroundTarget: null,
|
||||
autoAttackTargetId: null,
|
||||
nextAutoAttackAt: 0,
|
||||
comboPoints: 0,
|
||||
comboTargetId: null,
|
||||
playerPosition: [...position],
|
||||
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
||||
effects: state.effects.filter((effect) => effect.targetId !== null && effect.targetId !== PLAYER_AURA_ENTITY_ID),
|
||||
auras,
|
||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: now, kind: "resurrection", sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: "player-checkpoint-resurrection",
|
||||
rawAmount: maxHealth, effectiveAmount: maxHealth, overheal: 0, absorbed: 0, critical: false,
|
||||
}),
|
||||
feedback: nextFeedback("success", "You have revived. Your party is still fighting; use Recall only if you want to regroup."),
|
||||
};
|
||||
// Publish only the player snapshot; never replace or mirror companion/enemy actors.
|
||||
const player = combatActorSnapshots({ ...state, ...patch })[PLAYER_AURA_ENTITY_ID];
|
||||
set({ ...patch, actors: { ...state.actors, [PLAYER_AURA_ENTITY_ID]: player } });
|
||||
return true;
|
||||
},
|
||||
|
||||
cancelCast: (message = "Casting interrupted.") => {
|
||||
const cast = get().activeCast;
|
||||
if (!cast) return false;
|
||||
@@ -6441,7 +6497,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
let engagedGroups = 0;
|
||||
for (const id of Object.keys(mobs)) {
|
||||
const mob = mobs[id];
|
||||
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0) continue;
|
||||
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0
|
||||
|| mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now) continue;
|
||||
const position = getMobPosition(id);
|
||||
if (!position) continue;
|
||||
const actorId = nearestAggroActorId(runtimeState, position, mob.aggroRange);
|
||||
@@ -6452,6 +6509,38 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return engagedGroups;
|
||||
},
|
||||
|
||||
resetMobForNavigation: (id, atHome, now = combatNow()) => {
|
||||
const state = get();
|
||||
const mob = state.mobs[id];
|
||||
if (!mob || mob.dead || mob.despawned || !Number.isFinite(now)) return false;
|
||||
set({
|
||||
mobs: { ...state.mobs, [id]: {
|
||||
...mob,
|
||||
engaged: false,
|
||||
combatPhase: atHome ? "idle" : "returning",
|
||||
navigationEvadeUntil: Math.max(mob.navigationEvadeUntil ?? 0, now + 10_000),
|
||||
health: mob.maxHealth,
|
||||
statuses: [],
|
||||
controlDiminishingReturns: {},
|
||||
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
|
||||
auras: [],
|
||||
abilityReadyAt: {},
|
||||
abilityCastCounts: {},
|
||||
activeCast: null,
|
||||
schoolLockedUntil: {},
|
||||
nextAttackAt: 0,
|
||||
nextAttackIndex: 0,
|
||||
threatByActor: {},
|
||||
targetActorId: null,
|
||||
forcedTarget: null,
|
||||
} },
|
||||
effects: state.effects.filter((effect) => effect.targetId !== id && effect.sourceActorId !== id),
|
||||
auras: state.auras.filter((aura) => aura.targetId !== id && aura.sourceId !== id),
|
||||
areaEffects: state.areaEffects.filter((area) => area.sourceActorId !== id),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
advanceMobCombat: (now = combatNow(), playerPosition) => {
|
||||
const state = get();
|
||||
const runtimeState = playerPosition
|
||||
@@ -6527,6 +6616,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const dy = position[1] - targetPosition[1];
|
||||
const dz = position[2] - targetPosition[2];
|
||||
const distanceSquared = dx * dx + dy * dy + dz * dz;
|
||||
const targetVisible = combatHasLineOfSight(position, targetPosition);
|
||||
const abilityReadyAt = Object.keys(mob.abilityReadyAt).length
|
||||
? { ...mob.abilityReadyAt }
|
||||
: { ...initialEnemyAbilityReadyAt(mob, now) };
|
||||
@@ -6537,6 +6627,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
let enemyActiveCast = cannotAttack || (silenced && mob.activeCast?.school !== "physical")
|
||||
? null
|
||||
: mob.activeCast;
|
||||
if (enemyActiveCast) {
|
||||
const pendingAttack = mob.attacks.find((attack) => attack.id === enemyActiveCast!.attackId);
|
||||
const pendingPosition = enemyActiveCast.targetActorId
|
||||
? actorPosition(runtimeState, enemyActiveCast.targetActorId) : null;
|
||||
if (pendingAttack && enemyAbilityNeedsHostileRange(pendingAttack)
|
||||
&& (!pendingPosition || !combatHasLineOfSight(position, pendingPosition)
|
||||
|| Math.hypot(position[0] - pendingPosition[0], position[1] - pendingPosition[1],
|
||||
position[2] - pendingPosition[2]) > pendingAttack.range)) {
|
||||
emitEnemyAttackPresentation(mob, pendingAttack, "cast-cancel", now, position,
|
||||
enemyActiveCast.targetActorId, pendingPosition);
|
||||
abilityReadyAt[pendingAttack.id] = now + 250;
|
||||
enemyActiveCast = null;
|
||||
}
|
||||
}
|
||||
let completingEnemyCast = false;
|
||||
let selectedAttackIndex = -1;
|
||||
let selectedAttack: EnemyAttackDefinition | null = null;
|
||||
@@ -6551,7 +6655,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const attack = mob.attacks[index];
|
||||
const attackKind = enemyAttackKind(attack);
|
||||
const inAbilityRange = !enemyAbilityNeedsHostileRange(attack)
|
||||
|| distanceSquared <= attack.range * attack.range;
|
||||
|| (targetVisible && distanceSquared <= attack.range * attack.range);
|
||||
if (
|
||||
inAbilityRange
|
||||
&& !(silenced && attackKind === "spell")
|
||||
@@ -6567,10 +6671,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
}
|
||||
const inRange = mob.attacks.some((attack) => (
|
||||
!enemyAbilityNeedsHostileRange(attack)
|
||||
|| distanceSquared <= attack.range * attack.range
|
||||
));
|
||||
// A self buff/heal must not pin an otherwise blocked attacker in place.
|
||||
const hostileAttacks = mob.attacks.filter(enemyAbilityNeedsHostileRange);
|
||||
const inRange = targetVisible && (hostileAttacks.length
|
||||
? hostileAttacks.some((attack) => distanceSquared <= attack.range * attack.range)
|
||||
: distanceSquared <= mob.attackRange * mob.attackRange);
|
||||
if (selectedAttack && completingEnemyCast && enemyAbilityNeedsHostileRange(selectedAttack)) {
|
||||
const castTargetPosition = enemyActiveCast?.targetActorId
|
||||
? actorPosition(runtimeState, enemyActiveCast.targetActorId) : null;
|
||||
if (!castTargetPosition || !combatHasLineOfSight(position, castTargetPosition)
|
||||
|| Math.hypot(position[0] - castTargetPosition[0], position[1] - castTargetPosition[1],
|
||||
position[2] - castTargetPosition[2]) > selectedAttack.range) {
|
||||
emitEnemyAttackPresentation(mob, selectedAttack, "cast-cancel", now, position,
|
||||
enemyActiveCast?.targetActorId ?? resolution.targetActorId, castTargetPosition);
|
||||
abilityReadyAt[selectedAttack.id] = now + 250;
|
||||
enemyActiveCast = null;
|
||||
selectedAttack = null;
|
||||
}
|
||||
}
|
||||
const phase: MobCombatState["combatPhase"] = inRange ? "attacking" : rooted ? "idle" : "chasing";
|
||||
let nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY);
|
||||
let attackRevision = mob.attackRevision;
|
||||
@@ -7510,7 +7628,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
combatEvents: [...state.combatEvents],
|
||||
actors: { ...state.actors },
|
||||
cooldowns: { ...state.cooldowns },
|
||||
health: Math.min(
|
||||
health: state.health <= 0 ? 0 : Math.min(
|
||||
state.maxHealth,
|
||||
state.health + resolveAuraStatValue(
|
||||
state.gearStats.healthPer5 / 5 + state.gearStats.spirit * 0.01,
|
||||
@@ -7639,10 +7757,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
|
||||
const timedEffectTargetAlive = (effect: TimedCombatEffect): boolean => {
|
||||
if (effect.kind === "dot") {
|
||||
const mob = effect.targetId ? work.mobs[effect.targetId] : null;
|
||||
return Boolean(mob && !mob.dead && mob.health > 0);
|
||||
}
|
||||
const targetId = effect.targetId ?? PLAYER_AURA_ENTITY_ID;
|
||||
if (targetId === PLAYER_AURA_ENTITY_ID) return work.health > 0;
|
||||
const summon = work.summons.find((candidate) => candidate.id === targetId);
|
||||
if (summon) return summon.health > 0;
|
||||
const actor = ensureCompanionActorInWork(work, targetId) ?? work.actors[targetId];
|
||||
return Boolean(actor?.alive && actor.health > 0);
|
||||
};
|
||||
for (const effect of state.effects) {
|
||||
// Check before due ticks/procs, not only after applying their health changes.
|
||||
if (!timedEffectTargetAlive(effect)) continue;
|
||||
let remainingTicks = effect.remainingTicks;
|
||||
let nextTickAt = effect.nextTickAt;
|
||||
while (remainingTicks > 0 && nextTickAt <= now) {
|
||||
while (remainingTicks > 0 && nextTickAt <= now && timedEffectTargetAlive(effect)) {
|
||||
const ability = abilityById(effect.sourceAbilityId);
|
||||
const baseAmount = effect.fixedAmount ?? (ability
|
||||
? equipmentAdjustedCombatAmount(
|
||||
@@ -7696,10 +7828,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
"taken",
|
||||
));
|
||||
if (effect.targetId) {
|
||||
const target = work.actors[effect.targetId]
|
||||
?? ensureCompanionActorInWork(work, effect.targetId)
|
||||
?? (work.summons.some((summon) => summon.id === effect.targetId)
|
||||
? combatActorFromSummon(work.summons.find((summon) => summon.id === effect.targetId)!, progression.level, work.auras)
|
||||
const target = ensureCompanionActorInWork(work, hotTargetId)
|
||||
?? work.actors[hotTargetId]
|
||||
?? (work.summons.some((summon) => summon.id === hotTargetId)
|
||||
? combatActorFromSummon(work.summons.find((summon) => summon.id === hotTargetId)!, progression.level, work.auras)
|
||||
: null);
|
||||
if (target?.alive) partyHotTicks.push({
|
||||
memberId: target.id,
|
||||
@@ -7708,9 +7840,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
sourceAbilityId: effect.sourceAbilityId,
|
||||
});
|
||||
} else {
|
||||
const healed = Math.min(modifiedHealing, work.maxHealth - work.health);
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
const healed = healPlayerInWork(work, modifiedHealing);
|
||||
work.totalHealing += healed;
|
||||
addHealingThreatInWork(
|
||||
work,
|
||||
@@ -7755,12 +7885,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
remainingTicks -= 1;
|
||||
nextTickAt += effect.intervalMs;
|
||||
}
|
||||
const targetAlive = effect.kind === "dot"
|
||||
? Boolean(effect.targetId && !work.mobs[effect.targetId]?.dead)
|
||||
: !effect.targetId || usePartyStore.getState().members.some(
|
||||
(member) => member.id === effect.targetId && member.health > 0,
|
||||
);
|
||||
if (remainingTicks > 0 && targetAlive) {
|
||||
if (remainingTicks > 0 && timedEffectTargetAlive(effect)) {
|
||||
work.effects.push(
|
||||
remainingTicks === effect.remainingTicks && nextTickAt === effect.nextTickAt
|
||||
? effect
|
||||
@@ -7816,13 +7941,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|| Math.sqrt(distanceSquared(area.position, position)) > area.radius
|
||||
|| !combatHasLineOfSight(area.position, position)) continue;
|
||||
const member = partyMembers.find((candidate) => candidate.id === friendlyId);
|
||||
if (member ? member.health <= 0 : work.health <= 0) continue;
|
||||
const healed = member
|
||||
? healCompanionActorInWork(work, friendlyId, amount)
|
||||
: Math.min(amount, work.maxHealth - work.health);
|
||||
if (!member) {
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
}
|
||||
: healPlayerInWork(work, amount);
|
||||
work.totalHealing += healed;
|
||||
recordCombatEventInWork(work, {
|
||||
occurredAt: nextTickAt,
|
||||
@@ -7873,12 +7995,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const target = injured[0];
|
||||
if (target) {
|
||||
const healed = target.id === PLAYER_AURA_ENTITY_ID
|
||||
? Math.min(summon.attackDamage, work.maxHealth - work.health)
|
||||
? healPlayerInWork(work, summon.attackDamage)
|
||||
: healCompanionActorInWork(work, target.id, summon.attackDamage);
|
||||
if (target.id === PLAYER_AURA_ENTITY_ID) {
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
}
|
||||
work.totalHealing += healed;
|
||||
addHealingThreatInWork(work, { actorId: summon.ownerActorId, role: "healer", abilityId: summon.sourceAbilityId }, healed);
|
||||
recordCombatEventInWork(work, {
|
||||
@@ -8078,30 +8196,31 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
: "Channel interrupted: target unavailable.");
|
||||
return;
|
||||
}
|
||||
const damageAmount = ability.effects.reduce(
|
||||
(total, effect) => total + (effect.kind === "damage" || effect.kind === "dot"
|
||||
const channelEffects = channelAmountEffects(ability, cast.totalTicks);
|
||||
const damageAmount = channelEffects.reduce(
|
||||
(total, { effect, multiplier }) => total + (effect.kind === "damage" || effect.kind === "dot"
|
||||
? equipmentAdjustedCombatAmount(
|
||||
effect,
|
||||
get().level,
|
||||
get().gearStats,
|
||||
get().classId,
|
||||
ability,
|
||||
) * (effect.kind === "dot" ? effect.ticks : 1)
|
||||
) * multiplier
|
||||
: 0),
|
||||
0,
|
||||
) / cast.totalTicks;
|
||||
const healingAmount = ability.effects.reduce(
|
||||
(total, effect) => total + (effect.kind === "heal" || effect.kind === "hot"
|
||||
);
|
||||
const healingAmount = channelEffects.reduce(
|
||||
(total, { effect, multiplier }) => total + (effect.kind === "heal" || effect.kind === "hot"
|
||||
? equipmentAdjustedCombatAmount(
|
||||
effect,
|
||||
get().level,
|
||||
get().gearStats,
|
||||
get().classId,
|
||||
ability,
|
||||
) * (effect.kind === "hot" ? effect.ticks : 1)
|
||||
) * multiplier
|
||||
: 0),
|
||||
0,
|
||||
) / cast.totalTicks;
|
||||
);
|
||||
if (damageAmount > 0) {
|
||||
const channelCenter = channelHostile ? targetPosition ?? cast.origin : get().playerPosition;
|
||||
const channelTargets = ability.radius === undefined
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilityAtLevel } from "./abilityCatalog";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore, type TimedCombatEffect } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { respawnDefeatedPlayer } from "./deathRespawn";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
function killPlayer(): void {
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().playerAnimationEvent?.kind).toBe("death");
|
||||
}
|
||||
|
||||
function selfHot(): TimedCombatEffect {
|
||||
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 1_000).ok).toBe(true);
|
||||
const effect = useCombatStore.getState().effects.find((entry) => entry.kind === "hot" && entry.targetId === null);
|
||||
expect(effect).toBeDefined();
|
||||
return effect!;
|
||||
}
|
||||
|
||||
function setMemberHealth(id: string, health: number): void {
|
||||
const member = usePartyStore.getState().members.find((candidate) => candidate.id === id)!;
|
||||
useCombatStore.getState().commitCompanionActors([{ ...member, health, status: health <= 0 ? "down" : "ready" }]);
|
||||
}
|
||||
|
||||
describe("healing must not implicitly resurrect defeated players", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "druid", level: 1, xp: 0 });
|
||||
usePartyStore.getState().initializeParty("death-healing", 1, useCombatStore.getState().health);
|
||||
usePartyStore.getState().selectMember(null);
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useGameStore.getState().resetAtEntrance();
|
||||
});
|
||||
|
||||
it("reproduces death with a self HoT and rejects every overdue tick", () => {
|
||||
const effect = selfHot();
|
||||
killPlayer();
|
||||
const deathEvent = useCombatStore.getState().playerAnimationEvent;
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt + effect.intervalMs * 10);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().playerAnimationEvent).toBe(deathEvent);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 100_000))
|
||||
.toMatchObject({ ok: false, reason: "player-dead" });
|
||||
});
|
||||
|
||||
it("removes a dead player's HoT even before its next tick is due", () => {
|
||||
const effect = selfHot();
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0, effect.nextTickAt - 1);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects the actual Priest Renew tick after lethal damage", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 14 });
|
||||
const renew = useCombatStore.getState().abilities.find((ability) => ability.name === "Renew"
|
||||
&& ability.effects.some((effect) => effect.kind === "hot"));
|
||||
expect(renew).toBeDefined();
|
||||
expect(useCombatStore.getState().castAbility(renew!.id, undefined, 1_000).ok).toBe(true);
|
||||
const effect = useCombatStore.getState().effects.find((entry) => entry.kind === "hot")!;
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not let passive spirit or equipment regeneration heal a corpse", () => {
|
||||
const state = useCombatStore.getState();
|
||||
useCombatStore.setState({ gearStats: { ...state.gearStats, healthPer5: 500, spirit: 500 } });
|
||||
killPlayer();
|
||||
for (let frame = 0; frame < 30; frame += 1) {
|
||||
useCombatStore.getState().tick(0.1, 10_000 + frame * 100);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects companion HoTs with an explicit player target after death", () => {
|
||||
const effect = selfHot();
|
||||
const source = usePartyStore.getState().members[0].id;
|
||||
useCombatStore.setState({ effects: [{ ...effect, sourceActorId: source, targetId: PLAYER_AURA_ENTITY_ID }] });
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("living explicit player HoTs retain future ticks and healing combat events", () => {
|
||||
const effect = selfHot();
|
||||
const source = usePartyStore.getState().members[0].id;
|
||||
useCombatStore.setState({ effects: [{ ...effect, sourceActorId: source, targetId: PLAYER_AURA_ENTITY_ID }] });
|
||||
useCombatStore.getState().damagePlayer(50);
|
||||
const before = useCombatStore.getState().health;
|
||||
useCombatStore.getState().tick(0, effect.nextTickAt);
|
||||
expect(useCombatStore.getState().health).toBeGreaterThan(before);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(1);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "healing"
|
||||
&& event.sourceActorId === source && event.targetActorId === PLAYER_AURA_ENTITY_ID
|
||||
&& event.effectiveAmount > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects newly applied teammate HoTs on dead targets", () => {
|
||||
const ability = abilityAtLevel(useCombatStore.getState().abilities.find((entry) => entry.id === "druid-rejuvenation")!, 1);
|
||||
const hot = ability.effects.find((effect) => effect.kind === "hot")!;
|
||||
expect(hot.kind).toBe("hot");
|
||||
if (hot.kind !== "hot") throw new Error("Expected Rejuvenation HoT");
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const source = usePartyStore.getState().members[0].id;
|
||||
killPlayer();
|
||||
expect(useCombatStore.getState().applyTimedActorEffect(source, PLAYER_AURA_ENTITY_ID, ability, hot, 1_000)).toBe(false);
|
||||
const member = usePartyStore.getState().members[1];
|
||||
setMemberHealth(member.id, 0);
|
||||
expect(useCombatStore.getState().applyTimedActorEffect(source, member.id, ability, hot, 1_000)).toBe(false);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes a fallen ally's HoT without changing other party members", () => {
|
||||
const member = usePartyStore.getState().members[0];
|
||||
registerPartyRuntimePosition(member.id, [2, 0, 0]);
|
||||
usePartyStore.getState().selectMember(member.id);
|
||||
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 1_000).ok).toBe(true);
|
||||
const effect = useCombatStore.getState().effects[0];
|
||||
setMemberHealth(member.id, 0);
|
||||
const others = usePartyStore.getState().members.slice(1);
|
||||
useCombatStore.getState().tick(0, effect.nextTickAt);
|
||||
expect(usePartyStore.getState().members[0].health).toBe(0);
|
||||
expect(usePartyStore.getState().members.slice(1)).toEqual(others);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ground healing skips the dead player while continuing to heal living allies", () => {
|
||||
const effect = selfHot();
|
||||
const member = usePartyStore.getState().members[0];
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
setMemberHealth(member.id, 1);
|
||||
useCombatStore.setState({ effects: [], areaEffects: [{
|
||||
id: "healing-ground", sourceActorId: member.id, sourceAbilityId: effect.sourceAbilityId,
|
||||
position: [0, 0, 0], radius: 10, createdAt: 1_000, expiresAt: 20_000,
|
||||
nextTickAt: 4_000, tickIntervalMs: 3_000, fixedHealingPerTick: 50,
|
||||
}] });
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0.1, 4_000);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(usePartyStore.getState().members[0].health).toBeGreaterThan(1);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "healing"
|
||||
&& event.targetActorId === PLAYER_AURA_ENTITY_ID && event.effectiveAmount > 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("a HoT on a living ally continues even when its player caster dies", () => {
|
||||
const member = usePartyStore.getState().members[0];
|
||||
registerPartyRuntimePosition(member.id, [2, 0, 0]);
|
||||
setMemberHealth(member.id, 1);
|
||||
usePartyStore.getState().selectMember(member.id);
|
||||
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 1_000).ok).toBe(true);
|
||||
const effect = useCombatStore.getState().effects[0];
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(usePartyStore.getState().members[0].health).toBeGreaterThan(1);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("periodic damage cannot proc an ordinary self heal after its caster dies", () => {
|
||||
useCombatStore.getState().registerMob("dot-target", { name: "Target", maxHealth: 1_000, xpReward: 0 });
|
||||
setMobPosition("dot-target", [2, 0, 0]);
|
||||
const aura = applyAura([], { sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now: 1_000, definition: {
|
||||
id: "periodic-heal-proc", name: "Periodic heal", disposition: "buff", durationMs: null,
|
||||
procs: [{ id: "heal", triggers: "periodic", chance: 1,
|
||||
action: { kind: "heal", target: "aura-target", amount: 100 } }],
|
||||
},
|
||||
}).auras;
|
||||
useCombatStore.setState({ auras: [...aura], effects: [{
|
||||
id: "dot", kind: "dot", sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
sourceAbilityId: "druid-rejuvenation", targetId: "dot-target", coefficient: 1,
|
||||
fixedAmount: 1, remainingTicks: 3, intervalMs: 3_000, nextTickAt: 4_000,
|
||||
}] });
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0, 4_000);
|
||||
expect(useCombatStore.getState().mobs["dot-target"].health).toBeLessThan(1_000);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
});
|
||||
|
||||
it("a lingering damage tick can award a level without resurrecting its dead caster", () => {
|
||||
useCombatStore.getState().registerMob("dot-target", { name: "Target", maxHealth: 1, xpReward: 100_000 });
|
||||
setMobPosition("dot-target", [2, 0, 0]);
|
||||
useCombatStore.setState({ effects: [{
|
||||
id: "dot", kind: "dot", sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
sourceAbilityId: "druid-rejuvenation", targetId: "dot-target", coefficient: 1,
|
||||
fixedAmount: 100, remainingTicks: 1, intervalMs: 3_000, nextTickAt: 4_000,
|
||||
}] });
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0, 4_000);
|
||||
expect(useCombatStore.getState().level).toBeGreaterThan(1);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
});
|
||||
|
||||
it("living self HoTs and regeneration still restore health", () => {
|
||||
useCombatStore.getState().damagePlayer(50);
|
||||
const effect = selfHot();
|
||||
const before = useCombatStore.getState().health;
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||
expect(useCombatStore.getState().health).toBeGreaterThan(before);
|
||||
expect(useCombatStore.getState().health).toBeLessThanOrEqual(useCombatStore.getState().maxHealth);
|
||||
});
|
||||
|
||||
it("explicit resurrection and normal respawn still work after discarding corpse HoTs", () => {
|
||||
const effect = selfHot();
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||
const restored = useCombatStore.getState().revivePlayer(0.35);
|
||||
expect(restored).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().playerAnimationEvent?.kind).toBe("ability-cancel");
|
||||
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 20_000).ok).toBe(true);
|
||||
killPlayer();
|
||||
expect(respawnDefeatedPlayer()).toBe(true);
|
||||
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("actor-based party resurrection clears the corpse animation and actor death state", () => {
|
||||
const effect = selfHot();
|
||||
killPlayer();
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||
const restored = useCombatStore.getState().reviveActor(PLAYER_AURA_ENTITY_ID, 0.35,
|
||||
usePartyStore.getState().members[0].id, "party-resurrection", 10_000);
|
||||
expect(restored).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().playerAnimationEvent?.kind).toBe("ability-cancel");
|
||||
expect(useCombatStore.getState().actors[PLAYER_AURA_ENTITY_ID]).toMatchObject({ alive: true, health: restored });
|
||||
});
|
||||
});
|
||||
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user