Release Healer Man 0.1.22

This commit is contained in:
phenom committed 2026-09-09 11:21:33 -04:00
1 parent f6f2375145
commit 7f6001723a
144 files changed
+54107 -690

No files matched your search

+6
View File
@@ -207,6 +207,7 @@ $ReleaseVersion = [ordered]@{
npm run content:test npm run content:test
npm run server:test npm run server:test
npm test npm test
npm run content:publish
npm run build npm run build
npm test -- --run src/avatar npm test -- --run src/avatar
npx vitest run src/game/inputManager.test.ts src/game/inputMath.test.ts 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 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`. 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, Do not commit a `.env` file, password, API token, keystore, APK, database,
`content/`, `dist/`, `dist-android/`, `.web-public/`, or `.android-public/` `content/`, `dist/`, `dist-android/`, `.web-public/`, or `.android-public/`
+111
View File
@@ -1728,3 +1728,114 @@ Vite's existing large-chunk advisory remains informational.
audit covered 12,347 compressed textures with zero fallbacks, and the audit covered 12,347 compressed textures with zero fallbacks, and the
environment-performance audit found no regressions. The existing large-chunk environment-performance audit found no regressions. The existing large-chunk
advisory and Rapier initialization deprecation warning remain informational. 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
View File
@@ -237,10 +237,15 @@ Gitea tag after the signed APK builds successfully.
npm run content:test npm run content:test
npm run server:test npm run server:test
npm test npm test
npm run content:publish
npm run build 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: 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 ```powershell
Set-Location F:\Projects\HealerMan Set-Location F:\Projects\HealerMan
npm run content:test npm run content:test
npm run content:publish
npm run build npm run build
git status --short git status --short
npm run release:check npm run release:check
+35 -18
View File
@@ -1,8 +1,17 @@
# Five-player dungeon import checklist # 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 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: Run one dungeon at a time:
@@ -88,24 +97,28 @@ TypeScript/production bundle checks passed.
## Ascension-specific five-player instances ## Ascension-specific five-player instances
- [ ] Tinkertech Showdown (`821`) - blocked on authoritative Ascension server - [ ] Tinkertech Showdown (`821`) - playable HealerMan-authored adaptation.
population/model data. Encounter identity exists for Mechatron (`663012`), Mechatron (`663012`) and the environment are exact; the candidate machine
but no creature template, display mapping, or spawn rows exist locally. spell cluster, trash, cadence, positions, models, and phase rules remain
- [ ] Frozen Reach (`908`) - blocked on authoritative Ascension server explicitly authored because no server binding or spawn snapshot survived.
population/model data. Encounter identity exists for Alva (`97700`), but no - [ ] Frozen Reach (`908`) - playable source-backed adaptation. Alva (`97700`),
creature template, display mapping, or spawn rows exist locally. Arktos (`97701`), 85 exact spell rows, and summon links for entries `97703`,
- [ ] Forgotten Mine (`1781`) - blocked on authoritative Ascension server `97731`, and `97770` were recovered. Server cadence, positions, stats,
population/model data. Encounter identity exists for Geolord Vek (`254272`), models, targeting, and exact phase behavior remain authored.
but no creature template, display mapping, or spawn rows exist locally. - [ ] 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 The individual import command has been run for all three source-limited
and encounter identities are present in the installed client, but the installed instances. Their checksummed recovery manifest lives under `ascension/` and is
client and available creature caches do not provide the server-only validated by `npm run dungeon:ascension:recovery:validate`. Runtime adapters
entry-to-display and spawn data needed to package their actual mobs/bosses. consume the recovered evidence while retaining explicit `source-backed-adapted`
They intentionally remain on the documented procedural fallback until an or `healer-man-authored` labels. The checklist remains open until an
Ascension server snapshot or reviewed creature/spawn export is supplied. 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: 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] Boss order, entrance, and coordinates reviewed.
- [x] Generated campaign catalog points at `/assets/game/dungeons/<slug>/`. - [x] Generated campaign catalog points at `/assets/game/dungeons/<slug>/`.
- [x] Catalog verification, tests, type-check, and production bundle pass. - [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
View File
@@ -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 Stock AzerothCore population is used for the standard dungeons. The three
Ascension-specific instances—Tinkertech Showdown, Frozen Reach, and Forgotten Ascension-specific instances—Tinkertech Showdown, Frozen Reach, and Forgotten
Mine—keep their client encounter identities and use an explicit procedural Mine—keep their recovered client identities, spell/summon evidence where
trash-population fallback. Non-Epoch recipes keep explicit procedural actor 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 bodies until reviewed display exports are available. Epoch actors use packaged
client GLBs with their complete native animation libraries; only event-only client GLBs with their complete native animation libraries; only event-only
controllers without a creature display fall back. 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, classification, every native environment and creature URL, file sizes,
checksums, animation presence, and compiled entry-to-model bindings. The checksums, animation presence, and compiled entry-to-model bindings. The
2026-07-26 batch packaged 50 native imports; together with the Wailing Caverns 2026-07-26 batch packaged 50 native imports; together with the Wailing Caverns
golden fixture, 51/54 dungeons are authoritative. Tinkertech Showdown, Frozen golden fixture, 51/54 dungeons have authoritative WoW population packages.
Reach, and Forgotten Mine remain documented fallbacks because available local Tinkertech Showdown, Frozen Reach, and Forgotten Mine now have playable,
sources contain their maps and encounter identities but no authoritative provenance-labeled runtime adaptations backed by
Ascension creature templates, display mappings, or spawn rows. `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 1. `npm run dungeon:discover -- <slug>` writes deterministic client/server
snapshots, a provenance lock, review-required overrides, and—when 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, environment and creature assets, audits every mob and boss attack/mechanic,
opens every unique native creature GLB to verify idle, movement, attack, wound, 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 death clips, proves the procedural animation path for model-less actors,
and runs the actual runtime-registry coverage test. It writes deterministic runs the actual runtime-registry coverage test, and audits instance-level
JSON and Markdown reports to WOW_DUNGEON_CONTENT_REPORT.json and functionality. It writes deterministic JSON and Markdown reports to
WOW_DUNGEON_CONTENT_REPORT.md. 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: Useful batch options:
@@ -239,11 +249,40 @@ Useful batch options:
# Re-run green packages, then build the app after validation. # Re-run green packages, then build the app after validation.
npm run dungeon:wow:all -- --refresh --force --build 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 npm run dungeon:wow:all -- --audit-only
Kresh is intentionally recorded as a source-faithful melee-only boss: entry The functionality report is the production-fidelity gate. It separately checks
3653 has no spell or SmartAI row in the pinned AzerothCore snapshot. Trial of typed world-object contracts, source instance scripts, encounter orchestration,
the Champion is likewise documented as a boss-arena population rather than a required interactions, traversal evidence, resets, checkpoints, and completion
trash route. These exceptions are asserted by exact dungeon/entity IDs; any playtests. A green asset or combat audit does not imply that doors, escorts,
new basic-only boss or mob-less dungeon fails the batch. 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.
+166 -151
View File
@@ -4,19 +4,19 @@
"scope": "HealerMan WoW five-player combat and animation coverage", "scope": "HealerMan WoW five-player combat and animation coverage",
"summary": { "summary": {
"dungeons": 54, "dungeons": 54,
"mobs": 1031, "mobs": 1193,
"bosses": 253, "bosses": 273,
"attacks": 3765, "attacks": 4026,
"specialAttacks": 2481, "specialAttacks": 2572,
"nativeModels": 1266, "nativeModels": 1275,
"proceduralModels": 18, "proceduralModels": 191,
"uniqueNativeModels": 1166, "uniqueNativeModels": 1167,
"nativeStateFallbacks": 18 "nativeStateFallbacks": 18
}, },
"documentedExceptions": [ "documentedExceptions": [
{ {
"type": "basic-only-boss", "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." "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." "reason": "The server-faithful rare entry 5912 has no spell rows and is despawned by the Wailing Caverns initialization event."
}, },
{ {
"type": "no-trash-instance", "type": "basic-only-boss",
"key": "trial-of-the-champion", "key": "gnomeregan:wow-classic-batch-three-entry-7361",
"reason": "Trial of the Champion is an encounter arena; its imported combat population is the five Grand Champion bosses, not a trash route." "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": [ "animationFallbacks": [
@@ -180,6 +195,18 @@
], ],
"issues": [], "issues": [],
"dungeons": [ "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", "id": "shadowfang-keep",
"title": "Shadowfang Keep", "title": "Shadowfang Keep",
@@ -216,18 +243,6 @@
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "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", "id": "razorfen-kraul",
"title": "Razorfen Kraul", "title": "Razorfen Kraul",
@@ -244,12 +259,12 @@
"id": "blackfathom-deeps", "id": "blackfathom-deeps",
"title": "Blackfathom Deeps", "title": "Blackfathom Deeps",
"source": "azerothcore", "source": "azerothcore",
"mobs": 21, "mobs": 23,
"bosses": 6, "bosses": 6,
"attacks": 65, "attacks": 67,
"specialAttacks": 38, "specialAttacks": 38,
"nativeModels": 27, "nativeModels": 27,
"proceduralModels": 0, "proceduralModels": 2,
"status": "green" "status": "green"
}, },
{ {
@@ -268,36 +283,36 @@
"id": "gnomeregan", "id": "gnomeregan",
"title": "Gnomeregan", "title": "Gnomeregan",
"source": "azerothcore", "source": "azerothcore",
"mobs": 27, "mobs": 28,
"bosses": 4, "bosses": 5,
"attacks": 82, "attacks": 84,
"specialAttacks": 51, "specialAttacks": 51,
"nativeModels": 31, "nativeModels": 31,
"proceduralModels": 0, "proceduralModels": 2,
"status": "green" "status": "green"
}, },
{ {
"id": "sunken-temple", "id": "sunken-temple",
"title": "Sunken Temple", "title": "Sunken Temple",
"source": "azerothcore", "source": "azerothcore",
"mobs": 29, "mobs": 31,
"bosses": 4, "bosses": 4,
"attacks": 93, "attacks": 95,
"specialAttacks": 60, "specialAttacks": 60,
"nativeModels": 33, "nativeModels": 33,
"proceduralModels": 0, "proceduralModels": 2,
"status": "green" "status": "green"
}, },
{ {
"id": "razorfen-downs", "id": "razorfen-downs",
"title": "Razorfen Downs", "title": "Razorfen Downs",
"source": "azerothcore", "source": "azerothcore",
"mobs": 19, "mobs": 21,
"bosses": 6, "bosses": 6,
"attacks": 63, "attacks": 65,
"specialAttacks": 38, "specialAttacks": 38,
"nativeModels": 25, "nativeModels": 25,
"proceduralModels": 0, "proceduralModels": 2,
"status": "green" "status": "green"
}, },
{ {
@@ -316,60 +331,60 @@
"id": "zulfarrak", "id": "zulfarrak",
"title": "Zul'Farrak", "title": "Zul'Farrak",
"source": "azerothcore", "source": "azerothcore",
"mobs": 16, "mobs": 23,
"bosses": 5, "bosses": 5,
"attacks": 69, "attacks": 76,
"specialAttacks": 48, "specialAttacks": 48,
"nativeModels": 21, "nativeModels": 21,
"proceduralModels": 0, "proceduralModels": 7,
"status": "green" "status": "green"
}, },
{ {
"id": "blackrock-spire", "id": "blackrock-spire",
"title": "Blackrock Spire", "title": "Blackrock Spire",
"source": "azerothcore", "source": "azerothcore",
"mobs": 63, "mobs": 66,
"bosses": 15, "bosses": 17,
"attacks": 273, "attacks": 285,
"specialAttacks": 195, "specialAttacks": 202,
"nativeModels": 78, "nativeModels": 78,
"proceduralModels": 0, "proceduralModels": 5,
"status": "green" "status": "green"
}, },
{ {
"id": "blackrock-depths", "id": "blackrock-depths",
"title": "Blackrock Depths", "title": "Blackrock Depths",
"source": "azerothcore", "source": "azerothcore",
"mobs": 48, "mobs": 58,
"bosses": 13, "bosses": 13,
"attacks": 182, "attacks": 192,
"specialAttacks": 121, "specialAttacks": 121,
"nativeModels": 61, "nativeModels": 61,
"proceduralModels": 0, "proceduralModels": 10,
"status": "green" "status": "green"
}, },
{ {
"id": "scholomance", "id": "scholomance",
"title": "Scholomance", "title": "Scholomance",
"source": "azerothcore", "source": "azerothcore",
"mobs": 30, "mobs": 31,
"bosses": 8, "bosses": 8,
"attacks": 150, "attacks": 151,
"specialAttacks": 112, "specialAttacks": 112,
"nativeModels": 38, "nativeModels": 38,
"proceduralModels": 0, "proceduralModels": 1,
"status": "green" "status": "green"
}, },
{ {
"id": "stratholme", "id": "stratholme",
"title": "Stratholme", "title": "Stratholme",
"source": "azerothcore", "source": "azerothcore",
"mobs": 41, "mobs": 45,
"bosses": 9, "bosses": 9,
"attacks": 176, "attacks": 180,
"specialAttacks": 126, "specialAttacks": 126,
"nativeModels": 50, "nativeModels": 50,
"proceduralModels": 0, "proceduralModels": 4,
"status": "green" "status": "green"
}, },
{ {
@@ -436,12 +451,12 @@
"id": "hellfire-ramparts", "id": "hellfire-ramparts",
"title": "Hellfire Citadel: Ramparts", "title": "Hellfire Citadel: Ramparts",
"source": "azerothcore", "source": "azerothcore",
"mobs": 10, "mobs": 11,
"bosses": 2, "bosses": 4,
"attacks": 41, "attacks": 47,
"specialAttacks": 29, "specialAttacks": 32,
"nativeModels": 12, "nativeModels": 12,
"proceduralModels": 0, "proceduralModels": 3,
"status": "green" "status": "green"
}, },
{ {
@@ -460,48 +475,48 @@
"id": "underbog", "id": "underbog",
"title": "Coilfang: The Underbog", "title": "Coilfang: The Underbog",
"source": "azerothcore", "source": "azerothcore",
"mobs": 18, "mobs": 20,
"bosses": 4, "bosses": 4,
"attacks": 80, "attacks": 82,
"specialAttacks": 58, "specialAttacks": 58,
"nativeModels": 22, "nativeModels": 22,
"proceduralModels": 0, "proceduralModels": 2,
"status": "green" "status": "green"
}, },
{ {
"id": "slave-pens", "id": "slave-pens",
"title": "Coilfang: The Slave Pens", "title": "Coilfang: The Slave Pens",
"source": "azerothcore", "source": "azerothcore",
"mobs": 18, "mobs": 21,
"bosses": 3, "bosses": 4,
"attacks": 60, "attacks": 66,
"specialAttacks": 39, "specialAttacks": 43,
"nativeModels": 21, "nativeModels": 21,
"proceduralModels": 0, "proceduralModels": 4,
"status": "green" "status": "green"
}, },
{ {
"id": "arcatraz", "id": "arcatraz",
"title": "Tempest Keep: The Arcatraz", "title": "Tempest Keep: The Arcatraz",
"source": "azerothcore", "source": "azerothcore",
"mobs": 25, "mobs": 28,
"bosses": 4, "bosses": 4,
"attacks": 105, "attacks": 108,
"specialAttacks": 76, "specialAttacks": 76,
"nativeModels": 29, "nativeModels": 29,
"proceduralModels": 0, "proceduralModels": 3,
"status": "green" "status": "green"
}, },
{ {
"id": "botanica", "id": "botanica",
"title": "Tempest Keep: The Botanica", "title": "Tempest Keep: The Botanica",
"source": "azerothcore", "source": "azerothcore",
"mobs": 24, "mobs": 28,
"bosses": 5, "bosses": 5,
"attacks": 85, "attacks": 89,
"specialAttacks": 56, "specialAttacks": 56,
"nativeModels": 29, "nativeModels": 29,
"proceduralModels": 0, "proceduralModels": 4,
"status": "green" "status": "green"
}, },
{ {
@@ -532,72 +547,72 @@
"id": "sethekk-halls", "id": "sethekk-halls",
"title": "Auchindoun: Sethekk Halls", "title": "Auchindoun: Sethekk Halls",
"source": "azerothcore", "source": "azerothcore",
"mobs": 14, "mobs": 19,
"bosses": 2, "bosses": 3,
"attacks": 50, "attacks": 59,
"specialAttacks": 34, "specialAttacks": 37,
"nativeModels": 16, "nativeModels": 16,
"proceduralModels": 0, "proceduralModels": 6,
"status": "green" "status": "green"
}, },
{ {
"id": "mana-tombs", "id": "mana-tombs",
"title": "Auchindoun: Mana-Tombs", "title": "Auchindoun: Mana-Tombs",
"source": "azerothcore", "source": "azerothcore",
"mobs": 11, "mobs": 12,
"bosses": 3, "bosses": 4,
"attacks": 46, "attacks": 50,
"specialAttacks": 32, "specialAttacks": 34,
"nativeModels": 14, "nativeModels": 14,
"proceduralModels": 0, "proceduralModels": 2,
"status": "green" "status": "green"
}, },
{ {
"id": "auchenai-crypts", "id": "auchenai-crypts",
"title": "Auchindoun: Auchenai Crypts", "title": "Auchindoun: Auchenai Crypts",
"source": "azerothcore", "source": "azerothcore",
"mobs": 12, "mobs": 13,
"bosses": 2, "bosses": 3,
"attacks": 44, "attacks": 48,
"specialAttacks": 30, "specialAttacks": 32,
"nativeModels": 14, "nativeModels": 14,
"proceduralModels": 0, "proceduralModels": 2,
"status": "green" "status": "green"
}, },
{ {
"id": "magisters-terrace", "id": "magisters-terrace",
"title": "Magister's Terrace", "title": "Magister's Terrace",
"source": "azerothcore", "source": "azerothcore",
"mobs": 15, "mobs": 23,
"bosses": 3, "bosses": 3,
"attacks": 57, "attacks": 65,
"specialAttacks": 39, "specialAttacks": 39,
"nativeModels": 18, "nativeModels": 18,
"proceduralModels": 0, "proceduralModels": 8,
"status": "green" "status": "green"
}, },
{ {
"id": "black-morass", "id": "black-morass",
"title": "The Black Morass", "title": "The Black Morass",
"source": "azerothcore", "source": "azerothcore",
"mobs": 4, "mobs": 13,
"bosses": 3, "bosses": 3,
"attacks": 25, "attacks": 34,
"specialAttacks": 18, "specialAttacks": 18,
"nativeModels": 7, "nativeModels": 7,
"proceduralModels": 0, "proceduralModels": 9,
"status": "green" "status": "green"
}, },
{ {
"id": "escape-from-durnholde", "id": "escape-from-durnholde",
"title": "The Escape From Durnholde", "title": "The Escape From Durnholde",
"source": "azerothcore", "source": "azerothcore",
"mobs": 26, "mobs": 32,
"bosses": 3, "bosses": 3,
"attacks": 53, "attacks": 59,
"specialAttacks": 24, "specialAttacks": 24,
"nativeModels": 29, "nativeModels": 29,
"proceduralModels": 0, "proceduralModels": 6,
"status": "green" "status": "green"
}, },
{ {
@@ -605,107 +620,107 @@
"title": "Utgarde Keep", "title": "Utgarde Keep",
"source": "azerothcore", "source": "azerothcore",
"mobs": 19, "mobs": 19,
"bosses": 3, "bosses": 6,
"attacks": 44, "attacks": 55,
"specialAttacks": 22, "specialAttacks": 30,
"nativeModels": 22, "nativeModels": 22,
"proceduralModels": 0, "proceduralModels": 3,
"status": "green" "status": "green"
}, },
{ {
"id": "utgarde-pinnacle", "id": "utgarde-pinnacle",
"title": "Utgarde Pinnacle", "title": "Utgarde Pinnacle",
"source": "azerothcore", "source": "azerothcore",
"mobs": 13, "mobs": 19,
"bosses": 4, "bosses": 4,
"attacks": 44, "attacks": 50,
"specialAttacks": 27, "specialAttacks": 27,
"nativeModels": 17, "nativeModels": 17,
"proceduralModels": 0, "proceduralModels": 6,
"status": "green" "status": "green"
}, },
{ {
"id": "the-nexus", "id": "the-nexus",
"title": "The Nexus", "title": "The Nexus",
"source": "azerothcore", "source": "azerothcore",
"mobs": 21, "mobs": 24,
"bosses": 5, "bosses": 5,
"attacks": 61, "attacks": 64,
"specialAttacks": 35, "specialAttacks": 35,
"nativeModels": 26, "nativeModels": 26,
"proceduralModels": 0, "proceduralModels": 3,
"status": "green" "status": "green"
}, },
{ {
"id": "the-oculus", "id": "the-oculus",
"title": "The Oculus", "title": "The Oculus",
"source": "azerothcore", "source": "azerothcore",
"mobs": 8, "mobs": 17,
"bosses": 4, "bosses": 4,
"attacks": 33, "attacks": 42,
"specialAttacks": 21, "specialAttacks": 21,
"nativeModels": 12, "nativeModels": 12,
"proceduralModels": 0, "proceduralModels": 9,
"status": "green" "status": "green"
}, },
{ {
"id": "culling-of-stratholme", "id": "culling-of-stratholme",
"title": "The Culling of Stratholme", "title": "The Culling of Stratholme",
"source": "azerothcore", "source": "azerothcore",
"mobs": 16, "mobs": 21,
"bosses": 4, "bosses": 4,
"attacks": 44, "attacks": 49,
"specialAttacks": 24, "specialAttacks": 24,
"nativeModels": 20, "nativeModels": 20,
"proceduralModels": 0, "proceduralModels": 5,
"status": "green" "status": "green"
}, },
{ {
"id": "halls-of-stone", "id": "halls-of-stone",
"title": "Halls of Stone", "title": "Halls of Stone",
"source": "azerothcore", "source": "azerothcore",
"mobs": 13, "mobs": 16,
"bosses": 4, "bosses": 4,
"attacks": 42, "attacks": 45,
"specialAttacks": 25, "specialAttacks": 25,
"nativeModels": 16, "nativeModels": 16,
"proceduralModels": 1, "proceduralModels": 4,
"status": "green" "status": "green"
}, },
{ {
"id": "draktharon-keep", "id": "draktharon-keep",
"title": "Drak'Tharon Keep", "title": "Drak'Tharon Keep",
"source": "azerothcore", "source": "azerothcore",
"mobs": 18, "mobs": 20,
"bosses": 4, "bosses": 4,
"attacks": 45, "attacks": 47,
"specialAttacks": 23, "specialAttacks": 23,
"nativeModels": 22, "nativeModels": 22,
"proceduralModels": 0, "proceduralModels": 2,
"status": "green" "status": "green"
}, },
{ {
"id": "azjol-nerub", "id": "azjol-nerub",
"title": "Azjol-Nerub", "title": "Azjol-Nerub",
"source": "azerothcore", "source": "azerothcore",
"mobs": 10, "mobs": 18,
"bosses": 3, "bosses": 3,
"attacks": 30, "attacks": 38,
"specialAttacks": 17, "specialAttacks": 17,
"nativeModels": 13, "nativeModels": 13,
"proceduralModels": 0, "proceduralModels": 8,
"status": "green" "status": "green"
}, },
{ {
"id": "halls-of-lightning", "id": "halls-of-lightning",
"title": "Halls of Lightning", "title": "Halls of Lightning",
"source": "azerothcore", "source": "azerothcore",
"mobs": 16, "mobs": 19,
"bosses": 4, "bosses": 4,
"attacks": 49, "attacks": 52,
"specialAttacks": 29, "specialAttacks": 29,
"nativeModels": 20, "nativeModels": 20,
"proceduralModels": 0, "proceduralModels": 3,
"status": "green" "status": "green"
}, },
{ {
@@ -724,24 +739,24 @@
"id": "violet-hold", "id": "violet-hold",
"title": "Violet Hold", "title": "Violet Hold",
"source": "azerothcore", "source": "azerothcore",
"mobs": 2, "mobs": 17,
"bosses": 3, "bosses": 3,
"attacks": 24, "attacks": 39,
"specialAttacks": 19, "specialAttacks": 19,
"nativeModels": 5, "nativeModels": 5,
"proceduralModels": 0, "proceduralModels": 15,
"status": "green" "status": "green"
}, },
{ {
"id": "ahnkahet-old-kingdom", "id": "ahnkahet-old-kingdom",
"title": "Ahn'kahet: The Old Kingdom", "title": "Ahn'kahet: The Old Kingdom",
"source": "azerothcore", "source": "azerothcore",
"mobs": 19, "mobs": 23,
"bosses": 5, "bosses": 5,
"attacks": 60, "attacks": 64,
"specialAttacks": 36, "specialAttacks": 36,
"nativeModels": 24, "nativeModels": 24,
"proceduralModels": 0, "proceduralModels": 4,
"status": "green" "status": "green"
}, },
{ {
@@ -760,36 +775,36 @@
"id": "trial-of-the-champion", "id": "trial-of-the-champion",
"title": "Trial of the Champion", "title": "Trial of the Champion",
"source": "azerothcore", "source": "azerothcore",
"mobs": 0, "mobs": 6,
"bosses": 5, "bosses": 12,
"attacks": 88, "attacks": 123,
"specialAttacks": 83, "specialAttacks": 105,
"nativeModels": 5, "nativeModels": 5,
"proceduralModels": 0, "proceduralModels": 13,
"status": "green" "status": "green"
}, },
{ {
"id": "pit-of-saron", "id": "pit-of-saron",
"title": "Pit of Saron", "title": "Pit of Saron",
"source": "azerothcore", "source": "azerothcore",
"mobs": 27, "mobs": 31,
"bosses": 3, "bosses": 3,
"attacks": 49, "attacks": 53,
"specialAttacks": 19, "specialAttacks": 19,
"nativeModels": 30, "nativeModels": 30,
"proceduralModels": 0, "proceduralModels": 4,
"status": "green" "status": "green"
}, },
{ {
"id": "halls-of-reflection", "id": "halls-of-reflection",
"title": "Halls of Reflection", "title": "Halls of Reflection",
"source": "azerothcore", "source": "azerothcore",
"mobs": 8, "mobs": 16,
"bosses": 2, "bosses": 2,
"attacks": 38, "attacks": 46,
"specialAttacks": 28, "specialAttacks": 28,
"nativeModels": 10, "nativeModels": 10,
"proceduralModels": 0, "proceduralModels": 8,
"status": "green" "status": "green"
}, },
{ {
@@ -798,8 +813,8 @@
"source": "procedural-fallback", "source": "procedural-fallback",
"mobs": 3, "mobs": 3,
"bosses": 1, "bosses": 1,
"attacks": 8, "attacks": 13,
"specialAttacks": 4, "specialAttacks": 12,
"nativeModels": 0, "nativeModels": 0,
"proceduralModels": 4, "proceduralModels": 4,
"status": "green" "status": "green"
@@ -808,24 +823,24 @@
"id": "frozen-reach", "id": "frozen-reach",
"title": "Frozen Reach", "title": "Frozen Reach",
"source": "procedural-fallback", "source": "procedural-fallback",
"mobs": 3, "mobs": 2,
"bosses": 1, "bosses": 1,
"attacks": 8, "attacks": 19,
"specialAttacks": 4, "specialAttacks": 19,
"nativeModels": 0, "nativeModels": 0,
"proceduralModels": 4, "proceduralModels": 3,
"status": "green" "status": "green"
}, },
{ {
"id": "forgotten-mine", "id": "forgotten-mine",
"title": "Forgotten Mine", "title": "Forgotten Mine",
"source": "procedural-fallback", "source": "procedural-fallback",
"mobs": 3, "mobs": 4,
"bosses": 1, "bosses": 1,
"attacks": 8, "attacks": 13,
"specialAttacks": 4, "specialAttacks": 12,
"nativeModels": 0, "nativeModels": 0,
"proceduralModels": 4, "proceduralModels": 5,
"status": "green" "status": "green"
} }
] ]
+45 -42
View File
@@ -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. 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 | | 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 | | Shadowfang Keep | 24 | 7 | 65 | 34 | 31 | 0 | green |
| Stormwind Stockade | 6 | 5 | 42 | 31 | 11 | 0 | green | | Stormwind Stockade | 6 | 5 | 42 | 31 | 11 | 0 | green |
| The Deadmines | 15 | 7 | 55 | 33 | 22 | 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 | | 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 | | Uldaman | 31 | 7 | 104 | 66 | 38 | 0 | green |
| Gnomeregan | 27 | 4 | 82 | 51 | 31 | 0 | green | | Gnomeregan | 28 | 5 | 84 | 51 | 31 | 2 | green |
| Sunken Temple | 29 | 4 | 93 | 60 | 33 | 0 | green | | Sunken Temple | 31 | 4 | 95 | 60 | 33 | 2 | green |
| Razorfen Downs | 19 | 6 | 63 | 38 | 25 | 0 | green | | Razorfen Downs | 21 | 6 | 65 | 38 | 25 | 2 | green |
| Scarlet Monastery | 32 | 5 | 107 | 70 | 37 | 0 | green | | Scarlet Monastery | 32 | 5 | 107 | 70 | 37 | 0 | green |
| Zul'Farrak | 16 | 5 | 69 | 48 | 21 | 0 | green | | Zul'Farrak | 23 | 5 | 76 | 48 | 21 | 7 | green |
| Blackrock Spire | 63 | 15 | 273 | 195 | 78 | 0 | green | | Blackrock Spire | 66 | 17 | 285 | 202 | 78 | 5 | green |
| Blackrock Depths | 48 | 13 | 182 | 121 | 61 | 0 | green | | Blackrock Depths | 58 | 13 | 192 | 121 | 61 | 10 | green |
| Scholomance | 30 | 8 | 150 | 112 | 38 | 0 | green | | Scholomance | 31 | 8 | 151 | 112 | 38 | 1 | green |
| Stratholme | 41 | 9 | 176 | 126 | 50 | 0 | green | | Stratholme | 45 | 9 | 180 | 126 | 50 | 4 | green |
| Maraudon | 25 | 8 | 95 | 62 | 33 | 0 | green | | Maraudon | 25 | 8 | 95 | 62 | 33 | 0 | green |
| Ragefire Chasm | 7 | 4 | 23 | 12 | 11 | 0 | green | | Ragefire Chasm | 7 | 4 | 23 | 12 | 11 | 0 | green |
| Dire Maul | 63 | 12 | 242 | 167 | 75 | 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 Shattered Halls | 29 | 3 | 92 | 60 | 32 | 0 | green |
| Hellfire Citadel: The Blood Furnace | 14 | 3 | 53 | 36 | 17 | 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 Steamvault | 14 | 3 | 63 | 46 | 17 | 0 | green |
| Coilfang: The Underbog | 18 | 4 | 80 | 58 | 22 | 0 | green | | Coilfang: The Underbog | 20 | 4 | 82 | 58 | 22 | 2 | green |
| Coilfang: The Slave Pens | 18 | 3 | 60 | 39 | 21 | 0 | green | | Coilfang: The Slave Pens | 21 | 4 | 66 | 43 | 21 | 4 | green |
| Tempest Keep: The Arcatraz | 25 | 4 | 105 | 76 | 29 | 0 | green | | Tempest Keep: The Arcatraz | 28 | 4 | 108 | 76 | 29 | 3 | green |
| Tempest Keep: The Botanica | 24 | 5 | 85 | 56 | 29 | 0 | green | | Tempest Keep: The Botanica | 28 | 5 | 89 | 56 | 29 | 4 | green |
| Tempest Keep: The Mechanar | 14 | 3 | 69 | 52 | 17 | 0 | green | | Tempest Keep: The Mechanar | 14 | 3 | 69 | 52 | 17 | 0 | green |
| Auchindoun: Shadow Labyrinth | 18 | 4 | 67 | 45 | 22 | 0 | green | | Auchindoun: Shadow Labyrinth | 18 | 4 | 67 | 45 | 22 | 0 | green |
| Auchindoun: Sethekk Halls | 14 | 2 | 50 | 34 | 16 | 0 | green | | Auchindoun: Sethekk Halls | 19 | 3 | 59 | 37 | 16 | 6 | green |
| Auchindoun: Mana-Tombs | 11 | 3 | 46 | 32 | 14 | 0 | green | | Auchindoun: Mana-Tombs | 12 | 4 | 50 | 34 | 14 | 2 | green |
| Auchindoun: Auchenai Crypts | 12 | 2 | 44 | 30 | 14 | 0 | green | | Auchindoun: Auchenai Crypts | 13 | 3 | 48 | 32 | 14 | 2 | green |
| Magister's Terrace | 15 | 3 | 57 | 39 | 18 | 0 | green | | Magister's Terrace | 23 | 3 | 65 | 39 | 18 | 8 | green |
| The Black Morass | 4 | 3 | 25 | 18 | 7 | 0 | green | | The Black Morass | 13 | 3 | 34 | 18 | 7 | 9 | green |
| The Escape From Durnholde | 26 | 3 | 53 | 24 | 29 | 0 | green | | The Escape From Durnholde | 32 | 3 | 59 | 24 | 29 | 6 | green |
| Utgarde Keep | 19 | 3 | 44 | 22 | 22 | 0 | green | | Utgarde Keep | 19 | 6 | 55 | 30 | 22 | 3 | green |
| Utgarde Pinnacle | 13 | 4 | 44 | 27 | 17 | 0 | green | | Utgarde Pinnacle | 19 | 4 | 50 | 27 | 17 | 6 | green |
| The Nexus | 21 | 5 | 61 | 35 | 26 | 0 | green | | The Nexus | 24 | 5 | 64 | 35 | 26 | 3 | green |
| The Oculus | 8 | 4 | 33 | 21 | 12 | 0 | green | | The Oculus | 17 | 4 | 42 | 21 | 12 | 9 | green |
| The Culling of Stratholme | 16 | 4 | 44 | 24 | 20 | 0 | green | | The Culling of Stratholme | 21 | 4 | 49 | 24 | 20 | 5 | green |
| Halls of Stone | 13 | 4 | 42 | 25 | 16 | 1 | green | | Halls of Stone | 16 | 4 | 45 | 25 | 16 | 4 | green |
| Drak'Tharon Keep | 18 | 4 | 45 | 23 | 22 | 0 | green | | Drak'Tharon Keep | 20 | 4 | 47 | 23 | 22 | 2 | green |
| Azjol-Nerub | 10 | 3 | 30 | 17 | 13 | 0 | green | | Azjol-Nerub | 18 | 3 | 38 | 17 | 13 | 8 | green |
| Halls of Lightning | 16 | 4 | 49 | 29 | 20 | 0 | green | | Halls of Lightning | 19 | 4 | 52 | 29 | 20 | 3 | green |
| Gundrak | 15 | 5 | 53 | 33 | 20 | 0 | green | | Gundrak | 15 | 5 | 53 | 33 | 20 | 0 | green |
| Violet Hold | 2 | 3 | 24 | 19 | 5 | 0 | green | | Violet Hold | 17 | 3 | 39 | 19 | 5 | 15 | green |
| Ahn'kahet: The Old Kingdom | 19 | 5 | 60 | 36 | 24 | 0 | green | | Ahn'kahet: The Old Kingdom | 23 | 5 | 64 | 36 | 24 | 4 | green |
| The Forge of Souls | 11 | 2 | 23 | 10 | 13 | 0 | green | | The Forge of Souls | 11 | 2 | 23 | 10 | 13 | 0 | green |
| Trial of the Champion | 0 | 5 | 88 | 83 | 5 | 0 | green | | Trial of the Champion | 6 | 12 | 123 | 105 | 5 | 13 | green |
| Pit of Saron | 27 | 3 | 49 | 19 | 30 | 0 | green | | Pit of Saron | 31 | 3 | 53 | 19 | 30 | 4 | green |
| Halls of Reflection | 8 | 2 | 38 | 28 | 10 | 0 | green | | Halls of Reflection | 16 | 2 | 46 | 28 | 10 | 8 | green |
| Tinkertech Showdown | 3 | 1 | 8 | 4 | 0 | 4 | green | | Tinkertech Showdown | 3 | 1 | 13 | 12 | 0 | 4 | green |
| Frozen Reach | 3 | 1 | 8 | 4 | 0 | 4 | green | | Frozen Reach | 2 | 1 | 19 | 19 | 0 | 3 | green |
| Forgotten Mine | 3 | 1 | 8 | 4 | 0 | 4 | green | | Forgotten Mine | 4 | 1 | 13 | 12 | 0 | 5 | green |
## Documented source-faithful exceptions ## 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. - 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 ## 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>`.
+4 -1
View File
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/TempestKeepArcane", "mapDirectory": "World/Maps/TempestKeepArcane",
"environmentMode": "global-wmo", "environmentMode": "global-wmo",
"coordinateAlignment": "source", "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"}, "clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true}, "serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_TempestKeep/TK_Arcane.wmo", "doodadSets": ["Set_$DefaultGlobal"]}, "extraction": {"rootWmo": "World/wmo/Dungeon/OL_TempestKeep/TK_Arcane.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/AuchindounDraenei", "mapDirectory": "World/Maps/AuchindounDraenei",
"environmentMode": "global-wmo", "environmentMode": "global-wmo",
"coordinateAlignment": "source", "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"}, "clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true}, "serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Auchindoun/Draenei_wing.wmo", "doodadSets": ["Set_$DefaultGlobal"]}, "extraction": {"rootWmo": "World/wmo/Dungeon/OL_Auchindoun/Draenei_wing.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
+4 -1
View File
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/HellfireDemon", "mapDirectory": "World/Maps/HellfireDemon",
"environmentMode": "global-wmo", "environmentMode": "global-wmo",
"coordinateAlignment": "source", "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"}, "clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true}, "serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/Hellfire/hellfire_DemonWing.wmo", "doodadSets": ["Set_$DefaultGlobal"]}, "extraction": {"rootWmo": "World/wmo/Dungeon/Hellfire/hellfire_DemonWing.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
+4 -1
View File
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/TempestKeepAtrium", "mapDirectory": "World/Maps/TempestKeepAtrium",
"environmentMode": "global-wmo", "environmentMode": "global-wmo",
"coordinateAlignment": "source", "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"}, "clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true}, "serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_TempestKeep/TK_Atrium.wmo", "doodadSets": ["Set_$DefaultGlobal"]}, "extraction": {"rootWmo": "World/wmo/Dungeon/OL_TempestKeep/TK_Atrium.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
@@ -10,7 +10,10 @@
"environmentMode": "global-wmo", "environmentMode": "global-wmo",
"coordinateAlignment": "source-transform", "coordinateAlignment": "source-transform",
"coordinateTransform": {"origin": [1262.4427, -60.8902, 1622.5397], "yawDegrees": 262.143}, "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"}, "clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true}, "serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/Hellfire/Hellfire_ramparts.wmo", "doodadSets": ["Set_$DefaultGlobal"]}, "extraction": {"rootWmo": "World/wmo/Dungeon/Hellfire/Hellfire_ramparts.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/Sunwell5ManFix", "mapDirectory": "World/Maps/Sunwell5ManFix",
"environmentMode": "global-wmo", "environmentMode": "global-wmo",
"coordinateAlignment": "source", "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"}, "clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true}, "serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/QT_Sunwell_5Man/Sunwell_5Man.wmo", "doodadSets": ["Set_$DefaultGlobal"]}, "extraction": {"rootWmo": "World/wmo/Dungeon/QT_Sunwell_5Man/Sunwell_5Man.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
+4 -1
View File
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/AuchindounEthereal", "mapDirectory": "World/Maps/AuchindounEthereal",
"environmentMode": "global-wmo", "environmentMode": "global-wmo",
"coordinateAlignment": "source", "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"}, "clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true}, "serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Auchindoun/ethereal_wing.wmo", "doodadSets": ["Set_$DefaultGlobal"]}, "extraction": {"rootWmo": "World/wmo/Dungeon/OL_Auchindoun/ethereal_wing.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
+4 -1
View File
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/TempestKeepFactory", "mapDirectory": "World/Maps/TempestKeepFactory",
"environmentMode": "global-wmo", "environmentMode": "global-wmo",
"coordinateAlignment": "source", "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"}, "clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true}, "serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_TempestKeep/TK_Factory.wmo", "doodadSets": ["Set_$DefaultGlobal"]}, "extraction": {"rootWmo": "World/wmo/Dungeon/OL_TempestKeep/TK_Factory.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
+4 -1
View File
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/AuchindounDemon", "mapDirectory": "World/Maps/AuchindounDemon",
"environmentMode": "global-wmo", "environmentMode": "global-wmo",
"coordinateAlignment": "source", "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"}, "clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true}, "serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Auchindoun/demon_wing.wmo", "doodadSets": ["Set_$DefaultGlobal"]}, "extraction": {"rootWmo": "World/wmo/Dungeon/OL_Auchindoun/demon_wing.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/AuchindounShadow", "mapDirectory": "World/Maps/AuchindounShadow",
"environmentMode": "global-wmo", "environmentMode": "global-wmo",
"coordinateAlignment": "source", "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"}, "clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true}, "serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Auchindoun/shadow_council_wing.wmo", "doodadSets": ["Set_$DefaultGlobal"]}, "extraction": {"rootWmo": "World/wmo/Dungeon/OL_Auchindoun/shadow_council_wing.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/HellfireMilitary", "mapDirectory": "World/Maps/HellfireMilitary",
"environmentMode": "global-wmo", "environmentMode": "global-wmo",
"coordinateAlignment": "source", "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"}, "clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true}, "serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/Hellfire/Hellfire_Military.wmo", "doodadSets": ["Set_$DefaultGlobal"]}, "extraction": {"rootWmo": "World/wmo/Dungeon/Hellfire/Hellfire_Military.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
+4 -1
View File
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/CoilfangDraenei", "mapDirectory": "World/Maps/CoilfangDraenei",
"environmentMode": "global-wmo", "environmentMode": "global-wmo",
"coordinateAlignment": "source", "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"}, "clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true}, "serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Coilfang/Coilfang_Draenei.wmo", "doodadSets": ["Set_$DefaultGlobal"]}, "extraction": {"rootWmo": "World/wmo/Dungeon/OL_Coilfang/Coilfang_Draenei.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
+4 -1
View File
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/CoilfangPumping", "mapDirectory": "World/Maps/CoilfangPumping",
"environmentMode": "global-wmo", "environmentMode": "global-wmo",
"coordinateAlignment": "source", "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"}, "clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true}, "serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Coilfang/Coilfang_Pumping.wmo", "doodadSets": ["Set_$DefaultGlobal"]}, "extraction": {"rootWmo": "World/wmo/Dungeon/OL_Coilfang/Coilfang_Pumping.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
+4 -1
View File
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/CoilfangMarsh", "mapDirectory": "World/Maps/CoilfangMarsh",
"environmentMode": "global-wmo", "environmentMode": "global-wmo",
"coordinateAlignment": "source", "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"}, "clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true}, "serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Coilfang/Coilfang_Marsh.wmo", "doodadSets": ["Set_$DefaultGlobal"]}, "extraction": {"rootWmo": "World/wmo/Dungeon/OL_Coilfang/Coilfang_Marsh.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.21", "version": "0.1.22",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.21", "version": "0.1.22",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+5 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.21", "version": "0.1.22",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "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": "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: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: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: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:wow:all": "node scripts/dungeon-pipeline/run-all-wow-dungeons.mjs",
"dungeon:extract": "node scripts/dungeon-pipeline/cli.mjs extract", "dungeon:extract": "node scripts/dungeon-pipeline/cli.mjs extract",
"dungeon:creatures": "node scripts/dungeon-pipeline/cli.mjs creatures", "dungeon:creatures": "node scripts/dungeon-pipeline/cli.mjs creatures",
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.21", "versionName": "0.1.22",
"versionCode": 1021 "versionCode": 1022
} }
+206 -7
View File
@@ -3,6 +3,7 @@ import {
constants, constants,
copyFile, copyFile,
mkdir, mkdir,
readFile,
readdir, readdir,
rename, rename,
stat, stat,
@@ -13,8 +14,16 @@ import { fileURLToPath } from "node:url";
const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)); const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
export const PROJECT_ROOT = path.resolve(SCRIPT_DIRECTORY, "../.."); export const PROJECT_ROOT = path.resolve(SCRIPT_DIRECTORY, "../..");
const WOW_CAMPAIGN_CATALOG_PATH = path.join(
PROJECT_ROOT,
"src",
"game",
"generated",
"dungeonCampaignCatalog.json",
);
export const CONTENT_SCHEMA_VERSION = 1; export const CONTENT_SCHEMA_VERSION = 1;
export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0"; export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0";
export const WOW_CAMPAIGN_DUNGEON_COUNT = 54;
export const BUNDLED_ANDROID_PACKS = Object.freeze([ export const BUNDLED_ANDROID_PACKS = Object.freeze([
"core:ui", "core:ui",
"core:equipment", "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)); return [...discovered.values()].sort((left, right) => left.logicalPath.localeCompare(right.logicalPath));
} }
function contentVersion(files) { function contentVersion(files, packs) {
const digest = createHash("sha256"); const digest = createHash("sha256");
for (const file of files) digest.update(`${file.logicalPath}\0${file.sha256}\0${file.bytes}\n`); 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)}`; 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 = {}) { export function createContentManifest(files, options = {}) {
const baseUrl = String(options.baseUrl ?? "/content/objects").replace(/\/+$/, ""); const baseUrl = String(options.baseUrl ?? "/content/objects").replace(/\/+$/, "");
const version = options.version ?? contentVersion(files);
const packs = new Map(); const packs = new Map();
const fileEntries = {}; 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 { return {
schemaVersion: CONTENT_SCHEMA_VERSION, schemaVersion: CONTENT_SCHEMA_VERSION,
version, version,
createdAt: options.createdAt ?? new Date().toISOString(), createdAt: options.createdAt ?? new Date().toISOString(),
minimumAppVersion: options.minimumAppVersion ?? DEFAULT_MINIMUM_APP_VERSION, minimumAppVersion: options.minimumAppVersion ?? DEFAULT_MINIMUM_APP_VERSION,
packs: [...packs.values()].sort((left, right) => left.id.localeCompare(right.id)), packs: sortedPacks,
files: fileEntries, 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) { async function copyObjectIfMissing(file, objectDirectory) {
const objectName = `${file.sha256}${file.extension}`; const objectName = `${file.sha256}${file.extension}`;
const destination = path.join(objectDirectory, objectName); const destination = path.join(objectDirectory, objectName);
@@ -263,20 +446,35 @@ async function replaceJsonAtomically(destination, value) {
export async function publishContent(options = {}) { export async function publishContent(options = {}) {
const contentDirectory = path.resolve(options.contentDirectory ?? process.env.CONTENT_DIR ?? path.join(PROJECT_ROOT, "content")); 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 objectDirectory = path.join(contentDirectory, "objects");
const manifestDirectory = path.join(contentDirectory, "manifests"); const manifestDirectory = path.join(contentDirectory, "manifests");
await mkdir(objectDirectory, { recursive: true }); await mkdir(objectDirectory, { recursive: true });
await mkdir(manifestDirectory, { recursive: true }); await mkdir(manifestDirectory, { recursive: true });
const files = await scanContentSources(options.sources ?? DEFAULT_CONTENT_SOURCES);
let copiedObjects = 0; let copiedObjects = 0;
for (const file of files) { for (const file of files) {
if (await copyObjectIfMissing(file, objectDirectory)) copiedObjects += 1; 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(manifestDirectory, `${manifest.version}.json`), manifest);
await replaceJsonAtomically(path.join(contentDirectory, "manifest.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) { 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); 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(`Published ${result.manifest.files ? Object.keys(result.manifest.files).length : 0} files (${bytes} bytes)`);
console.log(`Content version: ${result.manifest.version}`); 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(`New immutable objects: ${result.copiedObjects}`);
console.log(`Manifest: ${path.join(result.contentDirectory, "manifest.json")}`); console.log(`Manifest: ${path.join(result.contentDirectory, "manifest.json")}`);
} }
+170 -3
View File
@@ -1,12 +1,15 @@
import assert from "node:assert/strict"; 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 os from "node:os";
import path from "node:path"; import path from "node:path";
import test from "node:test"; import test from "node:test";
import { import {
BUNDLED_ANDROID_PACKS, BUNDLED_ANDROID_PACKS,
PROJECT_ROOT,
assertCampaignDungeonManifest,
contentPackForPath, contentPackForPath,
createContentManifest, createContentManifest,
loadCampaignDungeonPackDefinitions,
publishContent, publishContent,
scanContentSources, scanContentSources,
} from "./publish-content.mjs"; } 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"); 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 () => { test("publishes objects before a deterministic latest manifest", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "healer-man-content-test-")); const directory = await mkdtemp(path.join(os.tmpdir(), "healer-man-content-test-"));
const sourceDirectory = path.join(directory, "source"); 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(Object.keys(firstManifest.files).length, 1);
assert.equal(firstManifest.packs[0].id, "dungeon:wailing-caverns"); 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); assert.equal(first.copiedObjects, 1);
const latest = JSON.parse(await readFile(path.join(contentDirectory, "manifest.json"), "utf8")); const latest = JSON.parse(await readFile(path.join(contentDirectory, "manifest.json"), "utf8"));
assert.equal(latest.version, first.manifest.version); 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.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"); 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.copiedObjects, 0);
assert.equal(second.manifest.version, first.manifest.version); assert.equal(second.manifest.version, first.manifest.version);
} finally { } finally {
@@ -2,6 +2,11 @@
import { open, readFile, writeFile } from "node:fs/promises"; import { open, readFile, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; 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 projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const publicRoot = path.join(projectRoot, "public"); 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 validSchools = new Set(["physical", "arcane", "fire", "frost", "nature", "shadow", "holy"]);
const validEffects = new Set([ const validEffects = new Set([
"damage", "heal", "aura", "control", "transform", "summon", "call-for-help", "encounter-event", "damage", "heal", "aura", "target-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.",
],
]); ]);
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 = { const animationIds = {
idle: new Set([0]), idle: new Set([0]),
move: new Set([4, 5]), 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"), readJson("dungeon-pipeline/fixtures/wailing-caverns/runtime-fixture.json"),
]); ]);
const campaignIds = new Set(campaign.dungeons.map((dungeon) => dungeon.slug)); 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 coverage = new Map(catalog.coverage.map((entry) => [entry.dungeonId, entry]));
const wailingModels = wailingFixtureModels(wailingFixture); const wailingModels = wailingFixtureModels(wailingFixture);
const issues = []; 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 modelUsers = new Map();
const animationFallbacks = []; const animationFallbacks = [];
const dungeons = []; const dungeons = [];
const actualBasicOnlyBossKeys = new Set();
const actualNoTrashDungeonKeys = new Set();
if (campaign.dungeons.length !== 54) { if (campaign.dungeons.length !== 54) {
issues.push("Expected 54 campaign dungeons; found " + campaign.dungeons.length + "."); issues.push("Expected 54 campaign dungeons; found " + campaign.dungeons.length + ".");
@@ -179,15 +182,15 @@ for (const dungeon of definitions) {
let specialAttacks = 0; let specialAttacks = 0;
let nativeModels = 0; let nativeModels = 0;
let proceduralModels = 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 (!entities.length) dungeonIssues.push("has no combat entities.");
if (!bosses.length) dungeonIssues.push("has no bosses."); if (!bosses.length) dungeonIssues.push("has no bosses.");
if (!mobs.length && !noTrashExceptions.has(dungeon.id)) dungeonIssues.push("has no trash mobs."); if (!mobs.length) {
if (!(dungeon.staticSpawns?.length || dungeon.roamingPacks?.length)) dungeonIssues.push("has no combat spawns."); 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 ?? []) { for (const spawn of dungeon.staticSpawns ?? []) {
if (!dungeon.entities?.[spawn.entityId]) { 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) { for (const [entityId, entity] of entities) {
const entityAttacks = entity.combat?.attacks ?? []; const entityAttacks = entity.combat?.attacks ?? [];
@@ -209,8 +219,9 @@ for (const dungeon of definitions) {
attacks += entityAttacks.length; attacks += entityAttacks.length;
const specials = entityAttacks.filter((attack) => attack.id !== "basic-attack"); const specials = entityAttacks.filter((attack) => attack.id !== "basic-attack");
specialAttacks += specials.length; specialAttacks += specials.length;
if (entity.kind === "boss" && !specials.length && !optionalBossEntityIds.has(entityId)) { if (entity.kind === "boss" && !specials.length) {
const exceptionKey = dungeon.id + ":" + entityId; const exceptionKey = dungeon.id + ":" + entityId;
actualBasicOnlyBossKeys.add(exceptionKey);
if (!basicOnlyBossExceptions.has(exceptionKey)) { if (!basicOnlyBossExceptions.has(exceptionKey)) {
dungeonIssues.push(entityId + " has no boss mechanic beyond its basic attack."); 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) { for (const [url, users] of modelUsers) {
try { try {
const names = await readGlbAnimationNames(url); 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 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 workRoot = path.resolve(process.env.DUNGEON_PIPELINE_WORK ?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons"));
const outputFile = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json"); const outputFile = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json");
const gameObjectAssetManifestFile = path.join(
projectRoot,
"dungeon-pipeline",
"WOW_GAMEOBJECT_ASSET_MANIFEST.json",
);
const compressedOutputFile = path.join( const compressedOutputFile = path.join(
projectRoot, projectRoot,
"src", "src",
@@ -81,6 +86,19 @@ const epochSpellDbc = path.join(
"DBFilesClient", "DBFilesClient",
"Spell.dbc", "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([ const WAILING_SERVER_ENCOUNTERS = Object.freeze([
[3671, "Lady Anacondra"], [3671, "Lady Anacondra"],
@@ -200,6 +218,100 @@ function slugPart(value) {
.replace(/^-|-$/g, "") || "unknown"; .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) { function inferEnvironmentMode(recipe, assetPackage) {
if (recipe.environmentMode !== "detect") return recipe.environmentMode; if (recipe.environmentMode !== "detect") return recipe.environmentMode;
const sourceFiles = assetPackage.visual.flatMap((chunk) => chunk.provenance?.sourceFiles ?? []); 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 GLOBAL_WMO_SOURCE_BASES = new Set(["draft", "flip-x"]);
const COORDINATE_ALIGNMENTS = new Set(["player-anchor", "source", "source-transform"]); 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) { function sourceBasisFor(recipe, environmentMode) {
if (environmentMode !== "adt-hybrid") { if (environmentMode !== "adt-hybrid") {
@@ -371,13 +872,44 @@ function uniqueDraftEncounters(encounterRows, mapId) {
...(resolvedEntry > 0 ? {} : { syntheticGroupEncounter: true }), ...(resolvedEntry > 0 ? {} : { syntheticGroupEncounter: true }),
}; };
const key = `${creatureId}:${normalizedName(encounter.name)}`; 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) => ( return [...byIdentity.values()].sort((left, right) => (
left.orderIndex - right.orderIndex || left.encounterId - right.encounterId 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) { function archetypeFor(name) {
const value = normalizedName(name); const value = normalizedName(name);
if (/murloc|murk|glub|fin/.test(value)) return "murloc"; if (/murloc|murk|glub|fin/.test(value)) return "murloc";
@@ -1032,6 +1564,12 @@ async function compile() {
const epochSpells = await exists(epochSpellDbc) const epochSpells = await exists(epochSpellDbc)
? parseSpells(await readFile(epochSpellDbc), wantedSpellIds) ? parseSpells(await readFile(epochSpellDbc), wantedSpellIds)
: ascensionSpells; : 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) const previousOutput = await exists(outputFile)
? await readJson(outputFile) ? await readJson(outputFile)
@@ -1103,6 +1641,14 @@ async function compile() {
?? draft?.spawns?.[0]?.position ?? draft?.spawns?.[0]?.position
?? playerAnchor.position, ?? playerAnchor.position,
); );
const serverGameObjects = compileSourceGameObjects(
draft,
mapPosition,
recipe,
recipe.clientBuild === "3.3.5a-Epoch"
? epochGameObjectDisplays
: standardGameObjectDisplays,
);
const sourceEntities = (draft?.entities ?? []) const sourceEntities = (draft?.entities ?? [])
.filter((entity) => ( .filter((entity) => (
!(recipe.slug === "wailing-caverns" && Number(entity.entry) === 5912) !(recipe.slug === "wailing-caverns" && Number(entity.entry) === 5912)
@@ -1230,6 +1776,11 @@ async function compile() {
const usedBossSpawnIds = new Set(); const usedBossSpawnIds = new Set();
const bossObjectives = encounters.map((encounter, index) => { const bossObjectives = encounters.map((encounter, index) => {
const candidate = bossCandidatesByEntry.get(Number(encounter.creatureId)); 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 alpha = encounters.length <= 1 ? 1 : index / (encounters.length - 1);
const routeAlpha = 0.18 + alpha * 0.82; const routeAlpha = 0.18 + alpha * 0.82;
const nativeRoutePosition = nativeRoutePositions.length const nativeRoutePosition = nativeRoutePositions.length
@@ -1253,7 +1804,10 @@ async function compile() {
name: encounter.name, name: encounter.name,
position, position,
yaw: candidate?.yaw ?? 0, yaw: candidate?.yaw ?? 0,
...(spawnChance < 1 ? { optional: true } : {}), spawnMask,
...(spawnChance < 1 || (spawnMask & declaredDifficultyMask) !== declaredDifficultyMask
? { optional: true }
: {}),
...(encounter.eventOnly ? { eventOnly: true } : {}), ...(encounter.eventOnly ? { eventOnly: true } : {}),
}; };
}); });
@@ -1313,7 +1867,7 @@ async function compile() {
entityId: boss.entityId, entityId: boss.entityId,
position: boss.position, position: boss.position,
yaw: boss.yaw, yaw: boss.yaw,
spawnMask: 1, spawnMask: boss.spawnMask,
}); });
} }
@@ -1426,7 +1980,8 @@ async function compile() {
bosses, bosses,
objectiveOrder: bosses, objectiveOrder: bosses,
navigationLinks: [], navigationLinks: [],
serverGameObjects: draft?.gameObjects ?? [], encounters: [compileBossProgressionEncounter(recipe, bosses, syntheticTrash)],
serverGameObjects,
provenance: { provenance: {
clientBuild: recipe.clientBuild, clientBuild: recipe.clientBuild,
coordinateAlignment, coordinateAlignment,
@@ -1490,7 +2045,13 @@ async function compile() {
}; };
const serialized = JSON.stringify(output); const serialized = JSON.stringify(output);
const compressed = gzipSync(Buffer.from(serialized), { level: 9 }); const compressed = gzipSync(Buffer.from(serialized), { level: 9 });
const gameObjectAssetManifest = gameObjectAssetManifestFor(definitions);
await writeFile(outputFile, `${serialized}\n`, "utf8"); await writeFile(outputFile, `${serialized}\n`, "utf8");
await writeFile(
gameObjectAssetManifestFile,
`${JSON.stringify(gameObjectAssetManifest, null, 2)}\n`,
"utf8",
);
await writeFile( await writeFile(
compressedOutputFile, compressedOutputFile,
`// Generated by npm run dungeon:catalog. Do not edit.\nexport default "${compressed.toString("base64")}";\n`, `// Generated by npm run dungeon:catalog. Do not edit.\nexport default "${compressed.toString("base64")}";\n`,
@@ -1517,6 +2078,7 @@ async function compile() {
status: "complete", status: "complete",
output: path.relative(projectRoot, outputFile).replace(/\\/g, "/"), output: path.relative(projectRoot, outputFile).replace(/\\/g, "/"),
compressedOutput: path.relative(projectRoot, compressedOutputFile).replace(/\\/g, "/"), compressedOutput: path.relative(projectRoot, compressedOutputFile).replace(/\\/g, "/"),
gameObjectAssetManifest: path.relative(projectRoot, gameObjectAssetManifestFile).replace(/\\/g, "/"),
compressedBytes: compressed.length, compressedBytes: compressed.length,
dungeons: definitions.length, dungeons: definitions.length,
encounters: coverage.reduce((total, row) => total + row.encounters, 0), 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), roamingPacks: coverage.reduce((total, row) => total + row.roamingPacks, 0),
authoritativeDungeons: coverage.filter((row) => row.source === "azerothcore").length, authoritativeDungeons: coverage.filter((row) => row.source === "azerothcore").length,
fallbackDungeons: coverage.filter((row) => row.source === "procedural-fallback").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)); }, null, 2));
} }
+25 -23
View File
@@ -1,4 +1,4 @@
import { spawn } from "node:child_process"; import { stat } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { import {
clientRootForRecipe, clientRootForRecipe,
@@ -10,24 +10,21 @@ import {
recipesRoot, recipesRoot,
workRoot, workRoot,
} from "./lib.mjs"; } from "./lib.mjs";
import { runWowExporter } from "./wow-export-tool.mjs";
async function runExporter(argumentsList) { async function resultMtime(output) {
const executable = path.resolve(projectRoot, "../wow335a/dungeon-export-work/tools/wow.export-0.2.19/wow.export.exe"); const resultFile = path.join(output, "automation-result.json");
if (!await exists(executable)) throw new Error("The patched portable wow.export executable was not found."); return await exists(resultFile) ? (await stat(resultFile)).mtimeMs : 0;
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 checkedResult(slug, output) { async function checkedResult(slug, output, previousMtime) {
const resultFile = path.join(output, "automation-result.json"); 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); const result = await readJson(resultFile);
if (!result.ok) return { if (!result.ok) return {
status: "blocked", status: "blocked",
@@ -43,13 +40,14 @@ export async function exportDungeonSource(slug) {
const recipe = await loadRecipe(slug); const recipe = await loadRecipe(slug);
const clientRoot = clientRootForRecipe(recipe); const clientRoot = clientRootForRecipe(recipe);
const output = path.join(workRoot, slug, "source-export"); const output = path.join(workRoot, slug, "source-export");
await runExporter([ const previousMtime = await resultMtime(output);
await runWowExporter([
"--export-dungeon", "--export-dungeon",
"--recipe", path.join(recipesRoot, `${slug}.json`), "--recipe", path.join(recipesRoot, `${slug}.json`),
"--client-root", clientRoot, "--client-root", clientRoot,
"--output", output, "--output", output,
]); ], { projectRoot });
const blocked = await checkedResult(slug, output); const blocked = await checkedResult(slug, output, previousMtime);
if (blocked) return blocked; if (blocked) return blocked;
return { status: "green", dungeonId: slug, output: path.relative(projectRoot, output).replace(/\\/g, "/") }; 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 recipe = await loadRecipe(slug);
const clientRoot = clientRootForRecipe(recipe); const clientRoot = clientRootForRecipe(recipe);
const output = path.join(workRoot, slug, "client-dbc"); const output = path.join(workRoot, slug, "client-dbc");
await runExporter([ const previousMtime = await resultMtime(output);
await runWowExporter([
"--discover-dungeon", "--discover-dungeon",
"--recipe", path.join(recipesRoot, `${slug}.json`), "--recipe", path.join(recipesRoot, `${slug}.json`),
"--client-root", clientRoot, "--client-root", clientRoot,
"--output", output, "--output", output,
]); ], { projectRoot });
const blocked = await checkedResult(slug, output, previousMtime);
if (blocked) throw new Error(blocked.blockers.join("\n"));
return output; 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.`), blockers: unresolved.map((creature) => `${creature.name ?? creature.slug} has an unresolved display/composite recipe.`),
}; };
const output = path.join(workRoot, slug, "creature-source"); const output = path.join(workRoot, slug, "creature-source");
await runExporter([ const previousMtime = await resultMtime(output);
await runWowExporter([
"--export-creatures", "--export-creatures",
"--recipe", selected, "--recipe", selected,
"--client-root", clientRoot, "--client-root", clientRoot,
"--output", output, "--output", output,
]); ], { projectRoot });
const blocked = await checkedResult(slug, output); const blocked = await checkedResult(slug, output, previousMtime);
if (blocked) return blocked; if (blocked) return blocked;
return { status: "green", dungeonId: slug, output: path.relative(projectRoot, output).replace(/\\/g, "/") }; return { status: "green", dungeonId: slug, output: path.relative(projectRoot, output).replace(/\\/g, "/") };
} }
@@ -45,6 +45,9 @@ function runNpm(script, argumentsList = []) {
async function main() { async function main() {
if (options.has("--audit-only")) { if (options.has("--audit-only")) {
await runNpm("dungeon:wow:audit"); 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"); await runNpm("dungeon:wow:test");
return; return;
} }
@@ -63,8 +66,11 @@ async function main() {
await runNpm("dungeon:catalog"); await runNpm("dungeon:catalog");
await runNpm("dungeon:five-player:verify"); await runNpm("dungeon:five-player:verify");
await runNpm("dungeon:wow:audit"); 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"); await runNpm("dungeon:wow:test");
if (options.has("--build")) await runNpm("build"); 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
View File
@@ -75,6 +75,18 @@ class ReleaseVersion:
version_code: int 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]: def parse_semver(value: str) -> tuple[int, int, int]:
match = SEMVER_PATTERN.fullmatch(value.strip()) match = SEMVER_PATTERN.fullmatch(value.strip())
if not match: if not match:
@@ -239,11 +251,13 @@ def validate_release_candidates(project_root: Path = PROJECT_ROOT) -> list[str]:
cwd=project_root, cwd=project_root,
env=environment, env=environment,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.PIPE,
check=False, check=False,
) )
if result.returncode != 0: 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( raise ReleaseError(
f"Release-candidate Git check failed ({result.returncode}): " f"Release-candidate Git check failed ({result.returncode}): "
f"git {' '.join(args)}\n{output}" f"git {' '.join(args)}\n{output}"
@@ -269,11 +283,15 @@ def validate_release_candidates(project_root: Path = PROJECT_ROOT) -> list[str]:
encoding="utf-8", encoding="utf-8",
errors="replace", errors="replace",
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.PIPE,
check=False, check=False,
) )
if whitespace_result.returncode != 0: 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( raise ReleaseError(
"Release-candidate whitespace check failed. This check includes " "Release-candidate whitespace check failed. This check includes "
f"tracked and untracked files:\n{detail}" 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}." f"Recorded Android version {version.version_name} / {version.version_code}."
) )
checks = ( for command, label in full_release_checks(npm):
([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:
self._run_command(command, label) self._run_command(command, label)
self._run_command(["git", "add", "-A"], "Stage release") self._run_command(["git", "add", "-A"], "Stage release")
staged = True staged = True
+7
View File
@@ -47,6 +47,13 @@ class SafetyTests(unittest.TestCase):
self.assertIn("pull --ff-only", block) self.assertIn("pull --ff-only", block)
self.assertIn('test "$ACTUAL_COMMIT" = "$EXPECTED_COMMIT"', 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): class ReleaseCandidateTests(unittest.TestCase):
def test_checks_untracked_files_without_changing_the_real_index(self): def test_checks_untracked_files_without_changing_the_real_index(self):
+21 -4
View File
@@ -6,8 +6,14 @@ import { useManastormAdminStore } from "./game/manastormAdminStore";
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen"; import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
import { LoginScreen } from "./ui/LoginScreen"; import { LoginScreen } from "./ui/LoginScreen";
import { OnlineGroupCoordinator } from "./app/OnlineGroupCoordinator"; import { OnlineGroupCoordinator } from "./app/OnlineGroupCoordinator";
import { 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 CharacterCreateScreen = lazy(() => import("./ui/CharacterCreateScreen").then((module) => ({ default: module.CharacterCreateScreen })));
const CharacterSelectScreen = lazy(() => import("./ui/CharacterSelectScreen").then((module) => ({ default: module.CharacterSelectScreen }))); const CharacterSelectScreen = lazy(() => import("./ui/CharacterSelectScreen").then((module) => ({ default: module.CharacterSelectScreen })));
const ClassHallScreen = lazy(() => import("./ui/ClassHallScreen").then((module) => ({ default: module.ClassHallScreen }))); 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]; if (definition.mode === "conquest") return [...BASE_CLASS_IDS, character.classId];
return [character.classId, ...(character.secondaryClassId ? [character.secondaryClassId] : [])]; return [character.classId, ...(character.secondaryClassId ? [character.secondaryClassId] : [])];
}, [character]); }, [character]);
const talentClassId = character && !character.classId.startsWith("rom-")
? character.classId
: null;
const requestKey = classIds.join(":"); const requestKey = classIds.join(":");
const originalMediaEnabled = useShellStore((state) => state.combatPresentationSettings.originalMediaEnabled);
const [loadedKey, setLoadedKey] = useState(""); const [loadedKey, setLoadedKey] = useState("");
const [failedKey, setFailedKey] = useState(""); const [failedKey, setFailedKey] = useState("");
@@ -40,13 +50,17 @@ function RuntimeCatalogGate({ children }: { readonly children: ReactNode }) {
if (!requestKey) return; if (!requestKey) return;
let active = true; let active = true;
setFailedKey(""); setFailedKey("");
void preloadAbilityCatalogsForClasses(classIds).then(() => { void Promise.all([
preloadAbilityCatalogsForClasses(classIds),
talentClassId ? preloadTalentGameplayResources(talentClassId) : Promise.resolve(),
preloadSharedGameplayResources(originalMediaEnabled),
]).then(() => {
if (active) setLoadedKey(requestKey); if (active) setLoadedKey(requestKey);
}).catch(() => { }).catch(() => {
if (active) setFailedKey(requestKey); if (active) setFailedKey(requestKey);
}); });
return () => { active = false; }; return () => { active = false; };
}, [classIds, requestKey]); }, [classIds, originalMediaEnabled, requestKey, talentClassId]);
if (!requestKey || loadedKey === requestKey) return children; if (!requestKey || loadedKey === requestKey) return children;
if (failedKey === requestKey) { if (failedKey === requestKey) {
@@ -61,6 +75,7 @@ export default function App() {
const phase = useShellStore((state) => state.phase); const phase = useShellStore((state) => state.phase);
const session = useShellStore((state) => state.session); const session = useShellStore((state) => state.session);
const refreshManastormConfig = useManastormAdminStore((state) => state.refresh); const refreshManastormConfig = useManastormAdminStore((state) => state.refresh);
const PreloadedGameRuntime = getLoadedGameRuntime();
useEffect(() => { useEffect(() => {
void refreshManastormConfig(session); void refreshManastormConfig(session);
@@ -77,7 +92,9 @@ export default function App() {
else if (phase === "arenas") screen = <Suspense fallback={<ShellTransition />}><ArenaSelectScreen /></Suspense>; else if (phase === "arenas") screen = <Suspense fallback={<ShellTransition />}><ArenaSelectScreen /></Suspense>;
else if (phase === "arena-game") screen = <Suspense fallback={<ShellTransition />}><ArenaRuntime /></Suspense>; else if (phase === "arena-game") screen = <Suspense fallback={<ShellTransition />}><ArenaRuntime /></Suspense>;
else if (phase === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><GmAdminScreen /></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); const requiresRuntimeCatalog = !["login", "characters", "create-character"].includes(phase);
return <><OnlineGroupCoordinator />{requiresRuntimeCatalog ? <RuntimeCatalogGate>{screen}</RuntimeCatalogGate> : screen}</>; return <><OnlineGroupCoordinator />{requiresRuntimeCatalog ? <RuntimeCatalogGate>{screen}</RuntimeCatalogGate> : screen}</>;
} }
+78
View File
@@ -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();
});
});
+59 -22
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { selectedCharacter, useShellStore } from "./shellStore"; import { selectedCharacter, useShellStore } from "./shellStore";
import { useOnlineGroupStore } from "./onlineGroupStore"; import { useOnlineGroupStore } from "./onlineGroupStore";
import { import {
@@ -6,11 +6,56 @@ import {
defaultPartyRoleForClass, defaultPartyRoleForClass,
type PartyRole, type PartyRole,
} from "../game/partyRoles"; } 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 GROUP_POLL_MS = 2_500;
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000; 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() { export function OnlineGroupCoordinator() {
const session = useShellStore((state) => state.session); const session = useShellStore((state) => state.session);
const character = useShellStore(selectedCharacter); const character = useShellStore(selectedCharacter);
@@ -21,6 +66,8 @@ export function OnlineGroupCoordinator() {
const updateMember = useOnlineGroupStore((state) => state.updateMember); const updateMember = useOnlineGroupStore((state) => state.updateMember);
const consumeActivity = useOnlineGroupStore((state) => state.consumeActivity); const consumeActivity = useOnlineGroupStore((state) => state.consumeActivity);
const reset = useOnlineGroupStore((state) => state.reset); const reset = useOnlineGroupStore((state) => state.reset);
const { installedRevision } = useContentManagerSnapshot();
const pendingActivityIds = useRef(new Set<string>());
useEffect(() => { useEffect(() => {
if (!session || session.kind !== "account" || !session.accessToken) { if (!session || session.kind !== "account" || !session.accessToken) {
@@ -60,26 +107,16 @@ export function OnlineGroupCoordinator() {
if (Date.now() - activity.startedAt > ACTIVITY_JOIN_WINDOW_MS) return; if (Date.now() - activity.startedAt > ACTIVITY_JOIN_WINDOW_MS) return;
const member = snapshot.group?.members.find((candidate) => candidate.accountId === session.ownerId); const member = snapshot.group?.members.find((candidate) => candidate.accountId === session.ownerId);
if (!member?.character || !member.role) return; if (!member?.character || !member.role) return;
consumeActivity(activity.id); if (pendingActivityIds.current.has(activity.id)) return;
void (async () => { pendingActivityIds.current.add(activity.id);
if (activity.type === "dungeon") { void enterOnlineGroupActivity(activity, member.role, consumeActivity)
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime"); .catch((error) => {
installDungeonShellRuntime(); useShellStore.getState().setNotice(error instanceof Error ? error.message : "The group activity could not be opened.");
useShellStore.getState().enterDungeon(activity.contentId, member.role ?? undefined); })
return; .finally(() => {
} pendingActivityIds.current.delete(activity.id);
const { installManastormShellRuntime } = await import("./manastormShellRuntime"); });
installManastormShellRuntime(); }, [consumeActivity, consumedActivityIds, installedRevision, phase, session?.ownerId, snapshot.group?.activity?.id]);
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]);
return null; return null;
} }
+5
View File
@@ -11,6 +11,7 @@ import { useShellStore } from "./shellStore";
import { registerDungeonShellRuntime } from "./dungeonShellRuntimeBridge"; import { registerDungeonShellRuntime } from "./dungeonShellRuntimeBridge";
import { touchCharacter } from "./accountRepository"; import { touchCharacter } from "./accountRepository";
import { specializationById } from "../game/specializationCatalog"; import { specializationById } from "../game/specializationCatalog";
import { dungeonContentIsUsable } from "../content/contentManager";
export function installDungeonShellRuntime(): void { export function installDungeonShellRuntime(): void {
registerDungeonShellRuntime({ enterDungeon }); registerDungeonShellRuntime({ enterDungeon });
@@ -22,6 +23,10 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
useShellStore.setState({ notice: "That dungeon is not available yet." }); useShellStore.setState({ notice: "That dungeon is not available yet." });
return false; return false;
} }
if (!dungeonContentIsUsable(dungeonId)) {
useShellStore.setState({ notice: "Install this expedition from Downloads before entering." });
return false;
}
const { session, selectedCharacterId, characters } = useShellStore.getState(); const { session, selectedCharacterId, characters } = useShellStore.getState();
if (!session || !selectedCharacterId) { if (!session || !selectedCharacterId) {
useShellStore.setState({ notice: "Select or create a character first." }); useShellStore.setState({ notice: "Select or create a character first." });
+16 -1
View File
@@ -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 { createDefaultAppearance } from "./characterCatalog";
import { useShellStore } from "./shellStore"; import { useShellStore } from "./shellStore";
import { installDungeonShellRuntime } from "./dungeonShellRuntime"; import { installDungeonShellRuntime } from "./dungeonShellRuntime";
@@ -79,6 +79,10 @@ function prepareSelectedCharacter(): void {
} }
describe("shell store", () => { describe("shell store", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
beforeEach(() => { beforeEach(() => {
useShellStore.setState({ useShellStore.setState({
phase: "login", phase: "login",
@@ -167,6 +171,17 @@ describe("shell store", () => {
expect(useGameStore.getState().distanceTravelled).toBe(42); 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", () => { it("returns from gameplay to the main menu with the same character selected", () => {
prepareSelectedCharacter(); prepareSelectedCharacter();
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id)).toBe(true); expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id)).toBe(true);
+13
View File
@@ -8,6 +8,7 @@ import {
contentPackIsBundled, contentPackIsBundled,
contentPackIsInstalled, contentPackIsInstalled,
contentPackIsUsable, contentPackIsUsable,
dungeonContentIsUsable,
getContentManagerSnapshot, getContentManagerSnapshot,
installContentPack, installContentPack,
removeContentPack, removeContentPack,
@@ -81,6 +82,7 @@ function contentManifest(version: string, objectName: string, bytes: Uint8Array)
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
vi.unstubAllEnvs();
}); });
describe("browser content packs", () => { describe("browser content packs", () => {
@@ -122,6 +124,7 @@ describe("browser content packs", () => {
manifest = contentManifest("content-two", "updated.glb", updatedBytes); manifest = contentManifest("content-two", "updated.glb", updatedBytes);
await checkForContentUpdates(); await checkForContentUpdates();
expect(contentPackIsUsable("characters:test")).toBe(false);
expect(contentPackHasUpdate("characters:test")).toBe(true); expect(contentPackHasUpdate("characters:test")).toBe(true);
corruptUpdate = true; corruptUpdate = true;
@@ -147,4 +150,14 @@ describe("browser content packs", () => {
await expect(removeContentPack("core:ui")).resolves.toBeUndefined(); await expect(removeContentPack("core:ui")).resolves.toBeUndefined();
expect(contentPackIsUsable("core:ui")).toBe(true); 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
View File
@@ -9,7 +9,7 @@ import {
} from "./contentManifest"; } from "./contentManifest";
import { NativeContentStorage } from "./nativeContentStorage"; 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 PRODUCTION_ORIGIN = "https://iwanttoheal.phenomrom.com";
const STATIC_ANDROID_BUNDLED_PACKS = new Set([ const STATIC_ANDROID_BUNDLED_PACKS = new Set([
"core:ui", "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_CACHE_NAME = "healer-man-content-v1";
export const BROWSER_CONTENT_WORKER_URL = "/content-cache-sw.js"; export const BROWSER_CONTENT_WORKER_URL = "/content-cache-sw.js";
const BROWSER_CONTENT_SHA256_HEADER = "x-healer-man-content-sha256";
const BROWSER_CONTENT_BYTES_HEADER = "x-healer-man-content-bytes";
export interface InstalledContentFile { export interface InstalledContentFile {
readonly packId: string; readonly packId: string;
@@ -39,9 +41,20 @@ export interface InstalledContentFile {
readonly extension: string; 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 { interface PersistedContentState {
readonly version: 1; readonly version: 2;
readonly packVersions: Readonly<Record<string, string>>; readonly packs: Readonly<Record<string, InstalledContentPackReceipt>>;
readonly files: Readonly<Record<string, InstalledContentFile>>; readonly files: Readonly<Record<string, InstalledContentFile>>;
} }
@@ -57,7 +70,7 @@ export interface ContentManagerSnapshot {
readonly error: string; readonly error: string;
} }
const EMPTY_STATE: PersistedContentState = { version: 1, packVersions: {}, files: {} }; const EMPTY_STATE: PersistedContentState = { version: 2, packs: {}, files: {} };
let installedState = readInstalledState(); let installedState = readInstalledState();
let snapshot: ContentManagerSnapshot = { let snapshot: ContentManagerSnapshot = {
revision: 0, revision: 0,
@@ -72,16 +85,69 @@ let snapshot: ContentManagerSnapshot = {
}; };
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
let initialization: Promise<void> | null = null; 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 { function safeLocalStorage(): Storage | null {
try { return typeof window === "undefined" ? null : window.localStorage; } catch { return 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 { function readInstalledState(): PersistedContentState {
try { try {
const value = JSON.parse(safeLocalStorage()?.getItem(CONTENT_STATE_KEY) ?? "null") as Partial<PersistedContentState> | null; const value = JSON.parse(safeLocalStorage()?.getItem(CONTENT_STATE_KEY) ?? "null") as unknown;
if (!value || value.version !== 1 || !value.files || !value.packVersions) return EMPTY_STATE; if (!isRecord(value) || value.version !== 2 || !isRecord(value.files) || !isRecord(value.packs)) {
return { version: 1, files: value.files, packVersions: value.packVersions }; 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 { } catch {
return EMPTY_STATE; return EMPTY_STATE;
} }
@@ -116,6 +182,60 @@ function browserCacheStorage(): CacheStorage | null {
return typeof caches === "undefined" ? null : caches; 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> { async function registerBrowserContentWorker(): Promise<void> {
if (Capacitor.isNativePlatform() if (Capacitor.isNativePlatform()
|| import.meta.env.DEV || import.meta.env.DEV
@@ -137,6 +257,19 @@ function extensionForEntry(entry: ContentManifestFile): string {
return match?.[0].split("?")[0].toLowerCase() ?? ".bin"; 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 }>> { function bundledFiles(): Readonly<Record<string, { readonly sha256: string }>> {
if (snapshot.bundledManifest) return snapshot.bundledManifest.files; if (snapshot.bundledManifest) return snapshot.bundledManifest.files;
if (!snapshot.manifest) return {}; if (!snapshot.manifest) return {};
@@ -166,7 +299,9 @@ export function resolveContentUrl(logicalUrl: string): string {
let pathname = logicalUrl; let pathname = logicalUrl;
try { pathname = new URL(logicalUrl, "https://local.invalid").pathname; } catch { /* preserve original */ } try { pathname = new URL(logicalUrl, "https://local.invalid").pathname; } catch { /* preserve original */ }
const installed = installedState.files[pathname]; 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 { export function contentPackIsBundled(packId: string): boolean {
@@ -176,20 +311,66 @@ export function contentPackIsBundled(packId: string): boolean {
} }
export function contentPackIsInstalled(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 { export function contentPackIsUsable(packId: string): boolean {
if (contentPackIsBundled(packId)) return true; return contentPackIsUsableInternal(packId, new Set());
if (!installedState.packVersions[packId]) return false; }
const pack = snapshot.manifest?.packs.find((candidate) => candidate.id === packId);
return pack?.dependencies.every((dependency) => contentPackIsUsable(dependency)) ?? true; /** 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 { export function contentPackHasUpdate(packId: string): boolean {
if (!snapshot.manifest || !contentPackIsInstalled(packId)) return false; if (!snapshot.manifest || !contentPackIsInstalled(packId)) return false;
const pack = snapshot.manifest.packs.find((candidate) => candidate.id === packId); 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) || Boolean(!contentPackIsBundled(packId)
&& pack?.dependencies.some((dependency) => ( && pack?.dependencies.some((dependency) => (
!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency) !contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)
@@ -224,7 +405,11 @@ async function loadBundledManifest(): Promise<void> {
export function initializeContentSystem(): Promise<void> { export function initializeContentSystem(): Promise<void> {
if (!initialization) { if (!initialization) {
initialization = (async () => { initialization = (async () => {
await Promise.all([loadBundledManifest(), registerBrowserContentWorker()]); await Promise.all([
loadBundledManifest(),
registerBrowserContentWorker(),
validateInstalledContentStorage(),
]);
if (typeof navigator === "undefined" || navigator.onLine !== false) void checkForContentUpdates(); if (typeof navigator === "undefined" || navigator.onLine !== false) void checkForContentUpdates();
})(); })();
} }
@@ -268,6 +453,8 @@ async function installBrowserContentPack(
const headers = new Headers(); const headers = new Headers();
const contentType = response.headers.get("content-type"); const contentType = response.headers.get("content-type");
if (contentType) headers.set("content-type", contentType); 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, { await staging.put(browserCacheRequest(logicalPath), new Response(bytes, {
headers, headers,
status: 200, status: 200,
@@ -289,6 +476,7 @@ async function installBrowserContentPack(
if (installed.packId !== packId || latestPaths.has(logicalPath)) continue; if (installed.packId !== packId || latestPaths.has(logicalPath)) continue;
await active.delete(browserCacheRequest(logicalPath)); await active.delete(browserCacheRequest(logicalPath));
delete pendingFiles[logicalPath]; delete pendingFiles[logicalPath];
validatedInstalledFiles.delete(logicalPath);
} }
for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) { for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) {
pendingFiles[logicalPath] = { pendingFiles[logicalPath] = {
@@ -298,11 +486,15 @@ async function installBrowserContentPack(
extension: extensionForEntry(entry), extension: extensionForEntry(entry),
fileUri: absoluteContentUrl(logicalPath), fileUri: absoluteContentUrl(logicalPath),
}; };
validatedInstalledFiles.add(logicalPath);
} }
installedState = { installedState = {
version: 1, version: 2,
files: pendingFiles, files: pendingFiles,
packVersions: { ...installedState.packVersions, [packId]: manifest.version }, packs: {
...installedState.packs,
[packId]: packReceiptForManifest(manifest, packId),
},
}; };
persistInstalledState(); persistInstalledState();
publish({ publish({
@@ -321,6 +513,7 @@ async function installBrowserContentPack(
} }
export async function installContentPack(packId: string): Promise<void> { export async function installContentPack(packId: string): Promise<void> {
await validateInstalledContentStorage();
const manifest = snapshot.manifest ?? await checkForContentUpdates(); const manifest = snapshot.manifest ?? await checkForContentUpdates();
if (!manifest) throw new Error(snapshot.error || "Content server is unavailable."); if (!manifest) throw new Error(snapshot.error || "Content server is unavailable.");
if (!appVersionMeetsMinimum(import.meta.env.VITE_APP_VERSION, manifest.minimumAppVersion)) { 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)); const latestPaths = new Set(filesForContentPack(manifest, packId).map(([logicalPath]) => logicalPath));
for (const [logicalPath, installed] of Object.entries(pendingFiles)) { 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 = { installedState = {
version: 1, version: 2,
files: pendingFiles, files: pendingFiles,
packVersions: { ...installedState.packVersions, [packId]: manifest.version }, packs: {
...installedState.packs,
[packId]: packReceiptForManifest(manifest, packId),
},
}; };
persistInstalledState(); persistInstalledState();
publish({ publish({
@@ -424,9 +635,10 @@ export async function removeContentPack(packId: string): Promise<void> {
delete remainingFiles[logicalPath]; delete remainingFiles[logicalPath];
} }
} }
const packVersions = { ...installedState.packVersions }; targetFiles.forEach(([logicalPath]) => validatedInstalledFiles.delete(logicalPath));
delete packVersions[packId]; const packs = { ...installedState.packs };
installedState = { version: 1, files: remainingFiles, packVersions }; delete packs[packId];
installedState = { version: 2, files: remainingFiles, packs };
persistInstalledState(); persistInstalledState();
publish({ error: "", installedRevision: snapshot.installedRevision + 1 }); 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);
});
});
+18 -2
View File
@@ -9,8 +9,13 @@ import { useManastormStore } from "./manastormStore";
import { import {
activateDungeonEncounterDefinitions, activateDungeonEncounterDefinitions,
advanceDungeonEncounters, advanceDungeonEncounters,
dispatchActiveDungeonEntityEvent,
resetDungeonEncounters, resetDungeonEncounters,
} from "./dungeonEncounterRuntime"; } from "./dungeonEncounterRuntime";
import {
deriveDungeonEncounterCombatEvents,
type DungeonEncounterMobCursor,
} from "./dungeonEncounterCombatEvents";
import { requireDungeonDefinition } from "./dungeonRegistry"; import { requireDungeonDefinition } from "./dungeonRegistry";
import { import {
onlineSessionIsActive, onlineSessionIsActive,
@@ -27,9 +32,11 @@ export function CombatBridge() {
const character = useShellStore((state) => state.activeCharacter); const character = useShellStore((state) => state.activeCharacter);
const gameMode = useGameStore((state) => state.gameMode); const gameMode = useGameStore((state) => state.gameMode);
const activeDungeonId = useGameStore((state) => state.activeDungeonId); const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const activeDifficultyId = useGameStore((state) => state.activeDifficultyId);
const activeRole = useShellStore((state) => state.activeDungeonRole); const activeRole = useShellStore((state) => state.activeDungeonRole);
const encounterResetRevision = useGameStore((state) => state.encounterResetRevision); const encounterResetRevision = useGameStore((state) => state.encounterResetRevision);
const mountedResetRevision = useRef(encounterResetRevision); const mountedResetRevision = useRef(encounterResetRevision);
const encounterMobCursor = useRef<DungeonEncounterMobCursor>({});
useEffect(() => { useEffect(() => {
if (!character) return; if (!character) return;
@@ -44,12 +51,14 @@ export function CombatBridge() {
useEffect(() => { useEffect(() => {
const definition = requireDungeonDefinition(activeDungeonId); const definition = requireDungeonDefinition(activeDungeonId);
activateDungeonEncounterDefinitions(activeDungeonId, definition.encounters ?? []); encounterMobCursor.current = {};
}, [activeDungeonId]); activateDungeonEncounterDefinitions(activeDungeonId, definition.encounters ?? [], activeDifficultyId);
}, [activeDifficultyId, activeDungeonId]);
useEffect(() => { useEffect(() => {
if (mountedResetRevision.current === encounterResetRevision) return; if (mountedResetRevision.current === encounterResetRevision) return;
mountedResetRevision.current = encounterResetRevision; mountedResetRevision.current = encounterResetRevision;
encounterMobCursor.current = {};
useCombatStore.getState().resetEncounter(); useCombatStore.getState().resetEncounter();
resetDungeonEncounters("dungeon", useGameStore.getState().activeDungeonId); resetDungeonEncounters("dungeon", useGameStore.getState().activeDungeonId);
}, [encounterResetRevision]); }, [encounterResetRevision]);
@@ -126,6 +135,13 @@ export function CombatBridge() {
useCombatStore.getState().synchronizeActors(); useCombatStore.getState().synchronizeActors();
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") { if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
const mobs = useCombatStore.getState().mobs; 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); const registeredEntityIds = Object.keys(mobs);
advanceDungeonEncounters(game.activeDungeonId, { advanceDungeonEncounters(game.activeDungeonId, {
kind: "tick", kind: "tick",
@@ -3,9 +3,17 @@ import { applyDungeonEncounterEffect } from "./DungeonEncounterEffectBridge";
import { clearPartyRuntimeRegistry, getPartyRuntimePosition, updatePartyRuntimePosition } from "./partyRuntimeRegistry"; import { clearPartyRuntimeRegistry, getPartyRuntimePosition, updatePartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore"; import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
import { useCombatStore } from "./combatStore";
import {
clearMobRuntimeRegistry,
getMobRuntimePosition,
registerMobRuntime,
} from "./mobRuntimeRegistry";
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
afterEach(() => { afterEach(() => {
clearPartyRuntimeRegistry(); clearPartyRuntimeRegistry();
clearMobRuntimeRegistry();
usePartyStore.setState({ members: [] }); usePartyStore.setState({ members: [] });
}); });
@@ -26,4 +34,34 @@ describe("dungeon encounter effects", () => {
expect(useGameStore.getState().playerPosition).toEqual([12, 3, -8]); expect(useGameStore.getState().playerPosition).toEqual([12, 3, -8]);
expect(getPartyRuntimePosition(allyId)).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");
});
}); });
+15 -1
View File
@@ -11,6 +11,8 @@ import { useCombatStore } from "./combatStore";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
import type { DungeonEncounterEffectEnvelope } from "./dungeonEncounterTypes"; import type { DungeonEncounterEffectEnvelope } from "./dungeonEncounterTypes";
import { publishOnlinePartyTeleport } from "./onlineSessionRuntime"; import { publishOnlinePartyTeleport } from "./onlineSessionRuntime";
import { dungeonDefinitionById } from "./dungeonRegistry";
import { removeMobPosition } from "./mobRuntimeRegistry";
function grantEncounterLoot(lootTableId: string, now: number): boolean { function grantEncounterLoot(lootTableId: string, now: number): boolean {
const table = LOOT_CATALOG.bosses.find((candidate) => candidate.lootTableId === lootTableId); const table = LOOT_CATALOG.bosses.find((candidate) => candidate.lootTableId === lootTableId);
@@ -33,7 +35,7 @@ export function DungeonEncounterEffectBridge() {
} }
export function applyDungeonEncounterEffect( export function applyDungeonEncounterEffect(
{ effect }: DungeonEncounterEffectEnvelope, { dungeonId, effect }: DungeonEncounterEffectEnvelope,
now = Date.now(), now = Date.now(),
): void { ): void {
if (effect.kind === "signal") { if (effect.kind === "signal") {
@@ -49,6 +51,18 @@ export function applyDungeonEncounterEffect(
publishOnlinePartyTeleport(effect.destination, effect.actionId, now); publishOnlinePartyTeleport(effect.destination, effect.actionId, now);
return; 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)) { if (effect.kind === "loot-grant" && !grantEncounterLoot(effect.lootTableId, now)) {
useShellStore.getState().setNotice(`Loot table ${effect.lootTableId} is not packaged for this encounter.`); useShellStore.getState().setNotice(`Loot table ${effect.lootTableId} is not packaged for this encounter.`);
} }
+6 -2
View File
@@ -57,7 +57,11 @@ export function ManastormBridge() {
}, [gameMode]); }, [gameMode]);
useEffect(() => { useEffect(() => {
if (gameMode !== "manastorm" || !nextEncounterId) return; if (
gameMode !== "manastorm"
|| (status !== "portal-ready" && status !== "transitioning")
|| !nextEncounterId
) return;
const nextEncounter = manastormEncounterById(nextEncounterId); const nextEncounter = manastormEncounterById(nextEncounterId);
const packageId = nextEncounter?.assetPackageId; const packageId = nextEncounter?.assetPackageId;
if (!packageId || packageId === encounter?.assetPackageId) return; if (!packageId || packageId === encounter?.assetPackageId) return;
@@ -67,7 +71,7 @@ export function ManastormBridge() {
console.warn(`Could not preload the next Manastorm package ${packageId}.`, error); console.warn(`Could not preload the next Manastorm package ${packageId}.`, error);
}); });
return () => controller.abort(); return () => controller.abort();
}, [encounter?.assetPackageId, gameMode, nextEncounterId]); }, [encounter?.assetPackageId, gameMode, nextEncounterId, status]);
useEffect(() => useCombatStore.subscribe((state, previous) => { useEffect(() => useCombatStore.subscribe((state, previous) => {
if ( if (
+61
View File
@@ -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,
});
});
});
+2 -2
View File
@@ -87,7 +87,7 @@ function sharedWorld(): OnlineSharedWorld {
}; };
} }
function applySharedWorld(world: OnlineSharedWorld): void { export function applySharedWorld(world: OnlineSharedWorld): void {
useCombatStore.setState({ mobs: world.mobs }); useCombatStore.setState({ mobs: world.mobs });
usePartyStore.setState({ usePartyStore.setState({
members: world.partyMembers, members: world.partyMembers,
@@ -103,7 +103,7 @@ function applySharedWorld(world: OnlineSharedWorld): void {
if (world.manastorm) useManastormStore.setState(world.manastorm as never); if (world.manastorm) useManastormStore.setState(world.manastorm as never);
const activeDungeonId = useGameStore.getState().activeDungeonId; const activeDungeonId = useGameStore.getState().activeDungeonId;
if (world.dungeonId === activeDungeonId && world.encounters) { 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
+8
View File
@@ -185,5 +185,13 @@ describe("boss guide model", () => {
}); });
const flowingChaos = guideByDungeon.get("forsaken-abbey")!.bosses.find((boss) => boss.name === "Flowing Chaos")!; const flowingChaos = guideByDungeon.get("forsaken-abbey")!.bosses.find((boss) => boss.name === "Flowing Chaos")!;
expect(flowingChaos.mechanics[0].fidelityLabel).toBe("HealerMan fallback"); 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
View File
@@ -1,4 +1,4 @@
import type { DungeonDefinition, DungeonExpansion } from "./dungeonTypes"; import type { DungeonBossObjective, DungeonDefinition, DungeonExpansion } from "./dungeonTypes";
import { import {
normalizeEnemyAttack, normalizeEnemyAttack,
type EnemyAbilityCondition, 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 === "heal") return `Heals ${target} for ${effect.multiplier}× boss power`;
if (effect.kind === "aura") { 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`; const magnitude = effect.magnitude === undefined ? "" : ` at ${effect.magnitude} strength`;
return `Applies ${titleCase(effect.aura)} to ${target} for ${seconds(effect.durationMs)}${magnitude}`; 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)}`; return `Applies ${titleCase(effect.aura)} to ${target} for ${seconds(effect.durationMs)}`;
} }
if (effect.kind === "control") { 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 === "transform") return `Transforms ${target} into ${titleCase(effect.form)}`;
if (effect.kind === "summon") return `Summons ${effect.count} add${effect.count === 1 ? "" : "s"} (creature ${effect.creatureEntry})`; 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( function bossEntityForObjective(
dungeon: DungeonDefinition, dungeon: DungeonDefinition,
objectiveId: string, objective: DungeonBossObjective,
objectiveName: string,
): readonly [string, PopulationEntityDefinition] | undefined { ): 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) { if (staticSpawn) {
const entity = dungeon.entities[staticSpawn.entityId]; const entity = dungeon.entities[staticSpawn.entityId];
if (entity) return [staticSpawn.entityId, entity]; 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) { for (const pack of dungeon.roamingPacks) {
const member = pack.members.find((candidate) => ( 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) { if (member) {
const entity = dungeon.entities[member.entityId]; const entity = dungeon.entities[member.entityId];
@@ -290,11 +304,46 @@ function bossEntityForObjective(
} }
} }
const wanted = normalizedName(objectiveName); const wanted = normalizedName(objective.name);
return Object.entries(dungeon.entities).find(([, entity]) => ( const named = Object.entries(dungeon.entities).find(([, entity]) => (
entity.kind === "boss" entity.kind === "boss"
&& (normalizedName(entity.name) === wanted || normalizedName(entity.title) === wanted) && (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( function bossGuide(
@@ -325,7 +374,7 @@ export function buildDungeonBossGuide(
): DungeonBossGuide { ): DungeonBossGuide {
const representedEntityIds = new Set<string>(); const representedEntityIds = new Set<string>();
const objectiveBosses = dungeon.bosses.map((objective) => { 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]); if (resolved) representedEntityIds.add(resolved[0]);
return bossGuide(dungeon, objective.id, objective.name, resolved, overrides); return bossGuide(dungeon, objective.id, objective.name, resolved, overrides);
}); });
+9 -3
View File
@@ -59,12 +59,18 @@ describe("named aura combat runtime", () => {
}); });
it("grants learned long-term stat buffs to the full party on load", () => { 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); usePartyStore.getState().initializeParty("default-party-buffs", 8, useCombatStore.getState().health);
useCombatStore.getState().synchronizeActors(); useCombatStore.getState().synchronizeActors();
const playerMaxHealth = useCombatStore.getState().maxHealth; const playerMaxHealth = useCombatStore.getState().maxHealth;
const memberMaxHealth = new Map(usePartyStore.getState().members.map((member) => [member.id, member.maxHealth])); const memberMaxHealth = new Map(usePartyStore.getState().members.map((member) => [member.id, member.maxHealth]));
expect(playerMaxHealth).toBe(407);
useCombatStore.getState().applyDefaultPartyBuffs(20_000); useCombatStore.getState().applyDefaultPartyBuffs(20_000);
const state = useCombatStore.getState(); const state = useCombatStore.getState();
@@ -75,7 +81,7 @@ describe("named aura combat runtime", () => {
definition: expect.objectContaining({ exclusiveGroup: "default-party-buff:fortitude" }), 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) { for (const member of usePartyStore.getState().members) {
expect(member.maxHealth).toBeGreaterThan(memberMaxHealth.get(member.id)!); expect(member.maxHealth).toBeGreaterThan(memberMaxHealth.get(member.id)!);
} }
+1 -1
View File
@@ -150,7 +150,7 @@ export interface AuraDefinition {
/** Explicit non-numeric gameplay state such as stealth, tracking, flight, or water walking. */ /** Explicit non-numeric gameplay state such as stealth, tracking, flight, or water walking. */
readonly utilityTags?: readonly string[]; readonly utilityTags?: readonly string[];
/** Enemy control mechanics ignored while this aura is active. */ /** 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. */ /** Chance from 0 through 1 to reflect an incoming spell. */
readonly reflectSpellChance?: number; readonly reflectSpellChance?: number;
/** Portion of incoming target damage transferred to the aura source. */ /** Portion of incoming target damage transferred to the aura source. */
+14
View File
@@ -237,6 +237,20 @@ describe("combat store", () => {
expect(useCombatStore.getState().maxResource).toBe(necromancerBase.mana); 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", () => { it("uses equipped spell power, stamina, and armor in player combat", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 }); useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
const baseMaxHealth = useCombatStore.getState().maxHealth; const baseMaxHealth = useCombatStore.getState().maxHealth;
+192 -41
View File
@@ -179,6 +179,7 @@ import {
type EnemyAttackDefinition, type EnemyAttackDefinition,
type EnemyControlMechanic, type EnemyControlMechanic,
type EnemyDamageSchool, type EnemyDamageSchool,
type EnemyMeleeAutoCriticalProc,
} from "./enemyAttacks"; } from "./enemyAttacks";
import { import {
creatureRuntimeBaseStats, creatureRuntimeBaseStats,
@@ -294,6 +295,11 @@ export interface MobAura {
readonly sourceAbilityId: string; readonly sourceAbilityId: string;
readonly endsAt: number; readonly endsAt: number;
readonly magnitude?: 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 { export interface MobMagicResistances {
@@ -715,7 +721,10 @@ export interface CombatState {
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number; engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number; advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
tick: (deltaSeconds: number, now?: number) => void; tick: (deltaSeconds: number, now?: number) => void;
resetEncounter: () => void; resetEncounter: (options?: {
readonly preserveDefeated?: boolean;
readonly preserveDefeatedIds?: readonly string[];
}) => void;
allocateTalent: (treeId: string) => boolean; allocateTalent: (treeId: string) => boolean;
resetTalents: () => void; resetTalents: () => void;
setActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, abilityId: string | null) => boolean; setActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, abilityId: string | null) => boolean;
@@ -1354,6 +1363,81 @@ function enemyAttackKind(attack: EnemyAttackDefinition): WotlkAttackKind {
return "spell"; 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( function resolveEnemyDamageResult(
mob: MobCombatState, mob: MobCombatState,
attack: EnemyAttackDefinition, attack: EnemyAttackDefinition,
@@ -1362,6 +1446,7 @@ function resolveEnemyDamageResult(
actorId: AggroActorId, actorId: AggroActorId,
attackRevision: number, attackRevision: number,
now: number, now: number,
bonusCriticalChancePercent = 0,
): CombatResult { ): CombatResult {
const attackKind = enemyAttackKind(attack); const attackKind = enemyAttackKind(attack);
return resolveCombatResult({ return resolveCombatResult({
@@ -1370,7 +1455,7 @@ function resolveEnemyDamageResult(
damageSchool: attack.school, damageSchool: attack.school,
attacker: { attacker: {
level: mob.level, level: mob.level,
criticalChancePercent: 5, criticalChancePercent: 5 + Math.max(0, bonusCriticalChancePercent),
}, },
defender: combatDefenderFromCharacterStats(defender), defender: combatDefenderFromCharacterStats(defender),
rules: { rules: {
@@ -2194,7 +2279,8 @@ function evaluateProcsInWork(
function eligibleTargetIds(state: CombatState, origin: CombatPosition, maxRange: number): string[] { function eligibleTargetIds(state: CombatState, origin: CombatPosition, maxRange: number): string[] {
return nearbyMobRuntimeIds(origin, maxRange).filter((id) => { return nearbyMobRuntimeIds(origin, maxRange).filter((id) => {
const mob = state.mobs[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, sourceAbilityId: attack.id,
endsAt, endsAt,
...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }), ...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }),
...(effect.meleeAutoCriticalProc === undefined
? {}
: { meleeAutoCriticalProc: effect.meleeAutoCriticalProc }),
}, },
]; ];
namedAuras = [...applyCombatAura(namedAuras, { namedAuras = [...applyCombatAura(namedAuras, {
@@ -6633,6 +6722,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}).auras]; }).auras];
} }
} else if (effect.kind === "control") { } 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) { for (const actorId of targets) {
if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue; if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue;
const control = { mechanic: effect.mechanic, endsAt: now + effect.durationMs }; const control = { mechanic: effect.mechanic, endsAt: now + effect.durationMs };
@@ -6823,6 +6930,29 @@ export const useCombatStore = create<CombatState>((set, get) => ({
); );
continue; 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) { if (hit.actorId === PLAYER_AGGRO_ID) {
const current = get(); const current = get();
const defender = applyAuraStats( const defender = applyAuraStats(
@@ -6844,7 +6974,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
hit.actorId, hit.actorId,
hit.attackRevision, hit.attackRevision,
now, now,
bonusCriticalChancePercent,
); );
commitMeleeAutoProc(combatResult.landed);
const auraAdjustedDamage = resolveAuraDamageValue( const auraAdjustedDamage = resolveAuraDamageValue(
combatResult.finalDamage, combatResult.finalDamage,
aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID), 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); const summon = get().summons.find((candidate) => candidate.id === hit.actorId);
if (summon) { if (summon) {
const current = get(); const current = get();
const critical = bonusCriticalChancePercent > 0;
const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1));
commitMeleeAutoProc(true);
const adjusted = resolveAuraDamageValue( const adjusted = resolveAuraDamageValue(
hit.amount, incomingAmount,
aurasForEntity(current.auras, summon.id), aurasForEntity(current.auras, summon.id),
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) }, { direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
); );
@@ -7010,12 +7145,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetActorId: summon.id, targetActorId: summon.id,
abilityId: hit.attack.id, abilityId: hit.attack.id,
school: hit.attack.school, school: hit.attack.school,
rawAmount: hit.amount, rawAmount: incomingAmount,
effectiveAmount: applied, effectiveAmount: applied,
overheal: 0, overheal: 0,
absorbed: absorbed.absorbed, absorbed: absorbed.absorbed,
critical: false, critical,
outcome: applied > 0 ? "hit" : "absorb", outcome: applied > 0 ? (critical ? "crit" : "hit") : "absorb",
}), }),
}); });
appliedDamage += applied; appliedDamage += applied;
@@ -7024,8 +7159,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const onlineActor = onlinePlayerRuntimeActor(hit.actorId); const onlineActor = onlinePlayerRuntimeActor(hit.actorId);
if (onlineActor) { if (onlineActor) {
const current = get(); const current = get();
const critical = bonusCriticalChancePercent > 0;
const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1));
commitMeleeAutoProc(true);
const adjusted = resolveAuraDamageValue( const adjusted = resolveAuraDamageValue(
hit.amount, incomingAmount,
aurasForEntity(current.auras, onlineActor.id), aurasForEntity(current.auras, onlineActor.id),
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) }, { direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
); );
@@ -7052,12 +7190,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetActorId: onlineActor.id, targetActorId: onlineActor.id,
abilityId: hit.attack.id, abilityId: hit.attack.id,
school: hit.attack.school, school: hit.attack.school,
rawAmount: hit.amount, rawAmount: incomingAmount,
effectiveAmount: damaged, effectiveAmount: damaged,
overheal: 0, overheal: 0,
absorbed: absorbed.absorbed, absorbed: absorbed.absorbed,
critical: false, critical,
outcome: damaged > 0 ? "hit" : "absorb", outcome: damaged > 0 ? (critical ? "crit" : "hit") : "absorb",
}), }),
}); });
appliedDamage += damaged; appliedDamage += damaged;
@@ -7090,7 +7228,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
hit.actorId, hit.actorId,
hit.attackRevision, hit.attackRevision,
now, now,
bonusCriticalChancePercent,
); );
commitMeleeAutoProc(combatResult.landed);
const currentAuras = get().auras; const currentAuras = get().auras;
const auraAdjustedDamage = resolveAuraDamageValue( const auraAdjustedDamage = resolveAuraDamageValue(
combatResult.finalDamage, combatResult.finalDamage,
@@ -8035,41 +8175,47 @@ export const useCombatStore = create<CombatState>((set, get) => ({
} }
}, },
resetEncounter: () => set((state) => { resetEncounter: (options = {}) => set((state) => {
resetCombatPresentationSession(); resetCombatPresentationSession();
const preserveDefeatedIds = new Set(options.preserveDefeatedIds ?? []);
for (const mob of Object.values(state.mobs)) { for (const mob of Object.values(state.mobs)) {
if (mob.summonedBy) removeMobPosition(mob.id); if (mob.summonedBy) removeMobPosition(mob.id);
} }
const mobs = Object.fromEntries(Object.entries(state.mobs) const mobs = Object.fromEntries(Object.entries(state.mobs)
.filter(([, mob]) => !mob.summonedBy) .filter(([, mob]) => !mob.summonedBy)
.map(([id, mob]) => [id, { .map(([id, mob]) => {
...mob, const preserveDefeated = mob.dead && (
health: mob.maxHealth, options.preserveDefeated === true || preserveDefeatedIds.has(id)
dead: false, );
pendingLoot: [], return [id, {
looted: false, ...mob,
despawned: false, health: preserveDefeated ? 0 : mob.maxHealth,
diedAt: null, dead: preserveDefeated,
corpseDespawnAt: null, pendingLoot: preserveDefeated ? mob.pendingLoot : [],
experienceGranted: false, looted: preserveDefeated ? mob.looted : false,
statuses: [], despawned: preserveDefeated ? mob.despawned : false,
controlDiminishingReturns: {}, diedAt: preserveDefeated ? mob.diedAt : null,
tauntDiminishingReturns: { applications: 0, resetAt: 0 }, corpseDespawnAt: preserveDefeated ? mob.corpseDespawnAt : null,
auras: [], experienceGranted: preserveDefeated ? mob.experienceGranted : false,
threatByActor: {}, statuses: [],
targetActorId: null, controlDiminishingReturns: {},
forcedTarget: null, tauntDiminishingReturns: { applications: 0, resetAt: 0 },
lastDamageAt: 0, auras: [],
engaged: false, threatByActor: {},
combatPhase: "idle" as const, targetActorId: null,
homePosition: null, forcedTarget: null,
nextAttackAt: 0, lastDamageAt: 0,
abilityReadyAt: {}, engaged: false,
abilityCastCounts: {}, combatPhase: "idle" as const,
activeCast: null, homePosition: null,
schoolLockedUntil: {}, nextAttackAt: 0,
nextWoundAnimationAt: 0, abilityReadyAt: {},
}])); abilityCastCounts: {},
activeCast: null,
schoolLockedUntil: {},
nextWoundAnimationAt: 0,
}] as const;
}));
const auras = syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow()); const auras = syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow());
const maxHealth = auraAdjustedPlayerMaxHealth( const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId, state.classId,
@@ -8116,7 +8262,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
combatEvents: [], combatEvents: [],
actors: {}, actors: {},
lastCombatResult: null, 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.",
),
}; };
}), }),
+38 -6
View File
@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { DUNGEON_MANIFEST } from "./dungeonManifest";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore } from "./combatStore"; import { useCombatStore } from "./combatStore";
import { respawnDefeatedPlayer } from "./deathRespawn"; import { respawnDefeatedPlayer } from "./deathRespawn";
@@ -15,13 +14,22 @@ describe("death respawn", () => {
useWailingEncounterStore.getState().reset(); 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 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().updatePlayerSnapshot({ position: [10, -50, 20], yaw: 0.5, distanceDelta: 30 });
useGameStore.getState().setCameraLookActive(true); useGameStore.getState().setCameraLookActive(true);
useCombatStore.getState().registerMob("respawn-pack:a", { name: "Ravager", maxHealth: 100 }); 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]); 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().selectMob("respawn-pack:a");
useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000); useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000);
expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true); 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; 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); useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth);
expect(useCombatStore.getState().health).toBe(0); expect(useCombatStore.getState().health).toBe(0);
expect(respawnDefeatedPlayer()).toBe(true); expect(respawnDefeatedPlayer()).toBe(true);
expect(useGameStore.getState()).toMatchObject({ expect(useGameStore.getState()).toMatchObject({
playerPosition: DUNGEON_MANIFEST.entrance.footPosition, playerPosition: checkpointSpawn.footPosition,
cameraYaw: DUNGEON_MANIFEST.entrance.yaw, cameraYaw: checkpointSpawn.yaw,
cameraLookActive: false, cameraLookActive: false,
resetRevision: revisionBefore + 1, resetRevision: revisionBefore + 1,
}); });
@@ -45,16 +64,29 @@ describe("death respawn", () => {
health: useCombatStore.getState().maxHealth, health: useCombatStore.getState().maxHealth,
resource: startingResource, resource: startingResource,
selectedTargetId: null, selectedTargetId: null,
playerPosition: DUNGEON_MANIFEST.entrance.footPosition, playerPosition: checkpointSpawn.footPosition,
}); });
expect(useCombatStore.getState().mobs["respawn-pack:a"]).toMatchObject({ expect(useCombatStore.getState().mobs["respawn-pack:a"]).toMatchObject({
health: 100, health: 100,
dead: false, dead: false,
engaged: 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({ expect(useWailingEncounterStore.getState()).toMatchObject({
phase: "locked", phase: "locked",
phaseStartedAt: 0, phaseStartedAt: 0,
flags: { "dungeon-complete": true },
completedObjectiveIds: ["mutanus-spawn"],
checkpointId: "mutanus-spawn",
}); });
expect(respawnDefeatedPlayer()).toBe(false); expect(respawnDefeatedPlayer()).toBe(false);
+26 -9
View File
@@ -1,23 +1,40 @@
import { useCombatStore } from "./combatStore"; import { useCombatStore } from "./combatStore";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
import { resetDungeonEncounters } from "./dungeonEncounterRuntime"; import {
activeDungeonCompletedObjectiveIds,
resetDungeonEncounters,
} from "./dungeonEncounterRuntime";
/** /**
* Atomically returns a defeated player to the dungeon entrance and restores * Atomically returns a defeated player to the saved run spawn and resets the
* the encounter. Keeping this outside the render tree makes the behavior easy * current pull. Defeated enemies and persisted dungeon progress remain clear,
* to verify and prevents a frame where the camera resets but combat stays dead. * matching a wipe instead of silently starting a brand-new dungeon.
*/ */
export function respawnDefeatedPlayer(): boolean { export function respawnDefeatedPlayer(): boolean {
const combat = useCombatStore.getState(); const combat = useCombatStore.getState();
if (combat.health > 0) return false; 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) { if (typeof document !== "undefined" && document.pointerLockElement) {
document.exitPointerLock(); document.exitPointerLock();
} }
useGameStore.getState().resetAtEntrance(); useGameStore.getState().resetAfterDefeat();
resetDungeonEncounters("dungeon", useGameStore.getState().activeDungeonId); resetDungeonEncounters("boss-wipe", dungeonId);
const entrance = useGameStore.getState().playerPosition; const respawnPosition = useGameStore.getState().playerPosition;
combat.resetEncounter(); combat.resetEncounter({ preserveDefeatedIds });
useCombatStore.getState().setPlayerPosition(entrance); useCombatStore.getState().setPlayerPosition(respawnPosition);
return true; return true;
} }
+369 -3
View File
@@ -1,9 +1,18 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import campaign from "../../dungeon-pipeline/dungeon-campaign.json"; import campaign from "../../dungeon-pipeline/dungeon-campaign.json";
import epochInstances from "../../dungeon-pipeline/epoch-five-player-instances.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 generated from "./generated/dungeonCampaignCatalog.json";
import manastormPackages from "./generated/manastormAssetPackages.json"; import manastormPackages from "./generated/manastormAssetPackages.json";
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry"; 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 = [ const SOURCE_NATIVE_DUNGEON_IDS = [
"ahnkahet-old-kingdom", "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", () => { it("runs Wailing Caverns with the complete server population and authored presentation", () => {
const wailing = requireDungeonDefinition("wailing-caverns"); const wailing = requireDungeonDefinition("wailing-caverns");
const runtimePopulation = wailing.staticSpawns.length const runtimePopulation = wailing.staticSpawns.length
@@ -101,9 +417,17 @@ describe("generated dungeon campaign catalog", () => {
const sourcePopulation = source.staticSpawns.length const sourcePopulation = source.staticSpawns.length
+ source.roamingPacks.reduce((total, pack) => total + pack.members.length, 0); + source.roamingPacks.reduce((total, pack) => total + pack.members.length, 0);
const runtimePopulation = runtime.staticSpawns.length 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), ...dungeon.roamingPacks.filter((pack) => "spawnChance" in pack),
]) as unknown as ReadonlyArray<{ readonly spawnChance: number }>; ]) as unknown as ReadonlyArray<{ readonly spawnChance: number }>;
const optionalBosses = generated.definitions.flatMap((dungeon) => ( 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); 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", () => { it("aligns the Deadmines source population to its packaged interior route", () => {
const deadmines = requireDungeonDefinition("deadmines"); const deadmines = requireDungeonDefinition("deadmines");
const firstPack = deadmines.staticSpawns.find( const firstPack = deadmines.staticSpawns.find(
+24
View File
@@ -46,6 +46,30 @@ describe("dungeon combat fidelity integration", () => {
expect(friendlyResult.reason).toBeUndefined(); 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", () => { it("applies only the first two direct-damage pushbacks and ignores fully absorbed hits", () => {
registerTarget(); registerTarget();
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 }).ok).toBe(true); 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 }]);
});
});
+70
View File
@@ -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) });
}
+300 -2
View File
@@ -2,7 +2,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
activateDungeonEncounterDefinitions, activateDungeonEncounterDefinitions,
activeDungeonEncounterRevision, activeDungeonEncounterRevision,
activeDungeonRunIsComplete,
createDungeonEncounterSnapshot, createDungeonEncounterSnapshot,
dispatchActiveDungeonEntityEvent,
dispatchActiveDungeonInteraction, dispatchActiveDungeonInteraction,
dispatchDungeonEncounterSignal, dispatchDungeonEncounterSignal,
drainDungeonEncounterEffects, drainDungeonEncounterEffects,
@@ -12,6 +14,7 @@ import {
importDungeonEncounterSnapshots, importDungeonEncounterSnapshots,
reduceDungeonEncounter, reduceDungeonEncounter,
registerDungeonEncounterEffectHandler, registerDungeonEncounterEffectHandler,
resetDungeonEncounters,
resetDungeonEncounterSnapshot, resetDungeonEncounterSnapshot,
subscribeDungeonEncounterSnapshots, subscribeDungeonEncounterSnapshots,
validateDungeonEncounterDefinition, validateDungeonEncounterDefinition,
@@ -104,14 +107,32 @@ const MANAGER_B = {
{ id: "switch-cue", kind: "emit-signal", signalId: "test:switch-opened" }, { 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; } 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(() => { afterEach(() => {
activateDungeonEncounterDefinitions("wailing-caverns", [WAILING_NARALEX_ENCOUNTER]); activateDungeonEncounterDefinitions("wailing-caverns", [WAILING_NARALEX_ENCOUNTER]);
useWailingEncounterStore.getState().reset(); useWailingEncounterStore.getState().reset();
drainDungeonEncounterEffects("manager-a"); drainDungeonEncounterEffects("manager-a");
drainDungeonEncounterEffects("manager-b"); drainDungeonEncounterEffects("manager-b");
drainDungeonEncounterEffects("manager-difficulty");
}); });
describe("dungeon encounter reducer", () => { 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", () => { it("rejects duplicate action ids at definition validation", () => {
expect(() => validateDungeonEncounterDefinition({ expect(() => validateDungeonEncounterDefinition({
id: "test:invalid", id: "test:invalid",
@@ -210,9 +282,173 @@ describe("dungeon encounter reducer", () => {
}], }],
})).toThrow(/duplicate action id same/); })).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", () => { 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", () => { it("isolates shared signals to the active dungeon", () => {
activateDungeonEncounterDefinitions("manager-a", [MANAGER_A]); activateDungeonEncounterDefinitions("manager-a", [MANAGER_A]);
dispatchDungeonEncounterSignal("test:shared", 1_000); dispatchDungeonEncounterSignal("test:shared", 1_000);
@@ -224,8 +460,34 @@ describe("active dungeon encounter manager", () => {
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.counters.signals).toBe(1); 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", () => { it("dispatches interactions, exposes effects, snapshots, hydration, and subscriptions", () => {
activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]); activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]);
resetDungeonEncounters("dungeon", "manager-b");
const subscriber = vi.fn(); const subscriber = vi.fn();
const effectHandler = vi.fn(); const effectHandler = vi.fn();
const unsubscribe = subscribeDungeonEncounterSnapshots(subscriber); const unsubscribe = subscribeDungeonEncounterSnapshots(subscriber);
@@ -238,10 +500,9 @@ describe("active dungeon encounter manager", () => {
actionId: "switch-cue", actionId: "switch-cue",
signalId: "test:switch-opened", signalId: "test:switch-opened",
}); });
expect(effectHandler).toHaveBeenCalled(); expect(effectHandler).toHaveBeenCalledTimes(3);
expect(subscriber).toHaveBeenCalled(); expect(subscriber).toHaveBeenCalled();
expect(activeDungeonEncounterRevision()).toBeGreaterThan(revisionBefore); expect(activeDungeonEncounterRevision()).toBeGreaterThan(revisionBefore);
expect(drainDungeonEncounterEffects("manager-b")).toHaveLength(3);
expect(drainDungeonEncounterEffects("manager-b")).toEqual([]); expect(drainDungeonEncounterEffects("manager-b")).toEqual([]);
const exported = exportDungeonEncounterSnapshots("manager-b"); const exported = exportDungeonEncounterSnapshots("manager-b");
@@ -267,7 +528,44 @@ describe("active dungeon encounter manager", () => {
expect(importDungeonEncounterSnapshots("manager-b", [hydrated])).toEqual([MANAGER_B.id]); expect(importDungeonEncounterSnapshots("manager-b", [hydrated])).toEqual([MANAGER_B.id]);
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.phase).toBe("restored"); 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(); unregisterEffect();
unsubscribe(); 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([]);
});
}); });
+205 -69
View File
@@ -20,6 +20,15 @@ function unique(values: readonly string[]): readonly string[] {
return Object.freeze([...new Set(values)].sort()); 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( export function createDungeonEncounterSnapshot(
definition: DungeonEncounterDefinition, definition: DungeonEncounterDefinition,
now = 0, now = 0,
@@ -53,12 +62,37 @@ export function validateDungeonEncounterDefinition(definition: DungeonEncounterD
validateStableId(definition.id, "Encounter"); validateStableId(definition.id, "Encounter");
validateStableId(definition.dungeonId, `Encounter ${definition.id} dungeon`); validateStableId(definition.dungeonId, `Encounter ${definition.id} dungeon`);
validateStableId(definition.initialPhase, `Encounter ${definition.id} initial phase`); 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 ruleIds = new Set<string>();
const actionIds = new Set<string>(); const actionIds = new Set<string>();
for (const rule of definition.rules) { for (const rule of definition.rules) {
validateStableId(rule.id, `Encounter ${definition.id} rule`); validateStableId(rule.id, `Encounter ${definition.id} rule`);
if (ruleIds.has(rule.id)) throw new Error(`Encounter ${definition.id} has duplicate rule id ${rule.id}`); if (ruleIds.has(rule.id)) throw new Error(`Encounter ${definition.id} has duplicate rule id ${rule.id}`);
ruleIds.add(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) { for (const action of rule.actions) {
validateStableId(action.id, `Encounter ${definition.id} action`); validateStableId(action.id, `Encounter ${definition.id} action`);
if (actionIds.has(action.id)) { 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 === "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 === "flag") return (snapshot.flags[guard.flagId] ?? false) === guard.equals;
if (guard.kind === "objective-complete") { if (guard.kind === "objective-complete") {
return snapshot.completedObjectiveIds.includes(guard.objectiveId) === guard.equals; return snapshot.completedObjectiveIds.includes(guard.objectiveId) === guard.equals;
@@ -90,33 +135,55 @@ function guardMatches(snapshot: DungeonEncounterSnapshot, guard: DungeonEncounte
return current <= guard.value; return current <= guard.value;
} }
type DungeonEncounterTriggerMatch = "event" | "timeout" | false;
function triggerMatches( function triggerMatches(
snapshot: DungeonEncounterSnapshot, snapshot: DungeonEncounterSnapshot,
trigger: DungeonEncounterRule["trigger"], trigger: DungeonEncounterRule["trigger"],
event: DungeonEncounterEvent, event: DungeonEncounterEvent,
): boolean { ): DungeonEncounterTriggerMatch {
if (trigger.kind === "tick") return event.kind === "tick"; if (trigger.kind === "tick") return event.kind === "tick" ? "event" : false;
if (trigger.kind === "signal") return event.kind === "signal" && event.signalId === trigger.signalId; if (trigger.kind === "signal") return event.kind === "signal" && event.signalId === trigger.signalId ? "event" : false;
if (trigger.kind === "entity-engaged" || trigger.kind === "entity-death") { 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") { if (trigger.kind === "entity-health") {
return event.kind === "entity-health" return event.kind === "entity-health"
&& event.entityId === trigger.entityId && 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") { 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; if (event.kind !== "tick") return false;
const registered = new Set(event.facts?.registeredEntityIds ?? []); const registered = new Set(event.facts?.registeredEntityIds ?? []);
const dead = new Set(event.facts?.deadEntityIds ?? []); const dead = new Set(event.facts?.deadEntityIds ?? []);
const allDead = trigger.entityIds.length > 0 const allDead = trigger.entityIds.length > 0
&& trigger.entityIds.every((id) => registered.has(id) && dead.has(id)); && 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 const timedOut = trigger.timeoutMs !== undefined
&& hasAuthoritativeFacts
&& !hasRegisteredLivingTarget
&& event.now - snapshot.phaseStartedAt >= trigger.timeoutMs; && 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 { function ruleAlreadyConsumed(snapshot: DungeonEncounterSnapshot, rule: DungeonEncounterRule): boolean {
@@ -193,7 +260,7 @@ function applyAction(
} }
return { return {
snapshot: consume snapshot: consume
? { ...next, consumedActionIds: unique([...next.consumedActionIds, action.id]) } ? { ...next, consumedActionIds: orderedUnique([...next.consumedActionIds, action.id]) }
: next, : next,
effect: actionEffect(action), effect: actionEffect(action),
}; };
@@ -203,6 +270,7 @@ export function reduceDungeonEncounter(
definition: DungeonEncounterDefinition, definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot, snapshot: DungeonEncounterSnapshot,
event: DungeonEncounterEvent, event: DungeonEncounterEvent,
context: DungeonEncounterRuntimeContext = {},
): DungeonEncounterReduction { ): DungeonEncounterReduction {
if (snapshot.encounterId !== definition.id) { if (snapshot.encounterId !== definition.id) {
throw new Error(`Snapshot ${snapshot.encounterId} does not belong to encounter ${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); .sort((left, right) => (right.rule.priority ?? 0) - (left.rule.priority ?? 0) || left.index - right.index);
for (const { rule } of rules) { for (const { rule } of rules) {
if (ruleAlreadyConsumed(next, rule)) continue; if (ruleAlreadyConsumed(next, rule)) continue;
if (!triggerMatches(next, rule.trigger, event)) continue; const triggerMatch = triggerMatches(next, rule.trigger, event);
if (!rule.guards?.every((guard) => guardMatches(next, guard)) && rule.guards?.length) continue; 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; let fired = false;
for (const action of rule.actions) { for (const action of rule.actions) {
const applied = applyAction(next, action, event.now, rule.once ?? true); 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))); return Object.freeze(snapshot.consumedActionIds.filter((id) => retained.has(id)));
} }
function retainedStateKeys(definition: DungeonEncounterDefinition): { function reconstructedPersistentState(
readonly flags: ReadonlySet<string>; definition: DungeonEncounterDefinition,
readonly counters: ReadonlySet<string>; snapshot: DungeonEncounterSnapshot,
readonly worldObjects: ReadonlySet<string>; initial: DungeonEncounterSnapshot,
readonly transitions: ReadonlySet<string>; ): {
readonly objectives: ReadonlySet<string>; readonly flags: Readonly<Record<string, boolean>>;
readonly checkpoints: 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"); .filter((action) => action.persistence === "checkpoint" || action.persistence === "run");
return { const flags = { ...initial.flags };
flags: new Set(actions.filter((action) => action.kind === "set-flag").map((action) => action.flagId)), const counters = { ...initial.counters };
counters: new Set(actions const worldObjectStates = { ...initial.worldObjectStates };
.filter((action) => action.kind === "set-counter" || action.kind === "increment-counter") const transitionStates = { ...initial.transitionStates };
.map((action) => action.counterId)), const completedObjectiveIds = new Set<string>();
worldObjects: new Set(actions let checkpointId: string | null = null;
.filter((action) => action.kind === "set-world-object-state") for (const action of actions) {
.map((action) => action.objectId)), if (action.kind === "set-flag") flags[action.flagId] = action.value;
transitions: new Set(actions else if (action.kind === "set-counter") counters[action.counterId] = action.value;
.filter((action) => action.kind === "set-transition-active") else if (action.kind === "increment-counter") {
.map((action) => action.transitionId)), counters[action.counterId] = (counters[action.counterId] ?? 0) + (action.amount ?? 1);
objectives: new Set(actions } else if (action.kind === "set-world-object-state") {
.filter((action) => action.kind === "complete-objective") worldObjectStates[action.objectId] = action.state;
.map((action) => action.objectiveId)), } else if (action.kind === "set-transition-active") {
checkpoints: actions.some((action) => action.kind === "set-checkpoint"), transitionStates[action.transitionId] = action.active;
}; } else if (action.kind === "complete-objective") {
} completedObjectiveIds.add(action.objectiveId);
} else if (action.kind === "set-checkpoint") {
function retainedRecord<T>( checkpointId = action.checkpointId;
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];
} }
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( export function resetDungeonEncounterSnapshot(
@@ -297,27 +377,18 @@ export function resetDungeonEncounterSnapshot(
const phase = scope === "boss-wipe" const phase = scope === "boss-wipe"
? definition.resetPolicy?.bossWipePhase ?? definition.initialPhase ? definition.resetPolicy?.bossWipePhase ?? definition.initialPhase
: definition.resetPolicy?.checkpointPhase ?? definition.initialPhase; : definition.resetPolicy?.checkpointPhase ?? definition.initialPhase;
const retained = retainedStateKeys(definition); const retained = reconstructedPersistentState(definition, snapshot, initial);
return Object.freeze({ return Object.freeze({
...initial, ...initial,
phase, phase,
phaseStartedAt: finiteTime(now), phaseStartedAt: finiteTime(now),
flags: retainedRecord(initial.flags, snapshot.flags, retained.flags), flags: retained.flags,
counters: retainedRecord(initial.counters, snapshot.counters, retained.counters), counters: retained.counters,
worldObjectStates: retainedRecord( worldObjectStates: retained.worldObjectStates,
initial.worldObjectStates, transitionStates: retained.transitionStates,
snapshot.worldObjectStates, completedObjectiveIds: retained.completedObjectiveIds,
retained.worldObjects,
),
transitionStates: retainedRecord(
initial.transitionStates,
snapshot.transitionStates,
retained.transitions,
),
completedObjectiveIds: Object.freeze(snapshot.completedObjectiveIds
.filter((id) => retained.objectives.has(id))),
consumedActionIds: retainedActionIds(definition, snapshot, scope), 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; readonly reset: (scope: DungeonEncounterResetScope, now?: number) => void;
/** Hydrates authoritative online/save state without involving renderer state. */ /** Hydrates authoritative online/save state without involving renderer state. */
readonly replaceSnapshot?: (snapshot: DungeonEncounterSnapshot) => void; readonly replaceSnapshot?: (snapshot: DungeonEncounterSnapshot) => void;
/** Updates ephemeral run context without polluting serialized snapshots. */
readonly setContext?: (context: DungeonEncounterRuntimeContext) => void;
} }
const providers = new Map<string, DungeonEncounterProvider>(); const providers = new Map<string, DungeonEncounterProvider>();
@@ -337,6 +410,14 @@ const pendingEffects: DungeonEncounterEffectEnvelope[] = [];
const effectHandlers = new Set<(value: DungeonEncounterEffectEnvelope) => void>(); const effectHandlers = new Set<(value: DungeonEncounterEffectEnvelope) => void>();
let activeDungeonId: string | null = null; 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( function queueProviderEffects(
provider: DungeonEncounterProvider, provider: DungeonEncounterProvider,
effects: readonly DungeonEncounterEffect[], effects: readonly DungeonEncounterEffect[],
@@ -347,8 +428,11 @@ function queueProviderEffects(
encounterId: provider.encounterId, encounterId: provider.encounterId,
effect, effect,
}); });
pendingEffects.push(value); if (effectHandlers.size) {
for (const handler of effectHandlers) handler(value); for (const handler of effectHandlers) handler(value);
} else {
pendingEffects.push(value);
}
} }
return effects; return effects;
} }
@@ -416,6 +500,22 @@ export function dispatchActiveDungeonInteraction(
return Object.freeze(effects); 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( export function advanceDungeonEncounters(
dungeonId: string, dungeonId: string,
event: Extract<DungeonEncounterEvent, { readonly kind: "tick" }>, event: Extract<DungeonEncounterEvent, { readonly kind: "tick" }>,
@@ -435,6 +535,7 @@ export function resetDungeonEncounters(
dungeonId?: string, dungeonId?: string,
now = 0, now = 0,
): void { ): void {
clearPendingDungeonEncounterEffects(dungeonId);
for (const provider of providers.values()) { for (const provider of providers.values()) {
if (!dungeonId || provider.dungeonId === dungeonId) provider.reset(scope, now); if (!dungeonId || provider.dungeonId === dungeonId) provider.reset(scope, now);
} }
@@ -449,7 +550,11 @@ export function resetDungeonEncounters(
export function activateDungeonEncounterDefinitions( export function activateDungeonEncounterDefinitions(
dungeonId: string, dungeonId: string,
definitions: readonly DungeonEncounterDefinition[], definitions: readonly DungeonEncounterDefinition[],
difficultyId?: string,
): void { ): void {
if (activeDungeonId && activeDungeonId !== dungeonId) {
clearPendingDungeonEncounterEffects(activeDungeonId);
}
activeDungeonId = dungeonId; activeDungeonId = dungeonId;
for (const definition of definitions) { for (const definition of definitions) {
validateDungeonEncounterDefinition(definition); validateDungeonEncounterDefinition(definition);
@@ -459,16 +564,18 @@ export function activateDungeonEncounterDefinitions(
const existing = providers.get(definition.id); const existing = providers.get(definition.id);
if (existing) { if (existing) {
if (existing.dungeonId !== dungeonId) throw new Error(`Encounter ${definition.id} has conflicting dungeon owners`); if (existing.dungeonId !== dungeonId) throw new Error(`Encounter ${definition.id} has conflicting dungeon owners`);
existing.setContext?.({ difficultyId });
continue; continue;
} }
let snapshot = createDungeonEncounterSnapshot(definition); let snapshot = createDungeonEncounterSnapshot(definition);
let context: DungeonEncounterRuntimeContext = { difficultyId };
const provider: DungeonEncounterProvider = { const provider: DungeonEncounterProvider = {
definition, definition,
encounterId: definition.id, encounterId: definition.id,
dungeonId, dungeonId,
getSnapshot: () => snapshot, getSnapshot: () => snapshot,
dispatch: (event) => { dispatch: (event) => {
const result = reduceDungeonEncounter(definition, snapshot, event); const result = reduceDungeonEncounter(definition, snapshot, event, context);
snapshot = result.snapshot; snapshot = result.snapshot;
return result.effects; return result.effects;
}, },
@@ -481,6 +588,9 @@ export function activateDungeonEncounterDefinitions(
} }
snapshot = cloneEncounterSnapshot(value); snapshot = cloneEncounterSnapshot(value);
}, },
setContext: (value) => {
context = Object.freeze({ ...value });
},
}; };
providers.set(definition.id, provider); providers.set(definition.id, provider);
} }
@@ -501,7 +611,7 @@ function cloneEncounterSnapshot(value: DungeonEncounterSnapshot): DungeonEncount
transitionStates: Object.freeze({ ...value.transitionStates }), transitionStates: Object.freeze({ ...value.transitionStates }),
activeSpawnGroupIds: unique(value.activeSpawnGroupIds), activeSpawnGroupIds: unique(value.activeSpawnGroupIds),
completedObjectiveIds: unique(value.completedObjectiveIds), completedObjectiveIds: unique(value.completedObjectiveIds),
consumedActionIds: unique(value.consumedActionIds), consumedActionIds: orderedUnique(value.consumedActionIds),
revision: Math.max(0, Math.trunc(value.revision)), revision: Math.max(0, Math.trunc(value.revision)),
}); });
} }
@@ -542,13 +652,14 @@ export function exportDungeonEncounterCheckpointSnapshots(
export function importDungeonEncounterSnapshots( export function importDungeonEncounterSnapshots(
dungeonId: string, dungeonId: string,
snapshots: readonly DungeonEncounterSnapshot[], snapshots: readonly DungeonEncounterSnapshot[],
options: { readonly authoritative?: boolean } = {},
): readonly string[] { ): readonly string[] {
const imported: string[] = []; const imported: string[] = [];
for (const raw of snapshots) { for (const raw of snapshots) {
const provider = providers.get(raw.encounterId); const provider = providers.get(raw.encounterId);
if (!provider || provider.dungeonId !== dungeonId || !provider.replaceSnapshot) continue; if (!provider || provider.dungeonId !== dungeonId || !provider.replaceSnapshot) continue;
const snapshot = cloneEncounterSnapshot(raw); const snapshot = cloneEncounterSnapshot(raw);
if (snapshot.revision < provider.getSnapshot().revision) continue; if (!options.authoritative && snapshot.revision < provider.getSnapshot().revision) continue;
provider.replaceSnapshot(snapshot); provider.replaceSnapshot(snapshot);
imported.push(snapshot.encounterId); imported.push(snapshot.encounterId);
} }
@@ -567,7 +678,7 @@ export function drainDungeonEncounterEffects(
return Object.freeze(drained); 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( export function registerDungeonEncounterEffectHandler(
handler: (value: DungeonEncounterEffectEnvelope) => void, handler: (value: DungeonEncounterEffectEnvelope) => void,
): () => void { ): () => void {
@@ -598,3 +709,28 @@ export function activeDungeonEncounterSpawnGroupIds(): readonly string[] {
return Object.freeze([...new Set(exportDungeonEncounterSnapshots() return Object.freeze([...new Set(exportDungeonEncounterSnapshots()
.flatMap((snapshot) => snapshot.activeSpawnGroupIds))].sort()); .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,
);
}
+24 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { activeEncounterSpawnGroupSpawns } from "./dungeonEncounterSpawns"; import {
activeEncounterSpawnGroupSpawns,
suppressCompletedDungeonPopulation,
} from "./dungeonEncounterSpawns";
const spawn = (id: string, spawnMask: number) => ({ const spawn = (id: string, spawnMask: number) => ({
id, id,
@@ -20,4 +23,24 @@ describe("encounter spawn groups", () => {
{ id: "wave-a", spawns: [spawn("one", 1), spawn("two", 2)] }, { id: "wave-a", spawns: [spawn("one", 1), spawn("two", 2)] },
], ["wave-a"], 2).map((entry) => entry.id)).toEqual(["two"]); ], ["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"]);
});
}); });
+26 -1
View File
@@ -1,4 +1,8 @@
import { spawnMatchesDifficulty, type StaticMobSpawnDefinition } from "./mobPopulation"; import {
spawnMatchesDifficulty,
type MobPackDefinition,
type StaticMobSpawnDefinition,
} from "./mobPopulation";
import type { DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes"; import type { DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
export function activeEncounterSpawnGroupSpawns( export function activeEncounterSpawnGroupSpawns(
@@ -20,3 +24,24 @@ export function activeEncounterSpawnGroupSpawns(
return true; 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 }] : [];
}),
};
}
+31 -2
View File
@@ -1,6 +1,8 @@
import type { Vector3Tuple } from "./dungeonTypes"; 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 DungeonEncounterResetScope = "boss-wipe" | "checkpoint" | "dungeon";
export type DungeonEncounterValue = string | number | boolean; export type DungeonEncounterValue = string | number | boolean;
@@ -52,12 +54,17 @@ export type DungeonEncounterTrigger =
| { | {
readonly kind: "entities-dead"; readonly kind: "entities-dead";
readonly entityIds: readonly string[]; 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; readonly timeoutMs?: number;
}; };
export type DungeonEncounterGuard = export type DungeonEncounterGuard =
| { readonly kind: "phase"; readonly phases: readonly string[] } | { 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: "flag"; readonly flagId: string; readonly equals: boolean }
| { | {
readonly kind: "counter"; readonly kind: "counter";
@@ -116,12 +123,34 @@ export interface DungeonEncounterResetPolicy {
readonly checkpointPhase?: string; 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 { export interface DungeonEncounterDefinition {
readonly id: string; readonly id: string;
readonly dungeonId: string; readonly dungeonId: string;
readonly initialPhase: string; readonly initialPhase: string;
readonly initialState?: DungeonEncounterInitialState; readonly initialState?: DungeonEncounterInitialState;
readonly resetPolicy?: DungeonEncounterResetPolicy; readonly resetPolicy?: DungeonEncounterResetPolicy;
readonly source?: DungeonEncounterSourceReview;
readonly rules: readonly DungeonEncounterRule[]; readonly rules: readonly DungeonEncounterRule[];
} }
+89 -1
View File
@@ -7,6 +7,18 @@ import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import { gunzipSync, strFromU8 } from "fflate"; import { gunzipSync, strFromU8 } from "fflate";
import { DEFAULT_DUNGEON_ID as DEFAULT_DUNGEON_KEY } from "./dungeonDefaults"; import { DEFAULT_DUNGEON_ID as DEFAULT_DUNGEON_KEY } from "./dungeonDefaults";
import { withRunewakerEncounterContent } from "./runewakerEncounterCatalog"; 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"; export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults";
@@ -60,6 +72,45 @@ function enrichAuthoredDefinition(
.filter(([, entity]) => entity.kind === "boss") .filter(([, entity]) => entity.kind === "boss")
.map(([id]) => id), .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 { return {
...authored, ...authored,
entities: { entities: {
@@ -76,6 +127,15 @@ function enrichAuthoredDefinition(
pack.members.some((member) => authoredBossEntityIds.has(member.entityId)) 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}`); 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])[] = [ const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
[authoredWailingCaverns.id, authoredWailingCaverns], [authoredWailingCaverns.id, authoredWailingCaverns],
[FORSAKEN_ABBEY_DEFINITION.id, FORSAKEN_ABBEY_DEFINITION], [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( export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = Object.freeze(
Object.fromEntries(dungeonRegistryEntries.map( Object.fromEntries(dungeonRegistryEntries.map(
([id, definition]) => [id, withRunewakerEncounterContent(definition)] as const, ([id, definition]) => [
id,
applyDungeonContentAdapters(definition),
] as const,
)), )),
); );
+13
View File
@@ -40,4 +40,17 @@ describe("dungeon run checkpoints", () => {
savedAt: 1, savedAt: 1,
})).toBeNull(); })).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();
});
}); });
+4 -1
View File
@@ -2,7 +2,10 @@ import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
import { DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION } from "./dungeonEncounterTypes"; import { DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION } from "./dungeonEncounterTypes";
import type { Vector3Tuple, WorldSpawn } from "./dungeonTypes"; 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 { export interface DungeonRunCheckpoint {
readonly version: typeof DUNGEON_RUN_CHECKPOINT_VERSION; readonly version: typeof DUNGEON_RUN_CHECKPOINT_VERSION;
+11 -3
View File
@@ -12,10 +12,12 @@ import { useManastormAdminStore } from "./manastormAdminStore";
import type { GmReviewTarget } from "./gmContentReview"; import type { GmReviewTarget } from "./gmContentReview";
import { import {
activateDungeonEncounterDefinitions, activateDungeonEncounterDefinitions,
exportDungeonEncounterSnapshots,
importDungeonEncounterSnapshots, importDungeonEncounterSnapshots,
resetDungeonEncounters, resetDungeonEncounters,
} from "./dungeonEncounterRuntime"; } from "./dungeonEncounterRuntime";
import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes"; import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
import { dungeonContentIsUsable } from "../content/contentManager";
export interface DungeonSessionOptions { export interface DungeonSessionOptions {
readonly gameMode?: GameMode; readonly gameMode?: GameMode;
@@ -39,13 +41,17 @@ export function activateDungeonSession(
options: DungeonSessionOptions = {}, options: DungeonSessionOptions = {},
): boolean { ): boolean {
const definition = dungeonDefinitionById(dungeonId); const definition = dungeonDefinitionById(dungeonId);
if (!definition || !dungeonCanEnter(dungeonId)) { if (!definition || !dungeonCanEnter(dungeonId) || !dungeonContentIsUsable(dungeonId)) {
return false; return false;
} }
const previousDefinition = dungeonDefinitionById(useGameStore.getState().activeDungeonId); const previousDefinition = dungeonDefinitionById(useGameStore.getState().activeDungeonId);
if (previousDefinition) releaseDungeonGltfCache(previousDefinition); if (previousDefinition) releaseDungeonGltfCache(previousDefinition);
activateDungeonEncounterDefinitions(dungeonId, definition.encounters ?? []); activateDungeonEncounterDefinitions(
dungeonId,
definition.encounters ?? [],
difficultyId ?? definition.defaultDifficultyId,
);
resetDungeonEncounters("dungeon", dungeonId); resetDungeonEncounters("dungeon", dungeonId);
clearMobRuntimeRegistry(); clearMobRuntimeRegistry();
clearPartyRuntimeRegistry(); clearPartyRuntimeRegistry();
@@ -91,7 +97,7 @@ export function activateDungeonSession(
gmReviewTarget: options.gmReviewTarget, gmReviewTarget: options.gmReviewTarget,
}); });
if (options.encounterSnapshots?.length) { if (options.encounterSnapshots?.length) {
importDungeonEncounterSnapshots(dungeonId, options.encounterSnapshots); importDungeonEncounterSnapshots(dungeonId, options.encounterSnapshots, { authoritative: true });
} }
useCombatStore.getState().setPlayerPosition(useGameStore.getState().playerPosition); useCombatStore.getState().setPlayerPosition(useGameStore.getState().playerPosition);
return true; return true;
@@ -103,11 +109,13 @@ export function activateDungeonSession(
*/ */
export function restartActiveDungeonSession(): boolean { export function restartActiveDungeonSession(): boolean {
const game = useGameStore.getState(); const game = useGameStore.getState();
const encounterSnapshots = exportDungeonEncounterSnapshots(game.activeDungeonId);
return activateDungeonSession(game.activeDungeonId, game.activeDifficultyId, { return activateDungeonSession(game.activeDungeonId, game.activeDifficultyId, {
gameMode: game.gameMode, gameMode: game.gameMode,
spawn: game.activeSpawn, spawn: game.activeSpawn,
gmTestMode: game.gmTestMode, gmTestMode: game.gmTestMode,
gmReviewTarget: game.activeGmReviewTarget, gmReviewTarget: game.activeGmReviewTarget,
preserveCharacter: true, preserveCharacter: true,
encounterSnapshots,
}); });
} }
+29 -6
View File
@@ -7,12 +7,16 @@ import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
import { DEFAULT_MANASTORM_ADMIN_CONFIG } from "./manastormAdminConfig"; import { DEFAULT_MANASTORM_ADMIN_CONFIG } from "./manastormAdminConfig";
import { useManastormAdminStore } from "./manastormAdminStore"; import { useManastormAdminStore } from "./manastormAdminStore";
import { dungeonEncounterSnapshot } from "./dungeonEncounterRuntime"; import {
dungeonEncounterSnapshot,
importDungeonEncounterSnapshots,
} from "./dungeonEncounterRuntime";
import { WAILING_NARALEX_ENCOUNTER, useWailingEncounterStore } from "./wailingCavernsEncounter"; import { WAILING_NARALEX_ENCOUNTER, useWailingEncounterStore } from "./wailingCavernsEncounter";
describe("dungeon session asset eviction", () => { describe("dungeon session asset eviction", () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllEnvs();
useManastormAdminStore.setState({ config: DEFAULT_MANASTORM_ADMIN_CONFIG, draft: DEFAULT_MANASTORM_ADMIN_CONFIG, dirty: false }); useManastormAdminStore.setState({ config: DEFAULT_MANASTORM_ADMIN_CONFIG, draft: DEFAULT_MANASTORM_ADMIN_CONFIG, dirty: false });
useGameStore.getState().activateDungeon("wailing-caverns"); useGameStore.getState().activateDungeon("wailing-caverns");
}); });
@@ -35,11 +39,26 @@ describe("dungeon session asset eviction", () => {
expect(clear).not.toHaveBeenCalled(); expect(clear).not.toHaveBeenCalled();
}); });
it("restarts the active dungeon in place while preserving its party", () => { it("does not activate a direct production session before its dungeon pack is usable", () => {
const active = dungeonDefinitionById("deadmines")!; vi.stubEnv("DEV", false);
useGameStore.getState().activateDungeon(active.id); 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"); usePartyStore.getState().initializeParty("retry-party", 20, 600, "healer");
const members = usePartyStore.getState().members.map((member) => member.id); 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 revision = useGameStore.getState().sessionRevision;
const clear = vi.spyOn(useGLTF, "clear").mockImplementation(() => undefined); 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(clear.mock.calls.map(([url]) => url)).toEqual(dungeonGltfUrls(active));
expect(useGameStore.getState().sessionRevision).toBe(revision + 1); expect(useGameStore.getState().sessionRevision).toBe(revision + 1);
expect(usePartyStore.getState().members.map((member) => member.id)).toEqual(members); 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", () => { 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); 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 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", { expect(activateDungeonSession("wailing-caverns", "normal", {
encounterSnapshots: [saved], encounterSnapshots: [saved],
})).toBe(true); })).toBe(true);
+42
View File
@@ -7,6 +7,20 @@ export type DungeonWorldObjectKind =
| "switch" | "switch"
| "key" | "key"
| "chest" | "chest"
| "resource"
| "generic"
| "chair"
| "spell-focus"
| "text"
| "trap"
| "transport"
| "camera"
| "ritual"
| "mailbox"
| "fishing-hole"
| "aura-generator"
| "spellcaster"
| "destructible"
| "npc" | "npc"
| "quest-object" | "quest-object"
| "trigger"; | "trigger";
@@ -21,6 +35,14 @@ export interface DungeonWorldObjectSource {
readonly files: readonly string[]; readonly files: readonly string[];
readonly templateId?: number; readonly templateId?: number;
readonly spawnId?: 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; readonly note?: string;
} }
@@ -45,6 +67,8 @@ export interface DungeonWorldObjectInteraction {
export interface DungeonWorldObjectVisual { export interface DungeonWorldObjectVisual {
/** Optional independently packaged GLB. Procedural geometry remains the safe fallback. */ /** Optional independently packaged GLB. Procedural geometry remains the safe fallback. */
readonly assetUrl?: string; readonly assetUrl?: string;
/** Source-adapted records stay hidden until their client display is reviewed. */
readonly hidden?: boolean;
readonly color?: string; readonly color?: string;
readonly emissive?: string; readonly emissive?: string;
readonly scale?: MobVector3; readonly scale?: MobVector3;
@@ -111,6 +135,24 @@ export function validateDungeonWorldObject(object: DungeonWorldObjectDefinition)
if (object.interaction && (!object.interaction.signalId.trim() || object.interaction.range <= 0)) { if (object.interaction && (!object.interaction.signalId.trim() || object.interaction.range <= 0)) {
throw new Error(`Dungeon world object ${object.id} has an invalid interaction.`); 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( export function nearestDungeonWorldInteraction(
+16 -1
View File
@@ -20,7 +20,19 @@ export type EnemyDamageSchool =
| "shadow" | "shadow"
| "holy"; | "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 EnemyTelegraphShape = "circle" | "cone" | "line";
export type EnemyTelegraphAnchor = "source" | "target"; export type EnemyTelegraphAnchor = "source" | "target";
@@ -45,6 +57,7 @@ export type EnemyAbilityEffect =
readonly aura: string; readonly aura: string;
readonly durationMs: number; readonly durationMs: number;
readonly magnitude?: number; readonly magnitude?: number;
readonly meleeAutoCriticalProc?: EnemyMeleeAutoCriticalProc;
} }
| { | {
readonly kind: "target-aura"; readonly kind: "target-aura";
@@ -55,6 +68,8 @@ export type EnemyAbilityEffect =
readonly kind: "control"; readonly kind: "control";
readonly mechanic: EnemyControlMechanic; readonly mechanic: EnemyControlMechanic;
readonly durationMs: number; readonly durationMs: number;
/** Optional damage resolved against the same targets as the control. */
readonly damageMultiplier?: number;
} }
| { readonly kind: "transform"; readonly form: string } | { readonly kind: "transform"; readonly form: string }
| { | {
+162
View File
@@ -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", () => { it("honors encounter-state conditions before selecting an ability", () => {
useWailingEncounterStore.getState().reset(); useWailingEncounterStore.getState().reset();
useCombatStore.getState().registerMob("event-caster", { useCombatStore.getState().registerMob("event-caster", {
@@ -1,2 +1,2 @@
// Generated by npm run dungeon:catalog. Do not edit. // 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
+13 -3
View File
@@ -6,17 +6,27 @@ import {
} from "./manastormStageAssets"; } from "./manastormStageAssets";
let initialized = false; let initialized = false;
const generatedPackages = registry.packages as unknown as readonly ManastormStageAssetPackage[];
export const GENERATED_MANASTORM_STAGE_ASSET_PACKAGE_COUNT = registry.packages.length; 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[] { export function registerGeneratedManastormStageAssets(): readonly string[] {
const packages = registry.packages as unknown as readonly ManastormStageAssetPackage[]; if (initialized) return Object.freeze(generatedPackages.map((assetPackage) => assetPackage.id));
if (initialized) return Object.freeze(packages.map((assetPackage) => assetPackage.id));
if (registry.schemaVersion !== MANASTORM_STAGE_ASSET_SCHEMA_VERSION) { if (registry.schemaVersion !== MANASTORM_STAGE_ASSET_SCHEMA_VERSION) {
throw new Error(`Unsupported generated Manastorm asset registry schema ${registry.schemaVersion}.`); throw new Error(`Unsupported generated Manastorm asset registry schema ${registry.schemaVersion}.`);
} }
const registered: string[] = []; const registered: string[] = [];
for (const assetPackage of packages) { for (const assetPackage of generatedPackages) {
registerManastormStageAssetPackage(assetPackage); registerManastormStageAssetPackage(assetPackage);
registered.push(assetPackage.id); registered.push(assetPackage.id);
} }
+7
View File
@@ -11,6 +11,7 @@ import {
preloadManastormStage, preloadManastormStage,
releaseActiveManastormStage, releaseActiveManastormStage,
} from "./manastormStageLoader"; } from "./manastormStageLoader";
import { generatedManastormContentPackId } from "./manastormAssetRegistry";
const CHECKSUM = "a".repeat(64); 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 () => { it("preloads one package, activates the stage, and releases the previous package", async () => {
registerManastormStageAssetPackage(assetPackage("first")); registerManastormStageAssetPackage(assetPackage("first"));
registerManastormStageAssetPackage(assetPackage("second")); 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);
});
});
+69
View File
@@ -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;
}
+13 -6
View File
@@ -29,6 +29,7 @@ import {
import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore"; import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
import { useManastormStore } from "./manastormStore"; import { useManastormStore } from "./manastormStore";
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
import { PLAYER_AGGRO_ID, type ThreatSource } from "./aggro"; import { PLAYER_AGGRO_ID, type ThreatSource } from "./aggro";
import type { ActiveAura, DispelCategory } from "./combatAuras"; import type { ActiveAura, DispelCategory } from "./combatAuras";
import { import {
@@ -121,13 +122,19 @@ export function activePartyBossObjectives(): readonly PartyBossObjective[] {
}] : []; }] : [];
} }
const game = useGameStore.getState(); const game = useGameStore.getState();
const completed = new Set(activeDungeonCompletedObjectiveIds(game.activeDungeonId));
const mobs = useCombatStore.getState().mobs;
const objectives = requireDungeonDefinition(game.activeDungeonId).objectiveOrder const objectives = requireDungeonDefinition(game.activeDungeonId).objectiveOrder
.filter((objective) => !objective.optional); .filter((objective) => !objective.optional && !completed.has(objective.id));
if (game.activeDungeonId !== "wailing-caverns") return objectives; const wailingPhase = game.activeDungeonId === "wailing-caverns"
const phase = useWailingEncounterStore.getState().phase; ? useWailingEncounterStore.getState().phase
return phase === "mutanus" || phase === "complete" : null;
? objectives return objectives.filter((objective) => (
: objectives.filter((objective) => objective.id !== "mutanus-spawn"); !objective.eventOnly
|| Boolean(mobs[objective.id])
|| (objective.id === "mutanus-spawn"
&& (wailingPhase === "mutanus" || wailingPhase === "complete"))
));
} }
export function resetPartyRuntimeClock(): void { export function resetPartyRuntimeClock(): void {
+21
View File
@@ -70,6 +70,8 @@ export interface GameState {
setAssetStatus: (status: AssetStatus, message?: string | null) => void; setAssetStatus: (status: AssetStatus, message?: string | null) => void;
updatePlayerSnapshot: (snapshot: PlayerSnapshot) => void; updatePlayerSnapshot: (snapshot: PlayerSnapshot) => void;
relocatePlayer: (spawn: WorldSpawn, makeActiveSpawn?: boolean) => 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; resetAtActiveSpawn: () => void;
resetAtEntrance: () => void; resetAtEntrance: () => void;
} }
@@ -227,6 +229,25 @@ export const useGameStore = create<GameState>((set) => ({
relocationRevision: state.relocationRevision + 1, 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) => { resetAtActiveSpawn: () => set((state) => {
const definition = requireDungeonDefinition(state.activeDungeonId); const definition = requireDungeonDefinition(state.activeDungeonId);
return { return {
+10 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { TALENT_NODES, TALENT_TREES } from "./talentCatalog"; import { TALENT_NODES, TALENT_TREES } from "./talentCatalog";
import { loadTalentUiCatalog, talentUiNodesForTree } from "./talentUiCatalog"; import { loadedTalentUiCatalog, loadTalentUiCatalog, talentUiArtUrls, talentUiNodesForTree } from "./talentUiCatalog";
describe("talent UI catalog shards", () => { describe("talent UI catalog shards", () => {
it.each(["warrior", "barbarian"] as const)("loads only the %s class projection", async (classId) => { 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); 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");
});
}); });
+13
View File
@@ -50,6 +50,7 @@ interface TalentUiCatalogModule {
const shardLoaders = import.meta.glob<TalentUiCatalogModule>("./generated/talentUi/*.json"); const shardLoaders = import.meta.glob<TalentUiCatalogModule>("./generated/talentUi/*.json");
const loadCache = new Map<ClassId, Promise<TalentUiCatalog>>(); const loadCache = new Map<ClassId, Promise<TalentUiCatalog>>();
const loadedCatalogs = new Map<ClassId, TalentUiCatalog>();
export function loadTalentUiCatalog(classId: ClassId): Promise<TalentUiCatalog> { export function loadTalentUiCatalog(classId: ClassId): Promise<TalentUiCatalog> {
const cached = loadCache.get(classId); 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) { if (module.default.schemaVersion !== 1 || module.default.classId !== classId) {
throw new Error(`Talent UI catalog ${classId} has incompatible metadata.`); throw new Error(`Talent UI catalog ${classId} has incompatible metadata.`);
} }
loadedCatalogs.set(classId, module.default);
return module.default; return module.default;
}).catch((error: unknown) => { }).catch((error: unknown) => {
loadCache.delete(classId); loadCache.delete(classId);
@@ -70,6 +72,10 @@ export function loadTalentUiCatalog(classId: ClassId): Promise<TalentUiCatalog>
return pending; return pending;
} }
export function loadedTalentUiCatalog(classId: ClassId): TalentUiCatalog | null {
return loadedCatalogs.get(classId) ?? null;
}
export function talentUiDescriptionForRank(node: TalentUiNode, rank: number): string { export function talentUiDescriptionForRank(node: TalentUiNode, rank: number): string {
const normalizedRank = Math.min( const normalizedRank = Math.min(
node.maxRank, node.maxRank,
@@ -91,3 +97,10 @@ export function talentUiNodesForTree(catalog: TalentUiCatalog, treeId: string):
.filter((node) => node.treeId === treeId) .filter((node) => node.treeId === treeId)
.sort((left, right) => left.row - right.row || left.index - right.index); .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),
])];
}
+81
View File
@@ -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);
}
}
}
}
}
});
});
+41 -1
View File
@@ -114,12 +114,50 @@ export const WAILING_NARALEX_ENCOUNTER = Object.freeze({
dungeonId: "wailing-caverns", dungeonId: "wailing-caverns",
initialPhase: "locked", initialPhase: "locked",
resetPolicy: { bossWipePhase: "locked", checkpointPhase: "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: [ rules: [
{ {
id: "fanglords-defeated", id: "fanglords-defeated",
trigger: { kind: "entities-dead", entityIds: FANG_LORD_RUNTIME_IDS }, trigger: { kind: "entities-dead", entityIds: FANG_LORD_RUNTIME_IDS },
guards: [{ kind: "phase", phases: ["locked"] }], 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", id: "start-escort",
@@ -196,6 +234,8 @@ export const WAILING_NARALEX_ENCOUNTER = Object.freeze({
actions: [ actions: [
{ id: "complete-naralex", kind: "set-phase", phase: "complete", persistence: "run" }, { id: "complete-naralex", kind: "set-phase", phase: "complete", persistence: "run" },
{ id: "complete-mutanus-objective", kind: "complete-objective", objectiveId: "mutanus-spawn", 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" },
], ],
}, },
], ],
+26
View File
@@ -136,5 +136,31 @@ describe("AzerothCore-backed Wailing Caverns parity slice", () => {
expect(useWailingEncounterStore.getState().phase).toBe("mutanus"); expect(useWailingEncounterStore.getState().phase).toBe("mutanus");
useWailingEncounterStore.getState().advance({ "mutanus-spawn": deadMob }, 264_504); useWailingEncounterStore.getState().advance({ "mutanus-spawn": deadMob }, 264_504);
expect(useWailingEncounterStore.getState().phase).toBe("complete"); 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