Release Healer Man 0.1.22
This commit is contained in:
1 parent
f6f2375145
commit
7f6001723a
144 files changed
+54107
-690
No files matched your search
@@ -207,6 +207,7 @@ $ReleaseVersion = [ordered]@{
|
||||
npm run content:test
|
||||
npm run server:test
|
||||
npm test
|
||||
npm run content:publish
|
||||
npm run build
|
||||
npm test -- --run src/avatar
|
||||
npx vitest run src/game/inputManager.test.ts src/game/inputMath.test.ts
|
||||
@@ -222,6 +223,11 @@ git push origin main
|
||||
git rev-parse HEAD
|
||||
```
|
||||
|
||||
`content:publish` is a fail-closed pre-build release check. It must report all
|
||||
54 WoW campaign dungeon packs and refuses to replace the manifest when a pack,
|
||||
dependency, or catalog-referenced asset is missing. Its local `content/` output
|
||||
is ignored and must not be staged.
|
||||
|
||||
Before `git commit`, read the staged file list printed by `git status --short`.
|
||||
Do not commit a `.env` file, password, API token, keystore, APK, database,
|
||||
`content/`, `dist/`, `dist-android/`, `.web-public/`, or `.android-public/`
|
||||
|
||||
@@ -1728,3 +1728,114 @@ Vite's existing large-chunk advisory remains informational.
|
||||
audit covered 12,347 compressed textures with zero fallbacks, and the
|
||||
environment-performance audit found no regressions. The existing large-chunk
|
||||
advisory and Rapier initialization deprecation warning remain informational.
|
||||
|
||||
# 2026-09-08 — WoW dungeon production-functionality audit
|
||||
|
||||
- Audited all 54 WoW five-player catalog entries against live runtime contracts,
|
||||
pinned AzerothCore population/object drafts, and instance-script source. The
|
||||
composed-runtime content audit is green for 1,193 mobs, 273 bosses, 4,026
|
||||
attacks, 2,572 specials, and 1,275 native models, but the stricter production-
|
||||
fidelity gate remains blocked at 0/54.
|
||||
- The audit found 3,943 source game-object spawns, including 2,321 actionable
|
||||
doors, buttons, chests/resources, transports, traps, and related objects. Raw
|
||||
database rows are no longer passed into the typed world-object renderer; all
|
||||
54 live definitions now have zero malformed world-object records.
|
||||
- The follow-up implementation sweep completed source review for all 54
|
||||
instances. Fifty-one now have runtime-wired instance orchestration and the three
|
||||
genuinely empty stock instance scripts are pinned as source-exact no-ops;
|
||||
there are no unreviewed instance scripts left. Wailing Caverns remains the
|
||||
only entry with a reviewed off-mesh link, and the three Ascension-specific
|
||||
dungeons still lack authoritative server population data.
|
||||
- Entered Wailing Caverns from the local browser roster as a Priest healer. The
|
||||
3D environment, populated combat route, party, objective, minimap, and HUD all
|
||||
loaded; the minimap strip showed one Power Word: Fortitude entry. No uncaught
|
||||
client runtime errors appeared. Rapier's existing initialization deprecation
|
||||
warning remained informational.
|
||||
- The local content manager exposed all 54 WoW entries but only Wailing Caverns
|
||||
was installed; the other 53 were marked download required. Those dungeons were
|
||||
therefore checked by source/runtime-contract audit in this pass, not claimed
|
||||
as completed visual traversal playtests.
|
||||
- The follow-up world-object compiler converted all 3,943 source rows—including
|
||||
all 2,321 actionable doors, chests/resources, switches, traps, and
|
||||
transports—into stable typed definitions with transformed coordinates,
|
||||
type-aware states, conservative dimensions, and source/template/spawn/type
|
||||
provenance. Wailing Caverns receives its 57 generated records through the
|
||||
authored-definition enrichment path as well.
|
||||
- Client `GameObjectDisplayInfo` metadata resolves a visual display ID for
|
||||
3,905 records, a native model path for 3,837, and exact bounds for 3,547. The
|
||||
generated export manifest deduplicates those instances into 458 native legacy
|
||||
assets, records 38 intentional no-display spell-focus objects, and isolates
|
||||
the remaining nine missing-model display IDs and 68 no-bounds display IDs for
|
||||
review.
|
||||
- An initial live pass exposed colored procedural markers for adapted
|
||||
chests/resources. Unreviewed source objects are now deliberately hidden,
|
||||
non-blocking, and non-interactive so incomplete door scripts cannot soft-lock
|
||||
a dungeon and placeholder art cannot leak into normal play. A second live
|
||||
Wailing Caverns pass confirmed the clean 3D scene, party combat, HUD, minimap,
|
||||
and single Fortitude entry. No uncaught client errors appeared; duplicate
|
||||
KTX2-loader warnings occurred only across deliberate hot-reload cycles.
|
||||
- The final runtime audit reports 54/54 reviewed dungeons. Fifty-one dungeons use
|
||||
52 rule-bearing instance-controller definitions (Blackrock Spire has separate
|
||||
lower- and upper-spire controllers), while three use verified source-exact
|
||||
no-op scripts. Every catalog objective has a completion action, and the audit
|
||||
found zero malformed-object, duplicate-id, or encounter-contract issues. All
|
||||
51 rule-bearing dungeons retain explicit adaptation/gap notes; native object
|
||||
models and full-route live traversal are not claimed complete.
|
||||
- The source-combat follow-up gives every runtime-spawnable boss a sourced kit
|
||||
unless the pinned source really is melee-only. The six deliberate exceptions
|
||||
are Kresh, Deviate Faerie Dragon, Spirestone Battle Lord, Spirestone Butcher,
|
||||
Crystal Fang, and Grubbis. Crystal Fang retains its source-exact 15% rare-spawn
|
||||
behavior and death summon. Trial of the Champion, Utgarde Keep, Hellfire
|
||||
Ramparts, Sethekk Halls, Mana-Tombs, and Slave Pens event bosses now use their
|
||||
pinned spell IDs and timing windows; unsupported vehicle, proc, queue, shared-
|
||||
health, phase, stack, and healing-reduction semantics are explicitly recorded
|
||||
as adaptations instead of being silently invented.
|
||||
- Wipe/reset validation exhaustively covered 673,664 reachable encounter states,
|
||||
180 reset paths, every one of the 68 non-initial reset targets, and all 135
|
||||
entity-timeout rules. Durable progression now fails closed on a missing entity,
|
||||
legacy snapshots with unordered action history fail closed, and Frozen Reach's
|
||||
Arktos is attempt-scoped so a wipe respawns him and can still advance the duo
|
||||
encounter on the next pull. The two viable post-reset resume states in Scarlet
|
||||
Cathedral and Halls of Stone are intentional, not soft locks.
|
||||
- Same-process checkpoint restore and online follower hydration now accept their
|
||||
trusted save/leader snapshots even when a retained local provider has a newer
|
||||
reset revision; ordinary stale imports remain rejected. A renderer restart
|
||||
preserves the current encounter snapshot. Encounter effects use one delivery
|
||||
path at a time, and obsolete headless queues are cleared on reset or dungeon
|
||||
switch instead of retaining already-applied envelopes for the process lifetime.
|
||||
- The generated download topology now contains an installable logical pack for
|
||||
every one of the 54 campaign dungeons. The three custom dungeons without their
|
||||
own creature payload are represented by dependency-only packs, while every
|
||||
non-bundled dungeon pulls its exact environment and shared-creature closure.
|
||||
The existing live content feed was not deployed or mutated during this pass;
|
||||
browser traversal therefore still reflects the packs already available there.
|
||||
- Installed packs now persist v2 verification receipts containing their exact
|
||||
file set, byte counts, hashes, and dependencies. Browser-cache and native-file
|
||||
presence are revalidated before entry, production dungeon entry fails closed
|
||||
when a pack or dependency is missing or stale, and `content:publish` validates
|
||||
the complete 54-dungeon dependency graph before any release output is written.
|
||||
- Run completion now leaves the final room active so loot can be collected before
|
||||
the player explicitly exits and clears the checkpoint. A wipe returns the party
|
||||
to the saved run spawn while preserving already-cleared bosses and durable run
|
||||
progress; attempt-scoped actors and effects reset for the next pull.
|
||||
- The live Priest check used the saved `Rolecheck` character, which advanced from
|
||||
level 11 to level 12 during the dungeon passes. The latest Wailing Caverns run
|
||||
displayed 677/677 health, one Power Word: Fortitude alongside Arcane Intellect
|
||||
(and Holy Concentration while active), and one Frostbolt plus one Icy Touch
|
||||
debuff. Repeated Frostbolt applications refreshed that one entry from 7.1 to
|
||||
21 seconds rather than duplicating it. The deterministic geared level-8 check
|
||||
produced 3,285 mana and increased health from 407 to 437 when Fortitude was
|
||||
applied.
|
||||
- Verification remains deliberately split: source coverage, runtime contracts,
|
||||
reset reachability, and automated behavior are green, while the combined
|
||||
production-fidelity audit remains 0/54. Its remaining blockers include native
|
||||
object export/collision, explicitly listed source-semantic gaps, and end-to-end
|
||||
traversal evidence for every production pack—not unfinished instance-script
|
||||
review.
|
||||
- Final release verification passed all 1,109 Vitest cases across 202 files,
|
||||
TypeScript project compilation, and the production build with 967 modules
|
||||
transformed. The build staged 5,587 browser-bootstrap files, validated all
|
||||
eight arenas, audited 12,347 compressed textures with zero fallbacks, and found
|
||||
no environment-performance regressions. The release-candidate check accepted
|
||||
all 118 changed paths. The existing large-chunk advisory and Rapier
|
||||
initialization deprecation warning remain informational.
|
||||
+7
-1
@@ -237,10 +237,15 @@ Gitea tag after the signed APK builds successfully.
|
||||
npm run content:test
|
||||
npm run server:test
|
||||
npm test
|
||||
npm run content:publish
|
||||
npm run build
|
||||
```
|
||||
|
||||
All four commands must succeed.
|
||||
All five commands must succeed. `content:publish` is a fail-closed release gate:
|
||||
it validates that the generated manifest contains all 54 WoW campaign dungeon
|
||||
packs, their exact dependency closure, and every catalog-referenced asset before
|
||||
it writes the ignored local `content/` output. Do not build or stage a release
|
||||
if that validation fails.
|
||||
|
||||
Apply these additional targeted checks whenever the matching area was edited:
|
||||
|
||||
@@ -540,6 +545,7 @@ Do **not** bump the Android version. Run these exact PowerShell commands:
|
||||
```powershell
|
||||
Set-Location F:\Projects\HealerMan
|
||||
npm run content:test
|
||||
npm run content:publish
|
||||
npm run build
|
||||
git status --short
|
||||
npm run release:check
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
# Five-player dungeon import checklist
|
||||
|
||||
Each dungeon is complete only when its actual instance environment, collision,
|
||||
This checklist tracks the native asset and population import, not complete
|
||||
instance gameplay. A checked dungeon has its environment, collision,
|
||||
navigation, AzerothCore population, boss roster, and native animated creature
|
||||
models are packaged under `public/assets/game/dungeons/<slug>/`.
|
||||
models packaged under `public/assets/game/dungeons/<slug>/`. Use
|
||||
`WOW_DUNGEON_FUNCTIONALITY_REPORT.md` for the separate production-fidelity gate
|
||||
covering doors, objects, scripted progression, waves, escorts, vehicles,
|
||||
traversal, resets, checkpoints, and completion playtests.
|
||||
|
||||
Instance-source review status (2026-09-08): 54/54 reviewed, 51/54 backed by an
|
||||
executable instance controller, and 3/54 pinned as source-exact no-op instance
|
||||
scripts. These counts describe implemented runtime coverage; they do not close
|
||||
the native game-object export or end-to-end playtest gates.
|
||||
|
||||
Run one dungeon at a time:
|
||||
|
||||
@@ -88,24 +97,28 @@ TypeScript/production bundle checks passed.
|
||||
|
||||
## Ascension-specific five-player instances
|
||||
|
||||
- [ ] Tinkertech Showdown (`821`) - blocked on authoritative Ascension server
|
||||
population/model data. Encounter identity exists for Mechatron (`663012`),
|
||||
but no creature template, display mapping, or spawn rows exist locally.
|
||||
- [ ] Frozen Reach (`908`) - blocked on authoritative Ascension server
|
||||
population/model data. Encounter identity exists for Alva (`97700`), but no
|
||||
creature template, display mapping, or spawn rows exist locally.
|
||||
- [ ] Forgotten Mine (`1781`) - blocked on authoritative Ascension server
|
||||
population/model data. Encounter identity exists for Geolord Vek (`254272`),
|
||||
but no creature template, display mapping, or spawn rows exist locally.
|
||||
- [ ] Tinkertech Showdown (`821`) - playable HealerMan-authored adaptation.
|
||||
Mechatron (`663012`) and the environment are exact; the candidate machine
|
||||
spell cluster, trash, cadence, positions, models, and phase rules remain
|
||||
explicitly authored because no server binding or spawn snapshot survived.
|
||||
- [ ] Frozen Reach (`908`) - playable source-backed adaptation. Alva (`97700`),
|
||||
Arktos (`97701`), 85 exact spell rows, and summon links for entries `97703`,
|
||||
`97731`, and `97770` were recovered. Server cadence, positions, stats,
|
||||
models, targeting, and exact phase behavior remain authored.
|
||||
- [ ] Forgotten Mine (`1781`) - playable source-backed adaptation. Geolord Vek
|
||||
(`254272`), 33 exact spell rows, Geospike (`254278`), and Resonite Shard
|
||||
(`254051`) were recovered. Server cadence, positions, trash, models, and the
|
||||
exact crystal rule remain authored.
|
||||
|
||||
The individual command has been run for all three blocked instances. Their maps
|
||||
and encounter identities are present in the installed client, but the installed
|
||||
client and available creature caches do not provide the server-only
|
||||
entry-to-display and spawn data needed to package their actual mobs/bosses.
|
||||
They intentionally remain on the documented procedural fallback until an
|
||||
Ascension server snapshot or reviewed creature/spawn export is supplied.
|
||||
The individual import command has been run for all three source-limited
|
||||
instances. Their checksummed recovery manifest lives under `ascension/` and is
|
||||
validated by `npm run dungeon:ascension:recovery:validate`. Runtime adapters
|
||||
consume the recovered evidence while retaining explicit `source-backed-adapted`
|
||||
or `healer-man-authored` labels. The checklist remains open until an
|
||||
authoritative Ascension creature/spawn/object/server-script snapshot and native
|
||||
display mappings replace the remaining authored portions.
|
||||
|
||||
## Per-dungeon completion gate
|
||||
## Per-dungeon asset/import gate
|
||||
|
||||
These gates passed for every checked dungeon above:
|
||||
|
||||
@@ -116,3 +129,7 @@ These gates passed for every checked dungeon above:
|
||||
- [x] Boss order, entrance, and coordinates reviewed.
|
||||
- [x] Generated campaign catalog points at `/assets/game/dungeons/<slug>/`.
|
||||
- [x] Catalog verification, tests, type-check, and production bundle pass.
|
||||
|
||||
These checks do not certify instance-script behavior. A dungeon remains
|
||||
functionally incomplete until its entry in `WOW_DUNGEON_FUNCTIONALITY_REPORT.md`
|
||||
is green.
|
||||
+54
-15
@@ -92,8 +92,10 @@ whose preserved source has no recoverable combat ability is explicitly marked
|
||||
|
||||
Stock AzerothCore population is used for the standard dungeons. The three
|
||||
Ascension-specific instances—Tinkertech Showdown, Frozen Reach, and Forgotten
|
||||
Mine—keep their client encounter identities and use an explicit procedural
|
||||
trash-population fallback. Non-Epoch recipes keep explicit procedural actor
|
||||
Mine—keep their recovered client identities, spell/summon evidence where
|
||||
available, and explicit source-backed or HealerMan-authored runtime adaptations.
|
||||
Their remaining procedural population is never labeled authoritative.
|
||||
Non-Epoch recipes keep explicit procedural actor
|
||||
bodies until reviewed display exports are available. Epoch actors use packaged
|
||||
client GLBs with their complete native animation libraries; only event-only
|
||||
controllers without a creature display fall back.
|
||||
@@ -160,10 +162,13 @@ The audit verifies the complete 54-dungeon catalog, authoritative/fallback
|
||||
classification, every native environment and creature URL, file sizes,
|
||||
checksums, animation presence, and compiled entry-to-model bindings. The
|
||||
2026-07-26 batch packaged 50 native imports; together with the Wailing Caverns
|
||||
golden fixture, 51/54 dungeons are authoritative. Tinkertech Showdown, Frozen
|
||||
Reach, and Forgotten Mine remain documented fallbacks because available local
|
||||
sources contain their maps and encounter identities but no authoritative
|
||||
Ascension creature templates, display mappings, or spawn rows.
|
||||
golden fixture, 51/54 dungeons have authoritative WoW population packages.
|
||||
Tinkertech Showdown, Frozen Reach, and Forgotten Mine now have playable,
|
||||
provenance-labeled runtime adaptations backed by
|
||||
`ascension/ASCENSION_CUSTOM_DUNGEON_RECOVERY.json`. They remain outside the
|
||||
authoritative package count because the recovered client evidence does not
|
||||
include complete Ascension creature templates, display mappings, spawn/object
|
||||
coordinates, or server-side cadence.
|
||||
|
||||
1. `npm run dungeon:discover -- <slug>` writes deterministic client/server
|
||||
snapshots, a provenance lock, review-required overrides, and—when server
|
||||
@@ -224,9 +229,14 @@ The default pass recompiles the 54-dungeon campaign, verifies all packaged
|
||||
environment and creature assets, audits every mob and boss attack/mechanic,
|
||||
opens every unique native creature GLB to verify idle, movement, attack, wound,
|
||||
and death clips, proves the procedural animation path for model-less actors,
|
||||
and runs the actual runtime-registry coverage test. It writes deterministic
|
||||
JSON and Markdown reports to WOW_DUNGEON_CONTENT_REPORT.json and
|
||||
WOW_DUNGEON_CONTENT_REPORT.md.
|
||||
runs the actual runtime-registry coverage test, and audits instance-level
|
||||
functionality. It writes deterministic JSON and Markdown reports to
|
||||
WOW_DUNGEON_CONTENT_REPORT.json, WOW_DUNGEON_CONTENT_REPORT.md,
|
||||
WOW_DUNGEON_FUNCTIONALITY_REPORT.json, and
|
||||
WOW_DUNGEON_FUNCTIONALITY_REPORT.md. Catalog compilation also writes
|
||||
WOW_GAMEOBJECT_ASSET_MANIFEST.json, which deduplicates the source-backed object
|
||||
spawns by client model path, preserves per-dungeon/display usage, assigns the
|
||||
planned shipping GLB URL, and lists unresolved display models and bounds.
|
||||
|
||||
Useful batch options:
|
||||
|
||||
@@ -239,11 +249,40 @@ Useful batch options:
|
||||
# Re-run green packages, then build the app after validation.
|
||||
npm run dungeon:wow:all -- --refresh --force --build
|
||||
|
||||
# Only re-run combat, mechanic, and animation coverage against current output.
|
||||
# Re-run combat, animation, and instance-functionality audits against current output.
|
||||
npm run dungeon:wow:all -- --audit-only
|
||||
|
||||
Kresh is intentionally recorded as a source-faithful melee-only boss: entry
|
||||
3653 has no spell or SmartAI row in the pinned AzerothCore snapshot. Trial of
|
||||
the Champion is likewise documented as a boss-arena population rather than a
|
||||
trash route. These exceptions are asserted by exact dungeon/entity IDs; any
|
||||
new basic-only boss or mob-less dungeon fails the batch.
|
||||
The functionality report is the production-fidelity gate. It separately checks
|
||||
typed world-object contracts, source instance scripts, encounter orchestration,
|
||||
required interactions, traversal evidence, resets, checkpoints, and completion
|
||||
playtests. A green asset or combat audit does not imply that doors, escorts,
|
||||
waves, vehicles, keys, chests, or scripted instance progression are implemented.
|
||||
The report fails closed and lists concrete edit targets for every remaining
|
||||
dungeon gap.
|
||||
|
||||
The 2026-09-08 implementation sweep completed an instance-level source review
|
||||
for all 54 campaign dungeons. Fifty-one now have runtime-wired reviewed
|
||||
orchestration in addition to fleet boss/checkpoint/completion declarations; the
|
||||
three stock instance scripts that are genuinely empty are pinned and recorded
|
||||
as source-exact no-ops. This means there is no remaining unreviewed instance
|
||||
script, not that every source feature is already exact or live-traversal validated. Per-dungeon adaptations,
|
||||
missing primitives, native-object export work, and live traversal evidence stay
|
||||
open in `WOW_DUNGEON_FUNCTIONALITY_REPORT.md`.
|
||||
|
||||
Source-backed object records stay hidden, non-blocking, and non-interactive
|
||||
until their manifest asset has been exported and the corresponding instance
|
||||
script transition has been reviewed. This lets the typed data ship through the
|
||||
runtime contract without leaking placeholder geometry or turning an incomplete
|
||||
door/transport port into a dungeon soft lock.
|
||||
|
||||
Kresh, the Deviate Faerie Dragon, Grubbis, Spirestone Battle Lord, Spirestone
|
||||
Butcher, and Crystal Fang are intentionally recorded as source-faithful
|
||||
melee-only bosses. Kresh entry 3653 and the Deviate Faerie Dragon entry 5912
|
||||
have no spell or SmartAI row in the pinned AzerothCore snapshot; the latter is
|
||||
also despawned by the Wailing Caverns initialization event. Grubbis entry 7361
|
||||
has only aggro dialogue and death-completion rows, with no combat spell cast.
|
||||
The three Blackrock Spire entries are 15% rare spawns whose database rows
|
||||
contain no active combat cast; Crystal Fang's exact on-death Spire Spiderling
|
||||
summon is implemented by the reviewed instance adapter. One shared exception
|
||||
table supplies the runtime test and composed-content audit; stale, duplicate,
|
||||
or newly introduced basic-only boss keys fail the batch.
|
||||
@@ -4,19 +4,19 @@
|
||||
"scope": "HealerMan WoW five-player combat and animation coverage",
|
||||
"summary": {
|
||||
"dungeons": 54,
|
||||
"mobs": 1031,
|
||||
"bosses": 253,
|
||||
"attacks": 3765,
|
||||
"specialAttacks": 2481,
|
||||
"nativeModels": 1266,
|
||||
"proceduralModels": 18,
|
||||
"uniqueNativeModels": 1166,
|
||||
"mobs": 1193,
|
||||
"bosses": 273,
|
||||
"attacks": 4026,
|
||||
"specialAttacks": 2572,
|
||||
"nativeModels": 1275,
|
||||
"proceduralModels": 191,
|
||||
"uniqueNativeModels": 1167,
|
||||
"nativeStateFallbacks": 18
|
||||
},
|
||||
"documentedExceptions": [
|
||||
{
|
||||
"type": "basic-only-boss",
|
||||
"key": "wailing-caverns:entry-3653",
|
||||
"key": "wailing-caverns:kresh",
|
||||
"reason": "Kresh is melee-only in the pinned AzerothCore snapshot; the historical server has no spell or SmartAI row for entry 3653."
|
||||
},
|
||||
{
|
||||
@@ -25,9 +25,24 @@
|
||||
"reason": "The server-faithful rare entry 5912 has no spell rows and is despawned by the Wailing Caverns initialization event."
|
||||
},
|
||||
{
|
||||
"type": "no-trash-instance",
|
||||
"key": "trial-of-the-champion",
|
||||
"reason": "Trial of the Champion is an encounter arena; its imported combat population is the five Grand Champion bosses, not a trash route."
|
||||
"type": "basic-only-boss",
|
||||
"key": "gnomeregan:wow-classic-batch-three-entry-7361",
|
||||
"reason": "Grubbis is melee-only in the pinned AzerothCore snapshot; entry 7361 has aggro dialogue and instance-completion rows but no combat spell cast."
|
||||
},
|
||||
{
|
||||
"type": "basic-only-boss",
|
||||
"key": "blackrock-spire:entry-9218",
|
||||
"reason": "Spirestone Battle Lord is a 15% rare spawn in the pinned database and has no combat spell cast beyond its basic melee attack."
|
||||
},
|
||||
{
|
||||
"type": "basic-only-boss",
|
||||
"key": "blackrock-spire:entry-9219",
|
||||
"reason": "Spirestone Butcher is a 15% rare spawn in the pinned database and has no combat spell cast beyond its basic melee attack."
|
||||
},
|
||||
{
|
||||
"type": "basic-only-boss",
|
||||
"key": "blackrock-spire:entry-10376",
|
||||
"reason": "Crystal Fang is a 15% rare spawn with no active combat spell in the pinned database; its exact on-death Spire Spiderling summon is implemented by the instance adapter."
|
||||
}
|
||||
],
|
||||
"animationFallbacks": [
|
||||
@@ -180,6 +195,18 @@
|
||||
],
|
||||
"issues": [],
|
||||
"dungeons": [
|
||||
{
|
||||
"id": "wailing-caverns",
|
||||
"title": "Wailing Caverns",
|
||||
"source": "azerothcore",
|
||||
"mobs": 23,
|
||||
"bosses": 9,
|
||||
"attacks": 72,
|
||||
"specialAttacks": 40,
|
||||
"nativeModels": 24,
|
||||
"proceduralModels": 8,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "shadowfang-keep",
|
||||
"title": "Shadowfang Keep",
|
||||
@@ -216,18 +243,6 @@
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "wailing-caverns",
|
||||
"title": "Wailing Caverns",
|
||||
"source": "azerothcore",
|
||||
"mobs": 12,
|
||||
"bosses": 8,
|
||||
"attacks": 51,
|
||||
"specialAttacks": 31,
|
||||
"nativeModels": 15,
|
||||
"proceduralModels": 5,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "razorfen-kraul",
|
||||
"title": "Razorfen Kraul",
|
||||
@@ -244,12 +259,12 @@
|
||||
"id": "blackfathom-deeps",
|
||||
"title": "Blackfathom Deeps",
|
||||
"source": "azerothcore",
|
||||
"mobs": 21,
|
||||
"mobs": 23,
|
||||
"bosses": 6,
|
||||
"attacks": 65,
|
||||
"attacks": 67,
|
||||
"specialAttacks": 38,
|
||||
"nativeModels": 27,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 2,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
@@ -268,36 +283,36 @@
|
||||
"id": "gnomeregan",
|
||||
"title": "Gnomeregan",
|
||||
"source": "azerothcore",
|
||||
"mobs": 27,
|
||||
"bosses": 4,
|
||||
"attacks": 82,
|
||||
"mobs": 28,
|
||||
"bosses": 5,
|
||||
"attacks": 84,
|
||||
"specialAttacks": 51,
|
||||
"nativeModels": 31,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 2,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "sunken-temple",
|
||||
"title": "Sunken Temple",
|
||||
"source": "azerothcore",
|
||||
"mobs": 29,
|
||||
"mobs": 31,
|
||||
"bosses": 4,
|
||||
"attacks": 93,
|
||||
"attacks": 95,
|
||||
"specialAttacks": 60,
|
||||
"nativeModels": 33,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 2,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "razorfen-downs",
|
||||
"title": "Razorfen Downs",
|
||||
"source": "azerothcore",
|
||||
"mobs": 19,
|
||||
"mobs": 21,
|
||||
"bosses": 6,
|
||||
"attacks": 63,
|
||||
"attacks": 65,
|
||||
"specialAttacks": 38,
|
||||
"nativeModels": 25,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 2,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
@@ -316,60 +331,60 @@
|
||||
"id": "zulfarrak",
|
||||
"title": "Zul'Farrak",
|
||||
"source": "azerothcore",
|
||||
"mobs": 16,
|
||||
"mobs": 23,
|
||||
"bosses": 5,
|
||||
"attacks": 69,
|
||||
"attacks": 76,
|
||||
"specialAttacks": 48,
|
||||
"nativeModels": 21,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 7,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "blackrock-spire",
|
||||
"title": "Blackrock Spire",
|
||||
"source": "azerothcore",
|
||||
"mobs": 63,
|
||||
"bosses": 15,
|
||||
"attacks": 273,
|
||||
"specialAttacks": 195,
|
||||
"mobs": 66,
|
||||
"bosses": 17,
|
||||
"attacks": 285,
|
||||
"specialAttacks": 202,
|
||||
"nativeModels": 78,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 5,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "blackrock-depths",
|
||||
"title": "Blackrock Depths",
|
||||
"source": "azerothcore",
|
||||
"mobs": 48,
|
||||
"mobs": 58,
|
||||
"bosses": 13,
|
||||
"attacks": 182,
|
||||
"attacks": 192,
|
||||
"specialAttacks": 121,
|
||||
"nativeModels": 61,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 10,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "scholomance",
|
||||
"title": "Scholomance",
|
||||
"source": "azerothcore",
|
||||
"mobs": 30,
|
||||
"mobs": 31,
|
||||
"bosses": 8,
|
||||
"attacks": 150,
|
||||
"attacks": 151,
|
||||
"specialAttacks": 112,
|
||||
"nativeModels": 38,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 1,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "stratholme",
|
||||
"title": "Stratholme",
|
||||
"source": "azerothcore",
|
||||
"mobs": 41,
|
||||
"mobs": 45,
|
||||
"bosses": 9,
|
||||
"attacks": 176,
|
||||
"attacks": 180,
|
||||
"specialAttacks": 126,
|
||||
"nativeModels": 50,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 4,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
@@ -436,12 +451,12 @@
|
||||
"id": "hellfire-ramparts",
|
||||
"title": "Hellfire Citadel: Ramparts",
|
||||
"source": "azerothcore",
|
||||
"mobs": 10,
|
||||
"bosses": 2,
|
||||
"attacks": 41,
|
||||
"specialAttacks": 29,
|
||||
"mobs": 11,
|
||||
"bosses": 4,
|
||||
"attacks": 47,
|
||||
"specialAttacks": 32,
|
||||
"nativeModels": 12,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 3,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
@@ -460,48 +475,48 @@
|
||||
"id": "underbog",
|
||||
"title": "Coilfang: The Underbog",
|
||||
"source": "azerothcore",
|
||||
"mobs": 18,
|
||||
"mobs": 20,
|
||||
"bosses": 4,
|
||||
"attacks": 80,
|
||||
"attacks": 82,
|
||||
"specialAttacks": 58,
|
||||
"nativeModels": 22,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 2,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "slave-pens",
|
||||
"title": "Coilfang: The Slave Pens",
|
||||
"source": "azerothcore",
|
||||
"mobs": 18,
|
||||
"bosses": 3,
|
||||
"attacks": 60,
|
||||
"specialAttacks": 39,
|
||||
"mobs": 21,
|
||||
"bosses": 4,
|
||||
"attacks": 66,
|
||||
"specialAttacks": 43,
|
||||
"nativeModels": 21,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 4,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "arcatraz",
|
||||
"title": "Tempest Keep: The Arcatraz",
|
||||
"source": "azerothcore",
|
||||
"mobs": 25,
|
||||
"mobs": 28,
|
||||
"bosses": 4,
|
||||
"attacks": 105,
|
||||
"attacks": 108,
|
||||
"specialAttacks": 76,
|
||||
"nativeModels": 29,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 3,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "botanica",
|
||||
"title": "Tempest Keep: The Botanica",
|
||||
"source": "azerothcore",
|
||||
"mobs": 24,
|
||||
"mobs": 28,
|
||||
"bosses": 5,
|
||||
"attacks": 85,
|
||||
"attacks": 89,
|
||||
"specialAttacks": 56,
|
||||
"nativeModels": 29,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 4,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
@@ -532,72 +547,72 @@
|
||||
"id": "sethekk-halls",
|
||||
"title": "Auchindoun: Sethekk Halls",
|
||||
"source": "azerothcore",
|
||||
"mobs": 14,
|
||||
"bosses": 2,
|
||||
"attacks": 50,
|
||||
"specialAttacks": 34,
|
||||
"mobs": 19,
|
||||
"bosses": 3,
|
||||
"attacks": 59,
|
||||
"specialAttacks": 37,
|
||||
"nativeModels": 16,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 6,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "mana-tombs",
|
||||
"title": "Auchindoun: Mana-Tombs",
|
||||
"source": "azerothcore",
|
||||
"mobs": 11,
|
||||
"bosses": 3,
|
||||
"attacks": 46,
|
||||
"specialAttacks": 32,
|
||||
"mobs": 12,
|
||||
"bosses": 4,
|
||||
"attacks": 50,
|
||||
"specialAttacks": 34,
|
||||
"nativeModels": 14,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 2,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "auchenai-crypts",
|
||||
"title": "Auchindoun: Auchenai Crypts",
|
||||
"source": "azerothcore",
|
||||
"mobs": 12,
|
||||
"bosses": 2,
|
||||
"attacks": 44,
|
||||
"specialAttacks": 30,
|
||||
"mobs": 13,
|
||||
"bosses": 3,
|
||||
"attacks": 48,
|
||||
"specialAttacks": 32,
|
||||
"nativeModels": 14,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 2,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "magisters-terrace",
|
||||
"title": "Magister's Terrace",
|
||||
"source": "azerothcore",
|
||||
"mobs": 15,
|
||||
"mobs": 23,
|
||||
"bosses": 3,
|
||||
"attacks": 57,
|
||||
"attacks": 65,
|
||||
"specialAttacks": 39,
|
||||
"nativeModels": 18,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 8,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "black-morass",
|
||||
"title": "The Black Morass",
|
||||
"source": "azerothcore",
|
||||
"mobs": 4,
|
||||
"mobs": 13,
|
||||
"bosses": 3,
|
||||
"attacks": 25,
|
||||
"attacks": 34,
|
||||
"specialAttacks": 18,
|
||||
"nativeModels": 7,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 9,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "escape-from-durnholde",
|
||||
"title": "The Escape From Durnholde",
|
||||
"source": "azerothcore",
|
||||
"mobs": 26,
|
||||
"mobs": 32,
|
||||
"bosses": 3,
|
||||
"attacks": 53,
|
||||
"attacks": 59,
|
||||
"specialAttacks": 24,
|
||||
"nativeModels": 29,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 6,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
@@ -605,107 +620,107 @@
|
||||
"title": "Utgarde Keep",
|
||||
"source": "azerothcore",
|
||||
"mobs": 19,
|
||||
"bosses": 3,
|
||||
"attacks": 44,
|
||||
"specialAttacks": 22,
|
||||
"bosses": 6,
|
||||
"attacks": 55,
|
||||
"specialAttacks": 30,
|
||||
"nativeModels": 22,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 3,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "utgarde-pinnacle",
|
||||
"title": "Utgarde Pinnacle",
|
||||
"source": "azerothcore",
|
||||
"mobs": 13,
|
||||
"mobs": 19,
|
||||
"bosses": 4,
|
||||
"attacks": 44,
|
||||
"attacks": 50,
|
||||
"specialAttacks": 27,
|
||||
"nativeModels": 17,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 6,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "the-nexus",
|
||||
"title": "The Nexus",
|
||||
"source": "azerothcore",
|
||||
"mobs": 21,
|
||||
"mobs": 24,
|
||||
"bosses": 5,
|
||||
"attacks": 61,
|
||||
"attacks": 64,
|
||||
"specialAttacks": 35,
|
||||
"nativeModels": 26,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 3,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "the-oculus",
|
||||
"title": "The Oculus",
|
||||
"source": "azerothcore",
|
||||
"mobs": 8,
|
||||
"mobs": 17,
|
||||
"bosses": 4,
|
||||
"attacks": 33,
|
||||
"attacks": 42,
|
||||
"specialAttacks": 21,
|
||||
"nativeModels": 12,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 9,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "culling-of-stratholme",
|
||||
"title": "The Culling of Stratholme",
|
||||
"source": "azerothcore",
|
||||
"mobs": 16,
|
||||
"mobs": 21,
|
||||
"bosses": 4,
|
||||
"attacks": 44,
|
||||
"attacks": 49,
|
||||
"specialAttacks": 24,
|
||||
"nativeModels": 20,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 5,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "halls-of-stone",
|
||||
"title": "Halls of Stone",
|
||||
"source": "azerothcore",
|
||||
"mobs": 13,
|
||||
"mobs": 16,
|
||||
"bosses": 4,
|
||||
"attacks": 42,
|
||||
"attacks": 45,
|
||||
"specialAttacks": 25,
|
||||
"nativeModels": 16,
|
||||
"proceduralModels": 1,
|
||||
"proceduralModels": 4,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "draktharon-keep",
|
||||
"title": "Drak'Tharon Keep",
|
||||
"source": "azerothcore",
|
||||
"mobs": 18,
|
||||
"mobs": 20,
|
||||
"bosses": 4,
|
||||
"attacks": 45,
|
||||
"attacks": 47,
|
||||
"specialAttacks": 23,
|
||||
"nativeModels": 22,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 2,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "azjol-nerub",
|
||||
"title": "Azjol-Nerub",
|
||||
"source": "azerothcore",
|
||||
"mobs": 10,
|
||||
"mobs": 18,
|
||||
"bosses": 3,
|
||||
"attacks": 30,
|
||||
"attacks": 38,
|
||||
"specialAttacks": 17,
|
||||
"nativeModels": 13,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 8,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "halls-of-lightning",
|
||||
"title": "Halls of Lightning",
|
||||
"source": "azerothcore",
|
||||
"mobs": 16,
|
||||
"mobs": 19,
|
||||
"bosses": 4,
|
||||
"attacks": 49,
|
||||
"attacks": 52,
|
||||
"specialAttacks": 29,
|
||||
"nativeModels": 20,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 3,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
@@ -724,24 +739,24 @@
|
||||
"id": "violet-hold",
|
||||
"title": "Violet Hold",
|
||||
"source": "azerothcore",
|
||||
"mobs": 2,
|
||||
"mobs": 17,
|
||||
"bosses": 3,
|
||||
"attacks": 24,
|
||||
"attacks": 39,
|
||||
"specialAttacks": 19,
|
||||
"nativeModels": 5,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 15,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "ahnkahet-old-kingdom",
|
||||
"title": "Ahn'kahet: The Old Kingdom",
|
||||
"source": "azerothcore",
|
||||
"mobs": 19,
|
||||
"mobs": 23,
|
||||
"bosses": 5,
|
||||
"attacks": 60,
|
||||
"attacks": 64,
|
||||
"specialAttacks": 36,
|
||||
"nativeModels": 24,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 4,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
@@ -760,36 +775,36 @@
|
||||
"id": "trial-of-the-champion",
|
||||
"title": "Trial of the Champion",
|
||||
"source": "azerothcore",
|
||||
"mobs": 0,
|
||||
"bosses": 5,
|
||||
"attacks": 88,
|
||||
"specialAttacks": 83,
|
||||
"mobs": 6,
|
||||
"bosses": 12,
|
||||
"attacks": 123,
|
||||
"specialAttacks": 105,
|
||||
"nativeModels": 5,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 13,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "pit-of-saron",
|
||||
"title": "Pit of Saron",
|
||||
"source": "azerothcore",
|
||||
"mobs": 27,
|
||||
"mobs": 31,
|
||||
"bosses": 3,
|
||||
"attacks": 49,
|
||||
"attacks": 53,
|
||||
"specialAttacks": 19,
|
||||
"nativeModels": 30,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 4,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "halls-of-reflection",
|
||||
"title": "Halls of Reflection",
|
||||
"source": "azerothcore",
|
||||
"mobs": 8,
|
||||
"mobs": 16,
|
||||
"bosses": 2,
|
||||
"attacks": 38,
|
||||
"attacks": 46,
|
||||
"specialAttacks": 28,
|
||||
"nativeModels": 10,
|
||||
"proceduralModels": 0,
|
||||
"proceduralModels": 8,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
@@ -798,8 +813,8 @@
|
||||
"source": "procedural-fallback",
|
||||
"mobs": 3,
|
||||
"bosses": 1,
|
||||
"attacks": 8,
|
||||
"specialAttacks": 4,
|
||||
"attacks": 13,
|
||||
"specialAttacks": 12,
|
||||
"nativeModels": 0,
|
||||
"proceduralModels": 4,
|
||||
"status": "green"
|
||||
@@ -808,24 +823,24 @@
|
||||
"id": "frozen-reach",
|
||||
"title": "Frozen Reach",
|
||||
"source": "procedural-fallback",
|
||||
"mobs": 3,
|
||||
"mobs": 2,
|
||||
"bosses": 1,
|
||||
"attacks": 8,
|
||||
"specialAttacks": 4,
|
||||
"attacks": 19,
|
||||
"specialAttacks": 19,
|
||||
"nativeModels": 0,
|
||||
"proceduralModels": 4,
|
||||
"proceduralModels": 3,
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"id": "forgotten-mine",
|
||||
"title": "Forgotten Mine",
|
||||
"source": "procedural-fallback",
|
||||
"mobs": 3,
|
||||
"mobs": 4,
|
||||
"bosses": 1,
|
||||
"attacks": 8,
|
||||
"specialAttacks": 4,
|
||||
"attacks": 13,
|
||||
"specialAttacks": 12,
|
||||
"nativeModels": 0,
|
||||
"proceduralModels": 4,
|
||||
"proceduralModels": 5,
|
||||
"status": "green"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,72 +4,75 @@ Status: **green**
|
||||
|
||||
Scope: executable combat populations and creature animation bindings. This report does not certify instance scripts, doors, interactables, escorts, waves, vehicles, or navigation.
|
||||
|
||||
Coverage: 54 dungeons, 1031 mobs, 253 bosses, 3765 executable attacks, and 2481 special attacks/mechanics.
|
||||
Coverage: 54 dungeons, 1193 mobs, 273 bosses, 4026 executable attacks, and 2572 special attacks/mechanics.
|
||||
|
||||
Animation paths: 1266 native model bindings and 18 procedural bindings across 1166 unique native GLBs; 18 missing native states use the shared procedural fallback.
|
||||
Animation paths: 1275 native model bindings and 191 procedural bindings across 1167 unique native GLBs; 18 missing native states use the shared procedural fallback.
|
||||
|
||||
| Dungeon | Mobs | Bosses | Attacks | Specials | Native | Procedural | Status |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| Wailing Caverns | 23 | 9 | 72 | 40 | 24 | 8 | green |
|
||||
| Shadowfang Keep | 24 | 7 | 65 | 34 | 31 | 0 | green |
|
||||
| Stormwind Stockade | 6 | 5 | 42 | 31 | 11 | 0 | green |
|
||||
| The Deadmines | 15 | 7 | 55 | 33 | 22 | 0 | green |
|
||||
| Wailing Caverns | 12 | 8 | 51 | 31 | 15 | 5 | green |
|
||||
| Razorfen Kraul | 31 | 7 | 87 | 49 | 38 | 0 | green |
|
||||
| Blackfathom Deeps | 21 | 6 | 65 | 38 | 27 | 0 | green |
|
||||
| Blackfathom Deeps | 23 | 6 | 67 | 38 | 27 | 2 | green |
|
||||
| Uldaman | 31 | 7 | 104 | 66 | 38 | 0 | green |
|
||||
| Gnomeregan | 27 | 4 | 82 | 51 | 31 | 0 | green |
|
||||
| Sunken Temple | 29 | 4 | 93 | 60 | 33 | 0 | green |
|
||||
| Razorfen Downs | 19 | 6 | 63 | 38 | 25 | 0 | green |
|
||||
| Gnomeregan | 28 | 5 | 84 | 51 | 31 | 2 | green |
|
||||
| Sunken Temple | 31 | 4 | 95 | 60 | 33 | 2 | green |
|
||||
| Razorfen Downs | 21 | 6 | 65 | 38 | 25 | 2 | green |
|
||||
| Scarlet Monastery | 32 | 5 | 107 | 70 | 37 | 0 | green |
|
||||
| Zul'Farrak | 16 | 5 | 69 | 48 | 21 | 0 | green |
|
||||
| Blackrock Spire | 63 | 15 | 273 | 195 | 78 | 0 | green |
|
||||
| Blackrock Depths | 48 | 13 | 182 | 121 | 61 | 0 | green |
|
||||
| Scholomance | 30 | 8 | 150 | 112 | 38 | 0 | green |
|
||||
| Stratholme | 41 | 9 | 176 | 126 | 50 | 0 | green |
|
||||
| Zul'Farrak | 23 | 5 | 76 | 48 | 21 | 7 | green |
|
||||
| Blackrock Spire | 66 | 17 | 285 | 202 | 78 | 5 | green |
|
||||
| Blackrock Depths | 58 | 13 | 192 | 121 | 61 | 10 | green |
|
||||
| Scholomance | 31 | 8 | 151 | 112 | 38 | 1 | green |
|
||||
| Stratholme | 45 | 9 | 180 | 126 | 50 | 4 | green |
|
||||
| Maraudon | 25 | 8 | 95 | 62 | 33 | 0 | green |
|
||||
| Ragefire Chasm | 7 | 4 | 23 | 12 | 11 | 0 | green |
|
||||
| Dire Maul | 63 | 12 | 242 | 167 | 75 | 0 | green |
|
||||
| Hellfire Citadel: The Shattered Halls | 29 | 3 | 92 | 60 | 32 | 0 | green |
|
||||
| Hellfire Citadel: The Blood Furnace | 14 | 3 | 53 | 36 | 17 | 0 | green |
|
||||
| Hellfire Citadel: Ramparts | 10 | 2 | 41 | 29 | 12 | 0 | green |
|
||||
| Hellfire Citadel: Ramparts | 11 | 4 | 47 | 32 | 12 | 3 | green |
|
||||
| Coilfang: The Steamvault | 14 | 3 | 63 | 46 | 17 | 0 | green |
|
||||
| Coilfang: The Underbog | 18 | 4 | 80 | 58 | 22 | 0 | green |
|
||||
| Coilfang: The Slave Pens | 18 | 3 | 60 | 39 | 21 | 0 | green |
|
||||
| Tempest Keep: The Arcatraz | 25 | 4 | 105 | 76 | 29 | 0 | green |
|
||||
| Tempest Keep: The Botanica | 24 | 5 | 85 | 56 | 29 | 0 | green |
|
||||
| Coilfang: The Underbog | 20 | 4 | 82 | 58 | 22 | 2 | green |
|
||||
| Coilfang: The Slave Pens | 21 | 4 | 66 | 43 | 21 | 4 | green |
|
||||
| Tempest Keep: The Arcatraz | 28 | 4 | 108 | 76 | 29 | 3 | green |
|
||||
| Tempest Keep: The Botanica | 28 | 5 | 89 | 56 | 29 | 4 | green |
|
||||
| Tempest Keep: The Mechanar | 14 | 3 | 69 | 52 | 17 | 0 | green |
|
||||
| Auchindoun: Shadow Labyrinth | 18 | 4 | 67 | 45 | 22 | 0 | green |
|
||||
| Auchindoun: Sethekk Halls | 14 | 2 | 50 | 34 | 16 | 0 | green |
|
||||
| Auchindoun: Mana-Tombs | 11 | 3 | 46 | 32 | 14 | 0 | green |
|
||||
| Auchindoun: Auchenai Crypts | 12 | 2 | 44 | 30 | 14 | 0 | green |
|
||||
| Magister's Terrace | 15 | 3 | 57 | 39 | 18 | 0 | green |
|
||||
| The Black Morass | 4 | 3 | 25 | 18 | 7 | 0 | green |
|
||||
| The Escape From Durnholde | 26 | 3 | 53 | 24 | 29 | 0 | green |
|
||||
| Utgarde Keep | 19 | 3 | 44 | 22 | 22 | 0 | green |
|
||||
| Utgarde Pinnacle | 13 | 4 | 44 | 27 | 17 | 0 | green |
|
||||
| The Nexus | 21 | 5 | 61 | 35 | 26 | 0 | green |
|
||||
| The Oculus | 8 | 4 | 33 | 21 | 12 | 0 | green |
|
||||
| The Culling of Stratholme | 16 | 4 | 44 | 24 | 20 | 0 | green |
|
||||
| Halls of Stone | 13 | 4 | 42 | 25 | 16 | 1 | green |
|
||||
| Drak'Tharon Keep | 18 | 4 | 45 | 23 | 22 | 0 | green |
|
||||
| Azjol-Nerub | 10 | 3 | 30 | 17 | 13 | 0 | green |
|
||||
| Halls of Lightning | 16 | 4 | 49 | 29 | 20 | 0 | green |
|
||||
| Auchindoun: Sethekk Halls | 19 | 3 | 59 | 37 | 16 | 6 | green |
|
||||
| Auchindoun: Mana-Tombs | 12 | 4 | 50 | 34 | 14 | 2 | green |
|
||||
| Auchindoun: Auchenai Crypts | 13 | 3 | 48 | 32 | 14 | 2 | green |
|
||||
| Magister's Terrace | 23 | 3 | 65 | 39 | 18 | 8 | green |
|
||||
| The Black Morass | 13 | 3 | 34 | 18 | 7 | 9 | green |
|
||||
| The Escape From Durnholde | 32 | 3 | 59 | 24 | 29 | 6 | green |
|
||||
| Utgarde Keep | 19 | 6 | 55 | 30 | 22 | 3 | green |
|
||||
| Utgarde Pinnacle | 19 | 4 | 50 | 27 | 17 | 6 | green |
|
||||
| The Nexus | 24 | 5 | 64 | 35 | 26 | 3 | green |
|
||||
| The Oculus | 17 | 4 | 42 | 21 | 12 | 9 | green |
|
||||
| The Culling of Stratholme | 21 | 4 | 49 | 24 | 20 | 5 | green |
|
||||
| Halls of Stone | 16 | 4 | 45 | 25 | 16 | 4 | green |
|
||||
| Drak'Tharon Keep | 20 | 4 | 47 | 23 | 22 | 2 | green |
|
||||
| Azjol-Nerub | 18 | 3 | 38 | 17 | 13 | 8 | green |
|
||||
| Halls of Lightning | 19 | 4 | 52 | 29 | 20 | 3 | green |
|
||||
| Gundrak | 15 | 5 | 53 | 33 | 20 | 0 | green |
|
||||
| Violet Hold | 2 | 3 | 24 | 19 | 5 | 0 | green |
|
||||
| Ahn'kahet: The Old Kingdom | 19 | 5 | 60 | 36 | 24 | 0 | green |
|
||||
| Violet Hold | 17 | 3 | 39 | 19 | 5 | 15 | green |
|
||||
| Ahn'kahet: The Old Kingdom | 23 | 5 | 64 | 36 | 24 | 4 | green |
|
||||
| The Forge of Souls | 11 | 2 | 23 | 10 | 13 | 0 | green |
|
||||
| Trial of the Champion | 0 | 5 | 88 | 83 | 5 | 0 | green |
|
||||
| Pit of Saron | 27 | 3 | 49 | 19 | 30 | 0 | green |
|
||||
| Halls of Reflection | 8 | 2 | 38 | 28 | 10 | 0 | green |
|
||||
| Tinkertech Showdown | 3 | 1 | 8 | 4 | 0 | 4 | green |
|
||||
| Frozen Reach | 3 | 1 | 8 | 4 | 0 | 4 | green |
|
||||
| Forgotten Mine | 3 | 1 | 8 | 4 | 0 | 4 | green |
|
||||
| Trial of the Champion | 6 | 12 | 123 | 105 | 5 | 13 | green |
|
||||
| Pit of Saron | 31 | 3 | 53 | 19 | 30 | 4 | green |
|
||||
| Halls of Reflection | 16 | 2 | 46 | 28 | 10 | 8 | green |
|
||||
| Tinkertech Showdown | 3 | 1 | 13 | 12 | 0 | 4 | green |
|
||||
| Frozen Reach | 2 | 1 | 19 | 19 | 0 | 3 | green |
|
||||
| Forgotten Mine | 4 | 1 | 13 | 12 | 0 | 5 | green |
|
||||
|
||||
## Documented source-faithful exceptions
|
||||
|
||||
- wailing-caverns:entry-3653: Kresh is melee-only in the pinned AzerothCore snapshot; the historical server has no spell or SmartAI row for entry 3653.
|
||||
- wailing-caverns:kresh: Kresh is melee-only in the pinned AzerothCore snapshot; the historical server has no spell or SmartAI row for entry 3653.
|
||||
- wailing-caverns:deviate-faerie-dragon: The server-faithful rare entry 5912 has no spell rows and is despawned by the Wailing Caverns initialization event.
|
||||
- trial-of-the-champion: Trial of the Champion is an encounter arena; its imported combat population is the five Grand Champion bosses, not a trash route.
|
||||
- gnomeregan:wow-classic-batch-three-entry-7361: Grubbis is melee-only in the pinned AzerothCore snapshot; entry 7361 has aggro dialogue and instance-completion rows but no combat spell cast.
|
||||
- blackrock-spire:entry-9218: Spirestone Battle Lord is a 15% rare spawn in the pinned database and has no combat spell cast beyond its basic melee attack.
|
||||
- blackrock-spire:entry-9219: Spirestone Butcher is a 15% rare spawn in the pinned database and has no combat spell cast beyond its basic melee attack.
|
||||
- blackrock-spire:entry-10376: Crystal Fang is a 15% rare spawn with no active combat spell in the pinned database; its exact on-death Spire Spiderling summon is implemented by the instance adapter.
|
||||
|
||||
## Issues
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,858 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"scope": "ascension-custom-five-player-source-recovery",
|
||||
"status": "partial-authoritative-recovery",
|
||||
"sourcePolicy": {
|
||||
"exact": "Values copied from installed-client DBC rows or already checksummed map packages.",
|
||||
"spellLinked": "Creature and gameobject IDs recovered from summon effects in installed-client Spell.dbc. The link is exact even when the missing creature_template or gameobject_template name is inferred from the spell context.",
|
||||
"authored": "Timing, phase ownership, placement, health tuning, and any entity explicitly marked authored are adaptation decisions, not claims about Ascension server behavior."
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"id": "ascension-area52-spell-dbc",
|
||||
"kind": "installed-client-dbc",
|
||||
"pathHint": "../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc",
|
||||
"sha256": "2f77f18b91ffbae32a19568e4bc05ec78ee384b6ba8dd2a6e05f0e97abbb2dce",
|
||||
"header": {
|
||||
"signature": "WDBC",
|
||||
"recordCount": 238891,
|
||||
"fieldCount": 234,
|
||||
"recordSize": 936,
|
||||
"stringBlockSize": 14058299
|
||||
},
|
||||
"localeField": "enUS",
|
||||
"confidence": "exact"
|
||||
},
|
||||
{
|
||||
"id": "manastorm-runtime-catalog",
|
||||
"kind": "installed-client-encounter-export",
|
||||
"path": "src/game/generated/manastormRuntimeCatalog.json",
|
||||
"confidence": "exact"
|
||||
},
|
||||
{
|
||||
"id": "manastorm-asset-packages",
|
||||
"kind": "installed-client-map-export",
|
||||
"path": "src/game/generated/manastormAssetPackages.json",
|
||||
"confidence": "exact-environment-provisional-semantics"
|
||||
},
|
||||
{
|
||||
"id": "campaign-runtime-snapshot",
|
||||
"kind": "generated-runtime-catalog",
|
||||
"path": "src/game/generated/dungeonCampaignCatalog.json",
|
||||
"confidence": "exact-current-state"
|
||||
},
|
||||
{
|
||||
"id": "ascension-db-alva",
|
||||
"kind": "official-public-database",
|
||||
"url": "https://db.ascension.gg/?npc=97700",
|
||||
"confidence": "exact"
|
||||
},
|
||||
{
|
||||
"id": "ascension-db-arktos",
|
||||
"kind": "official-public-database",
|
||||
"url": "https://db.ascension.gg/?npc=97701",
|
||||
"confidence": "exact"
|
||||
},
|
||||
{
|
||||
"id": "ascension-changelog-frozen-reach-blast-arrow",
|
||||
"kind": "official-changelog",
|
||||
"url": "https://ascension.gg/en/changelog/1?page=7",
|
||||
"confidence": "exact-wording-name-differs"
|
||||
},
|
||||
{
|
||||
"id": "ascension-changelog-frozen-reach-rebirth",
|
||||
"kind": "official-changelog",
|
||||
"url": "https://ascension.gg/en/changelog/1?page=8",
|
||||
"confidence": "exact"
|
||||
},
|
||||
{
|
||||
"id": "ascension-changelog-forgotten-mine-crystals",
|
||||
"kind": "official-changelog",
|
||||
"url": "https://ascension.gg/en/changelog/1?page=51",
|
||||
"confidence": "exact"
|
||||
},
|
||||
{
|
||||
"id": "ascension-changelog-forgotten-mine-geospike",
|
||||
"kind": "official-changelog",
|
||||
"url": "https://ascension.gg/en/changelog/1?page=29",
|
||||
"confidence": "exact"
|
||||
}
|
||||
],
|
||||
"localSearchAudit": {
|
||||
"searchedRoots": [
|
||||
"../wow-pvt-server-backups-2",
|
||||
"../WoW-Pvt-Server-Backups",
|
||||
"../wow-private-server-notes",
|
||||
"../LadiksMPQEditor",
|
||||
"../wow335a/Cache",
|
||||
"../resources/client/Cache"
|
||||
],
|
||||
"searchedArtifacts": [
|
||||
"creature_template and creature spawn SQL",
|
||||
"gameobject_template and gameobject spawn SQL",
|
||||
"SmartAI and ScriptName bindings",
|
||||
"Lua/XML/JSON/CSV encounter data",
|
||||
"creaturecache.wdb and gameobjectcache.wdb records"
|
||||
],
|
||||
"missingAuthoritativeData": [
|
||||
"creature templates and display mappings for 663012, 97700, and 254272",
|
||||
"world spawn rows for maps 821, 908, and 1781",
|
||||
"server-side encounter scripts and cast cadence",
|
||||
"authoritative trash population and patrol routes",
|
||||
"authoritative gameobject placements"
|
||||
],
|
||||
"publicSnapshotCheck": {
|
||||
"repository": "https://github.com/Ascension-Addons/Questie-X-AscensionDB.git",
|
||||
"commit": "cf6710ef94c264d842cf2ea94c4b50e9ece6dd61",
|
||||
"result": "No target boss IDs, names, spawns, or encounter scripts were present."
|
||||
}
|
||||
},
|
||||
"dungeons": [
|
||||
{
|
||||
"mapId": 821,
|
||||
"id": "tinkertech-showdown",
|
||||
"title": "Tinkertech Showdown",
|
||||
"mapDirectory": "TinketechShowdown",
|
||||
"sourceClassification": "identity-and-environment-only",
|
||||
"environment": {
|
||||
"sourceHash": "323c47c081d0a6c39daf9878284863c21e3e38dbced2ba3f58d1d157d080d41d",
|
||||
"rootWmo": "World/wmo/Dungeon/AZ_Subway/Subway_Dungeon.wmo",
|
||||
"anchors": {
|
||||
"player": [
|
||||
3.648781,
|
||||
-4.167801,
|
||||
66.263252
|
||||
],
|
||||
"trash": [
|
||||
10.220209,
|
||||
-126.067818,
|
||||
1266.206177
|
||||
],
|
||||
"boss": [
|
||||
-15.779793,
|
||||
-4.267807,
|
||||
2439.406006
|
||||
]
|
||||
},
|
||||
"semanticStatus": "provisional-geometry-derived"
|
||||
},
|
||||
"encounters": [
|
||||
{
|
||||
"encounterId": 9013,
|
||||
"modeId": 2,
|
||||
"name": "Mechatron",
|
||||
"creatureId": 663012,
|
||||
"identityConfidence": "exact"
|
||||
}
|
||||
],
|
||||
"currentRuntime": {
|
||||
"bossEntityId": "entry-663012",
|
||||
"proceduralTrashSpawnCount": 15,
|
||||
"proceduralMobEntries": [
|
||||
{
|
||||
"entityId": "tinkertech-showdown-guardian",
|
||||
"serverEntry": 2629079128
|
||||
},
|
||||
{
|
||||
"entityId": "tinkertech-showdown-caster",
|
||||
"serverEntry": 3849447491
|
||||
},
|
||||
{
|
||||
"entityId": "tinkertech-showdown-brute",
|
||||
"serverEntry": 1787979165
|
||||
}
|
||||
]
|
||||
},
|
||||
"sourceRecovery": {
|
||||
"association": "candidate-client-cluster-only",
|
||||
"associationConfidence": "low",
|
||||
"reason": "These adjacent client rows form a coherent mechanical-machine kit and include Sound: Goblin Head Machine Loop, but no recovered server row links them to creature 663012.",
|
||||
"spellRows": {
|
||||
"257438": "Rocket",
|
||||
"257459": "Gatling Missile Barrage",
|
||||
"257460": "Gatling Missile",
|
||||
"257461": "Gatling Missile",
|
||||
"257462": "Sound: Goblin Head Machine Loop",
|
||||
"257470": "Incineration Beam",
|
||||
"257471": "Energy Ball",
|
||||
"257472": "Electrified Zone",
|
||||
"257473": "Electric Shock"
|
||||
},
|
||||
"families": {
|
||||
"rocket": [
|
||||
257438
|
||||
],
|
||||
"gatlingMissileBarrage": [
|
||||
257459,
|
||||
257460,
|
||||
257461
|
||||
],
|
||||
"incinerationBeam": [
|
||||
257470
|
||||
],
|
||||
"electrifiedZone": [
|
||||
257472,
|
||||
257473
|
||||
]
|
||||
}
|
||||
},
|
||||
"adaptation": {
|
||||
"status": "custom-authoring-required",
|
||||
"populationPlan": {
|
||||
"disposition": "retain-current-route-as-explicit-adaptation",
|
||||
"packCount": 5,
|
||||
"membersPerPack": 3,
|
||||
"authoredMobs": [
|
||||
{
|
||||
"serverEntry": 2629079128,
|
||||
"name": "Tinkertech Guardbot",
|
||||
"role": "melee-interceptor"
|
||||
},
|
||||
{
|
||||
"serverEntry": 3849447491,
|
||||
"name": "Tinkertech Coilcaster",
|
||||
"role": "electrical-ranged"
|
||||
},
|
||||
{
|
||||
"serverEntry": 1787979165,
|
||||
"name": "Tinkertech Demolisher",
|
||||
"role": "rocket-bruiser"
|
||||
}
|
||||
]
|
||||
},
|
||||
"encounterPlan": [
|
||||
"Mechatron remains creature 663012 at the provisional boss anchor.",
|
||||
"Rocket uses a 2.0 second target reticle and splash damage on an authored 8 second cadence.",
|
||||
"Gatling Missile Barrage uses a 3.0 second warning followed by eight lane impacts over 4 seconds on an authored 14 second cadence.",
|
||||
"Incineration Beam uses a 2.5 second wind-up and sweeps the subway lane for 5 seconds; immutable web geometry means it visually disables lane sections instead of permanently destroying client platforms.",
|
||||
"Electrified Zone alternates floor strips after a 1.5 second warning, remains active for 8 seconds, and applies a 1.5 second stun on contact.",
|
||||
"At 35 percent health, cadence accelerates by 20 percent and two authored Coilcasters enter from the previous pack anchor."
|
||||
],
|
||||
"mustBeCustomAuthored": [
|
||||
"all trash identities, placements, and AI",
|
||||
"all boss timing and phase thresholds",
|
||||
"the decision to associate candidate spells 257438 and 257459-257473 with Mechatron",
|
||||
"platform or lane disable behavior",
|
||||
"boss and trash visual composites",
|
||||
"loot and completion scripting"
|
||||
]
|
||||
},
|
||||
"unresolved": [
|
||||
"No authoritative Mechatron spell-to-creature binding was found.",
|
||||
"No authoritative trash or gameobject IDs were found.",
|
||||
"The custom ADT placement transform and all semantic anchors still require playtest approval."
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": 908,
|
||||
"id": "frozen-reach",
|
||||
"title": "Frozen Reach",
|
||||
"mapDirectory": "Alvaencounter",
|
||||
"sourceClassification": "encounter-spells-and-summons-recovered",
|
||||
"environment": {
|
||||
"sourceHash": "5ab771df79c0e062cea3ebda4e416a9245bcf8a4cc6da048077df5e674081f97",
|
||||
"anchors": {
|
||||
"player": [
|
||||
1858.534668,
|
||||
78.860199,
|
||||
2229.334473
|
||||
],
|
||||
"trash": [
|
||||
2100.134766,
|
||||
80.860199,
|
||||
2132.93457
|
||||
],
|
||||
"boss": [
|
||||
2344.134766,
|
||||
62.460205,
|
||||
2134.334717
|
||||
]
|
||||
},
|
||||
"semanticStatus": "provisional-elevated-encounter-component"
|
||||
},
|
||||
"encounters": [
|
||||
{
|
||||
"encounterId": 2168,
|
||||
"modeId": 0,
|
||||
"name": "Alva",
|
||||
"creatureId": 97700,
|
||||
"identityConfidence": "exact"
|
||||
},
|
||||
{
|
||||
"encounterId": 2170,
|
||||
"modeId": 2,
|
||||
"name": "Alva",
|
||||
"creatureId": 97700,
|
||||
"identityConfidence": "exact"
|
||||
}
|
||||
],
|
||||
"entities": [
|
||||
{
|
||||
"entryId": 97700,
|
||||
"name": "Alva",
|
||||
"role": "boss",
|
||||
"sourceKind": "encounter-dbc-and-official-db"
|
||||
},
|
||||
{
|
||||
"entryId": 97701,
|
||||
"name": "Arktos",
|
||||
"role": "boss-companion",
|
||||
"sourceKind": "official-db"
|
||||
},
|
||||
{
|
||||
"entryId": 97703,
|
||||
"name": "Blast Arrow summoned unit",
|
||||
"role": "area-trigger",
|
||||
"sourceKind": "spell-linked-name-inferred"
|
||||
},
|
||||
{
|
||||
"entryId": 97731,
|
||||
"name": "Explosive Trap summoned unit",
|
||||
"role": "trap",
|
||||
"sourceKind": "spell-linked-name-inferred"
|
||||
},
|
||||
{
|
||||
"entryId": 97770,
|
||||
"name": "Alvajar summoned unit",
|
||||
"role": "add",
|
||||
"sourceKind": "spell-linked-name-inferred"
|
||||
}
|
||||
],
|
||||
"currentRuntime": {
|
||||
"bossEntityId": "entry-97700",
|
||||
"proceduralTrashSpawnCount": 15,
|
||||
"proceduralMobEntries": [
|
||||
{
|
||||
"entityId": "frozen-reach-guardian",
|
||||
"serverEntry": 1606324391
|
||||
},
|
||||
{
|
||||
"entityId": "frozen-reach-caster",
|
||||
"serverEntry": 854807736
|
||||
},
|
||||
{
|
||||
"entityId": "frozen-reach-brute",
|
||||
"serverEntry": 2855026892
|
||||
}
|
||||
]
|
||||
},
|
||||
"sourceRecovery": {
|
||||
"association": "explicit-alva-arktos-client-cluster",
|
||||
"associationConfidence": "high",
|
||||
"spellRows": {
|
||||
"255347": "Auto Shot",
|
||||
"255349": "Rain of Arrows",
|
||||
"255351": "Rain of Arrows",
|
||||
"255352": "Unbridled Arrows",
|
||||
"255353": "Unbridled Arrows",
|
||||
"255358": "Blast Arrow",
|
||||
"255359": "Blast Arrow",
|
||||
"255360": "Pact of the Hunt",
|
||||
"255362": "Blast Arrow",
|
||||
"255363": "Blast Arrow",
|
||||
"255374": "Ice Rock",
|
||||
"255375": "Ice Rock",
|
||||
"255377": "Ice Rock",
|
||||
"255378": "Unholy Frenzy",
|
||||
"255379": "Carnivorous Bite",
|
||||
"255380": "Frost Shield",
|
||||
"255381": "Set Ablaze",
|
||||
"255382": "Blaze",
|
||||
"255387": "Frost Strike",
|
||||
"255389": "Shadow Shroud Surge",
|
||||
"255390": "Shadow Shroud Rebirth",
|
||||
"255393": "Sphere of Grief and Wrath",
|
||||
"255394": "Vengeful Bolt",
|
||||
"255395": "Vengeful Bolt",
|
||||
"255397": "Shadow Veil Lance",
|
||||
"255398": "Shadow Veil Lance",
|
||||
"255399": "Frost Roar",
|
||||
"255401": "Cluster Shot",
|
||||
"255402": "Cluster Shot",
|
||||
"255403": "Cluster Shot",
|
||||
"255410": "Alva's Grief",
|
||||
"255411": "Alva's Grief",
|
||||
"255412": "Frost orb",
|
||||
"255413": "Frozen Pulse",
|
||||
"255417": "Shield Wall",
|
||||
"255418": "Shield Wall",
|
||||
"255419": "Alva's Wrath",
|
||||
"255420": "Throw Explosive Trap",
|
||||
"255421": "Explosive Trap",
|
||||
"255423": "Trap Explosion",
|
||||
"255424": "Charging",
|
||||
"255426": "Explosive Trap",
|
||||
"255427": "Frostblade Strike",
|
||||
"255428": "Frostblade Strike",
|
||||
"255432": "Army of the Dead",
|
||||
"255433": "Army of the Dead",
|
||||
"255434": "Lacerate",
|
||||
"255435": "Lacerate",
|
||||
"255436": "Lacerate",
|
||||
"255437": "Arcing Slice",
|
||||
"255438": "Rain of Arrows",
|
||||
"255439": "Rain of Arrows",
|
||||
"255440": "Cluster Shot",
|
||||
"255441": "Cluster Shot",
|
||||
"255442": "Unbridled Arrows",
|
||||
"255443": "Unbridled Arrows",
|
||||
"255444": "Blast Arrow",
|
||||
"255445": "Blast Arrow",
|
||||
"255446": "Frost Roar",
|
||||
"255447": "Frost Roar",
|
||||
"255448": "Vengeful Bolt",
|
||||
"255449": "Vengeful Bolt",
|
||||
"255450": "Ice Rock",
|
||||
"255451": "Ice Rock",
|
||||
"255452": "Frostblade Strike",
|
||||
"255453": "Frostblade Strike",
|
||||
"255454": "Shadow Shroud Surge",
|
||||
"255455": "Shadow Shroud Surge",
|
||||
"255456": "Shadow Shroud Rebirth",
|
||||
"255457": "Shadow Shroud Rebirth",
|
||||
"255458": "Shadow Veil Lance",
|
||||
"255459": "Shadow Veil Lance",
|
||||
"255460": "Alva's Wrath",
|
||||
"255461": "Alva's Wrath",
|
||||
"255462": "Sphere of Grief and Wrath",
|
||||
"255463": "Sphere of Grief and Wrath",
|
||||
"255464": "Frost Strike",
|
||||
"255465": "Frost Strike",
|
||||
"255466": "Predator's Fury",
|
||||
"255467": "Alva's Grief",
|
||||
"255468": "Alva's Grief",
|
||||
"255469": "Trap Explosion",
|
||||
"255470": "Trap Explosion",
|
||||
"255471": "Frozen Pulse",
|
||||
"255472": "Frozen Pulse"
|
||||
},
|
||||
"families": {
|
||||
"alvaRanged": [
|
||||
255347,
|
||||
255349,
|
||||
255351,
|
||||
255352,
|
||||
255353,
|
||||
255358,
|
||||
255359,
|
||||
255401,
|
||||
255402,
|
||||
255403,
|
||||
255420,
|
||||
255421,
|
||||
255423,
|
||||
255424
|
||||
],
|
||||
"arktos": [
|
||||
255360,
|
||||
255374,
|
||||
255375,
|
||||
255377,
|
||||
255378,
|
||||
255379,
|
||||
255380,
|
||||
255381,
|
||||
255382,
|
||||
255387,
|
||||
255399,
|
||||
255417,
|
||||
255418,
|
||||
255434
|
||||
],
|
||||
"alvaVengeance": [
|
||||
255389,
|
||||
255390,
|
||||
255393,
|
||||
255394,
|
||||
255395,
|
||||
255397,
|
||||
255398,
|
||||
255410,
|
||||
255411,
|
||||
255419,
|
||||
255432,
|
||||
255433
|
||||
],
|
||||
"difficultyOneAndTwoVariants": [
|
||||
255435,
|
||||
255436,
|
||||
255438,
|
||||
255439,
|
||||
255440,
|
||||
255441,
|
||||
255442,
|
||||
255443,
|
||||
255444,
|
||||
255445,
|
||||
255446,
|
||||
255447,
|
||||
255448,
|
||||
255449,
|
||||
255450,
|
||||
255451,
|
||||
255452,
|
||||
255453,
|
||||
255454,
|
||||
255455,
|
||||
255456,
|
||||
255457,
|
||||
255458,
|
||||
255459,
|
||||
255460,
|
||||
255461,
|
||||
255462,
|
||||
255463,
|
||||
255464,
|
||||
255465,
|
||||
255467,
|
||||
255468,
|
||||
255469,
|
||||
255470,
|
||||
255471,
|
||||
255472
|
||||
]
|
||||
},
|
||||
"summonLinks": [
|
||||
{
|
||||
"spellId": 255363,
|
||||
"effectIndex": 0,
|
||||
"effectType": 28,
|
||||
"targetKind": "creature",
|
||||
"entryId": 97703
|
||||
},
|
||||
{
|
||||
"spellId": 255421,
|
||||
"effectIndex": 0,
|
||||
"effectType": 28,
|
||||
"targetKind": "creature",
|
||||
"entryId": 97731
|
||||
},
|
||||
{
|
||||
"spellId": 255433,
|
||||
"effectIndex": 0,
|
||||
"effectType": 28,
|
||||
"targetKind": "creature",
|
||||
"entryId": 97770
|
||||
}
|
||||
]
|
||||
},
|
||||
"adaptation": {
|
||||
"status": "source-backed-custom-cadence-required",
|
||||
"populationPlan": {
|
||||
"disposition": "remove-unverified-generic-trash",
|
||||
"reason": "Alvaencounter and the recovered spell cluster describe a contained Alva/Arktos encounter; no evidence supports the current 15 generic hallway creatures.",
|
||||
"encounterEntities": [
|
||||
97700,
|
||||
97701,
|
||||
97703,
|
||||
97731,
|
||||
97770
|
||||
]
|
||||
},
|
||||
"encounterPlan": [
|
||||
"Phase one starts Alva 97700 and Arktos 97701 together, with Pact of the Hunt 255360 active while they remain close.",
|
||||
"Alva uses Rain of Arrows, Unbridled Arrows, Blast Arrow, Cluster Shot, and summoned Explosive Traps; Arktos uses Carnivorous Bite, Ice Rock, Frost Roar, Lacerate, and defensive Frost Shield or Shield Wall.",
|
||||
"Blast Arrow can strike Arktos and apply Set Ablaze 255381, causing panic movement, Blaze 255382, and increased damage taken. This matches the official changelog's Blast Shot/Arktos interaction despite the DBC naming it Blast Arrow.",
|
||||
"Arktos death leaves Alva's Grief 255410 around the corpse and transitions Alva to the vengeance kit.",
|
||||
"The transition casts Shadow Shroud Rebirth 255390 and summons four Alvajar 97770 around the arena. Rebirth damage must scale by living group size, reaching full value at five or more players per the official changelog.",
|
||||
"Phase two rotates Vengeful Bolt, Shadow Veil Lance, Sphere of Grief and Wrath, Army of the Dead, and stacking Alva's Wrath as the soft enrage.",
|
||||
"Wire ASC D0 rows to the current normal experience. Preserve D1 and D2 rows but do not assign public difficulty labels until an authoritative mode mapping is recovered."
|
||||
],
|
||||
"authoredCadence": {
|
||||
"rainOfArrowsMs": 12000,
|
||||
"unbridledArrowsMs": 20000,
|
||||
"blastArrowMs": 15000,
|
||||
"clusterShotMs": 18000,
|
||||
"explosiveTrapMs": 16000,
|
||||
"arktosIceRockMs": 12000,
|
||||
"arktosFrostRoarMs": 18000,
|
||||
"vengefulBoltMs": 7000,
|
||||
"shadowVeilLanceMs": 12000,
|
||||
"sphereOfGriefMs": 20000,
|
||||
"alvasWrathMs": 25000
|
||||
},
|
||||
"mustBeCustomAuthored": [
|
||||
"all cast cadence and target-selection rules",
|
||||
"Arktos initial placement and companion pathing",
|
||||
"phase transition and exact death threshold behavior",
|
||||
"Alvajar formation and spawn placement",
|
||||
"trap and area-trigger web-runtime behavior",
|
||||
"boss and add visual composites",
|
||||
"loot and completion scripting"
|
||||
]
|
||||
},
|
||||
"unresolved": [
|
||||
"The server-side spell schedule and encounter script are absent.",
|
||||
"Creature template stats, display IDs, and exact positions are absent.",
|
||||
"ASC D0/D1/D2 spell ranks cannot be mapped safely beyond D0 without a surviving difficulty script."
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": 1781,
|
||||
"id": "forgotten-mine",
|
||||
"title": "Forgotten Mine",
|
||||
"mapDirectory": "ForgottenMine",
|
||||
"sourceClassification": "encounter-spells-creature-summon-and-gameobject-recovered",
|
||||
"environment": {
|
||||
"sourceHash": "693b7067aa1109e14beb944bc95aed8299bf64e2ec6ae3dc43f4fab7d18f57b6",
|
||||
"anchors": {
|
||||
"player": [
|
||||
1285.886108,
|
||||
149.58075,
|
||||
1724.264526
|
||||
],
|
||||
"trash": [
|
||||
1857.461304,
|
||||
136.080749,
|
||||
1871.884277
|
||||
],
|
||||
"boss": [
|
||||
2246.06665,
|
||||
198.180786,
|
||||
2147.866699
|
||||
]
|
||||
},
|
||||
"semanticStatus": "provisional-main-navigation-component"
|
||||
},
|
||||
"encounters": [
|
||||
{
|
||||
"encounterId": 5069,
|
||||
"modeId": 0,
|
||||
"name": "Geolord Vek",
|
||||
"creatureId": 254272,
|
||||
"identityConfidence": "exact"
|
||||
},
|
||||
{
|
||||
"encounterId": 5071,
|
||||
"modeId": 2,
|
||||
"name": "Geolord Vek",
|
||||
"creatureId": 254272,
|
||||
"identityConfidence": "exact"
|
||||
}
|
||||
],
|
||||
"entities": [
|
||||
{
|
||||
"entryId": 254272,
|
||||
"name": "Geolord Vek",
|
||||
"role": "boss",
|
||||
"sourceKind": "encounter-dbc"
|
||||
},
|
||||
{
|
||||
"entryId": 254278,
|
||||
"name": "Geospike summoned unit",
|
||||
"role": "hazard",
|
||||
"sourceKind": "spell-linked-name-inferred"
|
||||
}
|
||||
],
|
||||
"gameObjects": [
|
||||
{
|
||||
"entryId": 254051,
|
||||
"name": "Resonite Shard",
|
||||
"role": "clickable-encounter-countermeasure",
|
||||
"sourceKind": "spell-linked-name-from-create-spell"
|
||||
}
|
||||
],
|
||||
"currentRuntime": {
|
||||
"bossEntityId": "entry-254272",
|
||||
"proceduralTrashSpawnCount": 15,
|
||||
"proceduralMobEntries": [
|
||||
{
|
||||
"entityId": "forgotten-mine-guardian",
|
||||
"serverEntry": 4175632753
|
||||
},
|
||||
{
|
||||
"entityId": "forgotten-mine-caster",
|
||||
"serverEntry": 647988074
|
||||
},
|
||||
{
|
||||
"entityId": "forgotten-mine-brute",
|
||||
"serverEntry": 2677742134
|
||||
}
|
||||
]
|
||||
},
|
||||
"sourceRecovery": {
|
||||
"association": "explicit-vek-client-cluster",
|
||||
"associationConfidence": "high",
|
||||
"reason": "The contiguous cluster contains Vek Death and Vek Reset control rows alongside the complete earth and Resonite kit.",
|
||||
"spellRows": {
|
||||
"354000": "Stonebreaker",
|
||||
"354001": "Quake",
|
||||
"354002": "Stone Shield",
|
||||
"354003": "Cosmetic Fear",
|
||||
"354004": "Geospike",
|
||||
"354005": "Geospike",
|
||||
"354006": "Geospike",
|
||||
"354007": "Vek Death",
|
||||
"354008": "Vek Reset",
|
||||
"354009": "Quake",
|
||||
"354010": "Quake",
|
||||
"354011": "Stone Shield",
|
||||
"354012": "Stone Shield",
|
||||
"354013": "Resonance",
|
||||
"354014": "Resonance",
|
||||
"354015": "Geode Barrage",
|
||||
"354016": "Geode Barrage",
|
||||
"354017": "Ruptured Earth",
|
||||
"354018": "Ruptured Earth",
|
||||
"354019": "Stoneskin",
|
||||
"354020": "Stoneskin",
|
||||
"354021": "Stonebreaker",
|
||||
"354022": "Stonebreaker",
|
||||
"354023": "Stonebreaker",
|
||||
"354024": "Stonebreaker",
|
||||
"354025": "Geospike",
|
||||
"354026": "Geospike",
|
||||
"354027": "Geode Barrage",
|
||||
"354028": "Geode Barrage",
|
||||
"354029": "Geode Barrage",
|
||||
"354030": "Geode Barrage",
|
||||
"354031": "Resonite Cleanse",
|
||||
"354032": "Create Resonite Shard"
|
||||
},
|
||||
"families": {
|
||||
"control": [
|
||||
354007,
|
||||
354008
|
||||
],
|
||||
"quake": [
|
||||
354001,
|
||||
354009,
|
||||
354010
|
||||
],
|
||||
"stoneShield": [
|
||||
354002,
|
||||
354011,
|
||||
354012
|
||||
],
|
||||
"geospike": [
|
||||
354004,
|
||||
354005,
|
||||
354006,
|
||||
354025,
|
||||
354026
|
||||
],
|
||||
"resonance": [
|
||||
354013,
|
||||
354014
|
||||
],
|
||||
"geodeBarrage": [
|
||||
354015,
|
||||
354016,
|
||||
354027,
|
||||
354028,
|
||||
354029,
|
||||
354030
|
||||
],
|
||||
"rupturedEarth": [
|
||||
354017,
|
||||
354018
|
||||
],
|
||||
"stoneskin": [
|
||||
354019,
|
||||
354020
|
||||
],
|
||||
"stonebreaker": [
|
||||
354000,
|
||||
354021,
|
||||
354022,
|
||||
354023,
|
||||
354024
|
||||
],
|
||||
"resonite": [
|
||||
354031,
|
||||
354032
|
||||
]
|
||||
},
|
||||
"summonLinks": [
|
||||
{
|
||||
"spellId": 354004,
|
||||
"effectIndex": 0,
|
||||
"effectType": 28,
|
||||
"targetKind": "creature",
|
||||
"entryId": 254278
|
||||
},
|
||||
{
|
||||
"spellId": 354032,
|
||||
"effectIndex": 0,
|
||||
"effectType": 76,
|
||||
"targetKind": "gameobject",
|
||||
"entryId": 254051
|
||||
}
|
||||
]
|
||||
},
|
||||
"adaptation": {
|
||||
"status": "source-backed-custom-cadence-required",
|
||||
"populationPlan": {
|
||||
"disposition": "retain-current-route-as-explicit-adaptation",
|
||||
"packCount": 5,
|
||||
"membersPerPack": 3,
|
||||
"authoredMobs": [
|
||||
{
|
||||
"serverEntry": 4175632753,
|
||||
"name": "Forgotten Mine Stoneguard",
|
||||
"role": "shielding-melee"
|
||||
},
|
||||
{
|
||||
"serverEntry": 647988074,
|
||||
"name": "Forgotten Mine Geomancer",
|
||||
"role": "geode-ranged"
|
||||
},
|
||||
{
|
||||
"serverEntry": 2677742134,
|
||||
"name": "Forgotten Mine Quarry Brute",
|
||||
"role": "frontal-bruiser"
|
||||
}
|
||||
]
|
||||
},
|
||||
"encounterPlan": [
|
||||
"Geolord Vek 254272 remains at the provisional boss anchor and opens with Stone Shield.",
|
||||
"Stonebreaker is a telegraphed frontal strike; Quake is a short-range raid pulse; Geode Barrage repeatedly targets one player; Ruptured Earth creates a marked split-damage pillar.",
|
||||
"Geospike 354004 summons entry 254278 after a clearly visible ground telegraph. The hazard erupts with the matching Geospike damage row and then despawns.",
|
||||
"Three clickable Resonite Shard gameobjects 254051 appear at authored offsets around the boss. Clicking consumes one and applies Resonite Cleanse 354031, which removes one active earth protection or prevents the next Resonance hit.",
|
||||
"At 50 percent health, Resonance begins and Vek alternates Quake with Ruptured Earth. Missing crystals respawn once per 30 second cycle.",
|
||||
"The official changelog requires large, obvious clickable crystal bounds and a high-contrast Geospike telegraph."
|
||||
],
|
||||
"authoredGameObjectOffsetsFromBoss": [
|
||||
[
|
||||
-12,
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
6,
|
||||
0,
|
||||
10.392
|
||||
],
|
||||
[
|
||||
6,
|
||||
0,
|
||||
-10.392
|
||||
]
|
||||
],
|
||||
"authoredCadence": {
|
||||
"stonebreakerMs": 6500,
|
||||
"geodeBarrageMs": 11000,
|
||||
"geospikeMs": 15000,
|
||||
"quakeMs": 18000,
|
||||
"rupturedEarthMs": 22000,
|
||||
"resoniteCycleMs": 30000
|
||||
},
|
||||
"mustBeCustomAuthored": [
|
||||
"all trash identities, placements, and AI",
|
||||
"all boss cadence, target selection, and phase thresholds",
|
||||
"the exact Resonite Cleanse interaction because the server script is absent",
|
||||
"crystal placement, interaction bounds, and respawn rules",
|
||||
"Geospike hazard placement and lifetime",
|
||||
"boss, hazard, trash, and crystal visual composites",
|
||||
"loot and completion scripting"
|
||||
]
|
||||
},
|
||||
"unresolved": [
|
||||
"Paired low/high-value spell rows do not expose a safe difficulty-label mapping.",
|
||||
"The authoritative creature and gameobject spawn coordinates are absent.",
|
||||
"Creature template stats and visual display IDs are absent.",
|
||||
"The exact Resonite click result is only constrained by client spell text and official changelog behavior."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# Ascension custom dungeon source recovery
|
||||
|
||||
This report separates recoverable client facts from the behavior that HealerMan must author for Tinkertech Showdown (821), Frozen Reach (908), and Forgotten Mine (1781). The machine-readable companion is `ASCENSION_CUSTOM_DUNGEON_RECOVERY.json`; its validator checks both the current generated catalogs and the original installed-client `Spell.dbc`.
|
||||
|
||||
## Result
|
||||
|
||||
| Map | Recovered exactly | Still requires adaptation |
|
||||
| --- | --- | --- |
|
||||
| 821 Tinkertech Showdown | Client environment, encounter 9013, boss creature 663012 | Every spawn, trash identity, gameobject, spell binding, boss schedule, phase, visual, loot, and completion rule |
|
||||
| 908 Frozen Reach | Client environment, encounters 2168/2170, Alva 97700, Arktos 97701, three summoned entries, and 85 encounter spell rows | Positions, stats, cast schedule, target rules, phase scripting, web hazards, visuals, loot, and completion rules |
|
||||
| 1781 Forgotten Mine | Client environment, encounters 5069/5071, Geolord Vek 254272, Geospike summon 254278, Resonite Shard gameobject 254051, and 33 encounter spell rows | Positions, stats, cast schedule, trash roster, crystal behavior, hazard lifetime, visuals, loot, and completion rules |
|
||||
|
||||
The local server/reference repositories, installed WDB caches, addon databases, and the public Questie Ascension snapshot do not contain surviving creature templates, world spawn rows, SmartAI, or encounter scripts for these maps. Client spell rows prove names and summon targets, but they do not prove cast cadence, ownership in every case, or coordinates.
|
||||
|
||||
## 821: Tinkertech Showdown
|
||||
|
||||
Exact client facts:
|
||||
|
||||
- Map directory: `TinketechShowdown` (the client spelling omits the second `r`). Source hash: `323c47c081d0a6c39daf9878284863c21e3e38dbced2ba3f58d1d157d080d41d`.
|
||||
- The recovered root WMO is `World/wmo/Dungeon/AZ_Subway/Subway_Dungeon.wmo`.
|
||||
- Manastorm encounter 9013 names Mechatron and binds it to creature 663012 in mode 2.
|
||||
- The player, trash, and boss anchors in the manifest are geometry-derived and provisional, not recovered server spawn points.
|
||||
|
||||
Candidate-only evidence:
|
||||
|
||||
- Spell 257438 `Rocket`.
|
||||
- Spells 257459-257461 `Gatling Missile Barrage` / `Gatling Missile`.
|
||||
- Spell 257462 `Sound: Goblin Head Machine Loop`.
|
||||
- Spell 257470 `Incineration Beam`, spell 257471 `Energy Ball`, and spells 257472-257473 `Electrified Zone` / `Electric Shock`.
|
||||
|
||||
Those rows form a coherent goblin-machine cluster, but no surviving source binds them to creature 663012. They must stay labeled as an adaptation if used.
|
||||
|
||||
Concrete adaptation:
|
||||
|
||||
- Retain the current route as five authored three-creature packs. Recast generated entries 2629079128, 3849447491, and 1787979165 as `Tinkertech Guardbot`, `Tinkertech Coilcaster`, and `Tinkertech Demolisher`; these IDs and names remain HealerMan-owned.
|
||||
- Keep Mechatron 663012 at the provisional boss anchor.
|
||||
- Use an authored rotation of Rocket reticles, Gatling lane impacts, Incineration Beam lane sweeps, and alternating Electrified Zones. At 35% health, accelerate the rotation and add two Coilcasters.
|
||||
- Model platform destruction as temporary lane disabling because the imported web geometry is immutable.
|
||||
|
||||
Everything in that combat implementation except the boss identity and candidate spell names is custom-authored. It should not be represented as recovered Ascension parity.
|
||||
|
||||
## 908: Frozen Reach
|
||||
|
||||
Exact client facts:
|
||||
|
||||
- Map directory: `Alvaencounter`. Source hash: `5ab771df79c0e062cea3ebda4e416a9245bcf8a4cc6da048077df5e674081f97`.
|
||||
- Manastorm encounters 2168 and 2170 name Alva and bind her to creature 97700 in modes 0 and 2.
|
||||
- The official Ascension database identifies Alva as creature 97700 and Arktos as creature 97701.
|
||||
- Spell 255363 summons creature 97703, spell 255421 summons creature 97731, and spell 255433 summons creature 97770. In context these are the Blast Arrow unit, Explosive Trap, and Alvajar add.
|
||||
- The client cluster spans 85 exact rows from 255347 through 255472. The principal families are Rain of Arrows, Unbridled Arrows, Blast Arrow, Cluster Shot, Pact of the Hunt, Ice Rock, Carnivorous Bite, Frost Shield, Set Ablaze, Blaze, Frost Strike, Frost Roar, Lacerate, Shield Wall, Shadow Shroud Surge/Rebirth, Vengeful Bolt, Shadow Veil Lance, Sphere of Grief and Wrath, Alva's Grief/Wrath, Explosive Trap, and Army of the Dead.
|
||||
- The official changelog confirms that the Blast Arrow/“Blast Shot” interaction can hit Arktos and that Shadow Shroud Rebirth reaches full damage scaling at five or more players.
|
||||
|
||||
Concrete adaptation:
|
||||
|
||||
- Remove the current 15 procedural hallway creatures (entries 1606324391, 854807736, and 2855026892). Available evidence describes a contained Alva/Arktos encounter and provides no support for that generic trash population.
|
||||
- Start Alva 97700 and Arktos 97701 together with Pact of the Hunt. Give Alva the recovered ranged/trap kit and Arktos the recovered bite, ice, roar, lacerate, and defensive kit.
|
||||
- Allow Blast Arrow to apply Set Ablaze to Arktos, forcing panic movement and increased damage taken.
|
||||
- On Arktos's death, leave Alva's Grief at the corpse, cast Shadow Shroud Rebirth, summon four Alvajar 97770, and move Alva into the recovered vengeance spell family.
|
||||
- Preserve D1/D2 spell variants as source data but use D0 for the current normal experience until a surviving difficulty mapping is found.
|
||||
|
||||
The exact cadence in the JSON is an explicit HealerMan proposal. Initial positions, transition rules, summon formation, stats, targeting, and hazard implementation all remain custom-authored.
|
||||
|
||||
## 1781: Forgotten Mine
|
||||
|
||||
Exact client facts:
|
||||
|
||||
- Map directory: `ForgottenMine`. Source hash: `693b7067aa1109e14beb944bc95aed8299bf64e2ec6ae3dc43f4fab7d18f57b6`.
|
||||
- Manastorm encounters 5069 and 5071 name Geolord Vek and bind him to creature 254272 in modes 0 and 2.
|
||||
- The contiguous 354000-354032 spell cluster explicitly contains `Vek Death` and `Vek Reset`, tying the cluster to Vek.
|
||||
- The recovered families are Stonebreaker, Quake, Stone Shield, Geospike, Resonance, Geode Barrage, Ruptured Earth, Stoneskin, Resonite Cleanse, and Create Resonite Shard.
|
||||
- Spell 354004 summons Geospike creature 254278. Spell 354032 creates gameobject 254051, named by the spell as a Resonite Shard.
|
||||
- Client text says Resonite counters the power of Earth. Official changelogs additionally require larger clickable crystal bounds and a visible Geospike telegraph.
|
||||
|
||||
Concrete adaptation:
|
||||
|
||||
- Retain five authored three-creature route packs only as an explicit approximation. Recast generated entries 4175632753, 647988074, and 2677742134 as `Forgotten Mine Stoneguard`, `Forgotten Mine Geomancer`, and `Forgotten Mine Quarry Brute`; these are not recovered Ascension creatures.
|
||||
- Keep Geolord Vek 254272 at the provisional boss anchor. Rotate Stonebreaker, Quake, Geode Barrage, Geospike, and Ruptured Earth, then begin Resonance at 50% health.
|
||||
- Spawn three clickable Resonite Shards 254051 at the authored triangular offsets in the JSON. Consuming a shard applies Resonite Cleanse 354031 and removes one active earth protection or prevents the next Resonance hit.
|
||||
- Give Geospike 254278 a high-contrast ground warning, eruption, and despawn sequence. Make crystal interaction bounds deliberately generous.
|
||||
|
||||
The crystal effect is constrained by spell text and changelog behavior but its exact server-side rule did not survive. Trash, timing, targeting, positions, respawn cadence, and visuals are custom-authored.
|
||||
|
||||
## Integration boundary
|
||||
|
||||
The recovery files intentionally do not change `compile-campaign-catalog.mjs`. Runtime integration should consume the recovered IDs and family lists while preserving three provenance labels:
|
||||
|
||||
- `exact`: copied from checksummed client/catalog data.
|
||||
- `spellLinked`: an entry ID read directly from a DBC summon effect; its descriptive name can still be contextual.
|
||||
- `authored`: HealerMan timing, placement, visuals, or behavior.
|
||||
|
||||
Validate the recovery against the current catalogs and installed client with:
|
||||
|
||||
```powershell
|
||||
node scripts/dungeon-pipeline/validate-ascension-custom-dungeon-recovery.mjs
|
||||
```
|
||||
|
||||
Use `--skip-dbc` only on machines without the checksummed Area-52 client, or pass another byte-identical file with `--dbc <path>`.
|
||||
@@ -9,7 +9,10 @@
|
||||
"mapDirectory": "World/Maps/TempestKeepArcane",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
|
||||
"difficultyVariants": [
|
||||
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]},
|
||||
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
|
||||
],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_TempestKeep/TK_Arcane.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
"mapDirectory": "World/Maps/AuchindounDraenei",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
|
||||
"difficultyVariants": [
|
||||
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]},
|
||||
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
|
||||
],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Auchindoun/Draenei_wing.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
"mapDirectory": "World/Maps/HellfireDemon",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [58, 70]}],
|
||||
"difficultyVariants": [
|
||||
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [58, 70]},
|
||||
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
|
||||
],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/Dungeon/Hellfire/hellfire_DemonWing.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
"mapDirectory": "World/Maps/TempestKeepAtrium",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
|
||||
"difficultyVariants": [
|
||||
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]},
|
||||
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
|
||||
],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_TempestKeep/TK_Atrium.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source-transform",
|
||||
"coordinateTransform": {"origin": [1262.4427, -60.8902, 1622.5397], "yawDegrees": 262.143},
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [58, 70]}],
|
||||
"difficultyVariants": [
|
||||
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [58, 70]},
|
||||
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
|
||||
],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/Dungeon/Hellfire/Hellfire_ramparts.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
"mapDirectory": "World/Maps/Sunwell5ManFix",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [70, 70]}],
|
||||
"difficultyVariants": [
|
||||
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [70, 70]},
|
||||
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
|
||||
],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/Dungeon/QT_Sunwell_5Man/Sunwell_5Man.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
"mapDirectory": "World/Maps/AuchindounEthereal",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
|
||||
"difficultyVariants": [
|
||||
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]},
|
||||
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
|
||||
],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Auchindoun/ethereal_wing.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
"mapDirectory": "World/Maps/TempestKeepFactory",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
|
||||
"difficultyVariants": [
|
||||
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]},
|
||||
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
|
||||
],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_TempestKeep/TK_Factory.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
"mapDirectory": "World/Maps/AuchindounDemon",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
|
||||
"difficultyVariants": [
|
||||
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]},
|
||||
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
|
||||
],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Auchindoun/demon_wing.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
"mapDirectory": "World/Maps/AuchindounShadow",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
|
||||
"difficultyVariants": [
|
||||
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]},
|
||||
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
|
||||
],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Auchindoun/shadow_council_wing.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
"mapDirectory": "World/Maps/HellfireMilitary",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]}],
|
||||
"difficultyVariants": [
|
||||
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]},
|
||||
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
|
||||
],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/Dungeon/Hellfire/Hellfire_Military.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
"mapDirectory": "World/Maps/CoilfangDraenei",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]}],
|
||||
"difficultyVariants": [
|
||||
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]},
|
||||
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
|
||||
],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Coilfang/Coilfang_Draenei.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
"mapDirectory": "World/Maps/CoilfangPumping",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]}],
|
||||
"difficultyVariants": [
|
||||
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]},
|
||||
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
|
||||
],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Coilfang/Coilfang_Pumping.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
"mapDirectory": "World/Maps/CoilfangMarsh",
|
||||
"environmentMode": "global-wmo",
|
||||
"coordinateAlignment": "source",
|
||||
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]}],
|
||||
"difficultyVariants": [
|
||||
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]},
|
||||
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
|
||||
],
|
||||
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
|
||||
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
|
||||
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Coilfang/Coilfang_Marsh.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.22",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.22",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+5
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.22",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
@@ -52,8 +52,11 @@
|
||||
"dungeon:five-player:import": "node scripts/dungeon-pipeline/import-five-player-dungeon.mjs",
|
||||
"dungeon:five-player:import:remaining": "node scripts/dungeon-pipeline/import-remaining-five-player-dungeons.mjs",
|
||||
"dungeon:five-player:verify": "node scripts/dungeon-pipeline/verify-five-player-dungeon-imports.mjs",
|
||||
"dungeon:wow:audit": "node scripts/dungeon-pipeline/audit-wow-dungeon-content.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: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",
|
||||
"dungeon:wow:all": "node scripts/dungeon-pipeline/run-all-wow-dungeons.mjs",
|
||||
"dungeon:extract": "node scripts/dungeon-pipeline/cli.mjs extract",
|
||||
"dungeon:creatures": "node scripts/dungeon-pipeline/cli.mjs creatures",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.21",
|
||||
"versionCode": 1021
|
||||
"versionName": "0.1.22",
|
||||
"versionCode": 1022
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
constants,
|
||||
copyFile,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
stat,
|
||||
@@ -13,8 +14,16 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const PROJECT_ROOT = path.resolve(SCRIPT_DIRECTORY, "../..");
|
||||
const WOW_CAMPAIGN_CATALOG_PATH = path.join(
|
||||
PROJECT_ROOT,
|
||||
"src",
|
||||
"game",
|
||||
"generated",
|
||||
"dungeonCampaignCatalog.json",
|
||||
);
|
||||
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",
|
||||
@@ -179,15 +188,112 @@ export async function scanContentSources(sources = DEFAULT_CONTENT_SOURCES) {
|
||||
return [...discovered.values()].sort((left, right) => left.logicalPath.localeCompare(right.logicalPath));
|
||||
}
|
||||
|
||||
function contentVersion(files) {
|
||||
function contentVersion(files, packs) {
|
||||
const digest = createHash("sha256");
|
||||
for (const file of files) digest.update(`${file.logicalPath}\0${file.sha256}\0${file.bytes}\n`);
|
||||
for (const pack of packs) {
|
||||
digest.update(`pack\0${pack.id}\0${pack.label}\0${pack.bundledOnAndroid}\0${pack.dependencies.join(",")}\n`);
|
||||
}
|
||||
return `content-${digest.digest("hex").slice(0, 16)}`;
|
||||
}
|
||||
|
||||
function assetPackId(url) {
|
||||
if (typeof url !== "string" || !url.startsWith("/")) return null;
|
||||
return contentPackForPath(url.slice(1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the logical install topology for the 54-dungeon WoW campaign.
|
||||
* Environment files intentionally remain in their independently downloadable
|
||||
* Manastorm packs; a dungeon pack owns its creatures and pulls in exactly the
|
||||
* environment/shared-creature packs referenced by the generated definition.
|
||||
*/
|
||||
export function campaignDungeonPackDefinitions(definitions) {
|
||||
const packs = new Map();
|
||||
for (const definition of definitions) {
|
||||
const dungeonPackId = `dungeon:${definition.id}`;
|
||||
if (definition.id === "wailing-caverns") {
|
||||
packs.set(dungeonPackId, {
|
||||
id: dungeonPackId,
|
||||
label: definition.title ?? packLabel(dungeonPackId),
|
||||
bundledOnAndroid: true,
|
||||
dependencies: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const environmentUrls = [
|
||||
...(definition.assets?.visual ?? []).map((asset) => asset.url),
|
||||
...(definition.assets?.collision ?? []).map((asset) => asset.url),
|
||||
definition.assets?.navigation?.url,
|
||||
].filter(Boolean);
|
||||
const resolvedEnvironmentPacks = environmentUrls.map(assetPackId);
|
||||
if (!resolvedEnvironmentPacks.length || resolvedEnvironmentPacks.some((id) => !id?.startsWith("manastorm:"))) {
|
||||
throw new Error(`${definition.id} has an environment asset outside a Manastorm content pack.`);
|
||||
}
|
||||
const environmentPackIds = new Set(resolvedEnvironmentPacks);
|
||||
if (environmentPackIds.size !== 1) {
|
||||
throw new Error(`${definition.id} must resolve to exactly one Manastorm environment pack.`);
|
||||
}
|
||||
const [environmentPackId] = environmentPackIds;
|
||||
packs.set(environmentPackId, {
|
||||
id: environmentPackId,
|
||||
label: packLabel(environmentPackId),
|
||||
bundledOnAndroid: false,
|
||||
dependencies: [],
|
||||
});
|
||||
|
||||
const creatureUrls = Object.values(definition.entities ?? {})
|
||||
.map((entity) => entity.visual?.model?.url)
|
||||
.filter(Boolean);
|
||||
const resolvedCreaturePacks = creatureUrls.map(assetPackId);
|
||||
if (resolvedCreaturePacks.some((id) => !id)) {
|
||||
throw new Error(`${definition.id} has a creature asset outside a content pack.`);
|
||||
}
|
||||
const referencedCreaturePacks = new Set(resolvedCreaturePacks.filter((id) => id !== dungeonPackId));
|
||||
const unexpectedCreaturePacks = [...referencedCreaturePacks].filter((id) => id !== "shared:creatures");
|
||||
if (unexpectedCreaturePacks.length) {
|
||||
throw new Error(`${definition.id} references unexpected creature packs: ${unexpectedCreaturePacks.join(", ")}`);
|
||||
}
|
||||
packs.set(dungeonPackId, {
|
||||
id: dungeonPackId,
|
||||
label: definition.title ?? packLabel(dungeonPackId),
|
||||
bundledOnAndroid: false,
|
||||
dependencies: [environmentPackId, ...referencedCreaturePacks],
|
||||
});
|
||||
}
|
||||
return [...packs.values()].sort((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
export async function loadCampaignDungeonPackDefinitions(
|
||||
catalogPath = WOW_CAMPAIGN_CATALOG_PATH,
|
||||
) {
|
||||
const definitions = await loadCampaignDungeonDefinitions(catalogPath);
|
||||
return campaignDungeonPackDefinitions(definitions);
|
||||
}
|
||||
|
||||
export async function loadCampaignDungeonDefinitions(
|
||||
catalogPath = WOW_CAMPAIGN_CATALOG_PATH,
|
||||
) {
|
||||
const campaign = JSON.parse(await readFile(catalogPath, "utf8"));
|
||||
if (!Array.isArray(campaign.definitions)) {
|
||||
throw new Error(`WoW campaign catalog is missing definitions: ${catalogPath}`);
|
||||
}
|
||||
if (campaign.definitions.length !== WOW_CAMPAIGN_DUNGEON_COUNT) {
|
||||
throw new Error(
|
||||
`WoW campaign catalog must contain ${WOW_CAMPAIGN_DUNGEON_COUNT} dungeons; found ${campaign.definitions.length}.`,
|
||||
);
|
||||
}
|
||||
const dungeonIds = campaign.definitions.map((definition) => definition?.id);
|
||||
if (dungeonIds.some((id) => typeof id !== "string" || !id)
|
||||
|| new Set(dungeonIds).size !== dungeonIds.length) {
|
||||
throw new Error("WoW campaign catalog contains a missing or duplicate dungeon id.");
|
||||
}
|
||||
return campaign.definitions;
|
||||
}
|
||||
|
||||
export function createContentManifest(files, options = {}) {
|
||||
const baseUrl = String(options.baseUrl ?? "/content/objects").replace(/\/+$/, "");
|
||||
const version = options.version ?? contentVersion(files);
|
||||
const packs = new Map();
|
||||
const fileEntries = {};
|
||||
|
||||
@@ -227,16 +333,93 @@ export function createContentManifest(files, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const declared of options.packDefinitions ?? []) {
|
||||
const current = packs.get(declared.id) ?? {
|
||||
id: declared.id,
|
||||
label: packLabel(declared.id),
|
||||
bytes: 0,
|
||||
fileCount: 0,
|
||||
bundledOnAndroid: BUNDLED_ANDROID_PACKS.includes(declared.id),
|
||||
dependencies: [],
|
||||
};
|
||||
packs.set(declared.id, {
|
||||
...current,
|
||||
label: declared.label ?? current.label,
|
||||
bundledOnAndroid: declared.bundledOnAndroid ?? current.bundledOnAndroid,
|
||||
dependencies: [...declared.dependencies],
|
||||
});
|
||||
}
|
||||
|
||||
const sortedPacks = [...packs.values()].sort((left, right) => left.id.localeCompare(right.id));
|
||||
const version = options.version ?? contentVersion(files, sortedPacks);
|
||||
|
||||
return {
|
||||
schemaVersion: CONTENT_SCHEMA_VERSION,
|
||||
version,
|
||||
createdAt: options.createdAt ?? new Date().toISOString(),
|
||||
minimumAppVersion: options.minimumAppVersion ?? DEFAULT_MINIMUM_APP_VERSION,
|
||||
packs: [...packs.values()].sort((left, right) => left.id.localeCompare(right.id)),
|
||||
packs: sortedPacks,
|
||||
files: fileEntries,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertCampaignDungeonManifest(manifest, definitions) {
|
||||
if (!Array.isArray(definitions) || definitions.length !== WOW_CAMPAIGN_DUNGEON_COUNT) {
|
||||
throw new Error(`Release content requires exactly ${WOW_CAMPAIGN_DUNGEON_COUNT} WoW dungeon definitions.`);
|
||||
}
|
||||
const expectedDefinitions = campaignDungeonPackDefinitions(definitions);
|
||||
const manifestPacks = new Map(manifest.packs.map((pack) => [pack.id, pack]));
|
||||
if (manifestPacks.size !== manifest.packs.length) {
|
||||
throw new Error("Release content manifest contains duplicate pack ids.");
|
||||
}
|
||||
|
||||
for (const expected of expectedDefinitions) {
|
||||
const actual = manifestPacks.get(expected.id);
|
||||
if (!actual) throw new Error(`Release content manifest is missing ${expected.id}.`);
|
||||
if (actual.bundledOnAndroid !== expected.bundledOnAndroid
|
||||
|| actual.dependencies.length !== expected.dependencies.length
|
||||
|| actual.dependencies.some((dependency, index) => dependency !== expected.dependencies[index])) {
|
||||
throw new Error(`Release content manifest has stale dependencies for ${expected.id}.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pack of manifest.packs) {
|
||||
const packFiles = Object.values(manifest.files).filter((entry) => entry.packId === pack.id);
|
||||
const packBytes = packFiles.reduce((total, entry) => total + entry.bytes, 0);
|
||||
if (pack.fileCount !== packFiles.length || pack.bytes !== packBytes) {
|
||||
throw new Error(`Release content manifest has invalid file totals for ${pack.id}.`);
|
||||
}
|
||||
for (const dependency of pack.dependencies) {
|
||||
if (!manifestPacks.has(dependency)) {
|
||||
throw new Error(`Release content manifest is missing dependency ${dependency} for ${pack.id}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const definition of definitions) {
|
||||
const referencedUrls = [
|
||||
...(definition.assets?.visual ?? []).map((asset) => asset.url),
|
||||
...(definition.assets?.collision ?? []).map((asset) => asset.url),
|
||||
definition.assets?.navigation?.url,
|
||||
...Object.values(definition.entities ?? {}).map((entity) => entity.visual?.model?.url),
|
||||
].filter(Boolean);
|
||||
for (const logicalPath of referencedUrls) {
|
||||
if (!manifest.files[logicalPath]) {
|
||||
throw new Error(`Release content manifest is missing ${definition.id} asset ${logicalPath}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dungeonPackCount = definitions.filter((definition) => (
|
||||
manifestPacks.has(`dungeon:${definition.id}`)
|
||||
)).length;
|
||||
if (dungeonPackCount !== WOW_CAMPAIGN_DUNGEON_COUNT) {
|
||||
throw new Error(
|
||||
`Release content manifest contains ${dungeonPackCount}/${WOW_CAMPAIGN_DUNGEON_COUNT} WoW dungeon packs.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyObjectIfMissing(file, objectDirectory) {
|
||||
const objectName = `${file.sha256}${file.extension}`;
|
||||
const destination = path.join(objectDirectory, objectName);
|
||||
@@ -263,20 +446,35 @@ async function replaceJsonAtomically(destination, value) {
|
||||
|
||||
export async function publishContent(options = {}) {
|
||||
const contentDirectory = path.resolve(options.contentDirectory ?? process.env.CONTENT_DIR ?? path.join(PROJECT_ROOT, "content"));
|
||||
const files = await scanContentSources(options.sources ?? DEFAULT_CONTENT_SOURCES);
|
||||
const includeCampaignPacks = options.includeCampaignPacks !== false;
|
||||
const campaignDefinitions = includeCampaignPacks ? await loadCampaignDungeonDefinitions() : null;
|
||||
const campaignPackDefinitions = campaignDefinitions
|
||||
? campaignDungeonPackDefinitions(campaignDefinitions)
|
||||
: [];
|
||||
const packDefinitions = [
|
||||
...campaignPackDefinitions,
|
||||
...(options.packDefinitions ?? []),
|
||||
];
|
||||
const manifest = createContentManifest(files, { ...options, packDefinitions });
|
||||
if (campaignDefinitions) assertCampaignDungeonManifest(manifest, campaignDefinitions);
|
||||
|
||||
const objectDirectory = path.join(contentDirectory, "objects");
|
||||
const manifestDirectory = path.join(contentDirectory, "manifests");
|
||||
await mkdir(objectDirectory, { recursive: true });
|
||||
await mkdir(manifestDirectory, { recursive: true });
|
||||
|
||||
const files = await scanContentSources(options.sources ?? DEFAULT_CONTENT_SOURCES);
|
||||
let copiedObjects = 0;
|
||||
for (const file of files) {
|
||||
if (await copyObjectIfMissing(file, objectDirectory)) copiedObjects += 1;
|
||||
}
|
||||
const manifest = createContentManifest(files, options);
|
||||
await replaceJsonAtomically(path.join(manifestDirectory, `${manifest.version}.json`), manifest);
|
||||
await replaceJsonAtomically(path.join(contentDirectory, "manifest.json"), manifest);
|
||||
return { contentDirectory, copiedObjects, manifest };
|
||||
return {
|
||||
contentDirectory,
|
||||
copiedObjects,
|
||||
manifest,
|
||||
campaignDungeonPackCount: campaignDefinitions?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCliArguments(argv) {
|
||||
@@ -296,6 +494,7 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me
|
||||
const bytes = result.manifest.packs.reduce((total, pack) => total + pack.bytes, 0);
|
||||
console.log(`Published ${result.manifest.files ? Object.keys(result.manifest.files).length : 0} files (${bytes} bytes)`);
|
||||
console.log(`Content version: ${result.manifest.version}`);
|
||||
console.log(`WoW campaign dungeon packs: ${result.campaignDungeonPackCount}/${WOW_CAMPAIGN_DUNGEON_COUNT}`);
|
||||
console.log(`New immutable objects: ${result.copiedObjects}`);
|
||||
console.log(`Manifest: ${path.join(result.contentDirectory, "manifest.json")}`);
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
BUNDLED_ANDROID_PACKS,
|
||||
PROJECT_ROOT,
|
||||
assertCampaignDungeonManifest,
|
||||
contentPackForPath,
|
||||
createContentManifest,
|
||||
loadCampaignDungeonPackDefinitions,
|
||||
publishContent,
|
||||
scanContentSources,
|
||||
} from "./publish-content.mjs";
|
||||
@@ -31,6 +34,160 @@ test("assigns stable player-facing packs from logical asset paths", () => {
|
||||
assert.equal(contentPackForPath("basis/basis_transcoder.wasm"), "core:runtime");
|
||||
});
|
||||
|
||||
test("gives every WoW campaign dungeon an exact install dependency closure", async () => {
|
||||
const campaign = JSON.parse(await readFile(path.join(
|
||||
PROJECT_ROOT,
|
||||
"src",
|
||||
"game",
|
||||
"generated",
|
||||
"dungeonCampaignCatalog.json",
|
||||
), "utf8"));
|
||||
const packDefinitions = await loadCampaignDungeonPackDefinitions();
|
||||
const dungeonDefinitions = packDefinitions.filter((pack) => pack.id.startsWith("dungeon:"));
|
||||
const wailingCreatureManifest = JSON.parse(await readFile(path.join(
|
||||
PROJECT_ROOT,
|
||||
"public",
|
||||
"assets",
|
||||
"creatures",
|
||||
"wailing-caverns",
|
||||
"animated-manifest.json",
|
||||
), "utf8"));
|
||||
assert.equal(campaign.definitions.length, 54);
|
||||
assert.equal(dungeonDefinitions.length, 54);
|
||||
assert.equal(wailingCreatureManifest.creatures.length, 16);
|
||||
|
||||
const referencedSourceFiles = [
|
||||
...campaign.definitions.flatMap((dungeon) => [
|
||||
...[
|
||||
...dungeon.assets.visual,
|
||||
...dungeon.assets.collision,
|
||||
dungeon.assets.navigation,
|
||||
].map((asset) => ({ dungeonId: dungeon.id, url: asset.url })),
|
||||
...Object.values(dungeon.entities).flatMap((entity) => (
|
||||
entity.visual?.model?.url ? [{ dungeonId: dungeon.id, url: entity.visual.model.url }] : []
|
||||
)),
|
||||
]),
|
||||
...wailingCreatureManifest.creatures.map((creature) => ({
|
||||
dungeonId: "wailing-caverns",
|
||||
url: creature.url,
|
||||
})),
|
||||
];
|
||||
await Promise.all(referencedSourceFiles.map(async ({ dungeonId, url }) => {
|
||||
try {
|
||||
await access(path.join(PROJECT_ROOT, "public", ...url.slice(1).split("/")));
|
||||
} catch {
|
||||
assert.fail(`${dungeonId} is missing ${url}`);
|
||||
}
|
||||
}));
|
||||
await Promise.all([
|
||||
"wailing-caverns-visual.glb",
|
||||
"wailing-caverns-collision.glb",
|
||||
"wailing-caverns-navigation.glb",
|
||||
].map((fileName) => access(path.join(
|
||||
PROJECT_ROOT,
|
||||
"src",
|
||||
"assets",
|
||||
"game",
|
||||
"dungeons",
|
||||
"wailing-caverns",
|
||||
fileName,
|
||||
))));
|
||||
|
||||
const logicalFiles = new Map();
|
||||
const addFile = (logicalPath) => logicalFiles.set(logicalPath, {
|
||||
logicalPath,
|
||||
sourcePath: logicalPath,
|
||||
sha256: "a".repeat(64),
|
||||
bytes: 1,
|
||||
extension: path.extname(logicalPath),
|
||||
packId: contentPackForPath(logicalPath),
|
||||
});
|
||||
addFile("assets/game/dungeons/wailing-caverns/visual.glb");
|
||||
for (const dungeon of campaign.definitions) {
|
||||
for (const asset of [
|
||||
...dungeon.assets.visual,
|
||||
...dungeon.assets.collision,
|
||||
dungeon.assets.navigation,
|
||||
]) addFile(asset.url.slice(1));
|
||||
for (const entity of Object.values(dungeon.entities)) {
|
||||
if (entity.visual?.model?.url) addFile(entity.visual.model.url.slice(1));
|
||||
}
|
||||
}
|
||||
const manifest = createContentManifest([...logicalFiles.values()], {
|
||||
packDefinitions,
|
||||
version: "campaign-contract",
|
||||
});
|
||||
assert.doesNotThrow(() => assertCampaignDungeonManifest(manifest, campaign.definitions));
|
||||
const packs = new Map(manifest.packs.map((pack) => [pack.id, pack]));
|
||||
|
||||
for (const dungeon of campaign.definitions) {
|
||||
const pack = packs.get(`dungeon:${dungeon.id}`);
|
||||
assert.ok(pack, dungeon.id);
|
||||
if (dungeon.id === "wailing-caverns") {
|
||||
assert.equal(pack.bundledOnAndroid, true);
|
||||
assert.deepEqual(pack.dependencies, []);
|
||||
continue;
|
||||
}
|
||||
const environmentPack = contentPackForPath(dungeon.assets.visual[0].url.slice(1));
|
||||
const needsSharedCreatures = Object.values(dungeon.entities).some((entity) => (
|
||||
entity.visual?.model?.url?.startsWith("/assets/game/creatures/shared/")
|
||||
));
|
||||
assert.deepEqual(
|
||||
pack.dependencies,
|
||||
[environmentPack, ...(needsSharedCreatures ? ["shared:creatures"] : [])],
|
||||
dungeon.id,
|
||||
);
|
||||
assert.deepEqual(packs.get(environmentPack)?.dependencies, [], dungeon.id);
|
||||
}
|
||||
|
||||
for (const [dungeonId, environmentPack] of [
|
||||
["tinkertech-showdown", "manastorm:821-tinketechshowdown"],
|
||||
["frozen-reach", "manastorm:908-alvaencounter"],
|
||||
["forgotten-mine", "manastorm:1781-forgottenmine"],
|
||||
]) {
|
||||
const pack = packs.get(`dungeon:${dungeonId}`);
|
||||
assert.equal(pack.fileCount, 0);
|
||||
assert.equal(pack.bytes, 0);
|
||||
assert.deepEqual(pack.dependencies, [environmentPack]);
|
||||
}
|
||||
});
|
||||
|
||||
test("default release publishing fails before writing an incomplete campaign manifest", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "healer-man-incomplete-content-test-"));
|
||||
const sourceDirectory = path.join(directory, "source");
|
||||
const contentDirectory = path.join(directory, "content");
|
||||
await mkdir(sourceDirectory, { recursive: true });
|
||||
await writeFile(path.join(sourceDirectory, "placeholder.glb"), Buffer.from("incomplete"));
|
||||
|
||||
try {
|
||||
await assert.rejects(publishContent({
|
||||
contentDirectory,
|
||||
sources: [{ directory: sourceDirectory, logicalPrefix: "assets/game/dungeons/placeholder" }],
|
||||
}), /Release content manifest is missing/);
|
||||
await assert.rejects(access(contentDirectory), { code: "ENOENT" });
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("content versions change when only logical pack dependencies change", () => {
|
||||
const files = [{
|
||||
logicalPath: "assets/game/dungeons/test/creature.glb",
|
||||
sourcePath: "creature.glb",
|
||||
sha256: "b".repeat(64),
|
||||
bytes: 1,
|
||||
extension: ".glb",
|
||||
packId: "dungeon:test",
|
||||
}];
|
||||
const first = createContentManifest(files, {
|
||||
packDefinitions: [{ id: "dungeon:test", dependencies: ["manastorm:first"] }],
|
||||
});
|
||||
const second = createContentManifest(files, {
|
||||
packDefinitions: [{ id: "dungeon:test", dependencies: ["manastorm:second"] }],
|
||||
});
|
||||
assert.notEqual(first.version, second.version);
|
||||
});
|
||||
|
||||
test("publishes objects before a deterministic latest manifest", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "healer-man-content-test-"));
|
||||
const sourceDirectory = path.join(directory, "source");
|
||||
@@ -45,7 +202,12 @@ test("publishes objects before a deterministic latest manifest", async () => {
|
||||
assert.equal(Object.keys(firstManifest.files).length, 1);
|
||||
assert.equal(firstManifest.packs[0].id, "dungeon:wailing-caverns");
|
||||
|
||||
const first = await publishContent({ contentDirectory, sources, createdAt: "2026-08-14T00:00:00.000Z" });
|
||||
const first = await publishContent({
|
||||
contentDirectory,
|
||||
sources,
|
||||
includeCampaignPacks: false,
|
||||
createdAt: "2026-08-14T00:00:00.000Z",
|
||||
});
|
||||
assert.equal(first.copiedObjects, 1);
|
||||
const latest = JSON.parse(await readFile(path.join(contentDirectory, "manifest.json"), "utf8"));
|
||||
assert.equal(latest.version, first.manifest.version);
|
||||
@@ -53,7 +215,12 @@ test("publishes objects before a deterministic latest manifest", async () => {
|
||||
assert.match(entry.url, /^\/content\/objects\/[a-f0-9]{64}\.glb$/);
|
||||
assert.equal(await readFile(path.join(contentDirectory, "objects", path.basename(entry.url)), "utf8"), "glb-one");
|
||||
|
||||
const second = await publishContent({ contentDirectory, sources, createdAt: "2026-08-14T00:00:00.000Z" });
|
||||
const second = await publishContent({
|
||||
contentDirectory,
|
||||
sources,
|
||||
includeCampaignPacks: false,
|
||||
createdAt: "2026-08-14T00:00:00.000Z",
|
||||
});
|
||||
assert.equal(second.copiedObjects, 0);
|
||||
assert.equal(second.manifest.version, first.manifest.version);
|
||||
} finally {
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
import { open, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { DUNGEON_DEFINITIONS } from "../../src/game/dungeonRegistry";
|
||||
import {
|
||||
WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS,
|
||||
WOW_DUNGEON_NO_TRASH_EXCEPTIONS,
|
||||
} from "../../src/game/wowDungeonContentExceptions";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const publicRoot = path.join(projectRoot, "public");
|
||||
@@ -18,24 +23,14 @@ const validTargets = new Set([
|
||||
]);
|
||||
const validSchools = new Set(["physical", "arcane", "fire", "frost", "nature", "shadow", "holy"]);
|
||||
const validEffects = new Set([
|
||||
"damage", "heal", "aura", "control", "transform", "summon", "call-for-help", "encounter-event",
|
||||
]);
|
||||
const basicOnlyBossExceptions = new Map([
|
||||
[
|
||||
"wailing-caverns:entry-3653",
|
||||
"Kresh is melee-only in the pinned AzerothCore snapshot; the historical server has no spell or SmartAI row for entry 3653.",
|
||||
],
|
||||
[
|
||||
"wailing-caverns:deviate-faerie-dragon",
|
||||
"The server-faithful rare entry 5912 has no spell rows and is despawned by the Wailing Caverns initialization event.",
|
||||
],
|
||||
]);
|
||||
const noTrashExceptions = new Map([
|
||||
[
|
||||
"trial-of-the-champion",
|
||||
"Trial of the Champion is an encounter arena; its imported combat population is the five Grand Champion bosses, not a trash route.",
|
||||
],
|
||||
"damage", "heal", "aura", "target-aura", "control", "transform", "summon", "call-for-help", "encounter-event",
|
||||
]);
|
||||
const basicOnlyBossExceptions = new Map(
|
||||
WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS.map(({ key, reason }) => [key, reason]),
|
||||
);
|
||||
const noTrashExceptions = new Map(
|
||||
WOW_DUNGEON_NO_TRASH_EXCEPTIONS.map(({ key, reason }) => [key, reason]),
|
||||
);
|
||||
const animationIds = {
|
||||
idle: new Set([0]),
|
||||
move: new Set([4, 5]),
|
||||
@@ -152,13 +147,21 @@ const [campaign, catalog, wailingFixture] = await Promise.all([
|
||||
readJson("dungeon-pipeline/fixtures/wailing-caverns/runtime-fixture.json"),
|
||||
]);
|
||||
const campaignIds = new Set(campaign.dungeons.map((dungeon) => dungeon.slug));
|
||||
const definitions = catalog.definitions.filter((definition) => campaignIds.has(definition.id));
|
||||
const definitions = DUNGEON_DEFINITIONS.filter((definition) => campaignIds.has(definition.id));
|
||||
const coverage = new Map(catalog.coverage.map((entry) => [entry.dungeonId, entry]));
|
||||
const wailingModels = wailingFixtureModels(wailingFixture);
|
||||
const issues = [];
|
||||
if (basicOnlyBossExceptions.size !== WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS.length) {
|
||||
issues.push("The shared basic-only boss exception contract contains a duplicate key.");
|
||||
}
|
||||
if (noTrashExceptions.size !== WOW_DUNGEON_NO_TRASH_EXCEPTIONS.length) {
|
||||
issues.push("The shared no-trash exception contract contains a duplicate key.");
|
||||
}
|
||||
const modelUsers = new Map();
|
||||
const animationFallbacks = [];
|
||||
const dungeons = [];
|
||||
const actualBasicOnlyBossKeys = new Set();
|
||||
const actualNoTrashDungeonKeys = new Set();
|
||||
|
||||
if (campaign.dungeons.length !== 54) {
|
||||
issues.push("Expected 54 campaign dungeons; found " + campaign.dungeons.length + ".");
|
||||
@@ -179,15 +182,15 @@ for (const dungeon of definitions) {
|
||||
let specialAttacks = 0;
|
||||
let nativeModels = 0;
|
||||
let proceduralModels = 0;
|
||||
const optionalBossEntityIds = new Set((dungeon.objectiveOrder ?? [])
|
||||
.filter((objective) => objective.optional)
|
||||
.map((objective) => dungeon.staticSpawns?.find((spawn) => spawn.id === objective.id)?.entityId)
|
||||
.filter(Boolean));
|
||||
|
||||
if (!entities.length) dungeonIssues.push("has no combat entities.");
|
||||
if (!bosses.length) dungeonIssues.push("has no bosses.");
|
||||
if (!mobs.length && !noTrashExceptions.has(dungeon.id)) dungeonIssues.push("has no trash mobs.");
|
||||
if (!(dungeon.staticSpawns?.length || dungeon.roamingPacks?.length)) dungeonIssues.push("has no combat spawns.");
|
||||
if (!mobs.length) {
|
||||
actualNoTrashDungeonKeys.add(dungeon.id);
|
||||
if (!noTrashExceptions.has(dungeon.id)) dungeonIssues.push("has no trash mobs.");
|
||||
}
|
||||
if (!(dungeon.staticSpawns?.length || dungeon.roamingPacks?.length || dungeon.encounterSpawnGroups?.some((group) => group.spawns?.length))) {
|
||||
dungeonIssues.push("has no combat spawns.");
|
||||
}
|
||||
|
||||
for (const spawn of dungeon.staticSpawns ?? []) {
|
||||
if (!dungeon.entities?.[spawn.entityId]) {
|
||||
@@ -201,6 +204,13 @@ for (const dungeon of definitions) {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const group of dungeon.encounterSpawnGroups ?? []) {
|
||||
for (const spawn of group.spawns ?? []) {
|
||||
if (!dungeon.entities?.[spawn.entityId]) {
|
||||
dungeonIssues.push("encounter group " + group.id + " references missing entity " + spawn.entityId + ".");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [entityId, entity] of entities) {
|
||||
const entityAttacks = entity.combat?.attacks ?? [];
|
||||
@@ -209,8 +219,9 @@ for (const dungeon of definitions) {
|
||||
attacks += entityAttacks.length;
|
||||
const specials = entityAttacks.filter((attack) => attack.id !== "basic-attack");
|
||||
specialAttacks += specials.length;
|
||||
if (entity.kind === "boss" && !specials.length && !optionalBossEntityIds.has(entityId)) {
|
||||
if (entity.kind === "boss" && !specials.length) {
|
||||
const exceptionKey = dungeon.id + ":" + entityId;
|
||||
actualBasicOnlyBossKeys.add(exceptionKey);
|
||||
if (!basicOnlyBossExceptions.has(exceptionKey)) {
|
||||
dungeonIssues.push(entityId + " has no boss mechanic beyond its basic attack.");
|
||||
}
|
||||
@@ -247,6 +258,17 @@ for (const dungeon of definitions) {
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of basicOnlyBossExceptions.keys()) {
|
||||
if (!actualBasicOnlyBossKeys.has(key)) {
|
||||
issues.push("Stale basic-only boss exception does not match a runtime boss: " + key + ".");
|
||||
}
|
||||
}
|
||||
for (const key of noTrashExceptions.keys()) {
|
||||
if (!actualNoTrashDungeonKeys.has(key)) {
|
||||
issues.push("Stale no-trash exception does not match a runtime dungeon: " + key + ".");
|
||||
}
|
||||
}
|
||||
|
||||
for (const [url, users] of modelUsers) {
|
||||
try {
|
||||
const names = await readGlbAnimationNames(url);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,6 +10,11 @@ const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "
|
||||
const recipesRoot = path.join(projectRoot, "dungeon-pipeline", "recipes");
|
||||
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(
|
||||
projectRoot,
|
||||
"dungeon-pipeline",
|
||||
"WOW_GAMEOBJECT_ASSET_MANIFEST.json",
|
||||
);
|
||||
const compressedOutputFile = path.join(
|
||||
projectRoot,
|
||||
"src",
|
||||
@@ -81,6 +86,19 @@ const epochSpellDbc = path.join(
|
||||
"DBFilesClient",
|
||||
"Spell.dbc",
|
||||
);
|
||||
const standardGameObjectDisplayDbc = path.join(
|
||||
workRoot,
|
||||
"deadmines",
|
||||
"client-dbc",
|
||||
"DBFilesClient",
|
||||
"GameObjectDisplayInfo.dbc",
|
||||
);
|
||||
const epochGameObjectDisplayDbc = path.join(
|
||||
workRoot,
|
||||
"epoch-client",
|
||||
"DBFilesClient",
|
||||
"GameObjectDisplayInfo.dbc",
|
||||
);
|
||||
|
||||
const WAILING_SERVER_ENCOUNTERS = Object.freeze([
|
||||
[3671, "Lady Anacondra"],
|
||||
@@ -200,6 +218,100 @@ function slugPart(value) {
|
||||
.replace(/^-|-$/g, "") || "unknown";
|
||||
}
|
||||
|
||||
function compileBossProgressionEncounter(recipe, bosses, syntheticTrash) {
|
||||
const runtimeBosses = bosses.filter((boss) => !boss.eventOnly);
|
||||
const requiredBosses = runtimeBosses.filter((boss) => !boss.optional);
|
||||
const rules = runtimeBosses.map((boss, index) => ({
|
||||
id: `boss-${index + 1}-defeated`,
|
||||
trigger: { kind: "entities-dead", entityIds: [boss.id] },
|
||||
actions: [
|
||||
{
|
||||
id: `record-boss-${index + 1}`,
|
||||
kind: "complete-objective",
|
||||
objectiveId: boss.id,
|
||||
persistence: "run",
|
||||
},
|
||||
...(!boss.optional ? [{
|
||||
id: `checkpoint-boss-${index + 1}`,
|
||||
kind: "set-checkpoint",
|
||||
checkpointId: boss.id,
|
||||
persistence: "checkpoint",
|
||||
}] : []),
|
||||
],
|
||||
}));
|
||||
const finalBoss = requiredBosses.at(-1);
|
||||
// Event-only objectives belong to detailed instance orchestration. A
|
||||
// baseline must not declare the dungeon complete before that event fires.
|
||||
if (finalBoss && !bosses.some((boss) => boss.eventOnly)) {
|
||||
rules.push({
|
||||
id: "all-required-bosses-defeated",
|
||||
// Re-evaluate persisted objectives independently of kill order. Tying
|
||||
// completion to the catalog's final actor can strand a run when players
|
||||
// defeat that actor early and finish another required boss afterward.
|
||||
trigger: { kind: "tick" },
|
||||
guards: requiredBosses.map((boss) => ({
|
||||
kind: "objective-complete",
|
||||
objectiveId: boss.id,
|
||||
equals: true,
|
||||
})),
|
||||
actions: [
|
||||
{
|
||||
id: "mark-dungeon-complete",
|
||||
kind: "set-flag",
|
||||
flagId: "dungeon-complete",
|
||||
value: true,
|
||||
persistence: "run",
|
||||
},
|
||||
{
|
||||
id: "enter-complete-phase",
|
||||
kind: "set-phase",
|
||||
phase: "complete",
|
||||
persistence: "run",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
const sourceBacked = !syntheticTrash;
|
||||
return {
|
||||
id: `${recipe.slug}:boss-progression`,
|
||||
dungeonId: recipe.slug,
|
||||
initialPhase: "progression",
|
||||
initialState: { flags: { "dungeon-complete": false } },
|
||||
resetPolicy: { bossWipePhase: "progression", checkpointPhase: "progression" },
|
||||
source: {
|
||||
fidelity: sourceBacked ? "source-backed-adapted" : "healer-man-authored",
|
||||
scope: "boss-progression",
|
||||
files: sourceBacked
|
||||
? [
|
||||
`dungeon-pipeline/work/${recipe.slug}/runtime-draft.json`,
|
||||
"src/game/generated/azerothCoreScriptSpells.json",
|
||||
"src/game/generated/azerothCoreWorldAi.json",
|
||||
]
|
||||
: [
|
||||
`dungeon-pipeline/recipes/${recipe.slug}.json`,
|
||||
"src/game/generated/manastormRuntimeCatalog.json",
|
||||
],
|
||||
reviewedBehavior: sourceBacked
|
||||
? "Live source-backed boss runtime IDs record persistent objective completion and stable post-boss checkpoints; all required non-event bosses complete the baseline run."
|
||||
: "Catalog boss identities record persistent objective completion and stable post-boss checkpoints; all required bosses complete the authored fallback run.",
|
||||
adaptations: [
|
||||
"Boss progression is intentionally order-independent unless a detailed encounter definition supplies an explicit gate.",
|
||||
"Optional rare bosses record completion when present but do not gate the final completion state.",
|
||||
"A checkpoint retains completed objectives and resets temporary combat state; it does not invent a source graveyard or teleport.",
|
||||
],
|
||||
remainingGaps: [
|
||||
...(sourceBacked ? [] : [
|
||||
"Replace the authored fallback with authoritative Ascension population and instance-script data when those sources become available.",
|
||||
]),
|
||||
"Port exact instance-script doors, waves, escorts, vehicles, dialogue, gossip, quests, area triggers, and event-only bosses where the source requires them.",
|
||||
"Enable source game-object visuals, collision, and state transitions only after their client models and script bindings are reviewed.",
|
||||
"Record an end-to-end traversal, wipe/reset, checkpoint reload, HUD, collision, and completion playtest before marking the dungeon validated.",
|
||||
],
|
||||
},
|
||||
rules,
|
||||
};
|
||||
}
|
||||
|
||||
function inferEnvironmentMode(recipe, assetPackage) {
|
||||
if (recipe.environmentMode !== "detect") return recipe.environmentMode;
|
||||
const sourceFiles = assetPackage.visual.flatMap((chunk) => chunk.provenance?.sourceFiles ?? []);
|
||||
@@ -214,6 +326,395 @@ const ADT_SOURCE_BASES = new Set([
|
||||
]);
|
||||
const GLOBAL_WMO_SOURCE_BASES = new Set(["draft", "flip-x"]);
|
||||
const COORDINATE_ALIGNMENTS = new Set(["player-anchor", "source", "source-transform"]);
|
||||
const SOURCE_GAME_OBJECT_TYPES = new Set(Array.from({ length: 36 }, (_, type) => type));
|
||||
|
||||
function rowValue(row, names, fallback = undefined) {
|
||||
for (const name of names) {
|
||||
if (row?.[name] !== undefined && row[name] !== null) return row[name];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function finiteNumber(value, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function clamp(value, minimum, maximum) {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
function sourceGameObjectPosition(row) {
|
||||
const wowX = finiteNumber(rowValue(row, ["position_x", "positionX", "x"]));
|
||||
const wowY = finiteNumber(rowValue(row, ["position_y", "positionY", "y"]));
|
||||
const wowZ = finiteNumber(rowValue(row, ["position_z", "positionZ", "z"]));
|
||||
return [-wowX, wowZ, wowY];
|
||||
}
|
||||
|
||||
function sourceGameObjectKind(type, name) {
|
||||
const normalized = normalizedName(name);
|
||||
if (type === 0 || type === 35) {
|
||||
if (normalized.includes("bridge") || normalized.includes("drawbridge")) return "bridge";
|
||||
if (normalized.includes("gate") || normalized.includes("portcullis")) return "gate";
|
||||
return "door";
|
||||
}
|
||||
if (type === 1 || type === 31) return "switch";
|
||||
if (type === 2 || type === 10 || type === 23) return "quest-object";
|
||||
if (type === 3) return /vein|deposit|herb|plant|school|node/i.test(name) ? "resource" : "chest";
|
||||
if (type === 5) return "generic";
|
||||
if (type === 6 || type === 12) return "trap";
|
||||
if (type === 7 || type === 32) return "chair";
|
||||
if (type === 8) return "spell-focus";
|
||||
if (type === 9) return "text";
|
||||
if (type === 11 || type === 15) return "transport";
|
||||
if (type === 13) return "camera";
|
||||
if (type === 18) return "ritual";
|
||||
if (type === 19) return "mailbox";
|
||||
if (type === 22) return "spellcaster";
|
||||
if (type === 25) return "fishing-hole";
|
||||
if (type === 33) return "destructible";
|
||||
return type === 30 ? "aura-generator" : "generic";
|
||||
}
|
||||
|
||||
function sourceGameObjectHalfExtents(kind, sourceSize) {
|
||||
const size = clamp(finiteNumber(sourceSize, 1), 0.1, 6);
|
||||
if (kind === "door" || kind === "gate") return vector([1.25 * size, 2 * size, 0.2 * size]);
|
||||
if (kind === "bridge") return vector([2.5 * size, 0.2 * size, 1.25 * size]);
|
||||
if (kind === "switch") return vector([0.35 * size, 0.5 * size, 0.35 * size]);
|
||||
if (kind === "chest" || kind === "resource") return vector([0.6 * size, 0.45 * size, 0.45 * size]);
|
||||
if (kind === "trap" || kind === "trigger") return vector([0.8 * size, 0.1 * size, 0.8 * size]);
|
||||
if (kind === "transport") return vector([2 * size, 0.25 * size, 2 * size]);
|
||||
if (kind === "destructible") return vector([1.5 * size, 1.5 * size, 1.5 * size]);
|
||||
return vector([0.45 * size, 0.65 * size, 0.45 * size]);
|
||||
}
|
||||
|
||||
function clientGameObjectHalfExtents(display, sourceSize) {
|
||||
if (!display?.bounds) return null;
|
||||
const size = clamp(finiteNumber(sourceSize, 1), 0.01, 100);
|
||||
const spans = display.bounds.max.map((value, axis) => value - display.bounds.min[axis]);
|
||||
if (!spans.every((value) => Number.isFinite(value) && value > 0 && value <= 1_000)) return null;
|
||||
// Client bounds are x/y/z. Runtime draft space is -x/z/y.
|
||||
return vector([
|
||||
clamp(spans[0] * size / 2, 0.025, 500),
|
||||
clamp(spans[2] * size / 2, 0.025, 500),
|
||||
clamp(spans[1] * size / 2, 0.025, 500),
|
||||
]);
|
||||
}
|
||||
|
||||
function parseGameObjectDisplays(buffer) {
|
||||
if (!buffer || buffer.toString("ascii", 0, 4) !== "WDBC") return new Map();
|
||||
const recordCount = buffer.readUInt32LE(4);
|
||||
const fieldCount = buffer.readUInt32LE(8);
|
||||
const recordSize = buffer.readUInt32LE(12);
|
||||
const stringSize = buffer.readUInt32LE(16);
|
||||
if (fieldCount !== 19 || recordSize !== fieldCount * 4) return new Map();
|
||||
const stringsOffset = 20 + recordCount * recordSize;
|
||||
const readString = (offset) => {
|
||||
if (!offset || offset >= stringSize) return "";
|
||||
const start = stringsOffset + offset;
|
||||
const end = buffer.indexOf(0, start);
|
||||
return buffer.toString("utf8", start, end < 0 ? buffer.length : end);
|
||||
};
|
||||
const displays = new Map();
|
||||
for (let index = 0; index < recordCount; index += 1) {
|
||||
const base = 20 + index * recordSize;
|
||||
const id = buffer.readUInt32LE(base);
|
||||
const modelPath = readString(buffer.readUInt32LE(base + 4)).replace(/\\/g, "/");
|
||||
const min = [12, 13, 14].map((field) => rounded(buffer.readFloatLE(base + field * 4)));
|
||||
const max = [15, 16, 17].map((field) => rounded(buffer.readFloatLE(base + field * 4)));
|
||||
displays.set(id, {
|
||||
id,
|
||||
modelPath,
|
||||
bounds: { min, max },
|
||||
});
|
||||
}
|
||||
return displays;
|
||||
}
|
||||
|
||||
function sourceGameObjectStates(kind) {
|
||||
if (kind === "door" || kind === "gate" || kind === "bridge") {
|
||||
return [
|
||||
// Adapted source objects remain non-blocking until an instance-script
|
||||
// transition has been reviewed; otherwise a closed door with no opener
|
||||
// would turn an incomplete import into an unavoidable soft lock.
|
||||
{ id: "closed", label: "Closed", visible: true, blocksMovement: false, blocksSight: false },
|
||||
{ id: "open", label: "Open", visible: false, blocksMovement: false, blocksSight: false },
|
||||
];
|
||||
}
|
||||
if (kind === "trap") {
|
||||
return [
|
||||
{ id: "armed", label: "Armed", visible: true, blocksMovement: false, interactionEnabled: false },
|
||||
{ id: "disabled", label: "Disabled", visible: false, blocksMovement: false, interactionEnabled: false },
|
||||
];
|
||||
}
|
||||
if (kind === "transport") {
|
||||
return [
|
||||
{ id: "stationary", label: "Stationary", visible: true, blocksMovement: false, interactionEnabled: false },
|
||||
{ id: "moving", label: "Moving", visible: true, blocksMovement: false, interactionEnabled: false },
|
||||
];
|
||||
}
|
||||
if (kind === "destructible") {
|
||||
return [
|
||||
{ id: "intact", label: "Intact", visible: true, blocksMovement: false, blocksSight: false },
|
||||
{ id: "destroyed", label: "Destroyed", visible: false, blocksMovement: false, blocksSight: false },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ id: "available", label: "Available", visible: true, blocksMovement: false, interactionEnabled: false },
|
||||
{ id: "inactive", label: "Inactive", visible: false, blocksMovement: false, interactionEnabled: false },
|
||||
];
|
||||
}
|
||||
|
||||
function sourceGameObjectInitialState(kind, row) {
|
||||
const sourceState = finiteNumber(rowValue(row, ["state", "State"]), 1);
|
||||
if (kind === "door" || kind === "gate" || kind === "bridge") return sourceState === 0 ? "open" : "closed";
|
||||
if (kind === "trap") return sourceState === 0 ? "disabled" : "armed";
|
||||
if (kind === "transport") return sourceState === 0 ? "moving" : "stationary";
|
||||
if (kind === "destructible") return sourceState === 2 ? "destroyed" : "intact";
|
||||
return sourceState === 0 ? "inactive" : "available";
|
||||
}
|
||||
|
||||
function sourceGameObjectVisual(kind) {
|
||||
const colors = {
|
||||
door: "#5f4932",
|
||||
gate: "#4d4a46",
|
||||
bridge: "#66543c",
|
||||
switch: "#c38d35",
|
||||
chest: "#8f682f",
|
||||
resource: "#54704b",
|
||||
trap: "#713b36",
|
||||
transport: "#53616b",
|
||||
camera: "#475f78",
|
||||
ritual: "#74518f",
|
||||
spellcaster: "#496e87",
|
||||
"spell-focus": "#5f698d",
|
||||
"aura-generator": "#586f80",
|
||||
destructible: "#6b5849",
|
||||
"quest-object": "#8a773b",
|
||||
generic: "#65584b",
|
||||
chair: "#725942",
|
||||
text: "#806c48",
|
||||
mailbox: "#596778",
|
||||
"fishing-hole": "#3d6878",
|
||||
trigger: "#58606a",
|
||||
};
|
||||
return { hidden: true, color: colors[kind] ?? "#65584b" };
|
||||
}
|
||||
|
||||
function compileSourceGameObjects(draft, mapPosition, recipe, gameObjectDisplays) {
|
||||
const templates = new Map((draft?.gameObjectTemplates ?? []).map((template) => [
|
||||
finiteNumber(rowValue(template, ["entry", "id"]), -1),
|
||||
template,
|
||||
]));
|
||||
return (draft?.gameObjects ?? [])
|
||||
.map((row, index) => {
|
||||
const templateId = finiteNumber(rowValue(row, ["id", "entry"]), -1);
|
||||
const template = templates.get(templateId);
|
||||
const type = finiteNumber(rowValue(template, ["type", "Type"]), -1);
|
||||
if (!template || !SOURCE_GAME_OBJECT_TYPES.has(type)) return null;
|
||||
const spawnId = finiteNumber(rowValue(row, ["guid", "spawnId"]), index + 1);
|
||||
const name = String(rowValue(template, ["name", "Name"], `Game object ${templateId}`)).trim()
|
||||
|| `Game object ${templateId}`;
|
||||
const kind = sourceGameObjectKind(type, name);
|
||||
const displayId = finiteNumber(rowValue(template, ["displayId", "displayID", "DisplayID"]), 0);
|
||||
const display = gameObjectDisplays.get(displayId);
|
||||
const sourceSize = rowValue(template, ["size", "Size"], 1);
|
||||
const clientHalfExtents = clientGameObjectHalfExtents(display, sourceSize);
|
||||
return {
|
||||
id: `gameobject-${spawnId}`,
|
||||
name,
|
||||
kind,
|
||||
position: mapPosition(sourceGameObjectPosition(row)),
|
||||
yaw: rounded(-finiteNumber(rowValue(row, ["orientation", "yaw"]))),
|
||||
halfExtents: clientHalfExtents ?? sourceGameObjectHalfExtents(kind, sourceSize),
|
||||
initialState: sourceGameObjectInitialState(kind, row),
|
||||
states: sourceGameObjectStates(kind),
|
||||
visual: sourceGameObjectVisual(kind),
|
||||
source: {
|
||||
fidelity: "source-backed-adapted",
|
||||
files: [
|
||||
`dungeon-pipeline/work/${recipe.slug}/source-snapshot.json`,
|
||||
`dungeon-pipeline/work/${recipe.slug}/runtime-draft.json`,
|
||||
],
|
||||
templateId,
|
||||
spawnId,
|
||||
gameObjectType: type,
|
||||
...(displayId > 0 ? { clientDisplayId: displayId } : {}),
|
||||
...(display?.modelPath ? { clientModelPath: display.modelPath } : {}),
|
||||
...(clientHalfExtents ? { clientBounds: display.bounds } : {}),
|
||||
note: clientHalfExtents
|
||||
? "Source transform/state and exact client display bounds/model path are preserved. Visual assets and collision remain hidden until the model export and instance-script transition are reviewed; interactions remain disabled until ported."
|
||||
: "Source position, orientation, type, initial state, and template scale are preserved. Visuals and collision remain hidden until client-display bounds/models are reviewed; encounter-script interactions remain disabled until ported.",
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.id.localeCompare(right.id, undefined, { numeric: true }));
|
||||
}
|
||||
|
||||
function increment(map, key, amount = 1) {
|
||||
map.set(key, (map.get(key) ?? 0) + amount);
|
||||
}
|
||||
|
||||
function sortedCountEntries(map, keyName) {
|
||||
return [...map.entries()]
|
||||
.sort(([left], [right]) => String(left).localeCompare(String(right), undefined, { numeric: true }))
|
||||
.map(([key, instances]) => ({ [keyName]: key, instances }));
|
||||
}
|
||||
|
||||
function gameObjectAssetManifestFor(definitions) {
|
||||
const assets = new Map();
|
||||
const nonVisualObjects = new Map();
|
||||
const unresolvedModels = new Map();
|
||||
const unresolvedBounds = new Map();
|
||||
let typedInstances = 0;
|
||||
let visualDisplayInstances = 0;
|
||||
let resolvedModelInstances = 0;
|
||||
let boundedInstances = 0;
|
||||
|
||||
for (const dungeon of definitions) {
|
||||
for (const object of dungeon.serverGameObjects ?? []) {
|
||||
if (object.source?.fidelity !== "source-backed-adapted") continue;
|
||||
typedInstances += 1;
|
||||
const displayId = Number(object.source.clientDisplayId);
|
||||
const modelPath = String(object.source.clientModelPath ?? "").replace(/\\/g, "/");
|
||||
const hasBounds = Boolean(object.source.clientBounds);
|
||||
if (!Number.isFinite(displayId) || displayId <= 0) {
|
||||
const templateId = Number(object.source.templateId) || 0;
|
||||
let nonVisual = nonVisualObjects.get(templateId);
|
||||
if (!nonVisual) {
|
||||
nonVisual = {
|
||||
templateId,
|
||||
instances: 0,
|
||||
dungeons: new Map(),
|
||||
kinds: new Map(),
|
||||
};
|
||||
nonVisualObjects.set(templateId, nonVisual);
|
||||
}
|
||||
nonVisual.instances += 1;
|
||||
increment(nonVisual.dungeons, dungeon.id);
|
||||
increment(nonVisual.kinds, object.kind);
|
||||
continue;
|
||||
}
|
||||
visualDisplayInstances += 1;
|
||||
if (hasBounds) boundedInstances += 1;
|
||||
|
||||
if (!modelPath) {
|
||||
const key = displayId;
|
||||
let unresolved = unresolvedModels.get(key);
|
||||
if (!unresolved) {
|
||||
unresolved = {
|
||||
displayId: key,
|
||||
instances: 0,
|
||||
dungeons: new Map(),
|
||||
kinds: new Map(),
|
||||
templateIds: new Set(),
|
||||
};
|
||||
unresolvedModels.set(key, unresolved);
|
||||
}
|
||||
unresolved.instances += 1;
|
||||
increment(unresolved.dungeons, dungeon.id);
|
||||
increment(unresolved.kinds, object.kind);
|
||||
if (object.source.templateId) unresolved.templateIds.add(object.source.templateId);
|
||||
} else {
|
||||
resolvedModelInstances += 1;
|
||||
const key = modelPath.toLowerCase();
|
||||
let asset = assets.get(key);
|
||||
if (!asset) {
|
||||
const extension = path.extname(modelPath).toLowerCase().replace(/^\./, "") || "unknown";
|
||||
const targetPath = modelPath.replace(/\.[^/.]+$/, ".glb");
|
||||
asset = {
|
||||
sourceModelPath: modelPath,
|
||||
sourceType: extension,
|
||||
targetUrl: `/assets/game/world-objects/${targetPath}`,
|
||||
instances: 0,
|
||||
boundedInstances: 0,
|
||||
displayIds: new Set(),
|
||||
dungeons: new Map(),
|
||||
kinds: new Map(),
|
||||
};
|
||||
assets.set(key, asset);
|
||||
}
|
||||
asset.instances += 1;
|
||||
if (hasBounds) asset.boundedInstances += 1;
|
||||
asset.displayIds.add(displayId);
|
||||
increment(asset.dungeons, dungeon.id);
|
||||
increment(asset.kinds, object.kind);
|
||||
}
|
||||
|
||||
if (!hasBounds) {
|
||||
const key = displayId;
|
||||
let unresolved = unresolvedBounds.get(key);
|
||||
if (!unresolved) {
|
||||
unresolved = {
|
||||
displayId: key,
|
||||
modelPath: modelPath || undefined,
|
||||
instances: 0,
|
||||
dungeons: new Map(),
|
||||
kinds: new Map(),
|
||||
templateIds: new Set(),
|
||||
};
|
||||
unresolvedBounds.set(key, unresolved);
|
||||
}
|
||||
unresolved.instances += 1;
|
||||
increment(unresolved.dungeons, dungeon.id);
|
||||
increment(unresolved.kinds, object.kind);
|
||||
if (object.source.templateId) unresolved.templateIds.add(object.source.templateId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const serializeUnresolved = (entry) => ({
|
||||
displayId: entry.displayId,
|
||||
...(entry.modelPath ? { modelPath: entry.modelPath } : {}),
|
||||
instances: entry.instances,
|
||||
templateIds: [...entry.templateIds].sort((left, right) => left - right),
|
||||
dungeons: sortedCountEntries(entry.dungeons, "dungeonId"),
|
||||
kinds: sortedCountEntries(entry.kinds, "kind"),
|
||||
});
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedBy: "scripts/dungeon-pipeline/compile-campaign-catalog.mjs",
|
||||
summary: {
|
||||
typedInstances,
|
||||
visualDisplayInstances,
|
||||
nonVisualInstances: typedInstances - visualDisplayInstances,
|
||||
resolvedModelInstances,
|
||||
boundedInstances,
|
||||
uniqueAssets: assets.size,
|
||||
unresolvedModelInstances: visualDisplayInstances - resolvedModelInstances,
|
||||
unresolvedBoundInstances: visualDisplayInstances - boundedInstances,
|
||||
unresolvedModelDisplayIds: unresolvedModels.size,
|
||||
unresolvedBoundDisplayIds: unresolvedBounds.size,
|
||||
},
|
||||
assets: [...assets.values()]
|
||||
.sort((left, right) => left.sourceModelPath.localeCompare(right.sourceModelPath))
|
||||
.map((asset) => ({
|
||||
sourceModelPath: asset.sourceModelPath,
|
||||
sourceType: asset.sourceType,
|
||||
targetUrl: asset.targetUrl,
|
||||
instances: asset.instances,
|
||||
boundedInstances: asset.boundedInstances,
|
||||
displayIds: [...asset.displayIds].sort((left, right) => left - right),
|
||||
dungeons: sortedCountEntries(asset.dungeons, "dungeonId"),
|
||||
kinds: sortedCountEntries(asset.kinds, "kind"),
|
||||
})),
|
||||
nonVisualObjects: [...nonVisualObjects.values()]
|
||||
.sort((left, right) => left.templateId - right.templateId)
|
||||
.map((entry) => ({
|
||||
templateId: entry.templateId,
|
||||
instances: entry.instances,
|
||||
dungeons: sortedCountEntries(entry.dungeons, "dungeonId"),
|
||||
kinds: sortedCountEntries(entry.kinds, "kind"),
|
||||
})),
|
||||
unresolvedModels: [...unresolvedModels.values()]
|
||||
.sort((left, right) => left.displayId - right.displayId)
|
||||
.map(serializeUnresolved),
|
||||
unresolvedBounds: [...unresolvedBounds.values()]
|
||||
.sort((left, right) => left.displayId - right.displayId)
|
||||
.map(serializeUnresolved),
|
||||
};
|
||||
}
|
||||
|
||||
function sourceBasisFor(recipe, environmentMode) {
|
||||
if (environmentMode !== "adt-hybrid") {
|
||||
@@ -371,13 +872,44 @@ function uniqueDraftEncounters(encounterRows, mapId) {
|
||||
...(resolvedEntry > 0 ? {} : { syntheticGroupEncounter: true }),
|
||||
};
|
||||
const key = `${creatureId}:${normalizedName(encounter.name)}`;
|
||||
if (!byIdentity.has(key)) byIdentity.set(key, encounter);
|
||||
const previous = byIdentity.get(key);
|
||||
if (!previous) {
|
||||
byIdentity.set(key, encounter);
|
||||
continue;
|
||||
}
|
||||
byIdentity.set(key, {
|
||||
...previous,
|
||||
modeIds: [...new Set([...previous.modeIds, ...encounter.modeIds])].sort((left, right) => left - right),
|
||||
sourceRowIds: [...new Set([
|
||||
...(previous.sourceRowIds ?? []),
|
||||
...(encounter.sourceRowIds ?? []),
|
||||
])].sort((left, right) => left - right),
|
||||
orderIndex: Math.min(previous.orderIndex, encounter.orderIndex),
|
||||
});
|
||||
}
|
||||
return [...byIdentity.values()].sort((left, right) => (
|
||||
left.orderIndex - right.orderIndex || left.encounterId - right.encounterId
|
||||
));
|
||||
}
|
||||
|
||||
function encounterSpawnMask(recipe, encounter, candidate) {
|
||||
const declaredMask = recipe.difficultyVariants.reduce(
|
||||
(mask, difficulty) => mask | Number(difficulty.spawnMask ?? 0),
|
||||
0,
|
||||
);
|
||||
const candidateMask = Number(candidate?.spawnMask ?? 0) & declaredMask;
|
||||
if (candidateMask > 0) return candidateMask;
|
||||
|
||||
const modeIds = new Set((encounter.modeIds ?? []).map(Number));
|
||||
const rowMask = recipe.difficultyVariants.reduce((mask, difficulty) => (
|
||||
difficulty.mapDifficultyId !== undefined
|
||||
&& modeIds.has(Number(difficulty.mapDifficultyId))
|
||||
? mask | Number(difficulty.spawnMask ?? 0)
|
||||
: mask
|
||||
), 0);
|
||||
return rowMask || declaredMask || 1;
|
||||
}
|
||||
|
||||
function archetypeFor(name) {
|
||||
const value = normalizedName(name);
|
||||
if (/murloc|murk|glub|fin/.test(value)) return "murloc";
|
||||
@@ -1032,6 +1564,12 @@ async function compile() {
|
||||
const epochSpells = await exists(epochSpellDbc)
|
||||
? parseSpells(await readFile(epochSpellDbc), wantedSpellIds)
|
||||
: ascensionSpells;
|
||||
const standardGameObjectDisplays = await exists(standardGameObjectDisplayDbc)
|
||||
? parseGameObjectDisplays(await readFile(standardGameObjectDisplayDbc))
|
||||
: new Map();
|
||||
const epochGameObjectDisplays = await exists(epochGameObjectDisplayDbc)
|
||||
? parseGameObjectDisplays(await readFile(epochGameObjectDisplayDbc))
|
||||
: standardGameObjectDisplays;
|
||||
|
||||
const previousOutput = await exists(outputFile)
|
||||
? await readJson(outputFile)
|
||||
@@ -1103,6 +1641,14 @@ async function compile() {
|
||||
?? draft?.spawns?.[0]?.position
|
||||
?? playerAnchor.position,
|
||||
);
|
||||
const serverGameObjects = compileSourceGameObjects(
|
||||
draft,
|
||||
mapPosition,
|
||||
recipe,
|
||||
recipe.clientBuild === "3.3.5a-Epoch"
|
||||
? epochGameObjectDisplays
|
||||
: standardGameObjectDisplays,
|
||||
);
|
||||
const sourceEntities = (draft?.entities ?? [])
|
||||
.filter((entity) => (
|
||||
!(recipe.slug === "wailing-caverns" && Number(entity.entry) === 5912)
|
||||
@@ -1230,6 +1776,11 @@ async function compile() {
|
||||
const usedBossSpawnIds = new Set();
|
||||
const bossObjectives = encounters.map((encounter, index) => {
|
||||
const candidate = bossCandidatesByEntry.get(Number(encounter.creatureId));
|
||||
const spawnMask = encounterSpawnMask(recipe, encounter, candidate);
|
||||
const declaredDifficultyMask = recipe.difficultyVariants.reduce(
|
||||
(mask, difficulty) => mask | Number(difficulty.spawnMask ?? 0),
|
||||
0,
|
||||
);
|
||||
const alpha = encounters.length <= 1 ? 1 : index / (encounters.length - 1);
|
||||
const routeAlpha = 0.18 + alpha * 0.82;
|
||||
const nativeRoutePosition = nativeRoutePositions.length
|
||||
@@ -1253,7 +1804,10 @@ async function compile() {
|
||||
name: encounter.name,
|
||||
position,
|
||||
yaw: candidate?.yaw ?? 0,
|
||||
...(spawnChance < 1 ? { optional: true } : {}),
|
||||
spawnMask,
|
||||
...(spawnChance < 1 || (spawnMask & declaredDifficultyMask) !== declaredDifficultyMask
|
||||
? { optional: true }
|
||||
: {}),
|
||||
...(encounter.eventOnly ? { eventOnly: true } : {}),
|
||||
};
|
||||
});
|
||||
@@ -1313,7 +1867,7 @@ async function compile() {
|
||||
entityId: boss.entityId,
|
||||
position: boss.position,
|
||||
yaw: boss.yaw,
|
||||
spawnMask: 1,
|
||||
spawnMask: boss.spawnMask,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1426,7 +1980,8 @@ async function compile() {
|
||||
bosses,
|
||||
objectiveOrder: bosses,
|
||||
navigationLinks: [],
|
||||
serverGameObjects: draft?.gameObjects ?? [],
|
||||
encounters: [compileBossProgressionEncounter(recipe, bosses, syntheticTrash)],
|
||||
serverGameObjects,
|
||||
provenance: {
|
||||
clientBuild: recipe.clientBuild,
|
||||
coordinateAlignment,
|
||||
@@ -1490,7 +2045,13 @@ async function compile() {
|
||||
};
|
||||
const serialized = JSON.stringify(output);
|
||||
const compressed = gzipSync(Buffer.from(serialized), { level: 9 });
|
||||
const gameObjectAssetManifest = gameObjectAssetManifestFor(definitions);
|
||||
await writeFile(outputFile, `${serialized}\n`, "utf8");
|
||||
await writeFile(
|
||||
gameObjectAssetManifestFile,
|
||||
`${JSON.stringify(gameObjectAssetManifest, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
compressedOutputFile,
|
||||
`// Generated by npm run dungeon:catalog. Do not edit.\nexport default "${compressed.toString("base64")}";\n`,
|
||||
@@ -1517,6 +2078,7 @@ async function compile() {
|
||||
status: "complete",
|
||||
output: path.relative(projectRoot, outputFile).replace(/\\/g, "/"),
|
||||
compressedOutput: path.relative(projectRoot, compressedOutputFile).replace(/\\/g, "/"),
|
||||
gameObjectAssetManifest: path.relative(projectRoot, gameObjectAssetManifestFile).replace(/\\/g, "/"),
|
||||
compressedBytes: compressed.length,
|
||||
dungeons: definitions.length,
|
||||
encounters: coverage.reduce((total, row) => total + row.encounters, 0),
|
||||
@@ -1525,6 +2087,11 @@ async function compile() {
|
||||
roamingPacks: coverage.reduce((total, row) => total + row.roamingPacks, 0),
|
||||
authoritativeDungeons: coverage.filter((row) => row.source === "azerothcore").length,
|
||||
fallbackDungeons: coverage.filter((row) => row.source === "procedural-fallback").length,
|
||||
gameObjectInstances: gameObjectAssetManifest.summary.typedInstances,
|
||||
nonVisualGameObjectInstances: gameObjectAssetManifest.summary.nonVisualInstances,
|
||||
gameObjectAssets: gameObjectAssetManifest.summary.uniqueAssets,
|
||||
unresolvedGameObjectModelInstances: gameObjectAssetManifest.summary.unresolvedModelInstances,
|
||||
unresolvedGameObjectBoundInstances: gameObjectAssetManifest.summary.unresolvedBoundInstances,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
clientRootForRecipe,
|
||||
@@ -10,24 +10,21 @@ import {
|
||||
recipesRoot,
|
||||
workRoot,
|
||||
} from "./lib.mjs";
|
||||
import { runWowExporter } from "./wow-export-tool.mjs";
|
||||
|
||||
async function runExporter(argumentsList) {
|
||||
const executable = path.resolve(projectRoot, "../wow335a/dungeon-export-work/tools/wow.export-0.2.19/wow.export.exe");
|
||||
if (!await exists(executable)) throw new Error("The patched portable wow.export executable was not found.");
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(executable, ["--disable-auto-update", ...argumentsList], {
|
||||
cwd: path.dirname(executable),
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`wow.export exited with code ${code}.`)));
|
||||
});
|
||||
async function resultMtime(output) {
|
||||
const resultFile = path.join(output, "automation-result.json");
|
||||
return await exists(resultFile) ? (await stat(resultFile)).mtimeMs : 0;
|
||||
}
|
||||
|
||||
async function checkedResult(slug, output) {
|
||||
async function checkedResult(slug, output, previousMtime) {
|
||||
const resultFile = path.join(output, "automation-result.json");
|
||||
if (!await exists(resultFile)) return { status: "blocked", dungeonId: slug, blockers: ["wow.export produced no automation-result.json."] };
|
||||
if (!await exists(resultFile)) return { status: "blocked", dungeonId: slug, blockers: [`wow.export produced no automation result at ${resultFile}.`] };
|
||||
if ((await stat(resultFile)).mtimeMs <= previousMtime) return {
|
||||
status: "blocked",
|
||||
dungeonId: slug,
|
||||
blockers: [`wow.export did not refresh ${resultFile}; refusing to accept a stale result from an earlier run.`],
|
||||
};
|
||||
const result = await readJson(resultFile);
|
||||
if (!result.ok) return {
|
||||
status: "blocked",
|
||||
@@ -43,13 +40,14 @@ export async function exportDungeonSource(slug) {
|
||||
const recipe = await loadRecipe(slug);
|
||||
const clientRoot = clientRootForRecipe(recipe);
|
||||
const output = path.join(workRoot, slug, "source-export");
|
||||
await runExporter([
|
||||
const previousMtime = await resultMtime(output);
|
||||
await runWowExporter([
|
||||
"--export-dungeon",
|
||||
"--recipe", path.join(recipesRoot, `${slug}.json`),
|
||||
"--client-root", clientRoot,
|
||||
"--output", output,
|
||||
]);
|
||||
const blocked = await checkedResult(slug, output);
|
||||
], { projectRoot });
|
||||
const blocked = await checkedResult(slug, output, previousMtime);
|
||||
if (blocked) return blocked;
|
||||
return { status: "green", dungeonId: slug, output: path.relative(projectRoot, output).replace(/\\/g, "/") };
|
||||
}
|
||||
@@ -58,12 +56,15 @@ export async function exportClientDiscoveryTables(slug) {
|
||||
const recipe = await loadRecipe(slug);
|
||||
const clientRoot = clientRootForRecipe(recipe);
|
||||
const output = path.join(workRoot, slug, "client-dbc");
|
||||
await runExporter([
|
||||
const previousMtime = await resultMtime(output);
|
||||
await runWowExporter([
|
||||
"--discover-dungeon",
|
||||
"--recipe", path.join(recipesRoot, `${slug}.json`),
|
||||
"--client-root", clientRoot,
|
||||
"--output", output,
|
||||
]);
|
||||
], { projectRoot });
|
||||
const blocked = await checkedResult(slug, output, previousMtime);
|
||||
if (blocked) throw new Error(blocked.blockers.join("\n"));
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -88,13 +89,14 @@ export async function exportCreatureSource(slug) {
|
||||
blockers: unresolved.map((creature) => `${creature.name ?? creature.slug} has an unresolved display/composite recipe.`),
|
||||
};
|
||||
const output = path.join(workRoot, slug, "creature-source");
|
||||
await runExporter([
|
||||
const previousMtime = await resultMtime(output);
|
||||
await runWowExporter([
|
||||
"--export-creatures",
|
||||
"--recipe", selected,
|
||||
"--client-root", clientRoot,
|
||||
"--output", output,
|
||||
]);
|
||||
const blocked = await checkedResult(slug, output);
|
||||
], { projectRoot });
|
||||
const blocked = await checkedResult(slug, output, previousMtime);
|
||||
if (blocked) return blocked;
|
||||
return { status: "green", dungeonId: slug, output: path.relative(projectRoot, output).replace(/\\/g, "/") };
|
||||
}
|
||||
@@ -45,6 +45,9 @@ function runNpm(script, argumentsList = []) {
|
||||
async function main() {
|
||||
if (options.has("--audit-only")) {
|
||||
await runNpm("dungeon:wow:audit");
|
||||
await runNpm("dungeon:wow:functionality:audit");
|
||||
await runNpm("dungeon:ascension:recovery:validate");
|
||||
await runNpm("dungeon:wow:export-tool:test");
|
||||
await runNpm("dungeon:wow:test");
|
||||
return;
|
||||
}
|
||||
@@ -63,8 +66,11 @@ async function main() {
|
||||
await runNpm("dungeon:catalog");
|
||||
await runNpm("dungeon:five-player:verify");
|
||||
await runNpm("dungeon:wow:audit");
|
||||
await runNpm("dungeon:wow:functionality:audit");
|
||||
await runNpm("dungeon:ascension:recovery:validate");
|
||||
await runNpm("dungeon:wow:export-tool:test");
|
||||
await runNpm("dungeon:wow:test");
|
||||
if (options.has("--build")) await runNpm("build");
|
||||
}
|
||||
|
||||
await main();
|
||||
await main();
|
||||
@@ -0,0 +1,300 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(scriptDirectory, "..", "..");
|
||||
const manifestPath = path.join(
|
||||
projectRoot,
|
||||
"dungeon-pipeline",
|
||||
"ascension",
|
||||
"ASCENSION_CUSTOM_DUNGEON_RECOVERY.json",
|
||||
);
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
fail(message);
|
||||
}
|
||||
}
|
||||
|
||||
function assertDeepEqual(actual, expected, message) {
|
||||
assert(JSON.stringify(actual) === JSON.stringify(expected), message);
|
||||
}
|
||||
|
||||
async function readJson(relativePath) {
|
||||
const contents = await readFile(path.join(projectRoot, relativePath), "utf8");
|
||||
return JSON.parse(contents);
|
||||
}
|
||||
|
||||
function resolveDbcPath(manifest) {
|
||||
const dbcArgumentIndex = process.argv.indexOf("--dbc");
|
||||
if (dbcArgumentIndex !== -1) {
|
||||
const value = process.argv[dbcArgumentIndex + 1];
|
||||
assert(value, "--dbc requires a path");
|
||||
return path.resolve(process.cwd(), value);
|
||||
}
|
||||
|
||||
if (process.env.ASCENSION_SPELL_DBC) {
|
||||
return path.resolve(process.env.ASCENSION_SPELL_DBC);
|
||||
}
|
||||
|
||||
const source = manifest.sources.find(
|
||||
(candidate) => candidate.id === "ascension-area52-spell-dbc",
|
||||
);
|
||||
assert(source?.pathHint, "Spell.dbc source path is missing from the manifest");
|
||||
return path.resolve(projectRoot, source.pathHint);
|
||||
}
|
||||
|
||||
function readCString(buffer, offset) {
|
||||
const end = buffer.indexOf(0, offset);
|
||||
assert(end !== -1, `Unterminated DBC string at byte ${offset}`);
|
||||
return buffer.toString("utf8", offset, end);
|
||||
}
|
||||
|
||||
async function validateSpellDbc(manifest) {
|
||||
if (process.argv.includes("--skip-dbc")) {
|
||||
return { skipped: true, reason: "requested with --skip-dbc" };
|
||||
}
|
||||
|
||||
const source = manifest.sources.find(
|
||||
(candidate) => candidate.id === "ascension-area52-spell-dbc",
|
||||
);
|
||||
assert(source, "Spell.dbc source metadata is missing");
|
||||
|
||||
const dbcPath = resolveDbcPath(manifest);
|
||||
let buffer;
|
||||
try {
|
||||
buffer = await readFile(dbcPath);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
return { skipped: true, reason: `not installed at ${dbcPath}` };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const digest = createHash("sha256").update(buffer).digest("hex");
|
||||
assert(digest === source.sha256, `Spell.dbc SHA-256 mismatch: ${digest}`);
|
||||
|
||||
const header = {
|
||||
signature: buffer.toString("ascii", 0, 4),
|
||||
recordCount: buffer.readUInt32LE(4),
|
||||
fieldCount: buffer.readUInt32LE(8),
|
||||
recordSize: buffer.readUInt32LE(12),
|
||||
stringBlockSize: buffer.readUInt32LE(16),
|
||||
};
|
||||
assertDeepEqual(header, source.header, "Spell.dbc header does not match the manifest");
|
||||
|
||||
const expectedNames = new Map();
|
||||
const summonLinks = new Map();
|
||||
for (const dungeon of manifest.dungeons) {
|
||||
for (const [spellId, name] of Object.entries(dungeon.sourceRecovery.spellRows)) {
|
||||
expectedNames.set(Number(spellId), name);
|
||||
}
|
||||
for (const link of dungeon.sourceRecovery.summonLinks ?? []) {
|
||||
const links = summonLinks.get(link.spellId) ?? [];
|
||||
links.push({ ...link, mapId: dungeon.mapId });
|
||||
summonLinks.set(link.spellId, links);
|
||||
}
|
||||
}
|
||||
|
||||
const recordStart = 20;
|
||||
const stringStart = recordStart + header.recordCount * header.recordSize;
|
||||
const expectedLength = stringStart + header.stringBlockSize;
|
||||
assert(
|
||||
expectedLength === buffer.length,
|
||||
`Spell.dbc length mismatch: expected ${expectedLength}, got ${buffer.length}`,
|
||||
);
|
||||
|
||||
const found = new Map();
|
||||
for (let recordIndex = 0; recordIndex < header.recordCount; recordIndex += 1) {
|
||||
const offset = recordStart + recordIndex * header.recordSize;
|
||||
const spellId = buffer.readUInt32LE(offset);
|
||||
if (!expectedNames.has(spellId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nameOffset = buffer.readUInt32LE(offset + 136 * 4);
|
||||
found.set(spellId, {
|
||||
name: readCString(buffer, stringStart + nameOffset),
|
||||
offset,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [spellId, expectedName] of expectedNames) {
|
||||
const row = found.get(spellId);
|
||||
assert(row, `Spell.dbc row ${spellId} is missing`);
|
||||
assert(
|
||||
row.name === expectedName,
|
||||
`Spell.dbc row ${spellId} name mismatch: expected ${JSON.stringify(expectedName)}, got ${JSON.stringify(row.name)}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [spellId, links] of summonLinks) {
|
||||
const row = found.get(spellId);
|
||||
assert(row, `Summon spell ${spellId} was not loaded`);
|
||||
for (const link of links) {
|
||||
const effectType = buffer.readUInt32LE(row.offset + (71 + link.effectIndex) * 4);
|
||||
const entryId = buffer.readInt32LE(row.offset + (110 + link.effectIndex) * 4);
|
||||
assert(
|
||||
effectType === link.effectType,
|
||||
`Map ${link.mapId} spell ${spellId} effect ${link.effectIndex} type mismatch: expected ${link.effectType}, got ${effectType}`,
|
||||
);
|
||||
assert(
|
||||
entryId === link.entryId,
|
||||
`Map ${link.mapId} spell ${spellId} effect ${link.effectIndex} entry mismatch: expected ${link.entryId}, got ${entryId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
skipped: false,
|
||||
path: dbcPath,
|
||||
spellRows: found.size,
|
||||
summonLinks: [...summonLinks.values()].flat().length,
|
||||
};
|
||||
}
|
||||
|
||||
function validateCatalogs(manifest, campaign, runtime, assets) {
|
||||
assert(manifest.schemaVersion === 1, "Unsupported recovery manifest schema");
|
||||
assert(manifest.status === "partial-authoritative-recovery", "Unexpected manifest status");
|
||||
assertDeepEqual(
|
||||
manifest.dungeons.map((dungeon) => dungeon.mapId),
|
||||
[821, 908, 1781],
|
||||
"Recovery manifest must contain exactly maps 821, 908, and 1781 in that order",
|
||||
);
|
||||
|
||||
let spellRows = 0;
|
||||
let summonLinks = 0;
|
||||
for (const dungeon of manifest.dungeons) {
|
||||
const definition = campaign.definitions.find(
|
||||
(candidate) => candidate.mapId === dungeon.mapId,
|
||||
);
|
||||
assert(definition, `Campaign definition for map ${dungeon.mapId} is missing`);
|
||||
assert(definition.id === dungeon.id, `Map ${dungeon.mapId} campaign ID drifted`);
|
||||
assert(definition.title === dungeon.title, `Map ${dungeon.mapId} title drifted`);
|
||||
|
||||
const assetPackage = assets.packages.find(
|
||||
(candidate) => candidate.mapId === dungeon.mapId,
|
||||
);
|
||||
assert(assetPackage, `Asset package for map ${dungeon.mapId} is missing`);
|
||||
assert(
|
||||
assetPackage.source.mapDirectory === dungeon.mapDirectory,
|
||||
`Map ${dungeon.mapId} client map directory drifted`,
|
||||
);
|
||||
assert(
|
||||
Object.values(assetPackage.source.checksums).includes(dungeon.environment.sourceHash),
|
||||
`Map ${dungeon.mapId} source checksum drifted`,
|
||||
);
|
||||
for (const kind of ["player", "trash", "boss"]) {
|
||||
const anchor = assetPackage.anchors.find((candidate) => candidate.kind === kind);
|
||||
assert(anchor, `Map ${dungeon.mapId} ${kind} anchor is missing`);
|
||||
assertDeepEqual(
|
||||
anchor.position,
|
||||
dungeon.environment.anchors[kind],
|
||||
`Map ${dungeon.mapId} ${kind} anchor drifted`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const encounter of dungeon.encounters) {
|
||||
const runtimeEncounter = runtime.encounters.find(
|
||||
(candidate) =>
|
||||
candidate.encounterId === encounter.encounterId &&
|
||||
candidate.mapIds.includes(dungeon.mapId) &&
|
||||
candidate.modeIds.includes(encounter.modeId),
|
||||
);
|
||||
assert(
|
||||
runtimeEncounter,
|
||||
`Map ${dungeon.mapId} encounter ${encounter.encounterId} mode ${encounter.modeId} is missing`,
|
||||
);
|
||||
assert(
|
||||
runtimeEncounter.name === encounter.name,
|
||||
`Encounter ${encounter.encounterId} name drifted`,
|
||||
);
|
||||
assert(
|
||||
runtimeEncounter.creatureId === encounter.creatureId,
|
||||
`Encounter ${encounter.encounterId} creature ID drifted`,
|
||||
);
|
||||
}
|
||||
|
||||
const bossEntity = definition.entities[dungeon.currentRuntime.bossEntityId];
|
||||
const bossCreatureId = dungeon.encounters[0].creatureId;
|
||||
assert(bossEntity, `Map ${dungeon.mapId} current boss entity is missing`);
|
||||
assert(
|
||||
bossEntity.combat.serverEntry === bossCreatureId,
|
||||
`Map ${dungeon.mapId} current boss server entry drifted`,
|
||||
);
|
||||
|
||||
const generatedTrash = definition.staticSpawns.filter((spawn) =>
|
||||
spawn.id.startsWith("generated-trash-"),
|
||||
);
|
||||
assert(
|
||||
generatedTrash.length === dungeon.currentRuntime.proceduralTrashSpawnCount,
|
||||
`Map ${dungeon.mapId} procedural trash count drifted`,
|
||||
);
|
||||
for (const proceduralEntity of dungeon.currentRuntime.proceduralMobEntries) {
|
||||
const entity = definition.entities[proceduralEntity.entityId];
|
||||
assert(entity, `Map ${dungeon.mapId} procedural entity ${proceduralEntity.entityId} is missing`);
|
||||
assert(
|
||||
entity.combat.serverEntry === proceduralEntity.serverEntry,
|
||||
`Map ${dungeon.mapId} procedural entity ${proceduralEntity.entityId} server entry drifted`,
|
||||
);
|
||||
}
|
||||
|
||||
const rowIds = new Set(
|
||||
Object.keys(dungeon.sourceRecovery.spellRows).map((spellId) => Number(spellId)),
|
||||
);
|
||||
spellRows += rowIds.size;
|
||||
for (const [family, familyRows] of Object.entries(dungeon.sourceRecovery.families)) {
|
||||
for (const spellId of familyRows) {
|
||||
assert(
|
||||
rowIds.has(spellId),
|
||||
`Map ${dungeon.mapId} family ${family} references absent spell ${spellId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const link of dungeon.sourceRecovery.summonLinks ?? []) {
|
||||
summonLinks += 1;
|
||||
assert(rowIds.has(link.spellId), `Map ${dungeon.mapId} summon spell ${link.spellId} is absent`);
|
||||
const targetCollection = link.targetKind === "gameobject" ? dungeon.gameObjects : dungeon.entities;
|
||||
assert(
|
||||
targetCollection?.some((target) => target.entryId === link.entryId),
|
||||
`Map ${dungeon.mapId} summon target ${link.targetKind} ${link.entryId} is absent`,
|
||||
);
|
||||
}
|
||||
|
||||
assert(
|
||||
dungeon.adaptation.mustBeCustomAuthored.length > 0,
|
||||
`Map ${dungeon.mapId} must explicitly list custom-authored gaps`,
|
||||
);
|
||||
}
|
||||
|
||||
return { dungeons: manifest.dungeons.length, spellRows, summonLinks };
|
||||
}
|
||||
|
||||
const manifest = await readJson(
|
||||
"dungeon-pipeline/ascension/ASCENSION_CUSTOM_DUNGEON_RECOVERY.json",
|
||||
);
|
||||
const campaign = await readJson("src/game/generated/dungeonCampaignCatalog.json");
|
||||
const runtime = await readJson("src/game/generated/manastormRuntimeCatalog.json");
|
||||
const assets = await readJson("src/game/generated/manastormAssetPackages.json");
|
||||
|
||||
const catalogResult = validateCatalogs(manifest, campaign, runtime, assets);
|
||||
const dbcResult = await validateSpellDbc(manifest);
|
||||
|
||||
console.log(
|
||||
`Validated ${catalogResult.dungeons} Ascension custom dungeons, ${catalogResult.spellRows} recovered spell rows, and ${catalogResult.summonLinks} summon links.`,
|
||||
);
|
||||
if (dbcResult.skipped) {
|
||||
console.log(`Spell.dbc verification skipped: ${dbcResult.reason}.`);
|
||||
} else {
|
||||
console.log(
|
||||
`Spell.dbc verified (${dbcResult.spellRows} rows, ${dbcResult.summonLinks} summon links): ${dbcResult.path}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { spawn as spawnChild } from "node:child_process";
|
||||
import { stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export const WOW_EXPORT_AUTOMATION_VERSION = "0.2.19";
|
||||
export const WOW_EXPORT_REQUIRED_FLAGS = Object.freeze([
|
||||
"--disable-auto-update",
|
||||
"--discover-dungeon",
|
||||
"--export-dungeon",
|
||||
"--export-creatures",
|
||||
]);
|
||||
|
||||
export function defaultWowExportExecutable(projectRoot) {
|
||||
return path.resolve(
|
||||
projectRoot,
|
||||
`../wow335a/dungeon-export-work/tools/wow.export-${WOW_EXPORT_AUTOMATION_VERSION}/wow.export.exe`,
|
||||
);
|
||||
}
|
||||
|
||||
async function inspectCandidate(file, statFile) {
|
||||
try {
|
||||
const info = await statFile(file);
|
||||
return info.isFile()
|
||||
? { usable: true, reason: null }
|
||||
: { usable: false, reason: "path exists but is not a file" };
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return { usable: false, reason: "file does not exist" };
|
||||
return { usable: false, reason: `cannot inspect file (${error?.code ?? error?.message ?? "unknown error"})` };
|
||||
}
|
||||
}
|
||||
|
||||
function unavailableMessage(checks, configured) {
|
||||
const checked = checks.map(({ label, file, reason }) => ` - ${label}: ${file} (${reason})`).join("\n");
|
||||
const selection = configured
|
||||
? "WOW_EXPORT_BIN is set, so the default location was not used. Correct or unset that variable."
|
||||
: "Restore the patched portable bundle at the default location, or set WOW_EXPORT_BIN to its exact executable path.";
|
||||
return [
|
||||
"HealerMan's patched wow.export automation bridge is unavailable.",
|
||||
`Required build: wow.export ${WOW_EXPORT_AUTOMATION_VERSION} with these automation flags: ${WOW_EXPORT_REQUIRED_FLAGS.join(", ")}.`,
|
||||
"Checked:",
|
||||
checked,
|
||||
selection,
|
||||
"A stock GUI-only wow.export executable is not a drop-in replacement unless it includes the HealerMan automation patch.",
|
||||
"No export was started and no placeholder assets were created.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export class WowExportUnavailableError extends Error {
|
||||
constructor(checks, configured) {
|
||||
super(unavailableMessage(checks, configured));
|
||||
this.name = "WowExportUnavailableError";
|
||||
this.code = "WOW_EXPORT_AUTOMATION_UNAVAILABLE";
|
||||
this.checks = checks;
|
||||
this.requiredVersion = WOW_EXPORT_AUTOMATION_VERSION;
|
||||
this.requiredFlags = [...WOW_EXPORT_REQUIRED_FLAGS];
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveWowExportExecutable({
|
||||
projectRoot,
|
||||
environment = process.env,
|
||||
statFile = stat,
|
||||
} = {}) {
|
||||
if (!projectRoot) throw new TypeError("resolveWowExportExecutable requires projectRoot.");
|
||||
const configuredValue = environment.WOW_EXPORT_BIN?.trim();
|
||||
const candidates = configuredValue
|
||||
? [{ label: "WOW_EXPORT_BIN", file: path.resolve(projectRoot, configuredValue) }]
|
||||
: [{ label: "default portable path", file: defaultWowExportExecutable(projectRoot) }];
|
||||
const checks = [];
|
||||
for (const candidate of candidates) {
|
||||
const inspection = await inspectCandidate(candidate.file, statFile);
|
||||
if (inspection.usable) return candidate.file;
|
||||
checks.push({ ...candidate, reason: inspection.reason });
|
||||
}
|
||||
throw new WowExportUnavailableError(checks, Boolean(configuredValue));
|
||||
}
|
||||
|
||||
function exporterFailureMessage(executable, argumentsList, detail) {
|
||||
const operation = argumentsList.find((argument) => argument.startsWith("--")) ?? "unknown operation";
|
||||
return [
|
||||
`wow.export automation failed during ${operation}: ${detail}`,
|
||||
`Executable: ${executable}`,
|
||||
`Required automation flags: ${WOW_EXPORT_REQUIRED_FLAGS.join(", ")}.`,
|
||||
"Verify that WOW_EXPORT_BIN points to the HealerMan-patched build, not the stock GUI-only release.",
|
||||
"No placeholder assets were created.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function runWowExporter(argumentsList, {
|
||||
projectRoot,
|
||||
environment = process.env,
|
||||
statFile = stat,
|
||||
spawn = spawnChild,
|
||||
} = {}) {
|
||||
const executable = await resolveWowExportExecutable({ projectRoot, environment, statFile });
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(executable, ["--disable-auto-update", ...argumentsList], {
|
||||
cwd: path.dirname(executable),
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
let settled = false;
|
||||
const fail = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
};
|
||||
child.once("error", (error) => fail(new Error(exporterFailureMessage(
|
||||
executable,
|
||||
argumentsList,
|
||||
`process could not start (${error?.code ?? error?.message ?? "unknown error"})`,
|
||||
))));
|
||||
child.once("exit", (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(exporterFailureMessage(
|
||||
executable,
|
||||
argumentsList,
|
||||
signal ? `process exited after signal ${signal}` : `process exited with code ${code}`,
|
||||
)));
|
||||
});
|
||||
});
|
||||
return executable;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
defaultWowExportExecutable,
|
||||
resolveWowExportExecutable,
|
||||
runWowExporter,
|
||||
WOW_EXPORT_REQUIRED_FLAGS,
|
||||
} from "./wow-export-tool.mjs";
|
||||
|
||||
const projectRoot = path.resolve("fixture/HealerMan");
|
||||
const fileInfo = { isFile: () => true };
|
||||
|
||||
test("resolveWowExportExecutable uses the versioned portable path by default", async () => {
|
||||
const expected = defaultWowExportExecutable(projectRoot);
|
||||
const inspected = [];
|
||||
const resolved = await resolveWowExportExecutable({
|
||||
projectRoot,
|
||||
environment: {},
|
||||
statFile: async (file) => {
|
||||
inspected.push(file);
|
||||
return fileInfo;
|
||||
},
|
||||
});
|
||||
assert.equal(resolved, expected);
|
||||
assert.deepEqual(inspected, [expected]);
|
||||
});
|
||||
|
||||
test("resolveWowExportExecutable honors an explicit relative WOW_EXPORT_BIN", async () => {
|
||||
const expected = path.resolve(projectRoot, "tools/patched/wow.export.exe");
|
||||
const resolved = await resolveWowExportExecutable({
|
||||
projectRoot,
|
||||
environment: { WOW_EXPORT_BIN: "tools/patched/wow.export.exe" },
|
||||
statFile: async (file) => {
|
||||
assert.equal(file, expected);
|
||||
return fileInfo;
|
||||
},
|
||||
});
|
||||
assert.equal(resolved, expected);
|
||||
});
|
||||
|
||||
test("resolveWowExportExecutable rejects a directory with actionable diagnostics", async () => {
|
||||
const configured = path.resolve(projectRoot, "tools/not-an-executable");
|
||||
await assert.rejects(
|
||||
resolveWowExportExecutable({
|
||||
projectRoot,
|
||||
environment: { WOW_EXPORT_BIN: configured },
|
||||
statFile: async () => ({ isFile: () => false }),
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error.code, "WOW_EXPORT_AUTOMATION_UNAVAILABLE");
|
||||
assert.match(error.message, /path exists but is not a file/);
|
||||
assert.match(error.message, /Correct or unset that variable/);
|
||||
assert.match(error.message, /No export was started and no placeholder assets were created/);
|
||||
for (const flag of WOW_EXPORT_REQUIRED_FLAGS) assert.ok(error.message.includes(flag));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveWowExportExecutable reports the exact missing default path", async () => {
|
||||
const expected = defaultWowExportExecutable(projectRoot);
|
||||
await assert.rejects(
|
||||
resolveWowExportExecutable({
|
||||
projectRoot,
|
||||
environment: {},
|
||||
statFile: async () => {
|
||||
const error = new Error("missing");
|
||||
error.code = "ENOENT";
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
(error) => {
|
||||
assert.ok(error.message.includes(expected));
|
||||
assert.match(error.message, /set WOW_EXPORT_BIN/);
|
||||
assert.match(error.message, /stock GUI-only wow\.export executable is not a drop-in replacement/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("runWowExporter supplies the non-updating automation invocation", async () => {
|
||||
const executable = path.resolve(projectRoot, "tools/wow.export.exe");
|
||||
let invocation;
|
||||
const spawn = (file, args, options) => {
|
||||
invocation = { file, args, options };
|
||||
const child = new EventEmitter();
|
||||
queueMicrotask(() => child.emit("exit", 0, null));
|
||||
return child;
|
||||
};
|
||||
const result = await runWowExporter(["--export-dungeon", "--recipe", "fixture.json"], {
|
||||
projectRoot,
|
||||
environment: { WOW_EXPORT_BIN: executable },
|
||||
statFile: async () => fileInfo,
|
||||
spawn,
|
||||
});
|
||||
assert.equal(result, executable);
|
||||
assert.deepEqual(invocation.args, ["--disable-auto-update", "--export-dungeon", "--recipe", "fixture.json"]);
|
||||
assert.equal(invocation.options.cwd, path.dirname(executable));
|
||||
assert.equal(invocation.options.windowsHide, true);
|
||||
});
|
||||
|
||||
test("runWowExporter identifies an incompatible executable after a failed launch", async () => {
|
||||
const executable = path.resolve(projectRoot, "tools/wow.export.exe");
|
||||
const spawn = () => {
|
||||
const child = new EventEmitter();
|
||||
queueMicrotask(() => child.emit("exit", 2, null));
|
||||
return child;
|
||||
};
|
||||
await assert.rejects(
|
||||
runWowExporter(["--export-creatures"], {
|
||||
projectRoot,
|
||||
environment: { WOW_EXPORT_BIN: executable },
|
||||
statFile: async () => fileInfo,
|
||||
spawn,
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /process exited with code 2/);
|
||||
assert.match(error.message, /HealerMan-patched build/);
|
||||
assert.match(error.message, /No placeholder assets were created/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
+23
-12
@@ -75,6 +75,18 @@ class ReleaseVersion:
|
||||
version_code: int
|
||||
|
||||
|
||||
def full_release_checks(npm: str) -> tuple[tuple[list[str], str], ...]:
|
||||
"""Commands that must succeed before a release can be staged."""
|
||||
return (
|
||||
([npm, "run", "content:test"], "Content tests"),
|
||||
([npm, "run", "server:test"], "Server tests"),
|
||||
([npm, "test"], "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"),
|
||||
)
|
||||
|
||||
|
||||
def parse_semver(value: str) -> tuple[int, int, int]:
|
||||
match = SEMVER_PATTERN.fullmatch(value.strip())
|
||||
if not match:
|
||||
@@ -239,11 +251,13 @@ def validate_release_candidates(project_root: Path = PROJECT_ROOT) -> list[str]:
|
||||
cwd=project_root,
|
||||
env=environment,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
output = result.stdout.decode("utf-8", errors="replace").strip()
|
||||
output = b"\n".join((result.stdout, result.stderr)).decode(
|
||||
"utf-8", errors="replace"
|
||||
).strip()
|
||||
raise ReleaseError(
|
||||
f"Release-candidate Git check failed ({result.returncode}): "
|
||||
f"git {' '.join(args)}\n{output}"
|
||||
@@ -269,11 +283,15 @@ def validate_release_candidates(project_root: Path = PROJECT_ROOT) -> list[str]:
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
if whitespace_result.returncode != 0:
|
||||
detail = whitespace_result.stdout.strip()
|
||||
detail = "\n".join(
|
||||
part.strip()
|
||||
for part in (whitespace_result.stdout, whitespace_result.stderr)
|
||||
if part.strip()
|
||||
)
|
||||
raise ReleaseError(
|
||||
"Release-candidate whitespace check failed. This check includes "
|
||||
f"tracked and untracked files:\n{detail}"
|
||||
@@ -1096,14 +1114,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
f"Recorded Android version {version.version_name} / {version.version_code}."
|
||||
)
|
||||
|
||||
checks = (
|
||||
([npm, "run", "content:test"], "Content tests"),
|
||||
([npm, "run", "server:test"], "Server tests"),
|
||||
([npm, "test"], "Application tests"),
|
||||
([npm, "run", "build"], "Production build"),
|
||||
([npm, "run", "android:sync"], "Android web build and Capacitor sync"),
|
||||
)
|
||||
for command, label in checks:
|
||||
for command, label in full_release_checks(npm):
|
||||
self._run_command(command, label)
|
||||
self._run_command(["git", "add", "-A"], "Stage release")
|
||||
staged = True
|
||||
|
||||
@@ -47,6 +47,13 @@ class SafetyTests(unittest.TestCase):
|
||||
self.assertIn("pull --ff-only", block)
|
||||
self.assertIn('test "$ACTUAL_COMMIT" = "$EXPECTED_COMMIT"', block)
|
||||
|
||||
def test_full_release_publishes_verified_content_before_building(self):
|
||||
checks = release_manager.full_release_checks("npm")
|
||||
commands = [command for command, _label in checks]
|
||||
publish_index = commands.index(["npm", "run", "content:publish"])
|
||||
self.assertLess(publish_index, commands.index(["npm", "run", "build"]))
|
||||
self.assertLess(publish_index, commands.index(["npm", "run", "android:sync"]))
|
||||
|
||||
|
||||
class ReleaseCandidateTests(unittest.TestCase):
|
||||
def test_checks_untracked_files_without_changing_the_real_index(self):
|
||||
|
||||
+21
-4
@@ -6,8 +6,14 @@ import { useManastormAdminStore } from "./game/manastormAdminStore";
|
||||
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
||||
import { LoginScreen } from "./ui/LoginScreen";
|
||||
import { OnlineGroupCoordinator } from "./app/OnlineGroupCoordinator";
|
||||
import { preloadTalentGameplayResources } from "./ui/talentGameplayResources";
|
||||
import {
|
||||
getLoadedGameRuntime,
|
||||
loadGameRuntime,
|
||||
preloadSharedGameplayResources,
|
||||
} from "./ui/gameplayResources";
|
||||
|
||||
const GameRuntime = lazy(() => import("./GameRuntime"));
|
||||
const GameRuntime = lazy(loadGameRuntime);
|
||||
const CharacterCreateScreen = lazy(() => import("./ui/CharacterCreateScreen").then((module) => ({ default: module.CharacterCreateScreen })));
|
||||
const CharacterSelectScreen = lazy(() => import("./ui/CharacterSelectScreen").then((module) => ({ default: module.CharacterSelectScreen })));
|
||||
const ClassHallScreen = lazy(() => import("./ui/ClassHallScreen").then((module) => ({ default: module.ClassHallScreen })));
|
||||
@@ -32,7 +38,11 @@ function RuntimeCatalogGate({ children }: { readonly children: ReactNode }) {
|
||||
if (definition.mode === "conquest") return [...BASE_CLASS_IDS, character.classId];
|
||||
return [character.classId, ...(character.secondaryClassId ? [character.secondaryClassId] : [])];
|
||||
}, [character]);
|
||||
const talentClassId = character && !character.classId.startsWith("rom-")
|
||||
? character.classId
|
||||
: null;
|
||||
const requestKey = classIds.join(":");
|
||||
const originalMediaEnabled = useShellStore((state) => state.combatPresentationSettings.originalMediaEnabled);
|
||||
const [loadedKey, setLoadedKey] = useState("");
|
||||
const [failedKey, setFailedKey] = useState("");
|
||||
|
||||
@@ -40,13 +50,17 @@ function RuntimeCatalogGate({ children }: { readonly children: ReactNode }) {
|
||||
if (!requestKey) return;
|
||||
let active = true;
|
||||
setFailedKey("");
|
||||
void preloadAbilityCatalogsForClasses(classIds).then(() => {
|
||||
void Promise.all([
|
||||
preloadAbilityCatalogsForClasses(classIds),
|
||||
talentClassId ? preloadTalentGameplayResources(talentClassId) : Promise.resolve(),
|
||||
preloadSharedGameplayResources(originalMediaEnabled),
|
||||
]).then(() => {
|
||||
if (active) setLoadedKey(requestKey);
|
||||
}).catch(() => {
|
||||
if (active) setFailedKey(requestKey);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [classIds, requestKey]);
|
||||
}, [classIds, originalMediaEnabled, requestKey, talentClassId]);
|
||||
|
||||
if (!requestKey || loadedKey === requestKey) return children;
|
||||
if (failedKey === requestKey) {
|
||||
@@ -61,6 +75,7 @@ export default function App() {
|
||||
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);
|
||||
@@ -77,7 +92,9 @@ export default function App() {
|
||||
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 (phase === "game") screen = <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>;
|
||||
else if (phase === "game") screen = PreloadedGameRuntime
|
||||
? <PreloadedGameRuntime />
|
||||
: <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>;
|
||||
const requiresRuntimeCatalog = !["login", "characters", "create-character"].includes(phase);
|
||||
return <><OnlineGroupCoordinator />{requiresRuntimeCatalog ? <RuntimeCatalogGate>{screen}</RuntimeCatalogGate> : screen}</>;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OnlineGroupActivity } from "./onlineGroupTypes";
|
||||
import { enterOnlineGroupActivity } from "./OnlineGroupCoordinator";
|
||||
|
||||
const DUNGEON_ACTIVITY: OnlineGroupActivity = {
|
||||
id: "activity-dungeon-1",
|
||||
type: "dungeon",
|
||||
contentId: "deadmines",
|
||||
fillWithAi: true,
|
||||
partySize: 5,
|
||||
startingLevel: null,
|
||||
startedAt: 1,
|
||||
};
|
||||
|
||||
function entryDependencies(enterDungeon: () => boolean) {
|
||||
return {
|
||||
installDungeonRuntime: vi.fn(async () => undefined),
|
||||
enterDungeon: vi.fn(enterDungeon),
|
||||
installManastormRuntime: vi.fn(async () => undefined),
|
||||
enterManastorm: vi.fn(() => true),
|
||||
};
|
||||
}
|
||||
|
||||
describe("online group activity entry", () => {
|
||||
it("leaves a dungeon activity unconsumed when dungeon entry fails", async () => {
|
||||
const consumeActivity = vi.fn();
|
||||
const dependencies = entryDependencies(() => false);
|
||||
|
||||
await expect(enterOnlineGroupActivity(
|
||||
DUNGEON_ACTIVITY,
|
||||
"healer",
|
||||
consumeActivity,
|
||||
dependencies,
|
||||
)).resolves.toBe(false);
|
||||
|
||||
expect(dependencies.installDungeonRuntime).toHaveBeenCalledOnce();
|
||||
expect(dependencies.enterDungeon).toHaveBeenCalledWith("deadmines", "healer");
|
||||
expect(consumeActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can retry the same dungeon activity and consumes it only after entry succeeds", async () => {
|
||||
const consumeActivity = vi.fn();
|
||||
const enterDungeon = vi.fn()
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(true);
|
||||
const dependencies = entryDependencies(enterDungeon);
|
||||
|
||||
await enterOnlineGroupActivity(DUNGEON_ACTIVITY, "tank", consumeActivity, dependencies);
|
||||
expect(consumeActivity).not.toHaveBeenCalled();
|
||||
|
||||
await expect(enterOnlineGroupActivity(
|
||||
DUNGEON_ACTIVITY,
|
||||
"tank",
|
||||
consumeActivity,
|
||||
dependencies,
|
||||
)).resolves.toBe(true);
|
||||
|
||||
expect(dependencies.enterDungeon).toHaveBeenCalledTimes(2);
|
||||
expect(consumeActivity).toHaveBeenCalledOnce();
|
||||
expect(consumeActivity).toHaveBeenCalledWith(DUNGEON_ACTIVITY.id);
|
||||
});
|
||||
|
||||
it("does not consume the activity when loading the dungeon runtime throws", async () => {
|
||||
const consumeActivity = vi.fn();
|
||||
const dependencies = entryDependencies(() => true);
|
||||
dependencies.installDungeonRuntime.mockRejectedValueOnce(new Error("runtime unavailable"));
|
||||
|
||||
await expect(enterOnlineGroupActivity(
|
||||
DUNGEON_ACTIVITY,
|
||||
"damage",
|
||||
consumeActivity,
|
||||
dependencies,
|
||||
)).rejects.toThrow("runtime unavailable");
|
||||
|
||||
expect(dependencies.enterDungeon).not.toHaveBeenCalled();
|
||||
expect(consumeActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { selectedCharacter, useShellStore } from "./shellStore";
|
||||
import { useOnlineGroupStore } from "./onlineGroupStore";
|
||||
import {
|
||||
@@ -6,11 +6,56 @@ import {
|
||||
defaultPartyRoleForClass,
|
||||
type PartyRole,
|
||||
} from "../game/partyRoles";
|
||||
import { isManastormPartySize } from "../game/manastormProgress";
|
||||
import { isManastormPartySize, type ManastormPartySize } from "../game/manastormProgress";
|
||||
import type { OnlineGroupActivity } from "./onlineGroupTypes";
|
||||
import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||
|
||||
const GROUP_POLL_MS = 2_500;
|
||||
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
interface OnlineGroupActivityEntryDependencies {
|
||||
readonly installDungeonRuntime: () => Promise<void>;
|
||||
readonly enterDungeon: (dungeonId: string, role?: PartyRole) => boolean;
|
||||
readonly installManastormRuntime: () => Promise<void>;
|
||||
readonly enterManastorm: (role?: PartyRole, partySize?: ManastormPartySize, startingLevel?: number) => boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_ACTIVITY_ENTRY_DEPENDENCIES: OnlineGroupActivityEntryDependencies = {
|
||||
installDungeonRuntime: async () => {
|
||||
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
|
||||
installDungeonShellRuntime();
|
||||
},
|
||||
enterDungeon: (dungeonId, role) => useShellStore.getState().enterDungeon(dungeonId, role),
|
||||
installManastormRuntime: async () => {
|
||||
const { installManastormShellRuntime } = await import("./manastormShellRuntime");
|
||||
installManastormShellRuntime();
|
||||
},
|
||||
enterManastorm: (role, partySize, startingLevel) => useShellStore.getState().enterManastorm(
|
||||
role,
|
||||
partySize,
|
||||
startingLevel,
|
||||
),
|
||||
};
|
||||
|
||||
export async function enterOnlineGroupActivity(
|
||||
activity: OnlineGroupActivity,
|
||||
role: PartyRole,
|
||||
consumeActivity: (activityId: string) => void,
|
||||
dependencies: OnlineGroupActivityEntryDependencies = DEFAULT_ACTIVITY_ENTRY_DEPENDENCIES,
|
||||
): Promise<boolean> {
|
||||
let entered: boolean;
|
||||
if (activity.type === "dungeon") {
|
||||
await dependencies.installDungeonRuntime();
|
||||
entered = dependencies.enterDungeon(activity.contentId, role);
|
||||
} else {
|
||||
await dependencies.installManastormRuntime();
|
||||
const partySize = isManastormPartySize(activity.partySize) ? activity.partySize : 5;
|
||||
entered = dependencies.enterManastorm(role, partySize, activity.startingLevel ?? undefined);
|
||||
}
|
||||
if (entered) consumeActivity(activity.id);
|
||||
return entered;
|
||||
}
|
||||
|
||||
export function OnlineGroupCoordinator() {
|
||||
const session = useShellStore((state) => state.session);
|
||||
const character = useShellStore(selectedCharacter);
|
||||
@@ -21,6 +66,8 @@ export function OnlineGroupCoordinator() {
|
||||
const updateMember = useOnlineGroupStore((state) => state.updateMember);
|
||||
const consumeActivity = useOnlineGroupStore((state) => state.consumeActivity);
|
||||
const reset = useOnlineGroupStore((state) => state.reset);
|
||||
const { installedRevision } = useContentManagerSnapshot();
|
||||
const pendingActivityIds = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || session.kind !== "account" || !session.accessToken) {
|
||||
@@ -60,26 +107,16 @@ export function OnlineGroupCoordinator() {
|
||||
if (Date.now() - activity.startedAt > ACTIVITY_JOIN_WINDOW_MS) return;
|
||||
const member = snapshot.group?.members.find((candidate) => candidate.accountId === session.ownerId);
|
||||
if (!member?.character || !member.role) return;
|
||||
consumeActivity(activity.id);
|
||||
void (async () => {
|
||||
if (activity.type === "dungeon") {
|
||||
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
|
||||
installDungeonShellRuntime();
|
||||
useShellStore.getState().enterDungeon(activity.contentId, member.role ?? undefined);
|
||||
return;
|
||||
}
|
||||
const { installManastormShellRuntime } = await import("./manastormShellRuntime");
|
||||
installManastormShellRuntime();
|
||||
const partySize = isManastormPartySize(activity.partySize) ? activity.partySize : 5;
|
||||
useShellStore.getState().enterManastorm(
|
||||
member.role ?? undefined,
|
||||
partySize,
|
||||
activity.startingLevel ?? undefined,
|
||||
);
|
||||
})().catch((error) => {
|
||||
useShellStore.getState().setNotice(error instanceof Error ? error.message : "The group activity could not be opened.");
|
||||
});
|
||||
}, [consumeActivity, consumedActivityIds, phase, session?.ownerId, snapshot.group?.activity?.id]);
|
||||
if (pendingActivityIds.current.has(activity.id)) return;
|
||||
pendingActivityIds.current.add(activity.id);
|
||||
void enterOnlineGroupActivity(activity, member.role, consumeActivity)
|
||||
.catch((error) => {
|
||||
useShellStore.getState().setNotice(error instanceof Error ? error.message : "The group activity could not be opened.");
|
||||
})
|
||||
.finally(() => {
|
||||
pendingActivityIds.current.delete(activity.id);
|
||||
});
|
||||
}, [consumeActivity, consumedActivityIds, installedRevision, phase, session?.ownerId, snapshot.group?.activity?.id]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useShellStore } from "./shellStore";
|
||||
import { registerDungeonShellRuntime } from "./dungeonShellRuntimeBridge";
|
||||
import { touchCharacter } from "./accountRepository";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import { dungeonContentIsUsable } from "../content/contentManager";
|
||||
|
||||
export function installDungeonShellRuntime(): void {
|
||||
registerDungeonShellRuntime({ enterDungeon });
|
||||
@@ -22,6 +23,10 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
|
||||
useShellStore.setState({ notice: "That dungeon is not available yet." });
|
||||
return false;
|
||||
}
|
||||
if (!dungeonContentIsUsable(dungeonId)) {
|
||||
useShellStore.setState({ notice: "Install this expedition from Downloads before entering." });
|
||||
return false;
|
||||
}
|
||||
const { session, selectedCharacterId, characters } = useShellStore.getState();
|
||||
if (!session || !selectedCharacterId) {
|
||||
useShellStore.setState({ notice: "Select or create a character first." });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDefaultAppearance } from "./characterCatalog";
|
||||
import { useShellStore } from "./shellStore";
|
||||
import { installDungeonShellRuntime } from "./dungeonShellRuntime";
|
||||
@@ -79,6 +79,10 @@ function prepareSelectedCharacter(): void {
|
||||
}
|
||||
|
||||
describe("shell store", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useShellStore.setState({
|
||||
phase: "login",
|
||||
@@ -167,6 +171,17 @@ describe("shell store", () => {
|
||||
expect(useGameStore.getState().distanceTravelled).toBe(42);
|
||||
});
|
||||
|
||||
it("rejects a shipped dungeon whose production content pack is not usable", () => {
|
||||
vi.stubEnv("DEV", false);
|
||||
prepareSelectedCharacter();
|
||||
useShellStore.getState().openMainMenu();
|
||||
useShellStore.getState().openDungeons();
|
||||
|
||||
expect(useShellStore.getState().enterDungeon("deadmines", "healer")).toBe(false);
|
||||
expect(useShellStore.getState().phase).toBe("dungeons");
|
||||
expect(useShellStore.getState().notice).toMatch(/downloads/i);
|
||||
});
|
||||
|
||||
it("returns from gameplay to the main menu with the same character selected", () => {
|
||||
prepareSelectedCharacter();
|
||||
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id)).toBe(true);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
contentPackIsBundled,
|
||||
contentPackIsInstalled,
|
||||
contentPackIsUsable,
|
||||
dungeonContentIsUsable,
|
||||
getContentManagerSnapshot,
|
||||
installContentPack,
|
||||
removeContentPack,
|
||||
@@ -81,6 +82,7 @@ function contentManifest(version: string, objectName: string, bytes: Uint8Array)
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("browser content packs", () => {
|
||||
@@ -122,6 +124,7 @@ describe("browser content packs", () => {
|
||||
|
||||
manifest = contentManifest("content-two", "updated.glb", updatedBytes);
|
||||
await checkForContentUpdates();
|
||||
expect(contentPackIsUsable("characters:test")).toBe(false);
|
||||
expect(contentPackHasUpdate("characters:test")).toBe(true);
|
||||
|
||||
corruptUpdate = true;
|
||||
@@ -147,4 +150,14 @@ describe("browser content packs", () => {
|
||||
await expect(removeContentPack("core:ui")).resolves.toBeUndefined();
|
||||
expect(contentPackIsUsable("core:ui")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses source-served dungeons in development and the content gate in shipped builds", () => {
|
||||
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
|
||||
vi.stubEnv("DEV", true);
|
||||
expect(dungeonContentIsUsable("deadmines")).toBe(true);
|
||||
|
||||
vi.stubEnv("DEV", false);
|
||||
expect(dungeonContentIsUsable("deadmines")).toBe(false);
|
||||
expect(dungeonContentIsUsable("wailing-caverns")).toBe(true);
|
||||
});
|
||||
});
|
||||
+235
-23
@@ -9,7 +9,7 @@ import {
|
||||
} from "./contentManifest";
|
||||
import { NativeContentStorage } from "./nativeContentStorage";
|
||||
|
||||
const CONTENT_STATE_KEY = "healer-man.content-state.v1";
|
||||
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",
|
||||
@@ -30,6 +30,8 @@ const STATIC_BROWSER_BUNDLED_PACKS = new Set([
|
||||
|
||||
export const BROWSER_CONTENT_CACHE_NAME = "healer-man-content-v1";
|
||||
export const BROWSER_CONTENT_WORKER_URL = "/content-cache-sw.js";
|
||||
const BROWSER_CONTENT_SHA256_HEADER = "x-healer-man-content-sha256";
|
||||
const BROWSER_CONTENT_BYTES_HEADER = "x-healer-man-content-bytes";
|
||||
|
||||
export interface InstalledContentFile {
|
||||
readonly packId: string;
|
||||
@@ -39,9 +41,20 @@ export interface InstalledContentFile {
|
||||
readonly extension: string;
|
||||
}
|
||||
|
||||
interface InstalledContentFileIdentity {
|
||||
readonly sha256: string;
|
||||
readonly bytes: number;
|
||||
}
|
||||
|
||||
interface InstalledContentPackReceipt {
|
||||
readonly manifestVersion: string;
|
||||
readonly dependencies: readonly string[];
|
||||
readonly files: Readonly<Record<string, InstalledContentFileIdentity>>;
|
||||
}
|
||||
|
||||
interface PersistedContentState {
|
||||
readonly version: 1;
|
||||
readonly packVersions: Readonly<Record<string, string>>;
|
||||
readonly version: 2;
|
||||
readonly packs: Readonly<Record<string, InstalledContentPackReceipt>>;
|
||||
readonly files: Readonly<Record<string, InstalledContentFile>>;
|
||||
}
|
||||
|
||||
@@ -57,7 +70,7 @@ export interface ContentManagerSnapshot {
|
||||
readonly error: string;
|
||||
}
|
||||
|
||||
const EMPTY_STATE: PersistedContentState = { version: 1, packVersions: {}, files: {} };
|
||||
const EMPTY_STATE: PersistedContentState = { version: 2, packs: {}, files: {} };
|
||||
let installedState = readInstalledState();
|
||||
let snapshot: ContentManagerSnapshot = {
|
||||
revision: 0,
|
||||
@@ -72,16 +85,69 @@ let snapshot: ContentManagerSnapshot = {
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
let initialization: Promise<void> | null = null;
|
||||
let installedStorageValidation: Promise<void> | null = null;
|
||||
let installedStorageValidationComplete = Object.keys(installedState.packs).length === 0;
|
||||
const validatedInstalledFiles = new Set<string>();
|
||||
|
||||
function safeLocalStorage(): Storage | null {
|
||||
try { return typeof window === "undefined" ? null : window.localStorage; } catch { return null; }
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isSha256(value: unknown): value is string {
|
||||
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
function isByteCount(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
function readInstalledState(): PersistedContentState {
|
||||
try {
|
||||
const value = JSON.parse(safeLocalStorage()?.getItem(CONTENT_STATE_KEY) ?? "null") as Partial<PersistedContentState> | null;
|
||||
if (!value || value.version !== 1 || !value.files || !value.packVersions) return EMPTY_STATE;
|
||||
return { version: 1, files: value.files, packVersions: value.packVersions };
|
||||
const value = JSON.parse(safeLocalStorage()?.getItem(CONTENT_STATE_KEY) ?? "null") as unknown;
|
||||
if (!isRecord(value) || value.version !== 2 || !isRecord(value.files) || !isRecord(value.packs)) {
|
||||
return EMPTY_STATE;
|
||||
}
|
||||
const files: Record<string, InstalledContentFile> = {};
|
||||
for (const [logicalPath, entry] of Object.entries(value.files)) {
|
||||
if (!logicalPath.startsWith("/")
|
||||
|| !isRecord(entry)
|
||||
|| typeof entry.packId !== "string"
|
||||
|| !isSha256(entry.sha256)
|
||||
|| !isByteCount(entry.bytes)
|
||||
|| typeof entry.fileUri !== "string"
|
||||
|| !entry.fileUri
|
||||
|| typeof entry.extension !== "string"
|
||||
|| !entry.extension.startsWith(".")) return EMPTY_STATE;
|
||||
files[logicalPath] = entry as unknown as InstalledContentFile;
|
||||
}
|
||||
const packs: Record<string, InstalledContentPackReceipt> = {};
|
||||
for (const [packId, receipt] of Object.entries(value.packs)) {
|
||||
if (!packId
|
||||
|| !isRecord(receipt)
|
||||
|| typeof receipt.manifestVersion !== "string"
|
||||
|| !receipt.manifestVersion
|
||||
|| !Array.isArray(receipt.dependencies)
|
||||
|| !receipt.dependencies.every((dependency) => typeof dependency === "string")
|
||||
|| !isRecord(receipt.files)) return EMPTY_STATE;
|
||||
const receiptFiles: Record<string, InstalledContentFileIdentity> = {};
|
||||
for (const [logicalPath, identity] of Object.entries(receipt.files)) {
|
||||
if (!logicalPath.startsWith("/")
|
||||
|| !isRecord(identity)
|
||||
|| !isSha256(identity.sha256)
|
||||
|| !isByteCount(identity.bytes)) return EMPTY_STATE;
|
||||
receiptFiles[logicalPath] = { sha256: identity.sha256, bytes: identity.bytes };
|
||||
}
|
||||
packs[packId] = {
|
||||
manifestVersion: receipt.manifestVersion,
|
||||
dependencies: [...receipt.dependencies],
|
||||
files: receiptFiles,
|
||||
};
|
||||
}
|
||||
return { version: 2, files, packs };
|
||||
} catch {
|
||||
return EMPTY_STATE;
|
||||
}
|
||||
@@ -116,6 +182,60 @@ function browserCacheStorage(): CacheStorage | null {
|
||||
return typeof caches === "undefined" ? null : caches;
|
||||
}
|
||||
|
||||
async function validateInstalledContentStorage(): Promise<void> {
|
||||
if (!installedStorageValidation) {
|
||||
installedStorageValidation = (async () => {
|
||||
const validFiles: Record<string, InstalledContentFile> = {};
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
for (const [logicalPath, entry] of Object.entries(installedState.files)) {
|
||||
try {
|
||||
const result = await NativeContentStorage.has({
|
||||
sha256: entry.sha256,
|
||||
extension: entry.extension,
|
||||
bytes: entry.bytes,
|
||||
});
|
||||
if (!result.exists) continue;
|
||||
validFiles[logicalPath] = {
|
||||
...entry,
|
||||
fileUri: result.fileUri || entry.fileUri,
|
||||
};
|
||||
} catch {
|
||||
// A file that cannot be verified must not make its pack launchable.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const storage = browserCacheStorage();
|
||||
const active = storage ? await storage.open(BROWSER_CONTENT_CACHE_NAME) : null;
|
||||
if (active) {
|
||||
for (const [logicalPath, entry] of Object.entries(installedState.files)) {
|
||||
const cached = await active.match(browserCacheRequest(logicalPath));
|
||||
if (cached?.headers.get(BROWSER_CONTENT_SHA256_HEADER) === entry.sha256
|
||||
&& cached.headers.get(BROWSER_CONTENT_BYTES_HEADER) === String(entry.bytes)) {
|
||||
validFiles[logicalPath] = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cache access failure is handled as unavailable content, never as a valid install.
|
||||
}
|
||||
}
|
||||
|
||||
validatedInstalledFiles.clear();
|
||||
Object.keys(validFiles).forEach((logicalPath) => validatedInstalledFiles.add(logicalPath));
|
||||
const filesChanged = Object.keys(validFiles).length !== Object.keys(installedState.files).length
|
||||
|| Object.entries(validFiles).some(([logicalPath, entry]) => (
|
||||
installedState.files[logicalPath]?.fileUri !== entry.fileUri
|
||||
));
|
||||
installedState = { ...installedState, files: validFiles };
|
||||
installedStorageValidationComplete = true;
|
||||
if (filesChanged) persistInstalledState();
|
||||
publish({ installedRevision: snapshot.installedRevision + 1 });
|
||||
})();
|
||||
}
|
||||
await installedStorageValidation;
|
||||
}
|
||||
|
||||
async function registerBrowserContentWorker(): Promise<void> {
|
||||
if (Capacitor.isNativePlatform()
|
||||
|| import.meta.env.DEV
|
||||
@@ -137,6 +257,19 @@ function extensionForEntry(entry: ContentManifestFile): string {
|
||||
return match?.[0].split("?")[0].toLowerCase() ?? ".bin";
|
||||
}
|
||||
|
||||
function packReceiptForManifest(manifest: ContentManifest, packId: string): InstalledContentPackReceipt {
|
||||
const pack = manifest.packs.find((candidate) => candidate.id === packId);
|
||||
if (!pack) throw new Error(`Unknown content pack: ${packId}`);
|
||||
return {
|
||||
manifestVersion: manifest.version,
|
||||
dependencies: [...pack.dependencies],
|
||||
files: Object.fromEntries(filesForContentPack(manifest, packId).map(([logicalPath, entry]) => [
|
||||
logicalPath,
|
||||
{ sha256: entry.sha256, bytes: entry.bytes },
|
||||
])),
|
||||
};
|
||||
}
|
||||
|
||||
function bundledFiles(): Readonly<Record<string, { readonly sha256: string }>> {
|
||||
if (snapshot.bundledManifest) return snapshot.bundledManifest.files;
|
||||
if (!snapshot.manifest) return {};
|
||||
@@ -166,7 +299,9 @@ export function resolveContentUrl(logicalUrl: string): string {
|
||||
let pathname = logicalUrl;
|
||||
try { pathname = new URL(logicalUrl, "https://local.invalid").pathname; } catch { /* preserve original */ }
|
||||
const installed = installedState.files[pathname];
|
||||
return installed?.fileUri ? Capacitor.convertFileSrc(installed.fileUri) : logicalUrl;
|
||||
return installed?.fileUri && validatedInstalledFiles.has(pathname)
|
||||
? Capacitor.convertFileSrc(installed.fileUri)
|
||||
: logicalUrl;
|
||||
}
|
||||
|
||||
export function contentPackIsBundled(packId: string): boolean {
|
||||
@@ -176,20 +311,66 @@ export function contentPackIsBundled(packId: string): boolean {
|
||||
}
|
||||
|
||||
export function contentPackIsInstalled(packId: string): boolean {
|
||||
return contentPackIsBundled(packId) || Boolean(installedState.packVersions[packId]);
|
||||
return contentPackIsBundled(packId) || Boolean(installedState.packs[packId]);
|
||||
}
|
||||
|
||||
function matchingReceiptFiles(
|
||||
packId: string,
|
||||
receipt: InstalledContentPackReceipt,
|
||||
expectedFiles: Readonly<Record<string, InstalledContentFileIdentity>>,
|
||||
): boolean {
|
||||
const expectedEntries = Object.entries(expectedFiles);
|
||||
if (Object.keys(receipt.files).length !== expectedEntries.length) return false;
|
||||
return expectedEntries.every(([logicalPath, expected]) => {
|
||||
const recorded = receipt.files[logicalPath];
|
||||
const installed = installedState.files[logicalPath];
|
||||
return recorded?.sha256 === expected.sha256
|
||||
&& recorded.bytes === expected.bytes
|
||||
&& installed?.packId === packId
|
||||
&& installed.sha256 === expected.sha256
|
||||
&& installed.bytes === expected.bytes
|
||||
&& validatedInstalledFiles.has(logicalPath);
|
||||
});
|
||||
}
|
||||
|
||||
function contentPackIsUsableInternal(packId: string, visiting: ReadonlySet<string>): boolean {
|
||||
if (contentPackIsBundled(packId)) return true;
|
||||
if (!installedStorageValidationComplete || visiting.has(packId)) return false;
|
||||
const receipt = installedState.packs[packId];
|
||||
if (!receipt) return false;
|
||||
|
||||
const manifestPack = snapshot.manifest?.packs.find((candidate) => candidate.id === packId);
|
||||
if (snapshot.manifest && !manifestPack) return false;
|
||||
const dependencies = manifestPack?.dependencies ?? receipt.dependencies;
|
||||
if (dependencies.length !== receipt.dependencies.length
|
||||
|| dependencies.some((dependency, index) => receipt.dependencies[index] !== dependency)) return false;
|
||||
const expectedFiles = manifestPack
|
||||
? Object.fromEntries(filesForContentPack(snapshot.manifest as ContentManifest, packId).map(([logicalPath, entry]) => [
|
||||
logicalPath,
|
||||
{ sha256: entry.sha256, bytes: entry.bytes },
|
||||
]))
|
||||
: receipt.files;
|
||||
if (!matchingReceiptFiles(packId, receipt, expectedFiles)) return false;
|
||||
|
||||
const nextVisiting = new Set(visiting);
|
||||
nextVisiting.add(packId);
|
||||
return dependencies.every((dependency) => contentPackIsUsableInternal(dependency, nextVisiting));
|
||||
}
|
||||
|
||||
export function contentPackIsUsable(packId: string): boolean {
|
||||
if (contentPackIsBundled(packId)) return true;
|
||||
if (!installedState.packVersions[packId]) return false;
|
||||
const pack = snapshot.manifest?.packs.find((candidate) => candidate.id === packId);
|
||||
return pack?.dependencies.every((dependency) => contentPackIsUsable(dependency)) ?? true;
|
||||
return contentPackIsUsableInternal(packId, new Set());
|
||||
}
|
||||
|
||||
/** Vite serves the complete source asset tree in development; shipped builds require an installed pack. */
|
||||
export function dungeonContentIsUsable(dungeonId: string): boolean {
|
||||
return import.meta.env.DEV || contentPackIsUsable(`dungeon:${dungeonId}`);
|
||||
}
|
||||
|
||||
export function contentPackHasUpdate(packId: string): boolean {
|
||||
if (!snapshot.manifest || !contentPackIsInstalled(packId)) return false;
|
||||
const pack = snapshot.manifest.packs.find((candidate) => candidate.id === packId);
|
||||
return changedFilesForPack(snapshot.manifest, packId, activeFileIdentities()).length > 0
|
||||
return Boolean(!contentPackIsBundled(packId) && !contentPackIsUsable(packId))
|
||||
|| changedFilesForPack(snapshot.manifest, packId, activeFileIdentities()).length > 0
|
||||
|| Boolean(!contentPackIsBundled(packId)
|
||||
&& pack?.dependencies.some((dependency) => (
|
||||
!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)
|
||||
@@ -224,7 +405,11 @@ async function loadBundledManifest(): Promise<void> {
|
||||
export function initializeContentSystem(): Promise<void> {
|
||||
if (!initialization) {
|
||||
initialization = (async () => {
|
||||
await Promise.all([loadBundledManifest(), registerBrowserContentWorker()]);
|
||||
await Promise.all([
|
||||
loadBundledManifest(),
|
||||
registerBrowserContentWorker(),
|
||||
validateInstalledContentStorage(),
|
||||
]);
|
||||
if (typeof navigator === "undefined" || navigator.onLine !== false) void checkForContentUpdates();
|
||||
})();
|
||||
}
|
||||
@@ -268,6 +453,8 @@ async function installBrowserContentPack(
|
||||
const headers = new Headers();
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (contentType) headers.set("content-type", contentType);
|
||||
headers.set(BROWSER_CONTENT_SHA256_HEADER, entry.sha256);
|
||||
headers.set(BROWSER_CONTENT_BYTES_HEADER, String(entry.bytes));
|
||||
await staging.put(browserCacheRequest(logicalPath), new Response(bytes, {
|
||||
headers,
|
||||
status: 200,
|
||||
@@ -289,6 +476,7 @@ async function installBrowserContentPack(
|
||||
if (installed.packId !== packId || latestPaths.has(logicalPath)) continue;
|
||||
await active.delete(browserCacheRequest(logicalPath));
|
||||
delete pendingFiles[logicalPath];
|
||||
validatedInstalledFiles.delete(logicalPath);
|
||||
}
|
||||
for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) {
|
||||
pendingFiles[logicalPath] = {
|
||||
@@ -298,11 +486,15 @@ async function installBrowserContentPack(
|
||||
extension: extensionForEntry(entry),
|
||||
fileUri: absoluteContentUrl(logicalPath),
|
||||
};
|
||||
validatedInstalledFiles.add(logicalPath);
|
||||
}
|
||||
installedState = {
|
||||
version: 1,
|
||||
version: 2,
|
||||
files: pendingFiles,
|
||||
packVersions: { ...installedState.packVersions, [packId]: manifest.version },
|
||||
packs: {
|
||||
...installedState.packs,
|
||||
[packId]: packReceiptForManifest(manifest, packId),
|
||||
},
|
||||
};
|
||||
persistInstalledState();
|
||||
publish({
|
||||
@@ -321,6 +513,7 @@ async function installBrowserContentPack(
|
||||
}
|
||||
|
||||
export async function installContentPack(packId: string): Promise<void> {
|
||||
await validateInstalledContentStorage();
|
||||
const manifest = snapshot.manifest ?? await checkForContentUpdates();
|
||||
if (!manifest) throw new Error(snapshot.error || "Content server is unavailable.");
|
||||
if (!appVersionMeetsMinimum(import.meta.env.VITE_APP_VERSION, manifest.minimumAppVersion)) {
|
||||
@@ -380,12 +573,30 @@ export async function installContentPack(packId: string): Promise<void> {
|
||||
|
||||
const latestPaths = new Set(filesForContentPack(manifest, packId).map(([logicalPath]) => logicalPath));
|
||||
for (const [logicalPath, installed] of Object.entries(pendingFiles)) {
|
||||
if (installed.packId === packId && !latestPaths.has(logicalPath)) delete pendingFiles[logicalPath];
|
||||
if (installed.packId === packId && !latestPaths.has(logicalPath)) {
|
||||
delete pendingFiles[logicalPath];
|
||||
validatedInstalledFiles.delete(logicalPath);
|
||||
}
|
||||
}
|
||||
for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) {
|
||||
const installed = pendingFiles[logicalPath];
|
||||
if (!installed) throw new Error(`Verified content is missing after installation: ${logicalPath}`);
|
||||
pendingFiles[logicalPath] = {
|
||||
...installed,
|
||||
packId,
|
||||
sha256: entry.sha256,
|
||||
bytes: entry.bytes,
|
||||
extension: extensionForEntry(entry),
|
||||
};
|
||||
validatedInstalledFiles.add(logicalPath);
|
||||
}
|
||||
installedState = {
|
||||
version: 1,
|
||||
version: 2,
|
||||
files: pendingFiles,
|
||||
packVersions: { ...installedState.packVersions, [packId]: manifest.version },
|
||||
packs: {
|
||||
...installedState.packs,
|
||||
[packId]: packReceiptForManifest(manifest, packId),
|
||||
},
|
||||
};
|
||||
persistInstalledState();
|
||||
publish({
|
||||
@@ -424,9 +635,10 @@ export async function removeContentPack(packId: string): Promise<void> {
|
||||
delete remainingFiles[logicalPath];
|
||||
}
|
||||
}
|
||||
const packVersions = { ...installedState.packVersions };
|
||||
delete packVersions[packId];
|
||||
installedState = { version: 1, files: remainingFiles, packVersions };
|
||||
targetFiles.forEach(([logicalPath]) => validatedInstalledFiles.delete(logicalPath));
|
||||
const packs = { ...installedState.packs };
|
||||
delete packs[packId];
|
||||
installedState = { version: 2, files: remainingFiles, packs };
|
||||
persistInstalledState();
|
||||
publish({ error: "", installedRevision: snapshot.installedRevision + 1 });
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./nativeContentStorage", () => ({ NativeContentStorage: {} }));
|
||||
|
||||
const STATE_KEY = "healer-man.content-state.v2";
|
||||
const ENVIRONMENT_PATH = "/assets/game/manastorm/test/visual.glb";
|
||||
const CREATURE_PATH = "/assets/game/creatures/shared/test.glb";
|
||||
const ENVIRONMENT_HASH = "a".repeat(64);
|
||||
const CREATURE_HASH = "b".repeat(64);
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
get length(): number { return this.values.size; }
|
||||
clear(): void { this.values.clear(); }
|
||||
getItem(key: string): string | null { return this.values.get(key) ?? null; }
|
||||
key(index: number): string | null { return [...this.values.keys()][index] ?? null; }
|
||||
removeItem(key: string): void { this.values.delete(key); }
|
||||
setItem(key: string, value: string): void { this.values.set(key, value); }
|
||||
}
|
||||
|
||||
class MemoryCache {
|
||||
readonly responses = new Map<string, Response>();
|
||||
|
||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||
return this.responses.get(this.key(request))?.clone();
|
||||
}
|
||||
|
||||
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||
this.responses.set(this.key(request), response.clone());
|
||||
}
|
||||
|
||||
async delete(request: RequestInfo | URL): Promise<boolean> {
|
||||
return this.responses.delete(this.key(request));
|
||||
}
|
||||
|
||||
private key(request: RequestInfo | URL): string {
|
||||
if (request instanceof Request) return request.url;
|
||||
return new URL(String(request), "http://localhost").toString();
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryCacheStorage {
|
||||
readonly stores = new Map<string, MemoryCache>();
|
||||
|
||||
async open(name: string): Promise<Cache> {
|
||||
const cache = this.stores.get(name) ?? new MemoryCache();
|
||||
this.stores.set(name, cache);
|
||||
return cache as unknown as Cache;
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<boolean> {
|
||||
return this.stores.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
function installedState() {
|
||||
return {
|
||||
version: 2,
|
||||
packs: {
|
||||
"dungeon:test": {
|
||||
manifestVersion: "content-one",
|
||||
dependencies: ["manastorm:test", "shared:creatures"],
|
||||
files: {},
|
||||
},
|
||||
"manastorm:test": {
|
||||
manifestVersion: "content-one",
|
||||
dependencies: [],
|
||||
files: { [ENVIRONMENT_PATH]: { sha256: ENVIRONMENT_HASH, bytes: 11 } },
|
||||
},
|
||||
"shared:creatures": {
|
||||
manifestVersion: "content-one",
|
||||
dependencies: [],
|
||||
files: { [CREATURE_PATH]: { sha256: CREATURE_HASH, bytes: 12 } },
|
||||
},
|
||||
},
|
||||
files: {
|
||||
[ENVIRONMENT_PATH]: {
|
||||
packId: "manastorm:test",
|
||||
sha256: ENVIRONMENT_HASH,
|
||||
bytes: 11,
|
||||
fileUri: `http://localhost${ENVIRONMENT_PATH}`,
|
||||
extension: ".glb",
|
||||
},
|
||||
[CREATURE_PATH]: {
|
||||
packId: "shared:creatures",
|
||||
sha256: CREATURE_HASH,
|
||||
bytes: 12,
|
||||
fileUri: `http://localhost${CREATURE_PATH}`,
|
||||
extension: ".glb",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOfflineManager(
|
||||
state: unknown,
|
||||
cachedPaths: readonly string[],
|
||||
) {
|
||||
vi.resetModules();
|
||||
vi.stubEnv("DEV", false);
|
||||
vi.stubEnv("VITE_CONTENT_MODE", "bundled");
|
||||
const localStorage = new MemoryStorage();
|
||||
localStorage.setItem(STATE_KEY, JSON.stringify(state));
|
||||
vi.stubGlobal("window", { localStorage, location: { origin: "http://localhost" } });
|
||||
vi.stubGlobal("navigator", { onLine: false });
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
vi.stubGlobal("caches", cacheStorage as unknown as CacheStorage);
|
||||
const { Capacitor } = await import("@capacitor/core");
|
||||
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
|
||||
const manager = await import("./contentManager");
|
||||
const active = await cacheStorage.open(manager.BROWSER_CONTENT_CACHE_NAME);
|
||||
for (const logicalPath of cachedPaths) {
|
||||
const entry = installedState().files[logicalPath as keyof ReturnType<typeof installedState>["files"]];
|
||||
await active.put(new Request(`http://localhost${logicalPath}`), new Response("cached", {
|
||||
headers: {
|
||||
"x-healer-man-content-sha256": entry.sha256,
|
||||
"x-healer-man-content-bytes": String(entry.bytes),
|
||||
},
|
||||
}));
|
||||
}
|
||||
return { localStorage, manager };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("persisted content readiness", () => {
|
||||
it("restores a complete dependency-only dungeon closure offline after verifying storage", async () => {
|
||||
const { manager } = await loadOfflineManager(installedState(), [ENVIRONMENT_PATH, CREATURE_PATH]);
|
||||
|
||||
expect(manager.dungeonContentIsUsable("test")).toBe(false);
|
||||
await manager.initializeContentSystem();
|
||||
expect(manager.dungeonContentIsUsable("test")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a persisted install when a required cached file is missing", async () => {
|
||||
const { localStorage, manager } = await loadOfflineManager(installedState(), [ENVIRONMENT_PATH]);
|
||||
|
||||
await manager.initializeContentSystem();
|
||||
expect(manager.dungeonContentIsUsable("test")).toBe(false);
|
||||
expect(JSON.parse(localStorage.getItem(STATE_KEY) ?? "null").files[CREATURE_PATH]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a missing dependency receipt", async () => {
|
||||
const state = installedState();
|
||||
delete (state.packs as Partial<typeof state.packs>)["shared:creatures"];
|
||||
const { manager } = await loadOfflineManager(state, [ENVIRONMENT_PATH, CREATURE_PATH]);
|
||||
await manager.initializeContentSystem();
|
||||
expect(manager.dungeonContentIsUsable("test")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a receipt whose dependency topology is stale", async () => {
|
||||
const { manager } = await loadOfflineManager(installedState(), [ENVIRONMENT_PATH, CREATURE_PATH]);
|
||||
await manager.initializeContentSystem();
|
||||
expect(manager.dungeonContentIsUsable("test")).toBe(true);
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn(async () => Response.json({
|
||||
schemaVersion: 1,
|
||||
version: "content-two",
|
||||
createdAt: "2026-09-08T00:00:00.000Z",
|
||||
minimumAppVersion: "0.1.0",
|
||||
packs: [
|
||||
{
|
||||
id: "dungeon:test",
|
||||
label: "Test",
|
||||
bytes: 0,
|
||||
fileCount: 0,
|
||||
bundledOnAndroid: false,
|
||||
dependencies: ["manastorm:new"],
|
||||
},
|
||||
],
|
||||
files: {},
|
||||
})));
|
||||
await manager.checkForContentUpdates();
|
||||
expect(manager.contentPackIsUsable("dungeon:test")).toBe(false);
|
||||
expect(manager.contentPackHasUpdate("dungeon:test")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not accept a legacy packVersions marker as an install receipt", async () => {
|
||||
const { manager } = await loadOfflineManager({
|
||||
version: 2,
|
||||
packVersions: { "dungeon:test": "content-one" },
|
||||
files: {},
|
||||
}, []);
|
||||
|
||||
await manager.initializeContentSystem();
|
||||
expect(manager.contentPackIsInstalled("dungeon:test")).toBe(false);
|
||||
expect(manager.dungeonContentIsUsable("test")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,13 @@ import { useManastormStore } from "./manastormStore";
|
||||
import {
|
||||
activateDungeonEncounterDefinitions,
|
||||
advanceDungeonEncounters,
|
||||
dispatchActiveDungeonEntityEvent,
|
||||
resetDungeonEncounters,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import {
|
||||
deriveDungeonEncounterCombatEvents,
|
||||
type DungeonEncounterMobCursor,
|
||||
} from "./dungeonEncounterCombatEvents";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import {
|
||||
onlineSessionIsActive,
|
||||
@@ -27,9 +32,11 @@ export function CombatBridge() {
|
||||
const character = useShellStore((state) => state.activeCharacter);
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const activeDifficultyId = useGameStore((state) => state.activeDifficultyId);
|
||||
const activeRole = useShellStore((state) => state.activeDungeonRole);
|
||||
const encounterResetRevision = useGameStore((state) => state.encounterResetRevision);
|
||||
const mountedResetRevision = useRef(encounterResetRevision);
|
||||
const encounterMobCursor = useRef<DungeonEncounterMobCursor>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
@@ -44,12 +51,14 @@ export function CombatBridge() {
|
||||
|
||||
useEffect(() => {
|
||||
const definition = requireDungeonDefinition(activeDungeonId);
|
||||
activateDungeonEncounterDefinitions(activeDungeonId, definition.encounters ?? []);
|
||||
}, [activeDungeonId]);
|
||||
encounterMobCursor.current = {};
|
||||
activateDungeonEncounterDefinitions(activeDungeonId, definition.encounters ?? [], activeDifficultyId);
|
||||
}, [activeDifficultyId, activeDungeonId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mountedResetRevision.current === encounterResetRevision) return;
|
||||
mountedResetRevision.current = encounterResetRevision;
|
||||
encounterMobCursor.current = {};
|
||||
useCombatStore.getState().resetEncounter();
|
||||
resetDungeonEncounters("dungeon", useGameStore.getState().activeDungeonId);
|
||||
}, [encounterResetRevision]);
|
||||
@@ -126,6 +135,13 @@ export function CombatBridge() {
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
|
||||
const mobs = useCombatStore.getState().mobs;
|
||||
const combatEvents = deriveDungeonEncounterCombatEvents(
|
||||
encounterMobCursor.current,
|
||||
mobs,
|
||||
epochNow,
|
||||
);
|
||||
encounterMobCursor.current = combatEvents.cursor;
|
||||
for (const event of combatEvents.events) dispatchActiveDungeonEntityEvent(event);
|
||||
const registeredEntityIds = Object.keys(mobs);
|
||||
advanceDungeonEncounters(game.activeDungeonId, {
|
||||
kind: "tick",
|
||||
|
||||
@@ -3,9 +3,17 @@ import { applyDungeonEncounterEffect } from "./DungeonEncounterEffectBridge";
|
||||
import { clearPartyRuntimeRegistry, getPartyRuntimePosition, updatePartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import {
|
||||
clearMobRuntimeRegistry,
|
||||
getMobRuntimePosition,
|
||||
registerMobRuntime,
|
||||
} from "./mobRuntimeRegistry";
|
||||
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
|
||||
|
||||
afterEach(() => {
|
||||
clearPartyRuntimeRegistry();
|
||||
clearMobRuntimeRegistry();
|
||||
usePartyStore.setState({ members: [] });
|
||||
});
|
||||
|
||||
@@ -26,4 +34,34 @@ describe("dungeon encounter effects", () => {
|
||||
expect(useGameStore.getState().playerPosition).toEqual([12, 3, -8]);
|
||||
expect(getPartyRuntimePosition(allyId)).toEqual([12, 3, -8]);
|
||||
});
|
||||
|
||||
it("removes every combat and spatial actor owned by a deactivated spawn group", () => {
|
||||
const dungeon = DUNGEON_REGISTRY["wailing-caverns"];
|
||||
const group = dungeon.encounterSpawnGroups?.find(
|
||||
(candidate) => candidate.id === "naralex-ravagers",
|
||||
);
|
||||
expect(group).toBeDefined();
|
||||
for (const spawn of group!.spawns) {
|
||||
useCombatStore.getState().registerMob(spawn.id, { name: spawn.id, maxHealth: 100 });
|
||||
registerMobRuntime(spawn.id, spawn.position);
|
||||
}
|
||||
useCombatStore.getState().registerMob("unrelated", { name: "Unrelated", maxHealth: 100 });
|
||||
|
||||
applyDungeonEncounterEffect({
|
||||
dungeonId: dungeon.id,
|
||||
encounterId: "wailing-caverns:naralex",
|
||||
effect: {
|
||||
kind: "despawn-group",
|
||||
actionId: "cleanup-ravagers",
|
||||
spawnGroupId: group!.id,
|
||||
},
|
||||
}, 100);
|
||||
|
||||
for (const spawn of group!.spawns) {
|
||||
expect(useCombatStore.getState().mobs[spawn.id]).toBeUndefined();
|
||||
expect(getMobRuntimePosition(spawn.id)).toBeNull();
|
||||
}
|
||||
expect(useCombatStore.getState().mobs.unrelated).toBeDefined();
|
||||
useCombatStore.getState().removeMob("unrelated");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@ import { useCombatStore } from "./combatStore";
|
||||
import { useGameStore } from "./store";
|
||||
import type { DungeonEncounterEffectEnvelope } from "./dungeonEncounterTypes";
|
||||
import { publishOnlinePartyTeleport } from "./onlineSessionRuntime";
|
||||
import { dungeonDefinitionById } from "./dungeonRegistry";
|
||||
import { removeMobPosition } from "./mobRuntimeRegistry";
|
||||
|
||||
function grantEncounterLoot(lootTableId: string, now: number): boolean {
|
||||
const table = LOOT_CATALOG.bosses.find((candidate) => candidate.lootTableId === lootTableId);
|
||||
@@ -33,7 +35,7 @@ export function DungeonEncounterEffectBridge() {
|
||||
}
|
||||
|
||||
export function applyDungeonEncounterEffect(
|
||||
{ effect }: DungeonEncounterEffectEnvelope,
|
||||
{ dungeonId, effect }: DungeonEncounterEffectEnvelope,
|
||||
now = Date.now(),
|
||||
): void {
|
||||
if (effect.kind === "signal") {
|
||||
@@ -49,6 +51,18 @@ export function applyDungeonEncounterEffect(
|
||||
publishOnlinePartyTeleport(effect.destination, effect.actionId, now);
|
||||
return;
|
||||
}
|
||||
if (effect.kind === "despawn-group") {
|
||||
const group = dungeonDefinitionById(dungeonId)?.encounterSpawnGroups?.find(
|
||||
(candidate) => candidate.id === effect.spawnGroupId,
|
||||
);
|
||||
if (!group) return;
|
||||
const combat = useCombatStore.getState();
|
||||
for (const spawn of group.spawns) {
|
||||
combat.removeMob(spawn.id);
|
||||
removeMobPosition(spawn.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (effect.kind === "loot-grant" && !grantEncounterLoot(effect.lootTableId, now)) {
|
||||
useShellStore.getState().setNotice(`Loot table ${effect.lootTableId} is not packaged for this encounter.`);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,11 @@ export function ManastormBridge() {
|
||||
}, [gameMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameMode !== "manastorm" || !nextEncounterId) return;
|
||||
if (
|
||||
gameMode !== "manastorm"
|
||||
|| (status !== "portal-ready" && status !== "transitioning")
|
||||
|| !nextEncounterId
|
||||
) return;
|
||||
const nextEncounter = manastormEncounterById(nextEncounterId);
|
||||
const packageId = nextEncounter?.assetPackageId;
|
||||
if (!packageId || packageId === encounter?.assetPackageId) return;
|
||||
@@ -67,7 +71,7 @@ export function ManastormBridge() {
|
||||
console.warn(`Could not preload the next Manastorm package ${packageId}.`, error);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [encounter?.assetPackageId, gameMode, nextEncounterId]);
|
||||
}, [encounter?.assetPackageId, gameMode, nextEncounterId, status]);
|
||||
|
||||
useEffect(() => useCombatStore.subscribe((state, previous) => {
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { OnlineSharedWorld } from "../app/onlineSessionTypes";
|
||||
import {
|
||||
activateDungeonEncounterDefinitions,
|
||||
dungeonEncounterSnapshot,
|
||||
resetDungeonEncounters,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import { applySharedWorld } from "./OnlineSessionBridge";
|
||||
import { useGameStore } from "./store";
|
||||
import {
|
||||
WAILING_NARALEX_ENCOUNTER,
|
||||
useWailingEncounterStore,
|
||||
} from "./wailingCavernsEncounter";
|
||||
|
||||
const EMPTY_SHARED_WORLD = {
|
||||
dungeonId: "wailing-caverns",
|
||||
mobs: {},
|
||||
mobTransforms: {},
|
||||
partyMembers: [],
|
||||
partyPositions: {},
|
||||
partyRuntime: {
|
||||
command: "defend",
|
||||
activeMobId: null,
|
||||
objectiveBossId: null,
|
||||
memberTargetIds: {},
|
||||
},
|
||||
wailing: null,
|
||||
manastorm: null,
|
||||
} as const satisfies OnlineSharedWorld;
|
||||
|
||||
describe("online shared-world hydration", () => {
|
||||
afterEach(() => {
|
||||
useWailingEncounterStore.getState().reset("dungeon", 0);
|
||||
useGameStore.getState().activateDungeon("wailing-caverns");
|
||||
});
|
||||
|
||||
it("accepts an authority snapshot older than a follower's local revision", () => {
|
||||
activateDungeonEncounterDefinitions(
|
||||
"wailing-caverns",
|
||||
[WAILING_NARALEX_ENCOUNTER],
|
||||
"normal",
|
||||
);
|
||||
useGameStore.getState().activateDungeon("wailing-caverns");
|
||||
const authorityBase = dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)!;
|
||||
resetDungeonEncounters("dungeon", "wailing-caverns");
|
||||
const follower = dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)!;
|
||||
const authority = {
|
||||
...authorityBase,
|
||||
phase: "ready",
|
||||
revision: Math.max(0, follower.revision - 1),
|
||||
} as const;
|
||||
expect(authority.revision).toBeLessThan(follower.revision);
|
||||
|
||||
applySharedWorld({ ...EMPTY_SHARED_WORLD, encounters: [authority] });
|
||||
|
||||
expect(dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)).toMatchObject({
|
||||
phase: "ready",
|
||||
revision: authority.revision,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -87,7 +87,7 @@ function sharedWorld(): OnlineSharedWorld {
|
||||
};
|
||||
}
|
||||
|
||||
function applySharedWorld(world: OnlineSharedWorld): void {
|
||||
export function applySharedWorld(world: OnlineSharedWorld): void {
|
||||
useCombatStore.setState({ mobs: world.mobs });
|
||||
usePartyStore.setState({
|
||||
members: world.partyMembers,
|
||||
@@ -103,7 +103,7 @@ function applySharedWorld(world: OnlineSharedWorld): void {
|
||||
if (world.manastorm) useManastormStore.setState(world.manastorm as never);
|
||||
const activeDungeonId = useGameStore.getState().activeDungeonId;
|
||||
if (world.dungeonId === activeDungeonId && world.encounters) {
|
||||
importDungeonEncounterSnapshots(activeDungeonId, world.encounters);
|
||||
importDungeonEncounterSnapshots(activeDungeonId, world.encounters, { authoritative: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDungeonEncounterSnapshot,
|
||||
reduceDungeonEncounter,
|
||||
resetDungeonEncounterSnapshot,
|
||||
validateDungeonEncounterDefinition,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type {
|
||||
DungeonEncounterAction,
|
||||
DungeonEncounterGuard,
|
||||
DungeonEncounterTrigger,
|
||||
} from "./dungeonEncounterTypes";
|
||||
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
|
||||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||||
import type { DungeonDefinition } from "./dungeonTypes";
|
||||
import {
|
||||
ASCENSION_CUSTOM_ENCOUNTER_EVIDENCE,
|
||||
withAscensionCustomEncounterContent,
|
||||
} from "./ascensionCustomEncounterCatalog";
|
||||
|
||||
const CUSTOM_DUNGEON_IDS = [
|
||||
"frozen-reach",
|
||||
"forgotten-mine",
|
||||
"tinkertech-showdown",
|
||||
] as const;
|
||||
|
||||
function adaptedDungeon(id: typeof CUSTOM_DUNGEON_IDS[number]): DungeonDefinition {
|
||||
const source = DUNGEON_REGISTRY[id];
|
||||
expect(source, id).toBeDefined();
|
||||
return withAscensionCustomEncounterContent(source);
|
||||
}
|
||||
|
||||
function runtimeSpawnIds(definition: DungeonDefinition): readonly string[] {
|
||||
return [
|
||||
...definition.staticSpawns.map((spawn) => spawn.id),
|
||||
...definition.roamingPacks.flatMap((pack) => pack.members.map((member) => `${pack.id}:${member.id}`)),
|
||||
...(definition.encounterSpawnGroups ?? []).flatMap((group) => group.spawns.map((spawn) => spawn.id)),
|
||||
];
|
||||
}
|
||||
|
||||
function expectTriggerReferences(
|
||||
trigger: DungeonEncounterTrigger,
|
||||
spawnIds: ReadonlySet<string>,
|
||||
objectIds: ReadonlySet<string>,
|
||||
): void {
|
||||
if (trigger.kind === "entity-death" || trigger.kind === "entity-engaged" || trigger.kind === "entity-health") {
|
||||
expect(spawnIds, `trigger entity ${trigger.entityId}`).toContain(trigger.entityId);
|
||||
}
|
||||
if (trigger.kind === "entities-dead") {
|
||||
for (const entityId of trigger.entityIds) expect(spawnIds, `trigger entity ${entityId}`).toContain(entityId);
|
||||
}
|
||||
if (trigger.kind === "interact") expect(objectIds, `trigger object ${trigger.objectId}`).toContain(trigger.objectId);
|
||||
}
|
||||
|
||||
function expectGuardReferences(
|
||||
guard: DungeonEncounterGuard,
|
||||
groupIds: ReadonlySet<string>,
|
||||
objectIds: ReadonlySet<string>,
|
||||
objectiveIds: ReadonlySet<string>,
|
||||
): void {
|
||||
if (guard.kind === "spawn-group-active") expect(groupIds).toContain(guard.spawnGroupId);
|
||||
if (guard.kind === "world-object-state") expect(objectIds).toContain(guard.objectId);
|
||||
if (guard.kind === "objective-complete") expect(objectiveIds).toContain(guard.objectiveId);
|
||||
}
|
||||
|
||||
function expectActionReferences(
|
||||
action: DungeonEncounterAction,
|
||||
groupIds: ReadonlySet<string>,
|
||||
objectsById: ReadonlyMap<string, DungeonDefinition["serverGameObjects"][number]>,
|
||||
objectiveIds: ReadonlySet<string>,
|
||||
): void {
|
||||
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
|
||||
expect(groupIds, `action group ${action.spawnGroupId}`).toContain(action.spawnGroupId);
|
||||
}
|
||||
if (action.kind === "set-world-object-state") {
|
||||
const object = objectsById.get(action.objectId);
|
||||
expect(object, `action object ${action.objectId}`).toBeDefined();
|
||||
expect(object?.states.map((state) => state.id), `object state ${action.objectId}:${action.state}`)
|
||||
.toContain(action.state);
|
||||
}
|
||||
if (action.kind === "complete-objective") expect(objectiveIds).toContain(action.objectiveId);
|
||||
}
|
||||
|
||||
describe("Ascension custom encounter content", () => {
|
||||
it("attaches idempotently with no dangling or duplicate static/runtime references", () => {
|
||||
expect(ASCENSION_CUSTOM_ENCOUNTER_EVIDENCE.map((entry) => entry.dungeonId).sort())
|
||||
.toEqual([...CUSTOM_DUNGEON_IDS].sort());
|
||||
|
||||
for (const id of CUSTOM_DUNGEON_IDS) {
|
||||
const definition = adaptedDungeon(id);
|
||||
expect(withAscensionCustomEncounterContent(definition)).toBe(definition);
|
||||
const spawnIdList = runtimeSpawnIds(definition);
|
||||
const spawnIds = new Set(spawnIdList);
|
||||
expect(spawnIds.size, `${id} duplicate runtime spawn ids`).toBe(spawnIdList.length);
|
||||
const groupIds = new Set((definition.encounterSpawnGroups ?? []).map((group) => group.id));
|
||||
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
|
||||
const objectsById = new Map(definition.serverGameObjects.map((object) => [object.id, object] as const));
|
||||
const objectiveIds = new Set([
|
||||
...definition.bosses.map((objective) => objective.id),
|
||||
...definition.objectiveOrder.map((objective) => objective.id),
|
||||
]);
|
||||
|
||||
for (const spawn of definition.staticSpawns) {
|
||||
expect(definition.entities[spawn.entityId], `${id} static ${spawn.id}`).toBeDefined();
|
||||
}
|
||||
for (const group of definition.encounterSpawnGroups ?? []) {
|
||||
for (const spawn of group.spawns) {
|
||||
expect(definition.entities[spawn.entityId], `${id} group ${group.id}:${spawn.id}`).toBeDefined();
|
||||
}
|
||||
}
|
||||
for (const object of definition.serverGameObjects) validateDungeonWorldObject(object);
|
||||
for (const encounter of definition.encounters ?? []) {
|
||||
validateDungeonEncounterDefinition(encounter);
|
||||
expect(encounter.dungeonId).toBe(id);
|
||||
for (const rule of encounter.rules) {
|
||||
expectTriggerReferences(rule.trigger, spawnIds, objectIds);
|
||||
for (const guard of rule.guards ?? []) {
|
||||
expectGuardReferences(guard, groupIds, objectIds, objectiveIds);
|
||||
}
|
||||
for (const action of rule.actions) {
|
||||
expectActionReferences(action, groupIds, objectsById, objectiveIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("removes Frozen Reach placeholder trash and drives the Arktos-to-vengeance lifecycle", () => {
|
||||
const definition = adaptedDungeon("frozen-reach");
|
||||
expect(definition.staticSpawns.map((spawn) => spawn.id)).toEqual(["encounter-2168"]);
|
||||
expect(definition.entities["frozen-reach-guardian"]).toBeUndefined();
|
||||
expect(definition.entities["frozen-reach-caster"]).toBeUndefined();
|
||||
expect(definition.entities["frozen-reach-brute"]).toBeUndefined();
|
||||
expect(definition.entities["entry-97700"]).toMatchObject({
|
||||
name: "Alva",
|
||||
combat: { serverEntry: 97_700 },
|
||||
});
|
||||
expect(definition.entities["frozen-reach:arktos"]).toMatchObject({
|
||||
name: "Arktos",
|
||||
combat: { serverEntry: 97_701 },
|
||||
});
|
||||
const alvaSpellIds = definition.entities["entry-97700"].combat?.attacks
|
||||
.flatMap((entry) => entry.spellId ?? []);
|
||||
expect(alvaSpellIds).toEqual(expect.arrayContaining([
|
||||
255_347,
|
||||
255_349,
|
||||
255_352,
|
||||
255_358,
|
||||
255_363,
|
||||
255_394,
|
||||
255_397,
|
||||
255_410,
|
||||
255_419,
|
||||
255_421,
|
||||
]));
|
||||
|
||||
const encounter = definition.encounters?.find((entry) => entry.id === "frozen-reach:alva-and-arktos");
|
||||
expect(encounter?.source?.fidelity).toBe("source-backed-adapted");
|
||||
const arktosGroup = definition.encounterSpawnGroups
|
||||
?.find((group) => group.id === "frozen-reach:arktos-attempt");
|
||||
expect(arktosGroup?.spawns).toMatchObject([{
|
||||
id: "frozen-reach:arktos",
|
||||
entityId: "frozen-reach:arktos",
|
||||
}]);
|
||||
let snapshot = createDungeonEncounterSnapshot(encounter!, 0);
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "tick",
|
||||
now: 1,
|
||||
}).snapshot;
|
||||
expect(snapshot).toMatchObject({
|
||||
phase: "duo-active",
|
||||
activeSpawnGroupIds: ["frozen-reach:arktos-attempt"],
|
||||
});
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: "frozen-reach:arktos",
|
||||
now: 1_000,
|
||||
}).snapshot;
|
||||
expect(snapshot).toMatchObject({
|
||||
phase: "vengeance",
|
||||
flags: { "arktos-defeated": true },
|
||||
activeSpawnGroupIds: ["frozen-reach:rebirth-alvajar"],
|
||||
});
|
||||
const addIds = definition.encounterSpawnGroups
|
||||
?.find((group) => group.id === "frozen-reach:rebirth-alvajar")
|
||||
?.spawns.map((spawn) => spawn.id) ?? [];
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "tick",
|
||||
now: 2_000,
|
||||
facts: { registeredEntityIds: addIds, deadEntityIds: addIds },
|
||||
}).snapshot;
|
||||
expect(snapshot.phase).toBe("vengeance");
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([]);
|
||||
const reset = resetDungeonEncounterSnapshot(encounter!, snapshot, "boss-wipe", 3_000);
|
||||
expect(reset).toMatchObject({
|
||||
phase: "duo-active",
|
||||
flags: { "arktos-defeated": false },
|
||||
activeSpawnGroupIds: [],
|
||||
});
|
||||
|
||||
snapshot = reduceDungeonEncounter(encounter!, reset, {
|
||||
kind: "tick",
|
||||
now: 3_001,
|
||||
}).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual(["frozen-reach:arktos-attempt"]);
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: "frozen-reach:arktos",
|
||||
now: 4_000,
|
||||
}).snapshot;
|
||||
expect(snapshot).toMatchObject({
|
||||
phase: "vengeance",
|
||||
flags: { "arktos-defeated": true },
|
||||
activeSpawnGroupIds: ["frozen-reach:rebirth-alvajar"],
|
||||
});
|
||||
});
|
||||
|
||||
it("runs Vek's Geospike, Resonance, and repeatable Resonite shard lifecycle", () => {
|
||||
const definition = adaptedDungeon("forgotten-mine");
|
||||
expect(definition.entities["entry-254272"]).toMatchObject({
|
||||
name: "Geolord Vek",
|
||||
combat: { serverEntry: 254_272 },
|
||||
});
|
||||
expect(definition.entities["forgotten-mine-guardian"].name).toBe("Forgotten Mine Stoneguard");
|
||||
expect(definition.entities["forgotten-mine-caster"].name).toBe("Forgotten Mine Geomancer");
|
||||
expect(definition.entities["forgotten-mine-brute"].name).toBe("Forgotten Mine Quarry Brute");
|
||||
expect(definition.entities["entry-254272"].combat?.attacks.flatMap((entry) => entry.spellId ?? []))
|
||||
.toEqual(expect.arrayContaining([354_000, 354_001, 354_002, 354_004, 354_013, 354_015, 354_017]));
|
||||
|
||||
const shards = definition.serverGameObjects.filter((object) => object.source?.templateId === 254_051);
|
||||
expect(shards).toHaveLength(3);
|
||||
for (const shard of shards) {
|
||||
expect(shard).toMatchObject({
|
||||
kind: "resource",
|
||||
halfExtents: [2.25, 2.4, 2.25],
|
||||
interaction: { range: 4.5, allowedStates: ["ready"] },
|
||||
source: { fidelity: "source-backed-adapted", templateId: 254_051 },
|
||||
});
|
||||
}
|
||||
|
||||
const encounter = definition.encounters?.find((entry) => entry.id === "forgotten-mine:geolord-vek");
|
||||
let snapshot = createDungeonEncounterSnapshot(encounter!, 0);
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "signal",
|
||||
signalId: "forgotten-mine:geospike-cast",
|
||||
now: 100,
|
||||
}).snapshot;
|
||||
expect(snapshot.phase).toBe("pre-resonance-geospike");
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual(["forgotten-mine:geospike"]);
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 3_599 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual(["forgotten-mine:geospike"]);
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 3_600 }).snapshot;
|
||||
expect(snapshot.phase).toBe("boss-active");
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([]);
|
||||
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "entity-health",
|
||||
entityId: "encounter-5069",
|
||||
healthPercent: 50,
|
||||
now: 4_000,
|
||||
}).snapshot;
|
||||
expect(snapshot.phase).toBe("resonance-active");
|
||||
expect(Object.values(snapshot.worldObjectStates)).toEqual(["ready", "ready", "ready"]);
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "interact",
|
||||
objectId: "forgotten-mine:resonite-shard-1",
|
||||
now: 5_000,
|
||||
}).snapshot;
|
||||
expect(snapshot).toMatchObject({
|
||||
phase: "resonance-cleansed",
|
||||
counters: { "resonite-cleanses": 1 },
|
||||
worldObjectStates: { "forgotten-mine:resonite-shard-1": "consumed" },
|
||||
});
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 12_999 }).snapshot;
|
||||
expect(snapshot.phase).toBe("resonance-cleansed");
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 13_000 }).snapshot;
|
||||
expect(snapshot.phase).toBe("resonance-recharge");
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 35_000 }).snapshot;
|
||||
expect(snapshot.phase).toBe("resonance-active");
|
||||
expect(Object.values(snapshot.worldObjectStates)).toEqual(["ready", "ready", "ready"]);
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "interact",
|
||||
objectId: "forgotten-mine:resonite-shard-1",
|
||||
now: 36_000,
|
||||
}).snapshot;
|
||||
expect(snapshot.counters["resonite-cleanses"]).toBe(2);
|
||||
const reset = resetDungeonEncounterSnapshot(encounter!, snapshot, "boss-wipe", 37_000);
|
||||
expect(reset.phase).toBe("boss-active");
|
||||
expect(Object.values(reset.worldObjectStates)).toEqual(["dormant", "dormant", "dormant"]);
|
||||
expect(reset.counters["resonite-cleanses"]).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps Tinkertech explicitly authored and switches Mechatron to faster overdrive with reinforcements", () => {
|
||||
const definition = adaptedDungeon("tinkertech-showdown");
|
||||
expect(definition.entities["entry-663012"]).toMatchObject({ name: "Mechatron", combat: { serverEntry: 663_012 } });
|
||||
expect(definition.entities["tinkertech-showdown-guardian"].name).toBe("Tinkertech Guardbot");
|
||||
expect(definition.entities["tinkertech-showdown-caster"].name).toBe("Tinkertech Coilcaster");
|
||||
expect(definition.entities["tinkertech-showdown-brute"].name).toBe("Tinkertech Demolisher");
|
||||
const review = ASCENSION_CUSTOM_ENCOUNTER_EVIDENCE.find((entry) => entry.dungeonId === definition.id)!;
|
||||
expect(review.fidelity).toBe("healer-man-authored");
|
||||
expect(review.exactSpellFamilies).toEqual({});
|
||||
expect(review.candidateUnboundSpellFamilies).toHaveProperty("gatlingMissileBarrage");
|
||||
|
||||
const attacks = definition.entities["entry-663012"].combat?.attacks ?? [];
|
||||
for (const spellId of [257_438, 257_459, 257_470, 257_472]) {
|
||||
const normal = attacks.find((entry) => entry.spellId === spellId && entry.id.endsWith("-normal"));
|
||||
const overdrive = attacks.find((entry) => entry.spellId === spellId && entry.id.endsWith("-overdrive"));
|
||||
expect(normal, `normal spell ${spellId}`).toBeDefined();
|
||||
expect(overdrive?.cooldownMs, `overdrive spell ${spellId}`).toBe(Math.round(normal!.cooldownMs * 0.8));
|
||||
}
|
||||
|
||||
const encounter = definition.encounters?.find((entry) => entry.id === "tinkertech-showdown:mechatron");
|
||||
expect(encounter?.source?.fidelity).toBe("healer-man-authored");
|
||||
let snapshot = createDungeonEncounterSnapshot(encounter!, 0);
|
||||
expect(reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "entity-health",
|
||||
entityId: "encounter-9013",
|
||||
healthPercent: 36,
|
||||
now: 1_000,
|
||||
}).snapshot).toBe(snapshot);
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "entity-health",
|
||||
entityId: "encounter-9013",
|
||||
healthPercent: 35,
|
||||
now: 2_000,
|
||||
}).snapshot;
|
||||
expect(snapshot).toMatchObject({
|
||||
phase: "overdrive",
|
||||
flags: { overdrive: true },
|
||||
activeSpawnGroupIds: ["tinkertech-showdown:overdrive-reinforcements"],
|
||||
});
|
||||
const reinforcements = definition.encounterSpawnGroups
|
||||
?.find((group) => group.id === "tinkertech-showdown:overdrive-reinforcements");
|
||||
expect(reinforcements?.spawns).toHaveLength(2);
|
||||
expect(reinforcements?.spawns.every((spawn) => spawn.entityId === "tinkertech-showdown-caster"))
|
||||
.toBe(true);
|
||||
const reset = resetDungeonEncounterSnapshot(encounter!, snapshot, "boss-wipe", 3_000);
|
||||
expect(reset).toMatchObject({ phase: "active", flags: { overdrive: false }, activeSpawnGroupIds: [] });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -185,5 +185,13 @@ describe("boss guide model", () => {
|
||||
});
|
||||
const flowingChaos = guideByDungeon.get("forsaken-abbey")!.bosses.find((boss) => boss.name === "Flowing Chaos")!;
|
||||
expect(flowingChaos.mechanics[0].fidelityLabel).toBe("HealerMan fallback");
|
||||
const theSeven = guideByDungeon.get("blackrock-depths")!.bosses.find((boss) => boss.name === "The Seven")!;
|
||||
expect(DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === "blackrock-depths")
|
||||
?.entities[theSeven.entityId!].combat?.serverEntry).toBe(9_039);
|
||||
const escape = guideByDungeon.get("halls-of-reflection")!.bosses
|
||||
.find((boss) => boss.name === "Escape from the Lich King")!;
|
||||
expect(DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === "halls-of-reflection")
|
||||
?.entities[escape.entityId!].combat?.serverEntry).toBe(36_954);
|
||||
expect(escape.mechanics.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
+58
-9
@@ -1,4 +1,4 @@
|
||||
import type { DungeonDefinition, DungeonExpansion } from "./dungeonTypes";
|
||||
import type { DungeonBossObjective, DungeonDefinition, DungeonExpansion } from "./dungeonTypes";
|
||||
import {
|
||||
normalizeEnemyAttack,
|
||||
type EnemyAbilityCondition,
|
||||
@@ -114,6 +114,10 @@ function describeEffect(effect: EnemyAbilityEffect | undefined, attack: EnemyAtt
|
||||
}
|
||||
if (effect.kind === "heal") return `Heals ${target} for ${effect.multiplier}× boss power`;
|
||||
if (effect.kind === "aura") {
|
||||
if (effect.meleeAutoCriticalProc) {
|
||||
const proc = effect.meleeAutoCriticalProc;
|
||||
return `Applies ${titleCase(effect.aura)} to ${target}; successful main-hand autos have a ${Math.round(proc.chance * 100)}% chance to grant ${proc.charges} charge${proc.charges === 1 ? "" : "s"} of +${proc.criticalChancePercent}% critical chance to the next main-hand auto for ${seconds(proc.durationMs)}`;
|
||||
}
|
||||
const magnitude = effect.magnitude === undefined ? "" : ` at ${effect.magnitude} strength`;
|
||||
return `Applies ${titleCase(effect.aura)} to ${target} for ${seconds(effect.durationMs)}${magnitude}`;
|
||||
}
|
||||
@@ -121,7 +125,10 @@ function describeEffect(effect: EnemyAbilityEffect | undefined, attack: EnemyAtt
|
||||
return `Applies ${titleCase(effect.aura)} to ${target} for ${seconds(effect.durationMs)}`;
|
||||
}
|
||||
if (effect.kind === "control") {
|
||||
return `${titleCase(effect.mechanic)} controls ${target} for ${seconds(effect.durationMs)}`;
|
||||
const damage = effect.damageMultiplier === undefined
|
||||
? ""
|
||||
: `${titleCase(attack.school)} damage (${Number(effect.damageMultiplier.toFixed(2))}× boss power) and `;
|
||||
return `${damage}${titleCase(effect.mechanic)} controls ${target} for ${seconds(effect.durationMs)}`;
|
||||
}
|
||||
if (effect.kind === "transform") return `Transforms ${target} into ${titleCase(effect.form)}`;
|
||||
if (effect.kind === "summon") return `Summons ${effect.count} add${effect.count === 1 ? "" : "s"} (creature ${effect.creatureEntry})`;
|
||||
@@ -271,18 +278,25 @@ function normalizedName(value: string): string {
|
||||
|
||||
function bossEntityForObjective(
|
||||
dungeon: DungeonDefinition,
|
||||
objectiveId: string,
|
||||
objectiveName: string,
|
||||
objective: DungeonBossObjective,
|
||||
): readonly [string, PopulationEntityDefinition] | undefined {
|
||||
const staticSpawn = dungeon.staticSpawns.find((spawn) => spawn.id === objectiveId);
|
||||
const staticSpawn = dungeon.staticSpawns.find((spawn) => spawn.id === objective.id);
|
||||
if (staticSpawn) {
|
||||
const entity = dungeon.entities[staticSpawn.entityId];
|
||||
if (entity) return [staticSpawn.entityId, entity];
|
||||
}
|
||||
|
||||
for (const group of dungeon.encounterSpawnGroups ?? []) {
|
||||
const spawn = group.spawns.find((candidate) => candidate.id === objective.id);
|
||||
if (spawn) {
|
||||
const entity = dungeon.entities[spawn.entityId];
|
||||
if (entity) return [spawn.entityId, entity];
|
||||
}
|
||||
}
|
||||
|
||||
for (const pack of dungeon.roamingPacks) {
|
||||
const member = pack.members.find((candidate) => (
|
||||
`${pack.id}:${candidate.id}` === objectiveId || candidate.id === objectiveId
|
||||
`${pack.id}:${candidate.id}` === objective.id || candidate.id === objective.id
|
||||
));
|
||||
if (member) {
|
||||
const entity = dungeon.entities[member.entityId];
|
||||
@@ -290,11 +304,46 @@ function bossEntityForObjective(
|
||||
}
|
||||
}
|
||||
|
||||
const wanted = normalizedName(objectiveName);
|
||||
return Object.entries(dungeon.entities).find(([, entity]) => (
|
||||
const wanted = normalizedName(objective.name);
|
||||
const named = Object.entries(dungeon.entities).find(([, entity]) => (
|
||||
entity.kind === "boss"
|
||||
&& (normalizedName(entity.name) === wanted || normalizedName(entity.title) === wanted)
|
||||
));
|
||||
if (named) return named;
|
||||
|
||||
if (objective.eventOnly) {
|
||||
const related = Object.entries(dungeon.entities)
|
||||
.filter(([, entity]) => {
|
||||
const entityName = normalizedName(entity.name);
|
||||
return entityName.length >= 5 && (wanted.includes(entityName) || entityName.includes(wanted));
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const leftBoss = left[1].kind === "boss" ? 1 : 0;
|
||||
const rightBoss = right[1].kind === "boss" ? 1 : 0;
|
||||
if (leftBoss !== rightBoss) return rightBoss - leftBoss;
|
||||
const leftMechanics = left[1].combat?.attacks.filter((attack) => attack.id !== "basic-attack").length ?? 0;
|
||||
const rightMechanics = right[1].combat?.attacks.filter((attack) => attack.id !== "basic-attack").length ?? 0;
|
||||
return rightMechanics - leftMechanics || left[0].localeCompare(right[0]);
|
||||
});
|
||||
if (related[0]) return related[0];
|
||||
}
|
||||
|
||||
// Aggregate objectives such as Blackrock Depths' "The Seven" use a
|
||||
// synthetic objective id but retain the final source actor's exact position.
|
||||
// That representative may be typed as a mob because the objective covers a
|
||||
// multi-actor event rather than one loot-bearing boss.
|
||||
for (const group of dungeon.encounterSpawnGroups ?? []) {
|
||||
const exact = group.spawns.filter((candidate) => {
|
||||
const dx = candidate.position[0] - objective.position[0];
|
||||
const dy = candidate.position[1] - objective.position[1];
|
||||
const dz = candidate.position[2] - objective.position[2];
|
||||
return dx * dx + dy * dy + dz * dz <= 0.25 * 0.25;
|
||||
});
|
||||
const spawn = exact.find((candidate) => dungeon.entities[candidate.entityId]?.kind === "boss")
|
||||
?? exact.find((candidate) => dungeon.entities[candidate.entityId] !== undefined);
|
||||
if (spawn) return [spawn.entityId, dungeon.entities[spawn.entityId]];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function bossGuide(
|
||||
@@ -325,7 +374,7 @@ export function buildDungeonBossGuide(
|
||||
): DungeonBossGuide {
|
||||
const representedEntityIds = new Set<string>();
|
||||
const objectiveBosses = dungeon.bosses.map((objective) => {
|
||||
const resolved = bossEntityForObjective(dungeon, objective.id, objective.name);
|
||||
const resolved = bossEntityForObjective(dungeon, objective);
|
||||
if (resolved) representedEntityIds.add(resolved[0]);
|
||||
return bossGuide(dungeon, objective.id, objective.name, resolved, overrides);
|
||||
});
|
||||
|
||||
@@ -59,12 +59,18 @@ describe("named aura combat runtime", () => {
|
||||
});
|
||||
|
||||
it("grants learned long-term stat buffs to the full party on load", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 8 });
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "priest",
|
||||
specializationId: "priest:holy",
|
||||
level: 8,
|
||||
inventory: [],
|
||||
equipment: {},
|
||||
});
|
||||
usePartyStore.getState().initializeParty("default-party-buffs", 8, useCombatStore.getState().health);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const playerMaxHealth = useCombatStore.getState().maxHealth;
|
||||
const memberMaxHealth = new Map(usePartyStore.getState().members.map((member) => [member.id, member.maxHealth]));
|
||||
|
||||
expect(playerMaxHealth).toBe(407);
|
||||
useCombatStore.getState().applyDefaultPartyBuffs(20_000);
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
@@ -75,7 +81,7 @@ describe("named aura combat runtime", () => {
|
||||
definition: expect.objectContaining({ exclusiveGroup: "default-party-buff:fortitude" }),
|
||||
}));
|
||||
}
|
||||
expect(state.maxHealth).toBeGreaterThan(playerMaxHealth);
|
||||
expect(state.maxHealth).toBe(437);
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
expect(member.maxHealth).toBeGreaterThan(memberMaxHealth.get(member.id)!);
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ export interface AuraDefinition {
|
||||
/** Explicit non-numeric gameplay state such as stealth, tracking, flight, or water walking. */
|
||||
readonly utilityTags?: readonly string[];
|
||||
/** Enemy control mechanics ignored while this aura is active. */
|
||||
readonly controlImmunities?: readonly ("fear" | "sleep" | "root" | "stun" | "silence")[];
|
||||
readonly controlImmunities?: readonly ("confuse" | "fear" | "sleep" | "root" | "stun" | "silence")[];
|
||||
/** Chance from 0 through 1 to reflect an incoming spell. */
|
||||
readonly reflectSpellChance?: number;
|
||||
/** Portion of incoming target damage transferred to the aura source. */
|
||||
|
||||
@@ -237,6 +237,20 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().maxResource).toBe(necromancerBase.mana);
|
||||
});
|
||||
|
||||
it("gives a geared level-eight Priest the expanded live-game mana pool", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "priest",
|
||||
specializationId: "priest:holy",
|
||||
level: 8,
|
||||
inventory: [],
|
||||
equipment: {},
|
||||
});
|
||||
expect(useCombatStore.getState()).toMatchObject({
|
||||
resource: 3_285,
|
||||
maxResource: 3_285,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses equipped spell power, stamina, and armor in player combat", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
|
||||
const baseMaxHealth = useCombatStore.getState().maxHealth;
|
||||
|
||||
+192
-41
@@ -179,6 +179,7 @@ import {
|
||||
type EnemyAttackDefinition,
|
||||
type EnemyControlMechanic,
|
||||
type EnemyDamageSchool,
|
||||
type EnemyMeleeAutoCriticalProc,
|
||||
} from "./enemyAttacks";
|
||||
import {
|
||||
creatureRuntimeBaseStats,
|
||||
@@ -294,6 +295,11 @@ export interface MobAura {
|
||||
readonly sourceAbilityId: string;
|
||||
readonly endsAt: number;
|
||||
readonly magnitude?: number;
|
||||
/** Persistent driver for a content-authored main-hand auto-attack proc. */
|
||||
readonly meleeAutoCriticalProc?: EnemyMeleeAutoCriticalProc;
|
||||
/** A charged modifier consumed by a successful main-hand auto attack. */
|
||||
readonly nextMeleeCriticalChancePercent?: number;
|
||||
readonly charges?: number;
|
||||
}
|
||||
|
||||
export interface MobMagicResistances {
|
||||
@@ -715,7 +721,10 @@ export interface CombatState {
|
||||
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
|
||||
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
|
||||
tick: (deltaSeconds: number, now?: number) => void;
|
||||
resetEncounter: () => void;
|
||||
resetEncounter: (options?: {
|
||||
readonly preserveDefeated?: boolean;
|
||||
readonly preserveDefeatedIds?: readonly string[];
|
||||
}) => void;
|
||||
allocateTalent: (treeId: string) => boolean;
|
||||
resetTalents: () => void;
|
||||
setActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, abilityId: string | null) => boolean;
|
||||
@@ -1354,6 +1363,81 @@ function enemyAttackKind(attack: EnemyAttackDefinition): WotlkAttackKind {
|
||||
return "spell";
|
||||
}
|
||||
|
||||
function isEnemyMainHandAutoAttack(attack: EnemyAttackDefinition): boolean {
|
||||
return attack.id === DEFAULT_ENEMY_ATTACK.id
|
||||
&& attack.delivery === "melee"
|
||||
&& attack.animation === "attack"
|
||||
&& (attack.castTimeMs ?? 0) === 0;
|
||||
}
|
||||
|
||||
function enemyMeleeCriticalCharge(
|
||||
mob: MobCombatState,
|
||||
attack: EnemyAttackDefinition,
|
||||
now: number,
|
||||
): MobAura | undefined {
|
||||
if (!isEnemyMainHandAutoAttack(attack)) return undefined;
|
||||
return mob.auras.find((aura) => (
|
||||
(aura.endsAt <= 0 || aura.endsAt > now)
|
||||
&& (aura.charges ?? 0) > 0
|
||||
&& (aura.nextMeleeCriticalChancePercent ?? 0) > 0
|
||||
));
|
||||
}
|
||||
|
||||
function advanceEnemyMeleeAutoProcAuras(
|
||||
mob: MobCombatState,
|
||||
attack: EnemyAttackDefinition,
|
||||
now: number,
|
||||
attackRevision: number,
|
||||
landed: boolean,
|
||||
consumedAuraId?: string,
|
||||
): readonly MobAura[] {
|
||||
if (!landed || !isEnemyMainHandAutoAttack(attack)) return mob.auras;
|
||||
|
||||
let changed = false;
|
||||
let nextAuras = mob.auras.flatMap((aura): MobAura[] => {
|
||||
if (aura.id !== consumedAuraId) return [aura];
|
||||
changed = true;
|
||||
const charges = Math.max(0, Math.round(aura.charges ?? 0) - 1);
|
||||
return charges > 0 ? [{ ...aura, charges }] : [];
|
||||
});
|
||||
|
||||
for (const driver of mob.auras) {
|
||||
const proc = driver.meleeAutoCriticalProc;
|
||||
if (!proc || (driver.endsAt > 0 && driver.endsAt <= now)) continue;
|
||||
const chance = clamp(Number.isFinite(proc.chance) ? proc.chance : 0, 0, 1);
|
||||
if (
|
||||
chance <= 0
|
||||
|| stableCombatUnit(
|
||||
`${mob.id}:${driver.id}:${attack.id}:${attackRevision}:${now}:melee-auto-critical-proc`,
|
||||
) >= chance
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const triggeredAura = proc.triggeredAura.trim();
|
||||
const criticalChancePercent = Math.max(
|
||||
0,
|
||||
Number.isFinite(proc.criticalChancePercent) ? proc.criticalChancePercent : 0,
|
||||
);
|
||||
const charges = Math.max(1, Math.round(
|
||||
Number.isFinite(proc.charges) ? proc.charges : 1,
|
||||
));
|
||||
if (!triggeredAura || criticalChancePercent <= 0) continue;
|
||||
nextAuras = [
|
||||
...nextAuras.filter((aura) => aura.id !== triggeredAura),
|
||||
{
|
||||
id: triggeredAura,
|
||||
sourceAbilityId: driver.sourceAbilityId,
|
||||
endsAt: proc.durationMs > 0 ? now + Math.round(proc.durationMs) : 0,
|
||||
nextMeleeCriticalChancePercent: criticalChancePercent,
|
||||
charges,
|
||||
},
|
||||
];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? nextAuras : mob.auras;
|
||||
}
|
||||
|
||||
function resolveEnemyDamageResult(
|
||||
mob: MobCombatState,
|
||||
attack: EnemyAttackDefinition,
|
||||
@@ -1362,6 +1446,7 @@ function resolveEnemyDamageResult(
|
||||
actorId: AggroActorId,
|
||||
attackRevision: number,
|
||||
now: number,
|
||||
bonusCriticalChancePercent = 0,
|
||||
): CombatResult {
|
||||
const attackKind = enemyAttackKind(attack);
|
||||
return resolveCombatResult({
|
||||
@@ -1370,7 +1455,7 @@ function resolveEnemyDamageResult(
|
||||
damageSchool: attack.school,
|
||||
attacker: {
|
||||
level: mob.level,
|
||||
criticalChancePercent: 5,
|
||||
criticalChancePercent: 5 + Math.max(0, bonusCriticalChancePercent),
|
||||
},
|
||||
defender: combatDefenderFromCharacterStats(defender),
|
||||
rules: {
|
||||
@@ -2194,7 +2279,8 @@ function evaluateProcsInWork(
|
||||
function eligibleTargetIds(state: CombatState, origin: CombatPosition, maxRange: number): string[] {
|
||||
return nearbyMobRuntimeIds(origin, maxRange).filter((id) => {
|
||||
const mob = state.mobs[id];
|
||||
return Boolean(mob && !mob.dead);
|
||||
const position = getMobPosition(id);
|
||||
return Boolean(mob && !mob.dead && position && combatHasLineOfSight(origin, position));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6603,6 +6689,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
sourceAbilityId: attack.id,
|
||||
endsAt,
|
||||
...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }),
|
||||
...(effect.meleeAutoCriticalProc === undefined
|
||||
? {}
|
||||
: { meleeAutoCriticalProc: effect.meleeAutoCriticalProc }),
|
||||
},
|
||||
];
|
||||
namedAuras = [...applyCombatAura(namedAuras, {
|
||||
@@ -6633,6 +6722,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}).auras];
|
||||
}
|
||||
} else if (effect.kind === "control") {
|
||||
if ((effect.damageMultiplier ?? 0) > 0) {
|
||||
const baselineDamage = Math.max(0, Math.round(mob.attackDamage * effect.damageMultiplier!));
|
||||
const attackDamage = attack.runewakerFormula
|
||||
? adaptRunewakerEnemyDamage({
|
||||
formula: attack.runewakerFormula,
|
||||
baselineDamage: mob.attackDamage,
|
||||
}).amount
|
||||
: baselineDamage;
|
||||
for (const actorId of targets) {
|
||||
incomingHits.push({
|
||||
actorId,
|
||||
amount: attackDamage,
|
||||
mob,
|
||||
attack,
|
||||
attackRevision,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const actorId of targets) {
|
||||
if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue;
|
||||
const control = { mechanic: effect.mechanic, endsAt: now + effect.durationMs };
|
||||
@@ -6823,6 +6930,29 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const procMob = get().mobs[hit.mob.id] ?? hit.mob;
|
||||
const meleeCriticalCharge = enemyMeleeCriticalCharge(procMob, hit.attack, now);
|
||||
const bonusCriticalChancePercent = meleeCriticalCharge?.nextMeleeCriticalChancePercent ?? 0;
|
||||
const commitMeleeAutoProc = (landed: boolean): void => {
|
||||
const current = get();
|
||||
const currentMob = current.mobs[hit.mob.id];
|
||||
if (!currentMob) return;
|
||||
const auras = advanceEnemyMeleeAutoProcAuras(
|
||||
currentMob,
|
||||
hit.attack,
|
||||
now,
|
||||
hit.attackRevision,
|
||||
landed,
|
||||
meleeCriticalCharge?.id,
|
||||
);
|
||||
if (auras === currentMob.auras) return;
|
||||
set({
|
||||
mobs: {
|
||||
...current.mobs,
|
||||
[currentMob.id]: { ...currentMob, auras },
|
||||
},
|
||||
});
|
||||
};
|
||||
if (hit.actorId === PLAYER_AGGRO_ID) {
|
||||
const current = get();
|
||||
const defender = applyAuraStats(
|
||||
@@ -6844,7 +6974,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
hit.actorId,
|
||||
hit.attackRevision,
|
||||
now,
|
||||
bonusCriticalChancePercent,
|
||||
);
|
||||
commitMeleeAutoProc(combatResult.landed);
|
||||
const auraAdjustedDamage = resolveAuraDamageValue(
|
||||
combatResult.finalDamage,
|
||||
aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID),
|
||||
@@ -6984,8 +7116,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const summon = get().summons.find((candidate) => candidate.id === hit.actorId);
|
||||
if (summon) {
|
||||
const current = get();
|
||||
const critical = bonusCriticalChancePercent > 0;
|
||||
const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1));
|
||||
commitMeleeAutoProc(true);
|
||||
const adjusted = resolveAuraDamageValue(
|
||||
hit.amount,
|
||||
incomingAmount,
|
||||
aurasForEntity(current.auras, summon.id),
|
||||
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
|
||||
);
|
||||
@@ -7010,12 +7145,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
targetActorId: summon.id,
|
||||
abilityId: hit.attack.id,
|
||||
school: hit.attack.school,
|
||||
rawAmount: hit.amount,
|
||||
rawAmount: incomingAmount,
|
||||
effectiveAmount: applied,
|
||||
overheal: 0,
|
||||
absorbed: absorbed.absorbed,
|
||||
critical: false,
|
||||
outcome: applied > 0 ? "hit" : "absorb",
|
||||
critical,
|
||||
outcome: applied > 0 ? (critical ? "crit" : "hit") : "absorb",
|
||||
}),
|
||||
});
|
||||
appliedDamage += applied;
|
||||
@@ -7024,8 +7159,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const onlineActor = onlinePlayerRuntimeActor(hit.actorId);
|
||||
if (onlineActor) {
|
||||
const current = get();
|
||||
const critical = bonusCriticalChancePercent > 0;
|
||||
const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1));
|
||||
commitMeleeAutoProc(true);
|
||||
const adjusted = resolveAuraDamageValue(
|
||||
hit.amount,
|
||||
incomingAmount,
|
||||
aurasForEntity(current.auras, onlineActor.id),
|
||||
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
|
||||
);
|
||||
@@ -7052,12 +7190,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
targetActorId: onlineActor.id,
|
||||
abilityId: hit.attack.id,
|
||||
school: hit.attack.school,
|
||||
rawAmount: hit.amount,
|
||||
rawAmount: incomingAmount,
|
||||
effectiveAmount: damaged,
|
||||
overheal: 0,
|
||||
absorbed: absorbed.absorbed,
|
||||
critical: false,
|
||||
outcome: damaged > 0 ? "hit" : "absorb",
|
||||
critical,
|
||||
outcome: damaged > 0 ? (critical ? "crit" : "hit") : "absorb",
|
||||
}),
|
||||
});
|
||||
appliedDamage += damaged;
|
||||
@@ -7090,7 +7228,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
hit.actorId,
|
||||
hit.attackRevision,
|
||||
now,
|
||||
bonusCriticalChancePercent,
|
||||
);
|
||||
commitMeleeAutoProc(combatResult.landed);
|
||||
const currentAuras = get().auras;
|
||||
const auraAdjustedDamage = resolveAuraDamageValue(
|
||||
combatResult.finalDamage,
|
||||
@@ -8035,41 +8175,47 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
resetEncounter: () => set((state) => {
|
||||
resetEncounter: (options = {}) => set((state) => {
|
||||
resetCombatPresentationSession();
|
||||
const preserveDefeatedIds = new Set(options.preserveDefeatedIds ?? []);
|
||||
for (const mob of Object.values(state.mobs)) {
|
||||
if (mob.summonedBy) removeMobPosition(mob.id);
|
||||
}
|
||||
const mobs = Object.fromEntries(Object.entries(state.mobs)
|
||||
.filter(([, mob]) => !mob.summonedBy)
|
||||
.map(([id, mob]) => [id, {
|
||||
...mob,
|
||||
health: mob.maxHealth,
|
||||
dead: false,
|
||||
pendingLoot: [],
|
||||
looted: false,
|
||||
despawned: false,
|
||||
diedAt: null,
|
||||
corpseDespawnAt: null,
|
||||
experienceGranted: false,
|
||||
statuses: [],
|
||||
controlDiminishingReturns: {},
|
||||
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
|
||||
auras: [],
|
||||
threatByActor: {},
|
||||
targetActorId: null,
|
||||
forcedTarget: null,
|
||||
lastDamageAt: 0,
|
||||
engaged: false,
|
||||
combatPhase: "idle" as const,
|
||||
homePosition: null,
|
||||
nextAttackAt: 0,
|
||||
abilityReadyAt: {},
|
||||
abilityCastCounts: {},
|
||||
activeCast: null,
|
||||
schoolLockedUntil: {},
|
||||
nextWoundAnimationAt: 0,
|
||||
}]));
|
||||
.map(([id, mob]) => {
|
||||
const preserveDefeated = mob.dead && (
|
||||
options.preserveDefeated === true || preserveDefeatedIds.has(id)
|
||||
);
|
||||
return [id, {
|
||||
...mob,
|
||||
health: preserveDefeated ? 0 : mob.maxHealth,
|
||||
dead: preserveDefeated,
|
||||
pendingLoot: preserveDefeated ? mob.pendingLoot : [],
|
||||
looted: preserveDefeated ? mob.looted : false,
|
||||
despawned: preserveDefeated ? mob.despawned : false,
|
||||
diedAt: preserveDefeated ? mob.diedAt : null,
|
||||
corpseDespawnAt: preserveDefeated ? mob.corpseDespawnAt : null,
|
||||
experienceGranted: preserveDefeated ? mob.experienceGranted : false,
|
||||
statuses: [],
|
||||
controlDiminishingReturns: {},
|
||||
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
|
||||
auras: [],
|
||||
threatByActor: {},
|
||||
targetActorId: null,
|
||||
forcedTarget: null,
|
||||
lastDamageAt: 0,
|
||||
engaged: false,
|
||||
combatPhase: "idle" as const,
|
||||
homePosition: null,
|
||||
nextAttackAt: 0,
|
||||
abilityReadyAt: {},
|
||||
abilityCastCounts: {},
|
||||
activeCast: null,
|
||||
schoolLockedUntil: {},
|
||||
nextWoundAnimationAt: 0,
|
||||
}] as const;
|
||||
}));
|
||||
const auras = syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow());
|
||||
const maxHealth = auraAdjustedPlayerMaxHealth(
|
||||
state.classId,
|
||||
@@ -8116,7 +8262,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
combatEvents: [],
|
||||
actors: {},
|
||||
lastCombatResult: null,
|
||||
feedback: nextFeedback("success", "The encounter has been reset."),
|
||||
feedback: nextFeedback(
|
||||
"success",
|
||||
options.preserveDefeated || preserveDefeatedIds.size > 0
|
||||
? "The current pull has been reset; defeated enemies remain cleared."
|
||||
: "The encounter has been reset.",
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { DUNGEON_MANIFEST } from "./dungeonManifest";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { respawnDefeatedPlayer } from "./deathRespawn";
|
||||
@@ -15,13 +14,22 @@ describe("death respawn", () => {
|
||||
useWailingEncounterStore.getState().reset();
|
||||
});
|
||||
|
||||
it("atomically restores the player and encounter at the Wailing Caverns entrance", () => {
|
||||
it("resets only the current pull and preserves cleared enemies and run progress", () => {
|
||||
const startingResource = useCombatStore.getState().maxResource;
|
||||
const checkpointSpawn = { footPosition: [4, -70, 12] as const, yaw: 0.25 };
|
||||
useGameStore.getState().relocatePlayer(checkpointSpawn, true);
|
||||
useGameStore.getState().updatePlayerSnapshot({ position: [10, -50, 20], yaw: 0.5, distanceDelta: 30 });
|
||||
useGameStore.getState().setCameraLookActive(true);
|
||||
|
||||
useCombatStore.getState().registerMob("respawn-pack:a", { name: "Ravager", maxHealth: 100 });
|
||||
useCombatStore.getState().registerMob("cleared-boss", { name: "Cleared Boss", maxHealth: 50, boss: true });
|
||||
useCombatStore.getState().registerMob("naralex-event:ravager:1", {
|
||||
name: "Event Ravager",
|
||||
maxHealth: 75,
|
||||
});
|
||||
setMobPosition("respawn-pack:a", [11, -50, 20]);
|
||||
useCombatStore.getState().damageMob("cleared-boss", 50, 900);
|
||||
useCombatStore.getState().damageMob("naralex-event:ravager:1", 75, 950);
|
||||
useCombatStore.getState().selectMob("respawn-pack:a");
|
||||
useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000);
|
||||
expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true);
|
||||
@@ -30,14 +38,25 @@ describe("death respawn", () => {
|
||||
);
|
||||
|
||||
const revisionBefore = useGameStore.getState().resetRevision;
|
||||
useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 });
|
||||
useWailingEncounterStore.setState({
|
||||
phase: "mutanus",
|
||||
phaseStartedAt: 5_000,
|
||||
flags: { "dungeon-complete": true },
|
||||
completedObjectiveIds: ["mutanus-spawn"],
|
||||
consumedActionIds: [
|
||||
"complete-mutanus-objective",
|
||||
"save-mutanus-checkpoint",
|
||||
"mark-wailing-complete",
|
||||
],
|
||||
checkpointId: "mutanus-spawn",
|
||||
});
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(respawnDefeatedPlayer()).toBe(true);
|
||||
|
||||
expect(useGameStore.getState()).toMatchObject({
|
||||
playerPosition: DUNGEON_MANIFEST.entrance.footPosition,
|
||||
cameraYaw: DUNGEON_MANIFEST.entrance.yaw,
|
||||
playerPosition: checkpointSpawn.footPosition,
|
||||
cameraYaw: checkpointSpawn.yaw,
|
||||
cameraLookActive: false,
|
||||
resetRevision: revisionBefore + 1,
|
||||
});
|
||||
@@ -45,16 +64,29 @@ describe("death respawn", () => {
|
||||
health: useCombatStore.getState().maxHealth,
|
||||
resource: startingResource,
|
||||
selectedTargetId: null,
|
||||
playerPosition: DUNGEON_MANIFEST.entrance.footPosition,
|
||||
playerPosition: checkpointSpawn.footPosition,
|
||||
});
|
||||
expect(useCombatStore.getState().mobs["respawn-pack:a"]).toMatchObject({
|
||||
health: 100,
|
||||
dead: false,
|
||||
engaged: false,
|
||||
});
|
||||
expect(useCombatStore.getState().mobs["cleared-boss"]).toMatchObject({
|
||||
health: 0,
|
||||
dead: true,
|
||||
engaged: false,
|
||||
});
|
||||
expect(useCombatStore.getState().mobs["naralex-event:ravager:1"]).toMatchObject({
|
||||
health: 75,
|
||||
dead: false,
|
||||
engaged: false,
|
||||
});
|
||||
expect(useWailingEncounterStore.getState()).toMatchObject({
|
||||
phase: "locked",
|
||||
phaseStartedAt: 0,
|
||||
flags: { "dungeon-complete": true },
|
||||
completedObjectiveIds: ["mutanus-spawn"],
|
||||
checkpointId: "mutanus-spawn",
|
||||
});
|
||||
|
||||
expect(respawnDefeatedPlayer()).toBe(false);
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { useGameStore } from "./store";
|
||||
import { resetDungeonEncounters } from "./dungeonEncounterRuntime";
|
||||
import {
|
||||
activeDungeonCompletedObjectiveIds,
|
||||
resetDungeonEncounters,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
|
||||
/**
|
||||
* Atomically returns a defeated player to the dungeon entrance and restores
|
||||
* the encounter. Keeping this outside the render tree makes the behavior easy
|
||||
* to verify and prevents a frame where the camera resets but combat stays dead.
|
||||
* Atomically returns a defeated player to the saved run spawn and resets the
|
||||
* current pull. Defeated enemies and persisted dungeon progress remain clear,
|
||||
* matching a wipe instead of silently starting a brand-new dungeon.
|
||||
*/
|
||||
export function respawnDefeatedPlayer(): boolean {
|
||||
const combat = useCombatStore.getState();
|
||||
if (combat.health > 0) return false;
|
||||
|
||||
const dungeonId = useGameStore.getState().activeDungeonId;
|
||||
const completedObjectiveIds = new Set(activeDungeonCompletedObjectiveIds(dungeonId));
|
||||
const retrySpawnIds = new Set(
|
||||
(requireDungeonDefinition(dungeonId).encounterSpawnGroups ?? [])
|
||||
.flatMap((group) => group.spawns.map((spawn) => spawn.id)),
|
||||
);
|
||||
const preserveDefeatedIds = Object.values(combat.mobs)
|
||||
.filter((mob) => (
|
||||
mob.dead
|
||||
&& (!retrySpawnIds.has(mob.id) || completedObjectiveIds.has(mob.id))
|
||||
))
|
||||
.map((mob) => mob.id);
|
||||
|
||||
if (typeof document !== "undefined" && document.pointerLockElement) {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
useGameStore.getState().resetAtEntrance();
|
||||
resetDungeonEncounters("dungeon", useGameStore.getState().activeDungeonId);
|
||||
const entrance = useGameStore.getState().playerPosition;
|
||||
combat.resetEncounter();
|
||||
useCombatStore.getState().setPlayerPosition(entrance);
|
||||
useGameStore.getState().resetAfterDefeat();
|
||||
resetDungeonEncounters("boss-wipe", dungeonId);
|
||||
const respawnPosition = useGameStore.getState().playerPosition;
|
||||
combat.resetEncounter({ preserveDefeatedIds });
|
||||
useCombatStore.getState().setPlayerPosition(respawnPosition);
|
||||
return true;
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import campaign from "../../dungeon-pipeline/dungeon-campaign.json";
|
||||
import epochInstances from "../../dungeon-pipeline/epoch-five-player-instances.json";
|
||||
import gameObjectAssetManifest from "../../dungeon-pipeline/WOW_GAMEOBJECT_ASSET_MANIFEST.json";
|
||||
import generated from "./generated/dungeonCampaignCatalog.json";
|
||||
import manastormPackages from "./generated/manastormAssetPackages.json";
|
||||
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import {
|
||||
createDungeonEncounterSnapshot,
|
||||
reduceDungeonEncounter,
|
||||
resetDungeonEncounterSnapshot,
|
||||
validateDungeonEncounterDefinition,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type { DungeonEncounterDefinition } from "./dungeonEncounterTypes";
|
||||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||||
|
||||
const SOURCE_NATIVE_DUNGEON_IDS = [
|
||||
"ahnkahet-old-kingdom",
|
||||
@@ -77,6 +86,313 @@ describe("generated dungeon campaign catalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("never exposes raw database gameobject rows to the live world-object renderer", () => {
|
||||
for (const dungeon of DUNGEON_DEFINITIONS) {
|
||||
for (const object of dungeon.serverGameObjects) {
|
||||
expect(() => validateDungeonWorldObject(object), `${dungeon.id}:${object.id}`).not.toThrow();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("gives every WoW dungeon executable persisted boss progression", () => {
|
||||
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
|
||||
campaign.dungeons.some((entry) => entry.slug === dungeon.id)
|
||||
));
|
||||
expect(wowDungeons).toHaveLength(54);
|
||||
|
||||
for (const dungeon of wowDungeons) {
|
||||
const objectiveIds = dungeon.objectiveOrder.map((objective) => objective.id);
|
||||
expect(new Set(objectiveIds).size, `${dungeon.id}:duplicate-objective-id`).toBe(objectiveIds.length);
|
||||
const encounters = dungeon.encounters ?? [];
|
||||
for (const encounter of encounters) {
|
||||
expect(() => validateDungeonEncounterDefinition(encounter), `${dungeon.id}:${encounter.id}`).not.toThrow();
|
||||
}
|
||||
const allCompletedObjectiveIds = new Set(encounters.flatMap((encounter) => (
|
||||
encounter.rules.flatMap((rule) => rule.actions.flatMap((action) => (
|
||||
action.kind === "complete-objective" ? [action.objectiveId] : []
|
||||
)))
|
||||
)));
|
||||
for (const objective of dungeon.objectiveOrder) {
|
||||
expect(allCompletedObjectiveIds.has(objective.id), `${dungeon.id}:${objective.name}`).toBe(true);
|
||||
}
|
||||
const progression = dungeon.encounters?.find(
|
||||
(encounter) => encounter.source?.scope === "boss-progression",
|
||||
) as DungeonEncounterDefinition | undefined;
|
||||
const instanceCompletion = encounters.find((encounter) => (
|
||||
encounter.source?.scope === "instance-orchestration"
|
||||
&& encounter.rules.some((rule) => rule.actions.some((action) => (
|
||||
action.kind === "set-flag"
|
||||
&& action.flagId === "dungeon-complete"
|
||||
&& action.value
|
||||
)))
|
||||
));
|
||||
const authority = progression ?? instanceCompletion;
|
||||
expect(authority, dungeon.id).toBeDefined();
|
||||
expect(authority!.source?.fidelity, dungeon.id).toBe(
|
||||
dungeon.id === "tinkertech-showdown"
|
||||
? "healer-man-authored"
|
||||
: "source-backed-adapted",
|
||||
);
|
||||
|
||||
if (!progression) {
|
||||
expect(instanceCompletion?.source?.scope, dungeon.id).toBe("instance-orchestration");
|
||||
const completedObjectiveIds = new Set(instanceCompletion!.rules.flatMap((rule) => (
|
||||
rule.actions.flatMap((action) => (
|
||||
action.kind === "complete-objective" ? [action.objectiveId] : []
|
||||
))
|
||||
)));
|
||||
for (const objective of dungeon.objectiveOrder.filter((candidate) => !candidate.optional)) {
|
||||
expect(completedObjectiveIds.has(objective.id), `${dungeon.id}:${objective.name}`).toBe(true);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const progressionObjectiveIds = new Set(progression.rules.flatMap((rule) => (
|
||||
rule.actions.flatMap((action) => (
|
||||
action.kind === "complete-objective" ? [action.objectiveId] : []
|
||||
))
|
||||
)));
|
||||
const runtimeBosses = dungeon.objectiveOrder.filter((objective) => (
|
||||
progressionObjectiveIds.has(objective.id)
|
||||
));
|
||||
let snapshot = createDungeonEncounterSnapshot(progression, 1_000);
|
||||
const deadEntityIds: string[] = [];
|
||||
for (const boss of runtimeBosses) {
|
||||
deadEntityIds.push(boss.id);
|
||||
snapshot = reduceDungeonEncounter(progression, snapshot, {
|
||||
kind: "tick",
|
||||
now: 1_000 + deadEntityIds.length * 100,
|
||||
facts: {
|
||||
registeredEntityIds: runtimeBosses.map((objective) => objective.id),
|
||||
deadEntityIds,
|
||||
},
|
||||
}).snapshot;
|
||||
expect(snapshot.completedObjectiveIds, `${dungeon.id}:${boss.id}`).toContain(boss.id);
|
||||
}
|
||||
|
||||
const checkpoint = resetDungeonEncounterSnapshot(progression, snapshot, "checkpoint", 5_000);
|
||||
expect(checkpoint.completedObjectiveIds, dungeon.id)
|
||||
.toEqual(expect.arrayContaining(runtimeBosses.map((boss) => boss.id)));
|
||||
const completesFromObjectivesAlone = progression.rules.some((rule) => (
|
||||
rule.actions.some((action) => (
|
||||
action.kind === "set-flag" && action.flagId === "dungeon-complete" && action.value
|
||||
))
|
||||
&& (rule.guards ?? []).every((guard) => guard.kind === "objective-complete")
|
||||
));
|
||||
if (!completesFromObjectivesAlone) {
|
||||
// A full source adapter may own completion inside a separate encounter;
|
||||
// its focused lifecycle suite exercises that path.
|
||||
expect(snapshot.flags["dungeon-complete"], dungeon.id).toBe(false);
|
||||
} else {
|
||||
expect(snapshot.flags["dungeon-complete"], dungeon.id).toBe(true);
|
||||
expect(snapshot.phase, dungeon.id).toBe("complete");
|
||||
expect(checkpoint.flags["dungeon-complete"], dungeon.id).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("has an instance-level source review for every campaign dungeon", () => {
|
||||
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
|
||||
campaign.dungeons.some((entry) => entry.slug === dungeon.id)
|
||||
));
|
||||
const verifiedNoOps: string[] = [];
|
||||
for (const dungeon of wowDungeons) {
|
||||
const reviewed = (dungeon.encounters ?? []).filter(
|
||||
(encounter) => encounter.source?.scope === "instance-orchestration",
|
||||
);
|
||||
expect(reviewed.length, dungeon.id).toBeGreaterThan(0);
|
||||
for (const encounter of reviewed) {
|
||||
expect(encounter.source?.files.length, `${dungeon.id}:${encounter.id}:files`).toBeGreaterThan(0);
|
||||
expect(encounter.source?.reviewedBehavior.trim(), `${dungeon.id}:${encounter.id}:review`).not.toBe("");
|
||||
if (!encounter.rules.length) {
|
||||
expect(encounter.source?.fidelity, `${dungeon.id}:${encounter.id}`).toBe("source-exact");
|
||||
expect(encounter.source?.adaptations, `${dungeon.id}:${encounter.id}`).toEqual([]);
|
||||
expect(encounter.source?.remainingGaps, `${dungeon.id}:${encounter.id}`).toEqual([]);
|
||||
verifiedNoOps.push(dungeon.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(wowDungeons).toHaveLength(54);
|
||||
expect(verifiedNoOps.sort()).toEqual([
|
||||
"ragefire-chasm",
|
||||
"razorfen-kraul",
|
||||
"stormwind-stockade",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gates every required event-only objective behind an instance completion authority", () => {
|
||||
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
|
||||
campaign.dungeons.some((entry) => entry.slug === dungeon.id)
|
||||
));
|
||||
for (const dungeon of wowDungeons) {
|
||||
const requiredEvents = dungeon.objectiveOrder.filter(
|
||||
(objective) => objective.eventOnly && !objective.optional,
|
||||
);
|
||||
if (!requiredEvents.length) continue;
|
||||
const progression = dungeon.encounters?.find(
|
||||
(encounter) => encounter.source?.scope === "boss-progression",
|
||||
);
|
||||
const baselineGatedObjectiveIds = new Set((progression?.rules ?? []).flatMap((rule) => (
|
||||
rule.actions.some((action) => (
|
||||
action.kind === "set-flag"
|
||||
&& action.flagId === "dungeon-complete"
|
||||
&& action.value
|
||||
))
|
||||
? (rule.guards ?? []).flatMap((guard) => (
|
||||
guard.kind === "objective-complete" && guard.equals ? [guard.objectiveId] : []
|
||||
))
|
||||
: []
|
||||
)));
|
||||
const completionAuthorities = (dungeon.encounters ?? []).filter((encounter) => (
|
||||
encounter.source?.scope === "instance-orchestration"
|
||||
&& encounter.rules.some((rule) => rule.actions.some((action) => (
|
||||
action.kind === "set-flag"
|
||||
&& action.flagId === "dungeon-complete"
|
||||
&& action.value
|
||||
)))
|
||||
));
|
||||
for (const objective of requiredEvents) {
|
||||
expect(
|
||||
baselineGatedObjectiveIds.has(objective.id) || completionAuthorities.length > 0,
|
||||
`${dungeon.id}:${objective.name}`,
|
||||
).toBe(true);
|
||||
if (progression && completionAuthorities.length) {
|
||||
const completionRules = completionAuthorities.flatMap((encounter) => (
|
||||
encounter.rules.filter((rule) => rule.actions.some((action) => (
|
||||
action.kind === "set-flag"
|
||||
&& action.flagId === "dungeon-complete"
|
||||
&& action.value
|
||||
)))
|
||||
));
|
||||
expect(completionRules.length, `${dungeon.id}:completion-authority`).toBeGreaterThan(0);
|
||||
expect(completionRules.every((rule) => (
|
||||
(rule.guards ?? []).some((guard) => (
|
||||
guard.kind === "objective-complete"
|
||||
&& guard.objectiveId === objective.id
|
||||
&& guard.equals
|
||||
))
|
||||
|| rule.actions.some((action) => (
|
||||
action.kind === "complete-objective" && action.objectiveId === objective.id
|
||||
))
|
||||
)), `${dungeon.id}:${objective.name}:premature-completion`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("only uses difficulty guards that match declared dungeon variants", () => {
|
||||
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
|
||||
campaign.dungeons.some((entry) => entry.slug === dungeon.id)
|
||||
));
|
||||
for (const dungeon of wowDungeons) {
|
||||
const difficultyIds = new Set(dungeon.difficultyVariants.map((variant) => variant.id));
|
||||
for (const encounter of dungeon.encounters ?? []) {
|
||||
for (const rule of encounter.rules) {
|
||||
for (const guard of rule.guards ?? []) {
|
||||
if (guard.kind !== "difficulty") continue;
|
||||
expect(guard.difficultyIds.length, `${dungeon.id}:${rule.id}`).toBeGreaterThan(0);
|
||||
for (const difficultyId of guard.difficultyIds) {
|
||||
expect(difficultyIds.has(difficultyId), `${dungeon.id}:${rule.id}:${difficultyId}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("completes ordinary dungeons regardless of required boss kill order", () => {
|
||||
const dungeon = requireDungeonDefinition("deadmines");
|
||||
const progression = dungeon.encounters?.find(
|
||||
(encounter) => encounter.id === "deadmines:boss-progression",
|
||||
);
|
||||
expect(progression).toBeDefined();
|
||||
const required = dungeon.objectiveOrder.filter((objective) => !objective.optional && !objective.eventOnly);
|
||||
expect(required.length).toBeGreaterThan(1);
|
||||
const killOrder = [required.at(-1)!, ...required.slice(0, -1)];
|
||||
const deadEntityIds: string[] = [];
|
||||
let snapshot = createDungeonEncounterSnapshot(progression!, 1_000);
|
||||
for (const boss of killOrder) {
|
||||
deadEntityIds.push(boss.id);
|
||||
snapshot = reduceDungeonEncounter(progression!, snapshot, {
|
||||
kind: "tick",
|
||||
now: 1_000 + deadEntityIds.length * 100,
|
||||
facts: {
|
||||
registeredEntityIds: required.map((objective) => objective.id),
|
||||
deadEntityIds,
|
||||
},
|
||||
}).snapshot;
|
||||
}
|
||||
expect(snapshot.flags["dungeon-complete"]).toBe(true);
|
||||
expect(snapshot.phase).toBe("complete");
|
||||
});
|
||||
|
||||
it("compiles every sourced WoW gameobject row with stable source provenance", () => {
|
||||
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
|
||||
const objects = wowDungeons.flatMap((dungeon) => dungeon.serverGameObjects);
|
||||
const sourceRows = objects.filter((object) => (
|
||||
object.source?.templateId !== undefined && object.source?.spawnId !== undefined
|
||||
));
|
||||
expect(wowDungeons).toHaveLength(54);
|
||||
expect(sourceRows).toHaveLength(3_943);
|
||||
expect(sourceRows.filter((object) => object.source?.clientDisplayId)).toHaveLength(3_905);
|
||||
expect(sourceRows.filter((object) => object.source?.clientModelPath)).toHaveLength(3_837);
|
||||
expect(sourceRows.filter((object) => object.source?.clientBounds)).toHaveLength(3_547);
|
||||
for (const dungeon of wowDungeons) {
|
||||
expect(new Set(dungeon.serverGameObjects.map((object) => object.id)).size)
|
||||
.toBe(dungeon.serverGameObjects.length);
|
||||
for (const object of dungeon.serverGameObjects) {
|
||||
expect(object.source?.fidelity).toBe("source-backed-adapted");
|
||||
expect(object.source?.files.length).toBeGreaterThan(0);
|
||||
if (object.source?.spawnId !== undefined) {
|
||||
expect(object.source.templateId).toBeGreaterThan(0);
|
||||
expect(object.source.spawnId).toBeGreaterThan(0);
|
||||
expect(object.source.gameObjectType).toBeGreaterThanOrEqual(0);
|
||||
expect(object.source.gameObjectType).toBeLessThanOrEqual(35);
|
||||
}
|
||||
if (object.visual?.hidden) {
|
||||
expect(object.interaction).toBeUndefined();
|
||||
expect(object.states.every((state) => !state.blocksMovement && !state.blocksSight)).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes a deduplicated export manifest for source-backed gameobject visuals", () => {
|
||||
expect(gameObjectAssetManifest.summary).toEqual({
|
||||
typedInstances: 3_943,
|
||||
visualDisplayInstances: 3_905,
|
||||
nonVisualInstances: 38,
|
||||
resolvedModelInstances: 3_837,
|
||||
boundedInstances: 3_547,
|
||||
uniqueAssets: 458,
|
||||
unresolvedModelInstances: 68,
|
||||
unresolvedBoundInstances: 358,
|
||||
unresolvedModelDisplayIds: 9,
|
||||
unresolvedBoundDisplayIds: 68,
|
||||
});
|
||||
expect(gameObjectAssetManifest.assets).toHaveLength(458);
|
||||
expect(new Set(gameObjectAssetManifest.assets.map(
|
||||
(asset) => asset.sourceModelPath.toLowerCase(),
|
||||
)).size).toBe(gameObjectAssetManifest.assets.length);
|
||||
expect(gameObjectAssetManifest.assets.every((asset) => (
|
||||
["mdl", "mdx"].includes(asset.sourceType)
|
||||
&& asset.targetUrl.startsWith("/assets/game/world-objects/")
|
||||
&& asset.targetUrl.endsWith(".glb")
|
||||
&& asset.instances > 0
|
||||
&& asset.displayIds.length > 0
|
||||
&& asset.dungeons.length > 0
|
||||
))).toBe(true);
|
||||
expect(gameObjectAssetManifest.assets.reduce((total, asset) => total + asset.instances, 0))
|
||||
.toBe(gameObjectAssetManifest.summary.resolvedModelInstances);
|
||||
expect(gameObjectAssetManifest.nonVisualObjects.reduce(
|
||||
(total, object) => total + object.instances,
|
||||
0,
|
||||
)).toBe(gameObjectAssetManifest.summary.nonVisualInstances);
|
||||
expect(gameObjectAssetManifest.unresolvedModels.map((entry) => entry.displayId)).toEqual([
|
||||
9158, 9159, 9160, 9161, 9162, 9163, 9236, 9467, 9468,
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs Wailing Caverns with the complete server population and authored presentation", () => {
|
||||
const wailing = requireDungeonDefinition("wailing-caverns");
|
||||
const runtimePopulation = wailing.staticSpawns.length
|
||||
@@ -101,9 +417,17 @@ describe("generated dungeon campaign catalog", () => {
|
||||
const sourcePopulation = source.staticSpawns.length
|
||||
+ source.roamingPacks.reduce((total, pack) => total + pack.members.length, 0);
|
||||
const runtimePopulation = runtime.staticSpawns.length
|
||||
+ runtime.roamingPacks.reduce((total, pack) => total + pack.members.length, 0);
|
||||
+ runtime.roamingPacks.reduce((total, pack) => total + pack.members.length, 0)
|
||||
+ new Set((runtime.encounterSpawnGroups ?? [])
|
||||
.flatMap((group) => group.spawns.map((spawn) => spawn.id))
|
||||
.filter((id) => !runtime.staticSpawns.some((spawn) => spawn.id === id))).size;
|
||||
|
||||
expect(runtimePopulation, source.id).toBeGreaterThanOrEqual(sourcePopulation);
|
||||
if (source.id === "frozen-reach") {
|
||||
expect(runtime.staticSpawns.some((spawn) => spawn.id.startsWith("generated-trash-"))).toBe(false);
|
||||
expect(runtime.validation.warnings.join(" ")).toMatch(/recovered Alva\/Arktos/i);
|
||||
} else {
|
||||
expect(runtimePopulation, source.id).toBeGreaterThanOrEqual(sourcePopulation);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -113,7 +437,11 @@ describe("generated dungeon campaign catalog", () => {
|
||||
...dungeon.roamingPacks.filter((pack) => "spawnChance" in pack),
|
||||
]) as unknown as ReadonlyArray<{ readonly spawnChance: number }>;
|
||||
const optionalBosses = generated.definitions.flatMap((dungeon) => (
|
||||
dungeon.objectiveOrder.filter((objective) => "optional" in objective && objective.optional)
|
||||
dungeon.objectiveOrder.filter((objective) => {
|
||||
if (!("optional" in objective) || !objective.optional) return false;
|
||||
const spawn = dungeon.staticSpawns.find((candidate) => candidate.id === objective.id);
|
||||
return Boolean(spawn && "spawnChance" in spawn);
|
||||
})
|
||||
));
|
||||
|
||||
expect(chanceSpawns).toHaveLength(24);
|
||||
@@ -355,6 +683,44 @@ describe("generated dungeon campaign catalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("makes every required boss objective available in every declared difficulty", () => {
|
||||
for (const dungeon of generated.definitions) {
|
||||
const spawns = new Map(dungeon.staticSpawns.map((spawn) => [spawn.id, spawn]));
|
||||
for (const boss of dungeon.objectiveOrder) {
|
||||
if (("eventOnly" in boss && boss.eventOnly) || ("optional" in boss && boss.optional)) continue;
|
||||
const spawn = spawns.get(boss.id);
|
||||
expect(spawn, `${dungeon.id}:${boss.name}`).toBeTruthy();
|
||||
for (const difficulty of dungeon.difficultyVariants) {
|
||||
expect(
|
||||
(spawn!.spawnMask & difficulty.spawnMask) !== 0,
|
||||
`${dungeon.id}:${boss.name}:${difficulty.id}`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves heroic-only Epoch bosses without gating normal completion", () => {
|
||||
for (const [dungeonId, bossName] of [
|
||||
["the-nexus", "Frozen Commander"],
|
||||
["ahnkahet-old-kingdom", "Amanitar"],
|
||||
["gundrak", "Eck the Ferocious"],
|
||||
] as const) {
|
||||
const dungeon = generated.definitions.find((entry) => entry.id === dungeonId)!;
|
||||
const boss = dungeon.objectiveOrder.find((objective) => objective.name === bossName)!;
|
||||
const spawn = dungeon.staticSpawns.find((entry) => entry.id === boss.id)!;
|
||||
expect("optional" in boss && boss.optional, `${dungeonId}:${bossName}`).toBe(true);
|
||||
expect(spawn.spawnMask, `${dungeonId}:${bossName}`).toBe(2);
|
||||
}
|
||||
|
||||
const blackMorass = generated.definitions.find((dungeon) => dungeon.id === "black-morass")!;
|
||||
for (const boss of blackMorass.objectiveOrder) {
|
||||
const spawn = blackMorass.staticSpawns.find((entry) => entry.id === boss.id)!;
|
||||
expect("optional" in boss && boss.optional, boss.name).not.toBe(true);
|
||||
expect(spawn.spawnMask, boss.name).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("aligns the Deadmines source population to its packaged interior route", () => {
|
||||
const deadmines = requireDungeonDefinition("deadmines");
|
||||
const firstPack = deadmines.staticSpawns.find(
|
||||
|
||||
@@ -46,6 +46,30 @@ describe("dungeon combat fidelity integration", () => {
|
||||
expect(friendlyResult.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cycles only living enemy targets that are in range and line of sight", () => {
|
||||
for (const [id, position] of [
|
||||
["blocked-near", [2, 0, 0]],
|
||||
["visible", [4, 0, 0]],
|
||||
["too-far", [41, 0, 0]],
|
||||
] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id });
|
||||
setMobPosition(id, position);
|
||||
}
|
||||
let blockVisibleTarget = false;
|
||||
registerCombatSpatialProvider({
|
||||
actor: (id) => ({ id, position: getMobPosition(id) ?? [0, 0, 0], yaw: 0 }),
|
||||
hasLineOfSight: (_origin, target) => target[0] !== 2 && (!blockVisibleTarget || target[0] !== 4),
|
||||
projectGroundPoint: (point) => point,
|
||||
});
|
||||
|
||||
expect(useCombatStore.getState().cycleTarget("next", [0, 0, 0], 40)).toBe("visible");
|
||||
expect(useCombatStore.getState().cycleTarget("next", [0, 0, 0], 40)).toBe("visible");
|
||||
|
||||
blockVisibleTarget = true;
|
||||
expect(useCombatStore.getState().cycleTarget("next", [0, 0, 0], 40)).toBeNull();
|
||||
expect(useCombatStore.getState().selectedTargetId).toBeNull();
|
||||
});
|
||||
|
||||
it("applies only the first two direct-damage pushbacks and ignores fully absorbed hits", () => {
|
||||
registerTarget();
|
||||
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 }).ok).toBe(true);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveDungeonEncounterCombatEvents } from "./dungeonEncounterCombatEvents";
|
||||
|
||||
describe("dungeon encounter combat event bridge", () => {
|
||||
it("emits stable engage, descending health, and death edges once", () => {
|
||||
const engaged = deriveDungeonEncounterCombatEvents({}, {
|
||||
boss: { engaged: true, dead: false, despawned: false, health: 1_000, maxHealth: 1_000 },
|
||||
}, 1);
|
||||
expect(engaged.events).toEqual([{ kind: "entity-engaged", entityId: "boss", now: 1 }]);
|
||||
|
||||
const damaged = deriveDungeonEncounterCombatEvents(engaged.cursor, {
|
||||
boss: { engaged: true, dead: false, despawned: false, health: 490, maxHealth: 1_000 },
|
||||
}, 2);
|
||||
expect(damaged.events).toEqual([{
|
||||
kind: "entity-health",
|
||||
entityId: "boss",
|
||||
healthPercent: 49,
|
||||
now: 2,
|
||||
}]);
|
||||
|
||||
const unchanged = deriveDungeonEncounterCombatEvents(damaged.cursor, {
|
||||
boss: { engaged: true, dead: false, despawned: false, health: 490, maxHealth: 1_000 },
|
||||
}, 3);
|
||||
expect(unchanged.events).toEqual([]);
|
||||
|
||||
const dead = deriveDungeonEncounterCombatEvents(unchanged.cursor, {
|
||||
boss: { engaged: false, dead: true, despawned: false, health: 0, maxHealth: 1_000 },
|
||||
}, 4);
|
||||
expect(dead.events).toEqual([{ kind: "entity-death", entityId: "boss", now: 4 }]);
|
||||
expect(deriveDungeonEncounterCombatEvents(dead.cursor, {
|
||||
boss: { engaged: false, dead: true, despawned: false, health: 0, maxHealth: 1_000 },
|
||||
}, 5).events).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not treat scripted despawn as death and permits a later respawn edge", () => {
|
||||
const first = deriveDungeonEncounterCombatEvents({}, {
|
||||
add: { engaged: true, dead: false, despawned: false, health: 100, maxHealth: 100 },
|
||||
}, 1);
|
||||
const despawned = deriveDungeonEncounterCombatEvents(first.cursor, {
|
||||
add: { engaged: false, dead: false, despawned: true, health: 100, maxHealth: 100 },
|
||||
}, 2);
|
||||
expect(despawned.events).toEqual([]);
|
||||
expect(despawned.cursor).toEqual({});
|
||||
expect(deriveDungeonEncounterCombatEvents(despawned.cursor, {
|
||||
add: { engaged: true, dead: false, despawned: false, health: 100, maxHealth: 100 },
|
||||
}, 3).events).toEqual([{ kind: "entity-engaged", entityId: "add", now: 3 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { DungeonEncounterEvent } from "./dungeonEncounterTypes";
|
||||
|
||||
export interface DungeonEncounterMobObservation {
|
||||
readonly engaged: boolean;
|
||||
readonly dead: boolean;
|
||||
readonly despawned: boolean;
|
||||
readonly health: number;
|
||||
readonly maxHealth: number;
|
||||
}
|
||||
|
||||
export interface DungeonEncounterMobCursorEntry {
|
||||
readonly engaged: boolean;
|
||||
readonly dead: boolean;
|
||||
readonly healthPercent: number;
|
||||
}
|
||||
|
||||
export type DungeonEncounterMobCursor = Readonly<Record<string, DungeonEncounterMobCursorEntry>>;
|
||||
|
||||
type CombatEdgeEvent = Extract<
|
||||
DungeonEncounterEvent,
|
||||
{ readonly kind: "entity-engaged" | "entity-health" | "entity-death" }
|
||||
>;
|
||||
|
||||
export interface DungeonEncounterCombatEventBatch {
|
||||
readonly cursor: DungeonEncounterMobCursor;
|
||||
readonly events: readonly CombatEdgeEvent[];
|
||||
}
|
||||
|
||||
function healthPercent(mob: DungeonEncounterMobObservation): number {
|
||||
if (!Number.isFinite(mob.maxHealth) || mob.maxHealth <= 0) return 0;
|
||||
return Math.max(0, Math.min(100, (Math.max(0, mob.health) / mob.maxHealth) * 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts mutable combat-state edges into deterministic encounter events.
|
||||
* Despawned actors are deliberately omitted so scripted cleanup never looks
|
||||
* like a kill and a later respawn of the same stable id can engage normally.
|
||||
*/
|
||||
export function deriveDungeonEncounterCombatEvents(
|
||||
previous: DungeonEncounterMobCursor,
|
||||
mobs: Readonly<Record<string, DungeonEncounterMobObservation>>,
|
||||
now: number,
|
||||
): DungeonEncounterCombatEventBatch {
|
||||
const cursor: Record<string, DungeonEncounterMobCursorEntry> = {};
|
||||
const events: CombatEdgeEvent[] = [];
|
||||
|
||||
for (const entityId of Object.keys(mobs).sort()) {
|
||||
const mob = mobs[entityId];
|
||||
if (mob.despawned) continue;
|
||||
const percent = healthPercent(mob);
|
||||
const prior = previous[entityId];
|
||||
cursor[entityId] = Object.freeze({
|
||||
engaged: mob.engaged,
|
||||
dead: mob.dead,
|
||||
healthPercent: percent,
|
||||
});
|
||||
|
||||
if (mob.engaged && !mob.dead && !prior?.engaged) {
|
||||
events.push(Object.freeze({ kind: "entity-engaged", entityId, now }));
|
||||
}
|
||||
if (!mob.dead && percent < (prior?.healthPercent ?? 100)) {
|
||||
events.push(Object.freeze({ kind: "entity-health", entityId, healthPercent: percent, now }));
|
||||
}
|
||||
if (mob.dead && !prior?.dead) {
|
||||
events.push(Object.freeze({ kind: "entity-death", entityId, now }));
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({ cursor: Object.freeze(cursor), events: Object.freeze(events) });
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activateDungeonEncounterDefinitions,
|
||||
activeDungeonEncounterRevision,
|
||||
activeDungeonRunIsComplete,
|
||||
createDungeonEncounterSnapshot,
|
||||
dispatchActiveDungeonEntityEvent,
|
||||
dispatchActiveDungeonInteraction,
|
||||
dispatchDungeonEncounterSignal,
|
||||
drainDungeonEncounterEffects,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
importDungeonEncounterSnapshots,
|
||||
reduceDungeonEncounter,
|
||||
registerDungeonEncounterEffectHandler,
|
||||
resetDungeonEncounters,
|
||||
resetDungeonEncounterSnapshot,
|
||||
subscribeDungeonEncounterSnapshots,
|
||||
validateDungeonEncounterDefinition,
|
||||
@@ -104,14 +107,32 @@ const MANAGER_B = {
|
||||
{ id: "switch-cue", kind: "emit-signal", signalId: "test:switch-opened" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "observe-boss-death",
|
||||
trigger: { kind: "entity-death", entityId: "boss" },
|
||||
actions: [{ id: "mark-boss-dead", kind: "set-flag", flagId: "boss-dead", value: true }],
|
||||
},
|
||||
],
|
||||
} as const satisfies DungeonEncounterDefinition;
|
||||
|
||||
const MANAGER_DIFFICULTY = {
|
||||
id: "test:manager-difficulty",
|
||||
dungeonId: "manager-difficulty",
|
||||
initialPhase: "idle",
|
||||
rules: [{
|
||||
id: "heroic-signal",
|
||||
trigger: { kind: "signal", signalId: "test:heroic" },
|
||||
guards: [{ kind: "difficulty", difficultyIds: ["heroic"] }],
|
||||
actions: [{ id: "mark-live-heroic", kind: "set-flag", flagId: "heroic", value: true }],
|
||||
}],
|
||||
} as const satisfies DungeonEncounterDefinition;
|
||||
|
||||
afterEach(() => {
|
||||
activateDungeonEncounterDefinitions("wailing-caverns", [WAILING_NARALEX_ENCOUNTER]);
|
||||
useWailingEncounterStore.getState().reset();
|
||||
drainDungeonEncounterEffects("manager-a");
|
||||
drainDungeonEncounterEffects("manager-b");
|
||||
drainDungeonEncounterEffects("manager-difficulty");
|
||||
});
|
||||
|
||||
describe("dungeon encounter reducer", () => {
|
||||
@@ -195,6 +216,57 @@ describe("dungeon encounter reducer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reconstructs persisted state instead of retaining a temporary overwrite on reset", () => {
|
||||
const definition = {
|
||||
id: "test:persistent-reconstruction",
|
||||
dungeonId: "test-dungeon",
|
||||
initialPhase: "locked",
|
||||
initialState: { worldObjectStates: { herald: "locked" } },
|
||||
resetPolicy: { bossWipePhase: "ready" },
|
||||
rules: [
|
||||
{
|
||||
id: "unlock-herald",
|
||||
trigger: { kind: "signal", signalId: "unlock" },
|
||||
actions: [{
|
||||
id: "persist-herald-ready",
|
||||
kind: "set-world-object-state",
|
||||
objectId: "herald",
|
||||
state: "ready",
|
||||
persistence: "run",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: "start-attempt",
|
||||
trigger: { kind: "interact", objectId: "herald" },
|
||||
guards: [{ kind: "world-object-state", objectId: "herald", states: ["ready"] }],
|
||||
actions: [{
|
||||
id: "temporarily-activate-herald",
|
||||
kind: "set-world-object-state",
|
||||
objectId: "herald",
|
||||
state: "active",
|
||||
}],
|
||||
},
|
||||
],
|
||||
} as const satisfies DungeonEncounterDefinition;
|
||||
const unlocked = reduceDungeonEncounter(
|
||||
definition,
|
||||
createDungeonEncounterSnapshot(definition),
|
||||
{ kind: "signal", signalId: "unlock", now: 1 },
|
||||
).snapshot;
|
||||
const active = reduceDungeonEncounter(
|
||||
definition,
|
||||
unlocked,
|
||||
{ kind: "interact", objectId: "herald", now: 2 },
|
||||
).snapshot;
|
||||
expect(active.worldObjectStates.herald).toBe("active");
|
||||
|
||||
const reset = resetDungeonEncounterSnapshot(definition, active, "boss-wipe", 3);
|
||||
expect(reset).toMatchObject({
|
||||
phase: "ready",
|
||||
worldObjectStates: { herald: "ready" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects duplicate action ids at definition validation", () => {
|
||||
expect(() => validateDungeonEncounterDefinition({
|
||||
id: "test:invalid",
|
||||
@@ -210,9 +282,173 @@ describe("dungeon encounter reducer", () => {
|
||||
}],
|
||||
})).toThrow(/duplicate action id same/);
|
||||
});
|
||||
|
||||
it("rejects empty difficulty guards at definition validation", () => {
|
||||
expect(() => validateDungeonEncounterDefinition({
|
||||
id: "test:invalid-difficulty",
|
||||
dungeonId: "test-dungeon",
|
||||
initialPhase: "idle",
|
||||
rules: [{
|
||||
id: "empty-difficulty",
|
||||
trigger: { kind: "tick" },
|
||||
guards: [{ kind: "difficulty", difficultyIds: [] }],
|
||||
actions: [{ id: "should-not-fire", kind: "set-phase", phase: "invalid" }],
|
||||
}],
|
||||
})).toThrow(/empty difficulty guard/);
|
||||
});
|
||||
|
||||
it("evaluates difficulty guards from ephemeral runtime context", () => {
|
||||
const definition = {
|
||||
id: "test:difficulty",
|
||||
dungeonId: "test-dungeon",
|
||||
initialPhase: "idle",
|
||||
rules: [{
|
||||
id: "heroic-branch",
|
||||
trigger: { kind: "signal", signalId: "start" },
|
||||
guards: [{ kind: "difficulty", difficultyIds: ["heroic"] }],
|
||||
actions: [{ id: "mark-heroic", kind: "set-flag", flagId: "heroic", value: true }],
|
||||
}],
|
||||
} as const satisfies DungeonEncounterDefinition;
|
||||
const initial = createDungeonEncounterSnapshot(definition);
|
||||
const event = { kind: "signal", signalId: "start", now: 1 } as const;
|
||||
|
||||
expect(reduceDungeonEncounter(definition, initial, event, { difficultyId: "normal" }).snapshot).toBe(initial);
|
||||
expect(reduceDungeonEncounter(definition, initial, event, { difficultyId: "heroic" }).snapshot.flags.heroic).toBe(true);
|
||||
});
|
||||
|
||||
it("never turns an entities-dead safety timeout into boss or dungeon credit", () => {
|
||||
const completion = {
|
||||
id: "test:timeout-completion",
|
||||
dungeonId: "test-dungeon",
|
||||
initialPhase: "boss-active",
|
||||
initialState: { flags: { "dungeon-complete": false } },
|
||||
rules: [{
|
||||
id: "boss-cleared",
|
||||
trigger: { kind: "entities-dead", entityIds: ["boss"], timeoutMs: 1_000 },
|
||||
actions: [
|
||||
{ id: "complete-timeout-boss", kind: "complete-objective", objectiveId: "boss", persistence: "run" },
|
||||
{ id: "checkpoint-timeout-boss", kind: "set-checkpoint", checkpointId: "boss", persistence: "run" },
|
||||
{ id: "complete-timeout-dungeon", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
|
||||
],
|
||||
}],
|
||||
} as const satisfies DungeonEncounterDefinition;
|
||||
const initial = createDungeonEncounterSnapshot(completion, 0);
|
||||
const idled = reduceDungeonEncounter(completion, initial, {
|
||||
kind: "tick",
|
||||
now: 1_001,
|
||||
facts: { registeredEntityIds: ["boss"], deadEntityIds: [] },
|
||||
}).snapshot;
|
||||
const missing = reduceDungeonEncounter(completion, initial, {
|
||||
kind: "tick",
|
||||
now: 1_001,
|
||||
facts: { registeredEntityIds: [], deadEntityIds: [] },
|
||||
}).snapshot;
|
||||
expect(idled).toBe(initial);
|
||||
expect(missing).toBe(initial);
|
||||
|
||||
const killed = reduceDungeonEncounter(completion, initial, {
|
||||
kind: "tick",
|
||||
now: 1_001,
|
||||
facts: { registeredEntityIds: ["boss"], deadEntityIds: ["boss"] },
|
||||
}).snapshot;
|
||||
expect(killed.completedObjectiveIds).toEqual(["boss"]);
|
||||
expect(killed.flags["dungeon-complete"]).toBe(true);
|
||||
|
||||
const recovery = {
|
||||
id: "test:timeout-recovery",
|
||||
dungeonId: "test-dungeon",
|
||||
initialPhase: "wave-active",
|
||||
rules: [{
|
||||
id: "missing-wave-recovery",
|
||||
trigger: { kind: "entities-dead", entityIds: ["missing-add"], timeoutMs: 1_000 },
|
||||
actions: [
|
||||
{ id: "despawn-missing-wave", kind: "deactivate-spawn-group", spawnGroupId: "missing-wave" },
|
||||
{ id: "leave-missing-wave", kind: "set-phase", phase: "recovered" },
|
||||
],
|
||||
}],
|
||||
} as const satisfies DungeonEncounterDefinition;
|
||||
const recovered = reduceDungeonEncounter(
|
||||
recovery,
|
||||
createDungeonEncounterSnapshot(recovery, 0),
|
||||
{ kind: "tick", now: 1_001, facts: { registeredEntityIds: [], deadEntityIds: [] } },
|
||||
).snapshot;
|
||||
expect(recovered.phase).toBe("recovered");
|
||||
|
||||
const surrogateProgress = {
|
||||
id: "test:timeout-surrogate-progress",
|
||||
dungeonId: "test-dungeon",
|
||||
initialPhase: "wave-active",
|
||||
initialState: { flags: { "escort-complete": false } },
|
||||
rules: [{
|
||||
id: "missing-wave-cannot-persist",
|
||||
trigger: { kind: "entities-dead", entityIds: ["missing-add"], timeoutMs: 1_000 },
|
||||
actions: [
|
||||
{ id: "leave-missing-persistent-wave", kind: "set-phase", phase: "recovered" },
|
||||
{
|
||||
id: "persist-fake-escort-credit",
|
||||
kind: "set-flag",
|
||||
flagId: "escort-complete",
|
||||
value: true,
|
||||
persistence: "run",
|
||||
},
|
||||
],
|
||||
}],
|
||||
} as const satisfies DungeonEncounterDefinition;
|
||||
const surrogateInitial = createDungeonEncounterSnapshot(surrogateProgress, 0);
|
||||
const refused = reduceDungeonEncounter(
|
||||
surrogateProgress,
|
||||
surrogateInitial,
|
||||
{ kind: "tick", now: 1_001, facts: { registeredEntityIds: [], deadEntityIds: [] } },
|
||||
).snapshot;
|
||||
expect(refused).toBe(surrogateInitial);
|
||||
});
|
||||
});
|
||||
|
||||
describe("active dungeon encounter manager", () => {
|
||||
it("requires every declared completion authority before finishing a dungeon", () => {
|
||||
const baseline = {
|
||||
id: "test:completion-baseline",
|
||||
dungeonId: "completion-dungeon",
|
||||
initialPhase: "active",
|
||||
initialState: { flags: { "dungeon-complete": false } },
|
||||
rules: [{
|
||||
id: "complete-baseline",
|
||||
trigger: { kind: "signal", signalId: "baseline-complete" },
|
||||
actions: [{
|
||||
id: "mark-baseline-complete",
|
||||
kind: "set-flag",
|
||||
flagId: "dungeon-complete",
|
||||
value: true,
|
||||
persistence: "run",
|
||||
}],
|
||||
}],
|
||||
} as const satisfies DungeonEncounterDefinition;
|
||||
const instance = {
|
||||
id: "test:completion-instance",
|
||||
dungeonId: "completion-dungeon",
|
||||
initialPhase: "active",
|
||||
initialState: { flags: { "dungeon-complete": false } },
|
||||
rules: [{
|
||||
id: "complete-instance",
|
||||
trigger: { kind: "signal", signalId: "instance-complete" },
|
||||
actions: [{
|
||||
id: "mark-instance-complete",
|
||||
kind: "set-flag",
|
||||
flagId: "dungeon-complete",
|
||||
value: true,
|
||||
persistence: "run",
|
||||
}],
|
||||
}],
|
||||
} as const satisfies DungeonEncounterDefinition;
|
||||
|
||||
activateDungeonEncounterDefinitions("completion-dungeon", [baseline, instance]);
|
||||
expect(activeDungeonRunIsComplete()).toBe(false);
|
||||
dispatchDungeonEncounterSignal("baseline-complete", 1);
|
||||
expect(activeDungeonRunIsComplete()).toBe(false);
|
||||
dispatchDungeonEncounterSignal("instance-complete", 2);
|
||||
expect(activeDungeonRunIsComplete()).toBe(true);
|
||||
});
|
||||
|
||||
it("isolates shared signals to the active dungeon", () => {
|
||||
activateDungeonEncounterDefinitions("manager-a", [MANAGER_A]);
|
||||
dispatchDungeonEncounterSignal("test:shared", 1_000);
|
||||
@@ -224,8 +460,34 @@ describe("active dungeon encounter manager", () => {
|
||||
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.counters.signals).toBe(1);
|
||||
});
|
||||
|
||||
it("broadcasts live combat entity edges to the active dungeon", () => {
|
||||
activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]);
|
||||
expect(dispatchActiveDungeonEntityEvent({
|
||||
kind: "entity-death",
|
||||
entityId: "boss",
|
||||
now: 2_500,
|
||||
})).toEqual([]);
|
||||
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.flags["boss-dead"]).toBe(true);
|
||||
const managerBRevision = dungeonEncounterSnapshot(MANAGER_B.id)?.revision;
|
||||
|
||||
activateDungeonEncounterDefinitions("manager-a", [MANAGER_A]);
|
||||
dispatchActiveDungeonEntityEvent({ kind: "entity-death", entityId: "boss", now: 2_600 });
|
||||
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.revision).toBe(managerBRevision);
|
||||
});
|
||||
|
||||
it("updates difficulty context when an existing dungeon provider is reactivated", () => {
|
||||
activateDungeonEncounterDefinitions("manager-difficulty", [MANAGER_DIFFICULTY], "normal");
|
||||
dispatchDungeonEncounterSignal("test:heroic", 1);
|
||||
expect(dungeonEncounterSnapshot(MANAGER_DIFFICULTY.id)?.flags.heroic).not.toBe(true);
|
||||
|
||||
activateDungeonEncounterDefinitions("manager-difficulty", [MANAGER_DIFFICULTY], "heroic");
|
||||
dispatchDungeonEncounterSignal("test:heroic", 2);
|
||||
expect(dungeonEncounterSnapshot(MANAGER_DIFFICULTY.id)?.flags.heroic).toBe(true);
|
||||
});
|
||||
|
||||
it("dispatches interactions, exposes effects, snapshots, hydration, and subscriptions", () => {
|
||||
activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]);
|
||||
resetDungeonEncounters("dungeon", "manager-b");
|
||||
const subscriber = vi.fn();
|
||||
const effectHandler = vi.fn();
|
||||
const unsubscribe = subscribeDungeonEncounterSnapshots(subscriber);
|
||||
@@ -238,10 +500,9 @@ describe("active dungeon encounter manager", () => {
|
||||
actionId: "switch-cue",
|
||||
signalId: "test:switch-opened",
|
||||
});
|
||||
expect(effectHandler).toHaveBeenCalled();
|
||||
expect(effectHandler).toHaveBeenCalledTimes(3);
|
||||
expect(subscriber).toHaveBeenCalled();
|
||||
expect(activeDungeonEncounterRevision()).toBeGreaterThan(revisionBefore);
|
||||
expect(drainDungeonEncounterEffects("manager-b")).toHaveLength(3);
|
||||
expect(drainDungeonEncounterEffects("manager-b")).toEqual([]);
|
||||
|
||||
const exported = exportDungeonEncounterSnapshots("manager-b");
|
||||
@@ -267,7 +528,44 @@ describe("active dungeon encounter manager", () => {
|
||||
expect(importDungeonEncounterSnapshots("manager-b", [hydrated])).toEqual([MANAGER_B.id]);
|
||||
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.phase).toBe("restored");
|
||||
|
||||
const olderAuthority = {
|
||||
...hydrated,
|
||||
phase: "authority-restored",
|
||||
revision: Math.max(0, hydrated.revision - 1),
|
||||
};
|
||||
expect(importDungeonEncounterSnapshots("manager-b", [olderAuthority])).toEqual([]);
|
||||
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.phase).toBe("restored");
|
||||
expect(importDungeonEncounterSnapshots(
|
||||
"manager-b",
|
||||
[olderAuthority],
|
||||
{ authoritative: true },
|
||||
)).toEqual([MANAGER_B.id]);
|
||||
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.phase).toBe("authority-restored");
|
||||
|
||||
unregisterEffect();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("keeps effects drainable without a handler and clears queued effects on reset", () => {
|
||||
activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]);
|
||||
resetDungeonEncounters("dungeon", "manager-b");
|
||||
|
||||
expect(dispatchActiveDungeonInteraction("switch", "player", 4_000)).toHaveLength(3);
|
||||
expect(drainDungeonEncounterEffects("manager-b")).toHaveLength(3);
|
||||
|
||||
resetDungeonEncounters("dungeon", "manager-b");
|
||||
expect(dispatchActiveDungeonInteraction("switch", "player", 4_100)).toHaveLength(3);
|
||||
resetDungeonEncounters("dungeon", "manager-b");
|
||||
expect(drainDungeonEncounterEffects("manager-b")).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards undrained effects when the active dungeon changes", () => {
|
||||
activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]);
|
||||
resetDungeonEncounters("dungeon", "manager-b");
|
||||
expect(dispatchActiveDungeonInteraction("switch", "player", 5_000)).toHaveLength(3);
|
||||
|
||||
activateDungeonEncounterDefinitions("manager-a", [MANAGER_A]);
|
||||
|
||||
expect(drainDungeonEncounterEffects("manager-b")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,15 @@ function unique(values: readonly string[]): readonly string[] {
|
||||
return Object.freeze([...new Set(values)].sort());
|
||||
}
|
||||
|
||||
function orderedUnique(values: readonly string[]): readonly string[] {
|
||||
const seen = new Set<string>();
|
||||
return Object.freeze(values.filter((value) => {
|
||||
if (seen.has(value)) return false;
|
||||
seen.add(value);
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
export function createDungeonEncounterSnapshot(
|
||||
definition: DungeonEncounterDefinition,
|
||||
now = 0,
|
||||
@@ -53,12 +62,37 @@ export function validateDungeonEncounterDefinition(definition: DungeonEncounterD
|
||||
validateStableId(definition.id, "Encounter");
|
||||
validateStableId(definition.dungeonId, `Encounter ${definition.id} dungeon`);
|
||||
validateStableId(definition.initialPhase, `Encounter ${definition.id} initial phase`);
|
||||
if (definition.source) {
|
||||
if (!definition.source.files.length || definition.source.files.some((file) => !file.trim())) {
|
||||
throw new Error(`Encounter ${definition.id} source review must name at least one source file`);
|
||||
}
|
||||
if (!definition.source.reviewedBehavior.trim()) {
|
||||
throw new Error(`Encounter ${definition.id} source review must describe the reviewed behavior`);
|
||||
}
|
||||
if (definition.source.fidelity === "source-exact"
|
||||
&& (definition.source.adaptations.length || definition.source.remainingGaps.length)) {
|
||||
throw new Error(`Source-exact encounter ${definition.id} cannot retain adaptations or gaps`);
|
||||
}
|
||||
}
|
||||
const ruleIds = new Set<string>();
|
||||
const actionIds = new Set<string>();
|
||||
for (const rule of definition.rules) {
|
||||
validateStableId(rule.id, `Encounter ${definition.id} rule`);
|
||||
if (ruleIds.has(rule.id)) throw new Error(`Encounter ${definition.id} has duplicate rule id ${rule.id}`);
|
||||
ruleIds.add(rule.id);
|
||||
for (const guard of rule.guards ?? []) {
|
||||
if (guard.kind === "difficulty") {
|
||||
if (!guard.difficultyIds.length) {
|
||||
throw new Error(`Encounter ${definition.id} rule ${rule.id} has an empty difficulty guard`);
|
||||
}
|
||||
for (const difficultyId of guard.difficultyIds) {
|
||||
validateStableId(
|
||||
difficultyId,
|
||||
`Encounter ${definition.id} rule ${rule.id} difficulty`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const action of rule.actions) {
|
||||
validateStableId(action.id, `Encounter ${definition.id} action`);
|
||||
if (actionIds.has(action.id)) {
|
||||
@@ -72,8 +106,19 @@ export function validateDungeonEncounterDefinition(definition: DungeonEncounterD
|
||||
}
|
||||
}
|
||||
|
||||
function guardMatches(snapshot: DungeonEncounterSnapshot, guard: DungeonEncounterGuard): boolean {
|
||||
export interface DungeonEncounterRuntimeContext {
|
||||
readonly difficultyId?: string;
|
||||
}
|
||||
|
||||
function guardMatches(
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
guard: DungeonEncounterGuard,
|
||||
context: DungeonEncounterRuntimeContext,
|
||||
): boolean {
|
||||
if (guard.kind === "phase") return guard.phases.includes(snapshot.phase);
|
||||
if (guard.kind === "difficulty") {
|
||||
return context.difficultyId !== undefined && guard.difficultyIds.includes(context.difficultyId);
|
||||
}
|
||||
if (guard.kind === "flag") return (snapshot.flags[guard.flagId] ?? false) === guard.equals;
|
||||
if (guard.kind === "objective-complete") {
|
||||
return snapshot.completedObjectiveIds.includes(guard.objectiveId) === guard.equals;
|
||||
@@ -90,33 +135,55 @@ function guardMatches(snapshot: DungeonEncounterSnapshot, guard: DungeonEncounte
|
||||
return current <= guard.value;
|
||||
}
|
||||
|
||||
type DungeonEncounterTriggerMatch = "event" | "timeout" | false;
|
||||
|
||||
function triggerMatches(
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
trigger: DungeonEncounterRule["trigger"],
|
||||
event: DungeonEncounterEvent,
|
||||
): boolean {
|
||||
if (trigger.kind === "tick") return event.kind === "tick";
|
||||
if (trigger.kind === "signal") return event.kind === "signal" && event.signalId === trigger.signalId;
|
||||
): DungeonEncounterTriggerMatch {
|
||||
if (trigger.kind === "tick") return event.kind === "tick" ? "event" : false;
|
||||
if (trigger.kind === "signal") return event.kind === "signal" && event.signalId === trigger.signalId ? "event" : false;
|
||||
if (trigger.kind === "entity-engaged" || trigger.kind === "entity-death") {
|
||||
return event.kind === trigger.kind && event.entityId === trigger.entityId;
|
||||
return event.kind === trigger.kind && event.entityId === trigger.entityId ? "event" : false;
|
||||
}
|
||||
if (trigger.kind === "entity-health") {
|
||||
return event.kind === "entity-health"
|
||||
&& event.entityId === trigger.entityId
|
||||
&& event.healthPercent <= trigger.atOrBelowPercent;
|
||||
&& event.healthPercent <= trigger.atOrBelowPercent
|
||||
? "event"
|
||||
: false;
|
||||
}
|
||||
if (trigger.kind === "interact") return event.kind === "interact" && event.objectId === trigger.objectId;
|
||||
if (trigger.kind === "interact") return event.kind === "interact" && event.objectId === trigger.objectId ? "event" : false;
|
||||
if (trigger.kind === "elapsed") {
|
||||
return event.kind === "tick" && event.now - snapshot.phaseStartedAt >= trigger.afterMs;
|
||||
return event.kind === "tick" && event.now - snapshot.phaseStartedAt >= trigger.afterMs ? "event" : false;
|
||||
}
|
||||
if (event.kind !== "tick") return false;
|
||||
const registered = new Set(event.facts?.registeredEntityIds ?? []);
|
||||
const dead = new Set(event.facts?.deadEntityIds ?? []);
|
||||
const allDead = trigger.entityIds.length > 0
|
||||
&& trigger.entityIds.every((id) => registered.has(id) && dead.has(id));
|
||||
if (allDead) return "event";
|
||||
const hasAuthoritativeFacts = event.facts?.registeredEntityIds !== undefined
|
||||
&& event.facts.deadEntityIds !== undefined;
|
||||
const hasRegisteredLivingTarget = trigger.entityIds.some((id) => (
|
||||
registered.has(id) && !dead.has(id)
|
||||
));
|
||||
const timedOut = trigger.timeoutMs !== undefined
|
||||
&& hasAuthoritativeFacts
|
||||
&& !hasRegisteredLivingTarget
|
||||
&& event.now - snapshot.phaseStartedAt >= trigger.timeoutMs;
|
||||
return allDead || timedOut;
|
||||
return timedOut ? "timeout" : false;
|
||||
}
|
||||
|
||||
function ruleCanAdvanceOnMissingEntityTimeout(rule: DungeonEncounterRule): boolean {
|
||||
return !rule.actions.some((action) => (
|
||||
action.persistence === "checkpoint"
|
||||
|| action.persistence === "run"
|
||||
|| action.kind === "complete-objective"
|
||||
|| action.kind === "set-checkpoint"
|
||||
|| action.kind === "grant-loot"
|
||||
));
|
||||
}
|
||||
|
||||
function ruleAlreadyConsumed(snapshot: DungeonEncounterSnapshot, rule: DungeonEncounterRule): boolean {
|
||||
@@ -193,7 +260,7 @@ function applyAction(
|
||||
}
|
||||
return {
|
||||
snapshot: consume
|
||||
? { ...next, consumedActionIds: unique([...next.consumedActionIds, action.id]) }
|
||||
? { ...next, consumedActionIds: orderedUnique([...next.consumedActionIds, action.id]) }
|
||||
: next,
|
||||
effect: actionEffect(action),
|
||||
};
|
||||
@@ -203,6 +270,7 @@ export function reduceDungeonEncounter(
|
||||
definition: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
event: DungeonEncounterEvent,
|
||||
context: DungeonEncounterRuntimeContext = {},
|
||||
): DungeonEncounterReduction {
|
||||
if (snapshot.encounterId !== definition.id) {
|
||||
throw new Error(`Snapshot ${snapshot.encounterId} does not belong to encounter ${definition.id}`);
|
||||
@@ -215,8 +283,10 @@ export function reduceDungeonEncounter(
|
||||
.sort((left, right) => (right.rule.priority ?? 0) - (left.rule.priority ?? 0) || left.index - right.index);
|
||||
for (const { rule } of rules) {
|
||||
if (ruleAlreadyConsumed(next, rule)) continue;
|
||||
if (!triggerMatches(next, rule.trigger, event)) continue;
|
||||
if (!rule.guards?.every((guard) => guardMatches(next, guard)) && rule.guards?.length) continue;
|
||||
const triggerMatch = triggerMatches(next, rule.trigger, event);
|
||||
if (!triggerMatch) continue;
|
||||
if (triggerMatch === "timeout" && !ruleCanAdvanceOnMissingEntityTimeout(rule)) continue;
|
||||
if (!rule.guards?.every((guard) => guardMatches(next, guard, context)) && rule.guards?.length) continue;
|
||||
let fired = false;
|
||||
for (const action of rule.actions) {
|
||||
const applied = applyAction(next, action, event.now, rule.once ?? true);
|
||||
@@ -246,44 +316,54 @@ function retainedActionIds(
|
||||
return Object.freeze(snapshot.consumedActionIds.filter((id) => retained.has(id)));
|
||||
}
|
||||
|
||||
function retainedStateKeys(definition: DungeonEncounterDefinition): {
|
||||
readonly flags: ReadonlySet<string>;
|
||||
readonly counters: ReadonlySet<string>;
|
||||
readonly worldObjects: ReadonlySet<string>;
|
||||
readonly transitions: ReadonlySet<string>;
|
||||
readonly objectives: ReadonlySet<string>;
|
||||
readonly checkpoints: boolean;
|
||||
function reconstructedPersistentState(
|
||||
definition: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
initial: DungeonEncounterSnapshot,
|
||||
): {
|
||||
readonly flags: Readonly<Record<string, boolean>>;
|
||||
readonly counters: Readonly<Record<string, number>>;
|
||||
readonly worldObjectStates: Readonly<Record<string, string>>;
|
||||
readonly transitionStates: Readonly<Record<string, boolean>>;
|
||||
readonly completedObjectiveIds: readonly string[];
|
||||
readonly checkpointId: string | null;
|
||||
} {
|
||||
const actions = definition.rules.flatMap((rule) => rule.actions)
|
||||
const actionsById = new Map(definition.rules
|
||||
.flatMap((rule) => rule.actions)
|
||||
.map((action) => [action.id, action] as const));
|
||||
const actions = snapshot.consumedActionIds
|
||||
.map((id) => actionsById.get(id))
|
||||
.filter((action): action is DungeonEncounterAction => action !== undefined)
|
||||
.filter((action) => action.persistence === "checkpoint" || action.persistence === "run");
|
||||
return {
|
||||
flags: new Set(actions.filter((action) => action.kind === "set-flag").map((action) => action.flagId)),
|
||||
counters: new Set(actions
|
||||
.filter((action) => action.kind === "set-counter" || action.kind === "increment-counter")
|
||||
.map((action) => action.counterId)),
|
||||
worldObjects: new Set(actions
|
||||
.filter((action) => action.kind === "set-world-object-state")
|
||||
.map((action) => action.objectId)),
|
||||
transitions: new Set(actions
|
||||
.filter((action) => action.kind === "set-transition-active")
|
||||
.map((action) => action.transitionId)),
|
||||
objectives: new Set(actions
|
||||
.filter((action) => action.kind === "complete-objective")
|
||||
.map((action) => action.objectiveId)),
|
||||
checkpoints: actions.some((action) => action.kind === "set-checkpoint"),
|
||||
};
|
||||
}
|
||||
|
||||
function retainedRecord<T>(
|
||||
initial: Readonly<Record<string, T>>,
|
||||
current: Readonly<Record<string, T>>,
|
||||
keys: ReadonlySet<string>,
|
||||
): Readonly<Record<string, T>> {
|
||||
const retained: Record<string, T> = { ...initial };
|
||||
for (const key of keys) {
|
||||
if (current[key] !== undefined) retained[key] = current[key];
|
||||
const flags = { ...initial.flags };
|
||||
const counters = { ...initial.counters };
|
||||
const worldObjectStates = { ...initial.worldObjectStates };
|
||||
const transitionStates = { ...initial.transitionStates };
|
||||
const completedObjectiveIds = new Set<string>();
|
||||
let checkpointId: string | null = null;
|
||||
for (const action of actions) {
|
||||
if (action.kind === "set-flag") flags[action.flagId] = action.value;
|
||||
else if (action.kind === "set-counter") counters[action.counterId] = action.value;
|
||||
else if (action.kind === "increment-counter") {
|
||||
counters[action.counterId] = (counters[action.counterId] ?? 0) + (action.amount ?? 1);
|
||||
} else if (action.kind === "set-world-object-state") {
|
||||
worldObjectStates[action.objectId] = action.state;
|
||||
} else if (action.kind === "set-transition-active") {
|
||||
transitionStates[action.transitionId] = action.active;
|
||||
} else if (action.kind === "complete-objective") {
|
||||
completedObjectiveIds.add(action.objectiveId);
|
||||
} else if (action.kind === "set-checkpoint") {
|
||||
checkpointId = action.checkpointId;
|
||||
}
|
||||
}
|
||||
return Object.freeze(retained);
|
||||
return {
|
||||
flags: Object.freeze(flags),
|
||||
counters: Object.freeze(counters),
|
||||
worldObjectStates: Object.freeze(worldObjectStates),
|
||||
transitionStates: Object.freeze(transitionStates),
|
||||
completedObjectiveIds: Object.freeze([...completedObjectiveIds].sort()),
|
||||
checkpointId,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetDungeonEncounterSnapshot(
|
||||
@@ -297,27 +377,18 @@ export function resetDungeonEncounterSnapshot(
|
||||
const phase = scope === "boss-wipe"
|
||||
? definition.resetPolicy?.bossWipePhase ?? definition.initialPhase
|
||||
: definition.resetPolicy?.checkpointPhase ?? definition.initialPhase;
|
||||
const retained = retainedStateKeys(definition);
|
||||
const retained = reconstructedPersistentState(definition, snapshot, initial);
|
||||
return Object.freeze({
|
||||
...initial,
|
||||
phase,
|
||||
phaseStartedAt: finiteTime(now),
|
||||
flags: retainedRecord(initial.flags, snapshot.flags, retained.flags),
|
||||
counters: retainedRecord(initial.counters, snapshot.counters, retained.counters),
|
||||
worldObjectStates: retainedRecord(
|
||||
initial.worldObjectStates,
|
||||
snapshot.worldObjectStates,
|
||||
retained.worldObjects,
|
||||
),
|
||||
transitionStates: retainedRecord(
|
||||
initial.transitionStates,
|
||||
snapshot.transitionStates,
|
||||
retained.transitions,
|
||||
),
|
||||
completedObjectiveIds: Object.freeze(snapshot.completedObjectiveIds
|
||||
.filter((id) => retained.objectives.has(id))),
|
||||
flags: retained.flags,
|
||||
counters: retained.counters,
|
||||
worldObjectStates: retained.worldObjectStates,
|
||||
transitionStates: retained.transitionStates,
|
||||
completedObjectiveIds: retained.completedObjectiveIds,
|
||||
consumedActionIds: retainedActionIds(definition, snapshot, scope),
|
||||
checkpointId: retained.checkpoints ? snapshot.checkpointId : null,
|
||||
checkpointId: retained.checkpointId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -330,6 +401,8 @@ export interface DungeonEncounterProvider {
|
||||
readonly reset: (scope: DungeonEncounterResetScope, now?: number) => void;
|
||||
/** Hydrates authoritative online/save state without involving renderer state. */
|
||||
readonly replaceSnapshot?: (snapshot: DungeonEncounterSnapshot) => void;
|
||||
/** Updates ephemeral run context without polluting serialized snapshots. */
|
||||
readonly setContext?: (context: DungeonEncounterRuntimeContext) => void;
|
||||
}
|
||||
|
||||
const providers = new Map<string, DungeonEncounterProvider>();
|
||||
@@ -337,6 +410,14 @@ const pendingEffects: DungeonEncounterEffectEnvelope[] = [];
|
||||
const effectHandlers = new Set<(value: DungeonEncounterEffectEnvelope) => void>();
|
||||
let activeDungeonId: string | null = null;
|
||||
|
||||
function clearPendingDungeonEncounterEffects(dungeonId?: string): void {
|
||||
for (let index = pendingEffects.length - 1; index >= 0; index -= 1) {
|
||||
if (!dungeonId || pendingEffects[index].dungeonId === dungeonId) {
|
||||
pendingEffects.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queueProviderEffects(
|
||||
provider: DungeonEncounterProvider,
|
||||
effects: readonly DungeonEncounterEffect[],
|
||||
@@ -347,8 +428,11 @@ function queueProviderEffects(
|
||||
encounterId: provider.encounterId,
|
||||
effect,
|
||||
});
|
||||
pendingEffects.push(value);
|
||||
for (const handler of effectHandlers) handler(value);
|
||||
if (effectHandlers.size) {
|
||||
for (const handler of effectHandlers) handler(value);
|
||||
} else {
|
||||
pendingEffects.push(value);
|
||||
}
|
||||
}
|
||||
return effects;
|
||||
}
|
||||
@@ -416,6 +500,22 @@ export function dispatchActiveDungeonInteraction(
|
||||
return Object.freeze(effects);
|
||||
}
|
||||
|
||||
export function dispatchActiveDungeonEntityEvent(
|
||||
event: Extract<
|
||||
DungeonEncounterEvent,
|
||||
{ readonly kind: "entity-engaged" | "entity-health" | "entity-death" }
|
||||
>,
|
||||
): readonly DungeonEncounterEffect[] {
|
||||
if (!activeDungeonId) return [];
|
||||
const effects: DungeonEncounterEffect[] = [];
|
||||
for (const provider of providers.values()) {
|
||||
if (provider.dungeonId !== activeDungeonId) continue;
|
||||
effects.push(...queueProviderEffects(provider, provider.dispatch(event)));
|
||||
}
|
||||
notifyEncounterSubscribers();
|
||||
return Object.freeze(effects);
|
||||
}
|
||||
|
||||
export function advanceDungeonEncounters(
|
||||
dungeonId: string,
|
||||
event: Extract<DungeonEncounterEvent, { readonly kind: "tick" }>,
|
||||
@@ -435,6 +535,7 @@ export function resetDungeonEncounters(
|
||||
dungeonId?: string,
|
||||
now = 0,
|
||||
): void {
|
||||
clearPendingDungeonEncounterEffects(dungeonId);
|
||||
for (const provider of providers.values()) {
|
||||
if (!dungeonId || provider.dungeonId === dungeonId) provider.reset(scope, now);
|
||||
}
|
||||
@@ -449,7 +550,11 @@ export function resetDungeonEncounters(
|
||||
export function activateDungeonEncounterDefinitions(
|
||||
dungeonId: string,
|
||||
definitions: readonly DungeonEncounterDefinition[],
|
||||
difficultyId?: string,
|
||||
): void {
|
||||
if (activeDungeonId && activeDungeonId !== dungeonId) {
|
||||
clearPendingDungeonEncounterEffects(activeDungeonId);
|
||||
}
|
||||
activeDungeonId = dungeonId;
|
||||
for (const definition of definitions) {
|
||||
validateDungeonEncounterDefinition(definition);
|
||||
@@ -459,16 +564,18 @@ export function activateDungeonEncounterDefinitions(
|
||||
const existing = providers.get(definition.id);
|
||||
if (existing) {
|
||||
if (existing.dungeonId !== dungeonId) throw new Error(`Encounter ${definition.id} has conflicting dungeon owners`);
|
||||
existing.setContext?.({ difficultyId });
|
||||
continue;
|
||||
}
|
||||
let snapshot = createDungeonEncounterSnapshot(definition);
|
||||
let context: DungeonEncounterRuntimeContext = { difficultyId };
|
||||
const provider: DungeonEncounterProvider = {
|
||||
definition,
|
||||
encounterId: definition.id,
|
||||
dungeonId,
|
||||
getSnapshot: () => snapshot,
|
||||
dispatch: (event) => {
|
||||
const result = reduceDungeonEncounter(definition, snapshot, event);
|
||||
const result = reduceDungeonEncounter(definition, snapshot, event, context);
|
||||
snapshot = result.snapshot;
|
||||
return result.effects;
|
||||
},
|
||||
@@ -481,6 +588,9 @@ export function activateDungeonEncounterDefinitions(
|
||||
}
|
||||
snapshot = cloneEncounterSnapshot(value);
|
||||
},
|
||||
setContext: (value) => {
|
||||
context = Object.freeze({ ...value });
|
||||
},
|
||||
};
|
||||
providers.set(definition.id, provider);
|
||||
}
|
||||
@@ -501,7 +611,7 @@ function cloneEncounterSnapshot(value: DungeonEncounterSnapshot): DungeonEncount
|
||||
transitionStates: Object.freeze({ ...value.transitionStates }),
|
||||
activeSpawnGroupIds: unique(value.activeSpawnGroupIds),
|
||||
completedObjectiveIds: unique(value.completedObjectiveIds),
|
||||
consumedActionIds: unique(value.consumedActionIds),
|
||||
consumedActionIds: orderedUnique(value.consumedActionIds),
|
||||
revision: Math.max(0, Math.trunc(value.revision)),
|
||||
});
|
||||
}
|
||||
@@ -542,13 +652,14 @@ export function exportDungeonEncounterCheckpointSnapshots(
|
||||
export function importDungeonEncounterSnapshots(
|
||||
dungeonId: string,
|
||||
snapshots: readonly DungeonEncounterSnapshot[],
|
||||
options: { readonly authoritative?: boolean } = {},
|
||||
): readonly string[] {
|
||||
const imported: string[] = [];
|
||||
for (const raw of snapshots) {
|
||||
const provider = providers.get(raw.encounterId);
|
||||
if (!provider || provider.dungeonId !== dungeonId || !provider.replaceSnapshot) continue;
|
||||
const snapshot = cloneEncounterSnapshot(raw);
|
||||
if (snapshot.revision < provider.getSnapshot().revision) continue;
|
||||
if (!options.authoritative && snapshot.revision < provider.getSnapshot().revision) continue;
|
||||
provider.replaceSnapshot(snapshot);
|
||||
imported.push(snapshot.encounterId);
|
||||
}
|
||||
@@ -567,7 +678,7 @@ export function drainDungeonEncounterEffects(
|
||||
return Object.freeze(drained);
|
||||
}
|
||||
|
||||
/** Immediate application hook; effects also remain drainable for frame-bound consumers. */
|
||||
/** Immediate application hook. While one is mounted, effects bypass the headless drain queue. */
|
||||
export function registerDungeonEncounterEffectHandler(
|
||||
handler: (value: DungeonEncounterEffectEnvelope) => void,
|
||||
): () => void {
|
||||
@@ -598,3 +709,28 @@ export function activeDungeonEncounterSpawnGroupIds(): readonly string[] {
|
||||
return Object.freeze([...new Set(exportDungeonEncounterSnapshots()
|
||||
.flatMap((snapshot) => snapshot.activeSpawnGroupIds))].sort());
|
||||
}
|
||||
|
||||
export function activeDungeonCompletedObjectiveIds(dungeonId = activeDungeonId): readonly string[] {
|
||||
return unique(exportDungeonEncounterSnapshots(dungeonId)
|
||||
.flatMap((snapshot) => snapshot.completedObjectiveIds));
|
||||
}
|
||||
|
||||
function definitionControlsDungeonCompletion(definition: DungeonEncounterDefinition): boolean {
|
||||
return definition.rules.some((rule) => rule.actions.some((action) => (
|
||||
action.kind === "set-flag"
|
||||
&& action.flagId === "dungeon-complete"
|
||||
&& action.value
|
||||
)));
|
||||
}
|
||||
|
||||
export function activeDungeonRunIsComplete(dungeonId = activeDungeonId): boolean {
|
||||
if (!dungeonId) return false;
|
||||
const completionProviders = [...providers.values()].filter((provider) => (
|
||||
provider.dungeonId === dungeonId
|
||||
&& definitionControlsDungeonCompletion(provider.definition)
|
||||
));
|
||||
return completionProviders.length > 0
|
||||
&& completionProviders.every(
|
||||
(provider) => provider.getSnapshot().flags["dungeon-complete"] === true,
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { activeEncounterSpawnGroupSpawns } from "./dungeonEncounterSpawns";
|
||||
import {
|
||||
activeEncounterSpawnGroupSpawns,
|
||||
suppressCompletedDungeonPopulation,
|
||||
} from "./dungeonEncounterSpawns";
|
||||
|
||||
const spawn = (id: string, spawnMask: number) => ({
|
||||
id,
|
||||
@@ -20,4 +23,24 @@ describe("encounter spawn groups", () => {
|
||||
{ id: "wave-a", spawns: [spawn("one", 1), spawn("two", 2)] },
|
||||
], ["wave-a"], 2).map((entry) => entry.id)).toEqual(["two"]);
|
||||
});
|
||||
|
||||
it("suppresses completed static and roaming boss runtime ids on checkpoint reload", () => {
|
||||
const result = suppressCompletedDungeonPopulation(
|
||||
[spawn("static-boss", 1), spawn("trash", 1)],
|
||||
[{
|
||||
id: "boss-patrol",
|
||||
name: "Boss Patrol",
|
||||
speed: 1,
|
||||
formationSource: "database-solo",
|
||||
waypoints: [[0, 0, 0], [1, 0, 0]],
|
||||
members: [
|
||||
{ id: "boss", entityId: "test-mob", role: "leader", formationOffset: [0, 0, 0] },
|
||||
{ id: "escort", entityId: "test-mob", role: "follower", formationOffset: [1, 0, 0] },
|
||||
],
|
||||
}],
|
||||
["static-boss", "boss-patrol:boss"],
|
||||
);
|
||||
expect(result.staticSpawns.map((entry) => entry.id)).toEqual(["trash"]);
|
||||
expect(result.roamingPacks[0].members.map((member) => member.id)).toEqual(["escort"]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { spawnMatchesDifficulty, type StaticMobSpawnDefinition } from "./mobPopulation";
|
||||
import {
|
||||
spawnMatchesDifficulty,
|
||||
type MobPackDefinition,
|
||||
type StaticMobSpawnDefinition,
|
||||
} from "./mobPopulation";
|
||||
import type { DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
|
||||
|
||||
export function activeEncounterSpawnGroupSpawns(
|
||||
@@ -20,3 +24,24 @@ export function activeEncounterSpawnGroupSpawns(
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function suppressCompletedDungeonPopulation(
|
||||
staticSpawns: readonly StaticMobSpawnDefinition[],
|
||||
roamingPacks: readonly MobPackDefinition[],
|
||||
completedObjectiveIds: readonly string[],
|
||||
): {
|
||||
readonly staticSpawns: readonly StaticMobSpawnDefinition[];
|
||||
readonly roamingPacks: readonly MobPackDefinition[];
|
||||
} {
|
||||
if (!completedObjectiveIds.length) return { staticSpawns, roamingPacks };
|
||||
const completed = new Set(completedObjectiveIds);
|
||||
return {
|
||||
staticSpawns: staticSpawns.filter((spawn) => !completed.has(spawn.id)),
|
||||
roamingPacks: roamingPacks.flatMap((pack) => {
|
||||
const members = pack.members.filter(
|
||||
(member) => !completed.has(`${pack.id}:${member.id}`),
|
||||
);
|
||||
return members.length ? [{ ...pack, members }] : [];
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Vector3Tuple } from "./dungeonTypes";
|
||||
|
||||
export const DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION = 1;
|
||||
// Version 2 preserves consumed action execution order so durable state can be
|
||||
// reconstructed without reviving attempt-scoped object/flag overwrites.
|
||||
export const DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION = 2;
|
||||
|
||||
export type DungeonEncounterResetScope = "boss-wipe" | "checkpoint" | "dungeon";
|
||||
export type DungeonEncounterValue = string | number | boolean;
|
||||
@@ -52,12 +54,17 @@ export type DungeonEncounterTrigger =
|
||||
| {
|
||||
readonly kind: "entities-dead";
|
||||
readonly entityIds: readonly string[];
|
||||
/** A source-authored safety timeout prevents an encounter from soft locking. */
|
||||
/**
|
||||
* Missing-actor recovery window. It may advance presentation-only rules
|
||||
* when no listed target is registered alive, but can never award an
|
||||
* objective, checkpoint, loot, or dungeon completion.
|
||||
*/
|
||||
readonly timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type DungeonEncounterGuard =
|
||||
| { readonly kind: "phase"; readonly phases: readonly string[] }
|
||||
| { readonly kind: "difficulty"; readonly difficultyIds: readonly string[] }
|
||||
| { readonly kind: "flag"; readonly flagId: string; readonly equals: boolean }
|
||||
| {
|
||||
readonly kind: "counter";
|
||||
@@ -116,12 +123,34 @@ export interface DungeonEncounterResetPolicy {
|
||||
readonly checkpointPhase?: string;
|
||||
}
|
||||
|
||||
export type DungeonEncounterFidelity =
|
||||
| "source-exact"
|
||||
| "source-backed-adapted"
|
||||
| "healer-man-authored";
|
||||
|
||||
export type DungeonEncounterScope = "boss-progression" | "instance-orchestration";
|
||||
|
||||
/**
|
||||
* Review record kept beside executable encounter data. Adapted behavior must
|
||||
* remain explicit so a safe approximation is never mistaken for a finished
|
||||
* port of the original instance script.
|
||||
*/
|
||||
export interface DungeonEncounterSourceReview {
|
||||
readonly fidelity: DungeonEncounterFidelity;
|
||||
readonly scope: DungeonEncounterScope;
|
||||
readonly files: readonly string[];
|
||||
readonly reviewedBehavior: string;
|
||||
readonly adaptations: readonly string[];
|
||||
readonly remainingGaps: readonly string[];
|
||||
}
|
||||
|
||||
export interface DungeonEncounterDefinition {
|
||||
readonly id: string;
|
||||
readonly dungeonId: string;
|
||||
readonly initialPhase: string;
|
||||
readonly initialState?: DungeonEncounterInitialState;
|
||||
readonly resetPolicy?: DungeonEncounterResetPolicy;
|
||||
readonly source?: DungeonEncounterSourceReview;
|
||||
readonly rules: readonly DungeonEncounterRule[];
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,18 @@ import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
||||
import { gunzipSync, strFromU8 } from "fflate";
|
||||
import { DEFAULT_DUNGEON_ID as DEFAULT_DUNGEON_KEY } from "./dungeonDefaults";
|
||||
import { withRunewakerEncounterContent } from "./runewakerEncounterCatalog";
|
||||
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
|
||||
import { withWowProgressionObjectContent } from "./wowProgressionObjectContentCatalog";
|
||||
import { withAscensionCustomEncounterContent } from "./ascensionCustomEncounterCatalog";
|
||||
import { withWowWrathEncounterContent } from "./wowWrathEncounterContentCatalog";
|
||||
import { withWowClassicEncounterContent } from "./wowClassicEncounterContentCatalog";
|
||||
import { withWowTbcEncounterContent } from "./wowTbcEncounterContentCatalog";
|
||||
import { withWowWrathEncounterBatchTwoContent } from "./wowWrathEncounterBatchTwoCatalog";
|
||||
import { withWowWrathEncounterBatchThreeContent } from "./wowWrathEncounterBatchThreeCatalog";
|
||||
import { withWowVerifiedNoOpInstanceContent } from "./wowVerifiedNoOpInstanceCatalog";
|
||||
import { withWowClassicEncounterBatchTwo } from "./wowClassicEncounterBatchTwoCatalog";
|
||||
import { withWowClassicEncounterBatchThree } from "./wowClassicEncounterBatchThreeCatalog";
|
||||
import { withWowTbcEncounterBatchTwoContent } from "./wowTbcEncounterBatchTwoCatalog";
|
||||
|
||||
export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults";
|
||||
|
||||
@@ -60,6 +72,45 @@ function enrichAuthoredDefinition(
|
||||
.filter(([, entity]) => entity.kind === "boss")
|
||||
.map(([id]) => id),
|
||||
);
|
||||
const authoredObjectiveByName = new Map(
|
||||
authored.objectiveOrder.map((objective) => [normalizedName(objective.name), objective.id]),
|
||||
);
|
||||
const generatedObjectiveAliases = new Map(
|
||||
generated.objectiveOrder.map((objective) => [
|
||||
objective.id,
|
||||
authoredObjectiveByName.get(normalizedName(objective.name)) ?? objective.id,
|
||||
]),
|
||||
);
|
||||
const authoredId = (id: string): string => generatedObjectiveAliases.get(id) ?? id;
|
||||
const remappedGeneratedEncounters = (generated.encounters ?? []).map((encounter) => (
|
||||
encounter.source?.scope !== "boss-progression"
|
||||
? encounter
|
||||
: {
|
||||
...encounter,
|
||||
rules: encounter.rules.map((rule) => ({
|
||||
...rule,
|
||||
trigger: rule.trigger.kind === "entities-dead"
|
||||
? { ...rule.trigger, entityIds: rule.trigger.entityIds.map(authoredId) }
|
||||
: rule.trigger.kind === "entity-death" || rule.trigger.kind === "entity-engaged"
|
||||
? { ...rule.trigger, entityId: authoredId(rule.trigger.entityId) }
|
||||
: rule.trigger,
|
||||
guards: rule.guards?.map((guard) => (
|
||||
guard.kind === "objective-complete"
|
||||
? { ...guard, objectiveId: authoredId(guard.objectiveId) }
|
||||
: guard
|
||||
)),
|
||||
actions: rule.actions.map((action) => {
|
||||
if (action.kind === "complete-objective") {
|
||||
return { ...action, objectiveId: authoredId(action.objectiveId) };
|
||||
}
|
||||
if (action.kind === "set-checkpoint") {
|
||||
return { ...action, checkpointId: authoredId(action.checkpointId) };
|
||||
}
|
||||
return action;
|
||||
}),
|
||||
})),
|
||||
}
|
||||
));
|
||||
return {
|
||||
...authored,
|
||||
entities: {
|
||||
@@ -76,6 +127,15 @@ function enrichAuthoredDefinition(
|
||||
pack.members.some((member) => authoredBossEntityIds.has(member.entityId))
|
||||
)),
|
||||
],
|
||||
encounters: [
|
||||
...remappedGeneratedEncounters,
|
||||
...(authored.encounters ?? []),
|
||||
],
|
||||
encounterSpawnGroups: [
|
||||
...(generated.encounterSpawnGroups ?? []),
|
||||
...(authored.encounterSpawnGroups ?? []),
|
||||
],
|
||||
serverGameObjects: generated.serverGameObjects,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,6 +148,31 @@ if (authoredWailingCaverns.id !== DEFAULT_DUNGEON_KEY) {
|
||||
throw new Error(`Default dungeon id mismatch: ${authoredWailingCaverns.id}`);
|
||||
}
|
||||
|
||||
const DUNGEON_CONTENT_ADAPTERS: readonly (
|
||||
(definition: DungeonDefinition) => DungeonDefinition
|
||||
)[] = Object.freeze([
|
||||
withWowEncounterContent,
|
||||
withWowClassicEncounterContent,
|
||||
withWowClassicEncounterBatchTwo,
|
||||
withWowClassicEncounterBatchThree,
|
||||
withWowVerifiedNoOpInstanceContent,
|
||||
withWowWrathEncounterContent,
|
||||
withWowWrathEncounterBatchTwoContent,
|
||||
withWowWrathEncounterBatchThreeContent,
|
||||
withWowProgressionObjectContent,
|
||||
withWowTbcEncounterContent,
|
||||
withWowTbcEncounterBatchTwoContent,
|
||||
withAscensionCustomEncounterContent,
|
||||
withRunewakerEncounterContent,
|
||||
]);
|
||||
|
||||
function applyDungeonContentAdapters(definition: DungeonDefinition): DungeonDefinition {
|
||||
return DUNGEON_CONTENT_ADAPTERS.reduce(
|
||||
(current, applyContent) => applyContent(current),
|
||||
definition,
|
||||
);
|
||||
}
|
||||
|
||||
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
|
||||
[authoredWailingCaverns.id, authoredWailingCaverns],
|
||||
[FORSAKEN_ABBEY_DEFINITION.id, FORSAKEN_ABBEY_DEFINITION],
|
||||
@@ -99,7 +184,10 @@ const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[]
|
||||
|
||||
export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = Object.freeze(
|
||||
Object.fromEntries(dungeonRegistryEntries.map(
|
||||
([id, definition]) => [id, withRunewakerEncounterContent(definition)] as const,
|
||||
([id, definition]) => [
|
||||
id,
|
||||
applyDungeonContentAdapters(definition),
|
||||
] as const,
|
||||
)),
|
||||
);
|
||||
|
||||
|
||||
@@ -40,4 +40,17 @@ describe("dungeon run checkpoints", () => {
|
||||
savedAt: 1,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("fails closed on legacy checkpoints whose sorted action history cannot be reconstructed", () => {
|
||||
const currentSnapshot = createDungeonEncounterSnapshot(definition);
|
||||
expect(normalizeDungeonRunCheckpoint({
|
||||
version: 1,
|
||||
dungeonId: "test",
|
||||
difficultyId: "normal",
|
||||
checkpointId: null,
|
||||
spawn: { footPosition: [1, 2, 3], yaw: 0 },
|
||||
encounters: [{ ...currentSnapshot, schemaVersion: 1 }],
|
||||
savedAt: 1,
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,10 @@ import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
||||
import { DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION } from "./dungeonEncounterTypes";
|
||||
import type { Vector3Tuple, WorldSpawn } from "./dungeonTypes";
|
||||
|
||||
export const DUNGEON_RUN_CHECKPOINT_VERSION = 1;
|
||||
// Version 1 snapshots sorted consumed action ids and cannot be migrated
|
||||
// reliably when multiple durable actions write the same state slot. Failing
|
||||
// those active-run checkpoints closed is safer than resuming corrupted doors.
|
||||
export const DUNGEON_RUN_CHECKPOINT_VERSION = 2;
|
||||
|
||||
export interface DungeonRunCheckpoint {
|
||||
readonly version: typeof DUNGEON_RUN_CHECKPOINT_VERSION;
|
||||
|
||||
@@ -12,10 +12,12 @@ import { useManastormAdminStore } from "./manastormAdminStore";
|
||||
import type { GmReviewTarget } from "./gmContentReview";
|
||||
import {
|
||||
activateDungeonEncounterDefinitions,
|
||||
exportDungeonEncounterSnapshots,
|
||||
importDungeonEncounterSnapshots,
|
||||
resetDungeonEncounters,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
||||
import { dungeonContentIsUsable } from "../content/contentManager";
|
||||
|
||||
export interface DungeonSessionOptions {
|
||||
readonly gameMode?: GameMode;
|
||||
@@ -39,13 +41,17 @@ export function activateDungeonSession(
|
||||
options: DungeonSessionOptions = {},
|
||||
): boolean {
|
||||
const definition = dungeonDefinitionById(dungeonId);
|
||||
if (!definition || !dungeonCanEnter(dungeonId)) {
|
||||
if (!definition || !dungeonCanEnter(dungeonId) || !dungeonContentIsUsable(dungeonId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousDefinition = dungeonDefinitionById(useGameStore.getState().activeDungeonId);
|
||||
if (previousDefinition) releaseDungeonGltfCache(previousDefinition);
|
||||
activateDungeonEncounterDefinitions(dungeonId, definition.encounters ?? []);
|
||||
activateDungeonEncounterDefinitions(
|
||||
dungeonId,
|
||||
definition.encounters ?? [],
|
||||
difficultyId ?? definition.defaultDifficultyId,
|
||||
);
|
||||
resetDungeonEncounters("dungeon", dungeonId);
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
@@ -91,7 +97,7 @@ export function activateDungeonSession(
|
||||
gmReviewTarget: options.gmReviewTarget,
|
||||
});
|
||||
if (options.encounterSnapshots?.length) {
|
||||
importDungeonEncounterSnapshots(dungeonId, options.encounterSnapshots);
|
||||
importDungeonEncounterSnapshots(dungeonId, options.encounterSnapshots, { authoritative: true });
|
||||
}
|
||||
useCombatStore.getState().setPlayerPosition(useGameStore.getState().playerPosition);
|
||||
return true;
|
||||
@@ -103,11 +109,13 @@ export function activateDungeonSession(
|
||||
*/
|
||||
export function restartActiveDungeonSession(): boolean {
|
||||
const game = useGameStore.getState();
|
||||
const encounterSnapshots = exportDungeonEncounterSnapshots(game.activeDungeonId);
|
||||
return activateDungeonSession(game.activeDungeonId, game.activeDifficultyId, {
|
||||
gameMode: game.gameMode,
|
||||
spawn: game.activeSpawn,
|
||||
gmTestMode: game.gmTestMode,
|
||||
gmReviewTarget: game.activeGmReviewTarget,
|
||||
preserveCharacter: true,
|
||||
encounterSnapshots,
|
||||
});
|
||||
}
|
||||
@@ -7,12 +7,16 @@ import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { DEFAULT_MANASTORM_ADMIN_CONFIG } from "./manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "./manastormAdminStore";
|
||||
import { dungeonEncounterSnapshot } from "./dungeonEncounterRuntime";
|
||||
import {
|
||||
dungeonEncounterSnapshot,
|
||||
importDungeonEncounterSnapshots,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import { WAILING_NARALEX_ENCOUNTER, useWailingEncounterStore } from "./wailingCavernsEncounter";
|
||||
|
||||
describe("dungeon session asset eviction", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
useManastormAdminStore.setState({ config: DEFAULT_MANASTORM_ADMIN_CONFIG, draft: DEFAULT_MANASTORM_ADMIN_CONFIG, dirty: false });
|
||||
useGameStore.getState().activateDungeon("wailing-caverns");
|
||||
});
|
||||
@@ -35,11 +39,26 @@ describe("dungeon session asset eviction", () => {
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restarts the active dungeon in place while preserving its party", () => {
|
||||
const active = dungeonDefinitionById("deadmines")!;
|
||||
useGameStore.getState().activateDungeon(active.id);
|
||||
it("does not activate a direct production session before its dungeon pack is usable", () => {
|
||||
vi.stubEnv("DEV", false);
|
||||
useGameStore.getState().activateDungeon("wailing-caverns");
|
||||
const clear = vi.spyOn(useGLTF, "clear").mockImplementation(() => undefined);
|
||||
|
||||
expect(activateDungeonSession("deadmines")).toBe(false);
|
||||
expect(useGameStore.getState().activeDungeonId).toBe("wailing-caverns");
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restarts the active dungeon in place while preserving its party and encounter state", () => {
|
||||
const active = dungeonDefinitionById("wailing-caverns")!;
|
||||
expect(activateDungeonSession(active.id)).toBe(true);
|
||||
usePartyStore.getState().initializeParty("retry-party", 20, 600, "healer");
|
||||
const members = usePartyStore.getState().members.map((member) => member.id);
|
||||
const current = dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)!;
|
||||
const progressed = { ...current, phase: "ready", revision: current.revision + 1 } as const;
|
||||
expect(importDungeonEncounterSnapshots(active.id, [progressed])).toEqual([
|
||||
WAILING_NARALEX_ENCOUNTER.id,
|
||||
]);
|
||||
const revision = useGameStore.getState().sessionRevision;
|
||||
const clear = vi.spyOn(useGLTF, "clear").mockImplementation(() => undefined);
|
||||
|
||||
@@ -48,6 +67,10 @@ describe("dungeon session asset eviction", () => {
|
||||
expect(clear.mock.calls.map(([url]) => url)).toEqual(dungeonGltfUrls(active));
|
||||
expect(useGameStore.getState().sessionRevision).toBe(revision + 1);
|
||||
expect(usePartyStore.getState().members.map((member) => member.id)).toEqual(members);
|
||||
expect(dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)).toMatchObject({
|
||||
phase: "ready",
|
||||
revision: progressed.revision,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the published dungeon group anchor at the session boundary", () => {
|
||||
@@ -59,9 +82,9 @@ describe("dungeon session asset eviction", () => {
|
||||
expect(useGameStore.getState().activeSpawn).toEqual(spawn);
|
||||
});
|
||||
|
||||
it("hydrates a safe encounter checkpoint after the dungeon reset", () => {
|
||||
it("hydrates a same-process checkpoint even when the reset revision is newer", () => {
|
||||
const current = dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)!;
|
||||
const saved = { ...current, phase: "ready", revision: current.revision + 10 } as const;
|
||||
const saved = { ...current, phase: "ready" } as const;
|
||||
expect(activateDungeonSession("wailing-caverns", "normal", {
|
||||
encounterSnapshots: [saved],
|
||||
})).toBe(true);
|
||||
|
||||
@@ -7,6 +7,20 @@ export type DungeonWorldObjectKind =
|
||||
| "switch"
|
||||
| "key"
|
||||
| "chest"
|
||||
| "resource"
|
||||
| "generic"
|
||||
| "chair"
|
||||
| "spell-focus"
|
||||
| "text"
|
||||
| "trap"
|
||||
| "transport"
|
||||
| "camera"
|
||||
| "ritual"
|
||||
| "mailbox"
|
||||
| "fishing-hole"
|
||||
| "aura-generator"
|
||||
| "spellcaster"
|
||||
| "destructible"
|
||||
| "npc"
|
||||
| "quest-object"
|
||||
| "trigger";
|
||||
@@ -21,6 +35,14 @@ export interface DungeonWorldObjectSource {
|
||||
readonly files: readonly string[];
|
||||
readonly templateId?: number;
|
||||
readonly spawnId?: number;
|
||||
readonly gameObjectType?: number;
|
||||
readonly clientDisplayId?: number;
|
||||
readonly clientModelPath?: string;
|
||||
/** Unscaled client x/y/z display bounds retained for asset/collision review. */
|
||||
readonly clientBounds?: {
|
||||
readonly min: MobVector3;
|
||||
readonly max: MobVector3;
|
||||
};
|
||||
readonly note?: string;
|
||||
}
|
||||
|
||||
@@ -45,6 +67,8 @@ export interface DungeonWorldObjectInteraction {
|
||||
export interface DungeonWorldObjectVisual {
|
||||
/** Optional independently packaged GLB. Procedural geometry remains the safe fallback. */
|
||||
readonly assetUrl?: string;
|
||||
/** Source-adapted records stay hidden until their client display is reviewed. */
|
||||
readonly hidden?: boolean;
|
||||
readonly color?: string;
|
||||
readonly emissive?: string;
|
||||
readonly scale?: MobVector3;
|
||||
@@ -111,6 +135,24 @@ export function validateDungeonWorldObject(object: DungeonWorldObjectDefinition)
|
||||
if (object.interaction && (!object.interaction.signalId.trim() || object.interaction.range <= 0)) {
|
||||
throw new Error(`Dungeon world object ${object.id} has an invalid interaction.`);
|
||||
}
|
||||
if (object.visual?.assetUrl && object.visual.hidden) {
|
||||
throw new Error(`Dungeon world object ${object.id} cannot hide an authored visual asset.`);
|
||||
}
|
||||
if (object.source?.gameObjectType !== undefined && (
|
||||
!Number.isInteger(object.source.gameObjectType)
|
||||
|| object.source.gameObjectType < 0
|
||||
|| object.source.gameObjectType > 35
|
||||
)) {
|
||||
throw new Error(`Dungeon world object ${object.id} has an invalid source gameobject type.`);
|
||||
}
|
||||
const clientBounds = object.source?.clientBounds;
|
||||
if (clientBounds && (
|
||||
!clientBounds.min.every(Number.isFinite)
|
||||
|| !clientBounds.max.every(Number.isFinite)
|
||||
|| clientBounds.min.some((value, axis) => value >= clientBounds.max[axis])
|
||||
)) {
|
||||
throw new Error(`Dungeon world object ${object.id} has invalid client display bounds.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function nearestDungeonWorldInteraction(
|
||||
|
||||
@@ -20,7 +20,19 @@ export type EnemyDamageSchool =
|
||||
| "shadow"
|
||||
| "holy";
|
||||
|
||||
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun" | "silence";
|
||||
export type EnemyControlMechanic = "confuse" | "fear" | "sleep" | "root" | "stun" | "silence";
|
||||
|
||||
/**
|
||||
* A persistent enemy aura that procs a short-lived, charged critical-strike
|
||||
* modifier from successful main-hand auto attacks.
|
||||
*/
|
||||
export interface EnemyMeleeAutoCriticalProc {
|
||||
readonly chance: number;
|
||||
readonly triggeredAura: string;
|
||||
readonly durationMs: number;
|
||||
readonly criticalChancePercent: number;
|
||||
readonly charges: number;
|
||||
}
|
||||
|
||||
export type EnemyTelegraphShape = "circle" | "cone" | "line";
|
||||
export type EnemyTelegraphAnchor = "source" | "target";
|
||||
@@ -45,6 +57,7 @@ export type EnemyAbilityEffect =
|
||||
readonly aura: string;
|
||||
readonly durationMs: number;
|
||||
readonly magnitude?: number;
|
||||
readonly meleeAutoCriticalProc?: EnemyMeleeAutoCriticalProc;
|
||||
}
|
||||
| {
|
||||
readonly kind: "target-aura";
|
||||
@@ -55,6 +68,8 @@ export type EnemyAbilityEffect =
|
||||
readonly kind: "control";
|
||||
readonly mechanic: EnemyControlMechanic;
|
||||
readonly durationMs: number;
|
||||
/** Optional damage resolved against the same targets as the control. */
|
||||
readonly damageMultiplier?: number;
|
||||
}
|
||||
| { readonly kind: "transform"; readonly form: string }
|
||||
| {
|
||||
|
||||
@@ -250,6 +250,168 @@ describe("server-backed enemy ability runtime", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("resolves compound enemy damage and control against the same target", () => {
|
||||
useCombatStore.getState().registerMob("compound-hammer", {
|
||||
name: "Compound Hammer",
|
||||
maxHealth: 1_000,
|
||||
attacks: [{
|
||||
id: "hammer-pair",
|
||||
name: "Hammer Pair",
|
||||
delivery: "projectile",
|
||||
target: "primary",
|
||||
school: "holy",
|
||||
animation: "cast",
|
||||
castTimeMs: 0,
|
||||
range: 20,
|
||||
cooldownMs: 25_000,
|
||||
initialCooldownMs: [0, 0],
|
||||
repeatCooldownMs: [25_000, 25_000],
|
||||
damageMultiplier: 1.3,
|
||||
effect: {
|
||||
kind: "control",
|
||||
mechanic: "stun",
|
||||
durationMs: 2_500,
|
||||
damageMultiplier: 1.3,
|
||||
},
|
||||
}],
|
||||
});
|
||||
setMobPosition("compound-hammer", [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob("compound-hammer", 1, 1_000);
|
||||
const healthBefore = useCombatStore.getState().health;
|
||||
|
||||
useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0]);
|
||||
|
||||
expect(useCombatStore.getState().health).toBeLessThan(healthBefore);
|
||||
expect(useCombatStore.getState()).toMatchObject({
|
||||
controlMechanic: "stun",
|
||||
controlledUntil: 3_500,
|
||||
});
|
||||
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
||||
sourceId: "compound-hammer",
|
||||
targetId: PLAYER_AGGRO_ID,
|
||||
expiresAt: 3_500,
|
||||
}));
|
||||
});
|
||||
|
||||
it("arms, limits, and consumes a next-melee critical proc only from main-hand autos", () => {
|
||||
useCombatStore.getState().registerMob("melee-proc-boss-1", {
|
||||
name: "Melee Proc Boss",
|
||||
level: 20,
|
||||
maxHealth: 1_000,
|
||||
damageMultiplier: 0.05,
|
||||
attacks: [
|
||||
{
|
||||
id: "vengeance-driver",
|
||||
name: "Vengeance",
|
||||
delivery: "area",
|
||||
target: "self",
|
||||
school: "holy",
|
||||
animation: "cast",
|
||||
castTimeMs: 0,
|
||||
range: 0,
|
||||
cooldownMs: 1,
|
||||
initialCooldownMs: [0, 0],
|
||||
repeatCooldownMs: [1, 1],
|
||||
maximumCasts: 1,
|
||||
damageMultiplier: 0,
|
||||
effect: {
|
||||
kind: "aura",
|
||||
aura: "vengeance",
|
||||
durationMs: 0,
|
||||
meleeAutoCriticalProc: {
|
||||
chance: 1,
|
||||
triggeredAura: "vengeance-next-melee-critical",
|
||||
durationMs: 6_000,
|
||||
criticalChancePercent: 100,
|
||||
charges: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "melee-special",
|
||||
name: "Melee Special",
|
||||
delivery: "melee",
|
||||
target: "primary",
|
||||
school: "physical",
|
||||
animation: "attack",
|
||||
castTimeMs: 0,
|
||||
range: 5,
|
||||
cooldownMs: 1_000,
|
||||
initialCooldownMs: [500, 500],
|
||||
repeatCooldownMs: [1_000, 1_000],
|
||||
damageMultiplier: 1,
|
||||
},
|
||||
{
|
||||
id: "basic-attack",
|
||||
name: "Basic Attack",
|
||||
delivery: "melee",
|
||||
target: "primary",
|
||||
school: "physical",
|
||||
animation: "attack",
|
||||
castTimeMs: 0,
|
||||
range: 4.5,
|
||||
cooldownMs: 2_000,
|
||||
initialCooldownMs: [1_000, 1_000],
|
||||
repeatCooldownMs: [2_000, 2_000],
|
||||
damageMultiplier: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
setMobPosition("melee-proc-boss-1", [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob("melee-proc-boss-1", 1, 1_000);
|
||||
|
||||
useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0]);
|
||||
useCombatStore.getState().advanceMobCombat(1_500, [0, 0, 0]);
|
||||
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras.map((aura) => aura.id))
|
||||
.toEqual(["vengeance"]);
|
||||
|
||||
useCombatStore.getState().advanceMobCombat(2_000, [0, 0, 0]);
|
||||
expect(useCombatStore.getState().lastCombatResult?.landed).toBe(true);
|
||||
expect(useCombatStore.getState().lastCombatResult?.attackTable.criticalPercent).toBe(5);
|
||||
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras).toContainEqual({
|
||||
id: "vengeance-next-melee-critical",
|
||||
sourceAbilityId: "vengeance-driver",
|
||||
endsAt: 8_000,
|
||||
nextMeleeCriticalChancePercent: 100,
|
||||
charges: 1,
|
||||
});
|
||||
|
||||
useCombatStore.getState().advanceMobCombat(2_500, [0, 0, 0]);
|
||||
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras).toContainEqual(
|
||||
expect.objectContaining({ id: "vengeance-next-melee-critical", endsAt: 8_000, charges: 1 }),
|
||||
);
|
||||
|
||||
useCombatStore.getState().advanceMobCombat(4_000, [0, 0, 0]);
|
||||
expect(useCombatStore.getState().lastCombatResult).toMatchObject({
|
||||
outcome: "crit",
|
||||
critical: true,
|
||||
landed: true,
|
||||
});
|
||||
expect(useCombatStore.getState().lastCombatResult?.damageBeforeMitigation)
|
||||
.toBe((useCombatStore.getState().lastCombatResult?.rawDamage ?? 0) * 2);
|
||||
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras).toContainEqual(
|
||||
expect.objectContaining({ id: "vengeance-next-melee-critical", endsAt: 10_000, charges: 1 }),
|
||||
);
|
||||
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
"melee-proc-boss-1": {
|
||||
...state.mobs["melee-proc-boss-1"],
|
||||
auras: state.mobs["melee-proc-boss-1"].auras.map((aura) => (
|
||||
aura.id === "vengeance" ? { ...aura, meleeAutoCriticalProc: undefined } : aura
|
||||
)),
|
||||
},
|
||||
},
|
||||
}));
|
||||
useCombatStore.getState().advanceMobCombat(5_000, [0, 0, 0]);
|
||||
useCombatStore.getState().advanceMobCombat(6_000, [0, 0, 0]);
|
||||
expect(useCombatStore.getState().lastCombatResult?.critical).toBe(true);
|
||||
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras.some(
|
||||
(aura) => aura.id === "vengeance-next-melee-critical",
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("honors encounter-state conditions before selecting an ability", () => {
|
||||
useWailingEncounterStore.getState().reset();
|
||||
useCombatStore.getState().registerMob("event-caster", {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Generated by npm run dungeon:catalog. Do not edit.
|
||||
export default "H4sIAAAAAAACCuy9a5PbyJUo+FcY3N24MxFkKs/J10lF3A/9GI+96x57W147NhwdN0AgWcURH7UgS2p1h/77xskESJAASbBISaWy/EHuQhLIzJPn/crfh+v8Piyyv4dyPVsth69hNFyvHss8DF//PsyzxUM2u1v+lC1n07DeDF8Pi8flXVgtxw+zhzCfLcOr+kH9W/Hf69VyOBqG5btZuVouwnLz1yx/m92F9fD1P4frMn91ly3Cq7uwDGW2CcWr6exd+Os8+xDKH9O3/rR4WJWbdf2ljlcW2TJbb1bl4rv1Omy/H18Y/Nsm8PtZ+WEwzebzSZa//ffhL7ygfPW43ITyh2yTzVd3w9cnv/zz43IzW4Tqx/Vast9Cudrc/7Aqww+rxWLGINGFL6DQE+NQBQKfm1x5ObV5BuAckiom0wkFtf/6mwjlP6/yt90L6fzpFiQPYT7f7uP3YbbOwzId4FCIV3/Oitnb9U9//b//o5htVuWrfD4Ly804fyzLsNy8ysqQjQ2+esg2+f34x1c/fv+H2Tysf4i/evWGvy2KSc6H+LDK79M3/xiyeSh/ypbjN5tVmd2FVzUKjN+vyrc1HqxfxXfGacqjn/74cTTMV+8Cf2j4+p+/13j1p4KhcZ8Vq/fTbHk3fhvCw3A0XGQPPKJU4xjXw9eO/9zMNjPGLcWou8k2s/zNQ/Z+uR6+BulHw3KVLWbLO8aR9fA16h1+16eRr8ow/Dg6WAPjwPvZshivN6v8bVaExjL0/jJMcxnQWoZXh6uQPRdRhKxYzJZh3ZjbngABYgsErg0C1XP299lsPlvejfPsXSiXjTXog2OgvTXIwzWgwcM1uJ5LKLPfVuU0LMdvy+xx3liBO4UI1AMKgD2XMJln+dtptrlfLcZFCA9NOND+KuweHFwLDhJaq+iLCY/zIltky93cTl4EASXxyRC4W64WoQx3zen9wfT6NCUq19q6gr6U+Lh8G5Zj5urzBhFG4j6+BNWCvjaHS9CXYmGxet+kA0B/CgFMCw29bh1CX2Jc51k5D5vxYsXyKZQfGssgf4IfqRYeKsa6g2XYnsv47XE+zcoye7ubHuWp6RF6sAPQlxBjucrfjtcPs7KBDXh4FLC3CtciCGNtizHixasowsPmvoERqA7oAlRzGbZDROn2YZjeOHG/mq8W2TJvQuIQG+gMZ2BG9dQFbEpmjPNFUzgenoRvzm9a0kGrFmvgL/Saf5GV2WOxWjZF88ndt7iCwRZXQNmbLdyF6awM4/w+Wy8aa6BTnKmtH0D7BPrSQ8HTL/ZFY4sScI8SWlzJmRYlmL4HsL7PNptQhmJ8n83nDUIw+oAQ9uhAtfUUT09mjZP5alWMp4/lMmsSgtF4YgnQYoug/JOXcB/m84gKJVtB5WYPEgfa0t5xQAsSZJ4sqNebkC3eZY/zTXN6cxkUOthBX23tcVmEcrK6a85uT5BCh7pq4cnq6nqevQvjh7DcA747sXvsIMWWbFJ9oZ+VebYps98asxs8tXt//uj70uFktcmWszxrzq1OSWXfVpHhydi/CPl9tszK5uz6Iqwj/WSVIBmL43k2+VDOlpv75iLMZchHT2cA67C5D2/fttigsaeI33aoA0+2WNlzMd6sFpO9+U/hf4Tx/vzgn6wMZI/5fVhms3FefnjY44CGTgFBn7eXlesNg7sZa8fr8SaU5b44oJOMsG0z6hYjtJfoh+PFqszWTd3Q+hML6BBHT9YKwjrPHsJ4WrLJ+lgu71fzpvvC2FOyuc0aFLUPpO9SHjd3WVmEAz+OcafYQwdlAj5dKFUreJgtl1netCCNO8Uf+jCpvlDY3IfxMvz62CQKZ0/Zji3OgOrpLJKnX+WP8/356dTm+xxBX8GYP86jH2k1HXcZDMafZNNtdxI82Z8WuXNax2q5twR/ESY492QtpSizt5v7rFwtD2jCSnmRtPL+yUZ89tt/r+bjZSgfJ8354RR/bFlOTj4VH7anMJ/d3W+Ws+VdcxV4ETJ0oWVfx9Ld47LY82RYecqx256c3JP9ee9mK/bmMGduzk8njqBluPmnkkF2v3yb3YfNeDUvxm9ny7ti1TBfLZz057R1BqWerDNMV+VdiAS5emzqTVbhKZWhhYy6pbxSX95YzrI5r4CZZH6fLR5mTX+CNfIEMFpHYp56JA+zTQQDM4bm7KcQQrWpwdKTjcctVZZhOg/5Zh8M9qQK117Ik1n0ZrZ8G8pNyO/H6/vVe/a47pZBeMCjoLmMNmbaU6t4KFd5KB7LbD6u44Rt/CxXv4XluAxZft90ftNnXsaqvFttNmE55mhQw/Pr6FMD5JfRsAjT2XLGCLGOobp2xHjWHbXbzDZz/vCb7cjg/2rF8+bTuzrsW/D3iUO1vz5kdTgzn2fr9Szfjyj/tCoiIhWb8f2HSTljJlrMptNZ/jjffPh7Vs6y5SatNq5tuSoX2XzvR82na4bPT9n6bdzNPLwL85+zZYxMAo2U/KUCA7tWftx+4U97X87eZbN5NpnNZ/Hb1Z9R33wowzos+SR4R78P56u8+u8O0Gzuw4L39peHsOTI6rwYhF8fQjGrKHL9uFhkJU/xBxafg819uXq8ux8cfGk0KMI0ZJvBbLMebDFkUK7YPBoNsmUx4FMabO7DgD1WHwbZfPYuiOFoOF9lxWx591NYr2N0dvjXMjxk5Wx5dziJEPz7x2V+n5WbUHxXhuy/srj6/6d+NmjvkBHrrlw9Lhl+/5skWUg+3mkKZeer+aqMAxkgyOFouAxZmQKfU/4PtPLjaJgtJhxS/jPDYO+1jCbas14zW244Js6nIQXix9HwPixm64f7UIbta+u3H36o3yTnc2IXSlrc9rn0ciL94Ret/jiK/tfIKrN5eyUF5s4X++/BaLiaTtdhw6iFI5SjsfvlIxPEJrAkWscvPG5Wj5u/RtdqpLnh//oe/nfOYUgjDGN+/MtoOJmHZbH/S4y/jAONH2YFY9C7sP9bHX9bj21/Hhf0wEt5Pys29+yklwy+tEXFmuoDv7S8SwpBvlpuVo9l/OhPqAcozeAHsgMAGoDGAWo3QEkDcDB4o5QeOBhoaQeAdjga/qRogMoPfgDpB0BqAA4HaGmAWsbX3iitB94OlHcD54a/cPJAxokfEVrvZuvHbL6j9erv0XA6m4cKHdMzcTdnxHgs58PXw1fpCyntYpv18Uqp8Y6Rvdp7L78P+dv142L4eqhyHbDwwVPg5A7IOMsjKAjKAHqy0oSM9KQgpRDB5zKTTmUZaJr4aeYc0/Hst8CakienrR5FRWR5Nw8/MKUOX2uPEiTznXw1n88SM6y3uH00llLu73Rv6PINd73e2Lf009zmUgbMwE+nmnIoJrqYkA+T4CUYJSeFJ3A4Vd5NrA7gC6cyaeS0cFY29g3a29aurTHKsvRrbROObxOu2ya0t2ktkAOTZSorKLM48Y6M9ACZs2C9MVY6RVIVqAiNmgLISY45wlRikXvT3KZlb0vvbeLxbeJ128T2Np0Bg3pqaALOZgEmEzMp0BaZM65QuZMFOqMmhSNHuTdGZnqSmcxM86CkySaNbaJEumCb6vg21XXbVO1tThRkVk9Ro8llkRszdQCoEVE56ae+KEwWMm/kVFqTaeNCRvkUpJv4acizsHeadMk29fFt6uu2qdvbhAnoaeb1BCl3ekpSBt6KRE2apibTjrSSTmbTSV5MvJ9M8hCmE5t7yieozD5t4gXbNMe3aa7bpulAWvBeTzJyJptOXI5IOMXcQjYxmpT0IE1AyjLjC5oUk4CTKRT5xNhpluUQfGOb2ttLTtMe36a9bpu2vU2cTmGKucNCSVdYC9KaScjcdCpzNIoXDwABZdDewkTDRCmcknSoJ9aDbmzTkL5km+74Nt1123TtbRYTkzkpMcszlfssA5qCk8HKoPzUoso1gQm59cFa1MFMNbqQT3xRKOX9hBrbtFrqC7ZJx7dJ122TOgRKkU0Kq5SWhUVLE2szdFmWBYdSGjOZBphgEYLNvAEMGnNJdopTNSWrPTW3qaS5hDb98W3667bp29skI0kGnILLc0usA2WB8mmhKAtqkod84oxRAbR3xoapDEZq8pR5p5XLpk2BYgxeIDfhuBYE12lB0KEFeYkTRUVArSaTqQrZNBRBeyomU3TTibQKCg1eFlPlpgi5lLLQMGXVAGHidttU3pBs06azyvvObR7XguA6LQi6tCCkXNHEkM5tXlCGAQk9eG8okNOQyaktzGSaBQpWaydVbjn7uZAhBN0UKJ7gEi0IjmtBcJ0WBB1akMmtd56NZT+xNndOEoSJC84GnE40WAw6gNUyoJxKJVFhwFxPMum1dQ2BgqBUF6e1jnTnNo9rQXCdFgQdWpCHPLg8965wBq2ZoCzAW2WsNQCFl+TJoIdM5Xk2LShoZ6yc5jK3KsiparAgrUBDW3dnztV9nMfVILhODYIONciyPjuZaFYQlC2mE1TW2rzIMlJTkkCWILOeFPrC5BkG67Wy2dTiVJJ32e440akO4rROkenc5nE1CK5Tg6BDDZITRThRbI9YpCxMZK4zm0uVGQ2UhRyDchonoShwQhhkPvFTlRVukk3l1DXVIPDuAokCx9UguE4Ngg41iELIZeGDnErjvXVocEJK596aqWK1wYQJTOR0YmkSfI4ZepPUQm08qN02lVW2Y5tOe9lNnMfVILhODYIONchQbomCUQSTzOQYiDcqyWSFdXnQhLrwCjGfZkZrK3NrIASvtcYJaIc7pLWku1gtEXZv87gaBNepQdChBjkqiGzmC/Jg0Ml8okEHLVU2MSqnTAPK3E+mboo+R6Nt5q3BgIVVJoep2m7TSOl1W9vzDrBbcB5Xg+A6NQg61CBVFASQ68Jq5VloThypSZ6bSeE8SI9agVSZy6koKGM/yqQwWTEtMJdu6nfeIUVWdZyms1Kprm3icTUIr1ODsEMN0hOZFZnxRqLSLF2UdZkzaprlpjAmI0eZx1BIZbydkp3oQmkDhZtOpkiyYaI
Line truncated
|
||||
export default "H4sIAAAAAAACCuy9a5PjNpY2+FcY+e7GzkRIKODgXhP7wa5y237b1fa6PO3d6HBMQCSUySlKzKGUWa7uqP++cQBSgu5USk63s9kfqp2ESNzOec4VB/+4WeR3fub+6ptFWc9vXrPRzaJ+aHJ/8/ofN7mb3bvydv7OzcupXyxvXt8UD/NbX8/H9+W9r8q5f9U96H5L/ntRz29GN37+WDb1fObnyx9c/sHd+sXN67/dLJr81a2b+Ve3fu4bt/TFq2n56H+o3CffvI3f+nZ2XzfLRfelPa/M3NwtlnUz+2Kx8Kvvhxeyf1t6fN81n7Kpq6qJyz/8+80vOKC8fpgvffPGLV1V3968PvrlHx/my3Lm2x93Y3F/9029vHtTN/5NPZuVuCSisAUrxERq4N4wm8ucWzpVuWNMazC8mEwnxvPN19+HVf6uzj/sH8jen66W5N5X1Woe/7hxi9zP4wbeEPLqO1eUHxbvfvh/virKZd28yqvSz5fj/KFp/Hz5yjXejSW8unfL/G789tXbL/9UVn7xJvzq1Xv8NikmOW7ifZ3fxW9+413lm3duPn6/rBt36191JDD+WDcfOjpYvArvjGOXBz/9+fPoJq8fPX7o5vXf/tHR1bcFrsadK+qPUze/HX/w/v5mdDNz99jCebKNi5vXGv9clssSaYsj6S7dsszf37uP88XNa0bt6Kap3ayc3yKNLG5eg1jTd7cbed34m8+jrTEgDXws58V4sazzD67wyTDE5jBkOgy2MwzLt0dBew6i8K6YlXO/SPpWR5YAYGcJ9O4S8J69f3RlVc5vx7l79M08GYPY2gazMQa6PQaQsD0G3XMIjft73Uz9fPyhcQ9VMgJ9jBBMj1Vg0HMIk8rlH6ZueVfPxoX39+k6mM1RqI110DvrQNnOKPpSwkNVuJmbr/vW9KwV4BSevAK383rmG3+bdm+3uhfHOZHrnalz1pcTH+Yf/HyMqF4lTBiY+/AQ+M7qC7k9BHEuFRb1x5QPGNhjBCB3yNCKnU3oy4yL3DWVX45nNcon33xKhmHsETziO3TIkeq2hqF6DuPvD9XUNY37sO4e6LHugfWAAybOYcamzj+MF/dlk1ADbG8F2xiF3mEIqdQOMMLZoyj8/fIuoQjgW3zBeDoMtUdEid3NkL1p4q6u6pmb5+lKbFODOYEMCFRPHcCyQWCsZqlw3N4Jm/Yvd6SD4DvQgF/o1f/MNe6hqOepaD46+x1UkLCDCkB7w8Ktn5aNH+d3bjFLxmCOIdOufsB2d6AvPxTY/WxTNO5wAmxwwg4qabnDCbLvBizu3HLpG1+M71xVJYwgxRYjbPAB39VTrHkyNE6qui7G04dm7lJGkAKODIHtwCLj9slDuPNVFUihQSuoWW6sxJa2tLEdbGcljHyyoF4svZs9uodqmXYvz1uFPXDQV1t7mBe+mdS3ae/qCCvsUVcVe7K6uqjcox/f+/nG4usjs4c9rLgjm3jf1XdN7paN+3vSu4Rjs7ent74vH07qpZuXuUv75sekst1VkdmTqX/m8zs3d03auziL6ox4skoQjcVx5SafmnK+vEsHIc8jPvN0AFj45Z3/8GEHBqU6xvxqjzrwZIsVPRfjZT2bbPR/jP7DGm/2z+yTlQH3kN/5uSvHefPpfgMBpTm2COK0vcx17zW4LVE7XoyXvmk2xYE5CoS7NqPYAUJ1jn44ntWNW6S6obJHBrBHHD1ZK/CL3N378bRBk/Whmd/VVeq+kOqYbN6FBm52N6TvUB6Wt64p/JYfR+pj8LCHMxk8XSi1I7gv53OXpxak1MfwoQ9I9V2F5Z0fz/2vDylTaHXMdtxBBuBPh0jsvs4fqs3+zbHJ99mCvoIxf6iCH6mejvcZDNIehelddxJ7sj8toHMcRz3fGII9ixK0frKWUjTuw/LONfV8iycUpWdJK2ufbMS7v/93XY3nvnmYpP2zY/i4Yzlp+lR6WO1CVd7eLefl/DYdBZxFDPvIsq9j6fZhXmx4MhQ95tjd7dzoJ/vzHssavTmIzGn/5sgW7Bhu9qls4O7mH9ydX47rqhh/KOe3RZ2Yr4od9efs6gycP1lnmNbNrQ8MWT+kepPicExl2CFGsaO8mr7Y2JSuwhEgSOZ3bnZfpv4EJemRxdjZEvnULbkvl2EZEBjS3o8RBN/lBmWebDyuuLLx08rny81lUEdVuN2BPBmil+X8g2+WPr8bL+7qj+hxXQ/DwBZGsXQYu5Spjo3ivqlzXzw0rhp3ccJd+mzqv/v5uPEuv0ud3+aZh1E3t/Vy6edjjAYlnl9tfusF+WV0U/hpOS+RIBYhVLcbMS73R+2W5bLCD79ftWR/3onnVdPbLuxb4PcNhmp/vXddODOv3GJR5psR5Xd1EQipWI7vPk2aEkG0KKfTMn+olp/+6prSzZdxtGFs87qZuWrjR+nTBa7PO7f4EGZT+Udf/ejmITLJzIjTX9plQNfK29UXvt34snt0ZeUmZVWGb7d/Bn3zvvELP8edwBn946aq8/a/9yzN8s7PcG7f3/s5RlarIvO/3vuibDly8TCbuQa7+BOKz2x519QPt3fZ1pdGWeGn3i2zcrnIVhSSNTWaR6PMzYsMdylb3vkMPVafMleVj57cjG6q2hXl/PadXyxCdPbmh8bfu6ac3253Qgj+/mGe37lm6YsvGu/+4sLo/7N7lu3OEAnrtqkf5rh+/4saWlDc3mkMZed1VTehwTFg9GZ0M/euiYHPKf4HKPp5dONmEwwpf4drsPGaMxNhUa8p50uMieNuUALweXRz52fl4v7ON3712uLDpzfdm0bb3KALJQ5u9ZxaOqF2+4tKfB4F/2uASlftjqSAXNti8z02uqmn04VfImnBCOhorH/5jAyx9CiJFuELD8v6YflDcK0Gnrv5ry/Z/4E5DLEF1xgf/zK6mVR+Xmz+EsIvQ0PyQ1cgBT36zd+K8NuubfXzMKB7HMrHsljeoZOe4vLFKXLUVO/xpfltVAjyer6sH5rw0XcgMqAye2NUxpjJmIAMhM6Amoxplr3nXGSaZYKqjIG6Gd284yYDbrM3jNqMGZ4xDRkok4Gg4bX3XIjMqoxbnWl98wsmDzhM/Air9VguHly15vX279HNtKx8S47xGbmtkDAemurm9c2r+IWYdrHK+njF+XgNZK823svvfP5h8TC7eX3Dc+GhsN4aj8kdzGGWh+fMc8nAGkWld0ZMCsM5ALM5dVRz55gwEzt1WiMfl3/3qClZo4USo6CIzG8r/wY59ea1sEAZRdzJ66oqIxh2U1w9GlNKN2e60XT+hPe9nsyb2mmucko9OGanU2FyVkxEMTHWT7ylTHI6KaxhGqbc6okSntlCc0clnRZa0WTeTFi1M2slJVco/XamyQ5Pk102TbY7TaWY0Uw6x11hnIKJ1UZSy5jTiikrpaKaG8oL4AYknzJGJznkwKYUitzKdJoKvS29pwmHpwmXTRN2p6klkyCm0kyYVs6zyUROClCF01IXPNe0AC35pNBGm9xKSZ2YOOnkNPecSjdJpgkUzBnT5IenyS+bJt+d5oQzp8QUBMicFrmUU80YCADgmtqpLQrpvLOSTqmSTkjtncmnjOqJnfrc+Y3dNOdMUxyeprhsmmJ3mmzCxNRZMQGTazE1lHqcCgVhhJlKJ7QRnGrqppO8mFg7meTeTycqtyafAJebvAlnTFMenqa8bJpyD9Eya8XEGS3ddKJzAANTyBVzEykMp5ZR6cE4J21hJsXEw2TKinwi1dS5nHmbTFNYdc5uqsPTVJdNU+1OE6ZTNoVcQ8GpLpRiVMmJd3o6pTlIjoNnjHmgXljFJoJNOIepoRrERFkmkmlKI86Zpj48TX3ZNPXuNIuJdJpScLnjuXWOmSnT1CvquZ0q4LkwTPpcWa8UCC+nArTPJ7YoOLd2YpJpKkHFGdM0h6dpLpum2SNQCjcpFOeCFgqUmSjlQDvnvAZKpZxMPZtA4b1yVjLwAnJq1BSmfGqUsCadJqfyHN60h6dpL5um3Z2mkdRQD1Om81wZ1IGcN/m04MZ5Psl9PtFScs+E1VL5KfWSCmONs1pw7aapQJESzpCb7LAWxC7TgtgeLchSmHBTeBB8Mply76a+8MKaYjIFPZ1QxVkhmKXFlOspsJxSWgg2RdUA2ESvp8mtNHSXN7Xi1u6d5mEtiF2mBbF9WhCYnJuJNCJXeWEceDBgmbXSeKMFc3SqCjmZOm+8EkJTnivMfi6o916kAsUado4WxA5rQewyLYjt0YJkrqy2aCzbiVK51tQwP9FeKw/TiWAKvPBMCeqBTimnwMFDLiaOWqF0IlCAcb4PaZU2Yu80D2tB7DItiO3RgizLvc5zqwstQckJ0IJZxaVSkrHCUmONBMscz3M3LYwXWio6zWmuuKdTnkCQ4EywXd0dkWv/dh5Wg9hlahDbowYp1GcnE4EKAlfFdAJcKZUXzhk+NZQZZZhT1nCwhcwdeGUFV26qYEqN1W69naD5HuZUmhu5d5qH1SB2mRrE9qhBdMINTDjaIwqM8xOaC6dyyp0UzDifg+dawMQXBUwMeJpP7JS7Qk/clE51qgYxq8+QKOywGsQuU4PYHjXIeJ/Twno6pdJapUHCxHCRWyWnHNUG6SdsQqcTZSbe5uDAyqgWCmkZX0+TK672TFMLS/cz52E1iF2mBrE9apA0uTLGS27YxMkcvMGJUiNdoXTuhQFRWA6QT50UQtFcSea9FULAhAkNa6JVRuyDWmNg/zQPq0HsMjWI7VGDtCmMUc4WxjIJmuYTwYQXlLuJ5LlxggHN7WSqp2BzkEI5qyR4KBSXOZvy1TQlpVbsantWM9gvOA+rQewyNYjtUYN4URjGclEowS0KzYk2fJLnclJoy6gFwRnlTuemKIxDP8qkkK6YFpBTPbVr7xA3iu/ZTa0o5/umCYfVILhMDYI9apCYUFc4aSUFLlC6cKWdlnzqcllI6Yw2zoIvKJdWTY2aiIILyQo9nUzB0MRE0caKM/QDOKwGwWVLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
|
||||
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
|
||||
@@ -6,17 +6,27 @@ import {
|
||||
} from "./manastormStageAssets";
|
||||
|
||||
let initialized = false;
|
||||
const generatedPackages = registry.packages as unknown as readonly ManastormStageAssetPackage[];
|
||||
|
||||
export const GENERATED_MANASTORM_STAGE_ASSET_PACKAGE_COUNT = registry.packages.length;
|
||||
|
||||
/** Resolves a runtime stage package to its independently downloadable content pack. */
|
||||
export function generatedManastormContentPackId(assetPackageId: string): string | null {
|
||||
const assetPackage = generatedPackages.find((candidate) => candidate.id === assetPackageId);
|
||||
const assetUrl = assetPackage?.visual[0]?.url
|
||||
?? assetPackage?.collision[0]?.url
|
||||
?? assetPackage?.navigation.url;
|
||||
const directory = /^\/assets\/game\/manastorm\/([^/]+)\//.exec(assetUrl ?? "")?.[1];
|
||||
return directory ? `manastorm:${directory}` : null;
|
||||
}
|
||||
|
||||
export function registerGeneratedManastormStageAssets(): readonly string[] {
|
||||
const packages = registry.packages as unknown as readonly ManastormStageAssetPackage[];
|
||||
if (initialized) return Object.freeze(packages.map((assetPackage) => assetPackage.id));
|
||||
if (initialized) return Object.freeze(generatedPackages.map((assetPackage) => assetPackage.id));
|
||||
if (registry.schemaVersion !== MANASTORM_STAGE_ASSET_SCHEMA_VERSION) {
|
||||
throw new Error(`Unsupported generated Manastorm asset registry schema ${registry.schemaVersion}.`);
|
||||
}
|
||||
const registered: string[] = [];
|
||||
for (const assetPackage of packages) {
|
||||
for (const assetPackage of generatedPackages) {
|
||||
registerManastormStageAssetPackage(assetPackage);
|
||||
registered.push(assetPackage.id);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
preloadManastormStage,
|
||||
releaseActiveManastormStage,
|
||||
} from "./manastormStageLoader";
|
||||
import { generatedManastormContentPackId } from "./manastormAssetRegistry";
|
||||
|
||||
const CHECKSUM = "a".repeat(64);
|
||||
|
||||
@@ -64,6 +65,12 @@ describe("Manastorm stage asset packages", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("maps generated stages to the content pack needed by the download prompt", () => {
|
||||
expect(generatedManastormContentPackId("manastorm:assets:43"))
|
||||
.toBe("manastorm:43-wailingcaverns");
|
||||
expect(generatedManastormContentPackId("missing-package")).toBeNull();
|
||||
});
|
||||
|
||||
it("preloads one package, activates the stage, and releases the previous package", async () => {
|
||||
registerManastormStageAssetPackage(assetPackage("first"));
|
||||
registerManastormStageAssetPackage(assetPackage("second"));
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BufferGeometry, Float32BufferAttribute, Group, Mesh } from "three";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
|
||||
|
||||
function navigationScene(): Group {
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute([
|
||||
0, 0, 0,
|
||||
2, 0, 0,
|
||||
0, 0, 2,
|
||||
], 3));
|
||||
const scene = new Group();
|
||||
scene.add(new Mesh(geometry));
|
||||
return scene;
|
||||
}
|
||||
|
||||
describe("party navigation asset cache", () => {
|
||||
it("reuses the graph prepared during expedition loading", () => {
|
||||
const first = preparePartyNavigationGraph("/navigation/cache-test.glb", navigationScene(), []);
|
||||
const second = preparePartyNavigationGraph("/navigation/cache-test.glb", new Group(), []);
|
||||
expect(second).toBe(first);
|
||||
expect(first.sourceTriangleCount).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps navigation variants with different authored links separate", () => {
|
||||
const first = preparePartyNavigationGraph("/navigation/link-test.glb", navigationScene(), []);
|
||||
const second = preparePartyNavigationGraph("/navigation/link-test.glb", navigationScene(), [{
|
||||
id: "ledge",
|
||||
start: [0, 0, 0],
|
||||
end: [2, 0, 2],
|
||||
bidirectional: true,
|
||||
authoredReason: "Test link",
|
||||
}]);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { BufferGeometry, type Group, type Mesh } from "three";
|
||||
import type { DungeonNavigationLink } from "./dungeonTypes";
|
||||
import {
|
||||
buildPartyNavigationGraph,
|
||||
type PartyNavigationGraph,
|
||||
} from "./partyNavigation";
|
||||
import { MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES } from "./partyNavigationPolicy";
|
||||
|
||||
const MAX_CACHED_NAVIGATION_GRAPHS = 8;
|
||||
const graphCache = new Map<string, PartyNavigationGraph>();
|
||||
|
||||
function graphCacheKey(url: string, offMeshLinks: readonly DungeonNavigationLink[]): string {
|
||||
const links = offMeshLinks.map((link) => [
|
||||
link.id,
|
||||
link.start,
|
||||
link.end,
|
||||
link.bidirectional,
|
||||
]);
|
||||
return `${url}:${JSON.stringify(links)}`;
|
||||
}
|
||||
|
||||
function navigationGeometry(scene: Group): BufferGeometry {
|
||||
scene.updateMatrixWorld(true);
|
||||
let sourceMesh: Mesh | null = null;
|
||||
let largestVertexCount = 0;
|
||||
scene.traverse((node) => {
|
||||
const mesh = node as Mesh;
|
||||
const count = mesh.isMesh ? mesh.geometry.getAttribute("position")?.count ?? 0 : 0;
|
||||
if (count > largestVertexCount) {
|
||||
sourceMesh = mesh;
|
||||
largestVertexCount = count;
|
||||
}
|
||||
});
|
||||
if (!sourceMesh) throw new Error("The party navigation GLB has no triangle geometry.");
|
||||
const geometry = (sourceMesh as Mesh).geometry.clone();
|
||||
geometry.applyMatrix4((sourceMesh as Mesh).matrixWorld);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export function preparePartyNavigationGraph(
|
||||
url: string,
|
||||
scene: Group,
|
||||
offMeshLinks: readonly DungeonNavigationLink[],
|
||||
): PartyNavigationGraph {
|
||||
const cacheKey = graphCacheKey(url, offMeshLinks);
|
||||
const cached = graphCache.get(cacheKey);
|
||||
if (cached) {
|
||||
graphCache.delete(cacheKey);
|
||||
graphCache.set(cacheKey, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const geometry = navigationGeometry(scene);
|
||||
let graph: PartyNavigationGraph;
|
||||
try {
|
||||
graph = buildPartyNavigationGraph(geometry, {
|
||||
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
|
||||
offMeshLinks,
|
||||
});
|
||||
} finally {
|
||||
geometry.dispose();
|
||||
}
|
||||
graphCache.set(cacheKey, graph);
|
||||
if (graphCache.size > MAX_CACHED_NAVIGATION_GRAPHS) {
|
||||
const oldest = graphCache.keys().next().value as string | undefined;
|
||||
if (oldest) graphCache.delete(oldest);
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { useManastormStore } from "./manastormStore";
|
||||
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
|
||||
import { PLAYER_AGGRO_ID, type ThreatSource } from "./aggro";
|
||||
import type { ActiveAura, DispelCategory } from "./combatAuras";
|
||||
import {
|
||||
@@ -121,13 +122,19 @@ export function activePartyBossObjectives(): readonly PartyBossObjective[] {
|
||||
}] : [];
|
||||
}
|
||||
const game = useGameStore.getState();
|
||||
const completed = new Set(activeDungeonCompletedObjectiveIds(game.activeDungeonId));
|
||||
const mobs = useCombatStore.getState().mobs;
|
||||
const objectives = requireDungeonDefinition(game.activeDungeonId).objectiveOrder
|
||||
.filter((objective) => !objective.optional);
|
||||
if (game.activeDungeonId !== "wailing-caverns") return objectives;
|
||||
const phase = useWailingEncounterStore.getState().phase;
|
||||
return phase === "mutanus" || phase === "complete"
|
||||
? objectives
|
||||
: objectives.filter((objective) => objective.id !== "mutanus-spawn");
|
||||
.filter((objective) => !objective.optional && !completed.has(objective.id));
|
||||
const wailingPhase = game.activeDungeonId === "wailing-caverns"
|
||||
? useWailingEncounterStore.getState().phase
|
||||
: null;
|
||||
return objectives.filter((objective) => (
|
||||
!objective.eventOnly
|
||||
|| Boolean(mobs[objective.id])
|
||||
|| (objective.id === "mutanus-spawn"
|
||||
&& (wailingPhase === "mutanus" || wailingPhase === "complete"))
|
||||
));
|
||||
}
|
||||
|
||||
export function resetPartyRuntimeClock(): void {
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface GameState {
|
||||
setAssetStatus: (status: AssetStatus, message?: string | null) => void;
|
||||
updatePlayerSnapshot: (snapshot: PlayerSnapshot) => void;
|
||||
relocatePlayer: (spawn: WorldSpawn, makeActiveSpawn?: boolean) => void;
|
||||
/** Returns the party to its saved run spawn without requesting a full dungeon reset. */
|
||||
resetAfterDefeat: () => void;
|
||||
resetAtActiveSpawn: () => void;
|
||||
resetAtEntrance: () => void;
|
||||
}
|
||||
@@ -227,6 +229,25 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
relocationRevision: state.relocationRevision + 1,
|
||||
};
|
||||
}),
|
||||
resetAfterDefeat: () => set((state) => {
|
||||
const definition = requireDungeonDefinition(state.activeDungeonId);
|
||||
return {
|
||||
overlay: null,
|
||||
paused: false,
|
||||
mapOpen: false,
|
||||
cameraLookActive: false,
|
||||
playerPosition: state.activeSpawn.footPosition,
|
||||
playerNavigationPosition: state.activeSpawn.footPosition,
|
||||
playerGrounded: true,
|
||||
cameraYaw: state.activeSpawn.yaw,
|
||||
areaName: resolveAreaNameForDungeon(definition, state.activeSpawn.footPosition),
|
||||
distanceTravelled: 0,
|
||||
hasMoved: false,
|
||||
actionLayer: "primary",
|
||||
resetRevision: state.resetRevision + 1,
|
||||
relocationRevision: state.relocationRevision + 1,
|
||||
};
|
||||
}),
|
||||
resetAtActiveSpawn: () => set((state) => {
|
||||
const definition = requireDungeonDefinition(state.activeDungeonId);
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TALENT_NODES, TALENT_TREES } from "./talentCatalog";
|
||||
import { loadTalentUiCatalog, talentUiNodesForTree } from "./talentUiCatalog";
|
||||
import { loadedTalentUiCatalog, loadTalentUiCatalog, talentUiArtUrls, talentUiNodesForTree } from "./talentUiCatalog";
|
||||
|
||||
describe("talent UI catalog shards", () => {
|
||||
it.each(["warrior", "barbarian"] as const)("loads only the %s class projection", async (classId) => {
|
||||
@@ -17,4 +17,13 @@ describe("talent UI catalog shards", () => {
|
||||
expect(talentUiNodesForTree(catalog, tree.id).every((node) => node.treeId === tree.id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns each Priest talent artwork URL once", async () => {
|
||||
const catalog = await loadTalentUiCatalog("priest");
|
||||
const urls = talentUiArtUrls(catalog);
|
||||
expect(loadedTalentUiCatalog("priest")).toBe(catalog);
|
||||
expect(urls).toHaveLength(new Set(urls).size);
|
||||
expect(urls).toContain("/assets/ui/talents/backgrounds/talent_priest1.png");
|
||||
expect(urls).toContain("/assets/ui/talents/icons/spell_holy_powerwordshield.png");
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,7 @@ interface TalentUiCatalogModule {
|
||||
|
||||
const shardLoaders = import.meta.glob<TalentUiCatalogModule>("./generated/talentUi/*.json");
|
||||
const loadCache = new Map<ClassId, Promise<TalentUiCatalog>>();
|
||||
const loadedCatalogs = new Map<ClassId, TalentUiCatalog>();
|
||||
|
||||
export function loadTalentUiCatalog(classId: ClassId): Promise<TalentUiCatalog> {
|
||||
const cached = loadCache.get(classId);
|
||||
@@ -61,6 +62,7 @@ export function loadTalentUiCatalog(classId: ClassId): Promise<TalentUiCatalog>
|
||||
if (module.default.schemaVersion !== 1 || module.default.classId !== classId) {
|
||||
throw new Error(`Talent UI catalog ${classId} has incompatible metadata.`);
|
||||
}
|
||||
loadedCatalogs.set(classId, module.default);
|
||||
return module.default;
|
||||
}).catch((error: unknown) => {
|
||||
loadCache.delete(classId);
|
||||
@@ -70,6 +72,10 @@ export function loadTalentUiCatalog(classId: ClassId): Promise<TalentUiCatalog>
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function loadedTalentUiCatalog(classId: ClassId): TalentUiCatalog | null {
|
||||
return loadedCatalogs.get(classId) ?? null;
|
||||
}
|
||||
|
||||
export function talentUiDescriptionForRank(node: TalentUiNode, rank: number): string {
|
||||
const normalizedRank = Math.min(
|
||||
node.maxRank,
|
||||
@@ -91,3 +97,10 @@ export function talentUiNodesForTree(catalog: TalentUiCatalog, treeId: string):
|
||||
.filter((node) => node.treeId === treeId)
|
||||
.sort((left, right) => left.row - right.row || left.index - right.index);
|
||||
}
|
||||
|
||||
export function talentUiArtUrls(catalog: TalentUiCatalog): readonly string[] {
|
||||
return [...new Set([
|
||||
...catalog.trees.flatMap((tree) => [tree.icon, tree.background]),
|
||||
...catalog.nodes.map((node) => node.icon),
|
||||
])];
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import campaign from "../../dungeon-pipeline/dungeon-campaign.json";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import type { DungeonDifficultyVariant, DungeonSourceRecipe } from "./dungeonTypes";
|
||||
|
||||
const recipeModules = import.meta.glob<DungeonSourceRecipe>(
|
||||
"../../dungeon-pipeline/recipes/*.json",
|
||||
{ eager: true, import: "default" },
|
||||
);
|
||||
|
||||
const TBC_DUNGEON_IDS = campaign.dungeons
|
||||
.filter((dungeon) => dungeon.source === "burning-crusade")
|
||||
.map((dungeon) => dungeon.slug);
|
||||
|
||||
function requireRecipe(dungeonId: string): DungeonSourceRecipe {
|
||||
const path = `../../dungeon-pipeline/recipes/${dungeonId}.json`;
|
||||
const recipe = recipeModules[path];
|
||||
if (!recipe) throw new Error(`Missing Burning Crusade recipe: ${path}`);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
function expectSelectableDifficultyContract(
|
||||
label: string,
|
||||
variants: readonly DungeonDifficultyVariant[],
|
||||
): void {
|
||||
const normal = variants.find((variant) => variant.id === "normal");
|
||||
const heroic = variants.find((variant) => variant.id === "heroic");
|
||||
|
||||
expect(normal, `${label}:normal`).toMatchObject({
|
||||
difficulty: "normal",
|
||||
spawnMask: 1,
|
||||
});
|
||||
expect(heroic, `${label}:heroic`).toMatchObject({
|
||||
difficulty: "heroic",
|
||||
spawnMask: 2,
|
||||
levelRange: [70, 70],
|
||||
});
|
||||
expect(new Set(variants.map((variant) => variant.id)).size, `${label}:unique-ids`)
|
||||
.toBe(variants.length);
|
||||
}
|
||||
|
||||
describe("Burning Crusade difficulty contract", () => {
|
||||
it("advertises selectable normal and level-70 heroic variants in every recipe and compiled definition", () => {
|
||||
expect(TBC_DUNGEON_IDS).toHaveLength(14);
|
||||
|
||||
for (const dungeonId of TBC_DUNGEON_IDS) {
|
||||
const recipe = requireRecipe(dungeonId);
|
||||
const definition = requireDungeonDefinition(dungeonId);
|
||||
|
||||
expect(recipe.slug, dungeonId).toBe(dungeonId);
|
||||
expect(recipe.expansion, dungeonId).toBe("burning-crusade");
|
||||
expectSelectableDifficultyContract(`${dungeonId}:recipe`, recipe.difficultyVariants);
|
||||
expectSelectableDifficultyContract(`${dungeonId}:runtime`, definition.difficultyVariants);
|
||||
}
|
||||
});
|
||||
|
||||
it("only uses encounter difficulty guards that players can select", () => {
|
||||
for (const dungeonId of TBC_DUNGEON_IDS) {
|
||||
const definition = requireDungeonDefinition(dungeonId);
|
||||
const selectableDifficultyIds = new Set(
|
||||
definition.difficultyVariants.map((variant) => variant.id),
|
||||
);
|
||||
|
||||
for (const encounter of definition.encounters ?? []) {
|
||||
for (const rule of encounter.rules) {
|
||||
for (const guard of rule.guards ?? []) {
|
||||
if (guard.kind !== "difficulty") continue;
|
||||
expect(guard.difficultyIds.length, `${dungeonId}:${encounter.id}:${rule.id}`)
|
||||
.toBeGreaterThan(0);
|
||||
for (const difficultyId of guard.difficultyIds) {
|
||||
expect(
|
||||
selectableDifficultyIds.has(difficultyId),
|
||||
`${dungeonId}:${encounter.id}:${rule.id}:${difficultyId}`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -114,12 +114,50 @@ export const WAILING_NARALEX_ENCOUNTER = Object.freeze({
|
||||
dungeonId: "wailing-caverns",
|
||||
initialPhase: "locked",
|
||||
resetPolicy: { bossWipePhase: "locked", checkpointPhase: "locked" },
|
||||
source: {
|
||||
fidelity: "source-backed-adapted",
|
||||
scope: "instance-orchestration",
|
||||
files: [
|
||||
"src/game/generated/azerothCoreReference.json",
|
||||
"dungeon-pipeline/fixtures/wailing-caverns/source-snapshot.json",
|
||||
"../HealerMan-Storage/pipeline-work/dungeons/azerothcore-scripts/src/server/scripts/Kalimdor/WailingCaverns/instance_wailing_caverns.cpp",
|
||||
"../HealerMan-Storage/pipeline-work/dungeons/azerothcore-scripts/src/server/scripts/Kalimdor/WailingCaverns/wailing_caverns.h",
|
||||
],
|
||||
reviewedBehavior: "The four Fanglords unlock the Disciple of Naralex event, which advances through ravager, serpent, cleansing, and nightmare stages before summoning Mutanus and completing the dungeon.",
|
||||
adaptations: [
|
||||
"Disciple movement is deterministic waypoint interpolation using source positions and reviewed stage durations instead of server-side escort motion packets.",
|
||||
"Wave safety timeouts prevent a missing or unreachable summon from permanently soft locking the event.",
|
||||
],
|
||||
remainingGaps: [
|
||||
"Port the complete source dialogue/emote sequence and every waypoint pause trigger.",
|
||||
"Bind the Disciple interaction to a reviewed source-backed NPC/gossip presentation instead of the current encounter signal adapter.",
|
||||
"Record full traversal, wipe/reset, checkpoint reload, HUD, collision, and completion playtest evidence.",
|
||||
],
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
id: "fanglords-defeated",
|
||||
trigger: { kind: "entities-dead", entityIds: FANG_LORD_RUNTIME_IDS },
|
||||
guards: [{ kind: "phase", phases: ["locked"] }],
|
||||
actions: [{ id: "unlock-naralex", kind: "set-phase", phase: "ready" }],
|
||||
actions: [
|
||||
{
|
||||
id: "record-fanglords-complete",
|
||||
kind: "set-flag",
|
||||
flagId: "fanglords-complete",
|
||||
value: true,
|
||||
persistence: "run",
|
||||
},
|
||||
{ id: "unlock-naralex", kind: "set-phase", phase: "ready" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "restore-ready-after-checkpoint",
|
||||
trigger: { kind: "tick" },
|
||||
guards: [
|
||||
{ kind: "phase", phases: ["locked"] },
|
||||
{ kind: "flag", flagId: "fanglords-complete", equals: true },
|
||||
],
|
||||
actions: [{ id: "restore-ready-phase", kind: "set-phase", phase: "ready" }],
|
||||
},
|
||||
{
|
||||
id: "start-escort",
|
||||
@@ -196,6 +234,8 @@ export const WAILING_NARALEX_ENCOUNTER = Object.freeze({
|
||||
actions: [
|
||||
{ id: "complete-naralex", kind: "set-phase", phase: "complete", persistence: "run" },
|
||||
{ id: "complete-mutanus-objective", kind: "complete-objective", objectiveId: "mutanus-spawn", persistence: "run" },
|
||||
{ id: "save-mutanus-checkpoint", kind: "set-checkpoint", checkpointId: "mutanus-spawn", persistence: "checkpoint" },
|
||||
{ id: "mark-wailing-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -136,5 +136,31 @@ describe("AzerothCore-backed Wailing Caverns parity slice", () => {
|
||||
expect(useWailingEncounterStore.getState().phase).toBe("mutanus");
|
||||
useWailingEncounterStore.getState().advance({ "mutanus-spawn": deadMob }, 264_504);
|
||||
expect(useWailingEncounterStore.getState().phase).toBe("complete");
|
||||
expect(useWailingEncounterStore.getState().flags["dungeon-complete"]).toBe(true);
|
||||
expect(useWailingEncounterStore.getState().checkpointId).toBe("mutanus-spawn");
|
||||
});
|
||||
|
||||
it("restores the Naralex unlock after a checkpoint reload suppresses the cleared Fanglords", () => {
|
||||
useWailingEncounterStore.getState().advance({
|
||||
"lady-anacondra-spawn": deadMob,
|
||||
"lord-cobrahn-spawn": deadMob,
|
||||
"lord-pythas-spawn": deadMob,
|
||||
"lord-serpentis-spawn": deadMob,
|
||||
}, 1_000);
|
||||
expect(useWailingEncounterStore.getState()).toMatchObject({
|
||||
phase: "ready",
|
||||
flags: { "fanglords-complete": true },
|
||||
});
|
||||
|
||||
useWailingEncounterStore.getState().reset("checkpoint", 2_000);
|
||||
expect(useWailingEncounterStore.getState()).toMatchObject({
|
||||
phase: "locked",
|
||||
flags: { "fanglords-complete": true },
|
||||
});
|
||||
|
||||
// A loaded checkpoint intentionally has no live Fanglord runtime ids: the
|
||||
// progression provider has already suppressed those completed bosses.
|
||||
useWailingEncounterStore.getState().advance({}, 2_100);
|
||||
expect(useWailingEncounterStore.getState().phase).toBe("ready");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,515 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { gunzipSync, strFromU8 } from "fflate";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDungeonEncounterSnapshot,
|
||||
reduceDungeonEncounter,
|
||||
resetDungeonEncounterSnapshot,
|
||||
validateDungeonEncounterDefinition,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
|
||||
import type { DungeonEncounterDefinition, DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
||||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||||
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
||||
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
|
||||
import {
|
||||
WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS,
|
||||
withWowClassicEncounterBatchThree,
|
||||
} from "./wowClassicEncounterBatchThreeCatalog";
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
const decoded = atob(value);
|
||||
const bytes = new Uint8Array(decoded.length);
|
||||
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const campaign = JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
|
||||
readonly definitions: readonly DungeonDefinition[];
|
||||
};
|
||||
|
||||
function original(id: string): DungeonDefinition {
|
||||
const definition = campaign.definitions.find((candidate) => candidate.id === id);
|
||||
if (!definition) throw new Error(`Missing generated dungeon ${id}`);
|
||||
return definition;
|
||||
}
|
||||
|
||||
function preadapted(id: string): DungeonDefinition {
|
||||
return withWowEncounterContent(original(id));
|
||||
}
|
||||
|
||||
function adapted(id: string): DungeonDefinition {
|
||||
return withWowClassicEncounterBatchThree(preadapted(id));
|
||||
}
|
||||
|
||||
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
|
||||
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:classic-batch-three`);
|
||||
if (!value) throw new Error(`Missing Classic batch-three encounter for ${definition.id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
|
||||
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
|
||||
if (!value) throw new Error(`Missing encounter group ${id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectBySpawn(definition: DungeonDefinition, spawnId: number) {
|
||||
const matches = definition.serverGameObjects.filter((object) => object.source?.spawnId === spawnId);
|
||||
if (matches.length !== 1) throw new Error(`${definition.id} expected one source object ${spawnId}, found ${matches.length}`);
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function killGroup(
|
||||
scripted: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
spawnGroup: DungeonEncounterSpawnGroupDefinition,
|
||||
now: number,
|
||||
): DungeonEncounterSnapshot {
|
||||
const ids = spawnGroup.spawns.map((spawn) => spawn.id);
|
||||
return reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "tick",
|
||||
now,
|
||||
facts: { registeredEntityIds: ids, deadEntityIds: ids },
|
||||
}).snapshot;
|
||||
}
|
||||
|
||||
function staticEntries(definition: DungeonDefinition): readonly number[] {
|
||||
return definition.staticSpawns.flatMap((spawn) => {
|
||||
const entry = definition.entities[spawn.entityId]?.combat?.serverEntry;
|
||||
return entry === undefined ? [] : [entry];
|
||||
});
|
||||
}
|
||||
|
||||
describe("Classic source-backed encounter batch three", () => {
|
||||
it("pins sources, validates references, suppresses event actors, remains idempotent, and preserves Solakar", () => {
|
||||
const cases = [
|
||||
["gnomeregan", [7_998]],
|
||||
["dire-maul", [11_496]],
|
||||
["blackrock-spire", [9_816, 10_316, 10_429, 10_264]],
|
||||
] as const;
|
||||
expect(WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS.map((review) => review.dungeonId))
|
||||
.toEqual(cases.map(([id]) => id));
|
||||
|
||||
const checkedPaths = new Set<string>();
|
||||
for (const review of WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS) {
|
||||
expect(review.fidelity).toBe("source-backed-adapted");
|
||||
expect(review.adaptations.length).toBeGreaterThan(0);
|
||||
expect(review.remainingGaps.length).toBeGreaterThan(0);
|
||||
for (const sourceFile of review.sourceFiles) {
|
||||
expect(sourceFile.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(sourceFile.anchors.length).toBeGreaterThan(0);
|
||||
expect(sourceFile.anchors.every((anchor) => /lines? \d+/.test(anchor))).toBe(true);
|
||||
if (!checkedPaths.has(sourceFile.path)) {
|
||||
expect(createHash("sha256").update(readFileSync(resolve(sourceFile.path))).digest("hex"), sourceFile.path)
|
||||
.toBe(sourceFile.sha256);
|
||||
checkedPaths.add(sourceFile.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const priorBRS = preadapted("blackrock-spire");
|
||||
const priorSolakarEncounter = priorBRS.encounters?.find((value) => value.id === "blackrock-spire:wow-instance");
|
||||
const priorSolakarGroups = priorBRS.encounterSpawnGroups?.filter((value) => value.id.startsWith("blackrock-spire:solakar"));
|
||||
const priorFatherFlame = priorBRS.serverGameObjects.find((value) => value.source?.templateId === 175_245);
|
||||
expect(priorSolakarEncounter).toBeDefined();
|
||||
expect(priorSolakarGroups).toHaveLength(6);
|
||||
expect(priorFatherFlame).toBeDefined();
|
||||
|
||||
for (const [id, suppressed] of cases) {
|
||||
const definition = adapted(id);
|
||||
const scripted = encounter(definition);
|
||||
validateDungeonEncounterDefinition(scripted);
|
||||
expect(scripted.source?.scope).toBe("instance-orchestration");
|
||||
expect(withWowClassicEncounterBatchThree(definition)).toBe(definition);
|
||||
expect(staticEntries(definition)).not.toEqual(expect.arrayContaining([...suppressed]));
|
||||
|
||||
const spawnIds = new Set([
|
||||
...definition.staticSpawns.map((spawn) => spawn.id),
|
||||
...(definition.encounterSpawnGroups ?? []).flatMap((spawnGroup) => spawnGroup.spawns.map((spawn) => spawn.id)),
|
||||
]);
|
||||
const groupIds = new Set(definition.encounterSpawnGroups?.map((spawnGroup) => spawnGroup.id));
|
||||
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
|
||||
const objectiveIds = new Set(definition.bosses.map((objective) => objective.id));
|
||||
expect(objectIds.size).toBe(definition.serverGameObjects.length);
|
||||
expect(groupIds.size).toBe(definition.encounterSpawnGroups?.length ?? 0);
|
||||
const actionIds = new Set<string>();
|
||||
for (const spawnGroup of definition.encounterSpawnGroups ?? []) {
|
||||
for (const spawn of spawnGroup.spawns) expect(definition.entities[spawn.entityId], `${id}:${spawn.id}`).toBeDefined();
|
||||
}
|
||||
for (const rule of scripted.rules) {
|
||||
if (rule.trigger.kind === "interact") expect(objectIds.has(rule.trigger.objectId), `${id}:${rule.id}`).toBe(true);
|
||||
if (rule.trigger.kind === "entity-death" || rule.trigger.kind === "entity-engaged" || rule.trigger.kind === "entity-health") {
|
||||
expect(spawnIds.has(rule.trigger.entityId), `${id}:${rule.id}:${rule.trigger.entityId}`).toBe(true);
|
||||
}
|
||||
if (rule.trigger.kind === "entities-dead") {
|
||||
expect(rule.trigger.entityIds.every((entityId) => spawnIds.has(entityId)), `${id}:${rule.id}`).toBe(true);
|
||||
}
|
||||
for (const action of rule.actions) {
|
||||
expect(actionIds.has(action.id), `${id}:duplicate action ${action.id}`).toBe(false);
|
||||
actionIds.add(action.id);
|
||||
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
|
||||
expect(groupIds.has(action.spawnGroupId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
||||
}
|
||||
if (action.kind === "set-world-object-state") {
|
||||
const object = definition.serverGameObjects.find((candidate) => candidate.id === action.objectId);
|
||||
expect(object, `${id}:${rule.id}:${action.id}`).toBeDefined();
|
||||
expect(object?.states.some((state) => state.id === action.state), `${id}:${rule.id}:${action.id}`).toBe(true);
|
||||
}
|
||||
if (action.kind === "complete-objective") {
|
||||
expect(objectiveIds.has(action.objectiveId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sourceSpawnIds = definition.serverGameObjects.flatMap((object) => (
|
||||
object.source?.spawnId === undefined ? [] : [object.source.spawnId]
|
||||
));
|
||||
expect(new Set(sourceSpawnIds).size).toBe(sourceSpawnIds.length);
|
||||
for (const object of definition.serverGameObjects.filter((candidate) => (
|
||||
scripted.initialState?.worldObjectStates?.[candidate.id] !== undefined
|
||||
))) {
|
||||
validateDungeonWorldObject(object);
|
||||
expect(object.visual?.hidden).not.toBe(true);
|
||||
}
|
||||
expect(definition.validation.warnings.some((warning) => warning.includes("remain"))).toBe(true);
|
||||
}
|
||||
|
||||
const brs = adapted("blackrock-spire");
|
||||
expect(brs.encounters?.find((value) => value.id === "blackrock-spire:wow-instance")).toEqual(priorSolakarEncounter);
|
||||
expect(brs.encounterSpawnGroups?.filter((value) => value.id.startsWith("blackrock-spire:solakar")))
|
||||
.toEqual(priorSolakarGroups);
|
||||
expect(brs.serverGameObjects.find((value) => value.source?.templateId === 175_245)).toEqual(priorFatherFlame);
|
||||
});
|
||||
|
||||
it("ports Gyth and Urok's pinned combat schedules instead of leaving event bosses basic-only", () => {
|
||||
const definition = adapted("blackrock-spire");
|
||||
const byEntry = (entry: number) => Object.values(definition.entities)
|
||||
.find((entity) => entity.combat?.serverEntry === entry)!;
|
||||
const gyth = byEntry(10_339);
|
||||
const urok = byEntry(10_584);
|
||||
|
||||
expect(gyth.combat?.attacks.filter((attack) => attack.id !== "basic-attack").map((attack) => ({
|
||||
id: attack.id,
|
||||
spellId: attack.spellId,
|
||||
initial: attack.initialCooldownMs,
|
||||
repeat: attack.repeatCooldownMs,
|
||||
}))).toEqual([
|
||||
{ id: "corrosive-acid-16359", spellId: 16_359, initial: [8_000, 16_000], repeat: [10_000, 16_000] },
|
||||
{ id: "freeze-16350", spellId: 16_350, initial: [8_000, 16_000], repeat: [10_000, 16_000] },
|
||||
{ id: "flame-breath-16390", spellId: 16_390, initial: [8_000, 16_000], repeat: [10_000, 16_000] },
|
||||
{ id: "knock-away-10101", spellId: 10_101, initial: [12_000, 18_000], repeat: [14_000, 20_000] },
|
||||
]);
|
||||
expect(gyth.combat?.mechanicIds).toEqual([
|
||||
"corrosive-acid-16359",
|
||||
"freeze-16350",
|
||||
"flame-breath-16390",
|
||||
"knock-away-10101",
|
||||
]);
|
||||
expect(gyth.combat?.attacks.find((attack) => attack.id === "corrosive-acid-16359"))
|
||||
.toMatchObject({
|
||||
target: "nearby-party",
|
||||
school: "nature",
|
||||
range: 20,
|
||||
radius: 20,
|
||||
telegraph: { shape: "cone", anchor: "source", length: 20, angleDegrees: 90 },
|
||||
});
|
||||
expect(gyth.combat?.attacks.find((attack) => attack.id === "freeze-16350"))
|
||||
.toMatchObject({
|
||||
target: "nearby-party",
|
||||
school: "frost",
|
||||
range: 20,
|
||||
radius: 20,
|
||||
effect: { kind: "control", mechanic: "stun", durationMs: 10_000, damageMultiplier: 0.8 },
|
||||
telegraph: { shape: "cone", anchor: "source", length: 20, angleDegrees: 90 },
|
||||
});
|
||||
expect(gyth.combat?.attacks.find((attack) => attack.id === "flame-breath-16390"))
|
||||
.toMatchObject({
|
||||
target: "nearby-party",
|
||||
school: "fire",
|
||||
range: 20,
|
||||
radius: 20,
|
||||
telegraph: { shape: "cone", anchor: "source", length: 20, angleDegrees: 90 },
|
||||
});
|
||||
|
||||
expect(urok.combat?.attacks.filter((attack) => attack.id !== "basic-attack").map((attack) => ({
|
||||
id: attack.id,
|
||||
spellId: attack.spellId,
|
||||
initial: attack.initialCooldownMs,
|
||||
repeat: attack.repeatCooldownMs,
|
||||
}))).toEqual([
|
||||
{ id: "rend-16509", spellId: 16_509, initial: [17_000, 20_000], repeat: [8_000, 10_000] },
|
||||
{ id: "strike-15580", spellId: 15_580, initial: [10_000, 12_000], repeat: [8_000, 10_000] },
|
||||
{ id: "intimidating-roar-16508", spellId: 16_508, initial: [25_000, 30_000], repeat: [40_000, 45_000] },
|
||||
]);
|
||||
expect(urok.combat?.attacks.find((attack) => attack.id === "intimidating-roar-16508"))
|
||||
.toMatchObject({
|
||||
target: "nearby-party",
|
||||
range: 8,
|
||||
radius: 8,
|
||||
effect: { kind: "control", mechanic: "fear", durationMs: 8_000 },
|
||||
telegraph: { shape: "circle", anchor: "source", radius: 8 },
|
||||
});
|
||||
|
||||
const review = WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS.find((candidate) => (
|
||||
candidate.dungeonId === "blackrock-spire"
|
||||
))!;
|
||||
expect(review.remainingGaps.join(" ")).toContain("Freeze periodic damage");
|
||||
expect(review.remainingGaps.join(" ")).toContain("split primary-root/nearby-fear targeting");
|
||||
});
|
||||
|
||||
it("ports Crystal Fang's exact death summon while keeping the source-faithful rare boss melee-only", () => {
|
||||
const definition = adapted("blackrock-spire");
|
||||
const scripted = encounter(definition);
|
||||
const crystalFang = definition.staticSpawns.find((spawn) => (
|
||||
definition.entities[spawn.entityId]?.combat?.serverEntry === 10_376
|
||||
))!;
|
||||
const spiderling = group(definition, "blackrock-spire:batch-three:crystal-fang-spiderling");
|
||||
expect(crystalFang.spawnChance).toBe(0.15);
|
||||
expect(spiderling.spawns).toHaveLength(1);
|
||||
expect(spiderling.spawns[0]).toMatchObject({
|
||||
entityId: expect.stringContaining("entry-10375"),
|
||||
position: crystalFang.position,
|
||||
});
|
||||
|
||||
const snapshot = reduceDungeonEncounter(scripted, createDungeonEncounterSnapshot(scripted), {
|
||||
kind: "entity-death",
|
||||
entityId: crystalFang.id,
|
||||
now: 1,
|
||||
}).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(spiderling.id);
|
||||
});
|
||||
|
||||
it("runs Gnomeregan's exact Emi group counts, cave-ins, retry, doors, and persistent Grubbis gate", () => {
|
||||
const definition = adapted("gnomeregan");
|
||||
const scripted = encounter(definition);
|
||||
const groups = ["ambushers-1", "ambushers-2", "ambushers-3a", "ambushers-3b", "grubbis"]
|
||||
.map((name) => group(definition, `gnomeregan:batch-three:${name}`));
|
||||
expect(groups.map((value) => value.spawns.length)).toEqual([10, 4, 5, 5, 2]);
|
||||
expect(groups[2].spawns.map((spawn) => spawn.position)).toEqual(groups[3].spawns.map((spawn) => spawn.position));
|
||||
const emi = definition.serverGameObjects.find((object) => object.id === "gnomeregan:batch-three:emi-shortfuse")!;
|
||||
const caveOne = objectBySpawn(definition, 6_447);
|
||||
const caveTwo = objectBySpawn(definition, 17_633);
|
||||
const workshop = objectBySpawn(definition, 12_003);
|
||||
const finalDoor = objectBySpawn(definition, 6_910);
|
||||
let snapshot = createDungeonEncounterSnapshot(scripted);
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: workshop.id, now: 0 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: finalDoor.id, now: 1 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emi.id, now: 2 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(groups[0].id);
|
||||
snapshot = killGroup(scripted, snapshot, groups[0], 3);
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 4);
|
||||
expect(snapshot).toMatchObject({ phase: "idle", activeSpawnGroupIds: [] });
|
||||
expect(snapshot.worldObjectStates[emi.id]).toBe("ready");
|
||||
expect(snapshot.worldObjectStates[caveTwo.id]).toBe("closed");
|
||||
expect(snapshot.worldObjectStates[workshop.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[finalDoor.id]).toBe("open");
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emi.id, now: 10 }).snapshot;
|
||||
snapshot = killGroup(scripted, snapshot, groups[0], 11);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_011 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(groups[1].id);
|
||||
snapshot = killGroup(scripted, snapshot, groups[1], 5_012);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 45_012 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[caveTwo.id]).toBe("open");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(groups[2].id);
|
||||
snapshot = killGroup(scripted, snapshot, groups[2], 45_013);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 48_013 }).snapshot;
|
||||
snapshot = killGroup(scripted, snapshot, groups[3], 48_014);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 108_014 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(groups[4].id);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: "gnomeregan:batch-three:grubbis",
|
||||
now: 108_015,
|
||||
}).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain("gnomeregan:batch-three:grubbis");
|
||||
expect(snapshot.worldObjectStates[caveOne.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[emi.id]).toBe("complete");
|
||||
expect(snapshot.checkpointId).toBe("gnomeregan-grubbis");
|
||||
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 108_016);
|
||||
expect(snapshot.completedObjectiveIds).toContain("gnomeregan:batch-three:grubbis");
|
||||
expect(snapshot.worldObjectStates[caveOne.id]).toBe("open");
|
||||
const fleet = definition.encounters?.find((value) => value.id === "gnomeregan:boss-progression")!;
|
||||
const completion = fleet.rules.find((rule) => rule.id === "all-required-bosses-defeated")!;
|
||||
expect(completion.guards).toContainEqual({
|
||||
kind: "objective-complete",
|
||||
objectiveId: "gnomeregan:batch-three:grubbis",
|
||||
equals: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("runs Dire Maul's pylons, Immol'thar recovery, east-wing wall, doors, and six tribute tiers", () => {
|
||||
const definition = adapted("dire-maul");
|
||||
const scripted = encounter(definition);
|
||||
const pylons = [29_742, 29_743, 29_744, 29_745, 29_746].map((id) => objectBySpawn(definition, id));
|
||||
const forcefield = objectBySpawn(definition, 29_747);
|
||||
const courtyard = objectBySpawn(definition, 29_750);
|
||||
const inner = objectBySpawn(definition, 29_756);
|
||||
const conservatory = objectBySpawn(definition, 268_275);
|
||||
const wall = objectBySpawn(definition, 268_276);
|
||||
const ironbark = definition.serverGameObjects.find((object) => object.id === "dire-maul:batch-three:old-ironbark")!;
|
||||
const tribute = definition.serverGameObjects.find((object) => object.id === "dire-maul:batch-three:gordok-tribute")!;
|
||||
const immol = group(definition, "dire-maul:batch-three:immolthar");
|
||||
const alzzin = group(definition, "dire-maul:batch-three:alzzin");
|
||||
let snapshot = createDungeonEncounterSnapshot(scripted);
|
||||
|
||||
pylons.forEach((pylon, index) => {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: pylon.id, now: index }).snapshot;
|
||||
});
|
||||
expect(snapshot.counters["pylons-off"]).toBe(5);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 10 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[forcefield.id]).toBe("open");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(immol.id);
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 11);
|
||||
expect(snapshot.activeSpawnGroupIds).not.toContain(immol.id);
|
||||
expect(snapshot.counters["pylons-off"]).toBe(5);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 12 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(immol.id);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: immol.spawns[0].id, now: 13 }).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain(immol.spawns[0].id);
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-248429", now: 20 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[ironbark.id]).toBe("ready");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: ironbark.id, now: 21 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 7_021 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[conservatory.id]).toBe("open");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(alzzin.id);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: alzzin.spawns[0].id, healthPercent: 51, now: 7_022 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[wall.id]).toBe("closed");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: alzzin.spawns[0].id, healthPercent: 50, now: 7_023 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[wall.id]).toBe("open");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 7_024);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 7_025 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(alzzin.id);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: alzzin.spawns[0].id, now: 7_026 }).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain("dire-maul:batch-three:alzzin");
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: courtyard.id, now: 8_000 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: inner.id, now: 8_001 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[courtyard.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[inner.id]).toBe("open");
|
||||
for (const id of ["creature-248079", "creature-248080"]) {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: id, now: snapshot.revision + 8_010 }).snapshot;
|
||||
}
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-248076", now: 9_000 }).snapshot;
|
||||
expect(snapshot.counters["tribute-penalties"]).toBe(2);
|
||||
expect(snapshot.worldObjectStates[tribute.id]).toBe("tier-4");
|
||||
|
||||
let perfect = createDungeonEncounterSnapshot(scripted);
|
||||
perfect = reduceDungeonEncounter(scripted, perfect, { kind: "entity-death", entityId: "creature-248076", now: 1 }).snapshot;
|
||||
expect(perfect.worldObjectStates[tribute.id]).toBe("tier-6");
|
||||
const optionalIds = new Set(["creature-248077", "creature-248079", "creature-248080", "creature-248081", "creature-248082"]);
|
||||
expect(definition.bosses.filter((boss) => optionalIds.has(boss.id)).every((boss) => boss.optional)).toBe(true);
|
||||
const fleetCompletion = definition.encounters?.find((value) => value.id === "dire-maul:boss-progression")
|
||||
?.rules.find((rule) => rule.id === "all-required-bosses-defeated");
|
||||
expect(fleetCompletion?.guards?.some((guard) => guard.kind === "objective-complete" && optionalIds.has(guard.objectiveId)))
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
it("runs Blackrock Spire's runes, Emberseer reset, exact stadium, Drakkisath doors, and Urok retry", () => {
|
||||
const definition = adapted("blackrock-spire");
|
||||
const scripted = encounter(definition);
|
||||
const hallRunes = [87_827, 87_829, 87_825, 87_830, 87_828, 87_826, 87_824].map((id) => objectBySpawn(definition, id));
|
||||
const emberRunes = [87_831, 87_832, 87_833, 87_834, 87_835, 87_836, 87_837].map((id) => objectBySpawn(definition, id));
|
||||
const emberIn = objectBySpawn(definition, 87_839);
|
||||
const emberOut = objectBySpawn(definition, 87_841);
|
||||
const emberDoors = objectBySpawn(definition, 87_842);
|
||||
const emberTrigger = definition.serverGameObjects.find((object) => object.id === "blackrock-spire:batch-three:emberseer-trigger")!;
|
||||
const incarcerators = group(definition, "blackrock-spire:batch-three:incarcerators");
|
||||
const emberseer = group(definition, "blackrock-spire:batch-three:emberseer");
|
||||
const runeMobs = [
|
||||
["creature-137869", "creature-137871", "creature-137928", "creature-137930"],
|
||||
["creature-137872", "creature-137896", "creature-137898"],
|
||||
["creature-137870", "creature-137886", "creature-137929"],
|
||||
["creature-137873", "creature-137892", "creature-137897", "creature-137903", "creature-137904", "creature-137905", "creature-137931", "creature-137934"],
|
||||
["creature-137878", "creature-137911", "creature-137912", "creature-137939"],
|
||||
["creature-137880", "creature-137881", "creature-137942", "creature-137943"],
|
||||
["creature-137877", "creature-137908", "creature-137909", "creature-137910", "creature-137938"],
|
||||
];
|
||||
let snapshot = createDungeonEncounterSnapshot(scripted);
|
||||
runeMobs.forEach((ids, index) => {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "tick",
|
||||
now: index + 1,
|
||||
facts: { registeredEntityIds: ids, deadEntityIds: ids },
|
||||
}).snapshot;
|
||||
});
|
||||
expect(snapshot.counters["dragonspire-runes"]).toBe(7);
|
||||
expect(hallRunes.every((rune) => snapshot.worldObjectStates[rune.id] === "inactive")).toBe(true);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 8 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[emberIn.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[emberDoors.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[emberTrigger.id]).toBe("ready");
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emberTrigger.id, now: 10 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 2_010 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(incarcerators.id);
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 2_011);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 2_012 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[emberTrigger.id]).toBe("ready");
|
||||
expect(snapshot.worldObjectStates[emberIn.id]).toBe("open");
|
||||
expect(snapshot.activeSpawnGroupIds).not.toContain(incarcerators.id);
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emberTrigger.id, now: 3_000 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_000 }).snapshot;
|
||||
snapshot = killGroup(scripted, snapshot, incarcerators, 5_001);
|
||||
expect(emberRunes.every((rune) => snapshot.worldObjectStates[rune.id] === "growing")).toBe(true);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 30_001 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(emberseer.id);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: emberseer.spawns[0].id, now: 30_002 }).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain("creature-137817");
|
||||
expect(snapshot.worldObjectStates[emberOut.id]).toBe("open");
|
||||
expect(emberRunes.every((rune) => snapshot.worldObjectStates[rune.id] === "released")).toBe(true);
|
||||
|
||||
const stadiumEntry = objectBySpawn(definition, 87_845);
|
||||
const stadiumWaveDoor = objectBySpawn(definition, 87_843);
|
||||
const stadiumExit = objectBySpawn(definition, 87_844);
|
||||
const waves = Array.from({ length: 6 }, (_, index) => group(definition, `blackrock-spire:batch-three:stadium-wave-${index + 1}`));
|
||||
expect(waves.map((value) => value.spawns.length)).toEqual([4, 3, 4, 4, 5, 5]);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: stadiumEntry.id, now: 31_000 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 42_000 }).snapshot;
|
||||
for (let index = 0; index < waves.length; index += 1) {
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(waves[index].id);
|
||||
snapshot = killGroup(scripted, snapshot, waves[index], snapshot.phaseStartedAt + 1);
|
||||
if (index < waves.length - 1) {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 13_000 }).snapshot;
|
||||
}
|
||||
}
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 18_000 }).snapshot;
|
||||
const gyth = group(definition, "blackrock-spire:batch-three:gyth");
|
||||
const rend = group(definition, "blackrock-spire:batch-three:rend");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(gyth.id);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: gyth.spawns[0].id, healthPercent: 26, now: snapshot.phaseStartedAt + 1 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).not.toContain(rend.id);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: gyth.spawns[0].id, healthPercent: 25, now: snapshot.phaseStartedAt + 2 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(rend.id);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: gyth.spawns[0].id, now: snapshot.phaseStartedAt + 3 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: rend.spawns[0].id, now: snapshot.phaseStartedAt + 4 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 5 }).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain("creature-137968");
|
||||
expect(snapshot.worldObjectStates[stadiumEntry.id]).toBe("complete");
|
||||
expect(snapshot.worldObjectStates[stadiumWaveDoor.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[stadiumExit.id]).toBe("open");
|
||||
|
||||
const drakkDoors = [objectBySpawn(definition, 87_854), objectBySpawn(definition, 87_855)];
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-137971", now: snapshot.phaseStartedAt + 6 }).snapshot;
|
||||
expect(drakkDoors.every((door) => snapshot.worldObjectStates[door.id] === "open")).toBe(true);
|
||||
|
||||
const pile = objectBySpawn(definition, 99_791);
|
||||
const urok = group(definition, "blackrock-spire:batch-three:urok");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: pile.id, now: snapshot.phaseStartedAt + 7 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(urok.id);
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", snapshot.phaseStartedAt + 8);
|
||||
expect(snapshot.worldObjectStates[pile.id]).toBe("ready");
|
||||
expect(snapshot.completedObjectiveIds).toContain("creature-137817");
|
||||
expect(snapshot.completedObjectiveIds).toContain("creature-137968");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: pile.id, now: snapshot.phaseStartedAt + 9 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: urok.spawns[0].id, now: snapshot.phaseStartedAt + 10 }).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain("blackrock-spire:batch-three:urok-doomhowl");
|
||||
expect(snapshot.worldObjectStates[pile.id]).toBe("complete");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,482 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { gunzipSync, strFromU8 } from "fflate";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDungeonEncounterSnapshot,
|
||||
reduceDungeonEncounter,
|
||||
resetDungeonEncounterSnapshot,
|
||||
validateDungeonEncounterDefinition,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
|
||||
import type { DungeonEncounterDefinition, DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
||||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||||
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
||||
import {
|
||||
WOW_CLASSIC_ENCOUNTER_BATCH_TWO_REVIEWS,
|
||||
withWowClassicEncounterBatchTwo,
|
||||
} from "./wowClassicEncounterBatchTwoCatalog";
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
const decoded = atob(value);
|
||||
const bytes = new Uint8Array(decoded.length);
|
||||
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const campaign = JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
|
||||
readonly definitions: readonly DungeonDefinition[];
|
||||
};
|
||||
|
||||
function original(id: string): DungeonDefinition {
|
||||
const definition = campaign.definitions.find((candidate) => candidate.id === id);
|
||||
if (!definition) throw new Error(`Missing generated dungeon ${id}`);
|
||||
return definition;
|
||||
}
|
||||
|
||||
function adapted(id: string): DungeonDefinition {
|
||||
return withWowClassicEncounterBatchTwo(original(id));
|
||||
}
|
||||
|
||||
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
|
||||
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:classic-batch-two`);
|
||||
if (!value) throw new Error(`Missing Classic batch-two encounter for ${definition.id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
|
||||
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
|
||||
if (!value) throw new Error(`Missing encounter group ${id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectBySpawn(definition: DungeonDefinition, spawnId: number) {
|
||||
const matches = definition.serverGameObjects.filter((object) => object.source?.spawnId === spawnId);
|
||||
if (matches.length !== 1) throw new Error(`${definition.id} expected one source object ${spawnId}, found ${matches.length}`);
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function killGroup(
|
||||
definition: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
spawnGroup: DungeonEncounterSpawnGroupDefinition,
|
||||
now: number,
|
||||
) {
|
||||
const entityIds = spawnGroup.spawns.map((spawn) => spawn.id);
|
||||
return reduceDungeonEncounter(definition, snapshot, {
|
||||
kind: "tick",
|
||||
now,
|
||||
facts: { registeredEntityIds: entityIds, deadEntityIds: entityIds },
|
||||
}).snapshot;
|
||||
}
|
||||
|
||||
function staticEntries(definition: DungeonDefinition): readonly number[] {
|
||||
return definition.staticSpawns.flatMap((spawn) => {
|
||||
const entry = definition.entities[spawn.entityId]?.combat?.serverEntry;
|
||||
return entry === undefined ? [] : [entry];
|
||||
});
|
||||
}
|
||||
|
||||
describe("Classic source-backed encounter batch two", () => {
|
||||
it("pins exact source hashes, validates every reference, suppresses event actors, and stays idempotent", () => {
|
||||
const cases = [
|
||||
["sunken-temple", [8_580, 8_443, 5_709]],
|
||||
["blackrock-depths", [9_035, 9_038, 9_040, 9_037, 9_036, 9_034, 9_039]],
|
||||
["scholomance", [1_853]],
|
||||
["stratholme", [10_440]],
|
||||
] as const;
|
||||
expect(WOW_CLASSIC_ENCOUNTER_BATCH_TWO_REVIEWS.map((review) => review.dungeonId))
|
||||
.toEqual(cases.map(([id]) => id));
|
||||
|
||||
const verifiedPaths = new Set<string>();
|
||||
for (const review of WOW_CLASSIC_ENCOUNTER_BATCH_TWO_REVIEWS) {
|
||||
expect(review.fidelity).toBe("source-backed-adapted");
|
||||
expect(review.adaptations.length).toBeGreaterThan(0);
|
||||
expect(review.remainingGaps.length).toBeGreaterThan(0);
|
||||
for (const sourceFile of review.sourceFiles) {
|
||||
expect(sourceFile.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(sourceFile.anchors.length).toBeGreaterThan(0);
|
||||
expect(sourceFile.anchors.every((anchor) => /lines? \d+/.test(anchor))).toBe(true);
|
||||
if (!verifiedPaths.has(sourceFile.path)) {
|
||||
const actualHash = createHash("sha256").update(readFileSync(resolve(sourceFile.path))).digest("hex");
|
||||
expect(actualHash, sourceFile.path).toBe(sourceFile.sha256);
|
||||
verifiedPaths.add(sourceFile.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, suppressedEntries] of cases) {
|
||||
const definition = adapted(id);
|
||||
const scripted = encounter(definition);
|
||||
validateDungeonEncounterDefinition(scripted);
|
||||
expect(withWowClassicEncounterBatchTwo(definition)).toBe(definition);
|
||||
expect(staticEntries(definition)).not.toEqual(expect.arrayContaining([...suppressedEntries]));
|
||||
|
||||
const spawnIds = new Set([
|
||||
...definition.staticSpawns.map((spawn) => spawn.id),
|
||||
...(definition.encounterSpawnGroups ?? []).flatMap((spawnGroup) => spawnGroup.spawns.map((spawn) => spawn.id)),
|
||||
]);
|
||||
const spawnGroupIds = new Set(definition.encounterSpawnGroups?.map((spawnGroup) => spawnGroup.id));
|
||||
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
|
||||
const objectiveIds = new Set(definition.bosses.map((objective) => objective.id));
|
||||
for (const spawnGroup of definition.encounterSpawnGroups ?? []) {
|
||||
for (const spawn of spawnGroup.spawns) {
|
||||
expect(definition.entities[spawn.entityId], `${id}:${spawnGroup.id}:${spawn.id}`).toBeDefined();
|
||||
}
|
||||
}
|
||||
for (const rule of scripted.rules) {
|
||||
if (rule.trigger.kind === "interact") expect(objectIds.has(rule.trigger.objectId), `${id}:${rule.id}`).toBe(true);
|
||||
if (rule.trigger.kind === "entity-death" || rule.trigger.kind === "entity-engaged" || rule.trigger.kind === "entity-health") {
|
||||
expect(spawnIds.has(rule.trigger.entityId), `${id}:${rule.id}:${rule.trigger.entityId}`).toBe(true);
|
||||
}
|
||||
if (rule.trigger.kind === "entities-dead") {
|
||||
expect(rule.trigger.entityIds.every((entityId) => spawnIds.has(entityId)), `${id}:${rule.id}`).toBe(true);
|
||||
}
|
||||
for (const action of rule.actions) {
|
||||
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
|
||||
expect(spawnGroupIds.has(action.spawnGroupId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
||||
}
|
||||
if (action.kind === "set-world-object-state") {
|
||||
const object = definition.serverGameObjects.find((candidate) => candidate.id === action.objectId);
|
||||
expect(object, `${id}:${rule.id}:${action.id}`).toBeDefined();
|
||||
expect(object?.states.some((state) => state.id === action.state), `${id}:${rule.id}:${action.id}`).toBe(true);
|
||||
}
|
||||
if (action.kind === "complete-objective") {
|
||||
expect(objectiveIds.has(action.objectiveId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sourceSpawnIds = definition.serverGameObjects
|
||||
.flatMap((object) => object.source?.spawnId === undefined ? [] : [object.source.spawnId]);
|
||||
expect(new Set(sourceSpawnIds).size).toBe(sourceSpawnIds.length);
|
||||
for (const object of definition.serverGameObjects.filter((candidate) => (
|
||||
scripted.initialState?.worldObjectStates?.[candidate.id] !== undefined
|
||||
))) {
|
||||
validateDungeonWorldObject(object);
|
||||
expect(object.visual?.hidden).not.toBe(true);
|
||||
}
|
||||
expect(definition.validation.warnings.some((warning) => warning.includes("remain"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("runs Sunken Temple's ordered statues, ritual, defender gate, and Eranikus recovery", () => {
|
||||
const definition = adapted("sunken-temple");
|
||||
const scripted = encounter(definition);
|
||||
const statues = [27_898, 27_899, 28_111, 28_112, 28_113, 28_114]
|
||||
.map((spawnId) => objectBySpawn(definition, spawnId));
|
||||
const forcefield = objectBySpawn(definition, 268_932);
|
||||
const altar = objectBySpawn(definition, 11_922);
|
||||
const flames = [47_561, 47_562, 47_563, 47_564].map((spawnId) => objectBySpawn(definition, spawnId));
|
||||
let snapshot = createDungeonEncounterSnapshot(scripted);
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: statues[1].id, now: 1 }).snapshot;
|
||||
expect(snapshot.counters["statue-progress"]).toBe(0);
|
||||
for (let index = 0; index < statues.length; index += 1) {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "interact",
|
||||
objectId: statues[index].id,
|
||||
now: index + 2,
|
||||
}).snapshot;
|
||||
}
|
||||
expect(snapshot).toMatchObject({ phase: "atal-alarion-active", counters: { "statue-progress": 6 } });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:atal-alarion");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 20);
|
||||
expect(snapshot).toMatchObject({ phase: "recovery", counters: { "statue-progress": 6 }, activeSpawnGroupIds: [] });
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 20 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:atal-alarion");
|
||||
snapshot = killGroup(scripted, snapshot, group(definition, "sunken-temple:atal-alarion"), 21);
|
||||
const atalAlarion = definition.bosses.find((objective) => objective.name === "Atal'alarion")!;
|
||||
expect(snapshot.completedObjectiveIds).toContain(atalAlarion.id);
|
||||
|
||||
const defenderIds = ["creature-39843", "creature-39844", "creature-39845", "creature-39846", "creature-34522", "creature-39847"];
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "tick",
|
||||
now: 22,
|
||||
facts: { registeredEntityIds: defenderIds, deadEntityIds: defenderIds },
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[forcefield.id]).toBe("open");
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: altar.id, now: 30 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:hakkar-pack-1");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 31);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 31 }).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "idle", activeSpawnGroupIds: [] });
|
||||
expect(snapshot.worldObjectStates[altar.id]).toBe("ready");
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: altar.id, now: 40 }).snapshot;
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const ritualGroup = group(definition, `sunken-temple:hakkar-pack-${index + 1}`);
|
||||
snapshot = killGroup(scripted, snapshot, ritualGroup, 41 + index * 2);
|
||||
expect(snapshot.worldObjectStates[flames[index].id]).toBe("ready");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "interact",
|
||||
objectId: flames[index].id,
|
||||
now: 42 + index * 2,
|
||||
}).snapshot;
|
||||
}
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:avatar-of-hakkar");
|
||||
snapshot = killGroup(scripted, snapshot, group(definition, "sunken-temple:avatar-of-hakkar"), 60);
|
||||
const avatar = definition.bosses.find((objective) => objective.name === "Avatar of Hakkar")!;
|
||||
expect(snapshot.completedObjectiveIds).toContain(avatar.id);
|
||||
expect(flames.every((flame) => snapshot.worldObjectStates[flame.id] === "doused")).toBe(true);
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-39737", now: 70 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:shade-of-eranikus");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 71);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 71 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:shade-of-eranikus");
|
||||
snapshot = killGroup(scripted, snapshot, group(definition, "sunken-temple:shade-of-eranikus"), 72);
|
||||
const eranikus = definition.bosses.find((objective) => objective.name === "Shade of Eranikus")!;
|
||||
expect(snapshot.completedObjectiveIds).toContain(eranikus.id);
|
||||
expect(snapshot.checkpointId).toBe("sunken-temple-eranikus");
|
||||
});
|
||||
|
||||
it("runs BRD's deterministic arena, twelve-coffer vault, and exact seven-stage tomb with retry", () => {
|
||||
const definition = adapted("blackrock-depths");
|
||||
const scripted = encounter(definition);
|
||||
const arenaGate4 = objectBySpawn(definition, 15_309);
|
||||
const arenaGate3 = objectBySpawn(definition, 15_295);
|
||||
const arenaGroups = [
|
||||
group(definition, "blackrock-depths:ring-wave-1"),
|
||||
group(definition, "blackrock-depths:ring-wave-2"),
|
||||
group(definition, "blackrock-depths:ring-boss"),
|
||||
];
|
||||
expect(arenaGroups.map((spawnGroup) => spawnGroup.spawns.length)).toEqual([3, 3, 1]);
|
||||
let snapshot = reduceDungeonEncounter(scripted, createDungeonEncounterSnapshot(scripted), {
|
||||
kind: "interact",
|
||||
objectId: arenaGate4.id,
|
||||
now: 0,
|
||||
}).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_000 }).snapshot;
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 5_001);
|
||||
expect(snapshot.worldObjectStates[arenaGate4.id]).toBe("ready");
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([]);
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: arenaGate4.id, now: 6_000 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 11_000 }).snapshot;
|
||||
snapshot = killGroup(scripted, snapshot, arenaGroups[0], 11_001);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 16_001 }).snapshot;
|
||||
snapshot = killGroup(scripted, snapshot, arenaGroups[1], 16_002);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 21_002 }).snapshot;
|
||||
snapshot = killGroup(scripted, snapshot, arenaGroups[2], 21_003);
|
||||
expect(snapshot.completedObjectiveIds).toContain("blackrock-depths:ring-of-law-gorosh");
|
||||
expect(snapshot.worldObjectStates[arenaGate3.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[arenaGate4.id]).toBe("open");
|
||||
|
||||
const coffers = [15_306, 15_329, 15_330, 15_331, 15_363, 15_364, 15_365, 15_536, 17_904, 35_801, 35_864, 39_924]
|
||||
.map((spawnId) => objectBySpawn(definition, spawnId));
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: coffers[index].id, now: 30 + index }).snapshot;
|
||||
}
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 40);
|
||||
expect(snapshot.counters["opened-coffers"]).toBe(6);
|
||||
expect(coffers.slice(0, 6).every((coffer) => snapshot.worldObjectStates[coffer.id] === "open")).toBe(true);
|
||||
for (let index = 6; index < coffers.length; index += 1) {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: coffers[index].id, now: 41 + index }).snapshot;
|
||||
}
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 60 }).snapshot;
|
||||
const watchman = group(definition, "blackrock-depths:vault-watchman");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(watchman.id);
|
||||
snapshot = killGroup(scripted, snapshot, watchman, 61);
|
||||
expect(snapshot.completedObjectiveIds).toContain("blackrock-depths:watchman-doomgrip");
|
||||
expect(snapshot.worldObjectStates[objectBySpawn(definition, 43_094).id]).toBe("open");
|
||||
|
||||
const marker = definition.serverGameObjects.find((object) => object.id === "blackrock-depths:doomrel-marker")!;
|
||||
const tombGroups = Array.from({ length: 7 }, (_, index) => group(definition, `blackrock-depths:tomb-spectral-${index + 1}`));
|
||||
expect(tombGroups.map((spawnGroup) => spawnGroup.spawns.length)).toEqual([1, 1, 1, 1, 1, 1, 1]);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: marker.id, now: 100 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 1_100 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(tombGroups[0].id);
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 1_101);
|
||||
expect(snapshot).toMatchObject({ phase: "idle", counters: { "tomb-kills": 0 }, activeSpawnGroupIds: [] });
|
||||
expect(snapshot.worldObjectStates[marker.id]).toBe("ready");
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: marker.id, now: 2_000 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 3_000 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: tombGroups[0].spawns[0].id,
|
||||
now: 3_001,
|
||||
}).snapshot;
|
||||
for (let index = 1; index < tombGroups.length; index += 1) {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "tick",
|
||||
now: snapshot.phaseStartedAt + 30_000,
|
||||
}).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: tombGroups[index].spawns[0].id,
|
||||
now: snapshot.phaseStartedAt + 1,
|
||||
}).snapshot;
|
||||
}
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 2 }).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain("blackrock-depths:tomb-of-seven");
|
||||
expect(snapshot.worldObjectStates[objectBySpawn(definition, 67_872).id]).toBe("available");
|
||||
expect(snapshot.worldObjectStates[objectBySpawn(definition, 15_334).id]).toBe("open");
|
||||
});
|
||||
|
||||
it("gates Scholomance completion on six minibosses and a wipe-safe Gandling reveal", () => {
|
||||
const definition = adapted("scholomance");
|
||||
const scripted = encounter(definition);
|
||||
const brazier = objectBySpawn(definition, 43_208);
|
||||
const kirtonosGate = objectBySpawn(definition, 18_892);
|
||||
const gandlingEntrance = objectBySpawn(definition, 43_201);
|
||||
const progression = definition.encounters?.find((candidate) => candidate.id === "scholomance:boss-progression")!;
|
||||
expect(progression.rules.some((rule) => rule.id === "all-required-bosses-defeated")).toBe(false);
|
||||
|
||||
let snapshot = reduceDungeonEncounter(scripted, createDungeonEncounterSnapshot(scripted), {
|
||||
kind: "interact",
|
||||
objectId: brazier.id,
|
||||
now: 0,
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[kirtonosGate.id]).toBe("closed");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 1);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 1 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[brazier.id]).toBe("ready");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: brazier.id, now: 2 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_002 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("scholomance:kirtonos");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: "scholomance:kirtonos-the-herald",
|
||||
now: 5_003,
|
||||
}).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain("scholomance:kirtonos-the-herald");
|
||||
expect(snapshot.worldObjectStates[kirtonosGate.id]).toBe("open");
|
||||
|
||||
const miniBossIds = ["creature-48867", "creature-48925", "creature-48946", "creature-48863", "creature-48857", "creature-48854"];
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "tick",
|
||||
now: 6_000,
|
||||
facts: { registeredEntityIds: miniBossIds, deadEntityIds: miniBossIds },
|
||||
}).snapshot;
|
||||
expect(snapshot.phase).toBe("gandling-reveal");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 6_001);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 6_001 }).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "gandling-ready", flags: { "gandling-unlocked": true } });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("scholomance:gandling");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-engaged",
|
||||
entityId: "creature-247104",
|
||||
now: 6_002,
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[gandlingEntrance.id]).toBe("closed");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 6_003);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 6_003 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[gandlingEntrance.id]).toBe("open");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: "creature-247104",
|
||||
now: 6_004,
|
||||
}).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain("creature-247104");
|
||||
|
||||
const fleetRequired = definition.bosses.filter((objective) => !objective.optional && !objective.eventOnly);
|
||||
for (const objective of fleetRequired) {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: objective.id,
|
||||
now: snapshot.revision + 10_000,
|
||||
}).snapshot;
|
||||
}
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 20_000 }).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true } });
|
||||
});
|
||||
|
||||
it("persists and resumes Stratholme's exact 3-to-13-to-1-to-33-to-5 slaughter chain", () => {
|
||||
const definition = adapted("stratholme");
|
||||
const scripted = encounter(definition);
|
||||
const acolytes = Array.from({ length: 3 }, (_, index) => group(definition, `stratholme:ziggurat-${index + 1}-acolyte`));
|
||||
const zigguratBosses = ["creature-54238", "creature-54239", "creature-54240"];
|
||||
const zigguratDoors = [11_415, 11_414, 11_416].map((spawnId) => objectBySpawn(definition, spawnId));
|
||||
const mindless = group(definition, "stratholme:mindless-undead");
|
||||
const guards = group(definition, "stratholme:black-guards");
|
||||
expect(acolytes.map((spawnGroup) => spawnGroup.spawns.length)).toEqual([1, 1, 1]);
|
||||
expect(mindless.spawns).toHaveLength(33);
|
||||
expect(guards.spawns).toHaveLength(5);
|
||||
let snapshot = createDungeonEncounterSnapshot(scripted);
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: zigguratBosses[0], now: 1 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(acolytes[0].id);
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 2);
|
||||
expect(snapshot.worldObjectStates[zigguratDoors[0].id]).toBe("open");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 2 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(acolytes[0].id);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: acolytes[0].spawns[0].id,
|
||||
now: 3,
|
||||
}).snapshot;
|
||||
for (let index = 1; index < 3; index += 1) {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: zigguratBosses[index],
|
||||
now: 4 + index * 2,
|
||||
}).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: acolytes[index].spawns[0].id,
|
||||
now: 5 + index * 2,
|
||||
}).snapshot;
|
||||
}
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 20 }).snapshot;
|
||||
expect(snapshot.flags["ziggurats-complete"]).toBe(true);
|
||||
expect(snapshot.worldObjectStates[objectBySpawn(definition, 6_911).id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[objectBySpawn(definition, 6_908).id]).toBe("open");
|
||||
|
||||
const slaughterIds = [
|
||||
"creature-53969", "creature-54002", "creature-54018", "creature-54019",
|
||||
"creature-54020", "creature-54021", "creature-54022", "creature-54026", "creature-54027",
|
||||
"creature-54039", "creature-54040", "creature-54041", "creature-54050",
|
||||
];
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "tick",
|
||||
now: 21,
|
||||
facts: { registeredEntityIds: slaughterIds, deadEntityIds: slaughterIds },
|
||||
}).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("stratholme:ramstein");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 22);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 22 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("stratholme:ramstein");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: "stratholme:ramstein-the-gorger",
|
||||
now: 23,
|
||||
}).snapshot;
|
||||
expect(snapshot.phase).toBe("mindless-delay");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "checkpoint", 24);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 24 }).snapshot;
|
||||
expect(snapshot.phase).toBe("mindless-delay");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_024 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(mindless.id);
|
||||
snapshot = killGroup(scripted, snapshot, mindless, 5_025);
|
||||
expect(snapshot.phase).toBe("black-guard-delay");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 5_026);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_026 }).snapshot;
|
||||
expect(snapshot.phase).toBe("black-guard-delay");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 25_026 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(guards.id);
|
||||
expect(snapshot.worldObjectStates[objectBySpawn(definition, 11_808).id]).toBe("open");
|
||||
snapshot = killGroup(scripted, snapshot, guards, 25_027);
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("stratholme:baron-rivendare");
|
||||
expect(snapshot.worldObjectStates[objectBySpawn(definition, 35_848).id]).toBe("open");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-engaged",
|
||||
entityId: "creature-54241",
|
||||
now: 25_028,
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[objectBySpawn(definition, 35_848).id]).toBe("closed");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 25_029);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 25_029 }).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "baron-ready", flags: { "guards-defeated": true } });
|
||||
expect(snapshot.worldObjectStates[objectBySpawn(definition, 35_848).id]).toBe("open");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: "creature-54241",
|
||||
now: 25_030,
|
||||
}).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain("creature-54241");
|
||||
expect(snapshot.checkpointId).toBe("creature-54241");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,334 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { gunzipSync, strFromU8 } from "fflate";
|
||||
import {
|
||||
createDungeonEncounterSnapshot,
|
||||
reduceDungeonEncounter,
|
||||
resetDungeonEncounterSnapshot,
|
||||
validateDungeonEncounterDefinition,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
|
||||
import type { DungeonEncounterDefinition, DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
||||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||||
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
||||
import {
|
||||
WOW_CLASSIC_ENCOUNTER_CONTENT_REVIEWS,
|
||||
withWowClassicEncounterContent,
|
||||
} from "./wowClassicEncounterContentCatalog";
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
const decoded = atob(value);
|
||||
const bytes = new Uint8Array(decoded.length);
|
||||
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const campaign = JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
|
||||
readonly definitions: readonly DungeonDefinition[];
|
||||
};
|
||||
|
||||
function adapted(id: string): DungeonDefinition {
|
||||
const definition = campaign.definitions.find((candidate) => candidate.id === id);
|
||||
if (!definition) throw new Error(`Missing generated dungeon ${id}`);
|
||||
return withWowClassicEncounterContent(definition);
|
||||
}
|
||||
|
||||
function classicEncounter(definition: DungeonDefinition): DungeonEncounterDefinition {
|
||||
const encounter = definition.encounters?.find((value) => value.id === `${definition.id}:classic-instance`);
|
||||
if (!encounter) throw new Error(`Missing Classic encounter for ${definition.id}`);
|
||||
return encounter;
|
||||
}
|
||||
|
||||
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
|
||||
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
|
||||
if (!value) throw new Error(`Missing spawn group ${id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectByTemplate(definition: DungeonDefinition, templateId: number) {
|
||||
const value = definition.serverGameObjects.find((object) => object.source?.templateId === templateId);
|
||||
if (!value) throw new Error(`Missing gameobject template ${templateId}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function killGroup(
|
||||
encounter: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
spawnGroup: DungeonEncounterSpawnGroupDefinition,
|
||||
now: number,
|
||||
) {
|
||||
const ids = spawnGroup.spawns.map((spawn) => spawn.id);
|
||||
return reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "tick",
|
||||
now,
|
||||
facts: { registeredEntityIds: ids, deadEntityIds: ids },
|
||||
});
|
||||
}
|
||||
|
||||
function staticEntries(definition: DungeonDefinition): readonly number[] {
|
||||
return definition.staticSpawns.flatMap((spawn) => {
|
||||
const entry = definition.entities[spawn.entityId]?.combat?.serverEntry;
|
||||
return entry === undefined ? [] : [entry];
|
||||
});
|
||||
}
|
||||
|
||||
describe("Classic source-backed dungeon encounter content", () => {
|
||||
it("pins source reviews, validates references, suppresses event bosses, and is idempotent", () => {
|
||||
const cases = [
|
||||
["blackfathom-deeps", []],
|
||||
["uldaman", [2_748]],
|
||||
["razorfen-downs", [7_355, 7_356]],
|
||||
["zulfarrak", [7_273]],
|
||||
] as const;
|
||||
expect(WOW_CLASSIC_ENCOUNTER_CONTENT_REVIEWS.map((review) => review.dungeonId)).toEqual(cases.map(([id]) => id));
|
||||
for (const review of WOW_CLASSIC_ENCOUNTER_CONTENT_REVIEWS) {
|
||||
expect(review.fidelity).toBe("source-backed-adapted");
|
||||
expect(review.sourceFiles.length).toBeGreaterThan(0);
|
||||
for (const sourceFile of review.sourceFiles) {
|
||||
expect(sourceFile.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(sourceFile.anchors.length).toBeGreaterThan(0);
|
||||
expect(sourceFile.anchors.every((anchor) => /lines? \d+/.test(anchor))).toBe(true);
|
||||
}
|
||||
expect(review.adaptations.length).toBeGreaterThan(0);
|
||||
expect(review.remainingGaps.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
for (const [id, suppressedEntries] of cases) {
|
||||
const definition = adapted(id);
|
||||
const encounter = classicEncounter(definition);
|
||||
validateDungeonEncounterDefinition(encounter);
|
||||
expect(withWowClassicEncounterContent(definition)).toBe(definition);
|
||||
if (suppressedEntries.length > 0) {
|
||||
expect(staticEntries(definition)).not.toEqual(expect.arrayContaining([...suppressedEntries]));
|
||||
}
|
||||
const spawnGroupIds = new Set(definition.encounterSpawnGroups?.map((spawnGroup) => spawnGroup.id));
|
||||
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
|
||||
const objectiveIds = new Set(definition.bosses.map((boss) => boss.id));
|
||||
for (const spawnGroup of definition.encounterSpawnGroups ?? []) {
|
||||
for (const spawn of spawnGroup.spawns) {
|
||||
expect(definition.entities[spawn.entityId], `${id}:${spawnGroup.id}:${spawn.id}`).toBeDefined();
|
||||
}
|
||||
}
|
||||
for (const rule of encounter.rules) {
|
||||
if (rule.trigger.kind === "interact") expect(objectIds.has(rule.trigger.objectId), `${id}:${rule.id}`).toBe(true);
|
||||
for (const action of rule.actions) {
|
||||
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
|
||||
expect(spawnGroupIds.has(action.spawnGroupId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
||||
}
|
||||
if (action.kind === "set-world-object-state") {
|
||||
expect(objectIds.has(action.objectId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
||||
}
|
||||
if (action.kind === "complete-objective") {
|
||||
expect(objectiveIds.has(action.objectiveId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const object of definition.serverGameObjects.filter((candidate) => (
|
||||
encounter.initialState?.worldObjectStates?.[candidate.id] !== undefined
|
||||
))) {
|
||||
validateDungeonWorldObject(object);
|
||||
expect(object.visual?.hidden).not.toBe(true);
|
||||
}
|
||||
for (const entry of suppressedEntries) {
|
||||
const entityIds = new Set(Object.entries(definition.entities)
|
||||
.filter(([, entity]) => entity.combat?.serverEntry === entry)
|
||||
.map(([entityId]) => entityId));
|
||||
const objective = definition.bosses.find((boss) => definition.encounterSpawnGroups?.some(
|
||||
(spawnGroup) => spawnGroup.spawns.some((spawn) => spawn.id === boss.id && entityIds.has(spawn.entityId)),
|
||||
));
|
||||
expect(objective, `${id}:${entry}`).toMatchObject({ eventOnly: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("runs all four Blackfathom fires, opens the physical portal, and persists its checkpoint", () => {
|
||||
const definition = adapted("blackfathom-deeps");
|
||||
const encounter = classicEncounter(definition);
|
||||
const fires = [21_118, 21_119, 21_120, 21_121].map((entry) => objectByTemplate(definition, entry));
|
||||
let snapshot = createDungeonEncounterSnapshot(encounter);
|
||||
fires.forEach((fire, index) => {
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "interact",
|
||||
objectId: fire.id,
|
||||
now: index * 10,
|
||||
}).snapshot;
|
||||
const spawnGroup = group(definition, `blackfathom-deeps:fire-${index + 1}-pack`);
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(spawnGroup.id);
|
||||
snapshot = killGroup(encounter, snapshot, spawnGroup, index * 10 + 1).snapshot;
|
||||
expect(snapshot.worldObjectStates[fire.id]).toBe("lit");
|
||||
});
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 100 }).snapshot;
|
||||
const portal = objectByTemplate(definition, 21_117);
|
||||
expect(snapshot).toMatchObject({ counters: { "completed-fires": 4 }, checkpointId: "blackfathom-fire-event" });
|
||||
expect(snapshot.worldObjectStates[portal.id]).toBe("open");
|
||||
const reset = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 200);
|
||||
expect(reset.worldObjectStates[portal.id]).toBe("open");
|
||||
expect(fires.every((fire) => reset.worldObjectStates[fire.id] === "lit")).toBe(true);
|
||||
expect(reset.activeSpawnGroupIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("opens Uldaman's seal, awakens four keepers with wipe recovery, and source-gates Archaedas", () => {
|
||||
const definition = adapted("uldaman");
|
||||
const encounter = classicEncounter(definition);
|
||||
const keystone = objectByTemplate(definition, 124_371);
|
||||
const seal = objectByTemplate(definition, 124_372);
|
||||
const keeperAltar = objectByTemplate(definition, 130_511);
|
||||
const keeperDoor = objectByTemplate(definition, 124_367);
|
||||
const archaedasAltar = objectByTemplate(definition, 133_234);
|
||||
const archaedasRoom = objectByTemplate(definition, 141_869);
|
||||
const vault = objectByTemplate(definition, 124_369);
|
||||
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
|
||||
kind: "interact",
|
||||
objectId: keystone.id,
|
||||
now: 0,
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[seal.id]).toBe("open");
|
||||
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: keeperAltar.id, now: 1 }).snapshot;
|
||||
snapshot = killGroup(encounter, snapshot, group(definition, "uldaman:stone-keeper-1"), 2).snapshot;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: keeperAltar.id, now: 3 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual(["uldaman:stone-keeper-2"]);
|
||||
snapshot = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 4);
|
||||
expect(snapshot).toMatchObject({ phase: "active", counters: { "keepers-defeated": 1 } });
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([]);
|
||||
|
||||
for (let number = 2; number <= 4; number += 1) {
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "interact",
|
||||
objectId: keeperAltar.id,
|
||||
now: number * 10,
|
||||
}).snapshot;
|
||||
snapshot = killGroup(encounter, snapshot, group(definition, `uldaman:stone-keeper-${number}`), number * 10 + 1).snapshot;
|
||||
}
|
||||
expect(snapshot).toMatchObject({ phase: "active", counters: { "keepers-defeated": 4 }, checkpointId: "uldaman-stone-keepers" });
|
||||
expect(snapshot.worldObjectStates[keeperDoor.id]).toBe("open");
|
||||
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: archaedasAltar.id, now: 50 }).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "archaedas-active", activeSpawnGroupIds: ["uldaman:archaedas"] });
|
||||
expect(snapshot.worldObjectStates[archaedasRoom.id]).toBe("closed");
|
||||
snapshot = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 51);
|
||||
expect(snapshot).toMatchObject({ phase: "active", activeSpawnGroupIds: [] });
|
||||
expect(snapshot.worldObjectStates[archaedasRoom.id]).toBe("open");
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: archaedasAltar.id, now: 52 }).snapshot;
|
||||
snapshot = killGroup(encounter, snapshot, group(definition, "uldaman:archaedas"), 53).snapshot;
|
||||
const objective = definition.bosses.find((boss) => boss.name === "Archaedas")!;
|
||||
expect(snapshot.completedObjectiveIds).toContain(objective.id);
|
||||
expect(snapshot.worldObjectStates[vault.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[archaedasRoom.id]).toBe("open");
|
||||
});
|
||||
|
||||
it("recovers Razorfen's gong and runs the full timed Belnistrasz event", () => {
|
||||
const definition = adapted("razorfen-downs");
|
||||
const encounter = classicEncounter(definition);
|
||||
const gong = objectByTemplate(definition, 148_917);
|
||||
const gongGroupIds = [
|
||||
"razorfen-downs:gong-fiends",
|
||||
"razorfen-downs:gong-reavers",
|
||||
"razorfen-downs:tutenkash",
|
||||
];
|
||||
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
|
||||
kind: "interact",
|
||||
objectId: gong.id,
|
||||
now: 0,
|
||||
}).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([gongGroupIds[0]]);
|
||||
snapshot = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 1);
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 1 }).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "idle", counters: { "gong-round": 0 } });
|
||||
for (let index = 0; index < gongGroupIds.length; index += 1) {
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "interact",
|
||||
objectId: gong.id,
|
||||
now: 10 + index * 2,
|
||||
}).snapshot;
|
||||
snapshot = killGroup(encounter, snapshot, group(definition, gongGroupIds[index]), 11 + index * 2).snapshot;
|
||||
}
|
||||
const tutenkash = definition.bosses.find((boss) => boss.name === "Tuten'kash")!;
|
||||
expect(snapshot).toMatchObject({ counters: { "gong-round": 3 } });
|
||||
expect(snapshot.completedObjectiveIds).toContain(tutenkash.id);
|
||||
expect(snapshot.worldObjectStates[gong.id]).toBe("complete");
|
||||
|
||||
const belnistrasz = definition.serverGameObjects.find((object) => object.id === "razorfen-downs:belnistrasz")!;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: belnistrasz.id, now: 100 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 8_100 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("razorfen-downs:idol-wave-1");
|
||||
const wiped = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 8_101);
|
||||
const recovered = reduceDungeonEncounter(encounter, wiped, { kind: "tick", now: 8_101 }).snapshot;
|
||||
expect(recovered).toMatchObject({ phase: "idle", activeSpawnGroupIds: [] });
|
||||
expect(recovered.worldObjectStates[belnistrasz.id]).toBe("ready");
|
||||
|
||||
snapshot = reduceDungeonEncounter(encounter, recovered, { kind: "interact", objectId: belnistrasz.id, now: 9_000 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 17_000 }).snapshot;
|
||||
for (let wave = 2; wave <= 8; wave += 1) {
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "tick",
|
||||
now: snapshot.phaseStartedAt + 35_000,
|
||||
}).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(`razorfen-downs:idol-wave-${wave}`);
|
||||
}
|
||||
const plaguemawGroup = group(definition, "razorfen-downs:idol-wave-8");
|
||||
snapshot = killGroup(encounter, snapshot, plaguemawGroup, snapshot.phaseStartedAt + 1).snapshot;
|
||||
const plaguemaw = definition.bosses.find((boss) => boss.name === "Plaguemaw the Rotting")!;
|
||||
expect(snapshot.completedObjectiveIds).toContain(plaguemaw.id);
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "tick",
|
||||
now: snapshot.phaseStartedAt + 55_000,
|
||||
}).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "idol-complete", flags: { "idol-extinguished": true } });
|
||||
const fires = definition.serverGameObjects.filter((object) => [151_951, 151_952, 151_973].includes(object.source?.templateId ?? -1));
|
||||
expect(fires.every((fire) => snapshot.worldObjectStates[fire.id] === "extinguished")).toBe(true);
|
||||
const resetComplete = resetDungeonEncounterSnapshot(encounter, snapshot, "checkpoint", snapshot.phaseStartedAt + 1);
|
||||
const resumedComplete = reduceDungeonEncounter(encounter, resetComplete, { kind: "tick", now: snapshot.phaseStartedAt + 1 }).snapshot;
|
||||
expect(resumedComplete).toMatchObject({ phase: "idol-complete", flags: { "idol-extinguished": true } });
|
||||
});
|
||||
|
||||
it("runs Zul'Farrak's exact 23/24/7 pyramid and independently recovers the Gahz'rilla gong", () => {
|
||||
const definition = adapted("zulfarrak");
|
||||
const encounter = classicEncounter(definition);
|
||||
const groups = [1, 2, 3].map((wave) => group(definition, `zulfarrak:pyramid-wave-${wave}`));
|
||||
expect(groups.map((spawnGroup) => spawnGroup.spawns.length)).toEqual([23, 24, 7]);
|
||||
const cages = definition.serverGameObjects.filter((object) => object.name === "Troll Cage");
|
||||
const endDoor = objectByTemplate(definition, 146_084);
|
||||
const weegli = definition.serverGameObjects.find((object) => object.id === "zulfarrak:weegli-gate-charge")!;
|
||||
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
|
||||
kind: "interact",
|
||||
objectId: cages[0].id,
|
||||
now: 0,
|
||||
}).snapshot;
|
||||
expect(cages.every((cage) => snapshot.worldObjectStates[cage.id] === "open")).toBe(true);
|
||||
snapshot = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 1);
|
||||
expect(snapshot).toMatchObject({ phase: "idle", activeSpawnGroupIds: [] });
|
||||
expect(cages.every((cage) => snapshot.worldObjectStates[cage.id] === "closed")).toBe(true);
|
||||
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: cages[2].id, now: 2 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 5_002 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([groups[0].id]);
|
||||
snapshot = killGroup(encounter, snapshot, groups[0], 5_003).snapshot;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 15_003 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([groups[1].id]);
|
||||
snapshot = killGroup(encounter, snapshot, groups[1], 15_004).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([groups[2].id]);
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 20_004 }).snapshot;
|
||||
snapshot = killGroup(encounter, snapshot, groups[2], 20_005).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "pyramid-aftermath", activeSpawnGroupIds: [] });
|
||||
expect(snapshot.worldObjectStates[weegli.id]).toBe("ready");
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: weegli.id, now: 20_006 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 24_006 }).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "pyramid-complete", flags: { "pyramid-complete": true }, checkpointId: "zulfarrak-pyramid" });
|
||||
expect(snapshot.worldObjectStates[endDoor.id]).toBe("open");
|
||||
const reset = resetDungeonEncounterSnapshot(encounter, snapshot, "checkpoint", 24_007);
|
||||
expect(reset.worldObjectStates[endDoor.id]).toBe("open");
|
||||
expect(cages.every((cage) => reset.worldObjectStates[cage.id] === "open")).toBe(true);
|
||||
|
||||
const gong = objectByTemplate(definition, 141_832);
|
||||
snapshot = reduceDungeonEncounter(encounter, reset, { kind: "interact", objectId: gong.id, now: 25_000 }).snapshot;
|
||||
const gahzrillaGroup = group(definition, "zulfarrak:gahzrilla");
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([gahzrillaGroup.id]);
|
||||
const gongWipe = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 25_001);
|
||||
expect(gongWipe.worldObjectStates[gong.id]).toBe("ready");
|
||||
snapshot = reduceDungeonEncounter(encounter, gongWipe, { kind: "interact", objectId: gong.id, now: 25_002 }).snapshot;
|
||||
snapshot = killGroup(encounter, snapshot, gahzrillaGroup, 25_003).snapshot;
|
||||
const gahzrilla = definition.bosses.find((boss) => boss.name === "Ghaz'rilla")!;
|
||||
expect(snapshot.completedObjectiveIds).toContain(gahzrilla.id);
|
||||
expect(snapshot.worldObjectStates[gong.id]).toBe("complete");
|
||||
});
|
||||
});
|
||||
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user