Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55cfd43d66 | ||
|
|
f4c9cf356c |
No files matched your search
@@ -4,6 +4,134 @@ This file is the durable record of runtime behavior, regressions, and the
|
||||
evidence required before calling a visual bug fixed. Update it whenever scene,
|
||||
camera, input, physics, mob-rendering, or asset-loading code changes.
|
||||
|
||||
## 2026-08-18 GM Manastorm administration - COMPLETE
|
||||
|
||||
- Server-authorized `Phenom` access, revisioned SQLite configuration, safe
|
||||
all-Needs-tested defaults, public/offline caching, optimistic conflicts, and
|
||||
denial/revision logging were exercised through the production HTTP surface.
|
||||
- The GM hub exposed all 406 deduplicated boss records, readiness and runnable
|
||||
state, pool filters, six global switches, dungeon entry, placement editing,
|
||||
and GM tests. A fresh revision correctly reported zero Ready bosses.
|
||||
- Live Wailing Caverns authoring captured and nudged the shared group anchor,
|
||||
displayed derived party, boss, portal, and exact linked-mob data, and saved a
|
||||
three-mob / two-kill Chaotic Link stage. The desktop Save control was moved
|
||||
above the Tactical control after the playtest found their hit targets
|
||||
overlapping; a normal semantic click then published the next revision.
|
||||
- Promoting an imported encounter directly to Ready initially exposed missing
|
||||
stage materialization at the server boundary. The readiness transition now
|
||||
persists its complete inherited stage. Cross-mode duplicates also merge
|
||||
their mode memberships, so a boss imported for both leveling and end-game is
|
||||
represented once and remains eligible in both modes.
|
||||
- Live pool acceptance promoted Lady Anacondra and Burning Felguard, entered a
|
||||
level-1 public Manastorm, then marked the active Lady Anacondra record Not
|
||||
ready from a parallel GM client. The existing run retained Lady Anacondra and
|
||||
its two-stack Chaotic Link snapshot; after reload, the next run selected only
|
||||
Burning Felguard. This verified new-run publication and active-run
|
||||
immutability together.
|
||||
- Desktop and Thor-preview passes rendered the DOM editor over the dungeon,
|
||||
retained camera/game separation while the panel was open, and exposed the GM
|
||||
pause-menu entry. Console inspection found no errors and only the existing
|
||||
third-party initialization deprecation warning.
|
||||
- Verification passed: server **7/7**, full Vitest **121 files / 699 tests**,
|
||||
TypeScript, production build, and the KTX2 audit of 11,839 compressed textures
|
||||
with zero fallbacks. The build retained the existing informational
|
||||
large-chunk advisory.
|
||||
- Evidence: `playtest-artifacts/2026-08-18-gm-manastorm-admin/desktop-admin-hub.png`,
|
||||
`desktop-world-editor-final.png`, and `thor-world-editor.png`.
|
||||
|
||||
## 2026-08-17 authentic combat VFX and audio - COMPLETE
|
||||
|
||||
- Player, party, and enemy combat actions now publish transient presentation
|
||||
phases without changing combat resolution. The scene consumes those events
|
||||
through bounded pooled effects (High: 64 systems / 8,192 particles; Low: 24
|
||||
systems / 2,048 particles with no effect lights), while Off skips visual
|
||||
spawning. Actor-group switches clear live systems and voices immediately.
|
||||
- The generated source catalog covers 6,383 runtime spell/skill IDs: 5,144
|
||||
resolve to preserved WoW/Ascension/RuneWaker descriptors and the remaining
|
||||
1,239 are explicit school/delivery fallbacks. All 38 referenced source sound
|
||||
paths resolve to 32 deduplicated packaged clips (6,365,096 bytes); the audit
|
||||
reports no missing packaged assets.
|
||||
- Owner settings migrated to local profile database v3 and remain independent
|
||||
of character gameplay settings. Options exposed High/Low/Off, independent
|
||||
Player/Party/Enemy visual switches, and independent audio mute/volume rows.
|
||||
The existing master volume also controls combat and Manastorm cues.
|
||||
- Live Wailing Caverns verification entered through the offline roster as a
|
||||
Human Priest healer. The entrance fight visibly presented party melee/ranged
|
||||
effects and enemy actions. A High-quality Smite capture showed the player
|
||||
cast system alongside party effects; Low-quality Lesser Heal showed the
|
||||
reduced healing system; Off-quality Shadow Word: Pain showed combat state,
|
||||
aura/combat text, and damage continuing without spawned VFX. Defaults were
|
||||
restored to High after the pass.
|
||||
- At a 390 x 844 mobile viewport, the Options dialog measured 362 px wide with
|
||||
345 px of scroll content and no document overflow. Player frame, party list,
|
||||
minimap, and action bar all remained inside the viewport. A discovered narrow
|
||||
Options text/control overflow was fixed by allowing grid labels to shrink and
|
||||
moving combat-audio controls onto a full-width narrow-screen row.
|
||||
- Verification passed: combat-effects parser/coverage audit **4/4**, focused
|
||||
persistence/event/cue/UI suites **68/68**, full Vitest **118 files / 674
|
||||
tests**, TypeScript, production build, and Android web build. Both builds
|
||||
retained the existing informational large-chunk advisory; the browser console
|
||||
had no errors and one existing third-party initialization deprecation warning.
|
||||
- Evidence: `playtest-artifacts/2026-08-17-combat-presentation/desktop-high-combat.png`,
|
||||
`desktop-player-smite.png`, `desktop-low-heal.png`, `desktop-off-combat.png`,
|
||||
`desktop-options.png`, `mobile-combat-controls.png`, and
|
||||
`mobile-high-combat.png`.
|
||||
- Coverage limit: browser automation exercised Web Audio creation, group
|
||||
routing, mute, volume, and authentic clip loading but cannot judge perceived
|
||||
loudness or spatial balance; that final acoustic judgment still requires a
|
||||
listening pass on physical desktop/mobile speakers.
|
||||
|
||||
## 2026-08-17 dungeon proximity aggro and range overlay - COMPLETE
|
||||
|
||||
- Idle dungeon enemies now check the living player and party positions during
|
||||
the low-frequency combat tick. Crossing a mob's radius engages its complete
|
||||
linked pack through the normal threat path, anchors each member at its pull
|
||||
position, and seeds initial threat on the nearest actor who crossed the line.
|
||||
- Normal enemies default to a 16-unit aggro radius and bosses to 20 units;
|
||||
creature definitions can override either value. Proximity uses full 3D
|
||||
distance so vertically overlapping dungeon floors do not pull one another,
|
||||
while the existing planar combat leash continues to support uneven terrain.
|
||||
- Options now includes `Mob aggro ranges`, defaulting to Off and persisting with
|
||||
the rest of the account gameplay settings. When enabled, idle dungeon mobs
|
||||
render restrained orange world-space boundaries above uneven terrain. Rings
|
||||
disappear once their mob engages and are never rendered in Manastorm.
|
||||
- Focused verification passed: `src/game/combatStore.test.ts` and
|
||||
`src/game/mobAi.test.ts`, **2 files / 60 tests**. `npx tsc -b --pretty false`
|
||||
passed, and `npx vite build` passed with 960 transformed modules.
|
||||
- Live browser verification entered Wailing Caverns as a healer through the
|
||||
offline roster. With the party left in Defend and no player attack issued,
|
||||
the entrance pack automatically acquired and damaged Garrick. The Options
|
||||
dialog showed the new setting Off by default; matched scene captures then
|
||||
confirmed visible range rings when On and their removal when toggled Off.
|
||||
- The browser console contained no errors and one existing third-party
|
||||
initialization deprecation warning. The local Vite host separately recorded
|
||||
the known optional `/content/manifest.json` proxy refusal because the content
|
||||
server on port 4173 was not running; packaged dungeon play was unaffected.
|
||||
|
||||
## 2026-08-17 Kresh cliff-pull aggro regression - COMPLETE
|
||||
|
||||
- Reproduced the reported reset with a deterministic combat-store regression:
|
||||
Kresh registers at one end of his long patrol, moves to a later patrol point,
|
||||
takes damage while the player is falling well above him, and advances combat
|
||||
after the player reaches the lower grotto.
|
||||
- Roaming mobs now anchor their leash at their runtime position when a new pull
|
||||
starts instead of retaining the position where their model first mounted.
|
||||
Aggro leash eligibility uses planar dungeon distance, matching the existing
|
||||
mob-AI rule, while attack reach still includes vertical separation. A real
|
||||
horizontal leash break continues to evade and restore the encounter.
|
||||
- Focused verification passed: `src/game/combatStore.test.ts` and
|
||||
`src/game/mobAi.test.ts`, **2 files / 58 tests**. `npm run build` also passed,
|
||||
including TypeScript, 956 transformed Vite modules, and the KTX2 audit of
|
||||
11,839 compressed textures with zero fallbacks.
|
||||
- Production-preview smoke testing entered Wailing Caverns through the offline
|
||||
roster, rendered the full dungeon/HUD/party view, issued the Attack order,
|
||||
and observed live enemy damage on the tank. This checkout lacks the optional
|
||||
local playable-character and creature model packs, so the documented
|
||||
procedural fallbacks and their asset warnings were visible during this pass.
|
||||
- Coverage limit: the exact cliff jump was validated by the deterministic
|
||||
combat-state regression rather than by manually traversing from the entrance
|
||||
to Kresh during the browser smoke test.
|
||||
|
||||
## 2026-08-16 responsive jump and natural teammate movement - COMPLETE
|
||||
|
||||
- Added the grounded single-jump path for Space and controller L3, including a
|
||||
|
||||
+5
-5
@@ -607,12 +607,12 @@ An already-installed Android APK cannot be rolled back in place to a lower
|
||||
still `com.phenomrom.healerman`, use the same signing key, and increase
|
||||
`versionCode`.
|
||||
- **Gitea APK upload returns an HTTP size error:** increase the Gitea/reverse
|
||||
proxy release-upload limit or use Gitea's direct LAN web endpoint with the
|
||||
script's `-GiteaBaseUrl` option. Do not assume `192.168.1.180:8080` is Gitea;
|
||||
verify the actual Gitea web port first.
|
||||
proxy release-upload limit or use Gitea's direct LAN web endpoint. The
|
||||
publisher defaults to `http://192.168.1.180:30008`; port `8080` is not Gitea.
|
||||
- **Release creation succeeds but attachment upload fails:** do not repeatedly
|
||||
create the same tag. Remove the incomplete release/tag in Gitea or upload the
|
||||
attachments to that release, then retry only after its state is understood.
|
||||
create the same tag. Use **Publish Current APK Only**. The publisher resumes
|
||||
the existing release, skips correctly sized attachments, and retries missing
|
||||
uploads through the direct LAN API.
|
||||
|
||||
## Appendix: known-good TrueNAS YAML
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/StormwindJail",
|
||||
"environmentMode": "global-wmo",
|
||||
"globalWmoAlignment": "source",
|
||||
"difficultyVariants": [
|
||||
{
|
||||
"id": "normal",
|
||||
|
||||
Generated
+3
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "8.4.1",
|
||||
"@dimforge/rapier3d-compat": "0.19.2",
|
||||
"@gltf-transform/cli": "^4.4.1",
|
||||
"@gltf-transform/core": "^4.4.2",
|
||||
"@recast-navigation/core": "0.43.1",
|
||||
|
||||
+7
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
@@ -12,6 +12,9 @@
|
||||
"content:publish": "node scripts/content/publish-content.mjs",
|
||||
"android:prepare": "node scripts/content/prepare-android-bootstrap.mjs",
|
||||
"content:test": "node --test scripts/content/*.test.mjs",
|
||||
"effects:generate": "node scripts/combat-effects/generate-manifest.mjs --package-audio",
|
||||
"effects:audit": "node scripts/combat-effects/generate-manifest.mjs --package-audio --audit",
|
||||
"effects:test": "node --test scripts/combat-effects/*.test.mjs",
|
||||
"wow335:abilities": "node scripts/generate-wow335-abilities.mjs",
|
||||
"coa:sync": "node scripts/sync-ascension-coa.mjs",
|
||||
"coa:sync:live": "node scripts/sync-ascension-coa.mjs --tooltips",
|
||||
@@ -89,8 +92,8 @@
|
||||
"assets:ktx2:audit": "node scripts/asset-pipeline/audit-ktx2.mjs --visual-only --require-all",
|
||||
"assets:ktx2:refresh-metadata": "node scripts/asset-pipeline/refresh-ktx2-metadata.mjs",
|
||||
"prebuild": "npm run loot:generate && npm run assets:ktx2:audit",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:android:web": "npm run prebuild && npm run android:prepare && tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android",
|
||||
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
|
||||
"build:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android",
|
||||
"android:sync": "npm run build:android:web && cap sync android",
|
||||
"android:apk": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleDebug",
|
||||
"android:apk:release": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleRelease",
|
||||
@@ -115,6 +118,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "8.4.1",
|
||||
"@dimforge/rapier3d-compat": "0.19.2",
|
||||
"@gltf-transform/cli": "^4.4.1",
|
||||
"@gltf-transform/core": "^4.4.2",
|
||||
"@recast-navigation/core": "0.43.1",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"generatedAt":null,"sources":[{"kind":"wow335","path":"../wow335a/Data/enUS/patch-enUS.MPQ","sha256":"9f0088da35c5ed0b5106cd20e422611489e435b74043e520e02221e0ce1b1af1"},{"kind":"ascension","path":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","sha256":"2f77f18b91ffbae32a19568e4bc05ec78ee384b6ba8dd2a6e05f0e97abbb2dce"},{"kind":"runewaker","path":"../Runewaker/Resource/data","sha256":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9"}],"coverage":{"requested":6383,"authentic":5144,"fallback":1239,"fallbackKeys":["wow335:20647","wow335:2687","wow335:64380","wow335:20187","wow335:25781","wow335:20185","wow335:20186","wow335:5502","wow335:20184","wow335:53651","wow335:31898","wow335:31804","wow335:53733","wow335:53726","wow335:75","wow335:13481","wow335:781","wow335:34026","wow335:34754","wow335:47633","wow335:46584","wow335:26372","wow335:49278","wow335:49279","wow335:44450","wow335:34913","wow335:43043","wow335:43044","wow335:55342","wow335:29886","wow335:58889","wow335:5225","wow335:52610","ascension:705153","ascension:705154","ascension:560456","ascension:800193","ascension:561322","ascension:804138","ascension:805821","ascension:801767","ascension:707583","ascension:705160","ascension:705184","ascension:705185","ascension:804746","ascension:706346","ascension:706347","ascension:705238","ascension:92081","ascension:706354","ascension:92083","ascension:560906","ascension:707207","ascension:706326","ascension:706327","ascension:92082","ascension:705209","ascension:705210","ascension:705215","ascension:707721","ascension:705196","ascension:706804","ascension:706350","ascension:706351","ascension:705206","ascension:561314","ascension:705172","ascension:561321","ascension:705171","ascension:807861","ascension:800943","ascension:561394","ascension:705202","ascension:705241","ascension:705174","ascension:705170","ascension:705169","ascension:706353","ascension:560564","ascension:503408","ascension:503409","ascension:503410","ascension:503411","ascension:503412","ascension:503413","ascension:503414","ascension:705921","ascension:301297","ascension:572870","ascension:92085","ascension:705923","ascension:712396","ascension:802756","ascension:705906","ascension:705888","ascension:705852","ascension:681465","ascension:705889","ascension:705890","ascension:705873","ascension:806283","ascension:705849","ascension:705907","ascension:706368","ascension:705936","ascension:705937","ascension:705861","ascension:705909","ascension:503748","ascension:92084","ascension:705840","ascension:705841","ascension:92086","ascension:705911","ascension:705878","ascension:706538","ascension:300663","ascension:705903","ascension:705904","ascension:501220","ascension:501221","ascension:501222","ascension:501223","ascension:501224","ascension:501225","ascension:501226","ascension:501227","ascension:501228","ascension:705843","ascension:706482","ascension:501162","ascension:705848","ascension:705844","ascension:807296","ascension:707617","ascension:704500","ascension:560822","ascension:706266","ascension:707844","ascension:92089","ascension:681376","ascension:300465","ascension:92087","ascension:520251","ascension:705102","ascension:707842","ascension:520257","ascension:705108","ascension:804613","ascension:704607","ascension:705120","ascension:705151","ascension:92088","ascension:705105","ascension:560573","ascension:704360","ascension:301287","ascension:707513","ascension:704374","ascension:804609","ascension:300468","ascension:705128","ascension:805244","ascension:704613","ascension:524947","ascension:704612","ascension:807430","ascension:705113","ascension:705125","ascension:705133","ascension:705521","ascension:705522","ascension:705523","ascension:705498","ascension:707862","ascension:92091","ascension:680529","ascension:681484","ascension:806195","ascension:802011","ascension:705488","ascension:92094","ascension:705466","ascension:92093","ascension:705493","ascension:705454","ascension:789256","ascension:707230","ascension:706242","ascension:706243","ascension:705528","ascension:681092","ascension:804024","ascension:680253","ascension:705526","ascension:803638","ascension:705534","ascension:800165","ascension:520211","ascension:520212","ascension:520213","ascension:578297","ascension:574319","ascension:574320","ascension:807462","ascension:807887","ascension:705690","ascension:705691","ascension:92097","ascension:705675","ascension:707053","ascension:705720","ascension:705701","ascension:570722","ascension:92098","ascension:705647","ascension:300595","ascension:92096","ascension:705640","ascension:567542","ascension:705693","ascension:705641","ascension:705642","ascension:705656","ascension:705648","ascension:705649","ascension:707652","ascension:705651","ascension:705696","ascension:705697","ascension:705698","ascension:800699","ascension:807233","ascension:705659","ascension:705660","ascension:804040","ascension:705677","ascension:705689","ascension:567533","ascension:705Line truncated
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.3",
|
||||
"versionCode": 1003
|
||||
"versionName": "0.1.5",
|
||||
"versionCode": 1005
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { MpqArchive } = require("stormlib-js");
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
function sha256(buffer) {
|
||||
return createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
export function parseDbc(buffer, name = "DBC") {
|
||||
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
|
||||
if (buffer.toString("ascii", 0, 4) !== "WDBC") throw new Error(`${name} is not WDBC data.`);
|
||||
const recordCount = buffer.readUInt32LE(4);
|
||||
const fieldCount = buffer.readUInt32LE(8);
|
||||
const recordSize = buffer.readUInt32LE(12);
|
||||
const stringSize = buffer.readUInt32LE(16);
|
||||
const recordsEnd = 20 + recordCount * recordSize;
|
||||
if (recordSize < fieldCount * 4 || recordsEnd + stringSize > buffer.length) {
|
||||
throw new Error(`${name} has an unsupported or truncated schema.`);
|
||||
}
|
||||
const stringAt = (offset) => {
|
||||
if (!offset || offset >= stringSize) return "";
|
||||
const start = recordsEnd + offset;
|
||||
const end = buffer.indexOf(0, start);
|
||||
return buffer.toString("utf8", start, end < 0 ? buffer.length : end);
|
||||
};
|
||||
const row = (index) => {
|
||||
const base = 20 + index * recordSize;
|
||||
return {
|
||||
uint: (field) => field < fieldCount ? buffer.readUInt32LE(base + field * 4) : 0,
|
||||
int: (field) => field < fieldCount ? buffer.readInt32LE(base + field * 4) : 0,
|
||||
float: (field) => field < fieldCount ? buffer.readFloatLE(base + field * 4) : 0,
|
||||
string: (field) => stringAt(field < fieldCount ? buffer.readUInt32LE(base + field * 4) : 0),
|
||||
};
|
||||
};
|
||||
return { name, buffer, recordCount, fieldCount, recordSize, stringSize, row };
|
||||
}
|
||||
|
||||
function decodeM2TrackHeader(buffer, offset) {
|
||||
if (offset < 0 || offset + 20 > buffer.length) return null;
|
||||
return {
|
||||
interpolation: buffer.readUInt16LE(offset),
|
||||
globalSequence: buffer.readInt16LE(offset + 2),
|
||||
timeCount: buffer.readUInt32LE(offset + 4),
|
||||
timeOffset: buffer.readUInt32LE(offset + 8),
|
||||
keyCount: buffer.readUInt32LE(offset + 12),
|
||||
keyOffset: buffer.readUInt32LE(offset + 16),
|
||||
};
|
||||
}
|
||||
|
||||
/** Decode the stable WotLK 3.x particle/ribbon fields used by the browser fallback converter. */
|
||||
export function decodeWotlkM2Effects(buffer, name = "M2") {
|
||||
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
|
||||
const magic = buffer.toString("ascii", 0, 4);
|
||||
if ((magic !== "MD20" && magic !== "MD21") || buffer.length < 304) {
|
||||
throw new Error(`${name} is not a supported WotLK M2.`);
|
||||
}
|
||||
const ribbonCount = buffer.readUInt32LE(288);
|
||||
const ribbonOffset = buffer.readUInt32LE(292);
|
||||
const particleCount = buffer.readUInt32LE(296);
|
||||
const particleOffset = buffer.readUInt32LE(300);
|
||||
const particleRecordSize = 476;
|
||||
const ribbonRecordSize = 176;
|
||||
if (particleCount && particleOffset + particleCount * particleRecordSize > buffer.length) {
|
||||
throw new Error(`${name} has truncated particle records.`);
|
||||
}
|
||||
if (ribbonCount && ribbonOffset + ribbonCount * ribbonRecordSize > buffer.length) {
|
||||
throw new Error(`${name} has truncated ribbon records.`);
|
||||
}
|
||||
const trackOffsets = {
|
||||
emissionSpeed: 56,
|
||||
speedVariation: 76,
|
||||
verticalRange: 96,
|
||||
horizontalRange: 116,
|
||||
gravity: 136,
|
||||
lifespan: 156,
|
||||
emissionRate: 180,
|
||||
emissionAreaLength: 200,
|
||||
emissionAreaWidth: 220,
|
||||
zSource: 240,
|
||||
color: 260,
|
||||
alpha: 280,
|
||||
scale: 300,
|
||||
};
|
||||
const particles = Array.from({ length: particleCount }, (_, index) => {
|
||||
const offset = particleOffset + index * particleRecordSize;
|
||||
return {
|
||||
id: buffer.readUInt32LE(offset),
|
||||
flags: buffer.readUInt32LE(offset + 4),
|
||||
position: [buffer.readFloatLE(offset + 8), buffer.readFloatLE(offset + 12), buffer.readFloatLE(offset + 16)],
|
||||
parentBone: buffer.readUInt16LE(offset + 20),
|
||||
textureIndex: buffer.readUInt16LE(offset + 22),
|
||||
blendMode: buffer.readUInt8(offset + 40),
|
||||
emitterType: buffer.readUInt8(offset + 41),
|
||||
particleType: buffer.readUInt8(offset + 44),
|
||||
headOrTail: buffer.readUInt8(offset + 45),
|
||||
textureRows: buffer.readUInt16LE(offset + 48),
|
||||
textureColumns: buffer.readUInt16LE(offset + 50),
|
||||
tracks: Object.fromEntries(Object.entries(trackOffsets)
|
||||
.map(([track, relativeOffset]) => [track, decodeM2TrackHeader(buffer, offset + relativeOffset)])),
|
||||
};
|
||||
});
|
||||
const ribbons = Array.from({ length: ribbonCount }, (_, index) => {
|
||||
const offset = ribbonOffset + index * ribbonRecordSize;
|
||||
return {
|
||||
id: buffer.readUInt32LE(offset),
|
||||
parentBone: buffer.readUInt32LE(offset + 4),
|
||||
position: [buffer.readFloatLE(offset + 8), buffer.readFloatLE(offset + 12), buffer.readFloatLE(offset + 16)],
|
||||
textureCount: buffer.readUInt32LE(offset + 20),
|
||||
textureOffset: buffer.readUInt32LE(offset + 24),
|
||||
materialCount: buffer.readUInt32LE(offset + 28),
|
||||
materialOffset: buffer.readUInt32LE(offset + 32),
|
||||
colorTrack: decodeM2TrackHeader(buffer, offset + 36),
|
||||
};
|
||||
});
|
||||
return { magic, particles, ribbons };
|
||||
}
|
||||
|
||||
function tableById(table, decode) {
|
||||
const result = new Map();
|
||||
for (let index = 0; index < table.recordCount; index += 1) {
|
||||
const row = table.row(index);
|
||||
result.set(row.uint(0), decode(row));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function collectAbilitySpellIds() {
|
||||
const requested = new Map();
|
||||
const add = (source, id) => {
|
||||
if (!Number.isInteger(id) || id <= 0) return;
|
||||
requested.set(`${source}:${id}`, { source, spellId: id });
|
||||
};
|
||||
|
||||
const abilityCatalog = fs.readFileSync(path.join(projectRoot, "src", "game", "abilityCatalog.ts"), "utf8");
|
||||
for (const match of abilityCatalog.matchAll(/dbcSpellId:\s*(\d+)/g)) add("wow335", Number(match[1]));
|
||||
|
||||
const wow = JSON.parse(fs.readFileSync(path.join(projectRoot, "src", "game", "wow335AbilityData.generated.json"), "utf8"));
|
||||
for (const chains of Object.values(wow.classes ?? {})) {
|
||||
for (const chain of chains) for (const rank of chain.ranks ?? []) add("wow335", Number(rank.spellId));
|
||||
}
|
||||
|
||||
const coa = JSON.parse(fs.readFileSync(path.join(projectRoot, "src", "game", "generated", "coaAbilityRuntimeCatalog.json"), "utf8"));
|
||||
for (const abilities of Object.values(coa.abilitiesByClass ?? {})) {
|
||||
for (const ability of abilities) {
|
||||
add("ascension", Number(ability.dbcSpellId));
|
||||
for (const rank of ability.ranks ?? []) add("ascension", Number(rank.spellId));
|
||||
}
|
||||
}
|
||||
|
||||
const rom = JSON.parse(fs.readFileSync(path.join(projectRoot, "src", "game", "romPlayerCatalog.generated.json"), "utf8"));
|
||||
const runewakerImageBySpellId = new Map();
|
||||
for (const skill of rom.skills ?? []) {
|
||||
add("runewaker", Number(skill.sourceSkillId));
|
||||
if (Number(skill.source?.imageId) > 0) runewakerImageBySpellId.set(Number(skill.sourceSkillId), Number(skill.source.imageId));
|
||||
for (const rank of skill.ranks ?? []) {
|
||||
add("runewaker", Number(rank.sourceSkillId));
|
||||
const imageId = Number(rank.source?.imageId ?? skill.source?.imageId);
|
||||
if (imageId > 0) runewakerImageBySpellId.set(Number(rank.sourceSkillId), imageId);
|
||||
}
|
||||
}
|
||||
|
||||
const generatedRoot = path.join(projectRoot, "src", "game", "generated");
|
||||
for (const name of fs.readdirSync(generatedRoot).filter((entry) => entry.endsWith("Population.generated.ts"))) {
|
||||
const source = fs.readFileSync(path.join(generatedRoot, name), "utf8");
|
||||
for (const match of source.matchAll(/"spellId":\s*(\d+)/g)) {
|
||||
const spellId = Number(match[1]);
|
||||
add(spellId >= 490_000 && spellId < 600_000 ? "runewaker" : "ascension", spellId);
|
||||
}
|
||||
}
|
||||
return { requested: [...requested.values()], runewakerImageBySpellId };
|
||||
}
|
||||
|
||||
function soundEntry(table, id) {
|
||||
if (!id) return [];
|
||||
for (let index = 0; index < table.recordCount; index += 1) {
|
||||
const row = table.row(index);
|
||||
if (row.uint(0) !== id) continue;
|
||||
const directory = row.string(23).replaceAll("\\", "/").replace(/\/$/, "");
|
||||
const volume = Number(row.float(24).toFixed(4));
|
||||
const minimumDistance = Number(row.float(26).toFixed(3));
|
||||
const maximumDistance = Number(row.float(27).toFixed(3));
|
||||
return Array.from({ length: 10 }, (_, fileIndex) => {
|
||||
const file = row.string(3 + fileIndex).replaceAll("\\", "/");
|
||||
if (!file) return null;
|
||||
return {
|
||||
sourcePath: directory ? `${directory}/${file}` : file,
|
||||
weight: Math.max(1, row.uint(13 + fileIndex)),
|
||||
volume: volume > 0 ? volume : 1,
|
||||
...(minimumDistance > 0 ? { minimumDistance } : {}),
|
||||
...(maximumDistance > 0 ? { maximumDistance } : {}),
|
||||
};
|
||||
}).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildWowEntries(source, requestedIds, tables) {
|
||||
const spell = parseDbc(tables.Spell, `${source} Spell.dbc`);
|
||||
const visual = tableById(parseDbc(tables.SpellVisual, `${source} SpellVisual.dbc`), (row) => ({
|
||||
precast: row.uint(1), cast: row.uint(2), impact: row.uint(3), state: row.uint(4), stateDone: row.uint(5),
|
||||
channel: row.uint(6), missileModel: row.uint(8), missileSound: row.uint(11), animationSound: row.uint(12),
|
||||
casterImpact: row.uint(14), targetImpact: row.uint(15), instantArea: row.uint(23), impactArea: row.uint(24), persistentArea: row.uint(25),
|
||||
}));
|
||||
const kitsTable = parseDbc(tables.SpellVisualKit, `${source} SpellVisualKit.dbc`);
|
||||
const kits = tableById(kitsTable, (row) => ({
|
||||
effects: Array.from({ length: 13 }, (_, offset) => row.uint(3 + offset)).filter(Boolean),
|
||||
soundId: row.uint(16),
|
||||
}));
|
||||
const effectNames = tableById(parseDbc(tables.SpellVisualEffectName, `${source} SpellVisualEffectName.dbc`), (row) => ({
|
||||
name: row.string(1),
|
||||
model: row.string(2).replaceAll("\\", "/"),
|
||||
scale: row.float(4) || 1,
|
||||
}));
|
||||
const attachments = new Map();
|
||||
const attachTable = parseDbc(tables.SpellVisualKitModelAttach, `${source} SpellVisualKitModelAttach.dbc`);
|
||||
for (let index = 0; index < attachTable.recordCount; index += 1) {
|
||||
const row = attachTable.row(index);
|
||||
const records = attachments.get(row.uint(1)) ?? [];
|
||||
records.push({ effectId: row.uint(2), attachment: `attachment:${row.uint(3)}` });
|
||||
attachments.set(row.uint(1), records);
|
||||
}
|
||||
const soundTable = parseDbc(tables.SoundEntries, `${source} SoundEntries.dbc`);
|
||||
const kitAssets = (kitId) => {
|
||||
const kit = kits.get(kitId);
|
||||
if (!kit) return [];
|
||||
const attached = new Map((attachments.get(kitId) ?? []).map((entry) => [entry.effectId, entry.attachment]));
|
||||
return [...new Set([...kit.effects, ...(attachments.get(kitId) ?? []).map((entry) => entry.effectId)])]
|
||||
.map((effectId) => {
|
||||
const effect = effectNames.get(effectId);
|
||||
if (!effect?.model) return null;
|
||||
return {
|
||||
model: effect.model,
|
||||
...(attached.get(effectId) ? { attachment: attached.get(effectId) } : {}),
|
||||
...(effect.scale !== 1 ? { scale: Number(effect.scale.toFixed(4)) } : {}),
|
||||
};
|
||||
}).filter(Boolean);
|
||||
};
|
||||
const soundsForKit = (kitId) => soundEntry(soundTable, kits.get(kitId)?.soundId ?? 0);
|
||||
const spellRows = new Map();
|
||||
const shift = spell.fieldCount >= 239 ? 6 : 0;
|
||||
const shifted = (field) => field >= 77 ? field + shift : field;
|
||||
const wanted = new Set(requestedIds);
|
||||
for (let index = 0; index < spell.recordCount; index += 1) {
|
||||
const row = spell.row(index);
|
||||
const id = row.uint(0);
|
||||
if (wanted.has(id)) spellRows.set(id, [row.uint(shifted(131)), row.uint(shifted(132))].filter(Boolean));
|
||||
}
|
||||
|
||||
const entries = [];
|
||||
for (const spellId of requestedIds) {
|
||||
const visualIds = spellRows.get(spellId) ?? [];
|
||||
const phases = {};
|
||||
const sounds = [];
|
||||
const addAssets = (phase, assets) => {
|
||||
const usable = assets.filter(Boolean);
|
||||
if (usable.length) phases[phase] = [...(phases[phase] ?? []), ...usable];
|
||||
};
|
||||
for (const visualId of visualIds) {
|
||||
const row = visual.get(visualId);
|
||||
if (!row) continue;
|
||||
for (const kitId of [row.precast, row.cast]) addAssets("cast-start", kitAssets(kitId));
|
||||
if (row.missileModel) {
|
||||
const missile = effectNames.get(row.missileModel);
|
||||
if (missile?.model) addAssets("release", [{ model: missile.model, scale: missile.scale }]);
|
||||
}
|
||||
for (const kitId of [row.impact, row.casterImpact, row.targetImpact]) addAssets("impact", kitAssets(kitId));
|
||||
addAssets("aura-start", kitAssets(row.state));
|
||||
addAssets("aura-end", kitAssets(row.stateDone));
|
||||
addAssets("tick", kitAssets(row.channel));
|
||||
for (const kitId of [row.instantArea, row.impactArea, row.persistentArea]) addAssets("impact", kitAssets(kitId));
|
||||
for (const kitId of [row.precast, row.cast, row.impact, row.state, row.channel, row.instantArea, row.impactArea, row.persistentArea]) {
|
||||
sounds.push(...soundsForKit(kitId));
|
||||
}
|
||||
sounds.push(...soundEntry(soundTable, row.missileSound), ...soundEntry(soundTable, row.animationSound));
|
||||
}
|
||||
for (const [phase, assets] of Object.entries(phases)) {
|
||||
phases[phase] = [...new Map(assets.map((asset) => [`${asset.model}:${asset.attachment ?? ""}`, asset])).values()];
|
||||
}
|
||||
const uniqueSounds = [...new Map(sounds.map((sound) => [sound.sourcePath, sound])).values()];
|
||||
if (!visualIds.length && !uniqueSounds.length) continue;
|
||||
entries.push({
|
||||
key: `${source}:${spellId}`,
|
||||
source,
|
||||
spellId,
|
||||
visualIds,
|
||||
...(Object.keys(phases).length ? { phases } : {}),
|
||||
...(uniqueSounds.length ? { sounds: uniqueSounds } : {}),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function readNullSeparatedPaths(record) {
|
||||
const values = [];
|
||||
let start = 0;
|
||||
for (let offset = 0; offset <= record.length; offset += 1) {
|
||||
if (offset < record.length && record[offset] !== 0) continue;
|
||||
if (offset - start >= 4) {
|
||||
const value = record.subarray(start, offset).toString("latin1").trim();
|
||||
if (/^[\x20-\x7e]+$/.test(value) && /[\\/]/.test(value)) values.push(value.replaceAll("\\", "/"));
|
||||
}
|
||||
start = offset + 1;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function parseRunewakerImageRecords(buffer, requestedImageIds) {
|
||||
const count = buffer.readInt32LE(132);
|
||||
const recordSize = 3712;
|
||||
const dataStart = 140;
|
||||
if (count <= 0 || dataStart + count * recordSize > buffer.length) throw new Error("ImageObject.DB has an incompatible layout.");
|
||||
const wanted = new Set(requestedImageIds);
|
||||
const result = new Map();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const offset = dataStart + index * recordSize;
|
||||
const id = buffer.readInt32LE(offset);
|
||||
if (!wanted.has(id)) continue;
|
||||
result.set(id, readNullSeparatedPaths(buffer.subarray(offset, offset + recordSize)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function runewakerImageIdsFromMagicCollect(buffer, spellIds) {
|
||||
const count = buffer.readInt32LE(132);
|
||||
const recordSize = 1032;
|
||||
const dataStart = 140;
|
||||
const wanted = new Set(spellIds);
|
||||
const result = new Map();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const offset = dataStart + index * recordSize;
|
||||
const spellId = buffer.readInt32LE(offset);
|
||||
if (wanted.has(spellId)) result.set(spellId, buffer.readInt32LE(offset + 32));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildRunewakerEntries(spellIds, imageBySpellId, dataRoot) {
|
||||
const magicCollect = fs.readFileSync(path.join(dataRoot, "magiccollectobject.db"));
|
||||
const collectedImages = runewakerImageIdsFromMagicCollect(magicCollect, spellIds);
|
||||
for (const [spellId, imageId] of collectedImages) if (!imageBySpellId.has(spellId)) imageBySpellId.set(spellId, imageId);
|
||||
const requestedImages = [...new Set(spellIds.map((id) => imageBySpellId.get(id)).filter(Boolean))];
|
||||
const images = parseRunewakerImageRecords(fs.readFileSync(path.join(dataRoot, "imageobject.db")), requestedImages);
|
||||
const entries = [];
|
||||
for (const spellId of spellIds) {
|
||||
const imageId = imageBySpellId.get(spellId);
|
||||
if (!imageId) continue;
|
||||
const paths = images.get(imageId) ?? [];
|
||||
const phases = {};
|
||||
const sounds = [];
|
||||
for (const sourcePath of paths) {
|
||||
if (/\.(?:wav|ogg|mp3)$/i.test(sourcePath)) {
|
||||
sounds.push({ sourcePath, weight: 1, volume: 1 });
|
||||
continue;
|
||||
}
|
||||
if (!/\.(?:ros|ras)$/i.test(sourcePath)) continue;
|
||||
const lower = sourcePath.toLowerCase();
|
||||
const phase = /(?:^|_)t(?:_|\.)|explode|impact|hit/.test(lower)
|
||||
? "impact"
|
||||
: /(?:^|_)f(?:_|\.)|fly|missile/.test(lower)
|
||||
? "release"
|
||||
: /buff|aura|extend/.test(lower)
|
||||
? "aura-start"
|
||||
: "cast-start";
|
||||
(phases[phase] ??= []).push({ model: sourcePath });
|
||||
}
|
||||
if (!Object.keys(phases).length && !sounds.length) continue;
|
||||
entries.push({
|
||||
key: `runewaker:${spellId}`,
|
||||
source: "runewaker",
|
||||
spellId,
|
||||
imageId,
|
||||
phases,
|
||||
...(sounds.length ? { sounds } : {}),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function extractWowTables(archivePath) {
|
||||
const archive = MpqArchive.open(archivePath);
|
||||
try {
|
||||
const extract = (name) => archive.extractFile(`DBFilesClient\\${name}.dbc`);
|
||||
return Object.fromEntries([
|
||||
"Spell", "SpellVisual", "SpellVisualKit", "SpellVisualEffectName", "SpellVisualKitModelAttach", "SoundEntries",
|
||||
].map((name) => [name, extract(name)]));
|
||||
} finally {
|
||||
archive.close();
|
||||
}
|
||||
}
|
||||
|
||||
function extractedAscensionTables(spellPath, supportRoot, baseSoundEntries) {
|
||||
const read = (name) => fs.readFileSync(path.join(supportRoot, `${name}.dbc`));
|
||||
return {
|
||||
Spell: fs.readFileSync(spellPath),
|
||||
SpellVisual: read("SpellVisual"),
|
||||
SpellVisualKit: read("SpellVisualKit"),
|
||||
SpellVisualEffectName: read("SpellVisualEffectName"),
|
||||
SpellVisualKitModelAttach: read("SpellVisualKitModelAttach"),
|
||||
SoundEntries: fs.existsSync(path.join(supportRoot, "SoundEntries.dbc")) ? read("SoundEntries") : baseSoundEntries,
|
||||
};
|
||||
}
|
||||
|
||||
function openedWowAudioArchives(dataRoot) {
|
||||
const names = [
|
||||
"patch-3.MPQ", "patch-2.MPQ", "patch.MPQ", "Patch-B.MPQ", "patch-A.MPQ",
|
||||
"lichking.MPQ", "expansion.MPQ", "common-2.MPQ", "common.MPQ",
|
||||
];
|
||||
return names
|
||||
.map((name) => path.join(dataRoot, name))
|
||||
.filter((archivePath) => fs.existsSync(archivePath) && fs.statSync(archivePath).isFile())
|
||||
.map((archivePath) => ({ archivePath, archive: MpqArchive.open(archivePath) }));
|
||||
}
|
||||
|
||||
function packageAudioAssets(entries, options) {
|
||||
const outputRoot = path.resolve(options.audioOutput
|
||||
?? path.join(projectRoot, "public", "assets", "game", "combat-effects", "audio"));
|
||||
const publicPrefix = "/assets/game/combat-effects/audio";
|
||||
const wowDataRoot = path.resolve(options.wowDataRoot
|
||||
?? path.join(projectRoot, "..", "wow335a", "Data"));
|
||||
const runewakerResourceRoot = path.resolve(options.runewakerResourceRoot
|
||||
?? path.join(projectRoot, "..", "Runewaker", "Resource"));
|
||||
const archives = openedWowAudioArchives(wowDataRoot);
|
||||
const sourceCache = new Map();
|
||||
const missing = [];
|
||||
const packagedFiles = new Set();
|
||||
fs.mkdirSync(outputRoot, { recursive: true });
|
||||
|
||||
const extractSource = (sourcePath, source) => {
|
||||
const cacheKey = `${source}:${sourcePath.toLowerCase()}`;
|
||||
if (sourceCache.has(cacheKey)) return sourceCache.get(cacheKey);
|
||||
let buffer = null;
|
||||
if (source === "runewaker") {
|
||||
const candidate = path.join(runewakerResourceRoot, ...sourcePath.split("/"));
|
||||
if (fs.existsSync(candidate)) buffer = fs.readFileSync(candidate);
|
||||
} else {
|
||||
const archiveName = sourcePath.replaceAll("/", "\\");
|
||||
for (const { archive } of archives) {
|
||||
try {
|
||||
buffer = archive.extractFile(archiveName);
|
||||
if (buffer?.length) break;
|
||||
} catch {
|
||||
// The source archive stack is sparse by design; continue to the base archives.
|
||||
}
|
||||
}
|
||||
}
|
||||
sourceCache.set(cacheKey, buffer);
|
||||
return buffer;
|
||||
};
|
||||
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
for (const sound of entry.sounds ?? []) {
|
||||
const buffer = extractSource(sound.sourcePath, entry.source);
|
||||
if (!buffer?.length) {
|
||||
missing.push(`${entry.source}:${sound.sourcePath}`);
|
||||
continue;
|
||||
}
|
||||
const extension = path.extname(sound.sourcePath).toLowerCase() || ".wav";
|
||||
const fileName = `${sha256(buffer).slice(0, 20)}${extension}`;
|
||||
const outputPath = path.join(outputRoot, fileName);
|
||||
if (!fs.existsSync(outputPath)) fs.writeFileSync(outputPath, buffer);
|
||||
packagedFiles.add(fileName);
|
||||
sound.url = `${publicPrefix}/${fileName}`;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
for (const { archive } of archives) archive.close();
|
||||
}
|
||||
|
||||
return {
|
||||
referenced: sourceCache.size,
|
||||
packaged: packagedFiles.size,
|
||||
missing: [...new Set(missing)].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
export function generateCombatEffectManifest(options = {}) {
|
||||
const wowArchive = path.resolve(options.wowArchive ?? path.join(projectRoot, "..", "wow335a", "Data", "enUS", "patch-enUS.MPQ"));
|
||||
const ascensionRoot = path.resolve(options.ascensionRoot ?? path.join(projectRoot, "..", "LadiksMPQEditor", "client-current"));
|
||||
const ascensionSpell = path.resolve(options.ascensionSpell ?? path.join(ascensionRoot, "area-52", "patch-D", "DBFilesClient", "Spell.dbc"));
|
||||
const ascensionSupport = path.resolve(options.ascensionSupport ?? path.join(ascensionRoot, "patch-S", "DBFilesClient"));
|
||||
const runewakerData = path.resolve(options.runewakerData ?? path.join(projectRoot, "..", "Runewaker", "Resource", "data"));
|
||||
const { requested, runewakerImageBySpellId } = collectAbilitySpellIds();
|
||||
const requestedBySource = requested.reduce((groups, entry) => {
|
||||
(groups[entry.source] ??= []).push(entry);
|
||||
return groups;
|
||||
}, {});
|
||||
const wowTables = extractWowTables(wowArchive);
|
||||
const wowEntries = buildWowEntries("wow335", (requestedBySource.wow335 ?? []).map((entry) => entry.spellId), wowTables);
|
||||
const ascensionTables = extractedAscensionTables(ascensionSpell, ascensionSupport, wowTables.SoundEntries);
|
||||
const ascensionEntries = buildWowEntries("ascension", (requestedBySource.ascension ?? []).map((entry) => entry.spellId), ascensionTables);
|
||||
const runewakerEntries = buildRunewakerEntries(
|
||||
(requestedBySource.runewaker ?? []).map((entry) => entry.spellId),
|
||||
runewakerImageBySpellId,
|
||||
runewakerData,
|
||||
);
|
||||
const entries = [...wowEntries, ...ascensionEntries, ...runewakerEntries]
|
||||
.sort((left, right) => left.source.localeCompare(right.source) || left.spellId - right.spellId);
|
||||
const audio = options.packageAudio
|
||||
? packageAudioAssets(entries, { ...options, runewakerResourceRoot: path.dirname(runewakerData) })
|
||||
: { referenced: new Set(entries.flatMap((entry) => (entry.sounds ?? []).map((sound) => `${entry.source}:${sound.sourcePath}`))).size, packaged: 0, missing: [] };
|
||||
const mapped = new Set(entries.map((entry) => entry.key));
|
||||
const fallbackKeys = requested.map((entry) => `${entry.source}:${entry.spellId}`).filter((key) => !mapped.has(key));
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: null,
|
||||
sources: [
|
||||
{ kind: "wow335", path: path.relative(projectRoot, wowArchive).replaceAll("\\", "/"), sha256: sha256(wowTables.Spell) },
|
||||
{ kind: "ascension", path: path.relative(projectRoot, ascensionSpell).replaceAll("\\", "/"), sha256: sha256(ascensionTables.Spell) },
|
||||
{ kind: "runewaker", path: path.relative(projectRoot, runewakerData).replaceAll("\\", "/"), sha256: sha256(fs.readFileSync(path.join(runewakerData, "imageobject.db"))) },
|
||||
],
|
||||
coverage: {
|
||||
requested: requested.length,
|
||||
authentic: entries.length,
|
||||
fallback: fallbackKeys.length,
|
||||
fallbackKeys,
|
||||
audio,
|
||||
unsupportedConstructs: [
|
||||
"WotLK event-driven child emitters use source-styled pooled fallbacks",
|
||||
"RuneWaker native shader programs outside decoded blend/billboard descriptors use source-styled pooled fallbacks",
|
||||
],
|
||||
},
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
function parseArguments(arguments_) {
|
||||
const options = {};
|
||||
for (let index = 0; index < arguments_.length; index += 1) {
|
||||
const argument = arguments_[index];
|
||||
if (argument === "--audit") options.audit = true;
|
||||
else if (argument === "--package-audio") options.packageAudio = true;
|
||||
else if (argument === "--output") options.output = arguments_[++index];
|
||||
else if (argument === "--wow-archive") options.wowArchive = arguments_[++index];
|
||||
else if (argument === "--ascension-root") options.ascensionRoot = arguments_[++index];
|
||||
else if (argument === "--runewaker-data") options.runewakerData = arguments_[++index];
|
||||
else throw new Error(`Unknown argument: ${argument}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
const options = parseArguments(process.argv.slice(2));
|
||||
const output = path.resolve(options.output ?? path.join(projectRoot, "public", "assets", "game", "combat-effects", "manifest.json"));
|
||||
const manifest = generateCombatEffectManifest(options);
|
||||
if (options.audit && manifest.coverage.audio.missing.length) {
|
||||
throw new Error(`Combat effects audit failed: ${manifest.coverage.audio.missing.length} referenced audio files could not be packaged.`);
|
||||
}
|
||||
if (!options.audit) {
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fs.writeFileSync(output, `${JSON.stringify(manifest)}\n`);
|
||||
}
|
||||
console.log(`Combat effects: ${manifest.coverage.authentic}/${manifest.coverage.requested} source mappings; ${manifest.coverage.fallback} intentional fallbacks.`);
|
||||
if (!options.audit) console.log(`Wrote ${output}`);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { decodeWotlkM2Effects, parseDbc, parseRunewakerImageRecords } from "./generate-manifest.mjs";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function tinyDbc() {
|
||||
const strings = Buffer.from("\0hello\0", "utf8");
|
||||
const buffer = Buffer.alloc(20 + 8 + strings.length);
|
||||
buffer.write("WDBC", 0, "ascii");
|
||||
buffer.writeUInt32LE(1, 4);
|
||||
buffer.writeUInt32LE(2, 8);
|
||||
buffer.writeUInt32LE(8, 12);
|
||||
buffer.writeUInt32LE(strings.length, 16);
|
||||
buffer.writeUInt32LE(42, 20);
|
||||
buffer.writeUInt32LE(1, 24);
|
||||
strings.copy(buffer, 28);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
test("parses numeric and string DBC fields", () => {
|
||||
const table = parseDbc(tinyDbc(), "fixture");
|
||||
assert.equal(table.row(0).uint(0), 42);
|
||||
assert.equal(table.row(0).string(1), "hello");
|
||||
});
|
||||
|
||||
test("classifies paths from fixed RuneWaker ImageObject records", () => {
|
||||
const buffer = Buffer.alloc(140 + 3712);
|
||||
buffer.writeInt32LE(1, 132);
|
||||
buffer.writeInt32LE(573727, 140);
|
||||
Buffer.from("model\\fx\\skill\\fire\\act_fireball01_f_i.ros\0", "ascii").copy(buffer, 140 + 300);
|
||||
const records = parseRunewakerImageRecords(buffer, [573727]);
|
||||
assert.deepEqual(records.get(573727), ["model/fx/skill/fire/act_fireball01_f_i.ros"]);
|
||||
});
|
||||
|
||||
test("decodes supported WotLK M2 particle and ribbon records", () => {
|
||||
const particleOffset = 304;
|
||||
const buffer = Buffer.alloc(particleOffset + 476);
|
||||
buffer.write("MD20", 0, "ascii");
|
||||
buffer.writeUInt32LE(1, 296);
|
||||
buffer.writeUInt32LE(particleOffset, 300);
|
||||
buffer.writeUInt32LE(17, particleOffset);
|
||||
buffer.writeUInt16LE(4, particleOffset + 20);
|
||||
buffer.writeUInt16LE(9, particleOffset + 22);
|
||||
buffer.writeUInt8(2, particleOffset + 40);
|
||||
buffer.writeUInt8(1, particleOffset + 41);
|
||||
buffer.writeUInt16LE(4, particleOffset + 48);
|
||||
buffer.writeUInt16LE(8, particleOffset + 50);
|
||||
buffer.writeUInt16LE(1, particleOffset + 56);
|
||||
buffer.writeUInt32LE(3, particleOffset + 56 + 12);
|
||||
const decoded = decodeWotlkM2Effects(buffer, "fixture");
|
||||
assert.equal(decoded.particles[0].id, 17);
|
||||
assert.equal(decoded.particles[0].textureIndex, 9);
|
||||
assert.equal(decoded.particles[0].blendMode, 2);
|
||||
assert.equal(decoded.particles[0].textureColumns, 8);
|
||||
assert.equal(decoded.particles[0].tracks.emissionSpeed.interpolation, 1);
|
||||
assert.equal(decoded.particles[0].tracks.emissionSpeed.keyCount, 3);
|
||||
});
|
||||
|
||||
test("generated coverage packages every referenced source sound", () => {
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "public", "assets", "game", "combat-effects", "manifest.json"), "utf8"));
|
||||
assert.equal(manifest.coverage.requested, manifest.coverage.authentic + manifest.coverage.fallback);
|
||||
assert.deepEqual(manifest.coverage.audio.missing, []);
|
||||
const sounds = manifest.entries.flatMap((entry) => entry.sounds ?? []);
|
||||
assert.ok(sounds.length > 0);
|
||||
for (const sound of sounds) {
|
||||
assert.ok(sound.url?.startsWith("/assets/game/combat-effects/audio/"));
|
||||
assert.ok(fs.existsSync(path.join(root, "public", sound.url)));
|
||||
}
|
||||
});
|
||||
@@ -201,6 +201,7 @@ const ADT_SOURCE_BASES = new Set([
|
||||
"z-y-negative-x",
|
||||
]);
|
||||
const GLOBAL_WMO_SOURCE_BASES = new Set(["draft", "flip-x"]);
|
||||
const GLOBAL_WMO_ALIGNMENTS = new Set(["player-anchor", "source"]);
|
||||
|
||||
function sourceBasisFor(recipe, environmentMode) {
|
||||
if (environmentMode !== "adt-hybrid") {
|
||||
@@ -220,6 +221,15 @@ function sourceBasisFor(recipe, environmentMode) {
|
||||
return basis;
|
||||
}
|
||||
|
||||
function coordinateAlignmentFor(recipe, environmentMode) {
|
||||
if (environmentMode !== "global-wmo") return "player-anchor";
|
||||
const alignment = recipe.globalWmoAlignment ?? "player-anchor";
|
||||
if (!GLOBAL_WMO_ALIGNMENTS.has(alignment)) {
|
||||
throw new Error(`${recipe.slug}: unsupported global WMO alignment ${alignment}.`);
|
||||
}
|
||||
return alignment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime drafts retain the original pipeline convention. wow.export ADT
|
||||
* packages have an additional horizontal basis change baked into their GLBs.
|
||||
@@ -238,7 +248,10 @@ function sourceToPackageBasis(position, environmentMode, sourceBasis) {
|
||||
: point;
|
||||
}
|
||||
|
||||
function coordinateMapper(draft, environmentMode, playerAnchor, sourceBasis) {
|
||||
function coordinateMapper(draft, environmentMode, playerAnchor, sourceBasis, alignment) {
|
||||
if (alignment === "source") {
|
||||
return (position) => sourceToPackageBasis(position, environmentMode, sourceBasis);
|
||||
}
|
||||
const sourceEntrance = draft?.entrance
|
||||
?? draft?.spawns?.[0]?.position
|
||||
?? playerAnchor.position;
|
||||
@@ -764,6 +777,7 @@ async function compile() {
|
||||
}
|
||||
const environmentMode = inferEnvironmentMode(recipe, assetPackage);
|
||||
const sourceBasis = sourceBasisFor(recipe, environmentMode);
|
||||
const coordinateAlignment = coordinateAlignmentFor(recipe, environmentMode);
|
||||
const playerAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "player");
|
||||
const trashAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "trash");
|
||||
const bossAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "boss");
|
||||
@@ -794,6 +808,12 @@ async function compile() {
|
||||
environmentMode,
|
||||
playerAnchor,
|
||||
sourceBasis,
|
||||
coordinateAlignment,
|
||||
);
|
||||
const entrancePosition = mapPosition(
|
||||
draft?.entrance
|
||||
?? draft?.spawns?.[0]?.position
|
||||
?? playerAnchor.position,
|
||||
);
|
||||
const sourceEntities = (draft?.entities ?? [])
|
||||
.filter((entity) => (
|
||||
@@ -966,7 +986,7 @@ async function compile() {
|
||||
});
|
||||
for (let index = 0; index < 15; index += 1) {
|
||||
const segment = index < 6
|
||||
? interpolate(playerAnchor.position, trashAnchor.position, 0.25 + index * 0.1)
|
||||
? interpolate(entrancePosition, trashAnchor.position, 0.25 + index * 0.1)
|
||||
: interpolate(trashAnchor.position, bossAnchor.position, 0.08 + (index - 6) * 0.085);
|
||||
staticSpawns.push({
|
||||
id: `generated-trash-${index + 1}`,
|
||||
@@ -979,7 +999,7 @@ async function compile() {
|
||||
}
|
||||
|
||||
const allPoints = [
|
||||
playerAnchor.position,
|
||||
entrancePosition,
|
||||
trashAnchor.position,
|
||||
bossAnchor.position,
|
||||
...staticSpawns.map((spawn) => spawn.position),
|
||||
@@ -1010,7 +1030,9 @@ async function compile() {
|
||||
modelCoverageWarning,
|
||||
...(syntheticTrash
|
||||
? ["Trash identities and placements are procedural; boss identities come from the installed Ascension encounter catalog."]
|
||||
: [`Server positions were aligned to the packaged ${environmentMode} environment at its reviewed player anchor.`]),
|
||||
: coordinateAlignment === "source"
|
||||
? [`Server positions use the packaged ${environmentMode} environment's native world coordinates.`]
|
||||
: [`Server positions were aligned to the packaged ${environmentMode} environment at its reviewed player anchor.`]),
|
||||
];
|
||||
|
||||
definitions.push({
|
||||
@@ -1034,12 +1056,12 @@ async function compile() {
|
||||
bounds,
|
||||
entrance: {
|
||||
name: `${recipe.title} Entrance`,
|
||||
footPosition: vector(playerAnchor.position),
|
||||
footPosition: vector(entrancePosition),
|
||||
forward: [Math.sin(entranceYaw), 0, Math.cos(entranceYaw)],
|
||||
yaw: entranceYaw,
|
||||
},
|
||||
areas: [
|
||||
{ id: "entrance", name: `${recipe.title} Entrance`, center: vector(playerAnchor.position), radius: 35 },
|
||||
{ id: "entrance", name: `${recipe.title} Entrance`, center: vector(entrancePosition), radius: 35 },
|
||||
...bosses.map((boss) => ({
|
||||
id: `area-${slugPart(boss.name)}`,
|
||||
name: `${boss.name}'s Encounter`,
|
||||
@@ -1068,6 +1090,10 @@ async function compile() {
|
||||
? sourceBasis === "z-y-negative-x"
|
||||
? "three(x,y,z)=(wow.y,wow.z,wow.x)"
|
||||
: "three(x,y,z)=(-wow.y,wow.z,wow.x)"
|
||||
: coordinateAlignment === "source"
|
||||
? sourceBasis === "flip-x"
|
||||
? "three(x,y,z)=(-draft.x,draft.y,draft.z)"
|
||||
: "three(x,y,z)=draft(x,y,z)"
|
||||
: sourceBasis === "flip-x"
|
||||
? "three(x,y,z)=(-draft.x,draft.y,draft.z)+package-anchor-translation"
|
||||
: "three(x,y,z)=draft(x,y,z)+package-anchor-translation",
|
||||
|
||||
@@ -8,7 +8,8 @@ param(
|
||||
[int]$VersionCode,
|
||||
|
||||
[string]$ReleaseNotes = 'Healer Man Android update.',
|
||||
[string]$GiteaBaseUrl = 'https://git.whoagland.com',
|
||||
[string]$GiteaBaseUrl = 'http://192.168.1.180:30008',
|
||||
[string]$GiteaPublicBaseUrl = 'https://git.whoagland.com',
|
||||
[string]$Owner = 'phenom',
|
||||
[string]$Repository = 'healer-man'
|
||||
)
|
||||
@@ -67,7 +68,7 @@ try {
|
||||
[IO.File]::WriteAllText($checksumPath, "$checksum $apkName`n", [Text.UTF8Encoding]::new($false))
|
||||
|
||||
$apiRoot = $GiteaBaseUrl.TrimEnd('/') + '/api/v1'
|
||||
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
|
||||
$headers = @{ Authorization = "token $($env:GITEA_TOKEN.Trim())" }
|
||||
$tag = "v$VersionName"
|
||||
$releaseBody = @{
|
||||
tag_name = $tag
|
||||
@@ -77,26 +78,73 @@ try {
|
||||
draft = $false
|
||||
prerelease = $false
|
||||
} | ConvertTo-Json
|
||||
$release = Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri "$apiRoot/repos/$Owner/$Repository/releases" `
|
||||
-Headers $headers `
|
||||
-ContentType 'application/json' `
|
||||
-Body $releaseBody
|
||||
$encodedTag = [Uri]::EscapeDataString($tag)
|
||||
try {
|
||||
$release = Invoke-RestMethod `
|
||||
-Method Get `
|
||||
-Uri "$apiRoot/repos/$Owner/$Repository/releases/tags/$encodedTag" `
|
||||
-Headers $headers
|
||||
Write-Host "Resuming existing Gitea release $tag."
|
||||
} catch {
|
||||
$statusCode = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 }
|
||||
if ($statusCode -ne 404) { throw }
|
||||
$release = Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri "$apiRoot/repos/$Owner/$Repository/releases" `
|
||||
-Headers $headers `
|
||||
-ContentType 'application/json' `
|
||||
-Body $releaseBody
|
||||
}
|
||||
|
||||
foreach ($attachment in @($apkPath, $checksumPath)) {
|
||||
$attachmentName = Split-Path -Leaf $attachment
|
||||
$attachmentSize = (Get-Item -LiteralPath $attachment).Length
|
||||
$encodedName = [Uri]::EscapeDataString($attachmentName)
|
||||
Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri "$apiRoot/repos/$Owner/$Repository/releases/$($release.id)/assets?name=$encodedName" `
|
||||
-Headers $headers `
|
||||
-ContentType 'application/octet-stream' `
|
||||
-InFile $attachment | Out-Null
|
||||
$release = Invoke-RestMethod `
|
||||
-Method Get `
|
||||
-Uri "$apiRoot/repos/$Owner/$Repository/releases/$($release.id)" `
|
||||
-Headers $headers
|
||||
$existingAsset = @($release.assets | Where-Object { $_.name -eq $attachmentName }) |
|
||||
Select-Object -First 1
|
||||
if ($existingAsset) {
|
||||
if ([long]$existingAsset.size -ne $attachmentSize) {
|
||||
throw "Release asset $attachmentName exists with the wrong size ($($existingAsset.size), expected $attachmentSize)."
|
||||
}
|
||||
Write-Host "Release asset $attachmentName already exists with the expected size; skipping upload."
|
||||
continue
|
||||
}
|
||||
|
||||
$uploaded = $false
|
||||
for ($attempt = 1; $attempt -le 3 -and -not $uploaded; $attempt++) {
|
||||
try {
|
||||
Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri "$apiRoot/repos/$Owner/$Repository/releases/$($release.id)/assets?name=$encodedName" `
|
||||
-Headers $headers `
|
||||
-ContentType 'application/octet-stream' `
|
||||
-InFile $attachment | Out-Null
|
||||
$uploaded = $true
|
||||
} catch {
|
||||
$refreshedRelease = Invoke-RestMethod `
|
||||
-Method Get `
|
||||
-Uri "$apiRoot/repos/$Owner/$Repository/releases/$($release.id)" `
|
||||
-Headers $headers
|
||||
$uploadedAsset = @($refreshedRelease.assets | Where-Object {
|
||||
$_.name -eq $attachmentName -and [long]$_.size -eq $attachmentSize
|
||||
}) | Select-Object -First 1
|
||||
if ($uploadedAsset) {
|
||||
$uploaded = $true
|
||||
continue
|
||||
}
|
||||
if ($attempt -eq 3) { throw }
|
||||
Write-Warning "Upload attempt $attempt for $attachmentName failed; retrying through the LAN API."
|
||||
Start-Sleep -Seconds ([math]::Pow(2, $attempt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Published $tag with $apkName and its SHA-256 checksum."
|
||||
Write-Host "$GiteaBaseUrl/$Owner/$Repository/releases/tag/$tag"
|
||||
Write-Host "$($GiteaPublicBaseUrl.TrimEnd('/'))/$Owner/$Repository/releases/tag/$tag"
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -319,7 +319,7 @@ def read_saved_signing_password() -> str:
|
||||
|
||||
|
||||
def read_saved_gitea_token() -> str:
|
||||
return read_windows_encrypted_secret(SAVED_GITEA_TOKEN_FILE, "Gitea token")
|
||||
return read_windows_encrypted_secret(SAVED_GITEA_TOKEN_FILE, "Gitea token").strip()
|
||||
|
||||
|
||||
def android_toolchain_status() -> tuple[bool, str]:
|
||||
@@ -843,7 +843,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
"ANDROID_KEY_ALIAS": self.alias_var.get().strip(),
|
||||
"ANDROID_KEYSTORE_PASSWORD": self.keystore_password_var.get(),
|
||||
"ANDROID_KEY_PASSWORD": self.key_password_var.get(),
|
||||
"GITEA_TOKEN": self.token_var.get(),
|
||||
"GITEA_TOKEN": self.token_var.get().strip(),
|
||||
}
|
||||
for key, value in list(fields.items()):
|
||||
if not value:
|
||||
|
||||
+98
-2
@@ -3,6 +3,10 @@ import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
cloneDefaultManastormAdminConfig,
|
||||
normalizeManastormAdminConfig,
|
||||
} from "./manastorm-config.mjs";
|
||||
|
||||
const scrypt = promisify(scryptCallback);
|
||||
|
||||
@@ -33,8 +37,19 @@ async function derivePassword(password, salt) {
|
||||
return Buffer.from(await scrypt(password, salt, 64));
|
||||
}
|
||||
|
||||
const GM_USERNAME_KEYS = new Set(["phenom"]);
|
||||
|
||||
function accountRoles(username) {
|
||||
return GM_USERNAME_KEYS.has(String(username ?? "").trim().toLowerCase()) ? ["gm"] : [];
|
||||
}
|
||||
|
||||
function publicAccount(row) {
|
||||
return { id: row.id, username: row.username, createdAt: Number(row.created_at) };
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
createdAt: Number(row.created_at ?? row.createdAt),
|
||||
roles: accountRoles(row.username),
|
||||
};
|
||||
}
|
||||
|
||||
export function openGameDatabase(options = {}) {
|
||||
@@ -62,7 +77,21 @@ export function openGameDatabase(options = {}) {
|
||||
account_id TEXT PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
revision INTEGER NOT NULL, data_json TEXT NOT NULL, updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS manastorm_admin_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
schema_version INTEGER NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
config_json TEXT NOT NULL,
|
||||
updated_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
|
||||
updated_by_username TEXT,
|
||||
updated_at INTEGER
|
||||
) STRICT;
|
||||
`);
|
||||
database.prepare(`
|
||||
INSERT OR IGNORE INTO manastorm_admin_config (
|
||||
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
|
||||
) VALUES (1, 1, 0, ?, NULL, NULL, NULL)
|
||||
`).run(JSON.stringify(cloneDefaultManastormAdminConfig()));
|
||||
|
||||
const statements = {
|
||||
accountByUsername: database.prepare("SELECT * FROM accounts WHERE username_key = ?"),
|
||||
@@ -82,6 +111,18 @@ export function openGameDatabase(options = {}) {
|
||||
data_json = excluded.data_json, updated_at = excluded.updated_at
|
||||
RETURNING revision, updated_at
|
||||
`),
|
||||
manastormConfig: database.prepare("SELECT * FROM manastorm_admin_config WHERE id = 1"),
|
||||
updateManastormConfig: database.prepare(`
|
||||
UPDATE manastorm_admin_config
|
||||
SET schema_version = 1,
|
||||
revision = revision + 1,
|
||||
config_json = ?,
|
||||
updated_by_account_id = ?,
|
||||
updated_by_username = ?,
|
||||
updated_at = ?
|
||||
WHERE id = 1 AND revision = ?
|
||||
RETURNING revision, updated_at
|
||||
`),
|
||||
};
|
||||
|
||||
function issueSession(accountId) {
|
||||
@@ -110,7 +151,7 @@ export function openGameDatabase(options = {}) {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return { account, ...issueSession(account.id) };
|
||||
return { account: publicAccount(account), ...issueSession(account.id) };
|
||||
}
|
||||
|
||||
async function login(usernameInput, password) {
|
||||
@@ -160,6 +201,58 @@ export function openGameDatabase(options = {}) {
|
||||
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data };
|
||||
}
|
||||
|
||||
function isGameMaster(account) {
|
||||
// Recompute authority from the server allowlist for every privileged call.
|
||||
return accountRoles(account?.username).includes("gm");
|
||||
}
|
||||
|
||||
function getManastormConfig() {
|
||||
const row = statements.manastormConfig.get();
|
||||
try {
|
||||
return {
|
||||
revision: Number(row?.revision ?? 0),
|
||||
updatedAt: row?.updated_at == null ? null : Number(row.updated_at),
|
||||
updatedBy: row?.updated_by_username ?? null,
|
||||
config: normalizeManastormAdminConfig(JSON.parse(row?.config_json ?? "null")),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Healer Man Manastorm configuration is corrupt; using safe defaults", error);
|
||||
return { revision: 0, updatedAt: null, updatedBy: null, config: cloneDefaultManastormAdminConfig() };
|
||||
}
|
||||
}
|
||||
|
||||
function putManastormConfig(account, expectedRevision, config) {
|
||||
if (!isGameMaster(account)) {
|
||||
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
||||
}
|
||||
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
|
||||
throw new GameDatabaseError("A valid expected revision is required.", 400, "invalid_revision");
|
||||
}
|
||||
let normalized;
|
||||
try {
|
||||
normalized = normalizeManastormAdminConfig(config);
|
||||
} catch (error) {
|
||||
throw new GameDatabaseError(error instanceof Error ? error.message : "Invalid Manastorm configuration.", 400, "invalid_manastorm_config");
|
||||
}
|
||||
const now = Date.now();
|
||||
const row = statements.updateManastormConfig.get(
|
||||
JSON.stringify(normalized),
|
||||
account.id,
|
||||
account.username,
|
||||
now,
|
||||
expectedRevision,
|
||||
);
|
||||
if (!row) {
|
||||
throw new GameDatabaseError("The Manastorm configuration changed. Reload and try again.", 409, "revision_conflict");
|
||||
}
|
||||
return {
|
||||
revision: Number(row.revision),
|
||||
updatedAt: Number(row.updated_at),
|
||||
updatedBy: account.username,
|
||||
config: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
databasePath,
|
||||
register,
|
||||
@@ -168,6 +261,9 @@ export function openGameDatabase(options = {}) {
|
||||
logout: (token) => { if (typeof token === "string" && token) statements.deleteSession.run(tokenHash(token)); },
|
||||
getCloudSave,
|
||||
putCloudSave,
|
||||
isGameMaster,
|
||||
getManastormConfig,
|
||||
putManastormConfig,
|
||||
pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes),
|
||||
close: () => database.close(),
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
||||
|
||||
function withDatabase(run) {
|
||||
@@ -52,3 +53,72 @@ test("persists opaque cloud roster data with increasing revisions", () => withDa
|
||||
assert.equal(second.revision, 2);
|
||||
assert.equal(database.getCloudSave(registered.account.id).data.characters[0].name, "Mendbetter");
|
||||
}));
|
||||
|
||||
test("derives GM authority from the hardcoded Phenom account", () => withDatabase(async (database) => {
|
||||
const gm = await database.register("Phenom", "storm-admin");
|
||||
const player = await database.register("Ordinary", "storm-player");
|
||||
assert.deepEqual(gm.account.roles, ["gm"]);
|
||||
assert.deepEqual((await database.login("pHeNoM", "storm-admin")).account.roles, ["gm"]);
|
||||
assert.equal(database.isGameMaster(database.authenticate(gm.token)), true);
|
||||
assert.equal(database.isGameMaster(database.authenticate(player.token)), false);
|
||||
assert.equal(database.isGameMaster({ ...player.account, roles: ["gm"] }), false);
|
||||
}));
|
||||
|
||||
test("recovers Manastorm configuration after restart and falls back safely from corrupt data", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-db-restart-test-"));
|
||||
const databasePath = path.join(directory, "game.db");
|
||||
try {
|
||||
const first = openGameDatabase({ databasePath });
|
||||
const gm = await first.register("Phenom", "storm-admin");
|
||||
const current = first.getManastormConfig();
|
||||
first.putManastormConfig(first.authenticate(gm.token), 0, {
|
||||
...current.config,
|
||||
global: { ...current.config.global, enabled: false },
|
||||
});
|
||||
first.close();
|
||||
|
||||
const restarted = openGameDatabase({ databasePath });
|
||||
assert.equal(restarted.getManastormConfig().revision, 1);
|
||||
assert.equal(restarted.getManastormConfig().config.global.enabled, false);
|
||||
restarted.close();
|
||||
|
||||
const raw = new DatabaseSync(databasePath);
|
||||
raw.prepare("UPDATE manastorm_admin_config SET config_json = ? WHERE id = 1").run("{corrupt");
|
||||
raw.close();
|
||||
const recovered = openGameDatabase({ databasePath });
|
||||
assert.equal(recovered.getManastormConfig().revision, 0);
|
||||
assert.deepEqual(recovered.getManastormConfig().config.bosses, {});
|
||||
recovered.close();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("persists revisioned Manastorm GM configuration and rejects stale or unauthorized writes", () => withDatabase(async (database) => {
|
||||
const gm = await database.register("Phenom", "storm-admin");
|
||||
const player = await database.register("Ordinary", "storm-player");
|
||||
const gmAccount = database.authenticate(gm.token);
|
||||
const playerAccount = database.authenticate(player.token);
|
||||
const initial = database.getManastormConfig();
|
||||
assert.equal(initial.revision, 0);
|
||||
assert.deepEqual(initial.config.bosses, {});
|
||||
|
||||
const configured = {
|
||||
...initial.config,
|
||||
global: { ...initial.config.global, affixesEnabled: false },
|
||||
};
|
||||
const saved = database.putManastormConfig(gmAccount, 0, configured);
|
||||
assert.equal(saved.revision, 1);
|
||||
assert.equal(saved.config.global.affixesEnabled, false);
|
||||
assert.equal(database.getManastormConfig().updatedBy, "Phenom");
|
||||
assert.throws(() => database.putManastormConfig(gmAccount, 0, configured), (error) => (
|
||||
error instanceof GameDatabaseError && error.status === 409
|
||||
));
|
||||
assert.throws(() => database.putManastormConfig(playerAccount, 1, configured), (error) => (
|
||||
error instanceof GameDatabaseError && error.status === 403
|
||||
));
|
||||
assert.throws(() => database.putManastormConfig(gmAccount, 1, {
|
||||
...configured,
|
||||
bosses: { "boss:incomplete": { status: "ready" } },
|
||||
}), (error) => error instanceof GameDatabaseError && error.status === 400);
|
||||
}));
|
||||
@@ -0,0 +1,161 @@
|
||||
export const MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION = 1;
|
||||
|
||||
export const DEFAULT_MANASTORM_ADMIN_CONFIG = Object.freeze({
|
||||
schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION,
|
||||
global: Object.freeze({
|
||||
enabled: true,
|
||||
levelingEnabled: true,
|
||||
endgameEnabled: true,
|
||||
affixesEnabled: true,
|
||||
chaoticLinkEnabled: true,
|
||||
milestoneEncountersEnabled: true,
|
||||
}),
|
||||
dungeonSpawns: Object.freeze({}),
|
||||
bosses: Object.freeze({}),
|
||||
});
|
||||
|
||||
const BOSS_STATUSES = new Set(["ready", "needs-tested", "not-ready"]);
|
||||
const MAX_RECORDS = 10_000;
|
||||
const MAX_ID_LENGTH = 256;
|
||||
|
||||
function invalid(message) {
|
||||
const error = new Error(message);
|
||||
error.code = "invalid_manastorm_config";
|
||||
return error;
|
||||
}
|
||||
|
||||
function record(value, label) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw invalid(`${label} must be an object.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function identifier(value, label) {
|
||||
if (typeof value !== "string" || !value.trim() || value.length > MAX_ID_LENGTH) {
|
||||
throw invalid(`${label} must be a non-empty string no longer than ${MAX_ID_LENGTH} characters.`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalIdentifier(value, label) {
|
||||
return value === undefined || value === null ? undefined : identifier(value, label);
|
||||
}
|
||||
|
||||
function finiteNumber(value, label) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw invalid(`${label} must be a finite number.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function vector(value, label) {
|
||||
if (!Array.isArray(value) || value.length !== 3) {
|
||||
throw invalid(`${label} must contain exactly three coordinates.`);
|
||||
}
|
||||
return value.map((coordinate, index) => finiteNumber(coordinate, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function worldSpawn(value, label) {
|
||||
const input = record(value, label);
|
||||
return {
|
||||
footPosition: vector(input.footPosition, `${label}.footPosition`),
|
||||
yaw: finiteNumber(input.yaw, `${label}.yaw`),
|
||||
};
|
||||
}
|
||||
|
||||
function boolean(value, label) {
|
||||
if (typeof value !== "boolean") throw invalid(`${label} must be a boolean.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeGlobal(value) {
|
||||
const input = record(value, "config.global");
|
||||
return {
|
||||
enabled: boolean(input.enabled, "config.global.enabled"),
|
||||
levelingEnabled: boolean(input.levelingEnabled, "config.global.levelingEnabled"),
|
||||
endgameEnabled: boolean(input.endgameEnabled, "config.global.endgameEnabled"),
|
||||
affixesEnabled: boolean(input.affixesEnabled, "config.global.affixesEnabled"),
|
||||
chaoticLinkEnabled: boolean(input.chaoticLinkEnabled, "config.global.chaoticLinkEnabled"),
|
||||
milestoneEncountersEnabled: boolean(
|
||||
input.milestoneEncountersEnabled,
|
||||
"config.global.milestoneEncountersEnabled",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStage(value, label) {
|
||||
const input = record(value, label);
|
||||
const linkedMobRuntimeIds = Array.isArray(input.linkedMobRuntimeIds)
|
||||
? input.linkedMobRuntimeIds.map((id, index) => identifier(id, `${label}.linkedMobRuntimeIds[${index}]`))
|
||||
: (() => { throw invalid(`${label}.linkedMobRuntimeIds must be an array.`); })();
|
||||
if (new Set(linkedMobRuntimeIds).size !== linkedMobRuntimeIds.length) {
|
||||
throw invalid(`${label}.linkedMobRuntimeIds must not contain duplicates.`);
|
||||
}
|
||||
const requiredKillCount = finiteNumber(input.requiredKillCount, `${label}.requiredKillCount`);
|
||||
if (!Number.isInteger(requiredKillCount) || requiredKillCount < 0 || requiredKillCount > linkedMobRuntimeIds.length) {
|
||||
throw invalid(`${label}.requiredKillCount must be an integer between zero and the linked mob count.`);
|
||||
}
|
||||
const mapId = finiteNumber(input.mapId, `${label}.mapId`);
|
||||
if (!Number.isInteger(mapId) || mapId <= 0) throw invalid(`${label}.mapId must be a positive integer.`);
|
||||
return {
|
||||
dungeonId: identifier(input.dungeonId, `${label}.dungeonId`),
|
||||
mapId,
|
||||
bossRuntimeId: identifier(input.bossRuntimeId, `${label}.bossRuntimeId`),
|
||||
bossEntityId: identifier(input.bossEntityId, `${label}.bossEntityId`),
|
||||
bossName: identifier(input.bossName, `${label}.bossName`),
|
||||
...(optionalIdentifier(input.baseStageId, `${label}.baseStageId`)
|
||||
? { baseStageId: optionalIdentifier(input.baseStageId, `${label}.baseStageId`) }
|
||||
: {}),
|
||||
...(optionalIdentifier(input.assetPackageId, `${label}.assetPackageId`)
|
||||
? { assetPackageId: optionalIdentifier(input.assetPackageId, `${label}.assetPackageId`) }
|
||||
: {}),
|
||||
bossPosition: vector(input.bossPosition, `${label}.bossPosition`),
|
||||
bossYaw: finiteNumber(input.bossYaw, `${label}.bossYaw`),
|
||||
groupSpawn: worldSpawn(input.groupSpawn, `${label}.groupSpawn`),
|
||||
portalPosition: vector(input.portalPosition, `${label}.portalPosition`),
|
||||
linkedMobRuntimeIds,
|
||||
requiredKillCount,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDictionary(value, label, normalizeValue) {
|
||||
const input = record(value, label);
|
||||
const entries = Object.entries(input);
|
||||
if (entries.length > MAX_RECORDS) throw invalid(`${label} contains too many records.`);
|
||||
return Object.fromEntries(entries.map(([key, entry]) => [
|
||||
identifier(key, `${label} key`),
|
||||
normalizeValue(entry, `${label}.${key}`),
|
||||
]));
|
||||
}
|
||||
|
||||
export function normalizeManastormAdminConfig(value) {
|
||||
const input = record(value, "config");
|
||||
if (input.schemaVersion !== MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION) {
|
||||
throw invalid(`Unsupported Manastorm admin config schema ${String(input.schemaVersion)}.`);
|
||||
}
|
||||
const dungeonSpawns = normalizeDictionary(input.dungeonSpawns, "config.dungeonSpawns", worldSpawn);
|
||||
const bosses = normalizeDictionary(input.bosses, "config.bosses", (rawBoss, label) => {
|
||||
const boss = record(rawBoss, label);
|
||||
if (!BOSS_STATUSES.has(boss.status)) throw invalid(`${label}.status is invalid.`);
|
||||
if (boss.status === "ready" && (boss.stage === undefined || boss.stage === null)) {
|
||||
throw invalid(`${label} cannot be Ready without a complete stage.`);
|
||||
}
|
||||
return {
|
||||
status: boss.status,
|
||||
...(boss.stage === undefined || boss.stage === null
|
||||
? {}
|
||||
: { stage: normalizeStage(boss.stage, `${label}.stage`) }),
|
||||
};
|
||||
});
|
||||
return {
|
||||
schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION,
|
||||
global: normalizeGlobal(input.global),
|
||||
dungeonSpawns,
|
||||
bosses,
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneDefaultManastormAdminConfig() {
|
||||
return JSON.parse(JSON.stringify(DEFAULT_MANASTORM_ADMIN_CONFIG));
|
||||
}
|
||||
@@ -209,6 +209,27 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, gameDatabase.putCloudSave(account.id, body.data));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/manastorm-config" && request.method === "GET") {
|
||||
const current = gameDatabase.getManastormConfig();
|
||||
json(response, 200, {
|
||||
revision: current.revision,
|
||||
updatedAt: current.updatedAt,
|
||||
config: current.config,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/gm/manastorm-config" && request.method === "PUT") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
if (!gameDatabase.isGameMaster(account)) {
|
||||
console.warn(`Denied Manastorm GM write for ${account.username} (${account.id})`);
|
||||
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
||||
}
|
||||
const body = await readJson(request, bodyLimit);
|
||||
const result = gameDatabase.putManastormConfig(account, body.expectedRevision, body.config);
|
||||
console.info(`Manastorm configuration revision ${result.revision} published by ${account.username}`);
|
||||
json(response, 200, result);
|
||||
return;
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
json(response, 404, { error: "API route not found.", code: "not_found" });
|
||||
return;
|
||||
|
||||
@@ -53,6 +53,37 @@ test("serves the game and authenticated cloud-save API", async () => {
|
||||
});
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal((await saved.json()).revision, 1);
|
||||
|
||||
const publicConfig = await fetch(`${origin}/api/manastorm-config`);
|
||||
assert.equal(publicConfig.status, 200);
|
||||
assert.equal((await publicConfig.json()).revision, 0);
|
||||
|
||||
const denied = await fetch(`${origin}/api/gm/manastorm-config`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||
body: JSON.stringify({ expectedRevision: 0, config: { schemaVersion: 1 } }),
|
||||
});
|
||||
assert.equal(denied.status, 403);
|
||||
|
||||
const gmRegistration = await fetch(`${origin}/api/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "Phenom", password: "storm-admin" }),
|
||||
});
|
||||
assert.equal(gmRegistration.status, 201);
|
||||
const gmAuth = await gmRegistration.json();
|
||||
assert.deepEqual(gmAuth.account.roles, ["gm"]);
|
||||
const current = await (await fetch(`${origin}/api/manastorm-config`)).json();
|
||||
const published = await fetch(`${origin}/api/gm/manastorm-config`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
|
||||
body: JSON.stringify({
|
||||
expectedRevision: current.revision,
|
||||
config: { ...current.config, global: { ...current.config.global, chaoticLinkEnabled: false } },
|
||||
}),
|
||||
});
|
||||
assert.equal(published.status, 200);
|
||||
assert.equal((await published.json()).config.global.chaoticLinkEnabled, false);
|
||||
} finally {
|
||||
await new Promise((resolve) => runtime.server.close(resolve));
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
|
||||
+10
-1
@@ -1,5 +1,6 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { lazy, Suspense, useEffect } from "react";
|
||||
import { useShellStore } from "./app/shellStore";
|
||||
import { useManastormAdminStore } from "./game/manastormAdminStore";
|
||||
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
||||
import { LoginScreen } from "./ui/LoginScreen";
|
||||
|
||||
@@ -10,6 +11,7 @@ const ClassHallScreen = lazy(() => import("./ui/ClassHallScreen").then((module)
|
||||
const DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen })));
|
||||
const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
|
||||
const ManastormSelectScreen = lazy(() => import("./ui/ManastormSelectScreen").then((module) => ({ default: module.ManastormSelectScreen })));
|
||||
const GmAdminScreen = lazy(() => import("./ui/GmAdminScreen").then((module) => ({ default: module.GmAdminScreen })));
|
||||
|
||||
function ShellTransition() {
|
||||
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
|
||||
@@ -19,6 +21,12 @@ export default function App() {
|
||||
useForcedThorDisplays();
|
||||
useAuthoritativeDualScreenSync();
|
||||
const phase = useShellStore((state) => state.phase);
|
||||
const sessionOwnerId = useShellStore((state) => state.session?.ownerId ?? null);
|
||||
const refreshManastormConfig = useManastormAdminStore((state) => state.refresh);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshManastormConfig();
|
||||
}, [refreshManastormConfig, sessionOwnerId]);
|
||||
|
||||
if (phase === "login") return <LoginScreen />;
|
||||
if (phase === "characters") return <Suspense fallback={<ShellTransition />}><CharacterSelectScreen /></Suspense>;
|
||||
@@ -27,6 +35,7 @@ export default function App() {
|
||||
if (phase === "class-hall") return <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
|
||||
if (phase === "dungeons") return <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
|
||||
if (phase === "manastorms") return <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
|
||||
if (phase === "gm-admin") return <Suspense fallback={<ShellTransition />}><GmAdminScreen /></Suspense>;
|
||||
if (phase !== "game") return null;
|
||||
return (
|
||||
<Suspense fallback={<ShellTransition />}>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { GameplayPanels } from "./ui/GameplayPanels";
|
||||
import { MapPanel } from "./ui/MapPanel";
|
||||
import { PauseMenu } from "./ui/PauseMenu";
|
||||
import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
|
||||
import { GmWorldEditor } from "./ui/GmWorldEditor";
|
||||
|
||||
function InputBridge() {
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
@@ -88,6 +89,7 @@ export default function GameRuntime() {
|
||||
<MapPanel />
|
||||
<GameplayPanels />
|
||||
<PauseMenu />
|
||||
<GmWorldEditor />
|
||||
</main>
|
||||
}
|
||||
bottom={<LocalCompanionPanel />}
|
||||
|
||||
@@ -5,16 +5,20 @@ import {
|
||||
createLocalAccount,
|
||||
createOfflineSession,
|
||||
listCharacters,
|
||||
loadCombatPresentationSettings,
|
||||
loginLocalAccount,
|
||||
restoreSession,
|
||||
updateCharacterActionBindings,
|
||||
updateCharacterEquipment,
|
||||
updateCharacterInventory,
|
||||
updateCharacterManastormProgress,
|
||||
updateCharacterProgression,
|
||||
updateCharacterSettings,
|
||||
updateCombatPresentationSettings,
|
||||
type StorageLike,
|
||||
} from "./accountRepository";
|
||||
import { DEFAULT_GAMEPLAY_SETTINGS } from "../game/combatStore";
|
||||
import { DEFAULT_COMBAT_PRESENTATION_SETTINGS } from "../game/combatPresentation";
|
||||
import { EMPTY_ITEM_STATS } from "../game/itemStats";
|
||||
import {
|
||||
MANASTORM_PROGRESS_VERSION,
|
||||
@@ -83,6 +87,17 @@ describe("local profile repository", () => {
|
||||
expect(listCharacters("offline-roster", storage)).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops spoofed GM roles from offline sessions", () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem(PROFILE_STORAGE_KEYS.session, JSON.stringify({
|
||||
kind: "offline",
|
||||
ownerId: "offline-roster",
|
||||
displayName: "Offline",
|
||||
roles: ["gm"],
|
||||
}));
|
||||
expect(restoreSession(storage)?.roles).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizes legacy profiles and persists progression atomically", () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
|
||||
@@ -124,6 +139,36 @@ describe("local profile repository", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates v1/v2 databases and stores combat presentation settings by owner", async () => {
|
||||
for (const version of [1, 2]) {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
|
||||
version,
|
||||
accounts: [],
|
||||
rosters: { "offline-roster": [] },
|
||||
}));
|
||||
expect(loadCombatPresentationSettings("offline-roster", storage))
|
||||
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(3);
|
||||
}
|
||||
|
||||
const storage = new MemoryStorage();
|
||||
const firstOwner = (await createLocalAccount("EffectsOne", "eight-plus", storage)).session!.ownerId;
|
||||
const secondOwner = (await createLocalAccount("EffectsTwo", "eight-plus", storage)).session!.ownerId;
|
||||
const customized = updateCombatPresentationSettings(firstOwner, {
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
vfxQuality: "low",
|
||||
vfxEnabled: { player: true, party: false, enemy: true },
|
||||
audioEnabled: { player: true, party: true, enemy: false },
|
||||
audioVolume: { player: 0.75, party: 0.4, enemy: 0.2 },
|
||||
}, storage);
|
||||
|
||||
expect(loadCombatPresentationSettings(firstOwner, storage)).toEqual(customized);
|
||||
expect(loadCombatPresentationSettings(secondOwner, storage)).toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
|
||||
expect(loadCombatPresentationSettings(createOfflineSession().ownerId, storage))
|
||||
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
|
||||
});
|
||||
|
||||
it("migrates custom-class profiles into the CoA category", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const offline = createOfflineSession();
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
import { normalizeInventory, type InventoryItem } from "../game/lootTypes";
|
||||
import { normalizeEquipment, type EquipmentAssignments } from "../game/equipment";
|
||||
import type { GameplaySettings } from "../game/combatStore";
|
||||
import {
|
||||
DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
normalizeCombatPresentationSettings,
|
||||
type CombatPresentationSettings,
|
||||
} from "../game/combatPresentation";
|
||||
import {
|
||||
createEmptyManastormProgress,
|
||||
normalizeManastormProgress,
|
||||
@@ -46,9 +51,10 @@ interface LocalAccount {
|
||||
}
|
||||
|
||||
interface ProfileDatabase {
|
||||
version: 2;
|
||||
version: 3;
|
||||
accounts: LocalAccount[];
|
||||
rosters: Record<string, CharacterProfile[]>;
|
||||
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
|
||||
}
|
||||
|
||||
export interface AccountResult {
|
||||
@@ -87,7 +93,7 @@ function browserSessionStorage(): StorageLike | null {
|
||||
}
|
||||
|
||||
function emptyDatabase(): ProfileDatabase {
|
||||
return { version: 2, accounts: [], rosters: {} };
|
||||
return { version: 3, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
|
||||
}
|
||||
|
||||
function isCharacterProfile(value: unknown): value is CharacterProfile {
|
||||
@@ -178,8 +184,9 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
version?: number;
|
||||
accounts?: unknown;
|
||||
rosters?: unknown;
|
||||
presentationSettingsByOwner?: unknown;
|
||||
}) | null;
|
||||
if (!parsed || (parsed.version !== 1 && parsed.version !== 2) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||
if (!parsed || ![1, 2, 3].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||
return emptyDatabase();
|
||||
}
|
||||
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
|
||||
@@ -197,8 +204,19 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
? roster.filter(isCharacterProfile).map(normalizeCharacterProfile).slice(0, MAX_CHARACTERS)
|
||||
: [],
|
||||
]));
|
||||
const database: ProfileDatabase = { version: 2, accounts, rosters };
|
||||
if (parsed.version === 1) writeDatabase(database, storage);
|
||||
const rawPresentationSettings = parsed.presentationSettingsByOwner
|
||||
&& typeof parsed.presentationSettingsByOwner === "object"
|
||||
&& !Array.isArray(parsed.presentationSettingsByOwner)
|
||||
? parsed.presentationSettingsByOwner as Record<string, unknown>
|
||||
: {};
|
||||
const presentationSettingsByOwner = Object.fromEntries(
|
||||
Object.entries(rawPresentationSettings).map(([ownerId, settings]) => [
|
||||
ownerId,
|
||||
normalizeCombatPresentationSettings(settings),
|
||||
]),
|
||||
);
|
||||
const database: ProfileDatabase = { version: 3, accounts, rosters, presentationSettingsByOwner };
|
||||
if (parsed.version !== 3) writeDatabase(database, storage);
|
||||
return database;
|
||||
} catch {
|
||||
return emptyDatabase();
|
||||
@@ -356,7 +374,11 @@ export function restoreSession(storage: StorageLike | null = browserSessionStora
|
||||
if (session.kind === "account"
|
||||
&& typeof session.accessToken !== "string"
|
||||
&& !readDatabase().accounts.some((account) => account.id === session.ownerId)) return null;
|
||||
return session as PlayerSession;
|
||||
return {
|
||||
...(session as PlayerSession),
|
||||
// Session storage is client-controlled. /api/me re-derives online roles.
|
||||
roles: [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -626,6 +648,28 @@ export function updateCharacterSettings(
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function loadCombatPresentationSettings(
|
||||
ownerId: string,
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): CombatPresentationSettings {
|
||||
if (!storage) return DEFAULT_COMBAT_PRESENTATION_SETTINGS;
|
||||
return readDatabase(storage).presentationSettingsByOwner[ownerId]
|
||||
?? DEFAULT_COMBAT_PRESENTATION_SETTINGS;
|
||||
}
|
||||
|
||||
export function updateCombatPresentationSettings(
|
||||
ownerId: string,
|
||||
settings: CombatPresentationSettings,
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): CombatPresentationSettings {
|
||||
const normalized = normalizeCombatPresentationSettings(settings);
|
||||
if (!storage) return normalized;
|
||||
const database = readDatabase(storage);
|
||||
database.presentationSettingsByOwner[ownerId] = normalized;
|
||||
writeDatabase(database, storage);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function updateCharacterManastormProgress(
|
||||
ownerId: string,
|
||||
characterId: string,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { touchCharacter } from "./accountRepository";
|
||||
import { useShellStore } from "./shellStore";
|
||||
import { registerGeneratedManastormStageAssets } from "../game/manastormAssetRegistry";
|
||||
import {
|
||||
isGameMasterSession,
|
||||
resolvedBossAdminCatalog,
|
||||
} from "../game/manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "../game/manastormInstalledCatalog";
|
||||
import { installedManastormStageBinding } from "../game/manastormInstalledStages";
|
||||
import { registerManastormStageBindingResolver } from "../game/manastormStagePopulation";
|
||||
import { activateManastormStage } from "../game/manastormStageLoader";
|
||||
import { manastormModeForCharacterLevel, setManastormCatalog } from "../game/manastorm";
|
||||
import { createEmptyManastormProgress, manastormModeProgress } from "../game/manastormProgress";
|
||||
import { manastormActiveSpellLoadoutModel } from "../game/manastormSpellLoadout";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { defaultPartyRoleForClass } from "../game/partyRoles";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
export function enterGmManastormTest(bossKey: string): boolean {
|
||||
const shell = useShellStore.getState();
|
||||
if (!isGameMasterSession(shell.session) || !shell.selectedCharacterId) {
|
||||
useShellStore.setState({ notice: "Game Master access is required." });
|
||||
return false;
|
||||
}
|
||||
const activeCharacter = touchCharacter(shell.session!.ownerId, shell.selectedCharacterId)
|
||||
?? shell.characters.find((character) => character.id === shell.selectedCharacterId)
|
||||
?? null;
|
||||
if (!activeCharacter) {
|
||||
useShellStore.setState({ notice: "Select a character before testing a boss." });
|
||||
return false;
|
||||
}
|
||||
const adminConfig = useManastormAdminStore.getState().config;
|
||||
const entry = resolvedBossAdminCatalog(adminConfig).find((candidate) => candidate.key === bossKey);
|
||||
if (!entry || entry.status === "not-ready" || !entry.runnable || !entry.stage) {
|
||||
useShellStore.setState({ notice: entry?.validationIssues[0] ?? "That boss is not available for GM testing." });
|
||||
return false;
|
||||
}
|
||||
|
||||
registerGeneratedManastormStageAssets();
|
||||
registerManastormStageBindingResolver(installedManastormStageBinding);
|
||||
const modeId = manastormModeForCharacterLevel(activeCharacter.level, INSTALLED_MANASTORM_RUNTIME_CATALOG);
|
||||
const { milestoneLevel: _milestoneLevel, ...stageWithoutMilestone } = entry.stage;
|
||||
const testStage = Object.freeze({
|
||||
...stageWithoutMilestone,
|
||||
modeIds: [modeId],
|
||||
stageKind: "standard" as const,
|
||||
unlockLevel: 1,
|
||||
chaoticLinkEnabled: adminConfig.global.chaoticLinkEnabled,
|
||||
});
|
||||
const testCatalog = Object.freeze({
|
||||
...INSTALLED_MANASTORM_RUNTIME_CATALOG,
|
||||
stages: [testStage],
|
||||
fallbackPolicy: "none" as const,
|
||||
runtimeOptions: Object.freeze({ ...adminConfig.global }),
|
||||
});
|
||||
setManastormCatalog(testCatalog);
|
||||
useManastormStore.getState().resetRun();
|
||||
const progress = activeCharacter.manastormProgress ?? createEmptyManastormProgress();
|
||||
const modeProgress = manastormModeProgress(progress, modeId);
|
||||
const spellLoadout = manastormActiveSpellLoadoutModel(
|
||||
activeCharacter.classId,
|
||||
activeCharacter.level,
|
||||
activeCharacter.talentRanks,
|
||||
);
|
||||
let encounter;
|
||||
try {
|
||||
encounter = useManastormStore.getState().startRun({
|
||||
partySize: 5,
|
||||
startingLevel: 1,
|
||||
unlockedThroughLevel: 1,
|
||||
modeId,
|
||||
activeSpellLoadout: {
|
||||
available: spellLoadout.options,
|
||||
selectedIds: progress.loadout,
|
||||
maxSelections: spellLoadout.maxSelections,
|
||||
provenance: spellLoadout.provenance,
|
||||
},
|
||||
cacheMeter: modeProgress.cacheMeter,
|
||||
rewardCaches: modeProgress.rewardCaches,
|
||||
random: () => 0,
|
||||
});
|
||||
} catch {
|
||||
useShellStore.setState({ notice: "The configured boss could not be prepared for testing." });
|
||||
return false;
|
||||
}
|
||||
if (!activateManastormStage(encounter, undefined, false, true).activated) {
|
||||
useManastormStore.getState().resetRun();
|
||||
useShellStore.setState({ notice: "The configured dungeon or stage package is not loadable." });
|
||||
return false;
|
||||
}
|
||||
|
||||
useGameStore.getState().setCompanionOpen(false);
|
||||
useShellStore.setState({
|
||||
phase: "game",
|
||||
activeCharacter,
|
||||
activeDungeonId: encounter.dungeonId,
|
||||
activeDungeonRole: defaultPartyRoleForClass(activeCharacter.classId),
|
||||
notice: `GM test: ${entry.name}`,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import { useGameStore } from "../game/store";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { manastormModeForCharacterLevel, setManastormCatalog } from "../game/manastorm";
|
||||
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "../game/manastormInstalledCatalog";
|
||||
import { effectiveManastormCatalog } from "../game/manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||
import { registerGeneratedManastormStageAssets } from "../game/manastormAssetRegistry";
|
||||
import { installedManastormStageBinding } from "../game/manastormInstalledStages";
|
||||
import { registerManastormStageBindingResolver } from "../game/manastormStagePopulation";
|
||||
@@ -71,11 +73,28 @@ function enterManastorm(
|
||||
|
||||
const partySize = isManastormPartySize(requestedPartySize) ? requestedPartySize : 5;
|
||||
registerGeneratedManastormStageAssets();
|
||||
setManastormCatalog(INSTALLED_MANASTORM_RUNTIME_CATALOG);
|
||||
const adminConfig = useManastormAdminStore.getState().config;
|
||||
if (!adminConfig.global.enabled) {
|
||||
useShellStore.setState({ notice: "Manastorms are currently disabled by a Game Master." });
|
||||
return false;
|
||||
}
|
||||
const effectiveCatalog = effectiveManastormCatalog(adminConfig, INSTALLED_MANASTORM_RUNTIME_CATALOG);
|
||||
const modeId = manastormModeForCharacterLevel(
|
||||
activeCharacter.level,
|
||||
INSTALLED_MANASTORM_RUNTIME_CATALOG,
|
||||
effectiveCatalog,
|
||||
);
|
||||
if (
|
||||
(modeId === 0 && !adminConfig.global.levelingEnabled)
|
||||
|| (modeId === 2 && !adminConfig.global.endgameEnabled)
|
||||
) {
|
||||
useShellStore.setState({ notice: `${modeId === 2 ? "End-game" : "Leveling"} Manastorms are currently disabled.` });
|
||||
return false;
|
||||
}
|
||||
if (!effectiveCatalog.stages?.length) {
|
||||
useShellStore.setState({ notice: "No bosses are currently marked Ready for Manastorms." });
|
||||
return false;
|
||||
}
|
||||
setManastormCatalog(effectiveCatalog);
|
||||
const storedProgress = activeCharacter.manastormProgress ?? createEmptyManastormProgress();
|
||||
const progress = modeId === 2
|
||||
? applyManastormEndgameHandoff(storedProgress)
|
||||
@@ -106,20 +125,26 @@ function enterManastorm(
|
||||
const startingLevel = requestedStartingLevel === 1
|
||||
? 1
|
||||
: availableCheckpoint;
|
||||
const encounter = useManastormStore.getState().startRun({
|
||||
partySize,
|
||||
startingLevel,
|
||||
unlockedThroughLevel: Math.max(startingLevel, savedParty.highestLevel + 1),
|
||||
modeId,
|
||||
activeSpellLoadout: {
|
||||
available: spellLoadoutModel.options,
|
||||
selectedIds: progress.loadout,
|
||||
maxSelections: spellLoadoutModel.maxSelections,
|
||||
provenance: spellLoadoutModel.provenance,
|
||||
},
|
||||
cacheMeter: modeProgress.cacheMeter,
|
||||
rewardCaches: modeProgress.rewardCaches,
|
||||
});
|
||||
let encounter;
|
||||
try {
|
||||
encounter = useManastormStore.getState().startRun({
|
||||
partySize,
|
||||
startingLevel,
|
||||
unlockedThroughLevel: Math.max(startingLevel, savedParty.highestLevel + 1),
|
||||
modeId,
|
||||
activeSpellLoadout: {
|
||||
available: spellLoadoutModel.options,
|
||||
selectedIds: progress.loadout,
|
||||
maxSelections: spellLoadoutModel.maxSelections,
|
||||
provenance: spellLoadoutModel.provenance,
|
||||
},
|
||||
cacheMeter: modeProgress.cacheMeter,
|
||||
rewardCaches: modeProgress.rewardCaches,
|
||||
});
|
||||
} catch {
|
||||
useShellStore.setState({ notice: "No Ready boss is eligible for this Manastorm level and party size." });
|
||||
return false;
|
||||
}
|
||||
if (!activateManastormStage(encounter, undefined, false).activated) {
|
||||
useManastormStore.getState().resetRun();
|
||||
useShellStore.setState({ notice: "The first Manastorm could not be opened." });
|
||||
|
||||
@@ -11,7 +11,7 @@ const NATIVE_API_ORIGIN = "https://iwanttoheal.phenomrom.com";
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
|
||||
interface AuthResponse {
|
||||
account: { id: string; username: string };
|
||||
account: { id: string; username: string; roles?: readonly "gm"[] };
|
||||
token: string;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ interface CloudSaveResponse {
|
||||
data: { version?: number; characters?: unknown };
|
||||
}
|
||||
|
||||
interface MeResponse {
|
||||
account: { id: string; username: string; roles?: readonly "gm"[] };
|
||||
}
|
||||
|
||||
class OnlineApiError extends Error {
|
||||
constructor(message: string, readonly status: number) {
|
||||
super(message);
|
||||
@@ -40,7 +44,7 @@ function apiOrigin(): string {
|
||||
return Capacitor.isNativePlatform() ? NATIVE_API_ORIGIN : "";
|
||||
}
|
||||
|
||||
async function requestJson<T>(pathname: string, init: RequestInit = {}, token?: string): Promise<T> {
|
||||
export async function requestOnlineJson<T>(pathname: string, init: RequestInit = {}, token?: string): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("Accept", "application/json");
|
||||
if (init.body !== undefined) headers.set("Content-Type", "application/json");
|
||||
@@ -63,6 +67,7 @@ function accountResult(response: AuthResponse): AccountResult {
|
||||
kind: "account",
|
||||
ownerId: response.account.id,
|
||||
displayName: response.account.username,
|
||||
roles: response.account.roles ?? [],
|
||||
accessToken: response.token,
|
||||
},
|
||||
};
|
||||
@@ -70,7 +75,7 @@ function accountResult(response: AuthResponse): AccountResult {
|
||||
|
||||
async function authenticate(pathname: string, username: string, password: string): Promise<AccountResult> {
|
||||
try {
|
||||
const response = await requestJson<AuthResponse>(pathname, {
|
||||
const response = await requestOnlineJson<AuthResponse>(pathname, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
@@ -94,7 +99,7 @@ function flushCloudSave(): void {
|
||||
pendingCharacters = null;
|
||||
if (!session?.accessToken || !characters) return;
|
||||
saveQueue = saveQueue.then(async () => {
|
||||
await requestJson<CloudSaveResponse>("/api/cloud-save", {
|
||||
await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ data: { version: 1, characters } }),
|
||||
}, session.accessToken);
|
||||
@@ -122,7 +127,7 @@ export function activateOnlineSession(session: PlayerSession): void {
|
||||
export async function hydrateOnlineSession(session: PlayerSession): Promise<CharacterProfile[]> {
|
||||
if (!session.accessToken) return listCharacters(session.ownerId);
|
||||
activateOnlineSession(session);
|
||||
const cloud = await requestJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
|
||||
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
|
||||
suppressUpload = true;
|
||||
try {
|
||||
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
|
||||
@@ -131,6 +136,17 @@ export async function hydrateOnlineSession(session: PlayerSession): Promise<Char
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshOnlineSession(session: PlayerSession): Promise<PlayerSession> {
|
||||
if (!session.accessToken) return { ...session, roles: [] };
|
||||
const response = await requestOnlineJson<MeResponse>("/api/me", {}, session.accessToken);
|
||||
return {
|
||||
...session,
|
||||
ownerId: response.account.id,
|
||||
displayName: response.account.username,
|
||||
roles: response.account.roles ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function logoutOnlineSession(session: PlayerSession | null): Promise<void> {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
@@ -139,7 +155,7 @@ export async function logoutOnlineSession(session: PlayerSession | null): Promis
|
||||
}
|
||||
if (session?.accessToken) {
|
||||
await saveQueue;
|
||||
await requestJson("/api/auth/logout", { method: "POST" }, session.accessToken).catch(() => undefined);
|
||||
await requestOnlineJson("/api/auth/logout", { method: "POST" }, session.accessToken).catch(() => undefined);
|
||||
}
|
||||
if (activeSession?.ownerId === session?.ownerId) activeSession = null;
|
||||
}
|
||||
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
|
||||
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(2);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(3);
|
||||
});
|
||||
|
||||
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
|
||||
|
||||
@@ -14,6 +14,12 @@ import {
|
||||
mergeManastormUnlockedContent,
|
||||
setManastormLoadout,
|
||||
} from "../game/manastormProgress";
|
||||
import {
|
||||
DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
UNIFIED_MANASTORM_BOSS_CATALOG,
|
||||
resolveBossAdminEntry,
|
||||
} from "../game/manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||
|
||||
installDungeonShellRuntime();
|
||||
installManastormShellRuntime();
|
||||
@@ -37,6 +43,29 @@ const character = {
|
||||
lastPlayedAt: 1,
|
||||
};
|
||||
|
||||
function readyBossForMode(modeId: number) {
|
||||
return UNIFIED_MANASTORM_BOSS_CATALOG.find((entry) => (
|
||||
(entry.baseStage?.unlockLevel ?? 1) <= 1
|
||||
&& (entry.baseStage?.minimumPartySize ?? 1) <= 1
|
||||
&& (entry.baseStage?.maximumPartySize ?? 5) >= 5
|
||||
&& (!entry.baseStage?.modeIds?.length || entry.baseStage.modeIds.includes(modeId))
|
||||
&& resolveBossAdminEntry(entry, {
|
||||
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
bosses: { [entry.key]: { status: "ready" } },
|
||||
}).runnable
|
||||
));
|
||||
}
|
||||
const readyLevelingBoss = readyBossForMode(0);
|
||||
const readyEndgameBoss = readyBossForMode(2);
|
||||
if (!readyLevelingBoss || !readyEndgameBoss) throw new Error("Shell tests require runnable Manastorm bosses for both modes.");
|
||||
const readyTestConfig = {
|
||||
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
bosses: {
|
||||
[readyLevelingBoss.key]: { status: "ready" as const },
|
||||
[readyEndgameBoss.key]: { status: "ready" as const },
|
||||
},
|
||||
};
|
||||
|
||||
function prepareSelectedCharacter(): void {
|
||||
useShellStore.setState({
|
||||
phase: "characters",
|
||||
@@ -61,6 +90,7 @@ describe("shell store", () => {
|
||||
});
|
||||
useGameStore.getState().resetAtEntrance();
|
||||
useManastormStore.getState().resetRun();
|
||||
useManastormAdminStore.setState({ config: readyTestConfig, draft: readyTestConfig, dirty: false });
|
||||
});
|
||||
|
||||
it("does not open the character hub or a dungeon without a selected character", () => {
|
||||
|
||||
+62
-3
@@ -5,6 +5,7 @@ import {
|
||||
createCharacter as persistCharacter,
|
||||
deleteCharacter as removeCharacter,
|
||||
listCharacters,
|
||||
loadCombatPresentationSettings,
|
||||
restoreSession as restoreStoredSession,
|
||||
saveSession,
|
||||
updateCharacterActionBindings,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
updateCharacterManastormProgress,
|
||||
updateCharacterProgression,
|
||||
updateCharacterSettings,
|
||||
updateCombatPresentationSettings as persistCombatPresentationSettings,
|
||||
swapRomClasses,
|
||||
} from "./accountRepository";
|
||||
import {
|
||||
@@ -36,6 +38,11 @@ import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults";
|
||||
import type { InventoryItem } from "../game/lootTypes";
|
||||
import type { EquipmentAssignments } from "../game/equipment";
|
||||
import type { GameplaySettings } from "../game/combatStore";
|
||||
import {
|
||||
DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
normalizeCombatPresentationSettings,
|
||||
type CombatPresentationSettings,
|
||||
} from "../game/combatPresentation";
|
||||
import type { PartyRole } from "../game/partyRoles";
|
||||
import type { ManastormPartySize, ManastormProgress } from "../game/manastormProgress";
|
||||
import { dungeonShellRuntime } from "./dungeonShellRuntimeBridge";
|
||||
@@ -44,7 +51,9 @@ import {
|
||||
activateOnlineSession,
|
||||
hydrateOnlineSession,
|
||||
logoutOnlineSession,
|
||||
refreshOnlineSession,
|
||||
} from "./onlineAccountClient";
|
||||
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
||||
|
||||
export interface ShellState {
|
||||
phase: ShellPhase;
|
||||
@@ -56,6 +65,7 @@ export interface ShellState {
|
||||
notice: string;
|
||||
activeDungeonId: DungeonId;
|
||||
activeDungeonRole: PartyRole;
|
||||
combatPresentationSettings: CombatPresentationSettings;
|
||||
beginSession: (session: PlayerSession) => void;
|
||||
signOut: () => void;
|
||||
selectCharacter: (characterId: string) => void;
|
||||
@@ -69,6 +79,7 @@ export interface ShellState {
|
||||
openMainMenu: () => boolean;
|
||||
openDungeons: () => boolean;
|
||||
openManastorms: () => boolean;
|
||||
openGmAdmin: () => boolean;
|
||||
openClassHall: () => boolean;
|
||||
chooseRomSecondaryClass: (classId: RomClassId) => boolean;
|
||||
swapSelectedRomClasses: () => boolean;
|
||||
@@ -89,6 +100,8 @@ export interface ShellState {
|
||||
saveActiveInventory: (inventory: readonly InventoryItem[]) => void;
|
||||
saveActiveEquipment: (equipment: EquipmentAssignments) => void;
|
||||
saveActiveSettings: (settings: GameplaySettings) => void;
|
||||
updateCombatPresentationSettings: (patch: Partial<CombatPresentationSettings>) => void;
|
||||
resetCombatPresentationSettings: () => void;
|
||||
saveActiveManastormProgress: (progress: ManastormProgress) => void;
|
||||
saveSelectedManastormLoadout: (loadout: readonly string[]) => boolean;
|
||||
setNotice: (notice: string) => void;
|
||||
@@ -108,15 +121,22 @@ function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
|
||||
};
|
||||
}
|
||||
|
||||
function restoredState(): Pick<ShellState, "phase" | "session" | "characters" | "selectedCharacterId"> {
|
||||
function restoredState(): Pick<ShellState, "phase" | "session" | "characters" | "selectedCharacterId" | "combatPresentationSettings"> {
|
||||
const session = restoreStoredSession();
|
||||
if (!session) return { phase: "login", session: null, characters: [], selectedCharacterId: null };
|
||||
if (!session) return {
|
||||
phase: "login",
|
||||
session: null,
|
||||
characters: [],
|
||||
selectedCharacterId: null,
|
||||
combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
};
|
||||
const characters = listCharacters(session.ownerId);
|
||||
return {
|
||||
phase: "characters",
|
||||
session,
|
||||
characters,
|
||||
selectedCharacterId: characters[0]?.id ?? null,
|
||||
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,6 +159,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
characters,
|
||||
selectedCharacterId: characters[0]?.id ?? null,
|
||||
activeCharacter: null,
|
||||
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
|
||||
notice: session.kind === "offline" ? "Offline roster loaded." : `Welcome, ${session.displayName}.`,
|
||||
});
|
||||
},
|
||||
@@ -154,6 +175,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
activeCharacter: null,
|
||||
draft: defaultDraft(),
|
||||
notice: "Signed out.",
|
||||
combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
});
|
||||
},
|
||||
selectCharacter: (selectedCharacterId) => set({ selectedCharacterId, notice: "" }),
|
||||
@@ -275,6 +297,19 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({ phase: "manastorms", activeCharacter: null, notice: "" });
|
||||
return true;
|
||||
},
|
||||
openGmAdmin: () => {
|
||||
const { session, selectedCharacterId, characters } = get();
|
||||
if (!isGameMasterSession(session)) {
|
||||
set({ notice: "Game Master access is required." });
|
||||
return false;
|
||||
}
|
||||
if (!selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
|
||||
set({ phase: "characters", notice: "Select a character before opening GM Tools." });
|
||||
return false;
|
||||
}
|
||||
set({ phase: "gm-admin", activeCharacter: null, notice: "" });
|
||||
return true;
|
||||
},
|
||||
openClassHall: () => {
|
||||
const { session, selectedCharacterId, characters } = get();
|
||||
const character = characters.find((entry) => entry.id === selectedCharacterId);
|
||||
@@ -361,6 +396,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
: characters[0]?.id ?? null,
|
||||
activeCharacter: null,
|
||||
notice: "",
|
||||
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
|
||||
});
|
||||
},
|
||||
saveActiveProgression: (level, experience, talentRanks) => {
|
||||
@@ -433,6 +469,24 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
|
||||
}));
|
||||
},
|
||||
updateCombatPresentationSettings: (patch) => {
|
||||
const { session, combatPresentationSettings } = get();
|
||||
const next = normalizeCombatPresentationSettings({
|
||||
...combatPresentationSettings,
|
||||
...patch,
|
||||
}, combatPresentationSettings);
|
||||
const persisted = session
|
||||
? persistCombatPresentationSettings(session.ownerId, next)
|
||||
: next;
|
||||
set({ combatPresentationSettings: persisted });
|
||||
},
|
||||
resetCombatPresentationSettings: () => {
|
||||
const { session } = get();
|
||||
const settings = session
|
||||
? persistCombatPresentationSettings(session.ownerId, DEFAULT_COMBAT_PRESENTATION_SETTINGS)
|
||||
: DEFAULT_COMBAT_PRESENTATION_SETTINGS;
|
||||
set({ combatPresentationSettings: settings });
|
||||
},
|
||||
saveActiveManastormProgress: (progress) => {
|
||||
const { session, activeCharacter } = get();
|
||||
if (!session || !activeCharacter) return;
|
||||
@@ -462,10 +516,15 @@ export function selectedCharacter(state: ShellState): CharacterProfile | null {
|
||||
|
||||
if (restored.session?.accessToken) {
|
||||
activateOnlineSession(restored.session);
|
||||
void hydrateOnlineSession(restored.session).then((characters) => {
|
||||
void Promise.all([
|
||||
hydrateOnlineSession(restored.session),
|
||||
refreshOnlineSession(restored.session),
|
||||
]).then(([characters, refreshedSession]) => {
|
||||
const current = useShellStore.getState().session;
|
||||
if (current?.ownerId !== restored.session?.ownerId) return;
|
||||
saveSession(refreshedSession);
|
||||
useShellStore.setState({
|
||||
session: refreshedSession,
|
||||
characters,
|
||||
selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId)
|
||||
? useShellStore.getState().selectedCharacterId
|
||||
|
||||
+3
-1
@@ -5,13 +5,15 @@ import type { EquipmentAssignments } from "../game/equipment";
|
||||
import type { GameplaySettings } from "../game/combatStore";
|
||||
import type { ManastormProgress } from "../game/manastormProgress";
|
||||
|
||||
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "game";
|
||||
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "gm-admin" | "game";
|
||||
export type SessionKind = "account" | "offline";
|
||||
export type AccountRole = "gm";
|
||||
|
||||
export interface PlayerSession {
|
||||
kind: SessionKind;
|
||||
ownerId: string;
|
||||
displayName: string;
|
||||
roles?: readonly AccountRole[];
|
||||
/** Present for server-backed accounts; kept in session storage only. */
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { respawnDefeatedPlayer } from "./deathRespawn";
|
||||
import { advanceDungeonPartyCombat } from "./partyRuntime";
|
||||
import { useGameStore } from "./store";
|
||||
import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout";
|
||||
import { useManastormStore } from "./manastormStore";
|
||||
import { resolveManastormActorDown } from "./manastormSession";
|
||||
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
||||
|
||||
/**
|
||||
@@ -115,6 +113,9 @@ export function CombatBridge() {
|
||||
const combat = useCombatStore.getState();
|
||||
combat.setPlayerPosition(game.playerPosition);
|
||||
combat.tick(delta, epochNow);
|
||||
if (game.gameMode === "dungeon") {
|
||||
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
|
||||
}
|
||||
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
|
||||
advanceDungeonPartyCombat(epochNow);
|
||||
if (game.gameMode === "dungeon" && game.activeDungeonId === "wailing-caverns") {
|
||||
@@ -123,16 +124,6 @@ export function CombatBridge() {
|
||||
epochNow,
|
||||
);
|
||||
}
|
||||
if (useCombatStore.getState().health <= 0 && game.gameMode === "manastorm") {
|
||||
const resolution = resolveManastormActorDown({ kind: "player" });
|
||||
if (resolution === "failed") useShellStore.getState().failActiveManastorm();
|
||||
return;
|
||||
}
|
||||
if (respawnDefeatedPlayer()) {
|
||||
// resetAtEntrance increments this revision. Mark it consumed because
|
||||
// respawnDefeatedPlayer already reset combat atomically.
|
||||
mountedResetRevision.current = useGameStore.getState().resetRevision;
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
return () => window.clearInterval(timer);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { preloadManastormStage } from "./manastormStageLoader";
|
||||
/** Connects staged combat, party sizing, and shared resurrections to Manastorm state. */
|
||||
export function ManastormBridge() {
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const gmTestMode = useGameStore((state) => state.gmTestMode);
|
||||
const status = useManastormStore((state) => state.status);
|
||||
const partySize = useManastormStore((state) => state.partySize);
|
||||
const encounter = useManastormStore((state) => state.currentEncounter);
|
||||
@@ -103,7 +104,7 @@ export function ManastormBridge() {
|
||||
useEffect(() => {
|
||||
if (!characterId) return;
|
||||
return useManastormStore.subscribe((state, previous) => {
|
||||
if (useGameStore.getState().gameMode !== "manastorm") return;
|
||||
if (useGameStore.getState().gameMode !== "manastorm" || useGameStore.getState().gmTestMode) return;
|
||||
const character = useShellStore.getState().activeCharacter;
|
||||
if (!character || character.id !== characterId) return;
|
||||
const result = synchronizeManastormProgress(
|
||||
@@ -116,7 +117,7 @@ export function ManastormBridge() {
|
||||
useShellStore.getState().saveActiveManastormProgress(result.progress);
|
||||
}
|
||||
});
|
||||
}, [characterId]);
|
||||
}, [characterId, gmTestMode]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import {
|
||||
combatEffectManifestEntry,
|
||||
type CombatActorGroup,
|
||||
type CombatEffectSoundAsset,
|
||||
type CombatPresentationEvent,
|
||||
type CombatPresentationSettings,
|
||||
type CombatPresentationSchool,
|
||||
} from "./combatPresentation";
|
||||
|
||||
const MAX_COMBAT_VOICES = 32;
|
||||
|
||||
interface ActiveVoice {
|
||||
readonly group: CombatActorGroup;
|
||||
readonly stop: () => void;
|
||||
}
|
||||
|
||||
let context: AudioContext | null = null;
|
||||
let masterGain: GainNode | null = null;
|
||||
let groupGains: Record<CombatActorGroup, GainNode> | null = null;
|
||||
let configuredSettings: CombatPresentationSettings | null = null;
|
||||
let configuredMasterVolume = 0.8;
|
||||
const activeVoices: ActiveVoice[] = [];
|
||||
const activeMasterVoices = new Set<() => void>();
|
||||
const decodedBuffers = new Map<string, Promise<AudioBuffer | null>>();
|
||||
|
||||
function audioContextConstructor(): typeof AudioContext | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.AudioContext
|
||||
?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||
?? null;
|
||||
}
|
||||
|
||||
function ensureContext(): AudioContext | null {
|
||||
if (context) return context;
|
||||
const Constructor = audioContextConstructor();
|
||||
if (!Constructor) return null;
|
||||
context = new Constructor();
|
||||
masterGain = context.createGain();
|
||||
masterGain.connect(context.destination);
|
||||
groupGains = {
|
||||
player: context.createGain(),
|
||||
party: context.createGain(),
|
||||
enemy: context.createGain(),
|
||||
};
|
||||
for (const gain of Object.values(groupGains)) gain.connect(masterGain);
|
||||
applyCombatAudioSettings();
|
||||
return context;
|
||||
}
|
||||
|
||||
function applyCombatAudioSettings(): void {
|
||||
if (!context || !masterGain || !groupGains || !configuredSettings) return;
|
||||
masterGain.gain.setTargetAtTime(configuredMasterVolume, context.currentTime, 0.02);
|
||||
for (const group of ["player", "party", "enemy"] as const) {
|
||||
const volume = configuredSettings.audioEnabled[group]
|
||||
? configuredSettings.audioVolume[group]
|
||||
: 0;
|
||||
groupGains[group].gain.setTargetAtTime(volume, context.currentTime, 0.02);
|
||||
if (volume <= 0) stopCombatAudioGroup(group);
|
||||
}
|
||||
}
|
||||
|
||||
export function configureCombatAudio(
|
||||
settings: CombatPresentationSettings,
|
||||
masterVolume: number,
|
||||
): void {
|
||||
configuredSettings = settings;
|
||||
configuredMasterVolume = Number.isFinite(masterVolume)
|
||||
? Math.max(0, Math.min(1, masterVolume))
|
||||
: 0.8;
|
||||
applyCombatAudioSettings();
|
||||
}
|
||||
|
||||
function positionPanner(
|
||||
audioContext: AudioContext,
|
||||
event: CombatPresentationEvent,
|
||||
): PannerNode | StereoPannerNode {
|
||||
if (typeof audioContext.createPanner === "function") {
|
||||
const panner = audioContext.createPanner();
|
||||
panner.panningModel = "HRTF";
|
||||
panner.distanceModel = "inverse";
|
||||
panner.refDistance = event.actorGroup === "player" ? 8 : 5;
|
||||
panner.maxDistance = 55;
|
||||
panner.rolloffFactor = event.actorGroup === "player" ? 0.15 : 0.8;
|
||||
panner.positionX.value = event.origin[0];
|
||||
panner.positionY.value = event.origin[1];
|
||||
panner.positionZ.value = event.origin[2];
|
||||
return panner;
|
||||
}
|
||||
return audioContext.createStereoPanner();
|
||||
}
|
||||
|
||||
function trimVoices(): void {
|
||||
while (activeVoices.length >= MAX_COMBAT_VOICES) activeVoices.shift()?.stop();
|
||||
}
|
||||
|
||||
function registerVoice(voice: ActiveVoice): () => void {
|
||||
trimVoices();
|
||||
activeVoices.push(voice);
|
||||
return () => {
|
||||
const index = activeVoices.indexOf(voice);
|
||||
if (index >= 0) activeVoices.splice(index, 1);
|
||||
};
|
||||
}
|
||||
|
||||
const SCHOOL_FREQUENCY: Readonly<Record<CombatPresentationSchool, number>> = {
|
||||
physical: 115,
|
||||
arcane: 520,
|
||||
fire: 190,
|
||||
frost: 720,
|
||||
nature: 360,
|
||||
shadow: 145,
|
||||
holy: 660,
|
||||
};
|
||||
|
||||
function playSynthesizedCombatCue(event: CombatPresentationEvent): void {
|
||||
const audioContext = ensureContext();
|
||||
if (!audioContext || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return;
|
||||
void audioContext.resume().catch(() => undefined);
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const envelope = audioContext.createGain();
|
||||
const panner = positionPanner(audioContext, event);
|
||||
const start = audioContext.currentTime;
|
||||
const isImpact = event.phase === "impact" || event.delivery === "melee";
|
||||
const duration = event.phase === "cast-start" ? 0.22 : isImpact ? 0.12 : event.phase === "tick" ? 0.1 : 0.18;
|
||||
const base = SCHOOL_FREQUENCY[event.school];
|
||||
oscillator.type = event.school === "physical" ? "sawtooth" : event.school === "holy" ? "sine" : "triangle";
|
||||
oscillator.frequency.setValueAtTime(base * (event.phase === "cast-start" ? 0.7 : 1), start);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(Math.max(35, base * (isImpact ? 0.42 : 1.55)), start + duration);
|
||||
envelope.gain.setValueAtTime(0.0001, start);
|
||||
envelope.gain.exponentialRampToValueAtTime(event.actorGroup === "player" ? 0.12 : 0.085, start + 0.015);
|
||||
envelope.gain.exponentialRampToValueAtTime(0.0001, start + duration);
|
||||
oscillator.connect(envelope);
|
||||
envelope.connect(panner);
|
||||
panner.connect(groupGains[event.actorGroup]);
|
||||
let stopped = false;
|
||||
const voice: ActiveVoice = {
|
||||
group: event.actorGroup,
|
||||
stop: () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
try { oscillator.stop(); } catch { /* already stopped */ }
|
||||
},
|
||||
};
|
||||
const unregister = registerVoice(voice);
|
||||
oscillator.onended = () => {
|
||||
unregister();
|
||||
oscillator.disconnect();
|
||||
envelope.disconnect();
|
||||
panner.disconnect();
|
||||
};
|
||||
oscillator.start(start);
|
||||
oscillator.stop(start + duration + 0.02);
|
||||
}
|
||||
|
||||
function weightedSound(event: CombatPresentationEvent): CombatEffectSoundAsset | null {
|
||||
const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? [];
|
||||
const playable = sounds.filter((sound) => sound.url);
|
||||
if (!playable.length) return null;
|
||||
const total = playable.reduce((sum, sound) => sum + Math.max(1, sound.weight ?? 1), 0);
|
||||
let cursor = (event.id * 0.61803398875 % 1) * total;
|
||||
for (const sound of playable) {
|
||||
cursor -= Math.max(1, sound.weight ?? 1);
|
||||
if (cursor <= 0) return sound;
|
||||
}
|
||||
return playable[playable.length - 1] ?? null;
|
||||
}
|
||||
|
||||
async function decodeAudio(url: string): Promise<AudioBuffer | null> {
|
||||
const audioContext = ensureContext();
|
||||
if (!audioContext) return null;
|
||||
if (!decodedBuffers.has(url)) {
|
||||
decodedBuffers.set(url, fetch(url)
|
||||
.then((response) => response.ok ? response.arrayBuffer() : Promise.reject(new Error(String(response.status))))
|
||||
.then((data) => audioContext.decodeAudioData(data))
|
||||
.catch(() => null));
|
||||
}
|
||||
return decodedBuffers.get(url)!;
|
||||
}
|
||||
|
||||
export async function playMasterAudioCue(
|
||||
url: string,
|
||||
volume = 1,
|
||||
): Promise<(() => void) | null> {
|
||||
const audioContext = ensureContext();
|
||||
const buffer = await decodeAudio(url);
|
||||
if (!audioContext || !buffer || !masterGain) return null;
|
||||
void audioContext.resume().catch(() => undefined);
|
||||
const source = audioContext.createBufferSource();
|
||||
const envelope = audioContext.createGain();
|
||||
source.buffer = buffer;
|
||||
envelope.gain.value = Math.max(0, Math.min(1, volume));
|
||||
source.connect(envelope);
|
||||
envelope.connect(masterGain);
|
||||
let stopped = false;
|
||||
const stop = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
activeMasterVoices.delete(stop);
|
||||
try { source.stop(); } catch { /* already stopped */ }
|
||||
};
|
||||
activeMasterVoices.add(stop);
|
||||
source.onended = () => {
|
||||
stopped = true;
|
||||
activeMasterVoices.delete(stop);
|
||||
source.disconnect();
|
||||
envelope.disconnect();
|
||||
};
|
||||
source.start();
|
||||
return stop;
|
||||
}
|
||||
|
||||
async function playBufferedCombatCue(event: CombatPresentationEvent, sound: CombatEffectSoundAsset): Promise<boolean> {
|
||||
if (!sound.url) return false;
|
||||
const audioContext = ensureContext();
|
||||
const buffer = await decodeAudio(sound.url);
|
||||
if (!audioContext || !buffer || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return false;
|
||||
void audioContext.resume().catch(() => undefined);
|
||||
const source = audioContext.createBufferSource();
|
||||
const envelope = audioContext.createGain();
|
||||
const panner = positionPanner(audioContext, event);
|
||||
source.buffer = buffer;
|
||||
envelope.gain.value = Math.max(0, Math.min(1, sound.volume ?? 1));
|
||||
source.connect(envelope);
|
||||
envelope.connect(panner);
|
||||
panner.connect(groupGains[event.actorGroup]);
|
||||
let stopped = false;
|
||||
const voice: ActiveVoice = {
|
||||
group: event.actorGroup,
|
||||
stop: () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
try { source.stop(); } catch { /* already stopped */ }
|
||||
},
|
||||
};
|
||||
const unregister = registerVoice(voice);
|
||||
source.onended = () => {
|
||||
unregister();
|
||||
source.disconnect();
|
||||
envelope.disconnect();
|
||||
panner.disconnect();
|
||||
};
|
||||
source.start();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function playCombatAudio(event: CombatPresentationEvent): void {
|
||||
if (event.phase === "cast-cancel") {
|
||||
stopCombatAudioGroup(event.actorGroup);
|
||||
return;
|
||||
}
|
||||
if (!configuredSettings?.audioEnabled[event.actorGroup] || configuredSettings.audioVolume[event.actorGroup] <= 0) return;
|
||||
const sound = weightedSound(event);
|
||||
if (!sound) {
|
||||
playSynthesizedCombatCue(event);
|
||||
return;
|
||||
}
|
||||
void playBufferedCombatCue(event, sound).then((played) => {
|
||||
if (!played) playSynthesizedCombatCue(event);
|
||||
});
|
||||
}
|
||||
|
||||
export function stopCombatAudioGroup(group: CombatActorGroup): void {
|
||||
for (const voice of [...activeVoices]) {
|
||||
if (voice.group === group) voice.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export function resetCombatAudio(): void {
|
||||
for (const voice of [...activeVoices]) voice.stop();
|
||||
for (const stop of [...activeMasterVoices]) stop();
|
||||
activeVoices.length = 0;
|
||||
}
|
||||
|
||||
export function setCombatAudioListener(
|
||||
position: readonly [number, number, number],
|
||||
forward: readonly [number, number, number],
|
||||
up: readonly [number, number, number] = [0, 1, 0],
|
||||
): void {
|
||||
if (!context) return;
|
||||
const listener = context.listener;
|
||||
const now = context.currentTime;
|
||||
listener.positionX?.setTargetAtTime(position[0], now, 0.01);
|
||||
listener.positionY?.setTargetAtTime(position[1], now, 0.01);
|
||||
listener.positionZ?.setTargetAtTime(position[2], now, 0.01);
|
||||
listener.forwardX?.setTargetAtTime(forward[0], now, 0.01);
|
||||
listener.forwardY?.setTargetAtTime(forward[1], now, 0.01);
|
||||
listener.forwardZ?.setTargetAtTime(forward[2], now, 0.01);
|
||||
listener.upX?.setTargetAtTime(up[0], now, 0.01);
|
||||
listener.upY?.setTargetAtTime(up[1], now, 0.01);
|
||||
listener.upZ?.setTargetAtTime(up[2], now, 0.01);
|
||||
}
|
||||
|
||||
export function activeCombatAudioVoiceCount(): number {
|
||||
return activeVoices.length;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { abilityById } from "./abilityCatalog";
|
||||
import {
|
||||
DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
combatEffectManifestEntry,
|
||||
combatEffectStyle,
|
||||
combatPresentationSessionId,
|
||||
emitCombatPresentation,
|
||||
normalizeCombatPresentationSettings,
|
||||
presentationDeliveryForAbility,
|
||||
presentationSchoolForAbility,
|
||||
resetCombatPresentationSession,
|
||||
subscribeCombatPresentation,
|
||||
type CombatPresentationMessage,
|
||||
} from "./combatPresentation";
|
||||
|
||||
describe("combat presentation", () => {
|
||||
it("normalizes owner settings without leaking partial or invalid values", () => {
|
||||
expect(normalizeCombatPresentationSettings({
|
||||
vfxQuality: "low",
|
||||
vfxEnabled: { player: false },
|
||||
audioEnabled: { enemy: false },
|
||||
audioVolume: { player: 2, party: -1, enemy: 0.35 },
|
||||
})).toEqual({
|
||||
vfxQuality: "low",
|
||||
vfxEnabled: { player: false, party: true, enemy: true },
|
||||
audioEnabled: { player: true, party: true, enemy: false },
|
||||
audioVolume: { player: 1, party: 0, enemy: 0.35 },
|
||||
});
|
||||
expect(normalizeCombatPresentationSettings(null)).toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
|
||||
});
|
||||
|
||||
it("delivers immutable, exactly-once events and a session reset", () => {
|
||||
const messages: CombatPresentationMessage[] = [];
|
||||
const unsubscribe = subscribeCombatPresentation((message) => messages.push(message));
|
||||
const origin = [1, 2, 3] as [number, number, number];
|
||||
const event = emitCombatPresentation({
|
||||
occurredAt: 1_000,
|
||||
phase: "release",
|
||||
actorGroup: "player",
|
||||
sourceActorId: "player",
|
||||
targetActorId: "mob",
|
||||
abilityId: "fixture",
|
||||
sourceSpellId: 116,
|
||||
name: "Frostbolt",
|
||||
source: "wow335",
|
||||
school: "frost",
|
||||
delivery: "projectile",
|
||||
origin,
|
||||
targetPosition: [8, 0, 0],
|
||||
});
|
||||
origin[0] = 99;
|
||||
const nextSession = resetCombatPresentationSession();
|
||||
unsubscribe();
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual({ kind: "event", event });
|
||||
expect(event.origin).toEqual([1, 2, 3]);
|
||||
expect(messages[1]).toEqual({ kind: "reset", sessionId: nextSession });
|
||||
expect(combatPresentationSessionId()).toBe(nextSession);
|
||||
});
|
||||
|
||||
it("uses authentic source descriptors and school/delivery fallbacks", () => {
|
||||
const frostbolt = abilityById("mage-frostbolt");
|
||||
expect(frostbolt).not.toBeNull();
|
||||
expect(presentationSchoolForAbility(frostbolt!)).toBe("frost");
|
||||
expect(presentationDeliveryForAbility(frostbolt!)).toBe("projectile");
|
||||
expect(combatEffectManifestEntry(999_999, "wow335")).toBeNull();
|
||||
const style = combatEffectStyle({
|
||||
id: 1,
|
||||
sessionId: 1,
|
||||
occurredAt: 1,
|
||||
phase: "release",
|
||||
actorGroup: "player",
|
||||
sourceActorId: "player",
|
||||
abilityId: "mage-frostbolt",
|
||||
sourceSpellId: 999_999,
|
||||
name: "Frostbolt",
|
||||
source: "wow335",
|
||||
school: "frost",
|
||||
delivery: "projectile",
|
||||
origin: [0, 0, 0],
|
||||
});
|
||||
expect(style.authentic).toBe(false);
|
||||
expect(style.sourceModelNames).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
|
||||
export type CombatActorGroup = "player" | "party" | "enemy";
|
||||
export type CombatVfxQuality = "off" | "low" | "high";
|
||||
export type CombatPresentationPhase =
|
||||
| "cast-start"
|
||||
| "cast-cancel"
|
||||
| "release"
|
||||
| "impact"
|
||||
| "tick"
|
||||
| "aura-start"
|
||||
| "aura-end";
|
||||
export type CombatPresentationDelivery = "melee" | "projectile" | "area" | "channel" | "aura";
|
||||
export type CombatPresentationSchool =
|
||||
| "physical"
|
||||
| "arcane"
|
||||
| "fire"
|
||||
| "frost"
|
||||
| "nature"
|
||||
| "shadow"
|
||||
| "holy";
|
||||
export type CombatPresentationSource = "wow335" | "ascension" | "runewaker" | "fallback";
|
||||
export type CombatPresentationPosition = readonly [number, number, number];
|
||||
|
||||
export interface CombatPresentationSettings {
|
||||
readonly vfxQuality: CombatVfxQuality;
|
||||
readonly vfxEnabled: Readonly<Record<CombatActorGroup, boolean>>;
|
||||
readonly audioEnabled: Readonly<Record<CombatActorGroup, boolean>>;
|
||||
readonly audioVolume: Readonly<Record<CombatActorGroup, number>>;
|
||||
}
|
||||
|
||||
const ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
|
||||
|
||||
export const DEFAULT_COMBAT_PRESENTATION_SETTINGS: CombatPresentationSettings = Object.freeze({
|
||||
vfxQuality: "high",
|
||||
vfxEnabled: Object.freeze({ player: true, party: true, enemy: true }),
|
||||
audioEnabled: Object.freeze({ player: true, party: true, enemy: true }),
|
||||
audioVolume: Object.freeze({ player: 1, party: 1, enemy: 1 }),
|
||||
});
|
||||
|
||||
function finiteVolume(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? Math.max(0, Math.min(1, value))
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function booleanGroups(
|
||||
value: unknown,
|
||||
fallback: Readonly<Record<CombatActorGroup, boolean>>,
|
||||
): Readonly<Record<CombatActorGroup, boolean>> {
|
||||
const record = value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Partial<Record<CombatActorGroup, unknown>>
|
||||
: {};
|
||||
return Object.freeze(Object.fromEntries(ACTOR_GROUPS.map((group) => [
|
||||
group,
|
||||
typeof record[group] === "boolean" ? record[group] : fallback[group],
|
||||
])) as Record<CombatActorGroup, boolean>);
|
||||
}
|
||||
|
||||
function volumeGroups(
|
||||
value: unknown,
|
||||
fallback: Readonly<Record<CombatActorGroup, number>>,
|
||||
): Readonly<Record<CombatActorGroup, number>> {
|
||||
const record = value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Partial<Record<CombatActorGroup, unknown>>
|
||||
: {};
|
||||
return Object.freeze(Object.fromEntries(ACTOR_GROUPS.map((group) => [
|
||||
group,
|
||||
finiteVolume(record[group], fallback[group]),
|
||||
])) as Record<CombatActorGroup, number>);
|
||||
}
|
||||
|
||||
export function normalizeCombatPresentationSettings(
|
||||
value: unknown,
|
||||
fallback: CombatPresentationSettings = DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
): CombatPresentationSettings {
|
||||
const record = value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Partial<Record<keyof CombatPresentationSettings, unknown>>
|
||||
: {};
|
||||
const vfxQuality = record.vfxQuality === "off" || record.vfxQuality === "low" || record.vfxQuality === "high"
|
||||
? record.vfxQuality
|
||||
: fallback.vfxQuality;
|
||||
return Object.freeze({
|
||||
vfxQuality,
|
||||
vfxEnabled: booleanGroups(record.vfxEnabled, fallback.vfxEnabled),
|
||||
audioEnabled: booleanGroups(record.audioEnabled, fallback.audioEnabled),
|
||||
audioVolume: volumeGroups(record.audioVolume, fallback.audioVolume),
|
||||
});
|
||||
}
|
||||
|
||||
export interface CombatPresentationEvent {
|
||||
readonly id: number;
|
||||
readonly sessionId: number;
|
||||
readonly occurredAt: number;
|
||||
readonly phase: CombatPresentationPhase;
|
||||
readonly actorGroup: CombatActorGroup;
|
||||
readonly sourceActorId: string;
|
||||
readonly targetActorId?: string;
|
||||
readonly abilityId: string;
|
||||
readonly sourceSpellId?: number;
|
||||
readonly name: string;
|
||||
readonly source: CombatPresentationSource;
|
||||
readonly school: CombatPresentationSchool;
|
||||
readonly delivery: CombatPresentationDelivery;
|
||||
readonly origin: CombatPresentationPosition;
|
||||
readonly targetPosition?: CombatPresentationPosition;
|
||||
readonly radius?: number;
|
||||
readonly durationMs?: number;
|
||||
}
|
||||
|
||||
export type CombatPresentationEventInput = Omit<CombatPresentationEvent, "id" | "sessionId">;
|
||||
|
||||
export type CombatPresentationMessage =
|
||||
| { readonly kind: "event"; readonly event: CombatPresentationEvent }
|
||||
| { readonly kind: "reset"; readonly sessionId: number };
|
||||
|
||||
type CombatPresentationListener = (message: CombatPresentationMessage) => void;
|
||||
const presentationListeners = new Set<CombatPresentationListener>();
|
||||
let presentationSequence = 0;
|
||||
let presentationSessionId = 1;
|
||||
|
||||
export function subscribeCombatPresentation(listener: CombatPresentationListener): () => void {
|
||||
presentationListeners.add(listener);
|
||||
return () => presentationListeners.delete(listener);
|
||||
}
|
||||
|
||||
export function emitCombatPresentation(input: CombatPresentationEventInput): CombatPresentationEvent {
|
||||
presentationSequence += 1;
|
||||
const event: CombatPresentationEvent = Object.freeze({
|
||||
...input,
|
||||
id: presentationSequence,
|
||||
sessionId: presentationSessionId,
|
||||
origin: [...input.origin] as CombatPresentationPosition,
|
||||
...(input.targetPosition
|
||||
? { targetPosition: [...input.targetPosition] as CombatPresentationPosition }
|
||||
: {}),
|
||||
});
|
||||
for (const listener of presentationListeners) listener({ kind: "event", event });
|
||||
return event;
|
||||
}
|
||||
|
||||
export function resetCombatPresentationSession(): number {
|
||||
presentationSessionId += 1;
|
||||
for (const listener of presentationListeners) {
|
||||
listener({ kind: "reset", sessionId: presentationSessionId });
|
||||
}
|
||||
return presentationSessionId;
|
||||
}
|
||||
|
||||
export function combatPresentationSessionId(): number {
|
||||
return presentationSessionId;
|
||||
}
|
||||
|
||||
export interface CombatEffectPhaseAsset {
|
||||
readonly model?: string;
|
||||
readonly attachment?: string;
|
||||
readonly scale?: number;
|
||||
}
|
||||
|
||||
export interface CombatEffectSoundAsset {
|
||||
readonly url?: string;
|
||||
readonly sourcePath?: string;
|
||||
readonly weight?: number;
|
||||
readonly volume?: number;
|
||||
readonly minimumDistance?: number;
|
||||
readonly maximumDistance?: number;
|
||||
}
|
||||
|
||||
export interface CombatEffectManifestEntry {
|
||||
readonly key: string;
|
||||
readonly source: Exclude<CombatPresentationSource, "fallback">;
|
||||
readonly spellId: number;
|
||||
readonly visualIds?: readonly number[];
|
||||
readonly phases?: Readonly<Partial<Record<CombatPresentationPhase, readonly CombatEffectPhaseAsset[]>>>;
|
||||
readonly sounds?: readonly CombatEffectSoundAsset[];
|
||||
}
|
||||
|
||||
interface GeneratedCombatEffectManifest {
|
||||
readonly schemaVersion: number;
|
||||
readonly entries: readonly CombatEffectManifestEntry[];
|
||||
}
|
||||
|
||||
const COMBAT_EFFECT_MANIFEST_URL = "/assets/game/combat-effects/manifest.json";
|
||||
const manifestByKey = new Map<string, CombatEffectManifestEntry>();
|
||||
const manifestBySpellId = new Map<number, CombatEffectManifestEntry>();
|
||||
let manifestRequest: Promise<void> | null = null;
|
||||
|
||||
function installCombatEffectManifest(manifest: GeneratedCombatEffectManifest): void {
|
||||
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.entries)) return;
|
||||
for (const entry of manifest.entries) {
|
||||
manifestByKey.set(entry.key, entry);
|
||||
if (!manifestBySpellId.has(entry.spellId)) manifestBySpellId.set(entry.spellId, entry);
|
||||
}
|
||||
}
|
||||
|
||||
export function preloadCombatEffectManifest(): Promise<void> {
|
||||
if (manifestRequest) return manifestRequest;
|
||||
if (typeof fetch === "undefined") return Promise.resolve();
|
||||
manifestRequest = fetch(COMBAT_EFFECT_MANIFEST_URL)
|
||||
.then((response) => response.ok ? response.json() : Promise.reject(new Error(String(response.status))))
|
||||
.then((manifest) => installCombatEffectManifest(manifest as GeneratedCombatEffectManifest))
|
||||
.catch(() => undefined);
|
||||
return manifestRequest;
|
||||
}
|
||||
|
||||
export function combatEffectManifestEntry(
|
||||
spellId: number | undefined,
|
||||
source?: CombatPresentationSource,
|
||||
): CombatEffectManifestEntry | null {
|
||||
if (!spellId) return null;
|
||||
if (source && source !== "fallback") {
|
||||
const exact = manifestByKey.get(`${source}:${spellId}`);
|
||||
if (exact) return exact;
|
||||
}
|
||||
return manifestBySpellId.get(spellId) ?? null;
|
||||
}
|
||||
|
||||
function abilityHasEffect(ability: AbilityDefinition, kind: AbilityEffect["kind"]): boolean {
|
||||
return ability.effects.some((effect) => effect.kind === kind);
|
||||
}
|
||||
|
||||
export function presentationSourceForAbility(ability: AbilityDefinition): CombatPresentationSource {
|
||||
if (ability.source === "runewaker") return "runewaker";
|
||||
if (ability.source?.startsWith("ascension") || ability.source?.startsWith("coa")) return "ascension";
|
||||
return "wow335";
|
||||
}
|
||||
|
||||
export function presentationSchoolForAbility(ability: AbilityDefinition): CombatPresentationSchool {
|
||||
const name = `${ability.name} ${ability.description}`.toLowerCase();
|
||||
if (/fire|flame|burn|inferno|pyro|molten/.test(name)) return "fire";
|
||||
if (/frost|ice|rime|chill|snow/.test(name)) return "frost";
|
||||
if (/shadow|void|curse|corrupt|death|plague|fel|blood/.test(name)) return "shadow";
|
||||
if (/arcane|mana|time|chron|runic|astral/.test(name)) return "arcane";
|
||||
if (/holy|light|smite|divine|sun|heal|renew|rejuven|salvation|blessing/.test(name)) return "holy";
|
||||
if (/nature|earth|storm|lightning|thunder|wind|wrath|root|poison|venom/.test(name)) return "nature";
|
||||
if (abilityHasEffect(ability, "heal") || abilityHasEffect(ability, "hot") || abilityHasEffect(ability, "resurrection")) {
|
||||
return ["druid", "shaman", "rom-druid", "rom-warden"].includes(ability.classId) ? "nature" : "holy";
|
||||
}
|
||||
return "physical";
|
||||
}
|
||||
|
||||
export function presentationDeliveryForAbility(ability: AbilityDefinition): CombatPresentationDelivery {
|
||||
if (ability.castMode === "channel") return "channel";
|
||||
if (ability.radius !== undefined && ability.radius > 0) return "area";
|
||||
if (ability.target === "hostile") return ability.range.max <= 4.5 ? "melee" : "projectile";
|
||||
if (abilityHasEffect(ability, "apply-aura") || abilityHasEffect(ability, "shield") || abilityHasEffect(ability, "hot")) {
|
||||
return "aura";
|
||||
}
|
||||
return "projectile";
|
||||
}
|
||||
|
||||
export function presentationSourceForSpellId(spellId: number | undefined): CombatPresentationSource {
|
||||
if (!spellId) return "fallback";
|
||||
const mapped = manifestBySpellId.get(spellId);
|
||||
if (mapped) return mapped.source;
|
||||
return spellId >= 490_000 && spellId < 600_000 ? "runewaker" : "wow335";
|
||||
}
|
||||
|
||||
export interface CombatEffectStyle {
|
||||
readonly primary: string;
|
||||
readonly secondary: string;
|
||||
readonly emissive: number;
|
||||
readonly authentic: boolean;
|
||||
readonly sourceModelNames: readonly string[];
|
||||
}
|
||||
|
||||
const SCHOOL_STYLE: Readonly<Record<CombatPresentationSchool, Omit<CombatEffectStyle, "authentic" | "sourceModelNames">>> = {
|
||||
physical: { primary: "#f3d18a", secondary: "#ffffff", emissive: 1.2 },
|
||||
arcane: { primary: "#b774ff", secondary: "#76d8ff", emissive: 1.8 },
|
||||
fire: { primary: "#ff6a20", secondary: "#ffd24a", emissive: 2.2 },
|
||||
frost: { primary: "#72d8ff", secondary: "#e8fbff", emissive: 1.8 },
|
||||
nature: { primary: "#64e06e", secondary: "#d4ff73", emissive: 1.7 },
|
||||
shadow: { primary: "#8a4de8", secondary: "#ed5cff", emissive: 1.9 },
|
||||
holy: { primary: "#ffe67a", secondary: "#fff7d0", emissive: 2 },
|
||||
};
|
||||
|
||||
export function combatEffectStyle(event: CombatPresentationEvent): CombatEffectStyle {
|
||||
const entry = combatEffectManifestEntry(event.sourceSpellId, event.source);
|
||||
const sourceModelNames = Object.values(entry?.phases ?? {})
|
||||
.flatMap((assets) => assets ?? [])
|
||||
.flatMap((asset) => asset.model ? [asset.model] : []);
|
||||
const inferred = sourceModelNames.join(" ").toLowerCase();
|
||||
const school = /fire|flame|molten/.test(inferred)
|
||||
? "fire"
|
||||
: /frost|ice|snow/.test(inferred)
|
||||
? "frost"
|
||||
: /shadow|void|dark/.test(inferred)
|
||||
? "shadow"
|
||||
: /holy|light|heal/.test(inferred)
|
||||
? "holy"
|
||||
: /nature|earth|lightning|wind/.test(inferred)
|
||||
? "nature"
|
||||
: event.school;
|
||||
return { ...SCHOOL_STYLE[school], authentic: Boolean(entry), sourceModelNames };
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { abilitiesForClass, defaultActionBarForClass } from "./abilityCatalog";
|
||||
import { defaultActionBindings } from "./actionBindings";
|
||||
import { deriveCharacterStats } from "./wotlkStats";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
|
||||
function allocateTalentRanks(name: string, ranks: number): void {
|
||||
const talent = TALENT_NODES.find((node) => node.name === name);
|
||||
@@ -94,6 +95,26 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
|
||||
});
|
||||
|
||||
it("emits exactly one cast and release event and cancels presentation with the cast", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
|
||||
registerTarget("presentation-target", [10, 0, 0]);
|
||||
const events: CombatPresentationEvent[] = [];
|
||||
const unsubscribe = subscribeCombatPresentation((message) => {
|
||||
if (message.kind === "event") events.push(message.event);
|
||||
});
|
||||
|
||||
expect(useCombatStore.getState().castAbility("mage-frostbolt", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
useCombatStore.getState().tick(0.1, 2_500);
|
||||
expect(events.filter((event) => event.abilityId === "mage-frostbolt").map((event) => event.phase))
|
||||
.toEqual(["cast-start", "release", "impact"]);
|
||||
|
||||
expect(useCombatStore.getState().castAbility("mage-frostbolt", [0, 0, 0], 4_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().cancelCast()).toBe(true);
|
||||
expect(events.filter((event) => event.abilityId === "mage-frostbolt").map((event) => event.phase))
|
||||
.toEqual(["cast-start", "release", "impact", "cast-start", "cast-cancel"]);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("does not cast or bind Priest Holy Concentration", () => {
|
||||
const abilityId = "wow335-priest-holy-concentration";
|
||||
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({
|
||||
@@ -103,6 +124,20 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().setActionBinding("primary", "face-bottom", abilityId)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires an explicit resurrection action to restore a defeated player", () => {
|
||||
const maxHealth = useCombatStore.getState().maxHealth;
|
||||
useCombatStore.getState().damagePlayer(maxHealth * 10);
|
||||
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().healPlayer(maxHealth)).toBe(0);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
|
||||
const restored = useCombatStore.getState().revivePlayer(0.35);
|
||||
expect(restored).toBe(Math.max(1, Math.round(maxHealth * 0.35)));
|
||||
expect(useCombatStore.getState().health).toBe(restored);
|
||||
expect(useCombatStore.getState().revivePlayer(1)).toBe(0);
|
||||
});
|
||||
|
||||
it("starts with attribute-derived pools including racial and automatic class passives", () => {
|
||||
const gnomeMage = deriveCharacterStats("mage", 1, EMPTY_ITEM_STATS, "gnome");
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", raceId: "gnome", level: 1 });
|
||||
@@ -385,6 +420,94 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().health).toBe(healthBefore - 12);
|
||||
});
|
||||
|
||||
it("automatically engages a dungeon pack when a living party actor enters its aggro range", () => {
|
||||
const partyMemberId = preparePartyMember(0, [40, 0, 0]);
|
||||
for (const [id, position] of [["proximity-pack:a", [20, 0, 0]], ["proximity-pack:b", [22, 0, 0]]] as const) {
|
||||
setMobPosition(id, position);
|
||||
useCombatStore.getState().registerMob(id, {
|
||||
name: id,
|
||||
maxHealth: 100,
|
||||
});
|
||||
}
|
||||
setMobPosition("proximity-boss", [100, 0, 0]);
|
||||
useCombatStore.getState().registerMob("proximity-boss", {
|
||||
name: "Proximity Boss",
|
||||
boss: true,
|
||||
maxHealth: 100,
|
||||
});
|
||||
|
||||
expect(useCombatStore.getState().mobs["proximity-pack:a"].aggroRange).toBe(16);
|
||||
expect(useCombatStore.getState().mobs["proximity-boss"].aggroRange).toBe(20);
|
||||
|
||||
expect(useCombatStore.getState().engageNearbyMobs(1_000, [0, 0, 0])).toBe(0);
|
||||
expect(useCombatStore.getState().mobs["proximity-pack:a"].engaged).toBe(false);
|
||||
|
||||
updatePartyRuntimePosition(partyMemberId, 27, 0, 0);
|
||||
expect(useCombatStore.getState().engageNearbyMobs(1_100, [0, 0, 0])).toBe(1);
|
||||
expect(useCombatStore.getState().mobs["proximity-pack:a"]).toMatchObject({
|
||||
engaged: true,
|
||||
targetActorId: partyMemberId,
|
||||
homePosition: [20, 0, 0],
|
||||
});
|
||||
expect(useCombatStore.getState().mobs["proximity-pack:b"]).toMatchObject({
|
||||
engaged: true,
|
||||
targetActorId: partyMemberId,
|
||||
homePosition: [22, 0, 0],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses 3D distance for proximity aggro so stacked dungeon floors do not pull", () => {
|
||||
setMobPosition("upper-floor-mob", [0, 20, 0]);
|
||||
useCombatStore.getState().registerMob("upper-floor-mob", {
|
||||
name: "Upper Floor Mob",
|
||||
maxHealth: 100,
|
||||
});
|
||||
|
||||
expect(useCombatStore.getState().engageNearbyMobs(1_000, [0, 0, 0])).toBe(0);
|
||||
expect(useCombatStore.getState().mobs["upper-floor-mob"].engaged).toBe(false);
|
||||
expect(useCombatStore.getState().engageNearbyMobs(1_100, [0, 8, 0])).toBe(1);
|
||||
expect(useCombatStore.getState().mobs["upper-floor-mob"].engaged).toBe(true);
|
||||
});
|
||||
|
||||
it("anchors a roaming mob at the pull location and keeps aggro across vertical terrain", () => {
|
||||
const id = "kresh-patrol:kresh-roaming";
|
||||
setMobPosition(id, [0, -70, 0]);
|
||||
useCombatStore.getState().registerMob(id, {
|
||||
name: "Kresh",
|
||||
boss: true,
|
||||
maxHealth: 100,
|
||||
leashRange: 55,
|
||||
xpReward: 0,
|
||||
});
|
||||
|
||||
// Kresh can travel far from the position where his model first mounted.
|
||||
// His leash must begin at the patrol position where combat actually starts.
|
||||
setMobPosition(id, [100, -106, 0]);
|
||||
useCombatStore.getState().damageMob(id, 10, 1_000);
|
||||
expect(useCombatStore.getState().mobs[id]).toMatchObject({
|
||||
health: 90,
|
||||
engaged: true,
|
||||
homePosition: [100, -106, 0],
|
||||
});
|
||||
|
||||
// A player falling from the ledge can be vertically farther than the leash
|
||||
// while remaining beside the encounter in the dungeon's horizontal plane.
|
||||
useCombatStore.getState().advanceMobCombat(1_450, [102, -30, 0]);
|
||||
expect(useCombatStore.getState().mobs[id]).toMatchObject({
|
||||
health: 90,
|
||||
engaged: true,
|
||||
combatPhase: "chasing",
|
||||
});
|
||||
|
||||
// Horizontal distance still enforces the normal evade/reset boundary.
|
||||
useCombatStore.getState().advanceMobCombat(1_500, [156, -106, 0]);
|
||||
expect(useCombatStore.getState().mobs[id]).toMatchObject({
|
||||
health: 100,
|
||||
engaged: false,
|
||||
combatPhase: "returning",
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks damage and effective-healing threat independently for every engaged mob", () => {
|
||||
for (const id of ["threat-pack:a", "threat-pack:b"]) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 500, xpReward: 0 });
|
||||
@@ -728,11 +851,13 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().talentRanks).toEqual({});
|
||||
|
||||
expect(useCombatStore.getState().settings.showThreatMeter).toBe(false);
|
||||
expect(useCombatStore.getState().settings.showMobAggroRanges).toBe(false);
|
||||
useCombatStore.getState().updateSettings({
|
||||
masterVolume: 9,
|
||||
uiScale: 0.1,
|
||||
minimapRotation: "north-up",
|
||||
showThreatMeter: true,
|
||||
showMobAggroRanges: true,
|
||||
threatMeterPosition: { x: -2, y: 4 },
|
||||
});
|
||||
expect(useCombatStore.getState().settings).toMatchObject({
|
||||
@@ -740,6 +865,7 @@ describe("combat store", () => {
|
||||
uiScale: 0.75,
|
||||
minimapRotation: "north-up",
|
||||
showThreatMeter: true,
|
||||
showMobAggroRanges: true,
|
||||
threatMeterPosition: { x: 0, y: 1 },
|
||||
});
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest" });
|
||||
|
||||
+330
-27
@@ -153,6 +153,16 @@ import {
|
||||
type ThreatSource,
|
||||
type ThreatTable,
|
||||
} from "./aggro";
|
||||
import { distanceSquared, planarDistanceSquared } from "./mobAi";
|
||||
import {
|
||||
emitCombatPresentation,
|
||||
presentationDeliveryForAbility,
|
||||
presentationSchoolForAbility,
|
||||
presentationSourceForAbility,
|
||||
presentationSourceForSpellId,
|
||||
resetCombatPresentationSession,
|
||||
type CombatPresentationPhase,
|
||||
} from "./combatPresentation";
|
||||
|
||||
/** Stable target id used by named player auras and HUD consumers. */
|
||||
export const PLAYER_AURA_ENTITY_ID = PLAYER_AGGRO_ID;
|
||||
@@ -166,6 +176,7 @@ export type MinimapRotation = "north-up" | "player-up";
|
||||
|
||||
export interface GameplaySettings {
|
||||
readonly showMobHealthBars: boolean;
|
||||
readonly showMobAggroRanges: boolean;
|
||||
readonly showTargetFrame: boolean;
|
||||
readonly showThreatMeter: boolean;
|
||||
readonly threatMeterPosition: NormalizedHudPosition;
|
||||
@@ -179,6 +190,7 @@ export interface GameplaySettings {
|
||||
|
||||
export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
|
||||
showMobHealthBars: true,
|
||||
showMobAggroRanges: false,
|
||||
showTargetFrame: true,
|
||||
showThreatMeter: false,
|
||||
threatMeterPosition: DEFAULT_THREAT_METER_POSITION,
|
||||
@@ -196,6 +208,9 @@ export const MOB_WOUND_ANIMATION_COOLDOWN_MS = 650;
|
||||
export const EMPTY_CORPSE_DESPAWN_MS = 10_000;
|
||||
/** Looting cannot hide a corpse before its one-shot death animation has had time to land. */
|
||||
export const MINIMUM_CORPSE_VISIBLE_MS = 1_500;
|
||||
/** Default proximity pull radii for dungeon enemies, expressed in world units. */
|
||||
export const DEFAULT_MOB_AGGRO_RANGE = 16;
|
||||
export const DEFAULT_BOSS_AGGRO_RANGE = 20;
|
||||
|
||||
export interface MobStatus {
|
||||
readonly kind: MobStatusKind;
|
||||
@@ -276,6 +291,7 @@ export interface MobCombatState {
|
||||
readonly attackRange: number;
|
||||
readonly attackDamage: number;
|
||||
readonly swingMs: number;
|
||||
readonly aggroRange: number;
|
||||
readonly leashRange: number;
|
||||
readonly moveSpeed: number;
|
||||
readonly attackRevision: number;
|
||||
@@ -397,6 +413,7 @@ export interface MobRegistration {
|
||||
readonly damageMultiplier?: number;
|
||||
readonly bonusLootChance?: number;
|
||||
readonly moveSpeed?: number;
|
||||
readonly aggroRange?: number;
|
||||
readonly leashRange?: number;
|
||||
readonly attacks?: readonly EnemyAttackDefinition[];
|
||||
readonly mechanicImmunities?: readonly AzerothCoreMechanic[];
|
||||
@@ -503,7 +520,9 @@ export interface CombatState {
|
||||
tauntMob: (id: string, source: ThreatSource, durationMs: number, now?: number) => boolean;
|
||||
damagePlayer: (amount: number, school?: DamageSchool, attackerLevel?: number) => number;
|
||||
healPlayer: (amount: number) => number;
|
||||
revivePlayer: (percentMaxHealth?: number) => number;
|
||||
cancelCast: (message?: string) => boolean;
|
||||
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
|
||||
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
|
||||
tick: (deltaSeconds: number, now?: number) => void;
|
||||
resetEncounter: () => void;
|
||||
@@ -576,6 +595,85 @@ function nextAnimationEvent(
|
||||
return { revision: animationSequence, kind, ...(abilityId ? { abilityId } : {}) };
|
||||
}
|
||||
|
||||
function emitPlayerAbilityPresentation(
|
||||
ability: AbilityDefinition,
|
||||
phase: CombatPresentationPhase,
|
||||
occurredAt: number,
|
||||
origin: CombatPosition,
|
||||
targetId: string | null,
|
||||
targetPosition: CombatPosition | null,
|
||||
): void {
|
||||
emitCombatPresentation({
|
||||
occurredAt,
|
||||
phase,
|
||||
actorGroup: "player",
|
||||
sourceActorId: PLAYER_AGGRO_ID,
|
||||
...(targetId ? { targetActorId: targetId } : {}),
|
||||
abilityId: ability.id,
|
||||
sourceSpellId: ability.dbcSpellId,
|
||||
name: ability.name,
|
||||
source: presentationSourceForAbility(ability),
|
||||
school: presentationSchoolForAbility(ability),
|
||||
delivery: presentationDeliveryForAbility(ability),
|
||||
origin,
|
||||
targetPosition: targetPosition ?? origin,
|
||||
...(ability.radius === undefined ? {} : { radius: ability.radius }),
|
||||
...(ability.castTimeMs > 0 ? { durationMs: ability.castTimeMs } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function emitPlayerAbilityCancellation(abilityId: string, occurredAt: number, origin: CombatPosition): void {
|
||||
const ability = abilityById(abilityId);
|
||||
if (!ability) return;
|
||||
emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, null, origin);
|
||||
}
|
||||
|
||||
function emitPlayerAbilityResolutionPresentation(
|
||||
ability: AbilityDefinition,
|
||||
occurredAt: number,
|
||||
origin: CombatPosition,
|
||||
targetId: string | null,
|
||||
targetPosition: CombatPosition | null,
|
||||
): void {
|
||||
emitPlayerAbilityPresentation(ability, "release", occurredAt, origin, targetId, targetPosition);
|
||||
emitPlayerAbilityPresentation(
|
||||
ability,
|
||||
presentationDeliveryForAbility(ability) === "aura" ? "aura-start" : "impact",
|
||||
occurredAt,
|
||||
origin,
|
||||
targetId,
|
||||
targetPosition,
|
||||
);
|
||||
}
|
||||
|
||||
function emitEnemyAttackPresentation(
|
||||
mob: MobCombatState,
|
||||
attack: EnemyAttackDefinition,
|
||||
phase: CombatPresentationPhase,
|
||||
occurredAt: number,
|
||||
origin: CombatPosition,
|
||||
targetActorId: AggroActorId | null,
|
||||
targetPosition: CombatPosition | null,
|
||||
): void {
|
||||
emitCombatPresentation({
|
||||
occurredAt,
|
||||
phase,
|
||||
actorGroup: "enemy",
|
||||
sourceActorId: mob.id,
|
||||
...(targetActorId ? { targetActorId } : {}),
|
||||
abilityId: attack.id,
|
||||
...(attack.spellId === undefined ? {} : { sourceSpellId: attack.spellId }),
|
||||
name: attack.name,
|
||||
source: presentationSourceForSpellId(attack.spellId),
|
||||
school: attack.school,
|
||||
delivery: attack.delivery,
|
||||
origin,
|
||||
targetPosition: targetPosition ?? origin,
|
||||
...(attack.radius === undefined ? {} : { radius: attack.radius }),
|
||||
...((attack.castTimeMs ?? 0) > 0 ? { durationMs: attack.castTimeMs } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function emptyCastResult(reason?: CastFailureReason, abilityId?: string): CastResult {
|
||||
return { ok: !reason, abilityId, reason, affectedMobIds: [], damage: 0, healing: 0, levelsGained: 0 };
|
||||
}
|
||||
@@ -721,10 +819,7 @@ function eligibleAggroActorIds(
|
||||
const withinLeash = (position: CombatPosition | null) => {
|
||||
if (!position) return false;
|
||||
if (!home) return true;
|
||||
const dx = position[0] - home[0];
|
||||
const dy = position[1] - home[1];
|
||||
const dz = position[2] - home[2];
|
||||
return dx * dx + dy * dy + dz * dz <= mob.leashRange * mob.leashRange;
|
||||
return planarDistanceSquared(position, home) <= mob.leashRange * mob.leashRange;
|
||||
};
|
||||
if (state.health > 0 && withinLeash(state.playerPosition)) eligible.add(PLAYER_AGGRO_ID);
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
@@ -1266,6 +1361,7 @@ function normalizedSettings(
|
||||
): GameplaySettings {
|
||||
return {
|
||||
showMobHealthBars: patch.showMobHealthBars ?? current.showMobHealthBars,
|
||||
showMobAggroRanges: patch.showMobAggroRanges ?? current.showMobAggroRanges,
|
||||
showTargetFrame: patch.showTargetFrame ?? current.showTargetFrame,
|
||||
showThreatMeter: patch.showThreatMeter ?? current.showThreatMeter,
|
||||
threatMeterPosition: normalizeHudPosition(
|
||||
@@ -1444,6 +1540,7 @@ function summonedMobCombatState(
|
||||
attackRange: attack.range,
|
||||
attackDamage: stats.attackDamage,
|
||||
swingMs: stats.swingMs,
|
||||
aggroRange: 0,
|
||||
leashRange: summoner.leashRange,
|
||||
moveSpeed: summoner.moveSpeed,
|
||||
attackRevision: 0,
|
||||
@@ -1749,34 +1846,76 @@ function mobGroupId(id: string): string {
|
||||
return separator > 0 ? id.slice(0, separator) : id;
|
||||
}
|
||||
|
||||
function engageMobGroup(
|
||||
mobs: Record<string, MobCombatState>,
|
||||
sourceId: string,
|
||||
now: number,
|
||||
sourceActorId: AggroActorId,
|
||||
): boolean {
|
||||
const groupId = mobGroupId(sourceId);
|
||||
const groupWasEngaged = Object.entries(mobs).some(([id, mob]) => (
|
||||
mobGroupId(id) === groupId && mob.engaged && !mob.dead
|
||||
));
|
||||
let changed = false;
|
||||
for (const [id, mob] of Object.entries(mobs)) {
|
||||
if (mob.dead || mob.despawned || mobGroupId(id) !== groupId) continue;
|
||||
const threatByActor = groupWasEngaged
|
||||
? mob.threatByActor
|
||||
: seedActorThreat(mob.threatByActor, sourceActorId);
|
||||
mobs[id] = {
|
||||
...mob,
|
||||
threatByActor,
|
||||
targetActorId: mob.targetActorId ?? (groupWasEngaged ? null : sourceActorId),
|
||||
engaged: true,
|
||||
combatPhase: "chasing",
|
||||
homePosition: groupWasEngaged
|
||||
? (mob.homePosition ?? getMobPosition(id))
|
||||
: (getMobPosition(id) ?? mob.homePosition),
|
||||
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
|
||||
? mob.abilityReadyAt
|
||||
: initialEnemyAbilityReadyAt(mob, now),
|
||||
nextAttackAt: mob.nextAttackAt > now ? mob.nextAttackAt : now + 450,
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function engageMobGroupInWork(
|
||||
work: MutableCastWork,
|
||||
sourceId: string,
|
||||
now: number,
|
||||
sourceActorId: AggroActorId,
|
||||
): void {
|
||||
const groupId = mobGroupId(sourceId);
|
||||
const groupWasEngaged = Object.entries(work.mobs).some(([id, mob]) => (
|
||||
mobGroupId(id) === groupId && mob.engaged && !mob.dead
|
||||
));
|
||||
for (const [id, mob] of Object.entries(work.mobs)) {
|
||||
if (mob.dead || mobGroupId(id) !== groupId) continue;
|
||||
const threatByActor = groupWasEngaged
|
||||
? mob.threatByActor
|
||||
: seedActorThreat(mob.threatByActor, sourceActorId);
|
||||
work.mobs[id] = {
|
||||
...mob,
|
||||
threatByActor,
|
||||
targetActorId: mob.targetActorId ?? (groupWasEngaged ? null : sourceActorId),
|
||||
engaged: true,
|
||||
combatPhase: "chasing",
|
||||
homePosition: mob.homePosition ?? getMobPosition(id),
|
||||
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
|
||||
? mob.abilityReadyAt
|
||||
: initialEnemyAbilityReadyAt(mob, now),
|
||||
nextAttackAt: mob.nextAttackAt > now ? mob.nextAttackAt : now + 450,
|
||||
};
|
||||
engageMobGroup(work.mobs, sourceId, now, sourceActorId);
|
||||
}
|
||||
|
||||
function nearestAggroActorId(
|
||||
state: Pick<CombatState, "health" | "playerPosition">,
|
||||
mobPosition: CombatPosition,
|
||||
aggroRange: number,
|
||||
): AggroActorId | null {
|
||||
if (!Number.isFinite(aggroRange) || aggroRange <= 0) return null;
|
||||
let nearestId: AggroActorId | null = null;
|
||||
let nearestDistanceSquared = aggroRange * aggroRange;
|
||||
if (state.health > 0) {
|
||||
const playerDistanceSquared = distanceSquared(mobPosition, state.playerPosition);
|
||||
if (playerDistanceSquared <= nearestDistanceSquared) {
|
||||
nearestId = PLAYER_AGGRO_ID;
|
||||
nearestDistanceSquared = playerDistanceSquared;
|
||||
}
|
||||
}
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
if (member.health <= 0) continue;
|
||||
const position = getPartyRuntimePosition(member.id);
|
||||
if (!position) continue;
|
||||
const memberDistanceSquared = distanceSquared(mobPosition, position);
|
||||
if (memberDistanceSquared < nearestDistanceSquared) {
|
||||
nearestId = member.id;
|
||||
nearestDistanceSquared = memberDistanceSquared;
|
||||
}
|
||||
}
|
||||
return nearestId;
|
||||
}
|
||||
|
||||
function mobResistanceForSchool(
|
||||
@@ -1893,7 +2032,7 @@ function damageMobInWork(
|
||||
forcedTarget: killed ? null : engagedMob.forcedTarget,
|
||||
engaged: !killed,
|
||||
combatPhase: killed ? "idle" : "chasing",
|
||||
homePosition: mob.homePosition ?? getMobPosition(id),
|
||||
homePosition: engagedMob.homePosition ?? getMobPosition(id),
|
||||
nextAttackAt: killed ? 0 : (mob.nextAttackAt > now ? mob.nextAttackAt : now + 450),
|
||||
activeCast: killed ? null : engagedMob.activeCast,
|
||||
woundRevision: canPlayWoundAnimation ? mob.woundRevision + 1 : mob.woundRevision,
|
||||
@@ -2266,6 +2405,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
...initialState(),
|
||||
|
||||
initializeCharacter: (profile) => {
|
||||
resetCombatPresentationSession();
|
||||
const classId = profile.classId;
|
||||
const secondaryClassId = profile.secondaryClassId ?? null;
|
||||
const raceId = profile.raceId ?? "human";
|
||||
@@ -2371,6 +2511,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
attackRange + 1,
|
||||
registration.leashRange ?? existing?.leashRange ?? (boss ? 55 : 42),
|
||||
);
|
||||
const aggroRange = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
leashRange,
|
||||
registration.aggroRange
|
||||
?? existing?.aggroRange
|
||||
?? (boss ? DEFAULT_BOSS_AGGRO_RANGE : DEFAULT_MOB_AGGRO_RANGE),
|
||||
),
|
||||
);
|
||||
const moveSpeed = Math.max(
|
||||
0,
|
||||
registration.moveSpeed ?? existing?.moveSpeed ?? (boss ? 3.15 : 3.5),
|
||||
@@ -2403,6 +2552,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
attacks,
|
||||
attackRange,
|
||||
swingMs,
|
||||
aggroRange,
|
||||
leashRange,
|
||||
moveSpeed,
|
||||
attackDamage,
|
||||
@@ -2454,6 +2604,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
attackRange,
|
||||
attackDamage,
|
||||
swingMs,
|
||||
aggroRange,
|
||||
leashRange,
|
||||
moveSpeed,
|
||||
attackRevision: 0,
|
||||
@@ -2690,6 +2841,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const movedWhileCasting = Boolean(cast && (
|
||||
(next[0] - cast.origin[0]) ** 2 + (next[2] - cast.origin[2]) ** 2 > 0.04
|
||||
));
|
||||
if (movedWhileCasting) emitPlayerAbilityCancellation(cast!.abilityId, Date.now(), next);
|
||||
return {
|
||||
playerPosition: next,
|
||||
activeCast: movedWhileCasting ? null : cast,
|
||||
@@ -2814,6 +2966,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
? { playerAnimationEvent: nextAnimationEvent("ability-cancel", abilityId) }
|
||||
: {}),
|
||||
});
|
||||
if (completingCast) emitPlayerAbilityCancellation(abilityId, now, playerOrigin);
|
||||
return emptyCastResult(reason, abilityId);
|
||||
}
|
||||
|
||||
@@ -2885,6 +3038,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
{ abilityId: ability.id, targetId: targetId ?? undefined },
|
||||
),
|
||||
});
|
||||
emitPlayerAbilityPresentation(
|
||||
ability,
|
||||
"cast-start",
|
||||
now,
|
||||
playerOrigin,
|
||||
targetId,
|
||||
targetPosition,
|
||||
);
|
||||
return { ...emptyCastResult(), ok: true, abilityId: ability.id };
|
||||
}
|
||||
|
||||
@@ -3452,6 +3613,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
{ abilityId: ability.id, targetId: targetId ?? undefined, amount: work.totalDamage || work.totalHealing },
|
||||
),
|
||||
});
|
||||
emitPlayerAbilityResolutionPresentation(
|
||||
ability,
|
||||
now,
|
||||
playerOrigin,
|
||||
targetId,
|
||||
targetPosition,
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -3611,7 +3779,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
: mob.forcedTarget,
|
||||
engaged: true,
|
||||
combatPhase: "chasing",
|
||||
homePosition: mob.homePosition ?? getMobPosition(mobId),
|
||||
homePosition: groupWasEngaged
|
||||
? (mob.homePosition ?? getMobPosition(mobId))
|
||||
: (getMobPosition(mobId) ?? mob.homePosition),
|
||||
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
|
||||
? mob.abilityReadyAt
|
||||
: initialEnemyAbilityReadyAt(mob, now),
|
||||
@@ -3682,6 +3852,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|
||||
healPlayer: (amount) => {
|
||||
const state = get();
|
||||
// Ordinary healing and lingering HoTs must not raise a defeated player.
|
||||
// Resurrection is an explicit combat action handled by revivePlayer.
|
||||
if (state.health <= 0) return 0;
|
||||
const modified = resolveAuraHealingValue(
|
||||
amount,
|
||||
aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID),
|
||||
@@ -3698,6 +3871,22 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return healed;
|
||||
},
|
||||
|
||||
revivePlayer: (percentMaxHealth = 0.35) => {
|
||||
const state = get();
|
||||
if (state.health > 0 || state.maxHealth <= 0) return 0;
|
||||
const restored = Math.max(1, Math.round(
|
||||
state.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)),
|
||||
));
|
||||
set({
|
||||
health: restored,
|
||||
controlledUntil: 0,
|
||||
controlMechanic: null,
|
||||
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
||||
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored }),
|
||||
});
|
||||
return restored;
|
||||
},
|
||||
|
||||
cancelCast: (message = "Casting interrupted.") => {
|
||||
const cast = get().activeCast;
|
||||
if (!cast) return false;
|
||||
@@ -3706,9 +3895,30 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
playerAnimationEvent: nextAnimationEvent("ability-cancel", cast.abilityId),
|
||||
feedback: nextFeedback("error", message, { abilityId: cast.abilityId, targetId: cast.targetId ?? undefined }),
|
||||
});
|
||||
emitPlayerAbilityCancellation(cast.abilityId, Date.now(), get().playerPosition);
|
||||
return true;
|
||||
},
|
||||
|
||||
engageNearbyMobs: (now = Date.now(), playerPosition) => {
|
||||
const state = get();
|
||||
const runtimeState = playerPosition
|
||||
? { ...state, playerPosition }
|
||||
: state;
|
||||
const mobs: Record<string, MobCombatState> = { ...state.mobs };
|
||||
let engagedGroups = 0;
|
||||
for (const id of Object.keys(mobs)) {
|
||||
const mob = mobs[id];
|
||||
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0) continue;
|
||||
const position = getMobPosition(id);
|
||||
if (!position) continue;
|
||||
const actorId = nearestAggroActorId(runtimeState, position, mob.aggroRange);
|
||||
if (!actorId) continue;
|
||||
if (engageMobGroup(mobs, id, now, actorId)) engagedGroups += 1;
|
||||
}
|
||||
if (engagedGroups > 0) set({ mobs });
|
||||
return engagedGroups;
|
||||
},
|
||||
|
||||
advanceMobCombat: (now = Date.now(), playerPosition) => {
|
||||
const state = get();
|
||||
const runtimeState = playerPosition
|
||||
@@ -3852,6 +4062,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
attackAnimation = "cast";
|
||||
lastAttackId = attack.id;
|
||||
nextAttackAt = enemyActiveCast.completesAt;
|
||||
emitEnemyAttackPresentation(
|
||||
mob,
|
||||
attack,
|
||||
"cast-start",
|
||||
now,
|
||||
position,
|
||||
resolution.targetActorId,
|
||||
targetPosition,
|
||||
);
|
||||
} else if (selectedAttack && !cannotAttack) {
|
||||
const attack = selectedAttack!;
|
||||
enemyActiveCast = null;
|
||||
@@ -3881,6 +4100,16 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
: attack.target === "primary"
|
||||
? [resolution.targetActorId]
|
||||
: [];
|
||||
const presentationTargetId = targets[0] ?? resolution.targetActorId;
|
||||
emitEnemyAttackPresentation(
|
||||
mob,
|
||||
attack,
|
||||
"release",
|
||||
now,
|
||||
position,
|
||||
presentationTargetId,
|
||||
actorPosition(runtimeState, presentationTargetId),
|
||||
);
|
||||
if (effect.kind === "damage") {
|
||||
const attackDamage = Math.max(0, Math.round(mob.attackDamage * effect.multiplier));
|
||||
for (const actorId of targets) {
|
||||
@@ -4102,6 +4331,19 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
let appliedDamage = 0;
|
||||
for (const hit of incomingHits) {
|
||||
const impactOrigin = getMobPosition(hit.mob.id);
|
||||
const impactTarget = actorPosition(get(), hit.actorId);
|
||||
if (impactOrigin && impactTarget) {
|
||||
emitEnemyAttackPresentation(
|
||||
hit.mob,
|
||||
hit.attack,
|
||||
"impact",
|
||||
now,
|
||||
impactOrigin,
|
||||
hit.actorId,
|
||||
impactTarget,
|
||||
);
|
||||
}
|
||||
if (hit.actorId === PLAYER_AGGRO_ID) {
|
||||
const current = get();
|
||||
const defender = applyAuraStats(
|
||||
@@ -4437,6 +4679,23 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
work.resourcePools = rage.resourcePools;
|
||||
}
|
||||
if (result.amount > 0) autoAttackEvent = nextAnimationEvent("attack");
|
||||
if (result.amount > 0) {
|
||||
const targetPosition = getMobPosition(autoAttackTargetId!);
|
||||
emitCombatPresentation({
|
||||
occurredAt: now,
|
||||
phase: "release",
|
||||
actorGroup: "player",
|
||||
sourceActorId: PLAYER_AGGRO_ID,
|
||||
targetActorId: autoAttackTargetId!,
|
||||
abilityId: "player-auto-attack",
|
||||
name: "Auto Attack",
|
||||
source: "fallback",
|
||||
school: autoAttackSchool,
|
||||
delivery: "melee",
|
||||
origin: state.playerPosition,
|
||||
targetPosition: targetPosition ?? state.playerPosition,
|
||||
});
|
||||
}
|
||||
nextAutoAttackAt = now + profile.intervalMs;
|
||||
if (result.killed) {
|
||||
autoAttackTargetId = null;
|
||||
@@ -4530,6 +4789,31 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
amount,
|
||||
...(effect.damageSchool ? { school: effect.damageSchool } : {}),
|
||||
}, nextTickAt);
|
||||
if (ability) {
|
||||
const sourceActorId = effect.sourceActorId;
|
||||
const actorGroup = sourceActorId === PLAYER_AGGRO_ID ? "player" : "party";
|
||||
const origin = sourceActorId === PLAYER_AGGRO_ID
|
||||
? state.playerPosition
|
||||
: getPartyRuntimePosition(sourceActorId) ?? state.playerPosition;
|
||||
const targetPosition = effect.targetId
|
||||
? getMobPosition(effect.targetId) ?? getPartyRuntimePosition(effect.targetId)
|
||||
: state.playerPosition;
|
||||
emitCombatPresentation({
|
||||
occurredAt: nextTickAt,
|
||||
phase: "tick",
|
||||
actorGroup,
|
||||
sourceActorId,
|
||||
...(effect.targetId ? { targetActorId: effect.targetId } : {}),
|
||||
abilityId: ability.id,
|
||||
sourceSpellId: ability.dbcSpellId,
|
||||
name: ability.name,
|
||||
source: presentationSourceForAbility(ability),
|
||||
school: presentationSchoolForAbility(ability),
|
||||
delivery: presentationDeliveryForAbility(ability),
|
||||
origin,
|
||||
targetPosition: targetPosition ?? origin,
|
||||
});
|
||||
}
|
||||
remainingTicks -= 1;
|
||||
nextTickAt += effect.intervalMs;
|
||||
}
|
||||
@@ -4711,6 +4995,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
get().addHealingThreat(playerThreatSource(get(), ability.id, true), healed);
|
||||
}
|
||||
}
|
||||
emitPlayerAbilityPresentation(
|
||||
ability,
|
||||
"tick",
|
||||
nextTickAt,
|
||||
get().playerPosition,
|
||||
cast.targetId,
|
||||
cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition,
|
||||
);
|
||||
ticksCompleted += 1;
|
||||
nextTickAt += (cast.completesAt - cast.startedAt) / cast.totalTicks;
|
||||
}
|
||||
@@ -4724,6 +5016,16 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
targetId: cast.targetId ?? undefined,
|
||||
}),
|
||||
});
|
||||
if (ability) {
|
||||
emitPlayerAbilityPresentation(
|
||||
ability,
|
||||
"release",
|
||||
now,
|
||||
get().playerPosition,
|
||||
cast.targetId,
|
||||
cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition,
|
||||
);
|
||||
}
|
||||
} else if (ticksCompleted !== cast.ticksCompleted) {
|
||||
set({ activeCast: { ...cast, ticksCompleted, nextTickAt } });
|
||||
}
|
||||
@@ -4736,6 +5038,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
},
|
||||
|
||||
resetEncounter: () => set((state) => {
|
||||
resetCombatPresentationSession();
|
||||
for (const mob of Object.values(state.mobs)) {
|
||||
if (mob.summonedBy) removeMobPosition(mob.id);
|
||||
}
|
||||
|
||||
@@ -223,6 +223,23 @@ describe("generated dungeon campaign catalog", () => {
|
||||
.toEqual([-819.8267, 39.4232, -87.3708]);
|
||||
});
|
||||
|
||||
it("keeps Stockades creatures in the package's native world coordinates", () => {
|
||||
const stockades = requireDungeonDefinition("stormwind-stockade");
|
||||
const firstSpawn = stockades.staticSpawns.find(
|
||||
(spawn) => spawn.id === "creature-79035",
|
||||
);
|
||||
const targorr = stockades.staticSpawns.find(
|
||||
(spawn) => spawn.id === "creature-84027",
|
||||
);
|
||||
|
||||
expect(stockades.environmentMode).toBe("global-wmo");
|
||||
expect(stockades.provenance.coordinateTransform)
|
||||
.toBe("three(x,y,z)=draft(x,y,z)");
|
||||
expect(stockades.entrance.footPosition).toEqual([-54.23, -18.34, 0.28]);
|
||||
expect(firstSpawn?.position).toEqual([-82.3245, -26.4396, -10.6106]);
|
||||
expect(targorr?.position).toEqual([-159.582, -25.6062, 1.2531]);
|
||||
});
|
||||
|
||||
it("starts Razorfen Kraul inside the portal and maps its first pack into the WMO", () => {
|
||||
const razorfenKraul = requireDungeonDefinition("razorfen-kraul");
|
||||
const firstPackSpawn = razorfenKraul.staticSpawns.find(
|
||||
|
||||
@@ -8,10 +8,12 @@ import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore, type GameMode } from "./store";
|
||||
import { dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
|
||||
import type { WorldSpawn } from "./dungeonTypes";
|
||||
import { useManastormAdminStore } from "./manastormAdminStore";
|
||||
|
||||
export interface DungeonSessionOptions {
|
||||
readonly gameMode?: GameMode;
|
||||
readonly spawn?: WorldSpawn;
|
||||
readonly gmTestMode?: boolean;
|
||||
/** Used by in-game encounter transitions so character progression and inventory survive. */
|
||||
readonly preserveCharacter?: boolean;
|
||||
}
|
||||
@@ -72,7 +74,8 @@ export function activateDungeonSession(
|
||||
}
|
||||
useGameStore.getState().activateDungeon(dungeonId as DungeonId, difficultyId, {
|
||||
gameMode: options.gameMode,
|
||||
spawn: options.spawn,
|
||||
spawn: options.spawn ?? useManastormAdminStore.getState().config.dungeonSpawns[dungeonId],
|
||||
gmTestMode: options.gmTestMode,
|
||||
});
|
||||
useCombatStore.getState().setPlayerPosition(useGameStore.getState().playerPosition);
|
||||
return true;
|
||||
@@ -87,6 +90,7 @@ export function restartActiveDungeonSession(): boolean {
|
||||
return activateDungeonSession(game.activeDungeonId, game.activeDifficultyId, {
|
||||
gameMode: game.gameMode,
|
||||
spawn: game.activeSpawn,
|
||||
gmTestMode: game.gmTestMode,
|
||||
preserveCharacter: true,
|
||||
});
|
||||
}
|
||||
@@ -5,10 +5,13 @@ import { dungeonDefinitionById } from "./dungeonRegistry";
|
||||
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { DEFAULT_MANASTORM_ADMIN_CONFIG } from "./manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "./manastormAdminStore";
|
||||
|
||||
describe("dungeon session asset eviction", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useManastormAdminStore.setState({ config: DEFAULT_MANASTORM_ADMIN_CONFIG, draft: DEFAULT_MANASTORM_ADMIN_CONFIG, dirty: false });
|
||||
useGameStore.getState().activateDungeon("wailing-caverns");
|
||||
});
|
||||
|
||||
@@ -44,4 +47,13 @@ describe("dungeon session asset eviction", () => {
|
||||
expect(useGameStore.getState().sessionRevision).toBe(revision + 1);
|
||||
expect(usePartyStore.getState().members.map((member) => member.id)).toEqual(members);
|
||||
});
|
||||
|
||||
it("applies the published dungeon group anchor at the session boundary", () => {
|
||||
const spawn = { footPosition: [12, 3, -8] as const, yaw: 1.25 };
|
||||
useManastormAdminStore.setState({
|
||||
config: { ...DEFAULT_MANASTORM_ADMIN_CONFIG, dungeonSpawns: { "wailing-caverns": spawn } },
|
||||
});
|
||||
expect(activateDungeonSession("wailing-caverns")).toBe(true);
|
||||
expect(useGameStore.getState().activeSpawn).toEqual(spawn);
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,7 @@ export interface EnemyCombatDefinition {
|
||||
readonly healthMultiplier?: number;
|
||||
readonly damageMultiplier?: number;
|
||||
readonly moveSpeed?: number;
|
||||
readonly aggroRange?: number;
|
||||
readonly leashRange?: number;
|
||||
readonly attacks: readonly EnemyAttackDefinition[];
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Generated by npm run dungeon:catalog. Do not edit.
|
||||
export default "H4sIAAAAAAACCuy9aY8jR5Ig+lcI7gI7g0d6ubn5WcB+0NE9rbetaT1VbzcWjcIgSDqT0UkycoPBKpWE+u8P5nEfJIOZqays7NQHSUln0Nwt7L78t+lhufG76G8+PcTJfvoWZtNDckyXfvr2t+ky2t1F8c3+x2gfr/0hm76dro77G5/s53fxnd/Ge/+m/KD8LvvnIdlPZ1O//xCnyX7n99lP0fI2uvGH6dt/TA/p8s1NtPNvbvzep1HmV2/W8Qf/0zb65NPv89/6YXeXpNmh/KWBR3bRPjpkSbr75nDw1e+HByb/lnl6Pko/TdbRdruIlrf/Pn1PG1omx33m0++iLNomN9O3Z3/55+M+i3e++HK5l+hXnybZ5rsk9d8lu11MKJErt4KVXCgj0FtwS7VEx9d6GQEYIyyuFuuF9dh+/F3A8p+T5e3wRga/WqHkzm+31Tl+m0aHpd/nL3DK2Js/R6v49vDjT//fH1ZxlqRvltvY77P58pimfp+9iVIfzZV4cxdly838+zfff/vHeOsP34VvvXlHv81WiyW9xLtkucl/808+2vr0x2g/f5claXTj35QkMP+YpLclHRzehGfmOciTP/3582y6TD54+qHp23/8VtLVDyvCxiZaJR/X0f5mfuv93XQ23UV3tILYeI2H6VtDf2ZxFhNtIZFuFmXx8t1d9HF/mL4F7mbTNIl28f6GaOQwfStkTd/l21gmqZ9+nnX2QDTwMd6v5ocsWd5GK9/YhmxvQzW3Ab1tOOzugo/cxMpHq12894cGbH0GBUL0UGD6KMCR0D9G8Tbe38yX0Qef7ht7kJ3XYFt74N09CCW6ezAjt5BGvybp2u/nt2l03DZ2YM4Rgh2BBRAjt7DYRsvbdZRtkt185f1dEw+2vQvdwoPp4YFDbxdjKeG4XUW7aF/DNvwqDCAX98bAzT7Z+dTfNMG7Dnh5nhPR9I6OMJYTj/tbv5+TVN82mDAw9+ktYA/7UnW3IK+lwlXysckHINw5AlA9MnSy9xLGMuNhGaVbn813Ceknn35qbMO6M/IIe3SIRHWdbeiR2/j1uF1HaRrd1uAFPwdewAhxAPIaZkyT5e38cBenDWoQ3VcBrV2YHkMorXuCUVy9i5W/yzYNihDY4QvA5jb0gIqS/ZehRtPEJtkmu2i/bGKiSw32gmQQ+t7UcMhSEozbXVM5dt+Ea8JXPe0gsSca6BdGwd9FaXRcJfumaj57+p5UUKInFQQfLRZu/DpO/Xy5iQ67xh7sOcnUtw+AVMX9JNOKwO/aqrHHCaLFCT2pZFSPANTYF3DYRFnmU7+ab6LttsEISnYYocUH2LdTnL23aFxsk2Q1Xx/TfdRkBCXFmS1ATywCuntvYeO320AKKXlBadbCRMdaar0O6GHCqnsr6kPmo92H6LjNmuDVdVjoEyOMtdaO+5VPF8lNE7o+wwoD5qqGe5urh230wc/v/L6FfHPm9GKAFXu6CcdiP0qXUZZGvzagK3Hu9O7yqx/Lh4ski/bxMmrCxnNa2fUx7+6th3Z+uYn2UdqELq+iOivvbRLkzuJ8Gy0+pfE+2zQ3oa4jPnt/AXDw2cbf3vbEoNLnmF8PmAP39lgpcjHPkt2iBf8c/Qcct+HD/akgOi43fh/F82X66a4lAZU9hwR52V9GMxoHNzFZx4d55tO0rQ7sWUHY9xn7/oK9xj6c75I0OjRtQ+3ObGBAHd3bKvCHZXTn5+uUXNZjut8k22b4QulzurkvGrDvPeNYVByzmyhd+U4cR5lz4mGAM0HcXykVO7iL9/to2fQglTknH8YIqbEvJNv4+d7/cmwyhdHnfMeeZBB4fxFJ4JPlcduGb88dfswrGKsYl8dtiCMl6/mQw6DcWTHdDyfBveNpQTrn+0j2rS24qyjBmHtbKas0us02UZrsOzyhOb9KWzl3b7ct+vWfyXa+9+lx0YQP5+Rjz3My/L70UL2FbXyzyfbx/qa5C3EVMQyR5djA0s1xv2pFMjQ/F9jtA7fm3vG8D3FC0RySzE349swr6Dlu7r5sEG32t9HGZ/Nku5rfxvubVdJwXzWcjef0bQbEe9sM6yS98YEhk2PTbtIozpkMPWKUcF9dnaVxtKUdkJBcbqLdXdyMJ2jFzyCj90rUfV/JXZwFNJBgaEI/RxDY5wZt7+08VlyZ+vXWL7M2GvRZE66/kXuL6Cze3/o088vN/LBJPlLEtd6GFR0ZBc1t9ClTn9vFXZos/eqYRtt5mSfs02ea/Or389RHy00z+G2feBtJepNkmd/PKRvUiPwa+3sj5P1suvLreB8TQRxCqq6fMY6Hs3ZZnG3ph99VK5P/1cvnbdc3Zdp3Rb9vKVX7y11UpjOX2+hwiJftjPKPyYp++GabLKLt/OMumc6mq3i9jpfHbfbpb1EaR/ss323Y2z5Jd9G29aXmpwfCz4/R4TacZus/+O3P0T5kJsHOkL8v0EChle+rX/ih9cvRhyjeRot4G4ffLv4M9uZd6g9+T2+CTvTbdJssi/8fQE228Ts62w/7Q0aR3dWkoATa53G3i1L6+T+S6pxkmzQ53mwmnV+ZTVZ+7aNsEmeHSUUdkzQh12g2ifarCb2hSbbxE4pWfZpE2/iDZ9PZdJtEq3h/86M/HEJmdvpT6u+iNN7fdIEwRt8/7pebKM386pvUR/8ZhZ3/7/KzSf90RFQ3aXLcE+7+G7d8xenVrvM09jLZJmlYiEAAn86mex+ledJzTf8jNP88m0a7BaWT/0w4aD0W2YV0ZNPE+4zy4fQmOBPi82y68bv4cLfxqa8eO9x++q580hq3tBQ+yTdXfc4dX3DX/UUtP89C7DWIyWjb38lKLI1btZ+D2TRZrw8+I7ISM8Fnc/P+MzFD5kkLHcIvHLPkmP0UwqqB36b/9S38d6pfyFcIx/Tx+9l0sfX7VfubInwzLDS+GK1WcUZFFq3vyvDdcq36etjQHW3lY7zKNhSg54S+/IhIVuodPbS/yY2BZbLPkmMafvRHISeCq8l3Vk8A7ASkmAhpJoLbCRiYvEOUEwMTyfUEhJ7Opj+inQh0k++AuwlYnIARE6HtREgeHnuHUk6cnqAzE2Om76lwIKKij4CtD/HhGG1rPi/+nk3X8dYX5Jh/xm62RBjHdDt9O32T/0JeclFVfLxBnNdC7E3rueXGL28Px9307VQtF9wZjipaCse5dVavBF8sDdr1mi+VkNKAX4Nfe+uRR+uF8dw5vVAI2kaS+Dj+1U/fOq05aRIyQfY3W/8d8SkJaXSOBM4y2W7jXAqW56s/ah2x+vj6U3YfbRwUI8kXQsiVM2sdKWcUX4r12iz9ag2IESxUtJQRSm9W67Xma7523CyjNXj0S+WrgwplrNNDB5WKDrqPPsQ3lXTMBXb9Ueuk9efXH7X3bOOsVqnlki+WErxYmYVD5DxaLKKVWUZOLdZLqYzhKJdcIdjV0ki3cHoJCxFZKzyvziqFMf2jCis5UW6WRvvDOkl3dNC75BDnh/4Hn/EZf0/KudQS1UeHZURKFGZkjOz+nnwMDn3q/b/9Mvs0+/Xf/+e/zT8mH9kvM/r3r+Hfn/59+nk2XZAgC1yyi+kH5wIVs+TmzTmz2sFsbiQDdPieWP6X6dt/GMekM3YG4JgTqGZCWiYsD2LK72n3eWHYPn8bHfk++UP5FZLoSfZTfcA5WiaNEzMBjJMHPwfFHA+w10n6MUpX9C3OFOdWojVGKS0pfM9ntFul0GgpUKK1Rr6fTT9FH6dv6ftCKoPSKGuktOBIPKQ+auh+X2/q8raXnlTlhQ2n0So+kgGuyFCL8+C/j+apzzbx9iZJG7B+rj77H4fJH0pd3IIEiimBYiYUQ4DZHJgzLThStOEsQvjgEG8/+HQb5RZhAe5bWpq8q5dOQTXMCGNngjPUdgZGMKG5OQN0mex2ESV45oc7sgc+RNsG3O/K1cm7avXkgaViXAgxk5o5w9UMUDJUZ6EnqyR4aIttvF99pLq18JsF9L+skmDMfNtYPQkdOXNci5mUTEiicc6cOwt87ffp8RDgr/yH5Jg2Yf8xLAbw3xeLp980Csa5EjMjmAOuZhKZMmdhf0y26/kulHzM99F+lRxq0H9PtuvJj2Ft8p9h7TRkqZm1TsycY9ahmqFmTp6FHKXLzS668fMoPd4EpVqA/aZYmHwTFk7DdDyXN66UJoah6MJ83/RYfgvc+mmOVkOlCxofzaa3cTAdd8miQfRbH91O/p6kFEhaJrtFFIyxYMfncZuNj7bZ5sfjNovvtjFtD2ZTqqi68Z0Pd8kH/+7Oe/JOmFYGKIe39dFhU3gE9HeUZbn7VMqYRXSIl/P84yYzHuLl5Jvy05UnKzuY7zu/9cQ9WZTekD04vUvjYNnPQlFHQjrtbvPpEC9z32If70qPoQKS5vuRTNGhky25yj+GOBXZaAOHq15vqB6dGxCmQUtU5Edm/t/j7basMA2FZiEj3Nj7XZr8kwzf7aUD5Kq3s/1ldMjqzQvb3rsUdnjvDMRnUkKlwfdbCbCy1CWqhSI9HC2JBL+rfYKVWcC0UqScOUM6Yrnx2ac7OvrmuIv2SRwCccnKh18fMCuqAteOo/tmmfooO6b+8GYVH+620ae55TDPD+1Xha1Ravb/M307h8D0TqPQxknrqBQp9zv+UvgHfDbdRgu//VNhdgMxqyI1nd769OeCe4AJ+/lzoZqJQ1SfadQppmkowb9v4oxyRtu7If6hcOAr//T5B4SSsqEHyL2efJPukrTJPuFb9+OfNf3iVewD/NTeR/CPl0u96vPPwi3sWtT8A4xTePf3ZSAj3OMykGBGG+wyEGfa6CYHOWG6HOSClCw4aJEczurfRtCrCGpNvs0f2USHPydJNn2bpUc/pKVgkMsYCtf8x556uyM4jzI2XznnbZLtp/nhmN40TM8/JdtPk3fFZ42dkhHT3CiFkhaf5iHg1dwt/eZ5LhPtbSpHOfL+NjmzojZtgD+SwgJGFZK/L78BgHlkjpOMh3xWV2VxQNd
Line truncated
|
||||
export default "H4sIAAAAAAACCuy9a48jx7Eg+lcI7gJ7Di6Zk5GRzwH2gx72se5ax7oar42FMTgokslmuUlWb7E4o5Ew//0ist4PksXuVk9Pu/VBUjNZjMyoeEdkxG/Tw3Ljd9HffHqIk/30Lcymh+SYLv307W/TZbS7i+Kb/Y/RPl77QzZ9O10d9zc+2c/v4ju/jff+TflB+V32z0Oyn86mfv8hTpP9zu+zn6LlbXTjD9O3/5ge0uWbm2jn39z4vU+jzK/erOMP/qdt9Mmn3+e/9cPuLkmzQ/lLA4/son10yJJ0983h4KvfDw9M/i3z9HyUfpqso+12ES1v/336nja0TI77zKffRVm0TW6mb8/+8s/HfRbvfPHlci/Rrz5Nss13Seq/S3a7mFAiV24FK7lQRqC34JZqiY6v9TICMEZYXC3WC+ux/fi7gOU/J8vb4Y0MfrVCyZ3fbqtz/DaNDku/z1/glLE3f45W8e3hx5/+vz+s4ixJ3yy3sd9n8+UxTf0+exOlPpor8eYuypab+fdvvv/2j/HWH74L33rzjn6brRZLeol3yXKT/+affLT16Y/Rfv4uS9Loxr8pSWD+MUlvSzo4vAnPzHOQJ3/68+fZdJl88PRD07f/+K2kqx9WhI1NtEo+rqP9zfzW+7vpbLqL7mgFsfEaD9O3hv7M4iwm2kIi3SzK4uW7u+jj/jB9C9zNpmkS7eL9DdHIYfpWyJq+y7exTFI//Tzr7IFo4GO8X80PWbK8jVa+sQ3Z3oZqbgN623DY3QUfuYmVj1a7eO8PDdj6DAqE6KHA9FGAI6F/jOJtvL+ZL6MPPt039iA7r8G29sC7exBKdPdgRm4hjX5N0rXfz2/T6Lht7MCcIwQ7AgsgRm5hsY2Wt+so2yS7+cr7uyYebHsXuoUH08MDh94uxlLCcbuKdtG+hm34VRhALu6NgZt9svOpv2mCdx3w8jwnoukdHWEsJx73t34/J6m+bTBhYO7TW8Ae9qXqbkFeS4Wr5GOTD0C4cwSgemToZO8ljGXGwzJKtz6b7xLSTz791NiGdWfkEfboEInqOtvQI7fx63G7jtI0uq3BC34OvIAR4gDkNcyYJsvb+eEuThvUILqvAlq7MD2GUFr3BKO4ehcrf5dtGhQhsMMXgM1t6AEVJfsvQ42miU2yTXbRftnERJca7AXJIPS9qeGQpSQYt7umcuy+CdeEr3raQWJPNNAvjIK/i9LouEr2TdV89vQ9qaBETyoIPlos3Ph1nPr5chMddo092HOSqW8fAKmK+0mmFYHftVVjjxNEixN6UsmoHgGosS/gsImyzKd+Nd9E222DEZTsMEKLD7Bvpzh7b9G42CbJar4+pvuoyQhKijNbgJ5YBHT33sLGb7eBFFLygtKshYmOtdR6HdDDhFX3VtSHzEe7D9FxmzXBq+uw0CdGGGutHfcrny6SmyZ0fYYVBsxVDfc2Vw/b6IOf3/l9C/nmzOnFACv2dBOOxX6ULqMsjX5tQFfi3Ond5Vc/lg8XSRbt42XUhI3ntLLrY97dWw/t/HIT7aO0CV1eRXVW3tskyJ3F+TZafErjfbZpbkJdR3z2/gLg4LONv73tiUGlzzG/HjAH7u2xUuRiniW7RQv+OfoPOG7Dh/tTQXRcbvw+iufL9NNdSwIqew4J8rK/jGY0Dm5iso4P88ynaVsd2LOCsO8z9v0Fe419ON8laXRo2obandnAgDq6t1XgD8vozs/XKbmsx3S/SbbN8IXS53RzXzRg33vGsag4ZjdRuvKdOI4y58TDAGeCuL9SKnZwF+/30bLpQSpzTj6MEVJjX0i28fO9/+XYZAqjz/mOPckg8P4iksAny+O2Dd+eO/yYVzBWMS6P2xBHStbzIYdBubNiuh9OgnvH04J0zveR7FtbcFdRgjH3tlJWaXSbbaI02Xd4QnN+lbZy7t5uW/TrP5PtfO/T46IJH87Jx57nZPh96aF6C9v4ZpPt4/1NcxfiKmIYIsuxgaWb437VimRofi6w2wduzb3jeR/ihKI5JJmb8O2ZV9Bz3Nx92SDa7G+jjc/myXY1v433N6uk4b5qOBvP6dsMiPe2GdZJeuMDQybHpt2kUZwzGXrEKOG+ujpL42hLOyAhudxEu7u4GU/Qip9BRu+VqPu+krs4C2ggwdCEfo4gsM8N2t7beay4MvXrrV9mbTTosyZcfyP3FtFZvL/1aeaXm/lhk3ykiGu9DSs6Mgqa2+hTpj63i7s0WfrVMY228zJP2KfPNPnV7+epj5abZvDbPvE2kvQmyTK/n1M2qBH5Nfb3Rsj72XTl1/E+JoI4hFRdP2McD2ftsjjb0g+/q1Ym/6uXz9uub8q074p+31Kq9pe7qExnLrfR4RAv2xnlH5MV/fDNNllE2/nHXTKdTVfxeh0vj9vs09+iNI72Wb7bsLd9ku6ibetLzU8PhJ8fo8NtOM3Wf/Dbn6N9yEyCnSF/X6CBQivfV7/wQ+uXow9RvI0W8TYOv138GezNu9Qf/J7eBJ3ot+k2WRb/P4CabON3dLYf9oeMIrurSUEJtM/jbhel9PN/JNU5yTZpcrzZTDq/Mpus/NpH2STODpOKOiZpQq7RbBLtVxN6Q5Ns4ycUrfo0ibbxB8+ms+k2iVbx/uZHfziEzOz0p9TfRWm8v+kCYYy+f9wvN1Ga+dU3qY/+Mwo7/9/lZ5P+6YiobtLkuCfc/Tdu+YrTq13naexlsk3SsBCBAD6dTfc+SvOk55r+R2j+eTaNdgtKJ/+ZcNB6LLIL6cimifcZ5cPpTXAmxOfZdON38eFu41NfPXa4/fRd+aQ1bmkpfJJvrvqcO77grvuLWn6ehdhrEJPRtr+TlVgat2o/B7Npsl4ffEZkJWaCz+bm/WdihsyTFjqEXzhmyTH7KYRVA79N/+tb+O9Uv5CvEI7p4/ez6WLr96v2N0X4ZlhofDFareKMiixa35Xhu+Va9fWwoTvaysd4lW0oQM8JffkRkazUO3pof5MbA8tknyXHNPzoj0JOBFeT76yeANgJSDER0kwEtxMwMHmHKCcGJpLrCQg9nU1/RDsR6CbfAXcTsDgBIyZC24mQPDz2DqWcOD1BZybGTN9T4UBERR8BWx/iwzHa1nxe/D2bruOtL8gx/4zdbIkwjul2+nb6Jv+FvOSiqvh4gzivhdib1nPLjV/eHo676dupWi64MxxVtBSOc+usXgm+WBq06zVfKiGlAb8Gv/bWI4/WC+O5c3qhELSNJPFx/KufvnVac9IkZILsb7b+O+JTEtLoHAmcZbLdxrkULM9Xf9Q6YvXx9afsPto4KEaSL4SQK2fWOlLOKL4U67VZ+tUaECNYqGgpI5TerNZrzdd87bhZRmvw6JfKVwcVylinhw4qFR10H32IbyrpmAvs+qPWSevPrz9q79nGWa1SyyVfLCV4sTILh8h5tFhEK7OMnFqsl1IZw1EuuUKwq6WRbuH0EhYislZ4Xp1VCmP6RxVWcqLcLI32h3WS7uigd8khzg/9Dz7jM/6elHOpJaqPDsuIlCjMyBjZ/T35GBz61Pt/+2X2afbrv//Pf5t/TD6yX2b071/Dvz/9+/TzbLogQRa4ZBfTD84FKmbJzZtzZrWD2dxIBujwPbH8L9O3/zCOSWfsDMAxJ1DNhLRMWB7ElN/T7vPCsH3+NjryffKH8isk0ZPsp/qAc7RMGidmAhgnD34OijkeYK+T9GOUruhbnCnOrURrjFJaUviez2i3SqHRUqBEa418P5t+ij5O39L3hVQGpVHWSGnBkXhIfdTQ/b7e1OVtLz2pygsbTqNVfCQDXJGhFufBfx/NU59t4u1NkjZg/Vx99j8Okz+UurgFCRRTAsVMKIYAszkwZ1pwpGjDWYTwwSHefvDpNsotwgLct7Q0eVcvnYJqmBHGzgRnqO0MjGBCc3MG6DLZ7SJK8MwPd2QPfIi2DbjflauTd9XqyQNLxbgQYiY1c4arGaBkqM5CT1ZJ8NAW23i/+kh1a+E3C+h/WSXBmPm2sXoSOnLmuBYzKZmQROOcOXcW+Nrv0+MhwF/5D8kxbcL+Y1gM4L8vFk+/aRSMcyVmRjAHXM0kMmXOwv6YbNfzXSj5mO+j/So51KD/nmzXkx/D2uQ/w9ppyFIza52YOcesQzVDzZw8CzlKl5tddOPnUXq8CUq1APtNsTD5Jiychul4Lm9cKU0MQ9GF+b7psfwWuPXTHK2GShc0PppNb+NgOu6SRYPotz66nfw9SSmQtEx2iygYY8GOz+M2Gx9ts82Px20W321j2h7MplRRdeM7H+6SD/7dnffknTCtDFAOb+ujw6bwCOjvKMty96mUMYvoEC/n+cdNZjzEy8k35acrT1Z2MN93fuuJe7IovSF7cHqXxsGyn4WijoR02t3m0yFe5r7FPt6VHkMFJM33I5miQydbcpV/DHEqstEGDle93lA9OjcgTIOWqMiPzPy/x9ttWWEaCs1CRrix97s0+ScZvttLB8hVb2f7y+iQ1ZsXtr13Kezw3hmIz6SESoPvtxJgZalLVAtFejhaEgl+V/sEK7OAaaVIOXOGdMRy47NPd3T0zXEX7ZM4BOKSlQ+/PmBWVAWuHUf3zTL1UXZM/eHNKj7cbaNPc8thnh/arwpbo9Ts/2f6dg6B6Z1GoY2T1lEpUu53/KXwD/hsuo0WfvunwuwGYlZFajq99enPBfcAE/bz50I1E4eoPtOoU0zTUIJ/38QZ5Yy2d0P8Q+HAV/7p8w8IJWVDD5B7Pfkm3SVpk33Ct+7HP2v6xavYB/ipvY/gHy+XetXnn4Vb2LWo+QcYp/Du78tARrjHZSDBjDbYZSDOtNFNDnLCdDnIBSlZcNAiOZzVv42gVxHUmnybP7KJDn9Okmz6NkuPfkhLwSCXMRSu+Y899XZHcB5lbL5yztsk20/zwzG9aZief0q2nybvis8aOyUjprlRCiUtPs1DwKu5W/rN81wm2ttUjnLk/W1yZkVt2gB/JIUFjCokf19+AwDzyBwnGQ/5rK7K4oC
Line 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":326,"roamingPacks":16,"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":169,"roamingPacks":15,"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":145,"roamingPacks":8,"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":326,"roamingPacks":16,"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":169,"roamingPacks":15,"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":145,"roamingPacks":8,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
manastormCheckpointForLevel,
|
||||
manastormModeForCharacterLevel,
|
||||
manastormExperienceReward,
|
||||
manastormAffixes,
|
||||
manastormScaling,
|
||||
setManastormCatalog,
|
||||
shuffleManastormEncounterIds,
|
||||
@@ -155,6 +156,32 @@ describe("Manastorm rules", () => {
|
||||
.toBe(fullGroup.encounter.id);
|
||||
});
|
||||
|
||||
it("does not restore fallback content when a published Ready pool is empty", () => {
|
||||
const emptyPublishedCatalog: ManastormCatalogLike = { stages: [], fallbackPolicy: "none" };
|
||||
expect(() => drawManastormStage(1, 1, [], null, () => 0, emptyPublishedCatalog))
|
||||
.toThrow("no unlocked encounter");
|
||||
});
|
||||
|
||||
it("uses the standard pool at milestone levels when forced milestones are disabled", () => {
|
||||
const catalog: ManastormCatalogLike = {
|
||||
stages: [
|
||||
stage("standard", { unlockLevel: 1 }),
|
||||
stage("milestone", { unlockLevel: 5, stageKind: "milestone", milestoneLevel: 5 }),
|
||||
],
|
||||
runtimeOptions: { milestoneEncountersEnabled: false },
|
||||
};
|
||||
expect(drawManastormStage(5, 1, [], null, () => 0, catalog).encounter.id).toBe("standard");
|
||||
});
|
||||
|
||||
it("disables both explicit and modifier-derived affixes", () => {
|
||||
const catalog: ManastormCatalogLike = {
|
||||
affixes: [{ id: "explicit", name: "Explicit", description: "Fixture", unlockLevel: 1 }],
|
||||
modifiers: [{ id: "fallback", modeId: 0, level: 1, rawFlag: 7, tuning: [] }],
|
||||
runtimeOptions: { affixesEnabled: false },
|
||||
};
|
||||
expect(manastormAffixes(1, catalog, 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps leveling and end-game encounter pools separate by character state", () => {
|
||||
const catalog: ManastormCatalogLike = {
|
||||
id: "split-mode-fixture",
|
||||
|
||||
+22
-4
@@ -32,6 +32,7 @@ export type ManastormPhase =
|
||||
|
||||
export interface ManastormEncounterDefinition {
|
||||
readonly id: string;
|
||||
readonly encounterId?: number;
|
||||
readonly dungeonId: DungeonId;
|
||||
/** Authoritative package map identity; dungeonId may only select fallback scene geometry. */
|
||||
readonly mapId?: number;
|
||||
@@ -42,9 +43,12 @@ export interface ManastormEncounterDefinition {
|
||||
readonly bossEntityId: string;
|
||||
readonly bossName: string;
|
||||
readonly bossPosition: MobVector3;
|
||||
readonly bossYaw?: number;
|
||||
readonly playerSpawn: WorldSpawn;
|
||||
readonly portalPosition: MobVector3;
|
||||
readonly trashRuntimeIds?: readonly string[];
|
||||
readonly requiredGuardianKills?: number;
|
||||
readonly chaoticLinkEnabled?: boolean;
|
||||
readonly modeIds?: readonly number[];
|
||||
readonly stageKind?: ManastormStageKind;
|
||||
readonly unlockLevel?: number;
|
||||
@@ -186,9 +190,12 @@ export interface ManastormCatalogEncounterLike {
|
||||
readonly bossEntityId?: string;
|
||||
readonly bossName?: string;
|
||||
readonly bossPosition?: MobVector3;
|
||||
readonly bossYaw?: number;
|
||||
readonly playerSpawn?: WorldSpawn;
|
||||
readonly portalPosition?: MobVector3;
|
||||
readonly trashRuntimeIds?: readonly string[];
|
||||
readonly requiredGuardianKills?: number;
|
||||
readonly chaoticLinkEnabled?: boolean;
|
||||
readonly stageKind?: ManastormStageKind;
|
||||
readonly unlockLevel?: number;
|
||||
readonly milestoneLevel?: ManastormSpecialLevel;
|
||||
@@ -299,6 +306,15 @@ export interface ManastormCatalogLike {
|
||||
readonly rewards?: readonly ManastormCatalogRewardLike[];
|
||||
readonly loadouts?: readonly ManastormCatalogLoadoutLike[];
|
||||
readonly defaultLoadoutId?: string;
|
||||
readonly fallbackPolicy?: "legacy" | "none";
|
||||
readonly runtimeOptions?: {
|
||||
readonly enabled?: boolean;
|
||||
readonly levelingEnabled?: boolean;
|
||||
readonly endgameEnabled?: boolean;
|
||||
readonly affixesEnabled?: boolean;
|
||||
readonly chaoticLinkEnabled?: boolean;
|
||||
readonly milestoneEncountersEnabled?: boolean;
|
||||
};
|
||||
readonly scaling?: {
|
||||
readonly perLevel?: number;
|
||||
readonly bonusLootPerLevel?: number;
|
||||
@@ -583,7 +599,7 @@ export function runtimeStages(catalog: ManastormCatalogLike = getManastormCatalo
|
||||
minimumPartySize: normalizeManastormPartySize(stage.minimumPartySize ?? 1),
|
||||
maximumPartySize: normalizeManastormPartySize(stage.maximumPartySize ?? 5),
|
||||
}));
|
||||
return stages.length ? stages : MANASTORM_ENCOUNTERS;
|
||||
return stages.length || catalog.fallbackPolicy === "none" ? stages : MANASTORM_ENCOUNTERS;
|
||||
}
|
||||
|
||||
export function drawManastormStage(
|
||||
@@ -609,7 +625,8 @@ export function drawManastormStage(
|
||||
&& (stage.minimumPartySize ?? 1) <= safePartySize
|
||||
&& (stage.maximumPartySize ?? 5) >= safePartySize
|
||||
));
|
||||
const special = isManastormSpecialLevel(safeLevel);
|
||||
const special = catalog.runtimeOptions?.milestoneEncountersEnabled !== false
|
||||
&& isManastormSpecialLevel(safeLevel);
|
||||
const milestoneStages = unlocked.filter((stage) => stage.milestoneLevel === safeLevel);
|
||||
const milestonePool = unlocked.filter((stage) => (
|
||||
stage.stageKind === "milestone" || stage.milestoneLevel !== undefined
|
||||
@@ -621,7 +638,7 @@ export function drawManastormStage(
|
||||
let eligible = special
|
||||
? (milestoneStages.length ? milestoneStages : milestonePool)
|
||||
: standardStages;
|
||||
if (!eligible.length) {
|
||||
if (!eligible.length && catalog.fallbackPolicy !== "none") {
|
||||
eligible = MANASTORM_ENCOUNTERS.filter((stage) => (
|
||||
(stage.unlockLevel ?? 1) <= safeLevel
|
||||
&& (special
|
||||
@@ -629,7 +646,7 @@ export function drawManastormStage(
|
||||
: stage.stageKind !== "milestone" && !stage.milestoneLevel)
|
||||
));
|
||||
}
|
||||
if (!eligible.length) eligible = [MANASTORM_ENCOUNTERS[0]];
|
||||
if (!eligible.length && catalog.fallbackPolicy !== "none") eligible = [MANASTORM_ENCOUNTERS[0]];
|
||||
|
||||
const byId = new Map(eligible.map((stage) => [stage.id, stage]));
|
||||
let available = bag.filter((id) => byId.has(id));
|
||||
@@ -839,6 +856,7 @@ export function manastormAffixes(
|
||||
modeId = manastormModeId(catalog),
|
||||
unlockedThroughLevel = level,
|
||||
): readonly ManastormAffixState[] {
|
||||
if (catalog.runtimeOptions?.affixesEnabled === false) return [];
|
||||
const safeLevel = Math.max(1, Math.trunc(Number.isFinite(level) ? level : 1));
|
||||
const safeUnlockedThroughLevel = Math.max(
|
||||
safeLevel,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runtimeStages } from "./manastorm";
|
||||
import {
|
||||
DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
UNIFIED_MANASTORM_BOSS_CATALOG,
|
||||
authoredStageConfigForBoss,
|
||||
bossAdminRecordForStatus,
|
||||
derivePartySpawnSlots,
|
||||
effectiveManastormCatalog,
|
||||
isGameMasterSession,
|
||||
resolveBossAdminEntry,
|
||||
resolvedBossAdminCatalog,
|
||||
type ManastormAdminConfig,
|
||||
} from "./manastormAdminConfig";
|
||||
|
||||
function configWithBoss(key: string, record: ManastormAdminConfig["bosses"][string]): ManastormAdminConfig {
|
||||
return {
|
||||
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
bosses: { [key]: record },
|
||||
};
|
||||
}
|
||||
|
||||
describe("Manastorm GM catalog", () => {
|
||||
it("deduplicates map/name records and defaults every boss to Needs tested", () => {
|
||||
const identities = UNIFIED_MANASTORM_BOSS_CATALOG.map((entry) => `${entry.mapId}:${entry.name.toLowerCase()}`);
|
||||
expect(new Set(identities).size).toBe(identities.length);
|
||||
expect(UNIFIED_MANASTORM_BOSS_CATALOG.length).toBeGreaterThan(0);
|
||||
expect(resolvedBossAdminCatalog(DEFAULT_MANASTORM_ADMIN_CONFIG).every((entry) => (
|
||||
entry.status === "needs-tested" && !entry.inPublicPool
|
||||
))).toBe(true);
|
||||
const crossMode = UNIFIED_MANASTORM_BOSS_CATALOG.find((entry) => entry.name === "Burning Felguard");
|
||||
expect(crossMode?.baseStage?.modeIds).toEqual(expect.arrayContaining([0, 2]));
|
||||
});
|
||||
|
||||
it("publishes only runnable Ready bosses and never restores the legacy pool", () => {
|
||||
const candidate = UNIFIED_MANASTORM_BOSS_CATALOG.find((entry) => (
|
||||
resolveBossAdminEntry(entry, configWithBoss(entry.key, { status: "ready" })).runnable
|
||||
));
|
||||
expect(candidate).toBeTruthy();
|
||||
const config = configWithBoss(candidate!.key, { status: "ready" });
|
||||
const effective = effectiveManastormCatalog(config);
|
||||
expect(effective.fallbackPolicy).toBe("none");
|
||||
expect(runtimeStages(effective)).toHaveLength(1);
|
||||
expect(runtimeStages(effective)[0].bossName).toBe(candidate!.name);
|
||||
|
||||
const empty = effectiveManastormCatalog(DEFAULT_MANASTORM_ADMIN_CONFIG);
|
||||
expect(empty.fallbackPolicy).toBe("none");
|
||||
expect(runtimeStages(empty)).toEqual([]);
|
||||
});
|
||||
|
||||
it("materializes an inherited imported stage when a runnable boss becomes Ready", () => {
|
||||
const candidate = UNIFIED_MANASTORM_BOSS_CATALOG.find((entry) => entry.baseStage);
|
||||
expect(candidate).toBeTruthy();
|
||||
const record = bossAdminRecordForStatus(candidate!, DEFAULT_MANASTORM_ADMIN_CONFIG, "ready");
|
||||
expect(record.status).toBe("ready");
|
||||
expect(record.stage).toMatchObject({
|
||||
dungeonId: candidate!.baseStage!.dungeonId,
|
||||
bossRuntimeId: candidate!.baseStage!.bossRuntimeId,
|
||||
bossPosition: candidate!.baseStage!.bossPosition,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects stale exact-mob IDs and bounds required kills", () => {
|
||||
const candidate = UNIFIED_MANASTORM_BOSS_CATALOG.find((entry) => authoredStageConfigForBoss(entry, DEFAULT_MANASTORM_ADMIN_CONFIG));
|
||||
expect(candidate).toBeTruthy();
|
||||
const stage = authoredStageConfigForBoss(candidate!, DEFAULT_MANASTORM_ADMIN_CONFIG)!;
|
||||
const stale = configWithBoss(candidate!.key, {
|
||||
status: "ready",
|
||||
stage: {
|
||||
...stage,
|
||||
linkedMobRuntimeIds: ["missing:runtime-id"],
|
||||
requiredKillCount: 1,
|
||||
},
|
||||
});
|
||||
const resolved = resolveBossAdminEntry(candidate!, stale);
|
||||
expect(resolved.runnable).toBe(false);
|
||||
expect(resolved.validationIssues.join(" ")).toContain("missing:runtime-id");
|
||||
});
|
||||
|
||||
it("derives a stable five-person formation from one anchor and yaw", () => {
|
||||
const slots = derivePartySpawnSlots({ footPosition: [10, 2, 20], yaw: Math.PI / 2 }, 5);
|
||||
expect(slots).toHaveLength(5);
|
||||
expect(slots[0]).toEqual({ footPosition: [10, 2, 20], yaw: Math.PI / 2 });
|
||||
expect(new Set(slots.map((slot) => slot.footPosition.join(","))).size).toBe(5);
|
||||
expect(slots.every((slot) => slot.yaw === Math.PI / 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("never grants GM UI authority to offline or local-only sessions", () => {
|
||||
expect(isGameMasterSession({ kind: "offline", roles: ["gm"] })).toBe(false);
|
||||
expect(isGameMasterSession({ kind: "account", roles: ["gm"] })).toBe(false);
|
||||
expect(isGameMasterSession({ kind: "account", accessToken: "server-token", roles: ["gm"] })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,425 @@
|
||||
import { DUNGEON_DEFINITIONS, dungeonDefinitionById } from "./dungeonRegistry";
|
||||
import type { DungeonBossObjective, Vector3Tuple, WorldSpawn } from "./dungeonTypes";
|
||||
import {
|
||||
runtimeStages,
|
||||
type ManastormCatalogLike,
|
||||
type ManastormEncounterDefinition,
|
||||
} from "./manastorm";
|
||||
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "./manastormInstalledCatalog";
|
||||
|
||||
export const MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION = 1;
|
||||
export const MANASTORM_ADMIN_CACHE_KEY = "healer-man.manastorm-admin-config.v1";
|
||||
|
||||
export type ManastormBossStatus = "ready" | "needs-tested" | "not-ready";
|
||||
|
||||
export interface ManastormGlobalOptions {
|
||||
readonly enabled: boolean;
|
||||
readonly levelingEnabled: boolean;
|
||||
readonly endgameEnabled: boolean;
|
||||
readonly affixesEnabled: boolean;
|
||||
readonly chaoticLinkEnabled: boolean;
|
||||
readonly milestoneEncountersEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface ManastormAdminStageConfig {
|
||||
readonly dungeonId: string;
|
||||
readonly mapId: number;
|
||||
readonly baseStageId?: string;
|
||||
readonly assetPackageId?: string;
|
||||
readonly bossRuntimeId: string;
|
||||
readonly bossEntityId: string;
|
||||
readonly bossName: string;
|
||||
readonly bossPosition: Vector3Tuple;
|
||||
readonly bossYaw: number;
|
||||
readonly groupSpawn: WorldSpawn;
|
||||
readonly portalPosition: Vector3Tuple;
|
||||
readonly linkedMobRuntimeIds: readonly string[];
|
||||
readonly requiredKillCount: number;
|
||||
}
|
||||
|
||||
export interface ManastormBossAdminRecord {
|
||||
readonly status: ManastormBossStatus;
|
||||
readonly stage?: ManastormAdminStageConfig;
|
||||
}
|
||||
|
||||
export interface ManastormAdminConfig {
|
||||
readonly schemaVersion: typeof MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION;
|
||||
readonly global: ManastormGlobalOptions;
|
||||
readonly dungeonSpawns: Readonly<Record<string, WorldSpawn>>;
|
||||
readonly bosses: Readonly<Record<string, ManastormBossAdminRecord>>;
|
||||
}
|
||||
|
||||
export interface PublishedManastormAdminConfig {
|
||||
readonly revision: number;
|
||||
readonly updatedAt: number | null;
|
||||
readonly config: ManastormAdminConfig;
|
||||
}
|
||||
|
||||
export interface UnifiedBossCatalogEntry {
|
||||
readonly key: string;
|
||||
readonly name: string;
|
||||
readonly dungeonId: string | null;
|
||||
readonly dungeonTitle: string;
|
||||
readonly mapId: number;
|
||||
readonly bossRuntimeId: string;
|
||||
readonly bossEntityId: string;
|
||||
readonly source: readonly ("dungeon" | "installed-manastorm")[];
|
||||
readonly sourceIds: readonly string[];
|
||||
readonly baseStage: ManastormEncounterDefinition | null;
|
||||
readonly objective: DungeonBossObjective | null;
|
||||
}
|
||||
|
||||
export interface ResolvedBossAdminEntry extends UnifiedBossCatalogEntry {
|
||||
readonly status: ManastormBossStatus;
|
||||
readonly stage: ManastormEncounterDefinition | null;
|
||||
readonly runnable: boolean;
|
||||
readonly validationIssues: readonly string[];
|
||||
readonly inPublicPool: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_MANASTORM_ADMIN_CONFIG: ManastormAdminConfig = Object.freeze({
|
||||
schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION,
|
||||
global: Object.freeze({
|
||||
enabled: true,
|
||||
levelingEnabled: true,
|
||||
endgameEnabled: true,
|
||||
affixesEnabled: true,
|
||||
chaoticLinkEnabled: true,
|
||||
milestoneEncountersEnabled: true,
|
||||
}),
|
||||
dungeonSpawns: Object.freeze({}),
|
||||
bosses: Object.freeze({}),
|
||||
});
|
||||
|
||||
function normalizedName(value: string): string {
|
||||
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function finiteVector(value: unknown): value is Vector3Tuple {
|
||||
return Array.isArray(value) && value.length === 3 && value.every((coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate));
|
||||
}
|
||||
|
||||
function normalizedSpawn(value: unknown): WorldSpawn | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const input = value as Partial<WorldSpawn>;
|
||||
if (!finiteVector(input.footPosition) || typeof input.yaw !== "number" || !Number.isFinite(input.yaw)) return null;
|
||||
return { footPosition: [...input.footPosition] as Vector3Tuple, yaw: input.yaw };
|
||||
}
|
||||
|
||||
function normalizedStage(value: unknown): ManastormAdminStageConfig | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const input = value as Partial<ManastormAdminStageConfig>;
|
||||
if (
|
||||
typeof input.dungeonId !== "string"
|
||||
|| !Number.isInteger(input.mapId)
|
||||
|| typeof input.bossRuntimeId !== "string"
|
||||
|| typeof input.bossEntityId !== "string"
|
||||
|| typeof input.bossName !== "string"
|
||||
|| !finiteVector(input.bossPosition)
|
||||
|| typeof input.bossYaw !== "number"
|
||||
|| !Number.isFinite(input.bossYaw)
|
||||
|| !finiteVector(input.portalPosition)
|
||||
|| !Array.isArray(input.linkedMobRuntimeIds)
|
||||
|| !input.linkedMobRuntimeIds.every((id) => typeof id === "string" && Boolean(id))
|
||||
|| !Number.isInteger(input.requiredKillCount)
|
||||
) return null;
|
||||
const groupSpawn = normalizedSpawn(input.groupSpawn);
|
||||
const linkedMobRuntimeIds = [...new Set(input.linkedMobRuntimeIds)];
|
||||
if (!groupSpawn || input.requiredKillCount! < 0 || input.requiredKillCount! > linkedMobRuntimeIds.length) return null;
|
||||
return {
|
||||
dungeonId: input.dungeonId,
|
||||
mapId: input.mapId!,
|
||||
...(typeof input.baseStageId === "string" && input.baseStageId ? { baseStageId: input.baseStageId } : {}),
|
||||
...(typeof input.assetPackageId === "string" && input.assetPackageId ? { assetPackageId: input.assetPackageId } : {}),
|
||||
bossRuntimeId: input.bossRuntimeId,
|
||||
bossEntityId: input.bossEntityId,
|
||||
bossName: input.bossName,
|
||||
bossPosition: [...input.bossPosition] as Vector3Tuple,
|
||||
bossYaw: input.bossYaw,
|
||||
groupSpawn,
|
||||
portalPosition: [...input.portalPosition] as Vector3Tuple,
|
||||
linkedMobRuntimeIds,
|
||||
requiredKillCount: input.requiredKillCount!,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeManastormAdminConfig(value: unknown): ManastormAdminConfig {
|
||||
if (!value || typeof value !== "object") return DEFAULT_MANASTORM_ADMIN_CONFIG;
|
||||
const input = value as Partial<ManastormAdminConfig>;
|
||||
if (input.schemaVersion !== MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION) return DEFAULT_MANASTORM_ADMIN_CONFIG;
|
||||
const globalInput = input.global as Partial<ManastormGlobalOptions> | undefined;
|
||||
const global: ManastormGlobalOptions = {
|
||||
enabled: globalInput?.enabled !== false,
|
||||
levelingEnabled: globalInput?.levelingEnabled !== false,
|
||||
endgameEnabled: globalInput?.endgameEnabled !== false,
|
||||
affixesEnabled: globalInput?.affixesEnabled !== false,
|
||||
chaoticLinkEnabled: globalInput?.chaoticLinkEnabled !== false,
|
||||
milestoneEncountersEnabled: globalInput?.milestoneEncountersEnabled !== false,
|
||||
};
|
||||
const dungeonSpawns = Object.fromEntries(Object.entries(input.dungeonSpawns ?? {}).flatMap(([id, value]) => {
|
||||
const spawn = normalizedSpawn(value);
|
||||
return id && spawn ? [[id, spawn]] : [];
|
||||
}));
|
||||
const bosses = Object.fromEntries(Object.entries(input.bosses ?? {}).flatMap(([key, value]) => {
|
||||
if (!key || !value || typeof value !== "object") return [];
|
||||
const candidate = value as Partial<ManastormBossAdminRecord>;
|
||||
const status: ManastormBossStatus = candidate.status === "ready" || candidate.status === "not-ready" ? candidate.status : "needs-tested";
|
||||
const stage = normalizedStage(candidate.stage);
|
||||
return [[key, { status, ...(stage ? { stage } : {}) }]];
|
||||
}));
|
||||
return { schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION, global, dungeonSpawns, bosses };
|
||||
}
|
||||
|
||||
function dungeonBossEntityId(dungeonId: string, objectiveId: string): string {
|
||||
const dungeon = dungeonDefinitionById(dungeonId);
|
||||
const staticSpawn = dungeon?.staticSpawns.find((spawn) => spawn.id === objectiveId);
|
||||
if (staticSpawn) return staticSpawn.entityId;
|
||||
for (const pack of dungeon?.roamingPacks ?? []) {
|
||||
const member = pack.members.find((candidate) => `${pack.id}:${candidate.id}` === objectiveId);
|
||||
if (member) return member.entityId;
|
||||
}
|
||||
return objectiveId;
|
||||
}
|
||||
|
||||
function entryMergeKey(mapId: number, name: string): string {
|
||||
return `${mapId}:${normalizedName(name)}`;
|
||||
}
|
||||
|
||||
export function buildUnifiedBossCatalog(): readonly UnifiedBossCatalogEntry[] {
|
||||
const entries = new Map<string, UnifiedBossCatalogEntry>();
|
||||
for (const dungeon of DUNGEON_DEFINITIONS) {
|
||||
for (const objective of dungeon.bosses) {
|
||||
const mergeKey = entryMergeKey(dungeon.mapId, objective.name);
|
||||
entries.set(mergeKey, {
|
||||
key: `dungeon:${dungeon.id}:boss:${objective.id}`,
|
||||
name: objective.name,
|
||||
dungeonId: dungeon.id,
|
||||
dungeonTitle: dungeon.title,
|
||||
mapId: dungeon.mapId,
|
||||
bossRuntimeId: objective.id,
|
||||
bossEntityId: dungeonBossEntityId(dungeon.id, objective.id),
|
||||
source: ["dungeon"],
|
||||
sourceIds: [objective.id],
|
||||
baseStage: null,
|
||||
objective,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const stage of runtimeStages(INSTALLED_MANASTORM_RUNTIME_CATALOG)) {
|
||||
const mapId = stage.mapId ?? dungeonDefinitionById(stage.dungeonId)?.mapId ?? 0;
|
||||
const mergeKey = entryMergeKey(mapId, stage.bossName);
|
||||
const matchingDungeon = DUNGEON_DEFINITIONS.find((dungeon) => dungeon.mapId === mapId) ?? null;
|
||||
const existing = entries.get(mergeKey);
|
||||
const mergedStage = existing?.baseStage
|
||||
? {
|
||||
...stage,
|
||||
modeIds: [...new Set([...(existing.baseStage.modeIds ?? []), ...(stage.modeIds ?? [])])],
|
||||
}
|
||||
: stage;
|
||||
const key = stage.encounterId !== undefined
|
||||
? `map:${mapId}:encounter:${stage.encounterId}`
|
||||
: existing?.key ?? `manastorm:${stage.id}`;
|
||||
entries.set(mergeKey, {
|
||||
key,
|
||||
name: stage.bossName,
|
||||
dungeonId: matchingDungeon?.id ?? existing?.dungeonId ?? null,
|
||||
dungeonTitle: matchingDungeon?.title ?? stage.location ?? existing?.dungeonTitle ?? `Map ${mapId}`,
|
||||
mapId,
|
||||
bossRuntimeId: existing?.bossRuntimeId ?? stage.bossRuntimeId,
|
||||
bossEntityId: existing?.bossEntityId ?? stage.bossEntityId,
|
||||
source: [...new Set([...(existing?.source ?? []), "installed-manastorm" as const])],
|
||||
sourceIds: [...new Set([...(existing?.sourceIds ?? []), stage.id])],
|
||||
baseStage: mergedStage,
|
||||
objective: existing?.objective ?? null,
|
||||
});
|
||||
}
|
||||
return Object.freeze([...entries.values()].sort((left, right) => (
|
||||
left.dungeonTitle.localeCompare(right.dungeonTitle) || left.name.localeCompare(right.name)
|
||||
)));
|
||||
}
|
||||
|
||||
export const UNIFIED_MANASTORM_BOSS_CATALOG = buildUnifiedBossCatalog();
|
||||
|
||||
function knownMobRuntimeIds(entry: UnifiedBossCatalogEntry): ReadonlySet<string> {
|
||||
const ids = new Set<string>(entry.baseStage?.trashRuntimeIds ?? []);
|
||||
const dungeon = entry.dungeonId ? dungeonDefinitionById(entry.dungeonId) : null;
|
||||
for (const spawn of dungeon?.staticSpawns ?? []) ids.add(spawn.id);
|
||||
for (const pack of dungeon?.roamingPacks ?? []) {
|
||||
for (const member of pack.members) ids.add(`${pack.id}:${member.id}`);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function configuredStageForBoss(
|
||||
entry: UnifiedBossCatalogEntry,
|
||||
config: ManastormAdminConfig,
|
||||
): ManastormEncounterDefinition | null {
|
||||
const override = config.bosses[entry.key]?.stage;
|
||||
const base = entry.baseStage;
|
||||
if (!override && !base) return null;
|
||||
const dungeonId = override?.dungeonId ?? entry.dungeonId ?? base?.dungeonId;
|
||||
if (!dungeonId) return null;
|
||||
return {
|
||||
...(base ?? {}),
|
||||
id: base?.id ?? `gm:stage:${entry.key}`,
|
||||
dungeonId,
|
||||
mapId: override?.mapId ?? base?.mapId ?? entry.mapId,
|
||||
location: base?.location ?? entry.dungeonTitle,
|
||||
...(override?.assetPackageId || base?.assetPackageId
|
||||
? { assetPackageId: override?.assetPackageId ?? base?.assetPackageId }
|
||||
: {}),
|
||||
bossRuntimeId: override?.bossRuntimeId ?? base?.bossRuntimeId ?? entry.bossRuntimeId,
|
||||
bossEntityId: override?.bossEntityId ?? base?.bossEntityId ?? entry.bossEntityId,
|
||||
bossName: override?.bossName ?? base?.bossName ?? entry.name,
|
||||
bossPosition: override?.bossPosition ?? base?.bossPosition ?? entry.objective?.position ?? [0, 0, 0],
|
||||
bossYaw: override?.bossYaw ?? base?.bossYaw ?? 0,
|
||||
playerSpawn: override?.groupSpawn ?? base?.playerSpawn ?? config.dungeonSpawns[dungeonId] ?? dungeonDefinitionById(dungeonId)?.entrance ?? { footPosition: [0, 0, 0], yaw: 0 },
|
||||
portalPosition: override?.portalPosition ?? base?.portalPosition ?? config.dungeonSpawns[dungeonId]?.footPosition ?? dungeonDefinitionById(dungeonId)?.entrance.footPosition ?? [0, 0, 0],
|
||||
trashRuntimeIds: override?.linkedMobRuntimeIds ?? base?.trashRuntimeIds ?? [],
|
||||
requiredGuardianKills: override?.requiredKillCount ?? base?.requiredGuardianKills,
|
||||
modeIds: base?.modeIds ?? [0, 2],
|
||||
stageKind: base?.stageKind ?? "standard",
|
||||
unlockLevel: base?.unlockLevel ?? 1,
|
||||
minimumPartySize: base?.minimumPartySize ?? 1,
|
||||
maximumPartySize: base?.maximumPartySize ?? 5,
|
||||
};
|
||||
}
|
||||
|
||||
/** Produces the complete persisted override edited by GM tooling. */
|
||||
export function authoredStageConfigForBoss(
|
||||
entry: UnifiedBossCatalogEntry,
|
||||
config: ManastormAdminConfig,
|
||||
): ManastormAdminStageConfig | null {
|
||||
const stage = configuredStageForBoss(entry, config);
|
||||
if (!stage) {
|
||||
if (!entry.dungeonId || !entry.objective) return null;
|
||||
const dungeon = dungeonDefinitionById(entry.dungeonId);
|
||||
if (!dungeon) return null;
|
||||
const groupSpawn = config.dungeonSpawns[entry.dungeonId] ?? dungeon.entrance;
|
||||
const staticBoss = dungeon.staticSpawns.find((spawn) => spawn.id === entry.bossRuntimeId);
|
||||
return {
|
||||
dungeonId: entry.dungeonId,
|
||||
mapId: entry.mapId,
|
||||
bossRuntimeId: entry.bossRuntimeId,
|
||||
bossEntityId: entry.bossEntityId,
|
||||
bossName: entry.name,
|
||||
bossPosition: [...entry.objective.position] as Vector3Tuple,
|
||||
bossYaw: staticBoss?.yaw ?? 0,
|
||||
groupSpawn: { footPosition: [...groupSpawn.footPosition] as Vector3Tuple, yaw: groupSpawn.yaw },
|
||||
portalPosition: [...groupSpawn.footPosition] as Vector3Tuple,
|
||||
linkedMobRuntimeIds: [],
|
||||
requiredKillCount: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
dungeonId: stage.dungeonId,
|
||||
mapId: stage.mapId ?? entry.mapId,
|
||||
baseStageId: stage.id,
|
||||
...(stage.assetPackageId ? { assetPackageId: stage.assetPackageId } : {}),
|
||||
bossRuntimeId: stage.bossRuntimeId,
|
||||
bossEntityId: stage.bossEntityId,
|
||||
bossName: stage.bossName,
|
||||
bossPosition: [...stage.bossPosition] as Vector3Tuple,
|
||||
bossYaw: stage.bossYaw ?? 0,
|
||||
groupSpawn: {
|
||||
footPosition: [...stage.playerSpawn.footPosition] as Vector3Tuple,
|
||||
yaw: stage.playerSpawn.yaw,
|
||||
},
|
||||
portalPosition: [...stage.portalPosition] as Vector3Tuple,
|
||||
linkedMobRuntimeIds: [...(stage.trashRuntimeIds ?? [])],
|
||||
requiredKillCount: Math.max(
|
||||
0,
|
||||
Math.min(stage.trashRuntimeIds?.length ?? 0, stage.requiredGuardianKills ?? Math.ceil((stage.trashRuntimeIds?.length ?? 0) / 2)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Materializes inherited stage data when a catalog boss is promoted to Ready. */
|
||||
export function bossAdminRecordForStatus(
|
||||
entry: UnifiedBossCatalogEntry,
|
||||
config: ManastormAdminConfig,
|
||||
status: ManastormBossStatus,
|
||||
): ManastormBossAdminRecord {
|
||||
const existing = config.bosses[entry.key];
|
||||
const stage = status === "ready"
|
||||
? (existing?.stage ?? authoredStageConfigForBoss(entry, config) ?? undefined)
|
||||
: existing?.stage;
|
||||
return { ...existing, status, ...(stage ? { stage } : {}) };
|
||||
}
|
||||
|
||||
export function resolveBossAdminEntry(
|
||||
entry: UnifiedBossCatalogEntry,
|
||||
config: ManastormAdminConfig,
|
||||
): ResolvedBossAdminEntry {
|
||||
const status = config.bosses[entry.key]?.status ?? "needs-tested";
|
||||
const stage = configuredStageForBoss(entry, config);
|
||||
const issues: string[] = [];
|
||||
if (!stage) issues.push("No runnable Manastorm stage has been configured.");
|
||||
if (stage && !dungeonDefinitionById(stage.dungeonId)) issues.push("The configured dungeon is not installed.");
|
||||
if (stage) {
|
||||
const dungeon = dungeonDefinitionById(stage.dungeonId);
|
||||
if (entry.dungeonId && stage.dungeonId !== entry.dungeonId) issues.push("The stage references a different dungeon than its boss record.");
|
||||
if (dungeon && stage.mapId !== dungeon.mapId) issues.push("The stage map does not match its dungeon.");
|
||||
const knownIds = knownMobRuntimeIds(entry);
|
||||
const missingIds = (stage.trashRuntimeIds ?? []).filter((id) => !knownIds.has(id));
|
||||
if (missingIds.length) issues.push(`Missing linked mobs: ${missingIds.join(", ")}.`);
|
||||
const required = stage.requiredGuardianKills ?? Math.ceil((stage.trashRuntimeIds?.length ?? 0) / 2);
|
||||
if (required < 0 || required > (stage.trashRuntimeIds?.length ?? 0)) issues.push("The required kill count is outside the linked mob set.");
|
||||
}
|
||||
const runnable = Boolean(stage && issues.length === 0);
|
||||
return {
|
||||
...entry,
|
||||
status,
|
||||
stage,
|
||||
runnable,
|
||||
validationIssues: Object.freeze(issues),
|
||||
inPublicPool: status === "ready" && runnable,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvedBossAdminCatalog(config: ManastormAdminConfig): readonly ResolvedBossAdminEntry[] {
|
||||
return UNIFIED_MANASTORM_BOSS_CATALOG.map((entry) => resolveBossAdminEntry(entry, config));
|
||||
}
|
||||
|
||||
export function effectiveManastormCatalog(
|
||||
config: ManastormAdminConfig,
|
||||
base: ManastormCatalogLike = INSTALLED_MANASTORM_RUNTIME_CATALOG,
|
||||
): ManastormCatalogLike {
|
||||
const stages = resolvedBossAdminCatalog(config)
|
||||
.filter((entry) => entry.inPublicPool)
|
||||
.flatMap((entry) => entry.stage ? [entry.stage] : []);
|
||||
return Object.freeze({
|
||||
...base,
|
||||
stages: Object.freeze(stages.map((stage) => Object.freeze({
|
||||
...stage,
|
||||
chaoticLinkEnabled: config.global.chaoticLinkEnabled,
|
||||
}))),
|
||||
fallbackPolicy: "none" as const,
|
||||
runtimeOptions: Object.freeze({ ...config.global }),
|
||||
});
|
||||
}
|
||||
|
||||
export function isGameMasterSession(session: { readonly kind: string; readonly accessToken?: string; readonly roles?: readonly string[] } | null): boolean {
|
||||
return Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
|
||||
}
|
||||
|
||||
export function derivePartySpawnSlots(spawn: WorldSpawn, partySize = 5): readonly WorldSpawn[] {
|
||||
const size = Math.max(1, Math.min(5, Math.trunc(partySize)));
|
||||
const offsets = [
|
||||
[0, 0],
|
||||
[-1.65, -1.8],
|
||||
[1.65, -1.8],
|
||||
[-3.05, -3.5],
|
||||
[3.05, -3.5],
|
||||
] as const;
|
||||
const sin = Math.sin(spawn.yaw);
|
||||
const cos = Math.cos(spawn.yaw);
|
||||
return Object.freeze(offsets.slice(0, size).map(([lateral, forward]) => ({
|
||||
footPosition: [
|
||||
spawn.footPosition[0] + lateral * cos + forward * sin,
|
||||
spawn.footPosition[1],
|
||||
spawn.footPosition[2] - lateral * sin + forward * cos,
|
||||
] as const,
|
||||
yaw: spawn.yaw,
|
||||
})));
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { create } from "zustand";
|
||||
import type { PlayerSession } from "../app/types";
|
||||
import { requestOnlineJson } from "../app/onlineAccountClient";
|
||||
import {
|
||||
DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
MANASTORM_ADMIN_CACHE_KEY,
|
||||
normalizeManastormAdminConfig,
|
||||
resolvedBossAdminCatalog,
|
||||
type ManastormAdminConfig,
|
||||
type PublishedManastormAdminConfig,
|
||||
} from "./manastormAdminConfig";
|
||||
|
||||
interface CachedPayload extends PublishedManastormAdminConfig {}
|
||||
|
||||
function readCache(): CachedPayload {
|
||||
try {
|
||||
const raw = typeof window !== "undefined" ? window.localStorage.getItem(MANASTORM_ADMIN_CACHE_KEY) : null;
|
||||
const parsed = raw ? JSON.parse(raw) as Partial<CachedPayload> : null;
|
||||
if (!parsed || !Number.isInteger(parsed.revision) || (parsed.revision ?? -1) < 0) throw new Error("missing cache");
|
||||
return {
|
||||
revision: parsed.revision!,
|
||||
updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : null,
|
||||
config: normalizeManastormAdminConfig(parsed.config),
|
||||
};
|
||||
} catch {
|
||||
return { revision: 0, updatedAt: null, config: DEFAULT_MANASTORM_ADMIN_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(payload: PublishedManastormAdminConfig): void {
|
||||
try {
|
||||
window.localStorage.setItem(MANASTORM_ADMIN_CACHE_KEY, JSON.stringify(payload));
|
||||
} catch {
|
||||
// Private browsing and storage quotas must not prevent play.
|
||||
}
|
||||
}
|
||||
|
||||
export interface ManastormAdminState extends PublishedManastormAdminConfig {
|
||||
readonly draft: ManastormAdminConfig;
|
||||
readonly loading: boolean;
|
||||
readonly saving: boolean;
|
||||
readonly error: string | null;
|
||||
readonly editingBossKey: string | null;
|
||||
readonly dirty: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
replaceDraft: (config: ManastormAdminConfig) => void;
|
||||
resetDraft: () => void;
|
||||
publish: (session: PlayerSession | null) => Promise<boolean>;
|
||||
setEditingBossKey: (key: string | null) => void;
|
||||
}
|
||||
|
||||
const cached = readCache();
|
||||
|
||||
export const useManastormAdminStore = create<ManastormAdminState>((set, get) => ({
|
||||
...cached,
|
||||
draft: cached.config,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
editingBossKey: null,
|
||||
dirty: false,
|
||||
refresh: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const payload = await requestOnlineJson<PublishedManastormAdminConfig>("/api/manastorm-config");
|
||||
const normalized = { ...payload, config: normalizeManastormAdminConfig(payload.config) };
|
||||
writeCache(normalized);
|
||||
set({ ...normalized, draft: normalized.config, dirty: false, loading: false });
|
||||
} catch (error) {
|
||||
set({ loading: false, error: error instanceof Error ? error.message : "Could not refresh Manastorm configuration." });
|
||||
}
|
||||
},
|
||||
replaceDraft: (draft) => set({ draft: normalizeManastormAdminConfig(draft), dirty: true, error: null }),
|
||||
resetDraft: () => set((state) => ({ draft: state.config, dirty: false, error: null })),
|
||||
publish: async (session) => {
|
||||
if (!session?.accessToken || !session.roles?.includes("gm")) {
|
||||
set({ error: "Game Master access is required." });
|
||||
return false;
|
||||
}
|
||||
const invalidReadyBoss = resolvedBossAdminCatalog(get().draft).find((entry) => entry.status === "ready" && !entry.runnable);
|
||||
if (invalidReadyBoss) {
|
||||
set({ error: `${invalidReadyBoss.name} cannot be published as Ready: ${invalidReadyBoss.validationIssues.join(" ")}` });
|
||||
return false;
|
||||
}
|
||||
set({ saving: true, error: null });
|
||||
try {
|
||||
const payload = await requestOnlineJson<PublishedManastormAdminConfig>("/api/gm/manastorm-config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ expectedRevision: get().revision, config: get().draft }),
|
||||
}, session.accessToken);
|
||||
const normalized = { ...payload, config: normalizeManastormAdminConfig(payload.config) };
|
||||
writeCache(normalized);
|
||||
set({ ...normalized, draft: normalized.config, dirty: false, saving: false });
|
||||
return true;
|
||||
} catch (error) {
|
||||
set({ saving: false, error: error instanceof Error ? error.message : "Could not publish Manastorm configuration." });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
setEditingBossKey: (editingBossKey) => set({ editingBossKey }),
|
||||
}));
|
||||
@@ -77,4 +77,20 @@ describe("Manastorm Chaotic Link", () => {
|
||||
expect(manastormChaoticLinkRuntimeIds(encounter, "boss")).toEqual(expected);
|
||||
expect(manastormChaoticLinkRuntimeIds(encounter, "next")).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses an authored exact requirement and disables empowerment with the global switch", () => {
|
||||
expect(manastormChaoticLinkState({ ...encounter, requiredGuardianKills: 1 }, {})).toMatchObject({
|
||||
requiredGuardianKills: 1,
|
||||
activeStacks: 1,
|
||||
broken: false,
|
||||
});
|
||||
expect(manastormChaoticLinkState({ ...encounter, requiredGuardianKills: 5, chaoticLinkEnabled: false }, {})).toMatchObject({
|
||||
guardianCount: 5,
|
||||
requiredGuardianKills: 0,
|
||||
activeStacks: 0,
|
||||
broken: true,
|
||||
bossHealthMultiplier: 1,
|
||||
bossDamageMultiplier: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,15 @@ export function manastormChaoticLinkState(
|
||||
const defeatedGuardians = guardianIds.filter(
|
||||
(runtimeId) => mobs[runtimeId]?.dead === true,
|
||||
).length;
|
||||
const requiredGuardianKills = Math.ceil(guardianCount / 2);
|
||||
const requiredGuardianKills = encounter.chaoticLinkEnabled === false
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
guardianCount,
|
||||
Math.trunc(encounter.requiredGuardianKills ?? Math.ceil(guardianCount / 2)),
|
||||
),
|
||||
);
|
||||
const activeStacks = Math.max(0, requiredGuardianKills - defeatedGuardians);
|
||||
return Object.freeze({
|
||||
guardianCount,
|
||||
|
||||
@@ -121,14 +121,32 @@ export function manastormCueForStatusTransition(
|
||||
}
|
||||
|
||||
function browserAudioFactory(url: string): ManastormAudioHandle {
|
||||
return new Audio(url);
|
||||
let stopPlayback: (() => void) | null = null;
|
||||
let stopped = false;
|
||||
const handle: ManastormAudioHandle = {
|
||||
currentTime: 0,
|
||||
preload: "auto",
|
||||
volume: 1,
|
||||
pause: () => {
|
||||
stopped = true;
|
||||
stopPlayback?.();
|
||||
stopPlayback = null;
|
||||
},
|
||||
play: async () => {
|
||||
stopped = false;
|
||||
const stop = await playMasterAudioCue(url, handle.volume);
|
||||
if (!stop) throw new Error("Web Audio is unavailable.");
|
||||
if (stopped) stop();
|
||||
else stopPlayback = stop;
|
||||
},
|
||||
};
|
||||
return handle;
|
||||
}
|
||||
|
||||
export async function playManastormClientCue(
|
||||
cueId: ManastormPortalCueId,
|
||||
audioFactory: ManastormAudioFactory = browserAudioFactory,
|
||||
): Promise<boolean> {
|
||||
if (audioFactory === browserAudioFactory && typeof Audio === "undefined") return false;
|
||||
const cue = manastormPortalAudioCue(cueId);
|
||||
try {
|
||||
activeAudio?.pause();
|
||||
@@ -149,3 +167,4 @@ export function stopManastormClientCue(): void {
|
||||
activeAudio?.pause();
|
||||
activeAudio = null;
|
||||
}
|
||||
import { playMasterAudioCue } from "./combatAudio";
|
||||
@@ -173,7 +173,7 @@ describe("Manastorm encounter sessions", () => {
|
||||
expect(useManastormStore.getState().status).toBe("failed");
|
||||
});
|
||||
|
||||
it("spends a remaining charge instead of failing a full-party down event", () => {
|
||||
it("waits for an explicit shared resurrection when the full party is down", () => {
|
||||
useCombatStore.setState({ health: 0 });
|
||||
const companions = usePartyStore.getState().members;
|
||||
for (const companion of companions) {
|
||||
@@ -184,13 +184,17 @@ describe("Manastorm encounter sessions", () => {
|
||||
expect(resolveManastormActorDown({
|
||||
kind: "companion",
|
||||
memberId: companion.id,
|
||||
})).toBe("revived");
|
||||
expect(usePartyStore.getState().members.at(-1)!.health).toBe(companion.maxHealth);
|
||||
})).toBe("downed");
|
||||
expect(usePartyStore.getState().members.at(-1)!.health).toBe(0);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useManastormStore.getState()).toMatchObject({
|
||||
status: "entering",
|
||||
resurrectionCharges: 4,
|
||||
resurrectionCharges: 5,
|
||||
});
|
||||
|
||||
expect(resurrectManastormSession({ kind: "player" })).toBe(true);
|
||||
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
|
||||
expect(useManastormStore.getState().resurrectionCharges).toBe(4);
|
||||
});
|
||||
|
||||
it("drives real trash, boss, loot, portal, and next-stage state end to end", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCombatStore } from "./combatStore";
|
||||
import { synchronizeManastormAffixes } from "./manastormAffixRuntime";
|
||||
import { useManastormStore } from "./manastormStore";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import {
|
||||
manastormChaoticLinkState,
|
||||
type ManastormChaoticLinkState,
|
||||
@@ -153,7 +154,12 @@ export function synchronizeManastormPartySize(): void {
|
||||
function activateCurrentStage(preserveCharacter: boolean): boolean {
|
||||
const encounter = useManastormStore.getState().currentEncounter;
|
||||
if (!encounter) return false;
|
||||
const result = activateManastormStage(encounter, undefined, preserveCharacter);
|
||||
const result = activateManastormStage(
|
||||
encounter,
|
||||
undefined,
|
||||
preserveCharacter,
|
||||
useGameStore.getState().gmTestMode,
|
||||
);
|
||||
if (result.activated) synchronizeManastormPartySize();
|
||||
return result.activated;
|
||||
}
|
||||
@@ -234,7 +240,7 @@ function reviveTarget(target: ManastormResurrectionTarget): boolean {
|
||||
if (target.kind === "player") {
|
||||
const combat = useCombatStore.getState();
|
||||
if (combat.health > 0 || combat.maxHealth <= 0) return false;
|
||||
combat.healPlayer(combat.maxHealth);
|
||||
combat.revivePlayer(1);
|
||||
return useCombatStore.getState().health > 0;
|
||||
}
|
||||
|
||||
@@ -277,16 +283,16 @@ export function manastormPartyIsDefeated(): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a newly downed actor without resetting the floor. An actor remains
|
||||
* down when the shared pool is empty while an ally is still standing. Only a
|
||||
* full-party defeat with no valid charge-backed revive fails the run.
|
||||
* Resolves a newly downed actor without spending a shared resurrection on the
|
||||
* player's behalf. A charge can be used explicitly from the defeated prompt;
|
||||
* only a full-party defeat with no remaining charge fails the run.
|
||||
*/
|
||||
export function resolveManastormActorDown(
|
||||
target: ManastormResurrectionTarget,
|
||||
): ManastormDownResolution {
|
||||
if (!targetIsFallen(target)) return "ignored";
|
||||
if (resurrectManastormSession(target)) return "revived";
|
||||
if (!manastormPartyIsDefeated()) return "downed";
|
||||
if (useManastormStore.getState().resurrectionCharges > 0) return "downed";
|
||||
useManastormStore.getState().failRun();
|
||||
return "failed";
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ export function activateManastormStage(
|
||||
stage: ManastormLoadableStage,
|
||||
loader: ManastormStageLoader = defaultLoader,
|
||||
preserveCharacter = true,
|
||||
gmTestMode = false,
|
||||
): ManastormStageLoadResult {
|
||||
const nextPackage = stage.assetPackageId
|
||||
? manastormStageAssetPackageById(stage.assetPackageId)
|
||||
@@ -76,6 +77,7 @@ export function activateManastormStage(
|
||||
gameMode: "manastorm",
|
||||
spawn: stage.playerSpawn,
|
||||
preserveCharacter,
|
||||
gmTestMode,
|
||||
});
|
||||
if (!activated) {
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "./manastormInstalledStages";
|
||||
import {
|
||||
manastormStagePopulation,
|
||||
manastormStageRoamingPacks,
|
||||
registerManastormStageBindingResolver,
|
||||
} from "./manastormStagePopulation";
|
||||
|
||||
@@ -82,4 +83,23 @@ describe("installed Manastorm stage population", () => {
|
||||
.toBeGreaterThan(1);
|
||||
expect(presentations.every((visual) => visual && !visual.model)).toBe(true);
|
||||
});
|
||||
|
||||
it("removes a configured roaming boss from its patrol so its fixed spawn is authoritative", () => {
|
||||
const packs = [{
|
||||
id: "boss-patrol",
|
||||
name: "Boss patrol",
|
||||
speed: 1,
|
||||
formationSource: "database-solo" as const,
|
||||
waypoints: [[0, 0, 0], [2, 0, 0]] as const,
|
||||
members: [
|
||||
{ id: "boss", entityId: "boss-entity", role: "leader" as const, formationOffset: [0, 0, 0] as const },
|
||||
{ id: "guard", entityId: "guard-entity", role: "follower" as const, formationOffset: [1, 0, 1] as const },
|
||||
],
|
||||
}];
|
||||
const filtered = manastormStageRoamingPacks(packs, {
|
||||
...stages[0]!,
|
||||
bossRuntimeId: "boss-patrol:boss",
|
||||
});
|
||||
expect(filtered[0].members.map((member) => member.id)).toEqual(["guard"]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { ManastormEncounterDefinition } from "./manastorm";
|
||||
import type {
|
||||
BossDefinition,
|
||||
MobDefinition,
|
||||
MobPackDefinition,
|
||||
PopulationDefinitionMap,
|
||||
StaticMobSpawnDefinition,
|
||||
} from "./mobPopulation";
|
||||
@@ -12,6 +13,19 @@ export interface ManastormStagePopulation {
|
||||
readonly staticSpawns: readonly StaticMobSpawnDefinition[];
|
||||
}
|
||||
|
||||
/** Removes a dungeon-native roaming boss from its patrol when a fixed authored spawn replaces it. */
|
||||
export function manastormStageRoamingPacks(
|
||||
packs: readonly MobPackDefinition[],
|
||||
encounter: ManastormEncounterDefinition | null,
|
||||
): readonly MobPackDefinition[] {
|
||||
if (!encounter) return packs;
|
||||
return packs.flatMap((pack) => {
|
||||
const members = pack.members.filter((member) => `${pack.id}:${member.id}` !== encounter.bossRuntimeId);
|
||||
if (!members.length) return [];
|
||||
return members.length === pack.members.length ? [pack] : [{ ...pack, members }];
|
||||
});
|
||||
}
|
||||
|
||||
type ManastormStageBindingResolver = (
|
||||
stageId: string,
|
||||
) => ManastormInstalledStageBinding | null;
|
||||
@@ -111,7 +125,16 @@ export function manastormStagePopulation(
|
||||
const existingEntity = baseEntities[encounter.bossEntityId];
|
||||
const existingSpawn = baseStaticSpawns.find((spawn) => spawn.id === encounter.bossRuntimeId);
|
||||
if (existingEntity && existingSpawn) {
|
||||
return { entities: baseEntities, staticSpawns: baseStaticSpawns };
|
||||
return {
|
||||
entities: baseEntities,
|
||||
staticSpawns: baseStaticSpawns.map((spawn) => spawn.id === encounter.bossRuntimeId
|
||||
? {
|
||||
...spawn,
|
||||
position: encounter.bossPosition,
|
||||
...(Number.isFinite(encounter.bossYaw) ? { yaw: encounter.bossYaw } : {}),
|
||||
}
|
||||
: spawn),
|
||||
};
|
||||
}
|
||||
|
||||
const exactNameVisual = Object.values(baseEntities).find(
|
||||
@@ -145,6 +168,7 @@ export function manastormStagePopulation(
|
||||
id: encounter.bossRuntimeId,
|
||||
entityId: encounter.bossEntityId,
|
||||
position: encounter.bossPosition,
|
||||
...(Number.isFinite(encounter.bossYaw) ? { yaw: encounter.bossYaw } : {}),
|
||||
spawnMask: 1,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearMobRuntimeRegistry,
|
||||
getMobPosition,
|
||||
getMobRuntimeTransform,
|
||||
nearbyMobRuntimeIds,
|
||||
readMobRuntimePosition,
|
||||
registerMobRuntime,
|
||||
@@ -14,12 +15,13 @@ describe("mob runtime position registry", () => {
|
||||
beforeEach(clearMobRuntimeRegistry);
|
||||
|
||||
it("supports allocation-free frame updates and snapshot reads", () => {
|
||||
const unregister = registerMobRuntime("mob-a", [1, 2, 3]);
|
||||
const unregister = registerMobRuntime("mob-a", [1, 2, 3], 0.4);
|
||||
const out: [number, number, number] = [0, 0, 0];
|
||||
expect(readMobRuntimePosition("mob-a", out)).toBe(true);
|
||||
expect(out).toEqual([1, 2, 3]);
|
||||
updateMobRuntimePosition("mob-a", 4, 5, 6);
|
||||
updateMobRuntimePosition("mob-a", 4, 5, 6, 1.2);
|
||||
expect(getMobPosition("mob-a")).toEqual([4, 5, 6]);
|
||||
expect(getMobRuntimeTransform("mob-a")).toEqual({ position: [4, 5, 6], yaw: 1.2 });
|
||||
unregister();
|
||||
expect(getMobPosition("mob-a")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ export type MutableCombatPosition = [x: number, y: number, z: number];
|
||||
|
||||
interface RuntimeMobPosition {
|
||||
readonly value: MutableCombatPosition;
|
||||
yaw: number;
|
||||
mounted: boolean;
|
||||
}
|
||||
|
||||
@@ -12,9 +13,10 @@ function finite(value: number): number {
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
export function registerMobRuntime(id: string, initial: CombatPosition = [0, 0, 0]): () => void {
|
||||
export function registerMobRuntime(id: string, initial: CombatPosition = [0, 0, 0], yaw = 0): () => void {
|
||||
const record: RuntimeMobPosition = {
|
||||
value: [finite(initial[0]), finite(initial[1]), finite(initial[2])],
|
||||
yaw: finite(yaw),
|
||||
mounted: true,
|
||||
};
|
||||
runtimePositions.set(id, record);
|
||||
@@ -24,16 +26,17 @@ export function registerMobRuntime(id: string, initial: CombatPosition = [0, 0,
|
||||
}
|
||||
|
||||
/** Allocation-free update intended for roaming-pack useFrame callbacks. */
|
||||
export function updateMobRuntimePosition(id: string, x: number, y: number, z: number): void {
|
||||
export function updateMobRuntimePosition(id: string, x: number, y: number, z: number, yaw?: number): void {
|
||||
const record = runtimePositions.get(id);
|
||||
if (record) {
|
||||
record.value[0] = finite(x);
|
||||
record.value[1] = finite(y);
|
||||
record.value[2] = finite(z);
|
||||
if (yaw !== undefined) record.yaw = finite(yaw);
|
||||
record.mounted = true;
|
||||
return;
|
||||
}
|
||||
runtimePositions.set(id, { value: [finite(x), finite(y), finite(z)], mounted: true });
|
||||
runtimePositions.set(id, { value: [finite(x), finite(y), finite(z)], yaw: finite(yaw ?? 0), mounted: true });
|
||||
}
|
||||
|
||||
export function unregisterMobRuntime(id: string): void {
|
||||
@@ -56,6 +59,11 @@ export function getMobRuntimePosition(id: string): CombatPosition | null {
|
||||
return record?.mounted ? [...record.value] as MutableCombatPosition : null;
|
||||
}
|
||||
|
||||
export function getMobRuntimeTransform(id: string): { readonly position: CombatPosition; readonly yaw: number } | null {
|
||||
const record = runtimePositions.get(id);
|
||||
return record?.mounted ? { position: [...record.value] as MutableCombatPosition, yaw: record.yaw } : null;
|
||||
}
|
||||
|
||||
export function mobRuntimeDistanceSquared(id: string, origin: CombatPosition): number {
|
||||
const record = runtimePositions.get(id);
|
||||
if (!record?.mounted) return Number.POSITIVE_INFINITY;
|
||||
|
||||
@@ -75,6 +75,20 @@ describe("party combat runtime", () => {
|
||||
expect(usePartyStore.getState().members.map((member) => member.nextActionAt)).toEqual(actionRevisions);
|
||||
});
|
||||
|
||||
it("keeps living companions in the fight while the player waits for resurrection", () => {
|
||||
registerEngagedMob();
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
}
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||
const beforeMobHealth = useCombatStore.getState().mobs["pack:a"].health;
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().mobs["pack:a"].health).toBeLessThan(beforeMobHealth);
|
||||
});
|
||||
|
||||
it("assigns the tank to a loose mob attacking the healer before one attacking DPS", () => {
|
||||
const party = usePartyStore.getState();
|
||||
const tank = party.members.find((member) => member.role === "tank")!;
|
||||
@@ -177,6 +191,31 @@ describe("party combat runtime", () => {
|
||||
expect(coolingDown.targetId).toBeNull();
|
||||
});
|
||||
|
||||
it("uses an unlocked party resurrection instead of treating a defeated player as a heal target", () => {
|
||||
usePartyStore.getState().initializeParty("resurrection-runtime", 80, 100, "damage");
|
||||
const support = applyHealerSupport(usePartyStore.getState().members, 0, 100, 1_000);
|
||||
|
||||
expect(support).toMatchObject({
|
||||
action: "resurrection",
|
||||
targetId: "__player__",
|
||||
playerHealing: 0,
|
||||
});
|
||||
expect(support.playerResurrectionPercent).toBeGreaterThan(0);
|
||||
expect(support.presentation?.name.toLowerCase()).toMatch(/rebirth|redemption|resurrect|ancestral|revive/);
|
||||
});
|
||||
|
||||
it("lets the party raise the player in place while combat remains active", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
||||
usePartyStore.getState().initializeParty("resurrection-runtime", 80, useCombatStore.getState().health, "damage");
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
expect(useCombatStore.getState().health).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().feedback?.message).toContain("Resurrected");
|
||||
});
|
||||
|
||||
it("classifies ranged classes and elemental shaman into a stand-off range band", () => {
|
||||
const ranged = [
|
||||
{ classId: "hunter", specialization: "Marksmanship", role: "damage" },
|
||||
|
||||
+236
-14
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
abilitiesForClass,
|
||||
abilityAtLevel,
|
||||
isAbilityUnlocked,
|
||||
type AbilityDefinition,
|
||||
} from "./abilityCatalog";
|
||||
import type { MobCombatState } from "./combatStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import type { MobVector3 } from "./mobPopulation";
|
||||
@@ -20,6 +26,11 @@ import {
|
||||
type WotlkAttackKind,
|
||||
} from "./wotlkStats";
|
||||
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
||||
import {
|
||||
emitCombatPresentation,
|
||||
type CombatPresentationDelivery,
|
||||
type CombatPresentationSchool,
|
||||
} from "./combatPresentation";
|
||||
|
||||
const OUT_OF_COMBAT_REGEN_FRACTION = 0.06;
|
||||
const ATTACK_ACQUISITION_RANGE = 30;
|
||||
@@ -30,6 +41,43 @@ const TANK_TAUNT_DURATION_MS = 3_000;
|
||||
let lastAdvanceAt = 0;
|
||||
let nextRegenAt = 0;
|
||||
|
||||
export interface PartyPresentationIdentity {
|
||||
readonly spellId: number;
|
||||
readonly name: string;
|
||||
readonly school: CombatPresentationSchool;
|
||||
readonly delivery: CombatPresentationDelivery;
|
||||
}
|
||||
|
||||
function partyAttackPresentation(member: PartyMember): PartyPresentationIdentity {
|
||||
if (!partyUsesRangedAttacks(member)) {
|
||||
const spellId = member.classId === "rogue" ? 1752
|
||||
: member.classId === "death-knight" ? 45462
|
||||
: member.classId === "paladin" ? 20271
|
||||
: 78;
|
||||
return { spellId, name: "Weapon Attack", school: "physical", delivery: "melee" };
|
||||
}
|
||||
if (member.classId === "hunter") return { spellId: 56641, name: "Steady Shot", school: "physical", delivery: "projectile" };
|
||||
if (member.classId === "mage") return { spellId: 116, name: "Frostbolt", school: "frost", delivery: "projectile" };
|
||||
if (member.classId === "warlock") return { spellId: 686, name: "Shadow Bolt", school: "shadow", delivery: "projectile" };
|
||||
if (member.classId === "priest") return { spellId: 585, name: "Smite", school: "holy", delivery: "projectile" };
|
||||
if (member.classId === "druid") return { spellId: 5176, name: "Wrath", school: "nature", delivery: "projectile" };
|
||||
return { spellId: 403, name: "Lightning Bolt", school: "nature", delivery: "projectile" };
|
||||
}
|
||||
|
||||
function partyHealPresentation(member: PartyMember): PartyPresentationIdentity {
|
||||
if (member.classId === "paladin") return { spellId: 635, name: "Holy Light", school: "holy", delivery: "projectile" };
|
||||
if (member.classId === "shaman") return { spellId: 331, name: "Healing Wave", school: "nature", delivery: "projectile" };
|
||||
if (member.classId === "druid") return { spellId: 5185, name: "Healing Touch", school: "nature", delivery: "projectile" };
|
||||
return { spellId: 2061, name: "Flash Heal", school: "holy", delivery: "projectile" };
|
||||
}
|
||||
|
||||
function partyTauntSpellId(member: PartyMember): number {
|
||||
if (member.classId === "paladin") return 62124;
|
||||
if (member.classId === "death-knight") return 56222;
|
||||
if (member.classId === "druid") return 6795;
|
||||
return 355;
|
||||
}
|
||||
|
||||
export interface PartyBossObjective {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
@@ -223,18 +271,59 @@ export function partyCommandTargetId(
|
||||
export interface HealerSupportResult {
|
||||
readonly members: readonly PartyMember[];
|
||||
readonly playerHealing: number;
|
||||
readonly playerResurrectionPercent: number;
|
||||
readonly targetId: string | null;
|
||||
readonly effectiveHealing: number;
|
||||
readonly sourceMemberId: string | null;
|
||||
readonly action: "heal" | "resurrection" | null;
|
||||
readonly presentation: PartyPresentationIdentity | null;
|
||||
}
|
||||
|
||||
function partyHealAmount(level: number, spellPower = 0): number {
|
||||
return Math.max(10, Math.round(12 + Math.max(1, level) * 2.6 + Math.max(0, spellPower) * 1.25));
|
||||
}
|
||||
|
||||
function partyResurrectionAbility(member: PartyMember): AbilityDefinition | null {
|
||||
const definition = abilitiesForClass(member.classId)
|
||||
.filter((ability) => !ability.passive && ability.target === "friendly")
|
||||
.filter((ability) => isAbilityUnlocked(ability, member.level))
|
||||
.find((ability) => ability.effects.some((effect) => effect.kind === "resurrection"));
|
||||
return definition ? abilityAtLevel(definition, member.level) : null;
|
||||
}
|
||||
|
||||
function partyResurrectionPresentation(
|
||||
member: PartyMember,
|
||||
ability: AbilityDefinition,
|
||||
): PartyPresentationIdentity {
|
||||
const nature = member.classId === "druid"
|
||||
|| member.classId === "shaman"
|
||||
|| member.classId === "rom-druid";
|
||||
return {
|
||||
spellId: ability.dbcSpellId,
|
||||
name: ability.name,
|
||||
school: nature ? "nature" : "holy",
|
||||
delivery: "projectile",
|
||||
};
|
||||
}
|
||||
|
||||
function noSupport(members: readonly PartyMember[]): HealerSupportResult {
|
||||
return {
|
||||
members,
|
||||
playerHealing: 0,
|
||||
playerResurrectionPercent: 0,
|
||||
targetId: null,
|
||||
effectiveHealing: 0,
|
||||
sourceMemberId: null,
|
||||
action: null,
|
||||
presentation: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives the dedicated NPC healer one legal, level-scaled heal at a time. The
|
||||
* runtime caller applies `playerHealing` to the combat store after this pure
|
||||
* party-state update.
|
||||
* Gives the party one legal support action at a time. Any living class with an
|
||||
* unlocked resurrection can raise a fallen target; ordinary healing remains
|
||||
* the dedicated healer's job. The runtime caller applies player-side changes
|
||||
* to the combat store after this pure party-state update.
|
||||
*/
|
||||
export function applyHealerSupport(
|
||||
members: readonly PartyMember[],
|
||||
@@ -242,10 +331,74 @@ export function applyHealerSupport(
|
||||
playerMaxHealth: number,
|
||||
now: number,
|
||||
): HealerSupportResult {
|
||||
const resurrector = members
|
||||
.map((member, index) => ({ member, index, ability: partyResurrectionAbility(member) }))
|
||||
.filter((candidate): candidate is typeof candidate & { ability: AbilityDefinition } => (
|
||||
candidate.member.health > 0
|
||||
&& candidate.member.controlledUntil <= now
|
||||
&& candidate.member.nextActionAt <= now
|
||||
&& candidate.ability !== null
|
||||
))
|
||||
.sort((left, right) => (
|
||||
Number(right.member.role === "healer") - Number(left.member.role === "healer")
|
||||
|| left.member.id.localeCompare(right.member.id)
|
||||
))[0];
|
||||
const fallen = [
|
||||
...(playerHealth <= 0 && playerMaxHealth > 0
|
||||
? [{ id: "__player__", index: -1, maxHealth: playerMaxHealth, rolePriority: -1 }]
|
||||
: []),
|
||||
...members
|
||||
.map((member, index) => ({
|
||||
id: member.id,
|
||||
index,
|
||||
maxHealth: member.maxHealth,
|
||||
rolePriority: member.role === "healer" ? 0 : member.role === "tank" ? 1 : 2,
|
||||
}))
|
||||
.filter((candidate) => members[candidate.index].health <= 0),
|
||||
].sort((left, right) => left.rolePriority - right.rolePriority || left.id.localeCompare(right.id));
|
||||
const fallenTarget = fallen[0];
|
||||
if (resurrector && fallenTarget) {
|
||||
const resurrection = resurrector.ability.effects.find((effect) => effect.kind === "resurrection");
|
||||
if (resurrection?.kind === "resurrection") {
|
||||
const restored = Math.max(1, Math.round(fallenTarget.maxHealth * resurrection.percentMaxHealth));
|
||||
const next = [...members];
|
||||
if (fallenTarget.index >= 0) {
|
||||
const target = next[fallenTarget.index];
|
||||
next[fallenTarget.index] = {
|
||||
...target,
|
||||
health: restored,
|
||||
status: "ready",
|
||||
controlledUntil: 0,
|
||||
controlMechanic: null,
|
||||
};
|
||||
}
|
||||
const caster = next[resurrector.index];
|
||||
next[resurrector.index] = {
|
||||
...caster,
|
||||
status: "healing",
|
||||
nextActionAt: now + Math.max(
|
||||
HEALER_ACTION_INTERVAL_MS,
|
||||
resurrector.ability.castTimeMs,
|
||||
resurrector.ability.gcdMs,
|
||||
),
|
||||
};
|
||||
return {
|
||||
members: next,
|
||||
playerHealing: 0,
|
||||
playerResurrectionPercent: fallenTarget.index < 0 ? resurrection.percentMaxHealth : 0,
|
||||
targetId: fallenTarget.id,
|
||||
effectiveHealing: restored,
|
||||
sourceMemberId: resurrector.member.id,
|
||||
action: "resurrection",
|
||||
presentation: partyResurrectionPresentation(resurrector.member, resurrector.ability),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const healerIndex = members.findIndex((member) => member.role === "healer" && member.health > 0);
|
||||
if (healerIndex < 0) return { members, playerHealing: 0, targetId: null, effectiveHealing: 0 };
|
||||
if (healerIndex < 0) return noSupport(members);
|
||||
const healer = members[healerIndex];
|
||||
if (now < healer.nextActionAt) return { members, playerHealing: 0, targetId: null, effectiveHealing: 0 };
|
||||
if (now < healer.nextActionAt) return noSupport(members);
|
||||
|
||||
const candidates = members
|
||||
.map((member, index) => ({
|
||||
@@ -266,11 +419,11 @@ export function applyHealerSupport(
|
||||
const target = candidates[0];
|
||||
if (!target) {
|
||||
if (healer.status !== "healing" && healer.nextActionAt === 0) {
|
||||
return { members, playerHealing: 0, targetId: null, effectiveHealing: 0 };
|
||||
return noSupport(members);
|
||||
}
|
||||
const next = [...members];
|
||||
next[healerIndex] = { ...healer, status: "ready", nextActionAt: 0 };
|
||||
return { members: next, playerHealing: 0, targetId: null, effectiveHealing: 0 };
|
||||
return noSupport(next);
|
||||
}
|
||||
|
||||
const requested = partyHealAmount(healer.level, healer.gearStats.spellPower);
|
||||
@@ -289,8 +442,12 @@ export function applyHealerSupport(
|
||||
return {
|
||||
members: next,
|
||||
playerHealing: target.index < 0 ? healed : 0,
|
||||
playerResurrectionPercent: 0,
|
||||
targetId: target.id,
|
||||
effectiveHealing: healed,
|
||||
sourceMemberId: healer.id,
|
||||
action: "heal",
|
||||
presentation: partyHealPresentation(healer),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -386,7 +543,9 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
const objectiveBossId = nextPartyBossObjective(combat.mobs)?.id ?? null;
|
||||
let memberTargetIds: Record<string, string> = {};
|
||||
|
||||
if (targetId && useCombatStore.getState().health > 0) {
|
||||
// A defeated player can watch the encounter continue while waiting for a
|
||||
// resurrection. Living companions remain active until the whole party falls.
|
||||
if (targetId && members.some((member) => member.health > 0)) {
|
||||
const nextMembers = [...members];
|
||||
for (let index = 0; index < nextMembers.length; index += 1) {
|
||||
let member = nextMembers[index];
|
||||
@@ -455,6 +614,22 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
const tauntSource: ThreatSource = { actorId: member.id, role: "tank" };
|
||||
if (currentCombat.tauntMob(memberTargetId, tauntSource, TANK_TAUNT_DURATION_MS, now)) {
|
||||
runtimeMember = { ...runtimeMember, tauntReadyAt: now + TANK_TAUNT_COOLDOWN_MS };
|
||||
const tauntPresentation = {
|
||||
occurredAt: now,
|
||||
actorGroup: "party",
|
||||
sourceActorId: member.id,
|
||||
targetActorId: memberTargetId,
|
||||
abilityId: `party-taunt:${member.id}`,
|
||||
sourceSpellId: partyTauntSpellId(member),
|
||||
name: "Taunt",
|
||||
source: "wow335",
|
||||
school: "physical",
|
||||
delivery: "aura",
|
||||
origin: memberPosition,
|
||||
targetPosition,
|
||||
} as const;
|
||||
emitCombatPresentation({ ...tauntPresentation, phase: "release" });
|
||||
emitCombatPresentation({ ...tauntPresentation, phase: "aura-start" });
|
||||
}
|
||||
}
|
||||
if (now < member.nextActionAt) {
|
||||
@@ -483,6 +658,23 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
now,
|
||||
{ actorId: member.id, role: member.role, abilityId: `party:${member.id}` },
|
||||
);
|
||||
const presentation = partyAttackPresentation(member);
|
||||
const attackPresentation = {
|
||||
occurredAt: now,
|
||||
actorGroup: "party",
|
||||
sourceActorId: member.id,
|
||||
targetActorId: memberTargetId,
|
||||
abilityId: `party:${member.id}`,
|
||||
sourceSpellId: presentation.spellId,
|
||||
name: presentation.name,
|
||||
source: "wow335",
|
||||
school: presentation.school,
|
||||
delivery: presentation.delivery,
|
||||
origin: memberPosition,
|
||||
targetPosition: targetPosition!,
|
||||
} as const;
|
||||
emitCombatPresentation({ ...attackPresentation, phase: "release" });
|
||||
emitCombatPresentation({ ...attackPresentation, phase: "impact" });
|
||||
nextMembers[index] = {
|
||||
...runtimeMember,
|
||||
status: desiredStatus,
|
||||
@@ -521,16 +713,46 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
|
||||
const currentPlayer = useCombatStore.getState();
|
||||
const support = applyHealerSupport(members, currentPlayer.health, currentPlayer.maxHealth, now);
|
||||
const healer = members.find((member) => member.role === "healer" && member.health > 0) ?? null;
|
||||
const supporter = support.sourceMemberId
|
||||
? members.find((member) => member.id === support.sourceMemberId && member.health > 0) ?? null
|
||||
: null;
|
||||
members = support.members;
|
||||
if (support.playerHealing > 0) {
|
||||
useCombatStore.getState().healPlayer(support.playerHealing);
|
||||
let effectiveSupport = support.effectiveHealing;
|
||||
if (support.playerResurrectionPercent > 0) {
|
||||
effectiveSupport = useCombatStore.getState().revivePlayer(support.playerResurrectionPercent);
|
||||
}
|
||||
if (healer && support.effectiveHealing > 0) {
|
||||
if (support.playerHealing > 0) {
|
||||
effectiveSupport = useCombatStore.getState().healPlayer(support.playerHealing);
|
||||
}
|
||||
if (supporter && support.presentation && effectiveSupport > 0) {
|
||||
useCombatStore.getState().addHealingThreat(
|
||||
{ actorId: healer.id, role: healer.role },
|
||||
support.effectiveHealing,
|
||||
{ actorId: supporter.id, role: supporter.role },
|
||||
effectiveSupport,
|
||||
);
|
||||
const presentation = support.presentation;
|
||||
const supporterPosition = getPartyRuntimePosition(supporter.id) ?? currentPlayer.playerPosition;
|
||||
const presentationTargetId = support.targetId === "__player__" ? PLAYER_AGGRO_ID : support.targetId;
|
||||
const targetPosition = presentationTargetId === PLAYER_AGGRO_ID
|
||||
? currentPlayer.playerPosition
|
||||
: presentationTargetId
|
||||
? getPartyRuntimePosition(presentationTargetId)
|
||||
: null;
|
||||
const healPresentation = {
|
||||
occurredAt: now,
|
||||
actorGroup: "party",
|
||||
sourceActorId: supporter.id,
|
||||
...(presentationTargetId ? { targetActorId: presentationTargetId } : {}),
|
||||
abilityId: `party-${support.action}:${supporter.id}`,
|
||||
sourceSpellId: presentation.spellId,
|
||||
name: presentation.name,
|
||||
source: "wow335",
|
||||
school: presentation.school,
|
||||
delivery: presentation.delivery,
|
||||
origin: supporterPosition,
|
||||
targetPosition: targetPosition ?? supporterPosition,
|
||||
} as const;
|
||||
emitCombatPresentation({ ...healPresentation, phase: "release" });
|
||||
emitCombatPresentation({ ...healPresentation, phase: "impact" });
|
||||
}
|
||||
|
||||
const targetMapChanged = (
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import RAPIER from "@dimforge/rapier3d-compat";
|
||||
import { NodeIO } from "@gltf-transform/core";
|
||||
import { EXTMeshoptCompression, KHRMeshQuantization } from "@gltf-transform/extensions";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
PLAYER_GROUND_MIN_NORMAL_Y,
|
||||
PLAYER_GROUND_PROBE_DISTANCE,
|
||||
} from "./playerJump";
|
||||
import {
|
||||
PLAYER_CHARACTER_CONTROLLER_OFFSET,
|
||||
PLAYER_GROUND_SNAP_DISTANCE,
|
||||
PLAYER_GROUND_STICK_VELOCITY,
|
||||
PLAYER_STEP_HEIGHT,
|
||||
PLAYER_STEP_MIN_WIDTH,
|
||||
airbornePlayerVerticalVelocity,
|
||||
configurePlayerCharacterController,
|
||||
playerFrameDeltaSeconds,
|
||||
} from "./playerMovement";
|
||||
|
||||
const PLAYER_RADIUS = 0.36;
|
||||
const PLAYER_CAPSULE_HALF_HEIGHT = 0.48;
|
||||
const PLAYER_CENTER_HEIGHT = PLAYER_RADIUS + PLAYER_CAPSULE_HALF_HEIGHT;
|
||||
const MOVE_SPEED = 4.8;
|
||||
const FRAME_SECONDS = 1 / 60;
|
||||
const STOCKADES_SPAWN = {
|
||||
x: -126.4629,
|
||||
y: -29.1512,
|
||||
z: 21.2946,
|
||||
forwardX: -0.2756962614591509,
|
||||
forwardZ: 0.9612448030639477,
|
||||
};
|
||||
|
||||
let stockadesCollision: { vertices: Float32Array; indices: Uint32Array };
|
||||
|
||||
beforeAll(async () => {
|
||||
await RAPIER.init();
|
||||
const io = new NodeIO()
|
||||
.registerExtensions([EXTMeshoptCompression, KHRMeshQuantization])
|
||||
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
|
||||
const document = await io.read("public/assets/game/manastorm/34-stormwindjail/collision.glb");
|
||||
const node = document.getRoot().listNodes().find((candidate) => candidate.getMesh());
|
||||
const primitive = node?.getMesh()?.listPrimitives()[0];
|
||||
const positions = primitive?.getAttribute("POSITION");
|
||||
const sourceVertices = positions?.getArray();
|
||||
const sourceIndices = primitive?.getIndices()?.getArray();
|
||||
if (!node || !positions || !sourceVertices || !sourceIndices) {
|
||||
throw new Error("Stormwind Stockade collision GLB is missing its trimesh primitive.");
|
||||
}
|
||||
|
||||
const translation = node.getWorldTranslation();
|
||||
const scale = node.getWorldScale();
|
||||
const normalization = positions.getNormalized() ? 32_767 : 1;
|
||||
const vertices = new Float32Array(sourceVertices.length);
|
||||
for (let offset = 0; offset < sourceVertices.length; offset += 3) {
|
||||
vertices[offset] = sourceVertices[offset] / normalization * scale[0] + translation[0];
|
||||
vertices[offset + 1] = sourceVertices[offset + 1] / normalization * scale[1] + translation[1];
|
||||
vertices[offset + 2] = sourceVertices[offset + 2] / normalization * scale[2] + translation[2];
|
||||
}
|
||||
stockadesCollision = {
|
||||
vertices,
|
||||
indices: Uint32Array.from(sourceIndices),
|
||||
};
|
||||
});
|
||||
|
||||
function walkIntoStep(stepHeight: number): { x: number; y: number; z: number } {
|
||||
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
||||
const floor = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
|
||||
world.createCollider(
|
||||
RAPIER.ColliderDesc.cuboid(5, 0.1, 8).setTranslation(0, -0.1, 3),
|
||||
floor,
|
||||
);
|
||||
const step = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
|
||||
world.createCollider(
|
||||
RAPIER.ColliderDesc.cuboid(2, stepHeight / 2, 5)
|
||||
.setTranslation(0, stepHeight / 2, 4.75),
|
||||
step,
|
||||
);
|
||||
const body = world.createRigidBody(
|
||||
RAPIER.RigidBodyDesc.kinematicPositionBased()
|
||||
.setTranslation(0, PLAYER_CENTER_HEIGHT, -0.8),
|
||||
);
|
||||
const collider = world.createCollider(
|
||||
RAPIER.ColliderDesc.capsule(PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS),
|
||||
body,
|
||||
);
|
||||
world.step();
|
||||
|
||||
const controller = configurePlayerCharacterController(
|
||||
world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET),
|
||||
);
|
||||
for (let frame = 0; frame < 50; frame += 1) {
|
||||
controller.computeColliderMovement(
|
||||
collider,
|
||||
{
|
||||
x: 0,
|
||||
y: PLAYER_GROUND_STICK_VELOCITY * FRAME_SECONDS,
|
||||
z: MOVE_SPEED * FRAME_SECONDS,
|
||||
},
|
||||
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||
);
|
||||
const translation = body.translation();
|
||||
const movement = controller.computedMovement();
|
||||
body.setNextKinematicTranslation({
|
||||
x: translation.x + movement.x,
|
||||
y: translation.y + movement.y,
|
||||
z: translation.z + movement.z,
|
||||
});
|
||||
world.step();
|
||||
}
|
||||
|
||||
const translation = body.translation();
|
||||
const result = { x: translation.x, y: translation.y, z: translation.z };
|
||||
world.removeCharacterController(controller);
|
||||
world.free();
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("player movement", () => {
|
||||
it("configures bounded autostep and ground snapping", () => {
|
||||
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
||||
const controller = configurePlayerCharacterController(
|
||||
world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET),
|
||||
);
|
||||
|
||||
expect(controller.autostepMaxHeight()).toBeCloseTo(PLAYER_STEP_HEIGHT);
|
||||
expect(controller.autostepMinWidth()).toBeCloseTo(PLAYER_STEP_MIN_WIDTH);
|
||||
expect(controller.autostepIncludesDynamicBodies()).toBe(false);
|
||||
expect(controller.snapToGroundDistance()).toBeCloseTo(PLAYER_GROUND_SNAP_DISTANCE);
|
||||
expect(controller.slideEnabled()).toBe(true);
|
||||
|
||||
world.removeCharacterController(controller);
|
||||
world.free();
|
||||
});
|
||||
|
||||
it("walks up an ordinary half-unit stair without jumping", () => {
|
||||
const translation = walkIntoStep(0.5);
|
||||
expect(translation.z).toBeGreaterThan(2.5);
|
||||
expect(translation.y).toBeGreaterThan(PLAYER_CENTER_HEIGHT + 0.45);
|
||||
});
|
||||
|
||||
it("does not autostep over a wall taller than the traversal limit", () => {
|
||||
const translation = walkIntoStep(0.8);
|
||||
expect(translation.z).toBeLessThan(-0.5);
|
||||
expect(translation.y).toBeLessThan(PLAYER_CENTER_HEIGHT + 0.1);
|
||||
});
|
||||
|
||||
it("walks back up the real Stockades entrance stairs without a jump impulse", () => {
|
||||
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
||||
const dungeonBody = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
|
||||
world.createCollider(
|
||||
RAPIER.ColliderDesc.trimesh(
|
||||
stockadesCollision.vertices,
|
||||
stockadesCollision.indices,
|
||||
),
|
||||
dungeonBody,
|
||||
);
|
||||
const body = world.createRigidBody(
|
||||
RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(
|
||||
STOCKADES_SPAWN.x,
|
||||
STOCKADES_SPAWN.y + PLAYER_CENTER_HEIGHT,
|
||||
STOCKADES_SPAWN.z,
|
||||
),
|
||||
);
|
||||
const collider = world.createCollider(
|
||||
RAPIER.ColliderDesc.capsule(PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS),
|
||||
body,
|
||||
);
|
||||
world.step();
|
||||
const controller = configurePlayerCharacterController(
|
||||
world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET),
|
||||
);
|
||||
const groundRay = new RAPIER.Ray(
|
||||
{ x: 0, y: 0, z: 0 },
|
||||
{ x: 0, y: -1, z: 0 },
|
||||
);
|
||||
const groundProbeLength = PLAYER_CENTER_HEIGHT + PLAYER_GROUND_PROBE_DISTANCE;
|
||||
let verticalVelocity = 0;
|
||||
|
||||
const walkFrame = (direction: 1 | -1) => {
|
||||
const translation = body.translation();
|
||||
groundRay.origin.x = translation.x;
|
||||
groundRay.origin.y = translation.y;
|
||||
groundRay.origin.z = translation.z;
|
||||
const groundHit = world.castRayAndGetNormal(
|
||||
groundRay,
|
||||
groundProbeLength,
|
||||
true,
|
||||
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||
undefined,
|
||||
undefined,
|
||||
body,
|
||||
);
|
||||
const groundedBeforeMove = verticalVelocity <= 0.5
|
||||
&& Boolean(
|
||||
groundHit
|
||||
&& groundHit.timeOfImpact <= groundProbeLength
|
||||
&& groundHit.normal.y >= PLAYER_GROUND_MIN_NORMAL_Y,
|
||||
);
|
||||
verticalVelocity = groundedBeforeMove
|
||||
? PLAYER_GROUND_STICK_VELOCITY
|
||||
: airbornePlayerVerticalVelocity(verticalVelocity, FRAME_SECONDS);
|
||||
controller.computeColliderMovement(
|
||||
collider,
|
||||
{
|
||||
x: direction * STOCKADES_SPAWN.forwardX * MOVE_SPEED * FRAME_SECONDS,
|
||||
y: verticalVelocity * FRAME_SECONDS,
|
||||
z: direction * STOCKADES_SPAWN.forwardZ * MOVE_SPEED * FRAME_SECONDS,
|
||||
},
|
||||
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||
);
|
||||
const movement = controller.computedMovement();
|
||||
body.setNextKinematicTranslation({
|
||||
x: translation.x + movement.x,
|
||||
y: translation.y + movement.y,
|
||||
z: translation.z + movement.z,
|
||||
});
|
||||
if (controller.computedGrounded() && verticalVelocity <= 0) verticalVelocity = 0;
|
||||
world.step();
|
||||
};
|
||||
|
||||
for (let frame = 0; frame < 190; frame += 1) walkFrame(1);
|
||||
const lowerFootHeight = body.translation().y - PLAYER_CENTER_HEIGHT;
|
||||
for (let frame = 0; frame < 240; frame += 1) walkFrame(-1);
|
||||
const returned = body.translation();
|
||||
const returnedFootHeight = returned.y - PLAYER_CENTER_HEIGHT;
|
||||
|
||||
expect(lowerFootHeight).toBeLessThan(-33);
|
||||
expect(returnedFootHeight).toBeGreaterThan(-30);
|
||||
expect(Math.hypot(
|
||||
returned.x - STOCKADES_SPAWN.x,
|
||||
returned.z - STOCKADES_SPAWN.z,
|
||||
)).toBeLessThan(3);
|
||||
|
||||
world.removeCharacterController(controller);
|
||||
world.free();
|
||||
});
|
||||
|
||||
it("clamps frame hitches and applies bounded airborne gravity", () => {
|
||||
expect(playerFrameDeltaSeconds(Number.NaN)).toBe(0);
|
||||
expect(playerFrameDeltaSeconds(1)).toBe(0.05);
|
||||
expect(airbornePlayerVerticalVelocity(0, 1)).toBeCloseTo(-0.9);
|
||||
expect(airbornePlayerVerticalVelocity(-100, FRAME_SECONDS)).toBe(-32);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { KinematicCharacterController } from "@dimforge/rapier3d-compat";
|
||||
import { PLAYER_GROUND_MIN_NORMAL_Y } from "./playerJump";
|
||||
|
||||
export const PLAYER_GRAVITY = -18;
|
||||
export const PLAYER_CHARACTER_CONTROLLER_OFFSET = 0.02;
|
||||
export const PLAYER_STEP_HEIGHT = 0.55;
|
||||
export const PLAYER_STEP_MIN_WIDTH = 0.15;
|
||||
export const PLAYER_GROUND_SNAP_DISTANCE = PLAYER_STEP_HEIGHT;
|
||||
export const PLAYER_GROUND_STICK_VELOCITY = -0.5;
|
||||
export const PLAYER_MAX_FRAME_DELTA_SECONDS = 0.05;
|
||||
export const PLAYER_TERMINAL_FALL_VELOCITY = -32;
|
||||
|
||||
/**
|
||||
* Configures Rapier's kinematic controller for MMO-style traversal. Ordinary
|
||||
* stairs and curb-sized seams are walkable, while taller obstacles remain
|
||||
* blocking geometry and still require a real jump or another route.
|
||||
*/
|
||||
export function configurePlayerCharacterController(
|
||||
controller: KinematicCharacterController,
|
||||
): KinematicCharacterController {
|
||||
controller.setUp({ x: 0, y: 1, z: 0 });
|
||||
controller.setSlideEnabled(true);
|
||||
controller.enableAutostep(PLAYER_STEP_HEIGHT, PLAYER_STEP_MIN_WIDTH, false);
|
||||
controller.enableSnapToGround(PLAYER_GROUND_SNAP_DISTANCE);
|
||||
controller.setMaxSlopeClimbAngle(Math.acos(PLAYER_GROUND_MIN_NORMAL_Y));
|
||||
return controller;
|
||||
}
|
||||
|
||||
export function playerFrameDeltaSeconds(deltaSeconds: number): number {
|
||||
if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0;
|
||||
return Math.min(deltaSeconds, PLAYER_MAX_FRAME_DELTA_SECONDS);
|
||||
}
|
||||
|
||||
export function airbornePlayerVerticalVelocity(
|
||||
currentVelocity: number,
|
||||
deltaSeconds: number,
|
||||
): number {
|
||||
const velocity = Number.isFinite(currentVelocity) ? currentVelocity : 0;
|
||||
const delta = playerFrameDeltaSeconds(deltaSeconds);
|
||||
return Math.max(
|
||||
PLAYER_TERMINAL_FALL_VELOCITY,
|
||||
velocity + PLAYER_GRAVITY * delta,
|
||||
);
|
||||
}
|
||||
@@ -147,7 +147,7 @@ describe("RuneWaker dungeon creature animation coverage", () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
}, 120_000);
|
||||
|
||||
it("ships every configured directly-convertible actor as native except documented pose/proxy cases", async () => {
|
||||
const recipes = await Promise.all(
|
||||
|
||||
+6
-2
@@ -7,7 +7,7 @@ import type { ActionBindingLayer } from "./actionBindings";
|
||||
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
|
||||
export type InputMode = "keyboard" | "gamepad";
|
||||
export type GameMode = "dungeon" | "manastorm";
|
||||
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "spellbook" | "talents" | "bindings" | "options" | null;
|
||||
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "spellbook" | "talents" | "bindings" | "options" | "gm" | null;
|
||||
|
||||
interface PlayerSnapshot {
|
||||
position: Vector3Tuple;
|
||||
@@ -20,6 +20,7 @@ export interface GameState {
|
||||
activeDungeonId: DungeonId;
|
||||
activeDifficultyId: string;
|
||||
gameMode: GameMode;
|
||||
gmTestMode: boolean;
|
||||
activeSpawn: WorldSpawn;
|
||||
sessionRevision: number;
|
||||
overlay: GameOverlay;
|
||||
@@ -42,7 +43,7 @@ export interface GameState {
|
||||
activateDungeon: (
|
||||
dungeonId: DungeonId,
|
||||
difficultyId?: string,
|
||||
options?: { readonly gameMode?: GameMode; readonly spawn?: WorldSpawn },
|
||||
options?: { readonly gameMode?: GameMode; readonly spawn?: WorldSpawn; readonly gmTestMode?: boolean },
|
||||
) => void;
|
||||
openOverlay: (overlay: Exclude<GameOverlay, null>) => void;
|
||||
toggleOverlay: (overlay: Exclude<GameOverlay, null>) => void;
|
||||
@@ -68,6 +69,7 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
activeDungeonId: DEFAULT_DUNGEON_ID,
|
||||
activeDifficultyId: initialDungeon.defaultDifficultyId,
|
||||
gameMode: "dungeon",
|
||||
gmTestMode: false,
|
||||
activeSpawn: { footPosition: entrance.footPosition, yaw: entrance.yaw },
|
||||
sessionRevision: 0,
|
||||
overlay: null,
|
||||
@@ -100,6 +102,7 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
activeDungeonId,
|
||||
activeDifficultyId,
|
||||
gameMode: options?.gameMode ?? "dungeon",
|
||||
gmTestMode: options?.gmTestMode ?? false,
|
||||
activeSpawn,
|
||||
sessionRevision: state.sessionRevision + 1,
|
||||
overlay: null,
|
||||
@@ -218,6 +221,7 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
const activeSpawn = { footPosition: activeEntrance.footPosition, yaw: activeEntrance.yaw };
|
||||
return {
|
||||
gameMode: "dungeon",
|
||||
gmTestMode: false,
|
||||
activeSpawn,
|
||||
overlay: null,
|
||||
paused: false,
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import {
|
||||
AdditiveBlending,
|
||||
Color,
|
||||
DoubleSide,
|
||||
Group,
|
||||
MeshBasicMaterial,
|
||||
PointsMaterial,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import {
|
||||
combatEffectStyle,
|
||||
preloadCombatEffectManifest,
|
||||
subscribeCombatPresentation,
|
||||
type CombatPresentationEvent,
|
||||
type CombatVfxQuality,
|
||||
} from "../game/combatPresentation";
|
||||
import {
|
||||
configureCombatAudio,
|
||||
playCombatAudio,
|
||||
resetCombatAudio,
|
||||
setCombatAudioListener,
|
||||
stopCombatAudioGroup,
|
||||
} from "../game/combatAudio";
|
||||
|
||||
interface ActiveCombatEffect {
|
||||
readonly key: string;
|
||||
readonly event: CombatPresentationEvent;
|
||||
}
|
||||
|
||||
export interface CombatEffectsProps {
|
||||
readonly active: boolean;
|
||||
readonly safeGraphics?: boolean;
|
||||
}
|
||||
|
||||
const QUALITY_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, number>> = {
|
||||
off: 0,
|
||||
low: 24,
|
||||
high: 64,
|
||||
};
|
||||
|
||||
const QUALITY_PARTICLE_COUNT: Readonly<Record<Exclude<CombatVfxQuality, "off">, number>> = {
|
||||
low: 48,
|
||||
high: 128,
|
||||
};
|
||||
|
||||
function effectDurationMs(event: CombatPresentationEvent): number {
|
||||
if (event.phase === "cast-start") return Math.max(280, Math.min(4_500, event.durationMs ?? 900));
|
||||
if (event.phase === "tick") return 320;
|
||||
if (event.phase === "impact") return 420;
|
||||
if (event.delivery === "melee") return 280;
|
||||
if (event.delivery === "area") return 850;
|
||||
if (event.delivery === "aura") return 950;
|
||||
if (event.delivery === "channel") return 620;
|
||||
if (event.delivery === "projectile" && event.targetPosition) {
|
||||
const dx = event.targetPosition[0] - event.origin[0];
|
||||
const dy = event.targetPosition[1] - event.origin[1];
|
||||
const dz = event.targetPosition[2] - event.origin[2];
|
||||
return Math.max(180, Math.min(550, Math.hypot(dx, dy, dz) / 28 * 1_000));
|
||||
}
|
||||
return 460;
|
||||
}
|
||||
|
||||
function deterministicParticles(seed: number, count: number): Float32Array {
|
||||
const result = new Float32Array(count * 3);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const angle = index * 2.399963229728653 + seed * 0.17;
|
||||
const normalized = (index + 0.5) / count;
|
||||
const radius = 0.12 + Math.sqrt(normalized) * 0.72;
|
||||
result[index * 3] = Math.cos(angle) * radius;
|
||||
result[index * 3 + 1] = (normalized - 0.45) * 1.4 + Math.sin(seed + index * 1.73) * 0.16;
|
||||
result[index * 3 + 2] = Math.sin(angle) * radius;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function CombatEffectPrimitive({
|
||||
event,
|
||||
quality,
|
||||
active,
|
||||
onDone,
|
||||
}: {
|
||||
readonly event: CombatPresentationEvent;
|
||||
readonly quality: Exclude<CombatVfxQuality, "off">;
|
||||
readonly active: boolean;
|
||||
readonly onDone: () => void;
|
||||
}) {
|
||||
const groupRef = useRef<Group>(null);
|
||||
const pointsMaterialRef = useRef<PointsMaterial>(null);
|
||||
const primaryMaterialRef = useRef<MeshBasicMaterial>(null);
|
||||
const secondaryMaterialRef = useRef<MeshBasicMaterial>(null);
|
||||
const elapsedRef = useRef(0);
|
||||
const doneRef = useRef(false);
|
||||
const style = useMemo(() => combatEffectStyle(event), [event]);
|
||||
const particleCount = QUALITY_PARTICLE_COUNT[quality];
|
||||
const positions = useMemo(
|
||||
() => deterministicParticles(event.id, particleCount),
|
||||
[event.id, particleCount],
|
||||
);
|
||||
const origin = useMemo(() => new Vector3(...event.origin), [event.origin]);
|
||||
const target = useMemo(
|
||||
() => new Vector3(...(event.targetPosition ?? event.origin)),
|
||||
[event.origin, event.targetPosition],
|
||||
);
|
||||
const durationMs = effectDurationMs(event);
|
||||
const projectile = event.phase === "release"
|
||||
&& event.delivery === "projectile"
|
||||
&& event.targetPosition !== undefined;
|
||||
const effectPosition = projectile
|
||||
? origin
|
||||
: event.phase === "cast-start"
|
||||
? origin
|
||||
: target;
|
||||
const isGround = event.delivery === "area";
|
||||
const initialY = effectPosition.y + (isGround ? 0.08 : event.delivery === "melee" ? 1 : 1.15);
|
||||
|
||||
useFrame((_state, delta) => {
|
||||
if (!active || doneRef.current || !groupRef.current) return;
|
||||
elapsedRef.current += Math.min(0.05, delta) * 1_000;
|
||||
const progress = Math.min(1, elapsedRef.current / durationMs);
|
||||
const eased = 1 - (1 - progress) ** 3;
|
||||
if (projectile) {
|
||||
groupRef.current.position.lerpVectors(origin, target, eased);
|
||||
groupRef.current.position.y += 1.05 + Math.sin(progress * Math.PI) * 0.28;
|
||||
groupRef.current.scale.setScalar(0.72 + Math.sin(progress * Math.PI) * 0.32);
|
||||
} else {
|
||||
groupRef.current.rotation.y += delta * (event.school === "physical" ? 4 : 1.8);
|
||||
const scale = event.phase === "cast-start"
|
||||
? 0.85 + Math.sin(progress * Math.PI * 3) * 0.08
|
||||
: 0.35 + eased * (event.delivery === "area" ? Math.max(1.2, event.radius ?? 2.4) : 1.75);
|
||||
groupRef.current.scale.setScalar(scale);
|
||||
if (!isGround && event.delivery !== "melee") groupRef.current.position.y = initialY + progress * 0.55;
|
||||
}
|
||||
const opacity = event.phase === "cast-start"
|
||||
? Math.min(1, (1 - progress) * 1.8)
|
||||
: Math.max(0, 1 - progress);
|
||||
if (pointsMaterialRef.current) opacity >= 0 && (pointsMaterialRef.current.opacity = opacity * 0.9);
|
||||
if (primaryMaterialRef.current) primaryMaterialRef.current.opacity = opacity * 0.72;
|
||||
if (secondaryMaterialRef.current) secondaryMaterialRef.current.opacity = opacity * 0.55;
|
||||
if (progress >= 1) {
|
||||
doneRef.current = true;
|
||||
onDone();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={[effectPosition.x, initialY, effectPosition.z]}
|
||||
rotation={isGround ? [-Math.PI / 2, 0, 0] : [0, 0, event.delivery === "melee" ? -0.55 : 0]}
|
||||
>
|
||||
<points>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
|
||||
</bufferGeometry>
|
||||
<pointsMaterial
|
||||
ref={pointsMaterialRef}
|
||||
color={style.primary}
|
||||
size={quality === "high" ? 0.15 : 0.19}
|
||||
sizeAttenuation
|
||||
transparent
|
||||
opacity={0.9}
|
||||
depthWrite={false}
|
||||
blending={AdditiveBlending}
|
||||
/>
|
||||
</points>
|
||||
{event.delivery === "melee" ? (
|
||||
<mesh rotation={[0, 0.2, Math.PI / 2]}>
|
||||
<torusGeometry args={[0.7, 0.075, 8, 32, Math.PI * 1.35]} />
|
||||
<meshBasicMaterial
|
||||
ref={primaryMaterialRef}
|
||||
color={style.primary}
|
||||
transparent
|
||||
opacity={0.75}
|
||||
depthWrite={false}
|
||||
side={DoubleSide}
|
||||
blending={AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
) : event.delivery === "area" ? (
|
||||
<>
|
||||
<mesh>
|
||||
<ringGeometry args={[0.72, 1, 48]} />
|
||||
<meshBasicMaterial
|
||||
ref={primaryMaterialRef}
|
||||
color={style.primary}
|
||||
transparent
|
||||
opacity={0.62}
|
||||
depthWrite={false}
|
||||
side={DoubleSide}
|
||||
blending={AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh scale={0.68}>
|
||||
<ringGeometry args={[0.82, 1, 32]} />
|
||||
<meshBasicMaterial
|
||||
ref={secondaryMaterialRef}
|
||||
color={style.secondary}
|
||||
transparent
|
||||
opacity={0.5}
|
||||
depthWrite={false}
|
||||
side={DoubleSide}
|
||||
blending={AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<mesh>
|
||||
<sphereGeometry args={[projectile ? 0.28 : 0.44, 12, 8]} />
|
||||
<meshBasicMaterial
|
||||
ref={primaryMaterialRef}
|
||||
color={style.primary}
|
||||
transparent
|
||||
opacity={0.7}
|
||||
depthWrite={false}
|
||||
blending={AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh scale={projectile ? 0.52 : 1.18}>
|
||||
<sphereGeometry args={[0.32, 10, 7]} />
|
||||
<meshBasicMaterial
|
||||
ref={secondaryMaterialRef}
|
||||
color={style.secondary}
|
||||
transparent
|
||||
opacity={0.45}
|
||||
wireframe={!projectile}
|
||||
depthWrite={false}
|
||||
blending={AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
</>
|
||||
)}
|
||||
{quality === "high" && event.school !== "physical" ? (
|
||||
<pointLight color={new Color(style.primary)} intensity={style.emissive * 1.5} distance={5} decay={2} />
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export function CombatEffects({ active, safeGraphics = false }: CombatEffectsProps) {
|
||||
const settings = useShellStore((state) => state.combatPresentationSettings);
|
||||
const masterVolume = useCombatStore((state) => state.settings.masterVolume);
|
||||
const [effects, setEffects] = useState<readonly ActiveCombatEffect[]>([]);
|
||||
const settingsRef = useRef(settings);
|
||||
const activeRef = useRef(active);
|
||||
const effectiveQuality: CombatVfxQuality = safeGraphics && settings.vfxQuality === "high"
|
||||
? "low"
|
||||
: settings.vfxQuality;
|
||||
const qualityRef = useRef(effectiveQuality);
|
||||
const camera = useThree((state) => state.camera);
|
||||
const forward = useMemo(() => new Vector3(), []);
|
||||
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings;
|
||||
qualityRef.current = safeGraphics && settings.vfxQuality === "high" ? "low" : settings.vfxQuality;
|
||||
configureCombatAudio(settings, masterVolume);
|
||||
if (
|
||||
qualityRef.current !== "off"
|
||||
|| Object.values(settings.audioEnabled).some(Boolean)
|
||||
) void preloadCombatEffectManifest();
|
||||
setEffects((current) => current.filter(({ event }) => (
|
||||
qualityRef.current !== "off" && settings.vfxEnabled[event.actorGroup]
|
||||
)));
|
||||
for (const group of ["player", "party", "enemy"] as const) {
|
||||
if (!settings.audioEnabled[group] || settings.audioVolume[group] <= 0) stopCombatAudioGroup(group);
|
||||
}
|
||||
}, [masterVolume, safeGraphics, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = active;
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => subscribeCombatPresentation((message) => {
|
||||
if (message.kind === "reset") {
|
||||
setEffects([]);
|
||||
resetCombatAudio();
|
||||
return;
|
||||
}
|
||||
const event = message.event;
|
||||
if (activeRef.current) playCombatAudio(event);
|
||||
setEffects((current) => {
|
||||
const withoutCast = event.phase === "release" || event.phase === "cast-cancel"
|
||||
? current.filter((effect) => !(
|
||||
effect.event.phase === "cast-start"
|
||||
&& effect.event.sourceActorId === event.sourceActorId
|
||||
&& effect.event.abilityId === event.abilityId
|
||||
))
|
||||
: current;
|
||||
const presentationSettings = settingsRef.current;
|
||||
const quality = qualityRef.current;
|
||||
if (
|
||||
event.phase === "cast-cancel"
|
||||
|| !activeRef.current
|
||||
|| quality === "off"
|
||||
|| !presentationSettings.vfxEnabled[event.actorGroup]
|
||||
) return withoutCast;
|
||||
const limit = QUALITY_SYSTEM_LIMIT[quality];
|
||||
return [...withoutCast, { key: `${event.sessionId}:${event.id}`, event }].slice(-limit);
|
||||
});
|
||||
}), []);
|
||||
|
||||
useEffect(() => () => resetCombatAudio(), []);
|
||||
|
||||
useFrame(() => {
|
||||
camera.getWorldDirection(forward);
|
||||
setCombatAudioListener(
|
||||
[camera.position.x, camera.position.y, camera.position.z],
|
||||
[forward.x, forward.y, forward.z],
|
||||
);
|
||||
});
|
||||
|
||||
if (effectiveQuality === "off") return null;
|
||||
return (
|
||||
<group name="combat-effects">
|
||||
{effects.map((effect) => (
|
||||
<CombatEffectPrimitive
|
||||
key={effect.key}
|
||||
event={effect.event}
|
||||
quality={effectiveQuality}
|
||||
active={active}
|
||||
onDone={() => setEffects((current) => current.filter((candidate) => candidate.key !== effect.key))}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
+11
-3
@@ -29,7 +29,7 @@ import {
|
||||
rendererCanvasKey,
|
||||
} from "./sceneLifecycle";
|
||||
import { manastormChaoticLinkRuntimeIds } from "../game/manastormChaoticLink";
|
||||
import { manastormStagePopulation } from "../game/manastormStagePopulation";
|
||||
import { manastormStagePopulation, manastormStageRoamingPacks } from "../game/manastormStagePopulation";
|
||||
import { resolveStagePresentation } from "../game/manastormStagePresentation";
|
||||
import {
|
||||
useWailingEncounterStore,
|
||||
@@ -37,6 +37,9 @@ import {
|
||||
} from "../game/wailingCavernsEncounter";
|
||||
import { WailingNaralexEvent } from "./WailingNaralexEvent";
|
||||
import { GameGltfLoaderLifecycle } from "./useGameGLTF";
|
||||
import { CombatEffects } from "./CombatEffects";
|
||||
import { PLAYER_GRAVITY } from "../game/playerMovement";
|
||||
import { GmPlacementMarkers } from "./GmPlacementMarkers";
|
||||
|
||||
export function GameScene() {
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
@@ -72,7 +75,10 @@ export function GameScene() {
|
||||
: { entities: dungeon.entities, staticSpawns: dungeon.staticSpawns },
|
||||
[dungeon, gameMode, manastormEncounter],
|
||||
);
|
||||
const roamingPacks = dungeon.roamingPacks.filter((pack) => spawnMatchesDifficulty(pack.spawnMask, difficultyMask));
|
||||
const roamingPacks = manastormStageRoamingPacks(
|
||||
dungeon.roamingPacks.filter((pack) => spawnMatchesDifficulty(pack.spawnMask, difficultyMask)),
|
||||
gameMode === "manastorm" ? manastormEncounter : null,
|
||||
);
|
||||
const difficultySpawns = stagePopulation.staticSpawns.filter(
|
||||
(spawn) => spawnMatchesDifficulty(spawn.spawnMask, difficultyMask),
|
||||
);
|
||||
@@ -222,7 +228,7 @@ export function GameScene() {
|
||||
/>
|
||||
<Physics
|
||||
key={encounterWorldKey(activeDungeonId, sessionRevision)}
|
||||
gravity={[0, -18, 0]}
|
||||
gravity={[0, PLAYER_GRAVITY, 0]}
|
||||
paused={simulationBlocked}
|
||||
timeStep={1 / 60}
|
||||
>
|
||||
@@ -240,6 +246,8 @@ export function GameScene() {
|
||||
/>
|
||||
<WailingNaralexEvent />
|
||||
<ManastormPortal />
|
||||
<GmPlacementMarkers />
|
||||
<CombatEffects active={!simulationBlocked} safeGraphics={safeGraphics} />
|
||||
</>
|
||||
)}
|
||||
</Physics>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useMemo, useRef } from "react";
|
||||
import type { Group } from "three";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import type { Vector3Tuple } from "../game/dungeonTypes";
|
||||
import {
|
||||
authoredStageConfigForBoss,
|
||||
derivePartySpawnSlots,
|
||||
isGameMasterSession,
|
||||
resolvedBossAdminCatalog,
|
||||
} from "../game/manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||
import { getMobRuntimePosition } from "../game/mobRuntimeRegistry";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
function WorldMarker(props: {
|
||||
readonly position: Vector3Tuple;
|
||||
readonly color: string;
|
||||
readonly scale?: number;
|
||||
readonly runtimeId?: string;
|
||||
}) {
|
||||
const group = useRef<Group>(null);
|
||||
useFrame(() => {
|
||||
if (!props.runtimeId || !group.current) return;
|
||||
const live = getMobRuntimePosition(props.runtimeId);
|
||||
if (live) group.current.position.set(live[0], live[1], live[2]);
|
||||
});
|
||||
return (
|
||||
<group ref={group} position={props.position} scale={props.scale ?? 1}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.04, 0]} renderOrder={20}>
|
||||
<ringGeometry args={[0.55, 0.76, 28]} />
|
||||
<meshBasicMaterial color={props.color} transparent opacity={0.9} depthTest={false} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.72, 0]} renderOrder={20}>
|
||||
<coneGeometry args={[0.16, 1.25, 10]} />
|
||||
<meshBasicMaterial color={props.color} transparent opacity={0.78} depthTest={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function fallbackMobPosition(dungeonId: string, runtimeId: string, bossPosition: Vector3Tuple): Vector3Tuple {
|
||||
const dungeon = requireDungeonDefinition(dungeonId);
|
||||
const staticSpawn = dungeon.staticSpawns.find((spawn) => spawn.id === runtimeId);
|
||||
if (staticSpawn) return staticSpawn.position;
|
||||
for (const pack of dungeon.roamingPacks) {
|
||||
const member = pack.members.find((candidate) => `${pack.id}:${candidate.id}` === runtimeId);
|
||||
const origin = pack.waypoints[0];
|
||||
if (member && origin) {
|
||||
return [origin[0] + member.formationOffset[0], origin[1] + member.formationOffset[1], origin[2] + member.formationOffset[2]];
|
||||
}
|
||||
}
|
||||
return bossPosition;
|
||||
}
|
||||
|
||||
export function GmPlacementMarkers() {
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const draft = useManastormAdminStore((state) => state.draft);
|
||||
const editingBossKey = useManastormAdminStore((state) => state.editingBossKey);
|
||||
const entry = useMemo(
|
||||
() => resolvedBossAdminCatalog(draft).find((candidate) => candidate.key === editingBossKey && candidate.dungeonId === activeDungeonId) ?? null,
|
||||
[activeDungeonId, draft, editingBossKey],
|
||||
);
|
||||
const stage = useMemo(() => entry ? authoredStageConfigForBoss(entry, draft) : null, [draft, entry]);
|
||||
const party = useMemo(() => stage ? derivePartySpawnSlots(stage.groupSpawn, 5) : [], [stage]);
|
||||
|
||||
if (overlay !== "gm" || !isGameMasterSession(session) || !entry || !stage) return null;
|
||||
return (
|
||||
<group name="gm-placement-markers">
|
||||
{party.map((slot, index) => (
|
||||
<WorldMarker key={`party-${index}`} position={slot.footPosition} color="#55e8ff" scale={index === 0 ? 1.15 : 0.8} />
|
||||
))}
|
||||
<WorldMarker position={stage.bossPosition} color="#ff485f" scale={1.4} />
|
||||
<WorldMarker position={stage.portalPosition} color="#c26bff" scale={1.2} />
|
||||
{stage.linkedMobRuntimeIds.map((runtimeId) => (
|
||||
<WorldMarker
|
||||
key={runtimeId}
|
||||
position={fallbackMobPosition(stage.dungeonId, runtimeId, stage.bossPosition)}
|
||||
color="#ffd255"
|
||||
runtimeId={runtimeId}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -819,6 +819,7 @@ function ProxyMob({
|
||||
}: ProxyMobProps) {
|
||||
const mob = useCombatStore((state) => state.mobs[instanceId]);
|
||||
const selected = useCombatStore((state) => state.selectedTargetId === instanceId);
|
||||
const showAggroRanges = useCombatStore((state) => state.settings.showMobAggroRanges);
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const characterLevel = useCombatStore((state) => state.level);
|
||||
const manastormLevel = useManastormStore((state) => state.level);
|
||||
@@ -872,6 +873,7 @@ function ProxyMob({
|
||||
damageMultiplier: baseDamageMultiplier * (manastormModifiers?.enemyStatMultiplier ?? 1),
|
||||
bonusLootChance: manastormModifiers?.bonusLootChance,
|
||||
moveSpeed: definition.combat?.moveSpeed,
|
||||
aggroRange: definition.combat?.aggroRange,
|
||||
leashRange: definition.combat?.leashRange,
|
||||
attacks: definition.combat?.attacks,
|
||||
xpReward: gameMode === "manastorm"
|
||||
@@ -952,6 +954,24 @@ function ProxyMob({
|
||||
)}
|
||||
</AnimatedMobBody>
|
||||
|
||||
{showAggroRanges && gameMode === "dungeon" && mob && !dead && !mob.engaged ? (
|
||||
<mesh
|
||||
name={`mob-aggro-range-${instanceId}`}
|
||||
position={[0, 0.045, 0]}
|
||||
rotation={[Math.PI / 2, 0, 0]}
|
||||
renderOrder={2}
|
||||
>
|
||||
<torusGeometry args={[mob.aggroRange, boss ? 0.075 : 0.05, 4, 56]} />
|
||||
<meshBasicMaterial
|
||||
color={boss ? "#e59a55" : "#c96b55"}
|
||||
transparent
|
||||
opacity={boss ? 0.62 : 0.5}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
) : null}
|
||||
|
||||
{boss && !dead ? (
|
||||
<>
|
||||
<mesh position={[0, 0.08, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
@@ -1126,9 +1146,10 @@ function RoamingPack({
|
||||
const cleanups = definition.members.map((member, index) => registerMobRuntime(
|
||||
`${definition.id}:${member.id}`,
|
||||
scratchPositions[index],
|
||||
Math.atan2(scratchForwards[index][0], scratchForwards[index][2]),
|
||||
));
|
||||
return () => cleanups.forEach((cleanup) => cleanup());
|
||||
}, [definition.id, definition.members, scratchPositions]);
|
||||
}, [definition.id, definition.members, scratchForwards, scratchPositions]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!nearby) return;
|
||||
@@ -1222,16 +1243,18 @@ function RoamingPack({
|
||||
chasePosition[1] = patrolPosition[1];
|
||||
chasePosition[2] = patrolPosition[2];
|
||||
}
|
||||
const yaw = Math.atan2(forward[0], forward[2]);
|
||||
updateMobRuntimePosition(
|
||||
instanceId,
|
||||
position[0],
|
||||
position[1],
|
||||
position[2],
|
||||
yaw,
|
||||
);
|
||||
const object = memberRefs.current[index];
|
||||
if (!object) continue;
|
||||
object.position.set(position[0], position[1], position[2]);
|
||||
object.rotation.y = Math.atan2(forward[0], forward[2]);
|
||||
object.rotation.y = yaw;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1278,7 +1301,7 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
|
||||
const entity = entities[spawn.entityId];
|
||||
const groupRef = useRef<Group>(null);
|
||||
const positionRef = useRef<MutableMobVector3>([...spawn.position]);
|
||||
useEffect(() => registerMobRuntime(spawn.id, spawn.position), [spawn.id, spawn.position]);
|
||||
useEffect(() => registerMobRuntime(spawn.id, spawn.position, spawn.yaw ?? 0), [spawn.id, spawn.position, spawn.yaw]);
|
||||
useFrame((_, delta) => {
|
||||
const mob = useCombatStore.getState().mobs[spawn.id];
|
||||
const position = positionRef.current;
|
||||
@@ -1314,7 +1337,6 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
|
||||
position[1] = spawn.position[1];
|
||||
position[2] = spawn.position[2];
|
||||
}
|
||||
updateMobRuntimePosition(spawn.id, position[0], position[1], position[2]);
|
||||
if (groupRef.current) {
|
||||
groupRef.current.position.set(position[0], position[1], position[2]);
|
||||
if (mob?.engaged) {
|
||||
@@ -1325,6 +1347,13 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
|
||||
);
|
||||
}
|
||||
}
|
||||
updateMobRuntimePosition(
|
||||
spawn.id,
|
||||
position[0],
|
||||
position[1],
|
||||
position[2],
|
||||
groupRef.current?.rotation.y ?? spawn.yaw ?? 0,
|
||||
);
|
||||
});
|
||||
if (!entity) return null;
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { equipmentItemsForOwner } from "../game/equipment";
|
||||
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
|
||||
import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dungeonAssets";
|
||||
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
||||
import { derivePartySpawnSlots } from "../game/manastormAdminConfig";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import type { DungeonBossObjective } from "../game/dungeonTypes";
|
||||
import { getMobPosition } from "../game/mobRuntimeRegistry";
|
||||
@@ -534,21 +535,13 @@ function PartyActors({
|
||||
|
||||
useEffect(() => {
|
||||
const game = useGameStore.getState();
|
||||
const spawnSlots = derivePartySpawnSlots(game.activeSpawn, members.length + 1);
|
||||
members.forEach((member, index) => {
|
||||
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
||||
if (game.gameMode === "manastorm" || stackAtEntrance) {
|
||||
position[0] = game.playerPosition[0];
|
||||
position[1] = game.playerPosition[1];
|
||||
position[2] = game.playerPosition[2];
|
||||
} else {
|
||||
setEntrancePosition(
|
||||
index,
|
||||
game.playerPosition,
|
||||
Math.sin(game.cameraYaw),
|
||||
Math.cos(game.cameraYaw),
|
||||
position,
|
||||
);
|
||||
}
|
||||
const slot = spawnSlots[index + 1] ?? spawnSlots[0];
|
||||
position[0] = slot.footPosition[0];
|
||||
position[1] = slot.footPosition[1];
|
||||
position[2] = slot.footPosition[2];
|
||||
const navigationSpawn = navigationGraphRef.current
|
||||
? findNearestPartyNavigationSurface(navigationGraphRef.current, position, 6)
|
||||
: null;
|
||||
|
||||
+74
-25
@@ -50,6 +50,13 @@ import {
|
||||
updatePlayerJumpTiming,
|
||||
type CharacterVerticalMotion,
|
||||
} from "../game/playerJump";
|
||||
import {
|
||||
PLAYER_CHARACTER_CONTROLLER_OFFSET,
|
||||
PLAYER_GROUND_STICK_VELOCITY,
|
||||
airbornePlayerVerticalVelocity,
|
||||
configurePlayerCharacterController,
|
||||
playerFrameDeltaSeconds,
|
||||
} from "../game/playerMovement";
|
||||
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
@@ -85,11 +92,13 @@ interface PlayerControllerProps extends PlayerRigRefs {
|
||||
|
||||
function placeAtActiveSpawn(body: RapierRigidBody, orbit: OrbitState): void {
|
||||
const spawn = useGameStore.getState().activeSpawn;
|
||||
body.setTranslation({
|
||||
const translation = {
|
||||
x: spawn.footPosition[0],
|
||||
y: spawn.footPosition[1] + PLAYER_CENTER_HEIGHT,
|
||||
z: spawn.footPosition[2],
|
||||
}, true);
|
||||
};
|
||||
body.setTranslation(translation, true);
|
||||
body.setNextKinematicTranslation(translation);
|
||||
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||
body.setAngvel({ x: 0, y: 0, z: 0 }, true);
|
||||
orbit.yaw = spawn.yaw;
|
||||
@@ -122,8 +131,19 @@ function PlayerController({
|
||||
() => new rapier.Ray({ x: 0, y: 0, z: 0 }, { x: 0, y: -1, z: 0 }),
|
||||
[rapier],
|
||||
);
|
||||
const characterController = useMemo(
|
||||
() => configurePlayerCharacterController(
|
||||
world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET),
|
||||
),
|
||||
[world],
|
||||
);
|
||||
const jumpTimingRef = useRef(createPlayerJumpTiming());
|
||||
const verticalPresentationTimingRef = useRef(createCharacterVerticalPresentationTiming());
|
||||
const verticalVelocityRef = useRef(0);
|
||||
|
||||
useEffect(() => () => {
|
||||
world.removeCharacterController(characterController);
|
||||
}, [characterController, world]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyRef.current) placeAtActiveSpawn(bodyRef.current, orbitRef.current);
|
||||
@@ -131,6 +151,7 @@ function PlayerController({
|
||||
clearJumpRequest();
|
||||
jumpTimingRef.current = createPlayerJumpTiming(performance.now());
|
||||
verticalPresentationTimingRef.current = createCharacterVerticalPresentationTiming();
|
||||
verticalVelocityRef.current = 0;
|
||||
verticalMotionRef.current = "grounded";
|
||||
}, [bodyRef, orbitRef, resetRevision, spawn, verticalMotionRef]);
|
||||
|
||||
@@ -160,7 +181,6 @@ function PlayerController({
|
||||
orbit.pitch = clampCameraPitch(orbit.pitch + snapshot.lookY * delta * 1.55);
|
||||
}
|
||||
|
||||
const velocity = body.linvel();
|
||||
const translation = body.translation();
|
||||
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) {
|
||||
placeAtActiveSpawn(body, orbit);
|
||||
@@ -181,20 +201,25 @@ function PlayerController({
|
||||
undefined,
|
||||
body,
|
||||
);
|
||||
let grounded = velocity.y <= 0.5 && isWalkableGroundHit(groundHit, groundProbeLength);
|
||||
const groundedBeforeMove = verticalVelocityRef.current <= 0.5
|
||||
&& isWalkableGroundHit(groundHit, groundProbeLength);
|
||||
const requestedAtMs = consumeJumpRequest();
|
||||
const gameplayBlocked = paused || mapOpen || companionOpen || controlledUntil > Date.now();
|
||||
const gameplayBlocked = paused
|
||||
|| mapOpen
|
||||
|| companionOpen
|
||||
|| useCombatStore.getState().health <= 0
|
||||
|| controlledUntil > Date.now();
|
||||
if (gameplayBlocked) {
|
||||
cancelBufferedPlayerJump(jumpTimingRef.current);
|
||||
movingRef.current = false;
|
||||
verticalMotionRef.current = updateCharacterVerticalPresentation(
|
||||
verticalPresentationTimingRef.current,
|
||||
grounded,
|
||||
velocity.y,
|
||||
groundedBeforeMove,
|
||||
verticalVelocityRef.current,
|
||||
nowMs,
|
||||
false,
|
||||
);
|
||||
body.setLinvel({ x: 0, y: velocity.y, z: 0 }, true);
|
||||
body.setNextKinematicTranslation(translation);
|
||||
return;
|
||||
}
|
||||
if (requestedAtMs !== null) bufferPlayerJump(jumpTimingRef.current, requestedAtMs);
|
||||
@@ -202,11 +227,42 @@ function PlayerController({
|
||||
const shouldJump = updatePlayerJumpTiming(
|
||||
jumpTimingRef.current,
|
||||
nowMs,
|
||||
grounded,
|
||||
velocity.y,
|
||||
groundedBeforeMove,
|
||||
verticalVelocityRef.current,
|
||||
);
|
||||
const verticalVelocity = shouldJump ? PLAYER_JUMP_VELOCITY : velocity.y;
|
||||
if (shouldJump) grounded = false;
|
||||
const movementDeltaSeconds = playerFrameDeltaSeconds(delta);
|
||||
let verticalVelocity = shouldJump
|
||||
? PLAYER_JUMP_VELOCITY
|
||||
: groundedBeforeMove
|
||||
? PLAYER_GROUND_STICK_VELOCITY
|
||||
: airbornePlayerVerticalVelocity(verticalVelocityRef.current, movementDeltaSeconds);
|
||||
|
||||
const [worldX, worldZ] = cameraRelativeMovement(
|
||||
snapshot.moveX,
|
||||
snapshot.moveForward,
|
||||
orbit.yaw,
|
||||
);
|
||||
if (body.numColliders() === 0) return;
|
||||
characterController.computeColliderMovement(
|
||||
body.collider(0),
|
||||
{
|
||||
x: worldX * MOVE_SPEED * movementDeltaSeconds,
|
||||
y: verticalVelocity * movementDeltaSeconds,
|
||||
z: worldZ * MOVE_SPEED * movementDeltaSeconds,
|
||||
},
|
||||
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||
);
|
||||
const computedMovement = characterController.computedMovement();
|
||||
const nextTranslation = {
|
||||
x: translation.x + computedMovement.x,
|
||||
y: translation.y + computedMovement.y,
|
||||
z: translation.z + computedMovement.z,
|
||||
};
|
||||
body.setNextKinematicTranslation(nextTranslation);
|
||||
|
||||
const grounded = !shouldJump && characterController.computedGrounded();
|
||||
if (grounded && verticalVelocity <= 0) verticalVelocity = 0;
|
||||
verticalVelocityRef.current = verticalVelocity;
|
||||
verticalMotionRef.current = updateCharacterVerticalPresentation(
|
||||
verticalPresentationTimingRef.current,
|
||||
grounded,
|
||||
@@ -215,13 +271,6 @@ function PlayerController({
|
||||
shouldJump,
|
||||
);
|
||||
|
||||
const [worldX, worldZ] = cameraRelativeMovement(
|
||||
snapshot.moveX,
|
||||
snapshot.moveForward,
|
||||
orbit.yaw,
|
||||
);
|
||||
body.setLinvel({ x: worldX * MOVE_SPEED, y: verticalVelocity, z: worldZ * MOVE_SPEED }, true);
|
||||
|
||||
const moving = Math.hypot(worldX, worldZ) > 0.05;
|
||||
movingRef.current = moving;
|
||||
if (avatarRef.current && moving) {
|
||||
@@ -235,11 +284,11 @@ function PlayerController({
|
||||
: null;
|
||||
const belowRecoveryPlane = activeManastormPackage
|
||||
? isBelowManastormRecoveryPlane(
|
||||
translation.y,
|
||||
nextTranslation.y,
|
||||
activeManastormPackage.anchors.map((anchor) => anchor.position[1]),
|
||||
dungeon.bounds,
|
||||
)
|
||||
: isBelowDungeonRecoveryPlane(translation.y, dungeon.bounds);
|
||||
: isBelowDungeonRecoveryPlane(nextTranslation.y, dungeon.bounds);
|
||||
if (belowRecoveryPlane) {
|
||||
useGameStore.getState().resetAtActiveSpawn();
|
||||
return;
|
||||
@@ -249,9 +298,9 @@ function PlayerController({
|
||||
if (reportAccumulator.current >= 0.1) {
|
||||
reportAccumulator.current = 0;
|
||||
const footPosition: [number, number, number] = [
|
||||
translation.x,
|
||||
translation.y - PLAYER_CENTER_HEIGHT,
|
||||
translation.z,
|
||||
nextTranslation.x,
|
||||
nextTranslation.y - PLAYER_CENTER_HEIGHT,
|
||||
nextTranslation.z,
|
||||
];
|
||||
const distanceDelta = planarDistance(lastReported.current, footPosition);
|
||||
useGameStore.getState().updatePlayerSnapshot({
|
||||
@@ -268,7 +317,7 @@ function PlayerController({
|
||||
<RigidBody
|
||||
ref={bodyRef}
|
||||
name="player-capsule"
|
||||
type="dynamic"
|
||||
type="kinematicPosition"
|
||||
colliders={false}
|
||||
canSleep={false}
|
||||
enabledRotations={[false, false, false]}
|
||||
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user