Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eca6b5db9b | ||
|
|
b12fb84859 | ||
|
|
55cfd43d66 |
@@ -0,0 +1,10 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.{bat,cmd}]
|
||||||
|
end_of_line = crlf
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"files.eol": "\n",
|
||||||
|
"files.insertFinalNewline": true,
|
||||||
|
"files.trimFinalNewlines": true,
|
||||||
|
"files.trimTrailingWhitespace": true
|
||||||
|
}
|
||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
These rules apply to every change under this project root.
|
These rules apply to every change under this project root.
|
||||||
|
|
||||||
|
## Text-file hygiene
|
||||||
|
|
||||||
|
- Every text file must end with exactly one newline, with no extra blank line
|
||||||
|
at EOF and no trailing whitespace.
|
||||||
|
- Before handing off changes, run `npm run release:check`. This check includes
|
||||||
|
untracked files; `git diff --check` alone does not.
|
||||||
|
|
||||||
## Workspace and reference boundaries
|
## Workspace and reference boundaries
|
||||||
|
|
||||||
- The active project root is `D:\Projects\HealerMan`. All implementation,
|
- The active project root is `D:\Projects\HealerMan`. All implementation,
|
||||||
@@ -44,4 +51,3 @@ These rules apply to every change under this project root.
|
|||||||
- `document.pointerLockElement` remains `null`;
|
- `document.pointerLockElement` remains `null`;
|
||||||
- releasing right mouse stops camera motion;
|
- releasing right mouse stops camera motion;
|
||||||
- no browser mouse-capture prompt appears.
|
- no browser mouse-capture prompt appears.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -185,6 +185,7 @@ on the development PC. Stop if any test or build fails:
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Set-Location F:\Projects\HealerMan
|
Set-Location F:\Projects\HealerMan
|
||||||
|
npm run release:check
|
||||||
|
|
||||||
$VersionName = '0.1.3'
|
$VersionName = '0.1.3'
|
||||||
$VersionCode = 1003
|
$VersionCode = 1003
|
||||||
@@ -206,8 +207,8 @@ npm run build
|
|||||||
npm test -- --run src/avatar
|
npm test -- --run src/avatar
|
||||||
npx vitest run src/game/inputManager.test.ts src/game/inputMath.test.ts
|
npx vitest run src/game/inputManager.test.ts src/game/inputMath.test.ts
|
||||||
|
|
||||||
|
npm run release:check
|
||||||
git status --short
|
git status --short
|
||||||
git diff --check
|
|
||||||
git add -A
|
git add -A
|
||||||
git status --short
|
git status --short
|
||||||
git diff --cached --stat
|
git diff --cached --stat
|
||||||
|
|||||||
@@ -4,6 +4,41 @@ This file is the durable record of runtime behavior, regressions, and the
|
|||||||
evidence required before calling a visual bug fixed. Update it whenever scene,
|
evidence required before calling a visual bug fixed. Update it whenever scene,
|
||||||
camera, input, physics, mob-rendering, or asset-loading code changes.
|
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
|
## 2026-08-17 authentic combat VFX and audio - COMPLETE
|
||||||
|
|
||||||
- Player, party, and enemy combat actions now publish transient presentation
|
- Player, party, and enemy combat actions now publish transient presentation
|
||||||
@@ -1386,3 +1421,33 @@ Vite's existing large-chunk advisory remains informational.
|
|||||||
- Verification: 38 focused party/pathing/jump/animation tests passed, all 28
|
- Verification: 38 focused party/pathing/jump/animation tests passed, all 28
|
||||||
avatar tests passed, and `npm run build` completed successfully with 954
|
avatar tests passed, and `npm run build` completed successfully with 954
|
||||||
modules transformed. The existing large-chunk advisory remains informational.
|
modules transformed. The existing large-chunk advisory remains informational.
|
||||||
|
|
||||||
|
# 2026-08-20 — shared online dungeon session with AI fill
|
||||||
|
|
||||||
|
- Ran two signed-in production clients against the local account server on
|
||||||
|
separate browser origins. **SharedLeader / Bulwarka** invited
|
||||||
|
**SharedMember / Mendara**; the accepted group roster showed both selected
|
||||||
|
characters and their Tank and Healer roles as ready.
|
||||||
|
- The leader launched Wailing Caverns with **Fill open slots with AI** enabled.
|
||||||
|
Both clients automatically entered the same activity, each rendered the
|
||||||
|
other player through the normal equipped `CharacterModel` path, and each
|
||||||
|
showed a live, targetable remote party frame. Three AI companions filled the
|
||||||
|
remaining slots, producing the expected five-member party on both clients.
|
||||||
|
- The reciprocal scene captures showed Bulwarka and Mendara at the same shared
|
||||||
|
dungeon position with matching environment, objective, AI roster, mob state,
|
||||||
|
and remote role labels. The leader client continued encounter simulation;
|
||||||
|
the member client received the synchronized mob and AI state without running
|
||||||
|
a second competing world simulation.
|
||||||
|
- From the member client, selected Bulwarka's online party frame and completed
|
||||||
|
a Lesser Heal cast. The member's mana/resource change propagated to the
|
||||||
|
leader client and both clients retained the same live remote health state.
|
||||||
|
Enemy combat and AI resource/health changes also continued to propagate while
|
||||||
|
the two clients remained connected.
|
||||||
|
- Evidence reviewed during the browser run: invitation/group roster DOM,
|
||||||
|
coordinated-launch DOM from both clients, reciprocal full-window dungeon
|
||||||
|
captures, and before/cast-complete remote-healing DOM snapshots. No missing
|
||||||
|
character-body or equipment fallback appeared in either dungeon capture.
|
||||||
|
- Verification: server/API tests passed all 11 cases, the complete Vitest suite
|
||||||
|
passed all 755 cases across 132 files (including the 28 avatar cases), and the
|
||||||
|
TypeScript production build completed successfully with 983 modules
|
||||||
|
transformed. The existing large-chunk advisory remains informational.
|
||||||
|
|||||||
+20
-2
@@ -18,6 +18,21 @@ approval before it commits, pushes `main`, builds the signed APK, and publishes
|
|||||||
the Gitea release. Signing passwords and the Gitea token are passed to the
|
the Gitea release. Signing passwords and the Gitea token are passed to the
|
||||||
child process in memory rather than on its command line.
|
child process in memory rather than on its command line.
|
||||||
|
|
||||||
|
Before changing versions or running the longer test/build sequence, the manager
|
||||||
|
runs an isolated release-candidate check. It validates tracked and untracked
|
||||||
|
files without changing the real Git index, so extra blank lines at EOF and
|
||||||
|
other whitespace errors fail immediately. Run the same fast check at any time:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run release:check
|
||||||
|
```
|
||||||
|
|
||||||
|
Editors should honor the repository's `.editorconfig`. The checked-in VS Code
|
||||||
|
settings also trim extra final newlines whenever a file is saved.
|
||||||
|
Git may print a one-time CRLF-to-LF conversion warning when an existing Windows
|
||||||
|
working copy is first staged under `.gitattributes`; that warning is not a
|
||||||
|
release failure, and subsequent repository text stays consistently LF.
|
||||||
|
|
||||||
The local release key is `F:\secure\healer-man-release.jks`. When the companion
|
The local release key is `F:\secure\healer-man-release.jks`. When the companion
|
||||||
`healer-man-release-password.dpapi` file is present, blank signing-password
|
`healer-man-release-password.dpapi` file is present, blank signing-password
|
||||||
fields are filled from that Windows user-encrypted credential in memory. The
|
fields are filled from that Windows user-encrypted credential in memory. The
|
||||||
@@ -254,11 +269,14 @@ npm run dev
|
|||||||
First check for whitespace errors and review the final file list:
|
First check for whitespace errors and review the final file list:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git diff --check
|
npm run release:check
|
||||||
git status --short
|
git status --short
|
||||||
git diff --stat
|
git diff --stat
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use `npm run release:check` before the longer release validation. Unlike
|
||||||
|
`git diff --check`, it includes new, untracked files.
|
||||||
|
|
||||||
Stage the intended release:
|
Stage the intended release:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -520,7 +538,7 @@ Set-Location F:\Projects\HealerMan
|
|||||||
npm run content:test
|
npm run content:test
|
||||||
npm run build
|
npm run build
|
||||||
git status --short
|
git status --short
|
||||||
git diff --check
|
npm run release:check
|
||||||
git add -A
|
git add -A
|
||||||
git status --short
|
git status --short
|
||||||
git diff --cached --stat
|
git diff --cached --stat
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"clientBuild": "3.3.5a-Ascension",
|
"clientBuild": "3.3.5a-Ascension",
|
||||||
"mapDirectory": "World/Maps/StormwindJail",
|
"mapDirectory": "World/Maps/StormwindJail",
|
||||||
"environmentMode": "global-wmo",
|
"environmentMode": "global-wmo",
|
||||||
|
"globalWmoAlignment": "source",
|
||||||
"difficultyVariants": [
|
"difficultyVariants": [
|
||||||
{
|
{
|
||||||
"id": "normal",
|
"id": "normal",
|
||||||
|
|||||||
Generated
+3
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.4",
|
"version": "0.1.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.4",
|
"version": "0.1.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "8.4.1",
|
"@capacitor/cli": "8.4.1",
|
||||||
|
"@dimforge/rapier3d-compat": "0.19.2",
|
||||||
"@gltf-transform/cli": "^4.4.1",
|
"@gltf-transform/cli": "^4.4.1",
|
||||||
"@gltf-transform/core": "^4.4.2",
|
"@gltf-transform/core": "^4.4.2",
|
||||||
"@recast-navigation/core": "0.43.1",
|
"@recast-navigation/core": "0.43.1",
|
||||||
|
|||||||
+7
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.4",
|
"version": "0.1.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
@@ -20,7 +20,8 @@
|
|||||||
"coa:sync:live": "node scripts/sync-ascension-coa.mjs --tooltips",
|
"coa:sync:live": "node scripts/sync-ascension-coa.mjs --tooltips",
|
||||||
"coa:icons": "node scripts/export-spell-icons.mjs",
|
"coa:icons": "node scripts/export-spell-icons.mjs",
|
||||||
"ascension:talents": "node scripts/generate-wow335-talents.mjs ../wow335a/Interface/AddOns/PlayerBotManager/PBM/PBM_TalentData.lua ../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc src/game/wow335Talents.generated.ts ../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Talent.dbc",
|
"ascension:talents": "node scripts/generate-wow335-talents.mjs ../wow335a/Interface/AddOns/PlayerBotManager/PBM/PBM_TalentData.lua ../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc src/game/wow335Talents.generated.ts ../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Talent.dbc",
|
||||||
"ascension:sync": "npm run coa:sync && npm run wow335:abilities && npm run ascension:talents && npm run coa:icons",
|
"catalogs:shard": "vite-node scripts/generate-catalog-shards.ts",
|
||||||
|
"ascension:sync": "npm run coa:sync && npm run wow335:abilities && npm run ascension:talents && npm run coa:icons && npm run catalogs:shard",
|
||||||
"manastorm:import": "node scripts/manastorm-import/cli.mjs",
|
"manastorm:import": "node scripts/manastorm-import/cli.mjs",
|
||||||
"manastorm:forensics": "node scripts/manastorm-import/forensics-cli.mjs",
|
"manastorm:forensics": "node scripts/manastorm-import/forensics-cli.mjs",
|
||||||
"manastorm:sync": "npm run manastorm:import && npm run manastorm:forensics && npm run coa:icons",
|
"manastorm:sync": "npm run manastorm:import && npm run manastorm:forensics && npm run coa:icons",
|
||||||
@@ -91,13 +92,15 @@
|
|||||||
"assets:ktx2:creatures": "node scripts/asset-pipeline/compress-creature-ktx2.mjs",
|
"assets:ktx2:creatures": "node scripts/asset-pipeline/compress-creature-ktx2.mjs",
|
||||||
"assets:ktx2:audit": "node scripts/asset-pipeline/audit-ktx2.mjs --visual-only --require-all",
|
"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",
|
"assets:ktx2:refresh-metadata": "node scripts/asset-pipeline/refresh-ktx2-metadata.mjs",
|
||||||
"prebuild": "npm run loot:generate && npm run assets:ktx2:audit",
|
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
|
||||||
|
"prebuild": "npm run loot:generate && npm run assets:ktx2:audit && npm run assets:performance:audit",
|
||||||
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
|
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
|
||||||
"build:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android",
|
"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:sync": "npm run build:android:web && cap sync android",
|
||||||
"android:apk": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleDebug",
|
"android:apk": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleDebug",
|
||||||
"android:apk:release": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleRelease",
|
"android:apk:release": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleRelease",
|
||||||
"android:install": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 installDebug",
|
"android:install": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 installDebug",
|
||||||
|
"release:check": "python scripts/release_manager.py --check-release",
|
||||||
"release:gui": "python scripts/release_manager.py",
|
"release:gui": "python scripts/release_manager.py",
|
||||||
"preview": "vite preview --host 0.0.0.0",
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
"pretest": "npm run loot:generate",
|
"pretest": "npm run loot:generate",
|
||||||
@@ -118,6 +121,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "8.4.1",
|
"@capacitor/cli": "8.4.1",
|
||||||
|
"@dimforge/rapier3d-compat": "0.19.2",
|
||||||
"@gltf-transform/cli": "^4.4.1",
|
"@gltf-transform/cli": "^4.4.1",
|
||||||
"@gltf-transform/core": "^4.4.2",
|
"@gltf-transform/core": "^4.4.2",
|
||||||
"@recast-navigation/core": "0.43.1",
|
"@recast-navigation/core": "0.43.1",
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
{
|
{
|
||||||
"id": "visual",
|
"id": "visual",
|
||||||
"url": "/assets/game/manastorm/230-blackrockdepths/visual.glb",
|
"url": "/assets/game/manastorm/230-blackrockdepths/visual.glb",
|
||||||
"checksum": "42d57961c31f54ecc7bc78829751110fc89294e6318336b8f260e132e478d13d",
|
"checksum": "7e4c7c75a898f5000fcb686dcfc6d3c1c4e675dc3dac5f7a8d48e53372b9f364",
|
||||||
"byteLength": 10266900,
|
"byteLength": 7068884,
|
||||||
"triangleCount": 248302,
|
"triangleCount": 248302,
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
{
|
{
|
||||||
"id": "visual",
|
"id": "visual",
|
||||||
"url": "/assets/game/manastorm/230-blackrockdepths/visual.glb",
|
"url": "/assets/game/manastorm/230-blackrockdepths/visual.glb",
|
||||||
"checksum": "42d57961c31f54ecc7bc78829751110fc89294e6318336b8f260e132e478d13d",
|
"checksum": "7e4c7c75a898f5000fcb686dcfc6d3c1c4e675dc3dac5f7a8d48e53372b9f364",
|
||||||
"byteLength": 10266900,
|
"byteLength": 7068884,
|
||||||
"triangleCount": 248302,
|
"triangleCount": 248302,
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.4",
|
"versionName": "0.1.7",
|
||||||
"versionCode": 1004
|
"versionCode": 1007
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { access, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { analyzeGlbFile } from "./glb-performance.mjs";
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
const baselineFile = path.join(
|
||||||
|
projectRoot,
|
||||||
|
"scripts/asset-pipeline/environment-performance-baseline.json",
|
||||||
|
);
|
||||||
|
const defaultRoots = [
|
||||||
|
path.join(projectRoot, "public/assets/game/manastorm"),
|
||||||
|
path.join(projectRoot, "src/assets/game/dungeons"),
|
||||||
|
];
|
||||||
|
const defaultThresholds = {
|
||||||
|
maxEstimatedDrawCalls: 1_500,
|
||||||
|
maxUnbatchedDrawCallSavings: 250,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function exists(file) {
|
||||||
|
try {
|
||||||
|
await access(file);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectVisualGlbs(root, files = []) {
|
||||||
|
if (!await exists(root)) return files;
|
||||||
|
const entry = await stat(root);
|
||||||
|
if (entry.isFile()) {
|
||||||
|
if (/(?:^|[-_])visual\.glb$/i.test(path.basename(root))) files.push(root);
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
for (const child of await readdir(root, { withFileTypes: true })) {
|
||||||
|
const target = path.join(root, child.name);
|
||||||
|
if (child.isDirectory()) await collectVisualGlbs(target, files);
|
||||||
|
else if (/(?:^|[-_])visual\.glb$/i.test(child.name)) files.push(target);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectPath(file) {
|
||||||
|
return path.relative(projectRoot, file).split(path.sep).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function exceeds(report, limits) {
|
||||||
|
return report.estimatedDrawCalls > limits.maxEstimatedDrawCalls
|
||||||
|
|| report.unbatchedDrawCallSavings > limits.maxUnbatchedDrawCallSavings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const writeBaseline = args.includes("--write-baseline");
|
||||||
|
const roots = args.filter((argument) => argument !== "--write-baseline")
|
||||||
|
.map((argument) => path.resolve(projectRoot, argument));
|
||||||
|
const files = [...new Set((await Promise.all(
|
||||||
|
(roots.length ? roots : defaultRoots).map((root) => collectVisualGlbs(root)),
|
||||||
|
)).flat())].sort();
|
||||||
|
const reports = await Promise.all(files.map(async (file) => ({
|
||||||
|
file: projectPath(file),
|
||||||
|
byteLength: (await stat(file)).size,
|
||||||
|
...(await analyzeGlbFile(file)),
|
||||||
|
})));
|
||||||
|
|
||||||
|
if (writeBaseline) {
|
||||||
|
const baseline = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
thresholds: defaultThresholds,
|
||||||
|
exceptions: Object.fromEntries(
|
||||||
|
reports
|
||||||
|
.filter((report) => exceeds(report, defaultThresholds))
|
||||||
|
.map((report) => [report.file, {
|
||||||
|
maxEstimatedDrawCalls: Math.max(defaultThresholds.maxEstimatedDrawCalls, report.estimatedDrawCalls),
|
||||||
|
maxUnbatchedDrawCallSavings: Math.max(
|
||||||
|
defaultThresholds.maxUnbatchedDrawCallSavings,
|
||||||
|
report.unbatchedDrawCallSavings,
|
||||||
|
),
|
||||||
|
}]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
await mkdir(path.dirname(baselineFile), { recursive: true });
|
||||||
|
await writeFile(baselineFile, `${JSON.stringify(baseline, null, 2)}\n`, "utf8");
|
||||||
|
console.log(`Wrote ${Object.keys(baseline.exceptions).length} performance exceptions to ${projectPath(baselineFile)}.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await exists(baselineFile)) {
|
||||||
|
throw new Error(`Missing ${projectPath(baselineFile)}. Run this script with --write-baseline.`);
|
||||||
|
}
|
||||||
|
const baseline = JSON.parse(await readFile(baselineFile, "utf8"));
|
||||||
|
const thresholds = { ...defaultThresholds, ...baseline.thresholds };
|
||||||
|
const regressions = reports.filter((report) => {
|
||||||
|
const limits = { ...thresholds, ...baseline.exceptions?.[report.file] };
|
||||||
|
return exceeds(report, limits);
|
||||||
|
});
|
||||||
|
const debt = reports.filter((report) => exceeds(report, thresholds));
|
||||||
|
const top = [...reports]
|
||||||
|
.sort((left, right) => right.estimatedDrawCalls - left.estimatedDrawCalls)
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((report) => ({
|
||||||
|
asset: report.file,
|
||||||
|
calls: report.estimatedDrawCalls,
|
||||||
|
avoidable: report.unbatchedDrawCallSavings,
|
||||||
|
gpuBatches: report.gpuInstanceBatches,
|
||||||
|
nodes: report.nodes,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.table(top);
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
status: regressions.length ? "blocked" : "green",
|
||||||
|
assets: reports.length,
|
||||||
|
knownBudgetExceptions: debt.length,
|
||||||
|
regressions: regressions.map((report) => ({
|
||||||
|
file: report.file,
|
||||||
|
estimatedDrawCalls: report.estimatedDrawCalls,
|
||||||
|
unbatchedDrawCallSavings: report.unbatchedDrawCallSavings,
|
||||||
|
})),
|
||||||
|
}, null, 2));
|
||||||
|
if (regressions.length) process.exitCode = 1;
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"thresholds": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 250
|
||||||
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"public/assets/game/manastorm/109-sunkentemple/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 320
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/129-razorfendowns/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1507,
|
||||||
|
"maxUnbatchedDrawCallSavings": 1406
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/1771-custom-marsh/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 2493,
|
||||||
|
"maxUnbatchedDrawCallSavings": 2114
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/1774-custom-tropical/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 910
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/1775-custom-tuskarr/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 500
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/1776-custom-vrykul1/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 2223,
|
||||||
|
"maxUnbatchedDrawCallSavings": 1818
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/1781-forgottenmine/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 5756,
|
||||||
|
"maxUnbatchedDrawCallSavings": 3818
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/1785-valsharaharena/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 392
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/1787-baradinholdarena/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 10462,
|
||||||
|
"maxUnbatchedDrawCallSavings": 9362
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/1794-twistingnether/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 3077,
|
||||||
|
"maxUnbatchedDrawCallSavings": 2557
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/209-tanarisinstance/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 4579,
|
||||||
|
"maxUnbatchedDrawCallSavings": 3547
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/229-blackrockspire/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 3972,
|
||||||
|
"maxUnbatchedDrawCallSavings": 3598
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/269-cavernsoftime/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 5446,
|
||||||
|
"maxUnbatchedDrawCallSavings": 5289
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/289-schoolofnecromancy/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 5140,
|
||||||
|
"maxUnbatchedDrawCallSavings": 4790
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/309-zul-gurub/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 17377,
|
||||||
|
"maxUnbatchedDrawCallSavings": 15605
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/329-stratholme/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 2954,
|
||||||
|
"maxUnbatchedDrawCallSavings": 2725
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/34-stormwindjail/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 487
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/349-mauradon/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 5624,
|
||||||
|
"maxUnbatchedDrawCallSavings": 5352
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/36-deadminesinstance/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 309
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/389-orgrimmarinstance/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 881
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/429-diremaul/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 5021,
|
||||||
|
"maxUnbatchedDrawCallSavings": 4636
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/469-blackwinglair/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 452
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/47-razorfenkraulinstance/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 897
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/48-blackfathom/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 2164,
|
||||||
|
"maxUnbatchedDrawCallSavings": 1950
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/509-ahnqiraj/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 600
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/531-ahnqirajtemple/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 953
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/532-karazahn/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 8540,
|
||||||
|
"maxUnbatchedDrawCallSavings": 7892
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/533-stratholme-raid/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 4165,
|
||||||
|
"maxUnbatchedDrawCallSavings": 4014
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/540-hellfiremilitary/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 1150
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/542-hellfiredemon/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 444
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/543-hellfirerampart/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 296
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/545-coilfangpumping/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 674
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/546-coilfangmarsh/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 3104,
|
||||||
|
"maxUnbatchedDrawCallSavings": 3033
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/547-coilfangdraenei/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1535,
|
||||||
|
"maxUnbatchedDrawCallSavings": 1455
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/548-coilfangraid/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1916,
|
||||||
|
"maxUnbatchedDrawCallSavings": 1683
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/550-tempestkeepraid/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 420
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/552-tempestkeeparcane/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 999
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/553-tempestkeepatrium/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 1115
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/554-tempestkeepfactory/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 872
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/555-auchindounshadow/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 2624,
|
||||||
|
"maxUnbatchedDrawCallSavings": 2516
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/556-auchindoundemon/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 2095,
|
||||||
|
"maxUnbatchedDrawCallSavings": 1987
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/557-auchindounethereal/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 3164,
|
||||||
|
"maxUnbatchedDrawCallSavings": 3026
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/558-auchindoundraenei/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 2613,
|
||||||
|
"maxUnbatchedDrawCallSavings": 2523
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/560-hillsbradpast/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 28259,
|
||||||
|
"maxUnbatchedDrawCallSavings": 24733
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/568-zulaman/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 4307,
|
||||||
|
"maxUnbatchedDrawCallSavings": 3850
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/574-valgarde70/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 8271,
|
||||||
|
"maxUnbatchedDrawCallSavings": 7072
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/575-utgardepinnacle/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 16277,
|
||||||
|
"maxUnbatchedDrawCallSavings": 14018
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/576-nexus70/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 396
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/578-nexus80/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 2283,
|
||||||
|
"maxUnbatchedDrawCallSavings": 1896
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/585-sunwell5manfix/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1810,
|
||||||
|
"maxUnbatchedDrawCallSavings": 1618
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/595-stratholmecot/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 4353,
|
||||||
|
"maxUnbatchedDrawCallSavings": 3386
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/600-draktheronkeep/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 3518,
|
||||||
|
"maxUnbatchedDrawCallSavings": 2876
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/602-ulduar80/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1973,
|
||||||
|
"maxUnbatchedDrawCallSavings": 1295
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/619-azjol-lowercity/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1596,
|
||||||
|
"maxUnbatchedDrawCallSavings": 1243
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/658-quarryoftears/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 618
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/668-hallsofreflection/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 5464,
|
||||||
|
"maxUnbatchedDrawCallSavings": 4606
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/70-uldaman/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 3268,
|
||||||
|
"maxUnbatchedDrawCallSavings": 3115
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/821-tinketechshowdown/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 2585,
|
||||||
|
"maxUnbatchedDrawCallSavings": 2379
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/90-gnomeragoninstance/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 1051
|
||||||
|
},
|
||||||
|
"public/assets/game/manastorm/908-alvaencounter/visual.glb": {
|
||||||
|
"maxEstimatedDrawCalls": 1500,
|
||||||
|
"maxUnbatchedDrawCallSavings": 1096
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { open } from "node:fs/promises";
|
||||||
|
|
||||||
|
const GLB_MAGIC = 0x46546c67;
|
||||||
|
const JSON_CHUNK_TYPE = 0x4e4f534a;
|
||||||
|
const GPU_INSTANCING_EXTENSION = "EXT_mesh_gpu_instancing";
|
||||||
|
|
||||||
|
function primitiveCount(json, meshIndex) {
|
||||||
|
return json.meshes?.[meshIndex]?.primitives?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gpuInstanceCount(json, node) {
|
||||||
|
const attributes = node.extensions?.[GPU_INSTANCING_EXTENSION]?.attributes;
|
||||||
|
if (!attributes || typeof attributes !== "object") return 0;
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
...Object.values(attributes).map((accessorIndex) => (
|
||||||
|
json.accessors?.[accessorIndex]?.count ?? 0
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function analyzeGlbJson(json) {
|
||||||
|
const repeatedMeshes = new Map();
|
||||||
|
let estimatedDrawCalls = 0;
|
||||||
|
let expandedPrimitiveInstances = 0;
|
||||||
|
let meshNodes = 0;
|
||||||
|
let gpuInstanceBatches = 0;
|
||||||
|
let gpuInstances = 0;
|
||||||
|
|
||||||
|
for (const node of json.nodes ?? []) {
|
||||||
|
if (!Number.isInteger(node.mesh)) continue;
|
||||||
|
meshNodes += 1;
|
||||||
|
const primitives = primitiveCount(json, node.mesh);
|
||||||
|
const instances = gpuInstanceCount(json, node);
|
||||||
|
estimatedDrawCalls += primitives;
|
||||||
|
expandedPrimitiveInstances += primitives * Math.max(1, instances);
|
||||||
|
if (instances > 0) {
|
||||||
|
gpuInstanceBatches += 1;
|
||||||
|
gpuInstances += instances;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
repeatedMeshes.set(node.mesh, (repeatedMeshes.get(node.mesh) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let eligibleRepeatedMeshes = 0;
|
||||||
|
let eligibleRepeatedNodes = 0;
|
||||||
|
let unbatchedDrawCallSavings = 0;
|
||||||
|
for (const [meshIndex, count] of repeatedMeshes) {
|
||||||
|
if (count < 3) continue;
|
||||||
|
eligibleRepeatedMeshes += 1;
|
||||||
|
eligibleRepeatedNodes += count;
|
||||||
|
unbatchedDrawCallSavings += (count - 1) * primitiveCount(json, meshIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const materials = json.materials ?? [];
|
||||||
|
return {
|
||||||
|
nodes: json.nodes?.length ?? 0,
|
||||||
|
meshNodes,
|
||||||
|
meshes: json.meshes?.length ?? 0,
|
||||||
|
materials: materials.length,
|
||||||
|
textures: json.textures?.length ?? 0,
|
||||||
|
doubleSidedMaterials: materials.filter((material) => material.doubleSided === true).length,
|
||||||
|
blendedMaterials: materials.filter((material) => material.alphaMode === "BLEND").length,
|
||||||
|
estimatedDrawCalls,
|
||||||
|
expandedPrimitiveInstances,
|
||||||
|
gpuInstanceBatches,
|
||||||
|
gpuInstances,
|
||||||
|
eligibleRepeatedMeshes,
|
||||||
|
eligibleRepeatedNodes,
|
||||||
|
unbatchedDrawCallSavings,
|
||||||
|
usesGpuInstancing: (json.extensionsUsed ?? []).includes(GPU_INSTANCING_EXTENSION),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readGlbJson(file) {
|
||||||
|
const handle = await open(file, "r");
|
||||||
|
try {
|
||||||
|
const header = Buffer.alloc(20);
|
||||||
|
const { bytesRead } = await handle.read(header, 0, header.length, 0);
|
||||||
|
if (bytesRead !== header.length
|
||||||
|
|| header.readUInt32LE(0) !== GLB_MAGIC
|
||||||
|
|| header.readUInt32LE(4) !== 2
|
||||||
|
|| header.readUInt32LE(16) !== JSON_CHUNK_TYPE) {
|
||||||
|
throw new Error(`${file} is not a supported GLB 2.0 file.`);
|
||||||
|
}
|
||||||
|
const jsonLength = header.readUInt32LE(12);
|
||||||
|
const jsonBuffer = Buffer.alloc(jsonLength);
|
||||||
|
const jsonRead = await handle.read(jsonBuffer, 0, jsonLength, 20);
|
||||||
|
if (jsonRead.bytesRead !== jsonLength) throw new Error(`${file} has a truncated JSON chunk.`);
|
||||||
|
return JSON.parse(jsonBuffer.toString("utf8"));
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzeGlbFile(file) {
|
||||||
|
return analyzeGlbJson(await readGlbJson(file));
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { analyzeGlbJson } from "./glb-performance.mjs";
|
||||||
|
|
||||||
|
test("counts repeated mesh placements as avoidable draw calls", () => {
|
||||||
|
const report = analyzeGlbJson({
|
||||||
|
meshes: [
|
||||||
|
{ primitives: [{}, {}] },
|
||||||
|
{ primitives: [{}] },
|
||||||
|
],
|
||||||
|
nodes: [
|
||||||
|
{ mesh: 0 },
|
||||||
|
{ mesh: 0 },
|
||||||
|
{ mesh: 0 },
|
||||||
|
{ mesh: 1 },
|
||||||
|
],
|
||||||
|
materials: [
|
||||||
|
{ doubleSided: true, alphaMode: "BLEND" },
|
||||||
|
{},
|
||||||
|
],
|
||||||
|
textures: [{}, {}],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(report.estimatedDrawCalls, 7);
|
||||||
|
assert.equal(report.expandedPrimitiveInstances, 7);
|
||||||
|
assert.equal(report.eligibleRepeatedMeshes, 1);
|
||||||
|
assert.equal(report.eligibleRepeatedNodes, 3);
|
||||||
|
assert.equal(report.unbatchedDrawCallSavings, 4);
|
||||||
|
assert.equal(report.doubleSidedMaterials, 1);
|
||||||
|
assert.equal(report.blendedMaterials, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("counts one draw-call set for an EXT_mesh_gpu_instancing batch", () => {
|
||||||
|
const report = analyzeGlbJson({
|
||||||
|
extensionsUsed: ["EXT_mesh_gpu_instancing"],
|
||||||
|
accessors: [{ count: 12 }],
|
||||||
|
meshes: [{ primitives: [{}, {}, {}] }],
|
||||||
|
nodes: [{
|
||||||
|
mesh: 0,
|
||||||
|
extensions: {
|
||||||
|
EXT_mesh_gpu_instancing: {
|
||||||
|
attributes: { TRANSLATION: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(report.estimatedDrawCalls, 3);
|
||||||
|
assert.equal(report.expandedPrimitiveInstances, 36);
|
||||||
|
assert.equal(report.gpuInstanceBatches, 1);
|
||||||
|
assert.equal(report.gpuInstances, 12);
|
||||||
|
assert.equal(report.unbatchedDrawCallSavings, 0);
|
||||||
|
assert.equal(report.usesGpuInstancing, true);
|
||||||
|
});
|
||||||
@@ -201,6 +201,7 @@ const ADT_SOURCE_BASES = new Set([
|
|||||||
"z-y-negative-x",
|
"z-y-negative-x",
|
||||||
]);
|
]);
|
||||||
const GLOBAL_WMO_SOURCE_BASES = new Set(["draft", "flip-x"]);
|
const GLOBAL_WMO_SOURCE_BASES = new Set(["draft", "flip-x"]);
|
||||||
|
const GLOBAL_WMO_ALIGNMENTS = new Set(["player-anchor", "source"]);
|
||||||
|
|
||||||
function sourceBasisFor(recipe, environmentMode) {
|
function sourceBasisFor(recipe, environmentMode) {
|
||||||
if (environmentMode !== "adt-hybrid") {
|
if (environmentMode !== "adt-hybrid") {
|
||||||
@@ -220,6 +221,15 @@ function sourceBasisFor(recipe, environmentMode) {
|
|||||||
return basis;
|
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
|
* Runtime drafts retain the original pipeline convention. wow.export ADT
|
||||||
* packages have an additional horizontal basis change baked into their GLBs.
|
* packages have an additional horizontal basis change baked into their GLBs.
|
||||||
@@ -238,7 +248,10 @@ function sourceToPackageBasis(position, environmentMode, sourceBasis) {
|
|||||||
: point;
|
: 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
|
const sourceEntrance = draft?.entrance
|
||||||
?? draft?.spawns?.[0]?.position
|
?? draft?.spawns?.[0]?.position
|
||||||
?? playerAnchor.position;
|
?? playerAnchor.position;
|
||||||
@@ -764,6 +777,7 @@ async function compile() {
|
|||||||
}
|
}
|
||||||
const environmentMode = inferEnvironmentMode(recipe, assetPackage);
|
const environmentMode = inferEnvironmentMode(recipe, assetPackage);
|
||||||
const sourceBasis = sourceBasisFor(recipe, environmentMode);
|
const sourceBasis = sourceBasisFor(recipe, environmentMode);
|
||||||
|
const coordinateAlignment = coordinateAlignmentFor(recipe, environmentMode);
|
||||||
const playerAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "player");
|
const playerAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "player");
|
||||||
const trashAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "trash");
|
const trashAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "trash");
|
||||||
const bossAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "boss");
|
const bossAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "boss");
|
||||||
@@ -794,6 +808,12 @@ async function compile() {
|
|||||||
environmentMode,
|
environmentMode,
|
||||||
playerAnchor,
|
playerAnchor,
|
||||||
sourceBasis,
|
sourceBasis,
|
||||||
|
coordinateAlignment,
|
||||||
|
);
|
||||||
|
const entrancePosition = mapPosition(
|
||||||
|
draft?.entrance
|
||||||
|
?? draft?.spawns?.[0]?.position
|
||||||
|
?? playerAnchor.position,
|
||||||
);
|
);
|
||||||
const sourceEntities = (draft?.entities ?? [])
|
const sourceEntities = (draft?.entities ?? [])
|
||||||
.filter((entity) => (
|
.filter((entity) => (
|
||||||
@@ -966,7 +986,7 @@ async function compile() {
|
|||||||
});
|
});
|
||||||
for (let index = 0; index < 15; index += 1) {
|
for (let index = 0; index < 15; index += 1) {
|
||||||
const segment = index < 6
|
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);
|
: interpolate(trashAnchor.position, bossAnchor.position, 0.08 + (index - 6) * 0.085);
|
||||||
staticSpawns.push({
|
staticSpawns.push({
|
||||||
id: `generated-trash-${index + 1}`,
|
id: `generated-trash-${index + 1}`,
|
||||||
@@ -979,7 +999,7 @@ async function compile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allPoints = [
|
const allPoints = [
|
||||||
playerAnchor.position,
|
entrancePosition,
|
||||||
trashAnchor.position,
|
trashAnchor.position,
|
||||||
bossAnchor.position,
|
bossAnchor.position,
|
||||||
...staticSpawns.map((spawn) => spawn.position),
|
...staticSpawns.map((spawn) => spawn.position),
|
||||||
@@ -1010,7 +1030,9 @@ async function compile() {
|
|||||||
modelCoverageWarning,
|
modelCoverageWarning,
|
||||||
...(syntheticTrash
|
...(syntheticTrash
|
||||||
? ["Trash identities and placements are procedural; boss identities come from the installed Ascension encounter catalog."]
|
? ["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({
|
definitions.push({
|
||||||
@@ -1034,12 +1056,12 @@ async function compile() {
|
|||||||
bounds,
|
bounds,
|
||||||
entrance: {
|
entrance: {
|
||||||
name: `${recipe.title} Entrance`,
|
name: `${recipe.title} Entrance`,
|
||||||
footPosition: vector(playerAnchor.position),
|
footPosition: vector(entrancePosition),
|
||||||
forward: [Math.sin(entranceYaw), 0, Math.cos(entranceYaw)],
|
forward: [Math.sin(entranceYaw), 0, Math.cos(entranceYaw)],
|
||||||
yaw: entranceYaw,
|
yaw: entranceYaw,
|
||||||
},
|
},
|
||||||
areas: [
|
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) => ({
|
...bosses.map((boss) => ({
|
||||||
id: `area-${slugPart(boss.name)}`,
|
id: `area-${slugPart(boss.name)}`,
|
||||||
name: `${boss.name}'s Encounter`,
|
name: `${boss.name}'s Encounter`,
|
||||||
@@ -1068,6 +1090,10 @@ async function compile() {
|
|||||||
? sourceBasis === "z-y-negative-x"
|
? 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)"
|
||||||
: "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"
|
: sourceBasis === "flip-x"
|
||||||
? "three(x,y,z)=(-draft.x,draft.y,draft.z)+package-anchor-translation"
|
? "three(x,y,z)=(-draft.x,draft.y,draft.z)+package-anchor-translation"
|
||||||
: "three(x,y,z)=draft(x,y,z)+package-anchor-translation",
|
: "three(x,y,z)=draft(x,y,z)+package-anchor-translation",
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(import.meta.dirname, "..");
|
||||||
|
const clientRoot = path.resolve(projectRoot, "..", "LadiksMPQEditor", "client-current");
|
||||||
|
const spellPath = path.join(clientRoot, "area-52", "patch-D", "DBFilesClient", "Spell.dbc");
|
||||||
|
const supportRoot = path.join(clientRoot, "patch-S", "DBFilesClient");
|
||||||
|
const runtimePath = path.join(projectRoot, "src", "game", "generated", "coaAbilityRuntimeCatalog.json");
|
||||||
|
const outputPath = path.join(projectRoot, "src", "game", "generated", "coaTriggeredSpellCatalog.json");
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbcNumberTable(filePath, valueField, floating = false) {
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
assert(buffer.toString("ascii", 0, 4) === "WDBC", `${path.basename(filePath)} is not a WDBC file`);
|
||||||
|
const recordCount = buffer.readUInt32LE(4);
|
||||||
|
const recordSize = buffer.readUInt32LE(12);
|
||||||
|
return new Map(Array.from({ length: recordCount }, (_, index) => {
|
||||||
|
const base = 20 + index * recordSize;
|
||||||
|
return [
|
||||||
|
buffer.readUInt32LE(base),
|
||||||
|
floating ? buffer.readFloatLE(base + valueField * 4) : buffer.readInt32LE(base + valueField * 4),
|
||||||
|
];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbcRangeTable(filePath) {
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
assert(buffer.toString("ascii", 0, 4) === "WDBC", `${path.basename(filePath)} is not a WDBC file`);
|
||||||
|
const recordCount = buffer.readUInt32LE(4);
|
||||||
|
const recordSize = buffer.readUInt32LE(12);
|
||||||
|
return new Map(Array.from({ length: recordCount }, (_, index) => {
|
||||||
|
const base = 20 + index * recordSize;
|
||||||
|
return [buffer.readUInt32LE(base), {
|
||||||
|
min: Math.max(0, buffer.readFloatLE(base + 4), buffer.readFloatLE(base + 8)),
|
||||||
|
max: Math.max(0, buffer.readFloatLE(base + 12), buffer.readFloatLE(base + 16)),
|
||||||
|
}];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function referencedSpellIds(runtime) {
|
||||||
|
const ids = new Set();
|
||||||
|
const inspectEffects = (effects) => {
|
||||||
|
for (const effect of effects ?? []) {
|
||||||
|
if (effect.kind === "trigger-spell" && effect.spellId > 0) ids.add(effect.spellId);
|
||||||
|
if (effect.kind !== "apply-aura") continue;
|
||||||
|
for (const proc of effect.aura?.procs ?? []) {
|
||||||
|
const spellId = proc.action?.kind === "custom" ? proc.action.data?.spellId : 0;
|
||||||
|
if (typeof spellId === "number" && spellId > 0) ids.add(spellId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const abilities of Object.values(runtime.abilitiesByClass)) {
|
||||||
|
for (const ability of abilities) {
|
||||||
|
inspectEffects(ability.effects);
|
||||||
|
for (const rank of ability.ranks ?? []) inspectEffects(rank.effects);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanText(value) {
|
||||||
|
return value.replace(/\r/g, "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function spellParser(buffer, castTimes, durations, radii, ranges) {
|
||||||
|
assert(buffer.toString("ascii", 0, 4) === "WDBC", "Spell.dbc is not a WDBC file");
|
||||||
|
const recordCount = buffer.readUInt32LE(4);
|
||||||
|
const fieldCount = buffer.readUInt32LE(8);
|
||||||
|
const recordSize = buffer.readUInt32LE(12);
|
||||||
|
const stringSize = buffer.readUInt32LE(16);
|
||||||
|
assert(fieldCount >= 234 && recordSize >= fieldCount * 4, "Unexpected Spell.dbc schema");
|
||||||
|
const stringsOffset = 20 + recordCount * recordSize;
|
||||||
|
const offsets = new Map(Array.from({ length: recordCount }, (_, index) => {
|
||||||
|
const base = 20 + index * recordSize;
|
||||||
|
return [buffer.readUInt32LE(base), base];
|
||||||
|
}));
|
||||||
|
const readString = (offset) => {
|
||||||
|
if (!offset || offset >= stringSize) return "";
|
||||||
|
const start = stringsOffset + offset;
|
||||||
|
const end = buffer.indexOf(0, start);
|
||||||
|
return buffer.toString("utf8", start, end < 0 ? buffer.length : end);
|
||||||
|
};
|
||||||
|
return (id) => {
|
||||||
|
const base = offsets.get(id);
|
||||||
|
if (base === undefined) return null;
|
||||||
|
const unsigned = (field) => buffer.readUInt32LE(base + field * 4);
|
||||||
|
const signed = (field) => buffer.readInt32LE(base + field * 4);
|
||||||
|
const float = (field) => buffer.readFloatLE(base + field * 4);
|
||||||
|
const localized = (firstField) => {
|
||||||
|
for (let locale = 0; locale < 16; locale += 1) {
|
||||||
|
const value = readString(unsigned(firstField + locale));
|
||||||
|
if (value) return cleanText(value);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
const effects = Array.from({ length: 3 }, (_, effectIndex) => ({
|
||||||
|
index: effectIndex,
|
||||||
|
effectType: unsigned(71 + effectIndex),
|
||||||
|
auraType: unsigned(95 + effectIndex),
|
||||||
|
basePoints: signed(80 + effectIndex) + 1,
|
||||||
|
dieSides: signed(74 + effectIndex),
|
||||||
|
pointsPerLevel: float(77 + effectIndex),
|
||||||
|
mechanic: unsigned(83 + effectIndex),
|
||||||
|
implicitTargetA: unsigned(86 + effectIndex),
|
||||||
|
implicitTargetB: unsigned(89 + effectIndex),
|
||||||
|
radiusIndex: unsigned(92 + effectIndex),
|
||||||
|
radius: radii.get(unsigned(92 + effectIndex)) ?? 0,
|
||||||
|
periodMs: unsigned(98 + effectIndex),
|
||||||
|
valueMultiplier: float(101 + effectIndex),
|
||||||
|
chainTargets: unsigned(104 + effectIndex),
|
||||||
|
itemType: unsigned(107 + effectIndex),
|
||||||
|
miscValue: signed(110 + effectIndex),
|
||||||
|
miscValueB: signed(113 + effectIndex),
|
||||||
|
triggerSpellId: unsigned(116 + effectIndex),
|
||||||
|
pointsPerCombo: float(119 + effectIndex),
|
||||||
|
coefficient: float(229 + effectIndex),
|
||||||
|
})).filter((effect) => effect.effectType || effect.auraType || effect.basePoints !== 1 || effect.triggerSpellId);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: localized(136),
|
||||||
|
rankText: localized(153),
|
||||||
|
description: localized(170) || localized(187),
|
||||||
|
categoryId: unsigned(1),
|
||||||
|
dispelType: unsigned(2),
|
||||||
|
mechanic: unsigned(3),
|
||||||
|
attributes: Array.from({ length: 8 }, (_, index) => unsigned(4 + index)),
|
||||||
|
castTimeIndex: unsigned(28),
|
||||||
|
castTimeMs: Math.max(0, castTimes.get(unsigned(28)) ?? 0),
|
||||||
|
recoveryTimeMs: unsigned(29),
|
||||||
|
categoryRecoveryTimeMs: unsigned(30),
|
||||||
|
interruptFlags: unsigned(31),
|
||||||
|
auraInterruptFlags: unsigned(32),
|
||||||
|
channelInterruptFlags: unsigned(33),
|
||||||
|
procFlags: unsigned(34),
|
||||||
|
procChance: unsigned(35),
|
||||||
|
procCharges: unsigned(36),
|
||||||
|
maxLevel: unsigned(37),
|
||||||
|
baseLevel: unsigned(38),
|
||||||
|
spellLevel: unsigned(39),
|
||||||
|
durationIndex: unsigned(40),
|
||||||
|
durationMs: durations.get(unsigned(40)) ?? 0,
|
||||||
|
powerType: signed(41),
|
||||||
|
powerCost: unsigned(42),
|
||||||
|
powerCostPerLevel: unsigned(43),
|
||||||
|
powerCostPerSecond: unsigned(44),
|
||||||
|
powerCostPerSecondPerLevel: unsigned(45),
|
||||||
|
rangeIndex: unsigned(46),
|
||||||
|
rangeMin: ranges.get(unsigned(46))?.min ?? 0,
|
||||||
|
rangeMax: ranges.get(unsigned(46))?.max ?? 0,
|
||||||
|
projectileSpeed: float(47),
|
||||||
|
stackAmount: unsigned(49),
|
||||||
|
equippedItemClass: signed(67),
|
||||||
|
equippedItemSubclassMask: signed(68),
|
||||||
|
equippedItemInventoryMask: signed(69),
|
||||||
|
iconId: unsigned(133),
|
||||||
|
powerCostPercentage: unsigned(204),
|
||||||
|
startRecoveryCategory: unsigned(205),
|
||||||
|
startRecoveryTimeMs: unsigned(206),
|
||||||
|
maximumTargets: unsigned(212),
|
||||||
|
damageClass: unsigned(213),
|
||||||
|
preventionType: unsigned(214),
|
||||||
|
schoolMask: unsigned(225),
|
||||||
|
runeCostId: unsigned(226),
|
||||||
|
powerDisplayId: unsigned(228),
|
||||||
|
effects,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(fs.existsSync(spellPath), `CoA Spell.dbc was not found at ${spellPath}`);
|
||||||
|
const runtime = JSON.parse(fs.readFileSync(runtimePath, "utf8"));
|
||||||
|
const castTimes = dbcNumberTable(path.join(supportRoot, "SpellCastTimes.dbc"), 1);
|
||||||
|
const durations = dbcNumberTable(path.join(supportRoot, "SpellDuration.dbc"), 3);
|
||||||
|
const radii = dbcNumberTable(path.join(supportRoot, "SpellRadius.dbc"), 3, true);
|
||||||
|
const ranges = dbcRangeTable(path.join(supportRoot, "SpellRange.dbc"));
|
||||||
|
const parseSpell = spellParser(fs.readFileSync(spellPath), castTimes, durations, radii, ranges);
|
||||||
|
const pending = [...referencedSpellIds(runtime)];
|
||||||
|
const spells = new Map();
|
||||||
|
while (pending.length) {
|
||||||
|
const id = pending.shift();
|
||||||
|
if (spells.has(id)) continue;
|
||||||
|
const spell = parseSpell(id);
|
||||||
|
if (!spell) continue;
|
||||||
|
spells.set(id, spell);
|
||||||
|
for (const effect of spell.effects) {
|
||||||
|
if (effect.triggerSpellId > 0 && !spells.has(effect.triggerSpellId)) pending.push(effect.triggerSpellId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(outputPath, `${JSON.stringify({ schemaVersion: 1, spells: [...spells.values()] })}\n`, "utf8");
|
||||||
|
console.log(`Wrote ${outputPath} with ${spells.size} triggered spell records.`);
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
unlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { basename, resolve } from "node:path";
|
||||||
|
import {
|
||||||
|
CLASSES,
|
||||||
|
COA_CLASS_IDS,
|
||||||
|
type ClassId,
|
||||||
|
type CoaClassId,
|
||||||
|
} from "../src/app/characterCatalog";
|
||||||
|
import { TALENT_NODES, TALENT_TREES } from "../src/game/talentCatalog";
|
||||||
|
|
||||||
|
const projectRoot = resolve(import.meta.dirname, "..");
|
||||||
|
const talentOutputDirectory = resolve(projectRoot, "src/game/generated/talentUi");
|
||||||
|
const coaAbilityOutputDirectory = resolve(projectRoot, "src/game/generated/coaAbilities");
|
||||||
|
|
||||||
|
function writeJson(file: string, value: unknown): void {
|
||||||
|
writeFileSync(file, `${JSON.stringify(value)}\n`, "utf8");
|
||||||
|
console.log(`Wrote ${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStaleJson(directory: string, expectedNames: ReadonlySet<string>): void {
|
||||||
|
for (const name of readdirSync(directory)) {
|
||||||
|
if (!name.endsWith(".json") || expectedNames.has(name)) continue;
|
||||||
|
unlinkSync(resolve(directory, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(talentOutputDirectory, { recursive: true });
|
||||||
|
const talentClassIds = new Set<ClassId>(TALENT_TREES.map((tree) => tree.classId));
|
||||||
|
const talentFileNames = new Set([...talentClassIds].map((classId) => `${classId}.json`));
|
||||||
|
removeStaleJson(talentOutputDirectory, talentFileNames);
|
||||||
|
|
||||||
|
for (const classId of talentClassIds) {
|
||||||
|
const trees = TALENT_TREES.filter((tree) => tree.classId === classId).map((tree) => ({
|
||||||
|
id: tree.id,
|
||||||
|
classId: tree.classId,
|
||||||
|
name: tree.name,
|
||||||
|
icon: tree.icon,
|
||||||
|
background: tree.background,
|
||||||
|
description: tree.description,
|
||||||
|
system: tree.system,
|
||||||
|
}));
|
||||||
|
const treeIds = new Set(trees.map((tree) => tree.id));
|
||||||
|
const nodes = TALENT_NODES.filter((node) => treeIds.has(node.treeId)).map((node) => ({
|
||||||
|
id: node.id,
|
||||||
|
treeId: node.treeId,
|
||||||
|
index: node.index,
|
||||||
|
prerequisiteId: node.prerequisiteId,
|
||||||
|
prerequisiteRank: node.prerequisiteRank,
|
||||||
|
prerequisites: node.prerequisites,
|
||||||
|
column: node.column,
|
||||||
|
row: node.row,
|
||||||
|
icon: node.icon,
|
||||||
|
maxRank: node.maxRank,
|
||||||
|
name: node.name,
|
||||||
|
description: node.description,
|
||||||
|
rankDescriptions: node.rankDescriptions,
|
||||||
|
system: node.system,
|
||||||
|
entryType: node.entryType,
|
||||||
|
abilityEssenceCost: node.abilityEssenceCost,
|
||||||
|
talentEssenceCost: node.talentEssenceCost,
|
||||||
|
requiredClassPoints: node.requiredClassPoints,
|
||||||
|
requiredSpecPoints: node.requiredSpecPoints,
|
||||||
|
requiredLevel: node.requiredLevel,
|
||||||
|
isPassive: node.isPassive,
|
||||||
|
}));
|
||||||
|
writeJson(resolve(talentOutputDirectory, `${classId}.json`), {
|
||||||
|
schemaVersion: 1,
|
||||||
|
classId,
|
||||||
|
trees,
|
||||||
|
nodes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoaAbilitySnapshot {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly abilitiesByClass: Readonly<Record<CoaClassId, readonly unknown[]>>;
|
||||||
|
readonly resourcesByClass: Readonly<Record<CoaClassId, unknown>>;
|
||||||
|
readonly triggeredAbilitiesBySpellId: Readonly<Record<string, { readonly classId?: ClassId }>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coaAbilityInput = resolve(projectRoot, "src/game/generated/coaAbilityRuntimeCatalog.json");
|
||||||
|
const coaSnapshot = JSON.parse(readFileSync(coaAbilityInput, "utf8")) as CoaAbilitySnapshot;
|
||||||
|
mkdirSync(coaAbilityOutputDirectory, { recursive: true });
|
||||||
|
const coaAbilityFileNames = new Set(COA_CLASS_IDS.map((classId) => `${classId}.json`));
|
||||||
|
removeStaleJson(coaAbilityOutputDirectory, coaAbilityFileNames);
|
||||||
|
|
||||||
|
for (const classId of COA_CLASS_IDS) {
|
||||||
|
const triggeredAbilitiesBySpellId = Object.fromEntries(Object.entries(
|
||||||
|
coaSnapshot.triggeredAbilitiesBySpellId,
|
||||||
|
).filter(([, ability]) => ability.classId === classId));
|
||||||
|
writeJson(resolve(coaAbilityOutputDirectory, `${classId}.json`), {
|
||||||
|
schemaVersion: 1,
|
||||||
|
classId,
|
||||||
|
abilities: coaSnapshot.abilitiesByClass[classId] ?? [],
|
||||||
|
resource: coaSnapshot.resourcesByClass[classId],
|
||||||
|
triggeredAbilitiesBySpellId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Generated ${talentFileNames.size} talent UI shards and ${coaAbilityFileNames.size} Conquest ability shards from ${basename(coaAbilityInput)}.`);
|
||||||
@@ -4,6 +4,7 @@ import { CLASSES, type CoaClassId } from "../src/app/characterCatalog";
|
|||||||
import {
|
import {
|
||||||
COA_ABILITIES_BY_CLASS,
|
COA_ABILITIES_BY_CLASS,
|
||||||
COA_CLASS_RESOURCES,
|
COA_CLASS_RESOURCES,
|
||||||
|
COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||||
} from "../src/game/coaAbilityCatalog";
|
} from "../src/game/coaAbilityCatalog";
|
||||||
import {
|
import {
|
||||||
COA_LIVE_SOURCE,
|
COA_LIVE_SOURCE,
|
||||||
@@ -25,6 +26,7 @@ writeJson("src/game/generated/coaAbilityRuntimeCatalog.json", {
|
|||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
abilitiesByClass: COA_ABILITIES_BY_CLASS,
|
abilitiesByClass: COA_ABILITIES_BY_CLASS,
|
||||||
resourcesByClass: COA_CLASS_RESOURCES,
|
resourcesByClass: COA_CLASS_RESOURCES,
|
||||||
|
triggeredAbilitiesBySpellId: COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||||
});
|
});
|
||||||
|
|
||||||
const coaBudgetsByClass = Object.fromEntries(CLASSES
|
const coaBudgetsByClass = Object.fromEntries(CLASSES
|
||||||
|
|||||||
@@ -404,15 +404,23 @@ async function optimizeStage(slug) {
|
|||||||
const source = path.join(staging, entry.path);
|
const source = path.join(staging, entry.path);
|
||||||
const outputName = optimizedChunkName(role, index, entries.length);
|
const outputName = optimizedChunkName(role, index, entries.length);
|
||||||
const output = path.join(staging, outputName);
|
const output = path.join(staging, outputName);
|
||||||
await run(process.execPath, [
|
|
||||||
cli,
|
|
||||||
"meshopt",
|
|
||||||
source,
|
|
||||||
output,
|
|
||||||
"--level",
|
|
||||||
"high",
|
|
||||||
]);
|
|
||||||
if (role === "visual") {
|
if (role === "visual") {
|
||||||
|
await run(process.execPath, [
|
||||||
|
cli,
|
||||||
|
"optimize",
|
||||||
|
source,
|
||||||
|
output,
|
||||||
|
"--compress",
|
||||||
|
"meshopt",
|
||||||
|
"--meshopt-level",
|
||||||
|
"high",
|
||||||
|
"--instance",
|
||||||
|
"true",
|
||||||
|
"--instance-min",
|
||||||
|
"3",
|
||||||
|
"--texture-size",
|
||||||
|
"2048",
|
||||||
|
]);
|
||||||
await run(process.execPath, [
|
await run(process.execPath, [
|
||||||
path.join(projectRoot, "scripts/asset-pipeline/compress-ktx2.mjs"),
|
path.join(projectRoot, "scripts/asset-pipeline/compress-ktx2.mjs"),
|
||||||
output,
|
output,
|
||||||
@@ -421,6 +429,15 @@ async function optimizeStage(slug) {
|
|||||||
"--jobs",
|
"--jobs",
|
||||||
"2",
|
"2",
|
||||||
]);
|
]);
|
||||||
|
} else {
|
||||||
|
await run(process.execPath, [
|
||||||
|
cli,
|
||||||
|
"meshopt",
|
||||||
|
source,
|
||||||
|
output,
|
||||||
|
"--level",
|
||||||
|
"high",
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
const geometry = await inspectGlb(output);
|
const geometry = await inspectGlb(output);
|
||||||
const descriptor = {
|
const descriptor = {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -226,6 +227,67 @@ def current_commit() -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_release_candidates(project_root: Path = PROJECT_ROOT) -> list[str]:
|
||||||
|
"""Check tracked and untracked release files without changing the real index."""
|
||||||
|
with tempfile.TemporaryDirectory(prefix="healer-man-release-index-") as temp_dir:
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["GIT_INDEX_FILE"] = str(Path(temp_dir) / "index")
|
||||||
|
|
||||||
|
def run_temporary_index_command(*args: str) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=project_root,
|
||||||
|
env=environment,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
output = result.stdout.decode("utf-8", errors="replace").strip()
|
||||||
|
raise ReleaseError(
|
||||||
|
f"Release-candidate Git check failed ({result.returncode}): "
|
||||||
|
f"git {' '.join(args)}\n{output}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
run_temporary_index_command("read-tree", "HEAD")
|
||||||
|
run_temporary_index_command("add", "-A")
|
||||||
|
raw_paths = run_temporary_index_command(
|
||||||
|
"diff", "--cached", "--name-only", "-z"
|
||||||
|
).stdout
|
||||||
|
staged_paths = [
|
||||||
|
path.decode("utf-8", errors="replace")
|
||||||
|
for path in raw_paths.split(b"\0")
|
||||||
|
if path
|
||||||
|
]
|
||||||
|
|
||||||
|
whitespace_result = subprocess.run(
|
||||||
|
["git", "diff", "--cached", "--check"],
|
||||||
|
cwd=project_root,
|
||||||
|
env=environment,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if whitespace_result.returncode != 0:
|
||||||
|
detail = whitespace_result.stdout.strip()
|
||||||
|
raise ReleaseError(
|
||||||
|
"Release-candidate whitespace check failed. This check includes "
|
||||||
|
f"tracked and untracked files:\n{detail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
dangerous = find_dangerous_staged_paths(staged_paths)
|
||||||
|
if dangerous:
|
||||||
|
raise ReleaseError(
|
||||||
|
"Refusing to release forbidden output or a possible secret:\n"
|
||||||
|
+ "\n".join(f" - {path}" for path in dangerous)
|
||||||
|
)
|
||||||
|
return staged_paths
|
||||||
|
|
||||||
|
|
||||||
def windows_powershell_environment(powershell: str) -> dict[str, str]:
|
def windows_powershell_environment(powershell: str) -> dict[str, str]:
|
||||||
environment = os.environ.copy()
|
environment = os.environ.copy()
|
||||||
if Path(powershell).name.lower() == "powershell.exe":
|
if Path(powershell).name.lower() == "powershell.exe":
|
||||||
@@ -1003,6 +1065,15 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
|||||||
credentials: dict[str, str],
|
credentials: dict[str, str],
|
||||||
) -> None:
|
) -> None:
|
||||||
self._preflight_git(require_clean=False)
|
self._preflight_git(require_clean=False)
|
||||||
|
self._log_event(
|
||||||
|
"\n--- Early release-candidate check ---\n"
|
||||||
|
"$ npm run release:check"
|
||||||
|
)
|
||||||
|
candidate_paths = validate_release_candidates()
|
||||||
|
self._log_event(
|
||||||
|
f"✓ Early release-candidate check passed "
|
||||||
|
f"({len(candidate_paths)} changed path(s), including untracked files)"
|
||||||
|
)
|
||||||
npm = shutil.which("npm")
|
npm = shutil.which("npm")
|
||||||
powershell = shutil.which("powershell.exe") or shutil.which("powershell")
|
powershell = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||||
if not npm or not powershell:
|
if not npm or not powershell:
|
||||||
@@ -1034,7 +1105,6 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
|||||||
)
|
)
|
||||||
for command, label in checks:
|
for command, label in checks:
|
||||||
self._run_command(command, label)
|
self._run_command(command, label)
|
||||||
self._run_command(["git", "diff", "--check"], "Whitespace check")
|
|
||||||
self._run_command(["git", "add", "-A"], "Stage release")
|
self._run_command(["git", "add", "-A"], "Stage release")
|
||||||
staged = True
|
staged = True
|
||||||
|
|
||||||
@@ -1371,12 +1441,28 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="print release/version/Git readiness without opening the GUI",
|
help="print release/version/Git readiness without opening the GUI",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--check-release",
|
||||||
|
action="store_true",
|
||||||
|
help="check all release candidates, including untracked files",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--smoke-test",
|
"--smoke-test",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help=argparse.SUPPRESS,
|
help=argparse.SUPPRESS,
|
||||||
)
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
if args.check_release:
|
||||||
|
try:
|
||||||
|
paths = validate_release_candidates()
|
||||||
|
print(
|
||||||
|
f"Release candidate check passed: {len(paths)} changed path(s), "
|
||||||
|
"including untracked files."
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Release candidate check failed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
if args.check:
|
if args.check:
|
||||||
try:
|
try:
|
||||||
print(json.dumps(check_summary(), indent=2))
|
print(json.dumps(check_summary(), indent=2))
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import release_manager
|
import release_manager
|
||||||
|
|
||||||
@@ -45,5 +48,57 @@ class SafetyTests(unittest.TestCase):
|
|||||||
self.assertIn('test "$ACTUAL_COMMIT" = "$EXPECTED_COMMIT"', block)
|
self.assertIn('test "$ACTUAL_COMMIT" = "$EXPECTED_COMMIT"', block)
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseCandidateTests(unittest.TestCase):
|
||||||
|
def test_checks_untracked_files_without_changing_the_real_index(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
project_root = Path(temp_dir)
|
||||||
|
|
||||||
|
def git(*args):
|
||||||
|
subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=project_root,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
git("init", "--quiet")
|
||||||
|
(project_root / "tracked.ts").write_text("export const tracked = true;\n")
|
||||||
|
git("add", "tracked.ts")
|
||||||
|
git(
|
||||||
|
"-c",
|
||||||
|
"user.name=Healer Man Tests",
|
||||||
|
"-c",
|
||||||
|
"user.email=tests@example.invalid",
|
||||||
|
"commit",
|
||||||
|
"--quiet",
|
||||||
|
"-m",
|
||||||
|
"Initial commit",
|
||||||
|
)
|
||||||
|
|
||||||
|
untracked = project_root / "untracked.ts"
|
||||||
|
untracked.write_text("export const untracked = true;\n\n")
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
release_manager.ReleaseError, "untracked.ts.*new blank line at EOF"
|
||||||
|
):
|
||||||
|
release_manager.validate_release_candidates(project_root)
|
||||||
|
|
||||||
|
untracked.write_text("export const untracked = true;\n")
|
||||||
|
self.assertEqual(
|
||||||
|
release_manager.validate_release_candidates(project_root),
|
||||||
|
["untracked.ts"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
subprocess.run(
|
||||||
|
["git", "diff", "--cached", "--name-only"],
|
||||||
|
cwd=project_root,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
check=True,
|
||||||
|
).stdout,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ function componentFor(id) {
|
|||||||
};
|
};
|
||||||
const attack = {
|
const attack = {
|
||||||
type: int32(buffers.magicObject, offset + 0x194),
|
type: int32(buffers.magicObject, offset + 0x194),
|
||||||
|
calculationType: int32(buffers.magicObject, offset + 0x2d4),
|
||||||
damagePower: cleanFloat(float32(buffers.magicObject, offset + 0x198)),
|
damagePower: cleanFloat(float32(buffers.magicObject, offset + 0x198)),
|
||||||
damagePowerSkillLevelArg: cleanFloat(float32(buffers.magicObject, offset + 0x19c)),
|
damagePowerSkillLevelArg: cleanFloat(float32(buffers.magicObject, offset + 0x19c)),
|
||||||
fixedValue: cleanFloat(float32(buffers.magicObject, offset + 0x1a0)),
|
fixedValue: cleanFloat(float32(buffers.magicObject, offset + 0x1a0)),
|
||||||
@@ -297,7 +298,9 @@ function sourceSpell(spellId, classIndex) {
|
|||||||
})).filter((cost) => cost.type > 0 && cost.value !== 0 && ![9, 10, 11, 12, 13, 14, 15].includes(cost.type));
|
})).filter((cost) => cost.type > 0 && cost.value !== 0 && ![9, 10, 11, 12, 13, 14, 15].includes(cost.type));
|
||||||
const costs = costRows.map((cost) => ({
|
const costs = costRows.map((cost) => ({
|
||||||
resource: resourceForCostType(cost.type, classIndex),
|
resource: resourceForCostType(cost.type, classIndex),
|
||||||
amount: Math.max(0, Math.min(100, Math.abs(cost.value))),
|
amount: Math.max(0, cost.type === 3 || cost.type === 4
|
||||||
|
? Math.min(100, Math.abs(cost.value))
|
||||||
|
: Math.abs(cost.value)),
|
||||||
percentage: cost.type === 3 || cost.type === 4,
|
percentage: cost.type === 3 || cost.type === 4,
|
||||||
}));
|
}));
|
||||||
const attackDistance = int32(buffers.magicCollect, offset + 0xbc);
|
const attackDistance = int32(buffers.magicCollect, offset + 0xbc);
|
||||||
|
|||||||
+410
-2
@@ -3,6 +3,10 @@ import { mkdirSync } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { DatabaseSync } from "node:sqlite";
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import {
|
||||||
|
cloneDefaultManastormAdminConfig,
|
||||||
|
normalizeManastormAdminConfig,
|
||||||
|
} from "./manastorm-config.mjs";
|
||||||
|
|
||||||
const scrypt = promisify(scryptCallback);
|
const scrypt = promisify(scryptCallback);
|
||||||
|
|
||||||
@@ -33,8 +37,19 @@ async function derivePassword(password, salt) {
|
|||||||
return Buffer.from(await scrypt(password, salt, 64));
|
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) {
|
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 = {}) {
|
export function openGameDatabase(options = {}) {
|
||||||
@@ -62,7 +77,49 @@ export function openGameDatabase(options = {}) {
|
|||||||
account_id TEXT PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
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
|
revision INTEGER NOT NULL, data_json TEXT NOT NULL, updated_at INTEGER NOT NULL
|
||||||
) STRICT;
|
) 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;
|
||||||
|
CREATE TABLE IF NOT EXISTS online_groups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
leader_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
activity_json TEXT,
|
||||||
|
activity_revision INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE TABLE IF NOT EXISTS online_group_members (
|
||||||
|
group_id TEXT NOT NULL REFERENCES online_groups(id) ON DELETE CASCADE,
|
||||||
|
account_id TEXT NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
character_json TEXT,
|
||||||
|
role TEXT,
|
||||||
|
joined_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (group_id, account_id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX IF NOT EXISTS online_group_members_group_idx ON online_group_members(group_id, joined_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS online_group_invites (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
group_id TEXT NOT NULL REFERENCES online_groups(id) ON DELETE CASCADE,
|
||||||
|
inviter_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
invitee_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX IF NOT EXISTS online_group_invites_invitee_idx
|
||||||
|
ON online_group_invites(invitee_account_id, status, expires_at);
|
||||||
`);
|
`);
|
||||||
|
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 = {
|
const statements = {
|
||||||
accountByUsername: database.prepare("SELECT * FROM accounts WHERE username_key = ?"),
|
accountByUsername: database.prepare("SELECT * FROM accounts WHERE username_key = ?"),
|
||||||
@@ -82,6 +139,18 @@ export function openGameDatabase(options = {}) {
|
|||||||
data_json = excluded.data_json, updated_at = excluded.updated_at
|
data_json = excluded.data_json, updated_at = excluded.updated_at
|
||||||
RETURNING revision, 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) {
|
function issueSession(accountId) {
|
||||||
@@ -110,7 +179,7 @@ export function openGameDatabase(options = {}) {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return { account, ...issueSession(account.id) };
|
return { account: publicAccount(account), ...issueSession(account.id) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(usernameInput, password) {
|
async function login(usernameInput, password) {
|
||||||
@@ -160,6 +229,334 @@ export function openGameDatabase(options = {}) {
|
|||||||
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data };
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneExpiredGroupInvites() {
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_group_invites SET status = 'expired'
|
||||||
|
WHERE status = 'pending' AND expires_at <= ?
|
||||||
|
`).run(Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoredJson(value, fallback = null) {
|
||||||
|
if (typeof value !== "string" || !value) return fallback;
|
||||||
|
try { return JSON.parse(value); } catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupMembership(accountId) {
|
||||||
|
return database.prepare(`
|
||||||
|
SELECT online_groups.*
|
||||||
|
FROM online_group_members
|
||||||
|
JOIN online_groups ON online_groups.id = online_group_members.group_id
|
||||||
|
WHERE online_group_members.account_id = ?
|
||||||
|
`).get(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicGroup(groupId) {
|
||||||
|
const group = database.prepare("SELECT * FROM online_groups WHERE id = ?").get(groupId);
|
||||||
|
if (!group) return null;
|
||||||
|
const members = database.prepare(`
|
||||||
|
SELECT accounts.id AS account_id, accounts.username,
|
||||||
|
online_group_members.character_json, online_group_members.role,
|
||||||
|
online_group_members.joined_at
|
||||||
|
FROM online_group_members
|
||||||
|
JOIN accounts ON accounts.id = online_group_members.account_id
|
||||||
|
WHERE online_group_members.group_id = ?
|
||||||
|
ORDER BY online_group_members.joined_at, accounts.username_key
|
||||||
|
`).all(groupId).map((member) => ({
|
||||||
|
accountId: member.account_id,
|
||||||
|
username: member.username,
|
||||||
|
character: parseStoredJson(member.character_json),
|
||||||
|
role: ["tank", "healer", "damage"].includes(member.role) ? member.role : null,
|
||||||
|
joinedAt: Number(member.joined_at),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
id: group.id,
|
||||||
|
leaderAccountId: group.leader_account_id,
|
||||||
|
members,
|
||||||
|
activity: parseStoredJson(group.activity_json),
|
||||||
|
activityRevision: Number(group.activity_revision),
|
||||||
|
createdAt: Number(group.created_at),
|
||||||
|
updatedAt: Number(group.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onlineGroupState(accountId) {
|
||||||
|
pruneExpiredGroupInvites();
|
||||||
|
const membership = groupMembership(accountId);
|
||||||
|
const invitations = database.prepare(`
|
||||||
|
SELECT online_group_invites.id, online_group_invites.group_id,
|
||||||
|
online_group_invites.created_at, online_group_invites.expires_at,
|
||||||
|
accounts.id AS inviter_account_id, accounts.username AS inviter_username
|
||||||
|
FROM online_group_invites
|
||||||
|
JOIN accounts ON accounts.id = online_group_invites.inviter_account_id
|
||||||
|
WHERE online_group_invites.invitee_account_id = ?
|
||||||
|
AND online_group_invites.status = 'pending'
|
||||||
|
AND online_group_invites.expires_at > ?
|
||||||
|
ORDER BY online_group_invites.created_at DESC
|
||||||
|
`).all(accountId, Date.now()).map((invite) => ({
|
||||||
|
id: invite.id,
|
||||||
|
groupId: invite.group_id,
|
||||||
|
inviterAccountId: invite.inviter_account_id,
|
||||||
|
inviterUsername: invite.inviter_username,
|
||||||
|
createdAt: Number(invite.created_at),
|
||||||
|
expiresAt: Number(invite.expires_at),
|
||||||
|
}));
|
||||||
|
return { group: membership ? publicGroup(membership.id) : null, invitations };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOnlineGroup(accountId) {
|
||||||
|
const existing = groupMembership(accountId);
|
||||||
|
if (existing) return existing;
|
||||||
|
const id = `group-${randomUUID()}`;
|
||||||
|
const now = Date.now();
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO online_groups (id, leader_account_id, activity_json, activity_revision, created_at, updated_at)
|
||||||
|
VALUES (?, ?, NULL, 0, ?, ?)
|
||||||
|
`).run(id, accountId, now, now);
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO online_group_members (group_id, account_id, character_json, role, joined_at)
|
||||||
|
VALUES (?, ?, NULL, NULL, ?)
|
||||||
|
`).run(id, accountId, now);
|
||||||
|
return database.prepare("SELECT * FROM online_groups WHERE id = ?").get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inviteOnlineGroupMember(account, usernameInput) {
|
||||||
|
pruneExpiredGroupInvites();
|
||||||
|
const username = typeof usernameInput === "string" ? usernameInput.trim() : "";
|
||||||
|
if (!username) throw new GameDatabaseError("Enter the player's account name.", 400, "missing_username");
|
||||||
|
const invitee = statements.accountByUsername.get(username.toLowerCase());
|
||||||
|
if (!invitee) throw new GameDatabaseError("No online player has that account name.", 404, "player_not_found");
|
||||||
|
if (invitee.id === account.id) throw new GameDatabaseError("You cannot invite yourself.", 400, "cannot_invite_self");
|
||||||
|
if (groupMembership(invitee.id)) throw new GameDatabaseError("That player is already in a group.", 409, "player_already_grouped");
|
||||||
|
const group = createOnlineGroup(account.id);
|
||||||
|
const memberCount = Number(database.prepare("SELECT COUNT(*) AS count FROM online_group_members WHERE group_id = ?").get(group.id).count);
|
||||||
|
if (memberCount >= 5) throw new GameDatabaseError("Your group is already full.", 409, "group_full");
|
||||||
|
const existing = database.prepare(`
|
||||||
|
SELECT id FROM online_group_invites
|
||||||
|
WHERE group_id = ? AND invitee_account_id = ? AND status = 'pending' AND expires_at > ?
|
||||||
|
`).get(group.id, invitee.id, Date.now());
|
||||||
|
if (existing) throw new GameDatabaseError("That player already has an invitation from your group.", 409, "invite_exists");
|
||||||
|
const id = `invite-${randomUUID()}`;
|
||||||
|
const createdAt = Date.now();
|
||||||
|
const expiresAt = createdAt + 10 * 60 * 1000;
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO online_group_invites (
|
||||||
|
id, group_id, inviter_account_id, invitee_account_id, status, created_at, expires_at
|
||||||
|
) VALUES (?, ?, ?, ?, 'pending', ?, ?)
|
||||||
|
`).run(id, group.id, account.id, invitee.id, createdAt, expiresAt);
|
||||||
|
return { id, expiresAt, state: onlineGroupState(account.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function respondToOnlineGroupInvite(accountId, inviteId, accept) {
|
||||||
|
pruneExpiredGroupInvites();
|
||||||
|
const invite = database.prepare(`
|
||||||
|
SELECT * FROM online_group_invites
|
||||||
|
WHERE id = ? AND invitee_account_id = ? AND status = 'pending' AND expires_at > ?
|
||||||
|
`).get(inviteId, accountId, Date.now());
|
||||||
|
if (!invite) throw new GameDatabaseError("That group invitation is no longer available.", 404, "invite_not_found");
|
||||||
|
if (!accept) {
|
||||||
|
database.prepare("UPDATE online_group_invites SET status = 'declined' WHERE id = ?").run(invite.id);
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
if (groupMembership(accountId)) throw new GameDatabaseError("Leave your current group before accepting another invitation.", 409, "already_grouped");
|
||||||
|
const group = database.prepare("SELECT * FROM online_groups WHERE id = ?").get(invite.group_id);
|
||||||
|
if (!group) throw new GameDatabaseError("That group no longer exists.", 404, "group_not_found");
|
||||||
|
const memberCount = Number(database.prepare("SELECT COUNT(*) AS count FROM online_group_members WHERE group_id = ?").get(group.id).count);
|
||||||
|
if (memberCount >= 5) throw new GameDatabaseError("That group is already full.", 409, "group_full");
|
||||||
|
const now = Date.now();
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO online_group_members (group_id, account_id, character_json, role, joined_at)
|
||||||
|
VALUES (?, ?, NULL, NULL, ?)
|
||||||
|
`).run(group.id, accountId, now);
|
||||||
|
database.prepare("UPDATE online_group_invites SET status = 'accepted' WHERE id = ?").run(invite.id);
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_group_invites SET status = 'expired'
|
||||||
|
WHERE invitee_account_id = ? AND status = 'pending'
|
||||||
|
`).run(accountId);
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_groups SET activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ?
|
||||||
|
`).run(now, group.id);
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaveOnlineGroup(accountId) {
|
||||||
|
const group = groupMembership(accountId);
|
||||||
|
if (!group) return onlineGroupState(accountId);
|
||||||
|
database.prepare("DELETE FROM online_group_members WHERE group_id = ? AND account_id = ?").run(group.id, accountId);
|
||||||
|
const remaining = database.prepare(`
|
||||||
|
SELECT account_id FROM online_group_members WHERE group_id = ? ORDER BY joined_at LIMIT 1
|
||||||
|
`).get(group.id);
|
||||||
|
if (!remaining) {
|
||||||
|
database.prepare("DELETE FROM online_groups WHERE id = ?").run(group.id);
|
||||||
|
} else {
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_groups
|
||||||
|
SET leader_account_id = CASE WHEN leader_account_id = ? THEN ? ELSE leader_account_id END,
|
||||||
|
activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(accountId, remaining.account_id, Date.now(), group.id);
|
||||||
|
}
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOnlineGroupMember(accountId, targetAccountId) {
|
||||||
|
const group = groupMembership(accountId);
|
||||||
|
if (!group || group.leader_account_id !== accountId) {
|
||||||
|
throw new GameDatabaseError("Only the group leader can remove players.", 403, "leader_required");
|
||||||
|
}
|
||||||
|
if (targetAccountId === accountId) return leaveOnlineGroup(accountId);
|
||||||
|
const removed = database.prepare(`
|
||||||
|
DELETE FROM online_group_members WHERE group_id = ? AND account_id = ?
|
||||||
|
`).run(group.id, targetAccountId);
|
||||||
|
if (!removed.changes) throw new GameDatabaseError("That player is not in your group.", 404, "member_not_found");
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_groups SET activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ?
|
||||||
|
`).run(Date.now(), group.id);
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGroupCharacter(character) {
|
||||||
|
if (!character || typeof character !== "object" || Array.isArray(character)) return null;
|
||||||
|
const id = String(character.id ?? "").slice(0, 100);
|
||||||
|
const name = String(character.name ?? "").trim().slice(0, 30);
|
||||||
|
const classId = String(character.classId ?? "").slice(0, 50);
|
||||||
|
if (!id || !name || !classId) throw new GameDatabaseError("Select a valid character for the group.", 400, "invalid_group_character");
|
||||||
|
let appearance = {};
|
||||||
|
if (character.appearance && typeof character.appearance === "object" && !Array.isArray(character.appearance)) {
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(character.appearance);
|
||||||
|
if (serialized.length <= 8 * 1024) appearance = JSON.parse(serialized);
|
||||||
|
} catch {
|
||||||
|
appearance = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
classId,
|
||||||
|
categoryId: String(character.categoryId ?? "wow").slice(0, 20),
|
||||||
|
raceId: String(character.raceId ?? "human").slice(0, 50),
|
||||||
|
gender: character.gender === "female" ? "female" : "male",
|
||||||
|
level: Math.max(1, Math.min(1000, Math.trunc(Number(character.level) || 1))),
|
||||||
|
appearance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOnlineGroupMember(accountId, character, role) {
|
||||||
|
const group = groupMembership(accountId);
|
||||||
|
if (!group) throw new GameDatabaseError("Join or create a group first.", 409, "group_required");
|
||||||
|
if (!["tank", "healer", "damage"].includes(role)) {
|
||||||
|
throw new GameDatabaseError("Choose a valid party role.", 400, "invalid_party_role");
|
||||||
|
}
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_group_members SET character_json = ?, role = ?
|
||||||
|
WHERE group_id = ? AND account_id = ?
|
||||||
|
`).run(JSON.stringify(normalizeGroupCharacter(character)), role, group.id, accountId);
|
||||||
|
database.prepare("UPDATE online_groups SET updated_at = ? WHERE id = ?").run(Date.now(), group.id);
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOnlineGroupActivity(accountId, input) {
|
||||||
|
const group = groupMembership(accountId);
|
||||||
|
if (!group || group.leader_account_id !== accountId) {
|
||||||
|
throw new GameDatabaseError("Only the group leader can start an activity.", 403, "leader_required");
|
||||||
|
}
|
||||||
|
const type = input?.type;
|
||||||
|
if (type !== "dungeon" && type !== "manastorm") {
|
||||||
|
throw new GameDatabaseError("Choose a Dungeon or Manastorm.", 400, "invalid_activity_type");
|
||||||
|
}
|
||||||
|
const contentId = String(input?.contentId ?? "").trim().slice(0, 120);
|
||||||
|
if (!contentId) throw new GameDatabaseError("Choose content before starting.", 400, "missing_content");
|
||||||
|
const members = database.prepare(`
|
||||||
|
SELECT account_id, character_json, role FROM online_group_members WHERE group_id = ? ORDER BY joined_at
|
||||||
|
`).all(group.id);
|
||||||
|
if (members.some((member) => !member.character_json || !["tank", "healer", "damage"].includes(member.role))) {
|
||||||
|
throw new GameDatabaseError("Every player must select a character and party role before the group can start.", 409, "group_not_ready");
|
||||||
|
}
|
||||||
|
const fillWithAi = input?.fillWithAi !== false;
|
||||||
|
const requestedSize = Math.max(1, Math.min(5, Math.trunc(Number(input?.partySize) || 5)));
|
||||||
|
const partySize = fillWithAi ? Math.max(members.length, requestedSize) : members.length;
|
||||||
|
const activity = {
|
||||||
|
id: `activity-${randomUUID()}`,
|
||||||
|
type,
|
||||||
|
contentId,
|
||||||
|
fillWithAi,
|
||||||
|
partySize,
|
||||||
|
startingLevel: type === "manastorm"
|
||||||
|
? Math.max(1, Math.trunc(Number(input?.startingLevel) || 1))
|
||||||
|
: null,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
};
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_groups
|
||||||
|
SET activity_json = ?, activity_revision = activity_revision + 1, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(JSON.stringify(activity), activity.startedAt, group.id);
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearOnlineGroupActivity(accountId) {
|
||||||
|
const group = groupMembership(accountId);
|
||||||
|
if (!group || group.leader_account_id !== accountId) {
|
||||||
|
throw new GameDatabaseError("Only the group leader can reset the activity.", 403, "leader_required");
|
||||||
|
}
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_groups SET activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ?
|
||||||
|
`).run(Date.now(), group.id);
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
databasePath,
|
databasePath,
|
||||||
register,
|
register,
|
||||||
@@ -168,6 +565,17 @@ export function openGameDatabase(options = {}) {
|
|||||||
logout: (token) => { if (typeof token === "string" && token) statements.deleteSession.run(tokenHash(token)); },
|
logout: (token) => { if (typeof token === "string" && token) statements.deleteSession.run(tokenHash(token)); },
|
||||||
getCloudSave,
|
getCloudSave,
|
||||||
putCloudSave,
|
putCloudSave,
|
||||||
|
isGameMaster,
|
||||||
|
getManastormConfig,
|
||||||
|
putManastormConfig,
|
||||||
|
onlineGroupState,
|
||||||
|
inviteOnlineGroupMember,
|
||||||
|
respondToOnlineGroupInvite,
|
||||||
|
leaveOnlineGroup,
|
||||||
|
removeOnlineGroupMember,
|
||||||
|
updateOnlineGroupMember,
|
||||||
|
startOnlineGroupActivity,
|
||||||
|
clearOnlineGroupActivity,
|
||||||
pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes),
|
pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes),
|
||||||
close: () => database.close(),
|
close: () => database.close(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
||||||
|
|
||||||
function withDatabase(run) {
|
function withDatabase(run) {
|
||||||
@@ -52,3 +53,129 @@ test("persists opaque cloud roster data with increasing revisions", () => withDa
|
|||||||
assert.equal(second.revision, 2);
|
assert.equal(second.revision, 2);
|
||||||
assert.equal(database.getCloudSave(registered.account.id).data.characters[0].name, "Mendbetter");
|
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);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("creates online groups through invitations and transfers leadership when the leader leaves", () => withDatabase(async (database) => {
|
||||||
|
const leaderAuth = await database.register("GroupLeader", "group-pass");
|
||||||
|
const healerAuth = await database.register("GroupHealer", "healer-pass");
|
||||||
|
const leader = database.authenticate(leaderAuth.token);
|
||||||
|
const healer = database.authenticate(healerAuth.token);
|
||||||
|
|
||||||
|
const invitation = database.inviteOnlineGroupMember(leader, "grouphealer");
|
||||||
|
assert.equal(invitation.state.group.members.length, 1);
|
||||||
|
const incoming = database.onlineGroupState(healer.id).invitations;
|
||||||
|
assert.equal(incoming.length, 1);
|
||||||
|
assert.equal(incoming[0].inviterUsername, "GroupLeader");
|
||||||
|
|
||||||
|
const accepted = database.respondToOnlineGroupInvite(healer.id, incoming[0].id, true);
|
||||||
|
assert.equal(accepted.group.members.length, 2);
|
||||||
|
assert.equal(accepted.group.leaderAccountId, leader.id);
|
||||||
|
|
||||||
|
database.leaveOnlineGroup(leader.id);
|
||||||
|
const transferred = database.onlineGroupState(healer.id);
|
||||||
|
assert.equal(transferred.group.leaderAccountId, healer.id);
|
||||||
|
assert.deepEqual(transferred.group.members.map((member) => member.username), ["GroupHealer"]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("launches players-only and AI-filled group activities only when every member is ready", () => withDatabase(async (database) => {
|
||||||
|
const leaderAuth = await database.register("ReadyLeader", "group-pass");
|
||||||
|
const memberAuth = await database.register("ReadyMember", "member-pass");
|
||||||
|
const leader = database.authenticate(leaderAuth.token);
|
||||||
|
const member = database.authenticate(memberAuth.token);
|
||||||
|
database.inviteOnlineGroupMember(leader, member.username);
|
||||||
|
const invite = database.onlineGroupState(member.id).invitations[0];
|
||||||
|
database.respondToOnlineGroupInvite(member.id, invite.id, true);
|
||||||
|
|
||||||
|
database.updateOnlineGroupMember(leader.id, {
|
||||||
|
id: "leader-character", name: "Leadwell", classId: "warrior", raceId: "human", gender: "male", level: 20,
|
||||||
|
}, "tank");
|
||||||
|
assert.throws(() => database.startOnlineGroupActivity(leader.id, {
|
||||||
|
type: "dungeon", contentId: "wailing-caverns", fillWithAi: true, partySize: 5,
|
||||||
|
}), (error) => error instanceof GameDatabaseError && error.code === "group_not_ready");
|
||||||
|
|
||||||
|
database.updateOnlineGroupMember(member.id, {
|
||||||
|
id: "member-character", name: "Mendwell", classId: "priest", raceId: "human", gender: "female", level: 20,
|
||||||
|
}, "healer");
|
||||||
|
const playersOnly = database.startOnlineGroupActivity(leader.id, {
|
||||||
|
type: "dungeon", contentId: "wailing-caverns", fillWithAi: false, partySize: 5,
|
||||||
|
});
|
||||||
|
assert.equal(playersOnly.group.activity.partySize, 2);
|
||||||
|
assert.equal(playersOnly.group.activity.fillWithAi, false);
|
||||||
|
|
||||||
|
const filled = database.startOnlineGroupActivity(leader.id, {
|
||||||
|
type: "manastorm", contentId: "manastorm", fillWithAi: true, partySize: 5, startingLevel: 10,
|
||||||
|
});
|
||||||
|
assert.equal(filled.group.activity.partySize, 5);
|
||||||
|
assert.equal(filled.group.activity.startingLevel, 10);
|
||||||
|
assert.throws(() => database.startOnlineGroupActivity(member.id, {
|
||||||
|
type: "dungeon", contentId: "wailing-caverns",
|
||||||
|
}), (error) => error instanceof GameDatabaseError && error.status === 403);
|
||||||
|
}));
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { GameDatabaseError } from "./database.mjs";
|
||||||
|
|
||||||
|
const PLAYER_TIMEOUT_MS = 15_000;
|
||||||
|
const ROOM_TIMEOUT_MS = 30 * 60_000;
|
||||||
|
const MAX_EVENTS = 256;
|
||||||
|
const MAX_BATCH_EVENTS = 48;
|
||||||
|
const ALLOWED_EVENT_KINDS = new Set([
|
||||||
|
"damage",
|
||||||
|
"healing",
|
||||||
|
"resurrection",
|
||||||
|
"loot",
|
||||||
|
"portal",
|
||||||
|
]);
|
||||||
|
const ALLOWED_SCHOOLS = new Set([
|
||||||
|
"physical",
|
||||||
|
"holy",
|
||||||
|
"fire",
|
||||||
|
"nature",
|
||||||
|
"frost",
|
||||||
|
"shadow",
|
||||||
|
"arcane",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function finite(value, fallback = 0) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedString(value, maximum = 160) {
|
||||||
|
return String(value ?? "").slice(0, maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vector3(value) {
|
||||||
|
if (!Array.isArray(value) || value.length < 3) return [0, 0, 0];
|
||||||
|
return value.slice(0, 3).map((entry) => Math.max(-100_000, Math.min(100_000, finite(entry))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonClone(value, fallback = null) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireActivity(account, snapshot) {
|
||||||
|
const group = snapshot?.group;
|
||||||
|
const activity = group?.activity;
|
||||||
|
const member = group?.members?.find((candidate) => candidate.accountId === account.id);
|
||||||
|
if (!group || !activity || !member) {
|
||||||
|
throw new GameDatabaseError("The shared activity is no longer active.", 409, "online_activity_required");
|
||||||
|
}
|
||||||
|
return { group, activity, member };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePresence(input, member, now) {
|
||||||
|
const maximumHealth = Math.max(1, Math.min(1_000_000_000, Math.round(finite(input?.maxHealth, 1))));
|
||||||
|
const maximumResource = Math.max(0, Math.min(1_000_000_000, Math.round(finite(input?.maxResource))));
|
||||||
|
return {
|
||||||
|
accountId: member.accountId,
|
||||||
|
username: member.username,
|
||||||
|
character: member.character,
|
||||||
|
role: member.role,
|
||||||
|
position: vector3(input?.position),
|
||||||
|
yaw: Math.max(-Math.PI * 8, Math.min(Math.PI * 8, finite(input?.yaw))),
|
||||||
|
grounded: input?.grounded !== false,
|
||||||
|
health: Math.max(0, Math.min(maximumHealth, Math.round(finite(input?.health, maximumHealth)))),
|
||||||
|
maxHealth: maximumHealth,
|
||||||
|
resource: Math.max(0, Math.min(maximumResource, finite(input?.resource))),
|
||||||
|
maxResource: maximumResource,
|
||||||
|
resourceName: boundedString(input?.resourceName, 40),
|
||||||
|
selectedTargetId: input?.selectedTargetId ? boundedString(input.selectedTargetId) : null,
|
||||||
|
activeCast: input?.activeCast && typeof input.activeCast === "object"
|
||||||
|
? jsonClone(input.activeCast)
|
||||||
|
: null,
|
||||||
|
animationEvent: input?.animationEvent && typeof input.animationEvent === "object"
|
||||||
|
? jsonClone(input.animationEvent)
|
||||||
|
: null,
|
||||||
|
equipment: Array.isArray(input?.equipment)
|
||||||
|
? jsonClone(input.equipment.slice(0, 24), [])
|
||||||
|
: [],
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeEvent(input, accountId, leaderAccountId) {
|
||||||
|
const kind = boundedString(input?.kind, 32);
|
||||||
|
if (!ALLOWED_EVENT_KINDS.has(kind)) return null;
|
||||||
|
const targetActorId = boundedString(input?.targetActorId);
|
||||||
|
if (!targetActorId && kind !== "portal") return null;
|
||||||
|
const sourceActorId = boundedString(input?.sourceActorId);
|
||||||
|
const localPlayerActorId = `online-player:${accountId}`;
|
||||||
|
// Non-leaders may submit only their own player actions. Enemy and AI events
|
||||||
|
// are produced by the activity authority and cannot be forged by members.
|
||||||
|
if (accountId !== leaderAccountId && sourceActorId !== localPlayerActorId) return null;
|
||||||
|
const school = boundedString(input?.school, 24);
|
||||||
|
return {
|
||||||
|
clientEventId: boundedString(input?.clientEventId, 200),
|
||||||
|
kind,
|
||||||
|
sourceActorId: sourceActorId || localPlayerActorId,
|
||||||
|
targetActorId,
|
||||||
|
abilityId: input?.abilityId ? boundedString(input.abilityId) : null,
|
||||||
|
school: ALLOWED_SCHOOLS.has(school) ? school : null,
|
||||||
|
rawAmount: Math.max(0, Math.min(1_000_000_000, finite(input?.rawAmount))),
|
||||||
|
effectiveAmount: Math.max(0, Math.min(1_000_000_000, finite(input?.effectiveAmount))),
|
||||||
|
critical: input?.critical === true,
|
||||||
|
occurredAt: Math.max(0, Math.trunc(finite(input?.occurredAt, Date.now()))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOnlineSessionManager() {
|
||||||
|
const rooms = new Map();
|
||||||
|
|
||||||
|
function prune(now) {
|
||||||
|
for (const [activityId, room] of rooms) {
|
||||||
|
if (now - room.updatedAt > ROOM_TIMEOUT_MS) rooms.delete(activityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomFor(group, activity, now) {
|
||||||
|
prune(now);
|
||||||
|
let room = rooms.get(activity.id);
|
||||||
|
if (!room || room.groupId !== group.id) {
|
||||||
|
room = {
|
||||||
|
activityId: activity.id,
|
||||||
|
groupId: group.id,
|
||||||
|
revision: 0,
|
||||||
|
nextEventId: 1,
|
||||||
|
players: new Map(),
|
||||||
|
events: [],
|
||||||
|
eventKeys: new Set(),
|
||||||
|
world: null,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
rooms.set(activity.id, room);
|
||||||
|
}
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync(account, groupSnapshot, input = {}) {
|
||||||
|
const now = Date.now();
|
||||||
|
const { group, activity, member } = requireActivity(account, groupSnapshot);
|
||||||
|
const room = roomFor(group, activity, now);
|
||||||
|
const presence = sanitizePresence(input.presence, member, now);
|
||||||
|
room.players.set(account.id, presence);
|
||||||
|
room.revision += 1;
|
||||||
|
room.updatedAt = now;
|
||||||
|
|
||||||
|
if (input.world !== undefined) {
|
||||||
|
if (group.leaderAccountId !== account.id) {
|
||||||
|
throw new GameDatabaseError("Only the activity leader can publish shared world state.", 403, "activity_authority_required");
|
||||||
|
}
|
||||||
|
if (!input.world || typeof input.world !== "object" || Array.isArray(input.world)) {
|
||||||
|
throw new GameDatabaseError("Shared world state must be an object.", 400, "invalid_world_state");
|
||||||
|
}
|
||||||
|
room.world = {
|
||||||
|
...jsonClone(input.world, {}),
|
||||||
|
publishedAt: now,
|
||||||
|
publishedBy: account.id,
|
||||||
|
};
|
||||||
|
room.revision += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch = Array.isArray(input.events) ? input.events.slice(0, MAX_BATCH_EVENTS) : [];
|
||||||
|
for (const candidate of batch) {
|
||||||
|
const event = sanitizeEvent(candidate, account.id, group.leaderAccountId);
|
||||||
|
if (!event?.clientEventId) continue;
|
||||||
|
const eventKey = `${account.id}:${event.clientEventId}`;
|
||||||
|
if (room.eventKeys.has(eventKey)) continue;
|
||||||
|
room.eventKeys.add(eventKey);
|
||||||
|
room.events.push({
|
||||||
|
...event,
|
||||||
|
id: room.nextEventId,
|
||||||
|
sourceAccountId: account.id,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
room.nextEventId += 1;
|
||||||
|
room.revision += 1;
|
||||||
|
}
|
||||||
|
if (room.events.length > MAX_EVENTS) {
|
||||||
|
const removed = room.events.splice(0, room.events.length - MAX_EVENTS);
|
||||||
|
for (const event of removed) room.eventKeys.delete(`${event.sourceAccountId}:${event.clientEventId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberIds = new Set(group.members.map((candidate) => candidate.accountId));
|
||||||
|
for (const [accountId, player] of room.players) {
|
||||||
|
if (!memberIds.has(accountId) || now - player.updatedAt > PLAYER_TIMEOUT_MS) {
|
||||||
|
room.players.delete(accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const afterEventId = Math.max(0, Math.trunc(finite(input.afterEventId)));
|
||||||
|
return {
|
||||||
|
activity,
|
||||||
|
groupId: group.id,
|
||||||
|
leaderAccountId: group.leaderAccountId,
|
||||||
|
localAccountId: account.id,
|
||||||
|
authority: group.leaderAccountId === account.id,
|
||||||
|
revision: room.revision,
|
||||||
|
serverTime: now,
|
||||||
|
players: [...room.players.values()].sort((left, right) => left.accountId.localeCompare(right.accountId)),
|
||||||
|
world: room.world,
|
||||||
|
events: room.events.filter((event) => event.id > afterEventId),
|
||||||
|
latestEventId: room.nextEventId - 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sync,
|
||||||
|
clear: () => rooms.clear(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { GameDatabaseError } from "./database.mjs";
|
||||||
|
import { createOnlineSessionManager } from "./online-session.mjs";
|
||||||
|
|
||||||
|
function groupSnapshot() {
|
||||||
|
return {
|
||||||
|
invitations: [],
|
||||||
|
group: {
|
||||||
|
id: "group-one",
|
||||||
|
leaderAccountId: "leader",
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
accountId: "leader",
|
||||||
|
username: "Leader",
|
||||||
|
character: { id: "lead", name: "Bulwark", classId: "warrior", raceId: "human", gender: "male", level: 20 },
|
||||||
|
role: "tank",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: "healer",
|
||||||
|
username: "Healer",
|
||||||
|
character: { id: "heal", name: "Mendara", classId: "priest", raceId: "human", gender: "female", level: 20 },
|
||||||
|
role: "healer",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activity: {
|
||||||
|
id: "activity-one",
|
||||||
|
type: "dungeon",
|
||||||
|
contentId: "wailing-caverns",
|
||||||
|
fillWithAi: false,
|
||||||
|
partySize: 2,
|
||||||
|
startingLevel: null,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const presence = {
|
||||||
|
position: [1, 2, 3],
|
||||||
|
yaw: 0.5,
|
||||||
|
health: 900,
|
||||||
|
maxHealth: 1_000,
|
||||||
|
resource: 400,
|
||||||
|
maxResource: 500,
|
||||||
|
resourceName: "Mana",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("synchronizes player presence, leader world state, and ordered combat events", () => {
|
||||||
|
const manager = createOnlineSessionManager();
|
||||||
|
const snapshot = groupSnapshot();
|
||||||
|
const leader = manager.sync({ id: "leader" }, snapshot, {
|
||||||
|
presence,
|
||||||
|
world: { mobs: { boss: { health: 500, maxHealth: 500 } }, encounter: { phase: "boss" } },
|
||||||
|
});
|
||||||
|
assert.equal(leader.authority, true);
|
||||||
|
assert.equal(leader.players[0].character.name, "Bulwark");
|
||||||
|
assert.equal(leader.world.mobs.boss.health, 500);
|
||||||
|
|
||||||
|
const healer = manager.sync({ id: "healer" }, snapshot, {
|
||||||
|
presence: { ...presence, position: [4, 5, 6] },
|
||||||
|
afterEventId: 0,
|
||||||
|
events: [{
|
||||||
|
clientEventId: "heal:1",
|
||||||
|
kind: "damage",
|
||||||
|
sourceActorId: "online-player:healer",
|
||||||
|
targetActorId: "boss",
|
||||||
|
school: "holy",
|
||||||
|
rawAmount: 75,
|
||||||
|
effectiveAmount: 60,
|
||||||
|
occurredAt: 123,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(healer.players.length, 2);
|
||||||
|
assert.equal(healer.events.length, 1);
|
||||||
|
assert.equal(healer.events[0].id, 1);
|
||||||
|
assert.equal(healer.events[0].sourceAccountId, "healer");
|
||||||
|
|
||||||
|
const deduplicated = manager.sync({ id: "healer" }, snapshot, {
|
||||||
|
presence,
|
||||||
|
afterEventId: 1,
|
||||||
|
events: [{
|
||||||
|
clientEventId: "heal:1",
|
||||||
|
kind: "damage",
|
||||||
|
sourceActorId: "online-player:healer",
|
||||||
|
targetActorId: "boss",
|
||||||
|
rawAmount: 75,
|
||||||
|
effectiveAmount: 60,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(deduplicated.events.length, 0);
|
||||||
|
assert.equal(deduplicated.latestEventId, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects non-leader world updates and forged enemy events", () => {
|
||||||
|
const manager = createOnlineSessionManager();
|
||||||
|
const snapshot = groupSnapshot();
|
||||||
|
assert.throws(() => manager.sync({ id: "healer" }, snapshot, {
|
||||||
|
presence,
|
||||||
|
world: { mobs: {} },
|
||||||
|
}), (error) => error instanceof GameDatabaseError && error.code === "activity_authority_required");
|
||||||
|
|
||||||
|
const state = manager.sync({ id: "healer" }, snapshot, {
|
||||||
|
presence,
|
||||||
|
events: [{
|
||||||
|
clientEventId: "forged",
|
||||||
|
kind: "damage",
|
||||||
|
sourceActorId: "boss",
|
||||||
|
targetActorId: "online-player:leader",
|
||||||
|
rawAmount: 1_000_000,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(state.events.length, 0);
|
||||||
|
});
|
||||||
+79
-1
@@ -3,6 +3,7 @@ import { createServer } from "node:http";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
||||||
|
import { createOnlineSessionManager } from "./online-session.mjs";
|
||||||
|
|
||||||
const MIME_TYPES = new Map([
|
const MIME_TYPES = new Map([
|
||||||
[".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"],
|
[".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"],
|
||||||
@@ -148,6 +149,7 @@ export function createGameServer(options = {}) {
|
|||||||
const bodyLimit = Math.max(1024, Number(options.bodyLimit ?? process.env.MAX_JSON_BODY_BYTES ?? 5 * 1024 * 1024));
|
const bodyLimit = Math.max(1024, Number(options.bodyLimit ?? process.env.MAX_JSON_BODY_BYTES ?? 5 * 1024 * 1024));
|
||||||
const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
|
const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
|
||||||
const gameDatabase = options.database ?? openGameDatabase(options);
|
const gameDatabase = options.database ?? openGameDatabase(options);
|
||||||
|
const onlineSessions = createOnlineSessionManager();
|
||||||
const allowAuthRequest = createAuthLimiter();
|
const allowAuthRequest = createAuthLimiter();
|
||||||
gameDatabase.pruneExpiredSessions();
|
gameDatabase.pruneExpiredSessions();
|
||||||
|
|
||||||
@@ -209,6 +211,79 @@ export function createGameServer(options = {}) {
|
|||||||
json(response, 200, gameDatabase.putCloudSave(account.id, body.data));
|
json(response, 200, gameDatabase.putCloudSave(account.id, body.data));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (url.pathname === "/api/online-group" && request.method === "GET") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
json(response, 200, gameDatabase.onlineGroupState(account.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/invite" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, 64 * 1024);
|
||||||
|
json(response, 201, gameDatabase.inviteOnlineGroupMember(account, body.username));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/invite/respond" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, 64 * 1024);
|
||||||
|
json(response, 200, gameDatabase.respondToOnlineGroupInvite(account.id, body.inviteId, body.accept === true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/leave" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
json(response, 200, gameDatabase.leaveOnlineGroup(account.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/remove" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, 64 * 1024);
|
||||||
|
json(response, 200, gameDatabase.removeOnlineGroupMember(account.id, body.accountId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/member" && request.method === "PUT") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, 64 * 1024);
|
||||||
|
json(response, 200, gameDatabase.updateOnlineGroupMember(account.id, body.character, body.role));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/activity" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, 64 * 1024);
|
||||||
|
json(response, 200, gameDatabase.startOnlineGroupActivity(account.id, body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/activity/clear" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
json(response, 200, gameDatabase.clearOnlineGroupActivity(account.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-session/sync" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, bodyLimit);
|
||||||
|
const groupSnapshot = gameDatabase.onlineGroupState(account.id);
|
||||||
|
json(response, 200, onlineSessions.sync(account, groupSnapshot, body));
|
||||||
|
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/")) {
|
if (url.pathname.startsWith("/api/")) {
|
||||||
json(response, 404, { error: "API route not found.", code: "not_found" });
|
json(response, 404, { error: "API route not found.", code: "not_found" });
|
||||||
return;
|
return;
|
||||||
@@ -238,7 +313,10 @@ export function createGameServer(options = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on("close", () => gameDatabase.close());
|
server.on("close", () => {
|
||||||
|
onlineSessions.clear();
|
||||||
|
gameDatabase.close();
|
||||||
|
});
|
||||||
return { server, database: gameDatabase, staticDir, contentDir };
|
return { server, database: gameDatabase, staticDir, contentDir };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,115 @@ test("serves the game and authenticated cloud-save API", async () => {
|
|||||||
});
|
});
|
||||||
assert.equal(saved.status, 200);
|
assert.equal(saved.status, 200);
|
||||||
assert.equal((await saved.json()).revision, 1);
|
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 memberRegistration = await fetch(`${origin}/api/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: "WebTank", password: "tanking-pass" }),
|
||||||
|
});
|
||||||
|
const memberAuth = await memberRegistration.json();
|
||||||
|
const invited = await fetch(`${origin}/api/online-group/invite`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||||
|
body: JSON.stringify({ username: "WebTank" }),
|
||||||
|
});
|
||||||
|
assert.equal(invited.status, 201);
|
||||||
|
const memberGroupState = await (await fetch(`${origin}/api/online-group`, {
|
||||||
|
headers: { Authorization: `Bearer ${memberAuth.token}` },
|
||||||
|
})).json();
|
||||||
|
assert.equal(memberGroupState.invitations.length, 1);
|
||||||
|
const accepted = await fetch(`${origin}/api/online-group/invite/respond`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${memberAuth.token}` },
|
||||||
|
body: JSON.stringify({ inviteId: memberGroupState.invitations[0].id, accept: true }),
|
||||||
|
});
|
||||||
|
assert.equal(accepted.status, 200);
|
||||||
|
assert.equal((await accepted.json()).group.members.length, 2);
|
||||||
|
|
||||||
|
for (const [token, character, role] of [
|
||||||
|
[auth.token, { id: "web-healer", name: "Mendara", classId: "priest", raceId: "human", gender: "female", level: 20 }, "healer"],
|
||||||
|
[memberAuth.token, { id: "web-tank", name: "Bulwarka", classId: "warrior", raceId: "human", gender: "male", level: 20 }, "tank"],
|
||||||
|
]) {
|
||||||
|
const ready = await fetch(`${origin}/api/online-group/member`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ character, role }),
|
||||||
|
});
|
||||||
|
assert.equal(ready.status, 200);
|
||||||
|
}
|
||||||
|
const activityResponse = await fetch(`${origin}/api/online-group/activity`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: "dungeon",
|
||||||
|
contentId: "wailing-caverns",
|
||||||
|
fillWithAi: false,
|
||||||
|
partySize: 2,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(activityResponse.status, 200);
|
||||||
|
const leaderSync = await fetch(`${origin}/api/online-session/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
afterEventId: 0,
|
||||||
|
presence: { position: [1, 0, 2], health: 900, maxHealth: 1_000 },
|
||||||
|
world: { mobs: { boss: { health: 500 } }, mobTransforms: {} },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(leaderSync.status, 200);
|
||||||
|
assert.equal((await leaderSync.json()).authority, true);
|
||||||
|
const memberSync = await fetch(`${origin}/api/online-session/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${memberAuth.token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
afterEventId: 0,
|
||||||
|
presence: { position: [3, 0, 4], health: 800, maxHealth: 1_000 },
|
||||||
|
events: [{
|
||||||
|
clientEventId: "web-tank-hit-1",
|
||||||
|
kind: "damage",
|
||||||
|
sourceActorId: `online-player:${memberAuth.account.id}`,
|
||||||
|
targetActorId: "boss",
|
||||||
|
rawAmount: 50,
|
||||||
|
effectiveAmount: 45,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(memberSync.status, 200);
|
||||||
|
const memberSession = await memberSync.json();
|
||||||
|
assert.equal(memberSession.players.length, 2);
|
||||||
|
assert.equal(memberSession.events[0].targetActorId, "boss");
|
||||||
|
|
||||||
|
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 {
|
} finally {
|
||||||
await new Promise((resolve) => runtime.server.close(resolve));
|
await new Promise((resolve) => runtime.server.close(resolve));
|
||||||
rmSync(directory, { recursive: true, force: true });
|
rmSync(directory, { recursive: true, force: true });
|
||||||
|
|||||||
+57
-14
@@ -1,7 +1,11 @@
|
|||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { BASE_CLASS_IDS, classById } from "./app/characterCatalog";
|
||||||
import { useShellStore } from "./app/shellStore";
|
import { useShellStore } from "./app/shellStore";
|
||||||
|
import { preloadAbilityCatalogsForClasses } from "./game/abilityCatalog";
|
||||||
|
import { useManastormAdminStore } from "./game/manastormAdminStore";
|
||||||
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
||||||
import { LoginScreen } from "./ui/LoginScreen";
|
import { LoginScreen } from "./ui/LoginScreen";
|
||||||
|
import { OnlineGroupCoordinator } from "./app/OnlineGroupCoordinator";
|
||||||
|
|
||||||
const GameRuntime = lazy(() => import("./GameRuntime"));
|
const GameRuntime = lazy(() => import("./GameRuntime"));
|
||||||
const CharacterCreateScreen = lazy(() => import("./ui/CharacterCreateScreen").then((module) => ({ default: module.CharacterCreateScreen })));
|
const CharacterCreateScreen = lazy(() => import("./ui/CharacterCreateScreen").then((module) => ({ default: module.CharacterCreateScreen })));
|
||||||
@@ -10,27 +14,66 @@ const ClassHallScreen = lazy(() => import("./ui/ClassHallScreen").then((module)
|
|||||||
const DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen })));
|
const DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen })));
|
||||||
const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
|
const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
|
||||||
const ManastormSelectScreen = lazy(() => import("./ui/ManastormSelectScreen").then((module) => ({ default: module.ManastormSelectScreen })));
|
const ManastormSelectScreen = lazy(() => import("./ui/ManastormSelectScreen").then((module) => ({ default: module.ManastormSelectScreen })));
|
||||||
|
const GmAdminScreen = lazy(() => import("./ui/GmAdminScreen").then((module) => ({ default: module.GmAdminScreen })));
|
||||||
|
|
||||||
function ShellTransition() {
|
function ShellTransition() {
|
||||||
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
|
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RuntimeCatalogGate({ children }: { readonly children: ReactNode }) {
|
||||||
|
const character = useShellStore((state) => (
|
||||||
|
state.characters.find((candidate) => candidate.id === state.selectedCharacterId) ?? null
|
||||||
|
));
|
||||||
|
const classIds = useMemo(() => {
|
||||||
|
if (!character) return [];
|
||||||
|
const definition = classById(character.classId);
|
||||||
|
if (definition.mode === "conquest") return [...BASE_CLASS_IDS, character.classId];
|
||||||
|
return [character.classId, ...(character.secondaryClassId ? [character.secondaryClassId] : [])];
|
||||||
|
}, [character]);
|
||||||
|
const requestKey = classIds.join(":");
|
||||||
|
const [loadedKey, setLoadedKey] = useState("");
|
||||||
|
const [failedKey, setFailedKey] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!requestKey) return;
|
||||||
|
let active = true;
|
||||||
|
setFailedKey("");
|
||||||
|
void preloadAbilityCatalogsForClasses(classIds).then(() => {
|
||||||
|
if (active) setLoadedKey(requestKey);
|
||||||
|
}).catch(() => {
|
||||||
|
if (active) setFailedKey(requestKey);
|
||||||
|
});
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [classIds, requestKey]);
|
||||||
|
|
||||||
|
if (!requestKey || loadedKey === requestKey) return children;
|
||||||
|
if (failedKey === requestKey) {
|
||||||
|
return <div className="world-transition"><span>!</span><strong>Catalog unavailable</strong><small>Reload the app to retry loading combat data.</small></div>;
|
||||||
|
}
|
||||||
|
return <ShellTransition />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
useForcedThorDisplays();
|
useForcedThorDisplays();
|
||||||
useAuthoritativeDualScreenSync();
|
useAuthoritativeDualScreenSync();
|
||||||
const phase = useShellStore((state) => state.phase);
|
const phase = useShellStore((state) => state.phase);
|
||||||
|
const sessionOwnerId = useShellStore((state) => state.session?.ownerId ?? null);
|
||||||
|
const refreshManastormConfig = useManastormAdminStore((state) => state.refresh);
|
||||||
|
|
||||||
if (phase === "login") return <LoginScreen />;
|
useEffect(() => {
|
||||||
if (phase === "characters") return <Suspense fallback={<ShellTransition />}><CharacterSelectScreen /></Suspense>;
|
void refreshManastormConfig();
|
||||||
if (phase === "create-character") return <Suspense fallback={<ShellTransition />}><CharacterCreateScreen /></Suspense>;
|
}, [refreshManastormConfig, sessionOwnerId]);
|
||||||
if (phase === "main-menu") return <Suspense fallback={<ShellTransition />}><MainMenuScreen /></Suspense>;
|
|
||||||
if (phase === "class-hall") return <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
|
let screen = null;
|
||||||
if (phase === "dungeons") return <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
|
if (phase === "login") screen = <LoginScreen />;
|
||||||
if (phase === "manastorms") return <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
|
else if (phase === "characters") screen = <Suspense fallback={<ShellTransition />}><CharacterSelectScreen /></Suspense>;
|
||||||
if (phase !== "game") return null;
|
else if (phase === "create-character") screen = <Suspense fallback={<ShellTransition />}><CharacterCreateScreen /></Suspense>;
|
||||||
return (
|
else if (phase === "main-menu") screen = <Suspense fallback={<ShellTransition />}><MainMenuScreen /></Suspense>;
|
||||||
<Suspense fallback={<ShellTransition />}>
|
else if (phase === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
|
||||||
<GameRuntime />
|
else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
|
||||||
</Suspense>
|
else if (phase === "manastorms") screen = <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
|
||||||
);
|
else if (phase === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><GmAdminScreen /></Suspense>;
|
||||||
|
else if (phase === "game") screen = <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>;
|
||||||
|
const requiresRuntimeCatalog = !["login", "characters", "create-character"].includes(phase);
|
||||||
|
return <><OnlineGroupCoordinator />{requiresRuntimeCatalog ? <RuntimeCatalogGate>{screen}</RuntimeCatalogGate> : screen}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { GameplayPanels } from "./ui/GameplayPanels";
|
|||||||
import { MapPanel } from "./ui/MapPanel";
|
import { MapPanel } from "./ui/MapPanel";
|
||||||
import { PauseMenu } from "./ui/PauseMenu";
|
import { PauseMenu } from "./ui/PauseMenu";
|
||||||
import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
|
import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
|
||||||
|
import { GmWorldEditor } from "./ui/GmWorldEditor";
|
||||||
|
import { OnlineSessionBridge } from "./game/OnlineSessionBridge";
|
||||||
|
|
||||||
function InputBridge() {
|
function InputBridge() {
|
||||||
const overlay = useGameStore((state) => state.overlay);
|
const overlay = useGameStore((state) => state.overlay);
|
||||||
@@ -50,6 +52,9 @@ function InputBridge() {
|
|||||||
useCombatStore.getState().clearTarget();
|
useCombatStore.getState().clearTarget();
|
||||||
usePartyStore.getState().clearSelection();
|
usePartyStore.getState().clearSelection();
|
||||||
},
|
},
|
||||||
|
groundTargetActive: () => useCombatStore.getState().pendingGroundTarget !== null,
|
||||||
|
confirmGroundTarget: () => { useCombatStore.getState().confirmGroundTarget(); },
|
||||||
|
cancelGroundTarget: () => useCombatStore.getState().cancelGroundTarget(),
|
||||||
gameplayActionsBlocked: () => {
|
gameplayActionsBlocked: () => {
|
||||||
const game = useGameStore.getState();
|
const game = useGameStore.getState();
|
||||||
return game.overlay !== null || game.companionOpen;
|
return game.overlay !== null || game.companionOpen;
|
||||||
@@ -80,6 +85,7 @@ export default function GameRuntime() {
|
|||||||
top={
|
top={
|
||||||
<main className="game-shell">
|
<main className="game-shell">
|
||||||
<InputBridge />
|
<InputBridge />
|
||||||
|
<OnlineSessionBridge />
|
||||||
<CombatBridge />
|
<CombatBridge />
|
||||||
<PartyBridge />
|
<PartyBridge />
|
||||||
<ManastormBridge />
|
<ManastormBridge />
|
||||||
@@ -88,6 +94,7 @@ export default function GameRuntime() {
|
|||||||
<MapPanel />
|
<MapPanel />
|
||||||
<GameplayPanels />
|
<GameplayPanels />
|
||||||
<PauseMenu />
|
<PauseMenu />
|
||||||
|
<GmWorldEditor />
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
bottom={<LocalCompanionPanel />}
|
bottom={<LocalCompanionPanel />}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { selectedCharacter, useShellStore } from "./shellStore";
|
||||||
|
import { useOnlineGroupStore } from "./onlineGroupStore";
|
||||||
|
import {
|
||||||
|
classCanFillPartyRole,
|
||||||
|
defaultPartyRoleForClass,
|
||||||
|
type PartyRole,
|
||||||
|
} from "../game/partyRoles";
|
||||||
|
import { isManastormPartySize } from "../game/manastormProgress";
|
||||||
|
|
||||||
|
const GROUP_POLL_MS = 2_500;
|
||||||
|
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
export function OnlineGroupCoordinator() {
|
||||||
|
const session = useShellStore((state) => state.session);
|
||||||
|
const character = useShellStore(selectedCharacter);
|
||||||
|
const phase = useShellStore((state) => state.phase);
|
||||||
|
const snapshot = useOnlineGroupStore((state) => state.snapshot);
|
||||||
|
const consumedActivityIds = useOnlineGroupStore((state) => state.consumedActivityIds);
|
||||||
|
const refresh = useOnlineGroupStore((state) => state.refresh);
|
||||||
|
const updateMember = useOnlineGroupStore((state) => state.updateMember);
|
||||||
|
const consumeActivity = useOnlineGroupStore((state) => state.consumeActivity);
|
||||||
|
const reset = useOnlineGroupStore((state) => state.reset);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session || session.kind !== "account" || !session.accessToken) {
|
||||||
|
reset();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
void refresh(session);
|
||||||
|
const timer = window.setInterval(() => void refresh(session, true), GROUP_POLL_MS);
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.visibilityState === "visible") void refresh(session, true);
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
};
|
||||||
|
}, [refresh, reset, session?.accessToken, session?.ownerId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const group = snapshot.group;
|
||||||
|
if (!session || !character || !group || group.activity) return;
|
||||||
|
const member = group.members.find((candidate) => candidate.accountId === session.ownerId);
|
||||||
|
if (!member) return;
|
||||||
|
const role: PartyRole = member.role && classCanFillPartyRole(character.classId, member.role)
|
||||||
|
? member.role
|
||||||
|
: defaultPartyRoleForClass(character.classId);
|
||||||
|
const characterChanged = member.character?.id !== character.id
|
||||||
|
|| member.character?.level !== character.level
|
||||||
|
|| member.character?.classId !== character.classId;
|
||||||
|
if (!characterChanged && member.role === role) return;
|
||||||
|
void updateMember(session, character, role);
|
||||||
|
}, [character?.classId, character?.id, character?.level, session?.ownerId, snapshot.group?.id, snapshot.group?.updatedAt, updateMember]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activity = snapshot.group?.activity;
|
||||||
|
if (!session || !activity || phase === "game" || consumedActivityIds.includes(activity.id)) return;
|
||||||
|
if (Date.now() - activity.startedAt > ACTIVITY_JOIN_WINDOW_MS) return;
|
||||||
|
const member = snapshot.group?.members.find((candidate) => candidate.accountId === session.ownerId);
|
||||||
|
if (!member?.character || !member.role) return;
|
||||||
|
consumeActivity(activity.id);
|
||||||
|
void (async () => {
|
||||||
|
if (activity.type === "dungeon") {
|
||||||
|
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
|
||||||
|
installDungeonShellRuntime();
|
||||||
|
useShellStore.getState().enterDungeon(activity.contentId, member.role ?? undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { installManastormShellRuntime } = await import("./manastormShellRuntime");
|
||||||
|
installManastormShellRuntime();
|
||||||
|
const partySize = isManastormPartySize(activity.partySize) ? activity.partySize : 5;
|
||||||
|
useShellStore.getState().enterManastorm(
|
||||||
|
member.role ?? undefined,
|
||||||
|
partySize,
|
||||||
|
activity.startingLevel ?? undefined,
|
||||||
|
);
|
||||||
|
})().catch((error) => {
|
||||||
|
useShellStore.getState().setNotice(error instanceof Error ? error.message : "The group activity could not be opened.");
|
||||||
|
});
|
||||||
|
}, [consumeActivity, consumedActivityIds, phase, session?.ownerId, snapshot.group?.activity?.id]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
listCharacters,
|
listCharacters,
|
||||||
loadCombatPresentationSettings,
|
loadCombatPresentationSettings,
|
||||||
loginLocalAccount,
|
loginLocalAccount,
|
||||||
|
restoreSession,
|
||||||
updateCharacterActionBindings,
|
updateCharacterActionBindings,
|
||||||
updateCharacterEquipment,
|
updateCharacterEquipment,
|
||||||
updateCharacterInventory,
|
updateCharacterInventory,
|
||||||
@@ -86,6 +87,17 @@ describe("local profile repository", () => {
|
|||||||
expect(listCharacters("offline-roster", storage)).toEqual([]);
|
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", () => {
|
it("normalizes legacy profiles and persists progression atomically", () => {
|
||||||
const storage = new MemoryStorage();
|
const storage = new MemoryStorage();
|
||||||
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
|
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
|
||||||
|
|||||||
@@ -374,7 +374,11 @@ export function restoreSession(storage: StorageLike | null = browserSessionStora
|
|||||||
if (session.kind === "account"
|
if (session.kind === "account"
|
||||||
&& typeof session.accessToken !== "string"
|
&& typeof session.accessToken !== "string"
|
||||||
&& !readDatabase().accounts.some((account) => account.id === session.ownerId)) return null;
|
&& !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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useManastormStore } from "../game/manastormStore";
|
||||||
import { manastormModeForCharacterLevel, setManastormCatalog } from "../game/manastorm";
|
import { manastormModeForCharacterLevel, setManastormCatalog } from "../game/manastorm";
|
||||||
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "../game/manastormInstalledCatalog";
|
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "../game/manastormInstalledCatalog";
|
||||||
|
import { effectiveManastormCatalog } from "../game/manastormAdminConfig";
|
||||||
|
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||||
import { registerGeneratedManastormStageAssets } from "../game/manastormAssetRegistry";
|
import { registerGeneratedManastormStageAssets } from "../game/manastormAssetRegistry";
|
||||||
import { installedManastormStageBinding } from "../game/manastormInstalledStages";
|
import { installedManastormStageBinding } from "../game/manastormInstalledStages";
|
||||||
import { registerManastormStageBindingResolver } from "../game/manastormStagePopulation";
|
import { registerManastormStageBindingResolver } from "../game/manastormStagePopulation";
|
||||||
@@ -71,11 +73,28 @@ function enterManastorm(
|
|||||||
|
|
||||||
const partySize = isManastormPartySize(requestedPartySize) ? requestedPartySize : 5;
|
const partySize = isManastormPartySize(requestedPartySize) ? requestedPartySize : 5;
|
||||||
registerGeneratedManastormStageAssets();
|
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(
|
const modeId = manastormModeForCharacterLevel(
|
||||||
activeCharacter.level,
|
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 storedProgress = activeCharacter.manastormProgress ?? createEmptyManastormProgress();
|
||||||
const progress = modeId === 2
|
const progress = modeId === 2
|
||||||
? applyManastormEndgameHandoff(storedProgress)
|
? applyManastormEndgameHandoff(storedProgress)
|
||||||
@@ -106,20 +125,26 @@ function enterManastorm(
|
|||||||
const startingLevel = requestedStartingLevel === 1
|
const startingLevel = requestedStartingLevel === 1
|
||||||
? 1
|
? 1
|
||||||
: availableCheckpoint;
|
: availableCheckpoint;
|
||||||
const encounter = useManastormStore.getState().startRun({
|
let encounter;
|
||||||
partySize,
|
try {
|
||||||
startingLevel,
|
encounter = useManastormStore.getState().startRun({
|
||||||
unlockedThroughLevel: Math.max(startingLevel, savedParty.highestLevel + 1),
|
partySize,
|
||||||
modeId,
|
startingLevel,
|
||||||
activeSpellLoadout: {
|
unlockedThroughLevel: Math.max(startingLevel, savedParty.highestLevel + 1),
|
||||||
available: spellLoadoutModel.options,
|
modeId,
|
||||||
selectedIds: progress.loadout,
|
activeSpellLoadout: {
|
||||||
maxSelections: spellLoadoutModel.maxSelections,
|
available: spellLoadoutModel.options,
|
||||||
provenance: spellLoadoutModel.provenance,
|
selectedIds: progress.loadout,
|
||||||
},
|
maxSelections: spellLoadoutModel.maxSelections,
|
||||||
cacheMeter: modeProgress.cacheMeter,
|
provenance: spellLoadoutModel.provenance,
|
||||||
rewardCaches: modeProgress.rewardCaches,
|
},
|
||||||
});
|
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) {
|
if (!activateManastormStage(encounter, undefined, false).activated) {
|
||||||
useManastormStore.getState().resetRun();
|
useManastormStore.getState().resetRun();
|
||||||
useShellStore.setState({ notice: "The first Manastorm could not be opened." });
|
useShellStore.setState({ notice: "The first Manastorm could not be opened." });
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import {
|
|||||||
type AccountResult,
|
type AccountResult,
|
||||||
} from "./accountRepository";
|
} from "./accountRepository";
|
||||||
import type { CharacterProfile, PlayerSession } from "./types";
|
import type { CharacterProfile, PlayerSession } from "./types";
|
||||||
|
import type { OnlineGroupSnapshot, StartOnlineGroupActivityInput } from "./onlineGroupTypes";
|
||||||
|
import type { OnlineSessionSnapshot, OnlineSessionSyncInput } from "./onlineSessionTypes";
|
||||||
|
|
||||||
const NATIVE_API_ORIGIN = "https://iwanttoheal.phenomrom.com";
|
const NATIVE_API_ORIGIN = "https://iwanttoheal.phenomrom.com";
|
||||||
const SAVE_DEBOUNCE_MS = 500;
|
const SAVE_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
interface AuthResponse {
|
interface AuthResponse {
|
||||||
account: { id: string; username: string };
|
account: { id: string; username: string; roles?: readonly "gm"[] };
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +23,10 @@ interface CloudSaveResponse {
|
|||||||
data: { version?: number; characters?: unknown };
|
data: { version?: number; characters?: unknown };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MeResponse {
|
||||||
|
account: { id: string; username: string; roles?: readonly "gm"[] };
|
||||||
|
}
|
||||||
|
|
||||||
class OnlineApiError extends Error {
|
class OnlineApiError extends Error {
|
||||||
constructor(message: string, readonly status: number) {
|
constructor(message: string, readonly status: number) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -40,7 +46,7 @@ function apiOrigin(): string {
|
|||||||
return Capacitor.isNativePlatform() ? NATIVE_API_ORIGIN : "";
|
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);
|
const headers = new Headers(init.headers);
|
||||||
headers.set("Accept", "application/json");
|
headers.set("Accept", "application/json");
|
||||||
if (init.body !== undefined) headers.set("Content-Type", "application/json");
|
if (init.body !== undefined) headers.set("Content-Type", "application/json");
|
||||||
@@ -63,6 +69,7 @@ function accountResult(response: AuthResponse): AccountResult {
|
|||||||
kind: "account",
|
kind: "account",
|
||||||
ownerId: response.account.id,
|
ownerId: response.account.id,
|
||||||
displayName: response.account.username,
|
displayName: response.account.username,
|
||||||
|
roles: response.account.roles ?? [],
|
||||||
accessToken: response.token,
|
accessToken: response.token,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -70,7 +77,7 @@ function accountResult(response: AuthResponse): AccountResult {
|
|||||||
|
|
||||||
async function authenticate(pathname: string, username: string, password: string): Promise<AccountResult> {
|
async function authenticate(pathname: string, username: string, password: string): Promise<AccountResult> {
|
||||||
try {
|
try {
|
||||||
const response = await requestJson<AuthResponse>(pathname, {
|
const response = await requestOnlineJson<AuthResponse>(pathname, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
@@ -94,7 +101,7 @@ function flushCloudSave(): void {
|
|||||||
pendingCharacters = null;
|
pendingCharacters = null;
|
||||||
if (!session?.accessToken || !characters) return;
|
if (!session?.accessToken || !characters) return;
|
||||||
saveQueue = saveQueue.then(async () => {
|
saveQueue = saveQueue.then(async () => {
|
||||||
await requestJson<CloudSaveResponse>("/api/cloud-save", {
|
await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ data: { version: 1, characters } }),
|
body: JSON.stringify({ data: { version: 1, characters } }),
|
||||||
}, session.accessToken);
|
}, session.accessToken);
|
||||||
@@ -122,7 +129,7 @@ export function activateOnlineSession(session: PlayerSession): void {
|
|||||||
export async function hydrateOnlineSession(session: PlayerSession): Promise<CharacterProfile[]> {
|
export async function hydrateOnlineSession(session: PlayerSession): Promise<CharacterProfile[]> {
|
||||||
if (!session.accessToken) return listCharacters(session.ownerId);
|
if (!session.accessToken) return listCharacters(session.ownerId);
|
||||||
activateOnlineSession(session);
|
activateOnlineSession(session);
|
||||||
const cloud = await requestJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
|
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
|
||||||
suppressUpload = true;
|
suppressUpload = true;
|
||||||
try {
|
try {
|
||||||
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
|
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
|
||||||
@@ -131,6 +138,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> {
|
export async function logoutOnlineSession(session: PlayerSession | null): Promise<void> {
|
||||||
if (saveTimer) {
|
if (saveTimer) {
|
||||||
clearTimeout(saveTimer);
|
clearTimeout(saveTimer);
|
||||||
@@ -139,7 +157,81 @@ export async function logoutOnlineSession(session: PlayerSession | null): Promis
|
|||||||
}
|
}
|
||||||
if (session?.accessToken) {
|
if (session?.accessToken) {
|
||||||
await saveQueue;
|
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;
|
if (activeSession?.ownerId === session?.ownerId) activeSession = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onlineGroupRequest<T>(session: PlayerSession, pathname: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
if (!session.accessToken) throw new OnlineApiError("Online grouping requires a signed-in account.", 401);
|
||||||
|
return requestOnlineJson<T>(pathname, init, session.accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchOnlineGroup(session: PlayerSession): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inviteOnlineGroupPlayer(session: PlayerSession, username: string): Promise<OnlineGroupSnapshot> {
|
||||||
|
const response = await onlineGroupRequest<{ state: OnlineGroupSnapshot }>(session, "/api/online-group/invite", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
});
|
||||||
|
return response.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function respondToOnlineGroupInvite(
|
||||||
|
session: PlayerSession,
|
||||||
|
inviteId: string,
|
||||||
|
accept: boolean,
|
||||||
|
): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group/invite/respond", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ inviteId, accept }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function leaveOnlineGroup(session: PlayerSession): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group/leave", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeOnlineGroupPlayer(session: PlayerSession, accountId: string): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group/remove", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ accountId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateOnlineGroupMember(
|
||||||
|
session: PlayerSession,
|
||||||
|
character: CharacterProfile,
|
||||||
|
role: string,
|
||||||
|
): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group/member", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ character, role }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOnlineGroupActivity(
|
||||||
|
session: PlayerSession,
|
||||||
|
activity: StartOnlineGroupActivityInput,
|
||||||
|
): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group/activity", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(activity),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearOnlineGroupActivity(session: PlayerSession): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group/activity/clear", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function synchronizeOnlineSession(
|
||||||
|
session: PlayerSession,
|
||||||
|
input: OnlineSessionSyncInput,
|
||||||
|
): Promise<OnlineSessionSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-session/sync", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { onlineGroupAiPartySize, useOnlineGroupStore } from "./onlineGroupStore";
|
||||||
|
|
||||||
|
describe("online group AI fill sizing", () => {
|
||||||
|
beforeEach(() => useOnlineGroupStore.getState().reset());
|
||||||
|
|
||||||
|
it("reserves one simulated slot for every grouped human other than the local player", () => {
|
||||||
|
useOnlineGroupStore.setState({
|
||||||
|
snapshot: {
|
||||||
|
invitations: [],
|
||||||
|
group: {
|
||||||
|
id: "group-test",
|
||||||
|
leaderAccountId: "one",
|
||||||
|
activity: null,
|
||||||
|
activityRevision: 0,
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
members: [
|
||||||
|
{ accountId: "one", username: "One", character: null, role: null, joinedAt: 1 },
|
||||||
|
{ accountId: "two", username: "Two", character: null, role: null, joinedAt: 2 },
|
||||||
|
{ accountId: "three", username: "Three", character: null, role: null, joinedAt: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onlineGroupAiPartySize(5)).toBe(3);
|
||||||
|
expect(onlineGroupAiPartySize(3)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps ordinary solo activity sizes unchanged", () => {
|
||||||
|
expect(onlineGroupAiPartySize(5)).toBe(5);
|
||||||
|
expect(onlineGroupAiPartySize(1)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { CharacterProfile, PlayerSession } from "./types";
|
||||||
|
import type { PartyRole } from "../game/partyRoles";
|
||||||
|
import type { PartySize } from "../game/partyRoles";
|
||||||
|
import {
|
||||||
|
clearOnlineGroupActivity,
|
||||||
|
fetchOnlineGroup,
|
||||||
|
inviteOnlineGroupPlayer,
|
||||||
|
leaveOnlineGroup,
|
||||||
|
removeOnlineGroupPlayer,
|
||||||
|
respondToOnlineGroupInvite,
|
||||||
|
startOnlineGroupActivity,
|
||||||
|
updateOnlineGroupMember,
|
||||||
|
} from "./onlineAccountClient";
|
||||||
|
import type { OnlineGroupActivity, OnlineGroupSnapshot, StartOnlineGroupActivityInput } from "./onlineGroupTypes";
|
||||||
|
|
||||||
|
const EMPTY_SNAPSHOT: OnlineGroupSnapshot = Object.freeze({ group: null, invitations: Object.freeze([]) });
|
||||||
|
|
||||||
|
interface OnlineGroupState {
|
||||||
|
snapshot: OnlineGroupSnapshot;
|
||||||
|
loading: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
error: string;
|
||||||
|
fillWithAi: boolean;
|
||||||
|
activeActivity: OnlineGroupActivity | null;
|
||||||
|
consumedActivityIds: readonly string[];
|
||||||
|
refresh: (session: PlayerSession, silent?: boolean) => Promise<void>;
|
||||||
|
invite: (session: PlayerSession, username: string) => Promise<boolean>;
|
||||||
|
respond: (session: PlayerSession, inviteId: string, accept: boolean) => Promise<boolean>;
|
||||||
|
leave: (session: PlayerSession) => Promise<boolean>;
|
||||||
|
remove: (session: PlayerSession, accountId: string) => Promise<boolean>;
|
||||||
|
updateMember: (session: PlayerSession, character: CharacterProfile, role: PartyRole) => Promise<boolean>;
|
||||||
|
startActivity: (session: PlayerSession, input: Omit<StartOnlineGroupActivityInput, "fillWithAi">) => Promise<boolean>;
|
||||||
|
clearActivity: (session: PlayerSession) => Promise<boolean>;
|
||||||
|
setFillWithAi: (fillWithAi: boolean) => void;
|
||||||
|
consumeActivity: (activityId: string) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageFor(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : "The online group could not be updated.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOnlineGroupStore = create<OnlineGroupState>((set, get) => ({
|
||||||
|
snapshot: EMPTY_SNAPSHOT,
|
||||||
|
loading: false,
|
||||||
|
busy: false,
|
||||||
|
error: "",
|
||||||
|
fillWithAi: true,
|
||||||
|
activeActivity: null,
|
||||||
|
consumedActivityIds: [],
|
||||||
|
|
||||||
|
refresh: async (session, silent = false) => {
|
||||||
|
if (session.kind !== "account" || !session.accessToken) {
|
||||||
|
set({ snapshot: EMPTY_SNAPSHOT, loading: false, busy: false, error: "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!silent) set({ loading: true });
|
||||||
|
try {
|
||||||
|
const snapshot = await fetchOnlineGroup(session);
|
||||||
|
set({ snapshot, loading: false, error: "" });
|
||||||
|
} catch (error) {
|
||||||
|
set({ loading: false, error: messageFor(error) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
invite: async (session, username) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await inviteOnlineGroupPlayer(session, username);
|
||||||
|
set({ snapshot, busy: false, activeActivity: null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
respond: async (session, inviteId, accept) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await respondToOnlineGroupInvite(session, inviteId, accept);
|
||||||
|
set({ snapshot, busy: false, activeActivity: null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
leave: async (session) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await leaveOnlineGroup(session);
|
||||||
|
set({ snapshot, busy: false, activeActivity: null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: async (session, accountId) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await removeOnlineGroupPlayer(session, accountId);
|
||||||
|
set({ snapshot, busy: false, activeActivity: null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMember: async (session, character, role) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await updateOnlineGroupMember(session, character, role);
|
||||||
|
set({ snapshot, busy: false, activeActivity: null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startActivity: async (session, input) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await startOnlineGroupActivity(session, { ...input, fillWithAi: get().fillWithAi });
|
||||||
|
set({ snapshot, busy: false });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearActivity: async (session) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await clearOnlineGroupActivity(session);
|
||||||
|
set({ snapshot, busy: false, activeActivity: null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setFillWithAi: (fillWithAi) => set({ fillWithAi }),
|
||||||
|
consumeActivity: (activityId) => set((state) => {
|
||||||
|
if (state.consumedActivityIds.includes(activityId)) return state;
|
||||||
|
return {
|
||||||
|
activeActivity: state.snapshot.group?.activity?.id === activityId
|
||||||
|
? state.snapshot.group.activity
|
||||||
|
: state.activeActivity,
|
||||||
|
consumedActivityIds: [...state.consumedActivityIds.slice(-7), activityId],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
reset: () => set({
|
||||||
|
snapshot: EMPTY_SNAPSHOT,
|
||||||
|
loading: false,
|
||||||
|
busy: false,
|
||||||
|
error: "",
|
||||||
|
activeActivity: null,
|
||||||
|
consumedActivityIds: [],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function onlineGroupHumanCount(): number {
|
||||||
|
return useOnlineGroupStore.getState().snapshot.group?.members.length ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlineGroupAiPartySize(activityPartySize: number): PartySize {
|
||||||
|
return Math.max(1, Math.min(5, activityPartySize - Math.max(0, onlineGroupHumanCount() - 1))) as PartySize;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { PartyRole } from "../game/partyRoles";
|
||||||
|
import type { CharacterAppearance } from "./characterCatalog";
|
||||||
|
|
||||||
|
export interface OnlineGroupCharacter {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly classId: string;
|
||||||
|
readonly categoryId: string;
|
||||||
|
readonly raceId: string;
|
||||||
|
readonly gender: "female" | "male";
|
||||||
|
readonly level: number;
|
||||||
|
readonly appearance?: CharacterAppearance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineGroupMember {
|
||||||
|
readonly accountId: string;
|
||||||
|
readonly username: string;
|
||||||
|
readonly character: OnlineGroupCharacter | null;
|
||||||
|
readonly role: PartyRole | null;
|
||||||
|
readonly joinedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineGroupActivity {
|
||||||
|
readonly id: string;
|
||||||
|
readonly type: "dungeon" | "manastorm";
|
||||||
|
readonly contentId: string;
|
||||||
|
readonly fillWithAi: boolean;
|
||||||
|
readonly partySize: number;
|
||||||
|
readonly startingLevel: number | null;
|
||||||
|
readonly startedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineGroup {
|
||||||
|
readonly id: string;
|
||||||
|
readonly leaderAccountId: string;
|
||||||
|
readonly members: readonly OnlineGroupMember[];
|
||||||
|
readonly activity: OnlineGroupActivity | null;
|
||||||
|
readonly activityRevision: number;
|
||||||
|
readonly createdAt: number;
|
||||||
|
readonly updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineGroupInvitation {
|
||||||
|
readonly id: string;
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly inviterAccountId: string;
|
||||||
|
readonly inviterUsername: string;
|
||||||
|
readonly createdAt: number;
|
||||||
|
readonly expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineGroupSnapshot {
|
||||||
|
readonly group: OnlineGroup | null;
|
||||||
|
readonly invitations: readonly OnlineGroupInvitation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartOnlineGroupActivityInput {
|
||||||
|
readonly type: OnlineGroupActivity["type"];
|
||||||
|
readonly contentId: string;
|
||||||
|
readonly fillWithAi: boolean;
|
||||||
|
readonly partySize: number;
|
||||||
|
readonly startingLevel?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { OnlineSessionSnapshot } from "./onlineSessionTypes";
|
||||||
|
|
||||||
|
interface OnlineSessionState {
|
||||||
|
snapshot: OnlineSessionSnapshot | null;
|
||||||
|
connected: boolean;
|
||||||
|
synchronizing: boolean;
|
||||||
|
error: string;
|
||||||
|
setSynchronizing: (synchronizing: boolean) => void;
|
||||||
|
accept: (snapshot: OnlineSessionSnapshot) => void;
|
||||||
|
fail: (message: string) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOnlineSessionStore = create<OnlineSessionState>((set) => ({
|
||||||
|
snapshot: null,
|
||||||
|
connected: false,
|
||||||
|
synchronizing: false,
|
||||||
|
error: "",
|
||||||
|
setSynchronizing: (synchronizing) => set({ synchronizing }),
|
||||||
|
accept: (snapshot) => set({ snapshot, connected: true, synchronizing: false, error: "" }),
|
||||||
|
fail: (error) => set({ connected: false, synchronizing: false, error }),
|
||||||
|
reset: () => set({ snapshot: null, connected: false, synchronizing: false, error: "" }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
|
||||||
|
import type { ActiveCast, MobCombatState } from "../game/combatStore";
|
||||||
|
import type { DamageSchool } from "../game/combatAuras";
|
||||||
|
import type { InventoryItem } from "../game/lootTypes";
|
||||||
|
import type { PartyMember } from "../game/partyStore";
|
||||||
|
import type { OnlineGroupActivity, OnlineGroupCharacter } from "./onlineGroupTypes";
|
||||||
|
import type { PartyRole } from "../game/partyRoles";
|
||||||
|
|
||||||
|
export type OnlineSessionVector = readonly [number, number, number];
|
||||||
|
|
||||||
|
export interface OnlineSessionPlayer {
|
||||||
|
readonly accountId: string;
|
||||||
|
readonly username: string;
|
||||||
|
readonly character: OnlineGroupCharacter | null;
|
||||||
|
readonly role: PartyRole | null;
|
||||||
|
readonly position: OnlineSessionVector;
|
||||||
|
readonly yaw: number;
|
||||||
|
readonly grounded: boolean;
|
||||||
|
readonly health: number;
|
||||||
|
readonly maxHealth: number;
|
||||||
|
readonly resource: number;
|
||||||
|
readonly maxResource: number;
|
||||||
|
readonly resourceName: string;
|
||||||
|
readonly selectedTargetId: string | null;
|
||||||
|
readonly activeCast: ActiveCast | null;
|
||||||
|
readonly animationEvent: CharacterCombatAnimationEvent | null;
|
||||||
|
readonly equipment: readonly InventoryItem[];
|
||||||
|
readonly updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineMobTransform {
|
||||||
|
readonly position: OnlineSessionVector;
|
||||||
|
readonly yaw: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineSharedWorld {
|
||||||
|
readonly mobs: Readonly<Record<string, MobCombatState>>;
|
||||||
|
readonly mobTransforms: Readonly<Record<string, OnlineMobTransform>>;
|
||||||
|
readonly partyMembers: readonly PartyMember[];
|
||||||
|
readonly partyPositions: Readonly<Record<string, OnlineSessionVector>>;
|
||||||
|
readonly partyRuntime: {
|
||||||
|
readonly command: string;
|
||||||
|
readonly activeMobId: string | null;
|
||||||
|
readonly objectiveBossId: string | null;
|
||||||
|
readonly memberTargetIds: Readonly<Record<string, string>>;
|
||||||
|
};
|
||||||
|
readonly wailing: Readonly<Record<string, unknown>> | null;
|
||||||
|
readonly manastorm: Readonly<Record<string, unknown>> | null;
|
||||||
|
readonly publishedAt?: number;
|
||||||
|
readonly publishedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OnlineRelayEventKind = "damage" | "healing" | "resurrection" | "loot" | "portal";
|
||||||
|
|
||||||
|
export interface OnlineRelayEventInput {
|
||||||
|
readonly clientEventId: string;
|
||||||
|
readonly kind: OnlineRelayEventKind;
|
||||||
|
readonly sourceActorId: string;
|
||||||
|
readonly targetActorId: string;
|
||||||
|
readonly abilityId?: string | null;
|
||||||
|
readonly school?: DamageSchool | null;
|
||||||
|
readonly rawAmount?: number;
|
||||||
|
readonly effectiveAmount?: number;
|
||||||
|
readonly critical?: boolean;
|
||||||
|
readonly occurredAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineRelayEvent extends OnlineRelayEventInput {
|
||||||
|
readonly id: number;
|
||||||
|
readonly sourceAccountId: string;
|
||||||
|
readonly createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineSessionSnapshot {
|
||||||
|
readonly activity: OnlineGroupActivity;
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly leaderAccountId: string;
|
||||||
|
readonly localAccountId: string;
|
||||||
|
readonly authority: boolean;
|
||||||
|
readonly revision: number;
|
||||||
|
readonly serverTime: number;
|
||||||
|
readonly players: readonly OnlineSessionPlayer[];
|
||||||
|
readonly world: OnlineSharedWorld | null;
|
||||||
|
readonly events: readonly OnlineRelayEvent[];
|
||||||
|
readonly latestEventId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlinePresenceInput {
|
||||||
|
readonly position: OnlineSessionVector;
|
||||||
|
readonly yaw: number;
|
||||||
|
readonly grounded: boolean;
|
||||||
|
readonly health: number;
|
||||||
|
readonly maxHealth: number;
|
||||||
|
readonly resource: number;
|
||||||
|
readonly maxResource: number;
|
||||||
|
readonly resourceName: string;
|
||||||
|
readonly selectedTargetId: string | null;
|
||||||
|
readonly activeCast: ActiveCast | null;
|
||||||
|
readonly animationEvent: CharacterCombatAnimationEvent | null;
|
||||||
|
readonly equipment: readonly InventoryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineSessionSyncInput {
|
||||||
|
readonly afterEventId: number;
|
||||||
|
readonly presence: OnlinePresenceInput;
|
||||||
|
readonly world?: OnlineSharedWorld;
|
||||||
|
readonly events?: readonly OnlineRelayEventInput[];
|
||||||
|
}
|
||||||
@@ -14,6 +14,12 @@ import {
|
|||||||
mergeManastormUnlockedContent,
|
mergeManastormUnlockedContent,
|
||||||
setManastormLoadout,
|
setManastormLoadout,
|
||||||
} from "../game/manastormProgress";
|
} from "../game/manastormProgress";
|
||||||
|
import {
|
||||||
|
DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||||
|
UNIFIED_MANASTORM_BOSS_CATALOG,
|
||||||
|
resolveBossAdminEntry,
|
||||||
|
} from "../game/manastormAdminConfig";
|
||||||
|
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||||
|
|
||||||
installDungeonShellRuntime();
|
installDungeonShellRuntime();
|
||||||
installManastormShellRuntime();
|
installManastormShellRuntime();
|
||||||
@@ -37,6 +43,29 @@ const character = {
|
|||||||
lastPlayedAt: 1,
|
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 {
|
function prepareSelectedCharacter(): void {
|
||||||
useShellStore.setState({
|
useShellStore.setState({
|
||||||
phase: "characters",
|
phase: "characters",
|
||||||
@@ -61,6 +90,7 @@ describe("shell store", () => {
|
|||||||
});
|
});
|
||||||
useGameStore.getState().resetAtEntrance();
|
useGameStore.getState().resetAtEntrance();
|
||||||
useManastormStore.getState().resetRun();
|
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", () => {
|
it("does not open the character hub or a dungeon without a selected character", () => {
|
||||||
|
|||||||
+22
-1
@@ -51,7 +51,9 @@ import {
|
|||||||
activateOnlineSession,
|
activateOnlineSession,
|
||||||
hydrateOnlineSession,
|
hydrateOnlineSession,
|
||||||
logoutOnlineSession,
|
logoutOnlineSession,
|
||||||
|
refreshOnlineSession,
|
||||||
} from "./onlineAccountClient";
|
} from "./onlineAccountClient";
|
||||||
|
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
||||||
|
|
||||||
export interface ShellState {
|
export interface ShellState {
|
||||||
phase: ShellPhase;
|
phase: ShellPhase;
|
||||||
@@ -77,6 +79,7 @@ export interface ShellState {
|
|||||||
openMainMenu: () => boolean;
|
openMainMenu: () => boolean;
|
||||||
openDungeons: () => boolean;
|
openDungeons: () => boolean;
|
||||||
openManastorms: () => boolean;
|
openManastorms: () => boolean;
|
||||||
|
openGmAdmin: () => boolean;
|
||||||
openClassHall: () => boolean;
|
openClassHall: () => boolean;
|
||||||
chooseRomSecondaryClass: (classId: RomClassId) => boolean;
|
chooseRomSecondaryClass: (classId: RomClassId) => boolean;
|
||||||
swapSelectedRomClasses: () => boolean;
|
swapSelectedRomClasses: () => boolean;
|
||||||
@@ -294,6 +297,19 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
|||||||
set({ phase: "manastorms", activeCharacter: null, notice: "" });
|
set({ phase: "manastorms", activeCharacter: null, notice: "" });
|
||||||
return true;
|
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: () => {
|
openClassHall: () => {
|
||||||
const { session, selectedCharacterId, characters } = get();
|
const { session, selectedCharacterId, characters } = get();
|
||||||
const character = characters.find((entry) => entry.id === selectedCharacterId);
|
const character = characters.find((entry) => entry.id === selectedCharacterId);
|
||||||
@@ -500,10 +516,15 @@ export function selectedCharacter(state: ShellState): CharacterProfile | null {
|
|||||||
|
|
||||||
if (restored.session?.accessToken) {
|
if (restored.session?.accessToken) {
|
||||||
activateOnlineSession(restored.session);
|
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;
|
const current = useShellStore.getState().session;
|
||||||
if (current?.ownerId !== restored.session?.ownerId) return;
|
if (current?.ownerId !== restored.session?.ownerId) return;
|
||||||
|
saveSession(refreshedSession);
|
||||||
useShellStore.setState({
|
useShellStore.setState({
|
||||||
|
session: refreshedSession,
|
||||||
characters,
|
characters,
|
||||||
selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId)
|
selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId)
|
||||||
? 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 { GameplaySettings } from "../game/combatStore";
|
||||||
import type { ManastormProgress } from "../game/manastormProgress";
|
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 SessionKind = "account" | "offline";
|
||||||
|
export type AccountRole = "gm";
|
||||||
|
|
||||||
export interface PlayerSession {
|
export interface PlayerSession {
|
||||||
kind: SessionKind;
|
kind: SessionKind;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
roles?: readonly AccountRole[];
|
||||||
/** Present for server-backed accounts; kept in session storage only. */
|
/** Present for server-backed accounts; kept in session storage only. */
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-17
@@ -1,13 +1,15 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useShellStore } from "../app/shellStore";
|
import { useShellStore } from "../app/shellStore";
|
||||||
import { useCombatStore } from "./combatStore";
|
import { useCombatStore } from "./combatStore";
|
||||||
import { respawnDefeatedPlayer } from "./deathRespawn";
|
|
||||||
import { advanceDungeonPartyCombat } from "./partyRuntime";
|
import { advanceDungeonPartyCombat } from "./partyRuntime";
|
||||||
import { useGameStore } from "./store";
|
import { useGameStore } from "./store";
|
||||||
import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout";
|
import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout";
|
||||||
import { useManastormStore } from "./manastormStore";
|
import { useManastormStore } from "./manastormStore";
|
||||||
import { resolveManastormActorDown } from "./manastormSession";
|
|
||||||
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
||||||
|
import {
|
||||||
|
onlineSessionIsActive,
|
||||||
|
onlineSessionOwnsWorldSimulation,
|
||||||
|
} from "./onlineSessionRuntime";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connects the pure combat runtime to the active character and persistent
|
* Connects the pure combat runtime to the active character and persistent
|
||||||
@@ -23,6 +25,7 @@ export function CombatBridge() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!character) return;
|
if (!character) return;
|
||||||
useCombatStore.getState().initializeCharacter(character);
|
useCombatStore.getState().initializeCharacter(character);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
if (gameMode === "manastorm") {
|
if (gameMode === "manastorm") {
|
||||||
activateManastormActiveSpellLoadout(
|
activateManastormActiveSpellLoadout(
|
||||||
useManastormStore.getState().activeSpellLoadout,
|
useManastormStore.getState().activeSpellLoadout,
|
||||||
@@ -110,32 +113,30 @@ export function CombatBridge() {
|
|||||||
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
|
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
|
||||||
previous = now;
|
previous = now;
|
||||||
const game = useGameStore.getState();
|
const game = useGameStore.getState();
|
||||||
if (!game.paused && !game.companionOpen) {
|
const sharedOnline = onlineSessionIsActive();
|
||||||
|
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
|
||||||
const epochNow = Date.now();
|
const epochNow = Date.now();
|
||||||
const combat = useCombatStore.getState();
|
const combat = useCombatStore.getState();
|
||||||
combat.setPlayerPosition(game.playerPosition);
|
combat.setPlayerPosition(game.playerPosition);
|
||||||
combat.tick(delta, epochNow);
|
combat.tick(delta, epochNow);
|
||||||
if (game.gameMode === "dungeon") {
|
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
|
||||||
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
|
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
|
||||||
}
|
}
|
||||||
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
|
if (onlineSessionOwnsWorldSimulation()) {
|
||||||
advanceDungeonPartyCombat(epochNow);
|
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
|
||||||
if (game.gameMode === "dungeon" && game.activeDungeonId === "wailing-caverns") {
|
advanceDungeonPartyCombat(epochNow);
|
||||||
|
}
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
if (
|
||||||
|
onlineSessionOwnsWorldSimulation()
|
||||||
|
&& game.gameMode === "dungeon"
|
||||||
|
&& game.activeDungeonId === "wailing-caverns"
|
||||||
|
) {
|
||||||
useWailingEncounterStore.getState().advance(
|
useWailingEncounterStore.getState().advance(
|
||||||
useCombatStore.getState().mobs,
|
useCombatStore.getState().mobs,
|
||||||
epochNow,
|
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);
|
}, 100);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import { getManastormCatalog, manastormEncounterById } from "./manastorm";
|
|||||||
import { synchronizeManastormProgress } from "./manastormProgressSync";
|
import { synchronizeManastormProgress } from "./manastormProgressSync";
|
||||||
import { synchronizeManastormAffixes } from "./manastormAffixRuntime";
|
import { synchronizeManastormAffixes } from "./manastormAffixRuntime";
|
||||||
import { preloadManastormStage } from "./manastormStageLoader";
|
import { preloadManastormStage } from "./manastormStageLoader";
|
||||||
|
import { onlineSessionIsActive, onlineSessionOwnsWorldSimulation } from "./onlineSessionRuntime";
|
||||||
|
|
||||||
/** Connects staged combat, party sizing, and shared resurrections to Manastorm state. */
|
/** Connects staged combat, party sizing, and shared resurrections to Manastorm state. */
|
||||||
export function ManastormBridge() {
|
export function ManastormBridge() {
|
||||||
const gameMode = useGameStore((state) => state.gameMode);
|
const gameMode = useGameStore((state) => state.gameMode);
|
||||||
|
const gmTestMode = useGameStore((state) => state.gmTestMode);
|
||||||
const status = useManastormStore((state) => state.status);
|
const status = useManastormStore((state) => state.status);
|
||||||
const partySize = useManastormStore((state) => state.partySize);
|
const partySize = useManastormStore((state) => state.partySize);
|
||||||
const encounter = useManastormStore((state) => state.currentEncounter);
|
const encounter = useManastormStore((state) => state.currentEncounter);
|
||||||
@@ -29,11 +31,12 @@ export function ManastormBridge() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameMode !== "manastorm") return;
|
if (gameMode !== "manastorm") return;
|
||||||
|
if (!onlineSessionOwnsWorldSimulation()) return;
|
||||||
synchronizeManastormPartySize();
|
synchronizeManastormPartySize();
|
||||||
}, [gameMode, partyMemberCount, partySize]);
|
}, [gameMode, partyMemberCount, partySize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameMode !== "manastorm" || status !== "entering" || !encounter) return;
|
if (gameMode !== "manastorm" || status !== "entering" || !encounter || !onlineSessionOwnsWorldSimulation()) return;
|
||||||
useManastormStore.getState().enterStage();
|
useManastormStore.getState().enterStage();
|
||||||
}, [encounter, gameMode, status]);
|
}, [encounter, gameMode, status]);
|
||||||
|
|
||||||
@@ -41,12 +44,14 @@ export function ManastormBridge() {
|
|||||||
if (
|
if (
|
||||||
gameMode !== "manastorm"
|
gameMode !== "manastorm"
|
||||||
|| !encounter
|
|| !encounter
|
||||||
|
|| !onlineSessionOwnsWorldSimulation()
|
||||||
) return;
|
) return;
|
||||||
synchronizeManastormCombat();
|
synchronizeManastormCombat();
|
||||||
}, [encounter, gameMode, mobs, status]);
|
}, [encounter, gameMode, mobs, status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameMode !== "manastorm") return;
|
if (gameMode !== "manastorm") return;
|
||||||
|
if (!onlineSessionOwnsWorldSimulation()) return;
|
||||||
const timer = window.setInterval(() => synchronizeManastormAffixes(Date.now()), 250);
|
const timer = window.setInterval(() => synchronizeManastormAffixes(Date.now()), 250);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [gameMode]);
|
}, [gameMode]);
|
||||||
@@ -70,6 +75,7 @@ export function ManastormBridge() {
|
|||||||
|| previous.health <= 0
|
|| previous.health <= 0
|
||||||
|| useGameStore.getState().gameMode !== "manastorm"
|
|| useGameStore.getState().gameMode !== "manastorm"
|
||||||
|| resolvingDefeatRef.current
|
|| resolvingDefeatRef.current
|
||||||
|
|| onlineSessionIsActive()
|
||||||
) return;
|
) return;
|
||||||
resolvingDefeatRef.current = true;
|
resolvingDefeatRef.current = true;
|
||||||
try {
|
try {
|
||||||
@@ -81,7 +87,11 @@ export function ManastormBridge() {
|
|||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
useEffect(() => usePartyStore.subscribe((state, previous) => {
|
useEffect(() => usePartyStore.subscribe((state, previous) => {
|
||||||
if (useGameStore.getState().gameMode !== "manastorm" || resolvingDefeatRef.current) return;
|
if (
|
||||||
|
useGameStore.getState().gameMode !== "manastorm"
|
||||||
|
|| resolvingDefeatRef.current
|
||||||
|
|| !onlineSessionOwnsWorldSimulation()
|
||||||
|
) return;
|
||||||
const previousById = new Map(previous.members.map((member) => [member.id, member]));
|
const previousById = new Map(previous.members.map((member) => [member.id, member]));
|
||||||
const downedMemberIds = state.members.filter((member) => (
|
const downedMemberIds = state.members.filter((member) => (
|
||||||
member.health <= 0 && (previousById.get(member.id)?.health ?? 0) > 0
|
member.health <= 0 && (previousById.get(member.id)?.health ?? 0) > 0
|
||||||
@@ -103,7 +113,7 @@ export function ManastormBridge() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!characterId) return;
|
if (!characterId) return;
|
||||||
return useManastormStore.subscribe((state, previous) => {
|
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;
|
const character = useShellStore.getState().activeCharacter;
|
||||||
if (!character || character.id !== characterId) return;
|
if (!character || character.id !== characterId) return;
|
||||||
const result = synchronizeManastormProgress(
|
const result = synchronizeManastormProgress(
|
||||||
@@ -116,7 +126,7 @@ export function ManastormBridge() {
|
|||||||
useShellStore.getState().saveActiveManastormProgress(result.progress);
|
useShellStore.getState().saveActiveManastormProgress(result.progress);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [characterId]);
|
}, [characterId, gmTestMode]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { synchronizeOnlineSession } from "../app/onlineAccountClient";
|
||||||
|
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||||
|
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||||
|
import type {
|
||||||
|
OnlineRelayEvent,
|
||||||
|
OnlineRelayEventInput,
|
||||||
|
OnlineSessionSnapshot,
|
||||||
|
OnlineSharedWorld,
|
||||||
|
} from "../app/onlineSessionTypes";
|
||||||
|
import { useShellStore } from "../app/shellStore";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
|
||||||
|
import { getMobRuntimeTransform } from "./mobRuntimeRegistry";
|
||||||
|
import {
|
||||||
|
configureOnlineSessionRuntime,
|
||||||
|
drainOnlineInteractions,
|
||||||
|
isOnlinePlayerActorId,
|
||||||
|
onlineAccountIdFromActor,
|
||||||
|
onlinePlayerActorId,
|
||||||
|
} from "./onlineSessionRuntime";
|
||||||
|
import {
|
||||||
|
getPartyRuntimePosition,
|
||||||
|
removePartyRuntimePosition,
|
||||||
|
updatePartyRuntimePosition,
|
||||||
|
} from "./partyRuntimeRegistry";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
|
import { useManastormStore } from "./manastormStore";
|
||||||
|
import { advanceManastormSession } from "./manastormSession";
|
||||||
|
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
||||||
|
|
||||||
|
const SYNC_INTERVAL_MS = 100;
|
||||||
|
const WORLD_INTERVAL_MS = 200;
|
||||||
|
|
||||||
|
function serializableState<T extends object>(state: T): Readonly<Record<string, unknown>> {
|
||||||
|
return JSON.parse(JSON.stringify(state)) as Readonly<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sharedWorld(): OnlineSharedWorld {
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
const party = usePartyStore.getState();
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
const mobTransforms = Object.fromEntries(
|
||||||
|
Object.keys(combat.mobs).flatMap((id) => {
|
||||||
|
const transform = getMobRuntimeTransform(id);
|
||||||
|
return transform ? [[id, transform]] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const partyPositions = Object.fromEntries(
|
||||||
|
party.members.flatMap((member) => {
|
||||||
|
const position = getPartyRuntimePosition(member.id);
|
||||||
|
return position ? [[member.id, position]] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
mobs: combat.mobs,
|
||||||
|
mobTransforms,
|
||||||
|
partyMembers: party.members,
|
||||||
|
partyPositions,
|
||||||
|
partyRuntime: {
|
||||||
|
command: party.command,
|
||||||
|
activeMobId: party.activeMobId,
|
||||||
|
objectiveBossId: party.objectiveBossId,
|
||||||
|
memberTargetIds: party.memberTargetIds,
|
||||||
|
},
|
||||||
|
wailing: game.gameMode === "dungeon"
|
||||||
|
? serializableState(useWailingEncounterStore.getState())
|
||||||
|
: null,
|
||||||
|
manastorm: game.gameMode === "manastorm"
|
||||||
|
? serializableState(useManastormStore.getState())
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySharedWorld(world: OnlineSharedWorld): void {
|
||||||
|
useCombatStore.setState({ mobs: world.mobs });
|
||||||
|
usePartyStore.setState({
|
||||||
|
members: world.partyMembers,
|
||||||
|
command: world.partyRuntime.command as ReturnType<typeof usePartyStore.getState>["command"],
|
||||||
|
activeMobId: world.partyRuntime.activeMobId,
|
||||||
|
objectiveBossId: world.partyRuntime.objectiveBossId,
|
||||||
|
memberTargetIds: world.partyRuntime.memberTargetIds,
|
||||||
|
});
|
||||||
|
for (const [id, position] of Object.entries(world.partyPositions)) {
|
||||||
|
updatePartyRuntimePosition(id, position[0], position[1], position[2]);
|
||||||
|
}
|
||||||
|
if (world.wailing) useWailingEncounterStore.setState(world.wailing as never);
|
||||||
|
if (world.manastorm) useManastormStore.setState(world.manastorm as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventForRelay(
|
||||||
|
event: ReturnType<typeof useCombatStore.getState>["combatEvents"][number],
|
||||||
|
localAccountId: string,
|
||||||
|
authority: boolean,
|
||||||
|
): OnlineRelayEventInput | null {
|
||||||
|
if (!["damage", "healing", "resurrection"].includes(event.kind)) return null;
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
const playerOwnedSummon = combat.summons.some((summon) => (
|
||||||
|
summon.id === event.sourceActorId && summon.ownerActorId === PLAYER_AGGRO_ID
|
||||||
|
));
|
||||||
|
const sourceIsLocal = event.sourceActorId === PLAYER_AGGRO_ID || playerOwnedSummon;
|
||||||
|
const targetsRemotePlayer = isOnlinePlayerActorId(event.targetActorId);
|
||||||
|
if (!sourceIsLocal && !(authority && targetsRemotePlayer)) return null;
|
||||||
|
const localActorId = onlinePlayerActorId(localAccountId);
|
||||||
|
const targetActorId = event.targetActorId === PLAYER_AGGRO_ID
|
||||||
|
? localActorId
|
||||||
|
: event.targetActorId;
|
||||||
|
if (sourceIsLocal && targetActorId === localActorId) return null;
|
||||||
|
return {
|
||||||
|
clientEventId: `combat:${localAccountId}:${event.occurredAt}:${event.id}:${event.kind}`,
|
||||||
|
kind: event.kind as "damage" | "healing" | "resurrection",
|
||||||
|
sourceActorId: sourceIsLocal ? localActorId : event.sourceActorId,
|
||||||
|
targetActorId,
|
||||||
|
abilityId: event.abilityId,
|
||||||
|
school: event.school,
|
||||||
|
rawAmount: event.rawAmount,
|
||||||
|
effectiveAmount: event.effectiveAmount,
|
||||||
|
critical: event.critical,
|
||||||
|
occurredAt: event.occurredAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRelayEvent(
|
||||||
|
event: OnlineRelayEvent,
|
||||||
|
snapshot: OnlineSessionSnapshot,
|
||||||
|
applyingRef: React.MutableRefObject<boolean>,
|
||||||
|
): void {
|
||||||
|
if (event.sourceAccountId === snapshot.localAccountId) return;
|
||||||
|
const localActorId = onlinePlayerActorId(snapshot.localAccountId);
|
||||||
|
applyingRef.current = true;
|
||||||
|
try {
|
||||||
|
if (event.kind === "portal" && snapshot.authority) {
|
||||||
|
advanceManastormSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.kind === "loot" && snapshot.authority && event.targetActorId) {
|
||||||
|
useCombatStore.getState().lootMob(event.targetActorId, event.occurredAt || Date.now());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.targetActorId === localActorId) {
|
||||||
|
if (event.kind === "damage") {
|
||||||
|
const sourceLevel = useCombatStore.getState().mobs[event.sourceActorId]?.level;
|
||||||
|
useCombatStore.getState().damagePlayer(
|
||||||
|
event.rawAmount ?? event.effectiveAmount ?? 0,
|
||||||
|
event.school ?? "physical",
|
||||||
|
sourceLevel,
|
||||||
|
);
|
||||||
|
} else if (event.kind === "healing") {
|
||||||
|
useCombatStore.getState().healActor(PLAYER_AGGRO_ID, event.effectiveAmount ?? 0, {
|
||||||
|
sourceActorId: event.sourceActorId,
|
||||||
|
abilityId: event.abilityId ?? undefined,
|
||||||
|
school: event.school ?? undefined,
|
||||||
|
now: event.occurredAt,
|
||||||
|
amountAlreadyModified: true,
|
||||||
|
});
|
||||||
|
} else if (event.kind === "resurrection") {
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
combat.revivePlayer((event.effectiveAmount ?? 1) / Math.max(1, combat.maxHealth));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!snapshot.authority) return;
|
||||||
|
const partyMember = usePartyStore.getState().members.find((member) => member.id === event.targetActorId);
|
||||||
|
if (partyMember) {
|
||||||
|
if (event.kind === "healing") {
|
||||||
|
useCombatStore.getState().healActor(event.targetActorId, event.effectiveAmount ?? 0, {
|
||||||
|
sourceActorId: event.sourceActorId,
|
||||||
|
abilityId: event.abilityId ?? undefined,
|
||||||
|
school: event.school ?? undefined,
|
||||||
|
now: event.occurredAt,
|
||||||
|
amountAlreadyModified: true,
|
||||||
|
});
|
||||||
|
} else if (event.kind === "resurrection") {
|
||||||
|
useCombatStore.getState().reviveActor(
|
||||||
|
event.targetActorId,
|
||||||
|
(event.effectiveAmount ?? 1) / Math.max(1, partyMember.maxHealth),
|
||||||
|
event.sourceActorId,
|
||||||
|
event.abilityId ?? undefined,
|
||||||
|
event.occurredAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mob = useCombatStore.getState().mobs[event.targetActorId];
|
||||||
|
if (mob && event.kind === "damage") {
|
||||||
|
const sourceMember = snapshot.players.find((player) => player.accountId === event.sourceAccountId);
|
||||||
|
useCombatStore.getState().damageMob(
|
||||||
|
mob.id,
|
||||||
|
event.rawAmount ?? event.effectiveAmount ?? 0,
|
||||||
|
event.occurredAt,
|
||||||
|
{
|
||||||
|
actorId: event.sourceActorId,
|
||||||
|
role: sourceMember?.role ?? "damage",
|
||||||
|
abilityId: event.abilityId ?? undefined,
|
||||||
|
},
|
||||||
|
event.school ?? "physical",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
applyingRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnlineSessionBridge() {
|
||||||
|
const session = useShellStore((state) => state.session);
|
||||||
|
const activeActivity = useOnlineGroupStore((state) => state.activeActivity);
|
||||||
|
const group = useOnlineGroupStore((state) => state.snapshot.group);
|
||||||
|
const accept = useOnlineSessionStore((state) => state.accept);
|
||||||
|
const fail = useOnlineSessionStore((state) => state.fail);
|
||||||
|
const reset = useOnlineSessionStore((state) => state.reset);
|
||||||
|
const pendingEventsRef = useRef<OnlineRelayEventInput[]>([]);
|
||||||
|
const applyingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session?.accessToken || !activeActivity || !group) {
|
||||||
|
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||||
|
reset();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const localAccountId = session.ownerId;
|
||||||
|
const immediateAuthority = group.leaderAccountId === localAccountId;
|
||||||
|
configureOnlineSessionRuntime({
|
||||||
|
active: true,
|
||||||
|
authority: immediateAuthority,
|
||||||
|
localAccountId,
|
||||||
|
});
|
||||||
|
let disposed = false;
|
||||||
|
let syncing = false;
|
||||||
|
let latestEventId = 0;
|
||||||
|
let lastWorldSentAt = 0;
|
||||||
|
const remotePositionIds = new Set<string>();
|
||||||
|
|
||||||
|
const unsubscribeCombat = useCombatStore.subscribe((state, previous) => {
|
||||||
|
if (applyingRef.current || state.combatEvents === previous.combatEvents) return;
|
||||||
|
const previousKeys = new Set(previous.combatEvents.map((event) => `${event.id}:${event.occurredAt}:${event.kind}`));
|
||||||
|
for (const event of state.combatEvents) {
|
||||||
|
if (previousKeys.has(`${event.id}:${event.occurredAt}:${event.kind}`)) continue;
|
||||||
|
const relay = eventForRelay(event, localAccountId, group.leaderAccountId === localAccountId);
|
||||||
|
if (relay) pendingEventsRef.current.push(relay);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const synchronize = async () => {
|
||||||
|
if (disposed || syncing) return;
|
||||||
|
syncing = true;
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
const outgoingEvents = [...pendingEventsRef.current.splice(0), ...drainOnlineInteractions()];
|
||||||
|
const now = Date.now();
|
||||||
|
const publishWorld = group.leaderAccountId === localAccountId && now - lastWorldSentAt >= WORLD_INTERVAL_MS;
|
||||||
|
if (publishWorld) lastWorldSentAt = now;
|
||||||
|
try {
|
||||||
|
const snapshot = await synchronizeOnlineSession(session, {
|
||||||
|
afterEventId: latestEventId,
|
||||||
|
presence: {
|
||||||
|
position: game.playerPosition,
|
||||||
|
yaw: game.cameraYaw,
|
||||||
|
grounded: game.playerGrounded,
|
||||||
|
health: combat.health,
|
||||||
|
maxHealth: combat.maxHealth,
|
||||||
|
resource: combat.resource,
|
||||||
|
maxResource: combat.maxResource,
|
||||||
|
resourceName: combat.resourceName,
|
||||||
|
selectedTargetId: combat.selectedTargetId,
|
||||||
|
activeCast: combat.activeCast,
|
||||||
|
animationEvent: combat.playerAnimationEvent,
|
||||||
|
equipment: equipmentItemsForOwner(
|
||||||
|
combat.equipment,
|
||||||
|
combat.inventory,
|
||||||
|
PLAYER_EQUIPMENT_OWNER_ID,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(publishWorld ? { world: sharedWorld() } : {}),
|
||||||
|
...(outgoingEvents.length ? { events: outgoingEvents } : {}),
|
||||||
|
});
|
||||||
|
if (disposed) return;
|
||||||
|
latestEventId = Math.max(latestEventId, snapshot.latestEventId);
|
||||||
|
configureOnlineSessionRuntime({
|
||||||
|
active: true,
|
||||||
|
authority: snapshot.authority,
|
||||||
|
localAccountId,
|
||||||
|
players: snapshot.players,
|
||||||
|
mobTransforms: snapshot.world?.mobTransforms,
|
||||||
|
});
|
||||||
|
for (const player of snapshot.players) {
|
||||||
|
if (player.accountId === localAccountId) continue;
|
||||||
|
const actorId = onlinePlayerActorId(player.accountId);
|
||||||
|
remotePositionIds.add(actorId);
|
||||||
|
updatePartyRuntimePosition(actorId, player.position[0], player.position[1], player.position[2]);
|
||||||
|
}
|
||||||
|
if (!snapshot.authority && snapshot.world) applySharedWorld(snapshot.world);
|
||||||
|
for (const event of snapshot.events) applyRelayEvent(event, snapshot, applyingRef);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
accept(snapshot);
|
||||||
|
} catch (error) {
|
||||||
|
if (outgoingEvents.length) pendingEventsRef.current.unshift(...outgoingEvents);
|
||||||
|
if (!disposed) fail(error instanceof Error ? error.message : "The shared activity lost connection.");
|
||||||
|
} finally {
|
||||||
|
syncing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void synchronize();
|
||||||
|
const timer = window.setInterval(() => void synchronize(), SYNC_INTERVAL_MS);
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
unsubscribeCombat();
|
||||||
|
for (const id of remotePositionIds) removePartyRuntimePosition(id);
|
||||||
|
pendingEventsRef.current = [];
|
||||||
|
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
}, [accept, activeActivity?.id, fail, group?.id, group?.leaderAccountId, reset, session?.accessToken, session?.ownerId]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { resetPartyRuntimeClock } from "./partyRuntime";
|
|||||||
import { usePartyStore } from "./partyStore";
|
import { usePartyStore } from "./partyStore";
|
||||||
import { useGameStore } from "./store";
|
import { useGameStore } from "./store";
|
||||||
import { useManastormStore } from "./manastormStore";
|
import { useManastormStore } from "./manastormStore";
|
||||||
|
import { onlineGroupAiPartySize, useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||||
|
|
||||||
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
|
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
|
||||||
export function PartyBridge() {
|
export function PartyBridge() {
|
||||||
@@ -12,6 +13,7 @@ export function PartyBridge() {
|
|||||||
const playerRole = useShellStore((state) => state.activeDungeonRole);
|
const playerRole = useShellStore((state) => state.activeDungeonRole);
|
||||||
const gameMode = useGameStore((state) => state.gameMode);
|
const gameMode = useGameStore((state) => state.gameMode);
|
||||||
const manastormPartySize = useManastormStore((state) => state.partySize);
|
const manastormPartySize = useManastormStore((state) => state.partySize);
|
||||||
|
const onlineActivity = useOnlineGroupStore((state) => state.activeActivity);
|
||||||
const level = useCombatStore((state) => state.level);
|
const level = useCombatStore((state) => state.level);
|
||||||
const resetRevision = useGameStore((state) => state.resetRevision);
|
const resetRevision = useGameStore((state) => state.resetRevision);
|
||||||
const mountedResetRevision = useRef(resetRevision);
|
const mountedResetRevision = useRef(resetRevision);
|
||||||
@@ -20,17 +22,22 @@ export function PartyBridge() {
|
|||||||
if (!character) return;
|
if (!character) return;
|
||||||
const combat = useCombatStore.getState();
|
const combat = useCombatStore.getState();
|
||||||
const party = usePartyStore.getState();
|
const party = usePartyStore.getState();
|
||||||
|
const requestedPartySize = gameMode === "manastorm" ? manastormPartySize : 5;
|
||||||
|
const aiPartySize = onlineActivity
|
||||||
|
&& onlineActivity.type === (gameMode === "manastorm" ? "manastorm" : "dungeon")
|
||||||
|
? onlineGroupAiPartySize(onlineActivity.partySize)
|
||||||
|
: requestedPartySize;
|
||||||
party.initializeParty(
|
party.initializeParty(
|
||||||
character.id,
|
character.id,
|
||||||
combat.level,
|
combat.level,
|
||||||
combat.health,
|
combat.health,
|
||||||
playerRole,
|
playerRole,
|
||||||
gameMode === "manastorm" ? manastormPartySize : 5,
|
aiPartySize,
|
||||||
character.categoryId ?? "wow",
|
character.categoryId ?? "wow",
|
||||||
);
|
);
|
||||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||||
resetPartyRuntimeClock();
|
resetPartyRuntimeClock();
|
||||||
}, [character?.id, gameMode, manastormPartySize, playerRole]);
|
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
usePartyStore.getState().syncLevel(level);
|
usePartyStore.getState().syncLevel(level);
|
||||||
|
|||||||
@@ -38,7 +38,20 @@ describe("baseline ability catalog", () => {
|
|||||||
const iconPath = path.join(process.cwd(), "public", ability.icon.slice(1));
|
const iconPath = path.join(process.cwd(), "public", ability.icon.slice(1));
|
||||||
expect(existsSync(iconPath), `${ability.name} icon should exist at ${iconPath}`).toBe(true);
|
expect(existsSync(iconPath), `${ability.name} icon should exist at ${iconPath}`).toBe(true);
|
||||||
expect(statSync(iconPath).size, `${ability.name} icon should not be empty`).toBeGreaterThan(0);
|
expect(statSync(iconPath).size, `${ability.name} icon should not be empty`).toBeGreaterThan(0);
|
||||||
expect(ability.cost.resource).toBe(resource.type);
|
if (characterClass.mode !== "classic" || ability.cost.amount > 0) {
|
||||||
|
const classicResources: Partial<Record<typeof characterClass.id, readonly string[]>> = {
|
||||||
|
warrior: ["rage", "health"],
|
||||||
|
rogue: ["energy"],
|
||||||
|
"death-knight": ["runic-power", "health"],
|
||||||
|
druid: ["mana", "rage", "energy"],
|
||||||
|
warlock: ["mana", "health"],
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
characterClass.mode === "classic"
|
||||||
|
? classicResources[characterClass.id] ?? [resource.type]
|
||||||
|
: ["mana", "rage", "energy", "runic-power", "focus", "health"],
|
||||||
|
).toContain(ability.cost.resource);
|
||||||
|
}
|
||||||
expect(ability.cost.amount).toBeGreaterThanOrEqual(0);
|
expect(ability.cost.amount).toBeGreaterThanOrEqual(0);
|
||||||
expect(ability.effects.length).toBeGreaterThan(0);
|
expect(ability.effects.length).toBeGreaterThan(0);
|
||||||
expect(allIds.has(ability.id)).toBe(false);
|
expect(allIds.has(ability.id)).toBe(false);
|
||||||
|
|||||||
+227
-47
@@ -1,16 +1,78 @@
|
|||||||
import type { ClassId } from "../app/characterCatalog";
|
|
||||||
import {
|
import {
|
||||||
COA_RUNTIME_ABILITIES_BY_CLASS as COA_ABILITIES_BY_CLASS,
|
BASE_CLASS_IDS,
|
||||||
COA_RUNTIME_CLASS_RESOURCES as COA_CLASS_RESOURCES,
|
COA_CLASS_IDS,
|
||||||
} from "./coaAbilityRuntimeCatalog";
|
ROM_CLASS_IDS,
|
||||||
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
type BaseClassId,
|
||||||
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES, ROM_ORDINARY_ABILITIES_BY_CLASS, type RomSkillGroup } from "./romAbilityCatalog";
|
type ClassId,
|
||||||
|
type CoaClassId,
|
||||||
|
type RomClassId,
|
||||||
|
} from "../app/characterCatalog";
|
||||||
|
import { loadCoaAbilityCatalogShard } from "./coaAbilityCatalogLoader";
|
||||||
import { registerAbilityAnimationLookup } from "./abilityAnimationLookup";
|
import { registerAbilityAnimationLookup } from "./abilityAnimationLookup";
|
||||||
import type { AuraDefinition, DispelCategory } from "./combatAuras";
|
import type { AuraDefinition, DamageSchool, DispelCategory } from "./combatAuras";
|
||||||
|
|
||||||
export type ResourceType = "mana" | "rage" | "energy" | "runic-power" | "focus" | "nature-power" | "psi" | "health";
|
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
|
||||||
export type AbilityTarget = "hostile" | "friendly" | "self";
|
|
||||||
|
export type ResourceType =
|
||||||
|
| "mana"
|
||||||
|
| "rage"
|
||||||
|
| "energy"
|
||||||
|
| "runic-power"
|
||||||
|
| "focus"
|
||||||
|
| "nature-power"
|
||||||
|
| "psi"
|
||||||
|
| "health"
|
||||||
|
| "holy-power"
|
||||||
|
| "chi"
|
||||||
|
| "soul-power"
|
||||||
|
| `custom:${string}`;
|
||||||
|
export type AbilityTarget = "hostile" | "friendly" | "any" | "self";
|
||||||
export type AbilityCastMode = "instant" | "cast" | "channel";
|
export type AbilityCastMode = "instant" | "cast" | "channel";
|
||||||
|
export type AbilityTargetShape = "unit" | "circle" | "cone" | "chain" | "ground";
|
||||||
|
|
||||||
|
export interface AbilityTargetingRules {
|
||||||
|
readonly shape: AbilityTargetShape;
|
||||||
|
readonly requiresLineOfSight?: boolean;
|
||||||
|
readonly requiresFacing?: boolean;
|
||||||
|
readonly requiresBehindTarget?: boolean;
|
||||||
|
readonly attackArcRadians?: number;
|
||||||
|
readonly radius?: number;
|
||||||
|
readonly maximumTargets?: number;
|
||||||
|
readonly jumpRange?: number;
|
||||||
|
readonly groundRange?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AbilityCastRules {
|
||||||
|
readonly school?: DamageSchool;
|
||||||
|
readonly canCastWhileMoving?: boolean;
|
||||||
|
readonly pushbackImmune?: boolean;
|
||||||
|
/** A fraction from zero to one, applied independently to each pushback hit. */
|
||||||
|
readonly pushbackResistance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AbilityRuneCost {
|
||||||
|
readonly blood?: number;
|
||||||
|
readonly frost?: number;
|
||||||
|
readonly unholy?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AbilityAiHints {
|
||||||
|
readonly categories: readonly (
|
||||||
|
| "damage"
|
||||||
|
| "execute"
|
||||||
|
| "heal"
|
||||||
|
| "emergency-heal"
|
||||||
|
| "area-heal"
|
||||||
|
| "mitigation"
|
||||||
|
| "taunt"
|
||||||
|
| "interrupt"
|
||||||
|
| "dispel"
|
||||||
|
| "resurrection"
|
||||||
|
| "control"
|
||||||
|
| "debuff"
|
||||||
|
)[];
|
||||||
|
readonly priority?: number;
|
||||||
|
}
|
||||||
export type MobStatusKind =
|
export type MobStatusKind =
|
||||||
| "slow"
|
| "slow"
|
||||||
| "stun"
|
| "stun"
|
||||||
@@ -63,6 +125,8 @@ export interface AbilityAmountScaling {
|
|||||||
readonly basePoints?: number;
|
readonly basePoints?: number;
|
||||||
readonly dieSides?: number;
|
readonly dieSides?: number;
|
||||||
readonly pointsPerLevel?: number;
|
readonly pointsPerLevel?: number;
|
||||||
|
/** Additional power coefficient gained per source skill level. */
|
||||||
|
readonly coefficientPerLevel?: number;
|
||||||
readonly bonusPowerCoefficient?: number;
|
readonly bonusPowerCoefficient?: number;
|
||||||
readonly sourceLevel?: number;
|
readonly sourceLevel?: number;
|
||||||
readonly maxScalingLevel?: number;
|
readonly maxScalingLevel?: number;
|
||||||
@@ -77,15 +141,17 @@ export type AbilityEffect =
|
|||||||
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number } & AbilityAmountScaling)
|
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number } & AbilityAmountScaling)
|
||||||
| { readonly kind: "resurrection"; readonly percentMaxHealth: number }
|
| { readonly kind: "resurrection"; readonly percentMaxHealth: number }
|
||||||
| { readonly kind: "summon"; readonly creatureId: number; readonly coefficient: number; readonly durationMs: number }
|
| { readonly kind: "summon"; readonly creatureId: number; readonly coefficient: number; readonly durationMs: number }
|
||||||
| { readonly kind: "resource"; readonly amount: number; readonly resource?: ResourceType }
|
| { readonly kind: "resource"; readonly amount: number; readonly resource?: ResourceType; readonly percentage?: boolean }
|
||||||
| { readonly kind: "combo"; readonly amount: number }
|
| { readonly kind: "combo"; readonly amount: number }
|
||||||
| { readonly kind: "taunt"; readonly durationMs: number; readonly maxTargets?: number }
|
| { readonly kind: "taunt"; readonly durationMs: number; readonly maxTargets?: number }
|
||||||
| { readonly kind: "status"; readonly status: MobStatusKind; readonly durationMs: number; readonly magnitude?: number; readonly maxTargets?: number }
|
| { readonly kind: "status"; readonly status: MobStatusKind; readonly durationMs: number; readonly magnitude?: number; readonly maxTargets?: number; readonly breakOnDamage?: boolean }
|
||||||
| {
|
| {
|
||||||
readonly kind: "apply-aura";
|
readonly kind: "apply-aura";
|
||||||
readonly aura: AuraDefinition;
|
readonly aura: AuraDefinition;
|
||||||
/** Some hostile spells apply their beneficial rider to the caster. */
|
/** Some hostile spells apply their beneficial rider to the caster. */
|
||||||
readonly recipient?: "ability-target" | "caster";
|
readonly recipient?: "ability-target" | "caster";
|
||||||
|
/** Party/raid area auras are applied to the player and every active party member. */
|
||||||
|
readonly scope?: "target" | "party";
|
||||||
readonly control?: AbilityAuraControl;
|
readonly control?: AbilityAuraControl;
|
||||||
readonly sourceEffectType: number;
|
readonly sourceEffectType: number;
|
||||||
readonly sourceAuraType: number;
|
readonly sourceAuraType: number;
|
||||||
@@ -109,6 +175,7 @@ export type AbilityEffect =
|
|||||||
readonly kind: "threat";
|
readonly kind: "threat";
|
||||||
readonly mode: "flat" | "percent" | "redirect";
|
readonly mode: "flat" | "percent" | "redirect";
|
||||||
readonly amount: number;
|
readonly amount: number;
|
||||||
|
readonly recipient?: "caster" | "ability-target";
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly kind: "movement";
|
readonly kind: "movement";
|
||||||
@@ -119,7 +186,14 @@ export type AbilityEffect =
|
|||||||
| { readonly kind: "pet"; readonly action: "summon" | "call" | "recall" | "dismiss" | "resurrect" | "feed" | "tame"; readonly creatureId?: number }
|
| { readonly kind: "pet"; readonly action: "summon" | "call" | "recall" | "dismiss" | "resurrect" | "feed" | "tame"; readonly creatureId?: number }
|
||||||
| { readonly kind: "totem"; readonly action: "destroy-all" }
|
| { readonly kind: "totem"; readonly action: "destroy-all" }
|
||||||
| { readonly kind: "rune"; readonly action: "activate"; readonly count: number }
|
| { readonly kind: "rune"; readonly action: "activate"; readonly count: number }
|
||||||
| { readonly kind: "cooldown-reset"; readonly mode: "reset" | "reduce"; readonly abilityName?: string; readonly amountMs?: number }
|
| {
|
||||||
|
readonly kind: "cooldown-reset";
|
||||||
|
readonly mode: "reset" | "reduce";
|
||||||
|
readonly abilityName?: string;
|
||||||
|
readonly amountMs?: number;
|
||||||
|
/** Fraction of the target ability's remaining cooldown removed at execution time. */
|
||||||
|
readonly amountPercent?: number;
|
||||||
|
}
|
||||||
| { readonly kind: "trigger-spell"; readonly spellId: number; readonly withValue?: number }
|
| { readonly kind: "trigger-spell"; readonly spellId: number; readonly withValue?: number }
|
||||||
| {
|
| {
|
||||||
/** A known combat-side DBC script hook that requires a spell-specific handler. */
|
/** A known combat-side DBC script hook that requires a spell-specific handler. */
|
||||||
@@ -140,6 +214,7 @@ export type AbilityEffect =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly kind: "utility";
|
readonly kind: "utility";
|
||||||
|
readonly action?: "create-item" | "open-lock" | "portal" | "weapon-enchant" | "pickpocket" | "farsight" | "class-script";
|
||||||
readonly effectType: number;
|
readonly effectType: number;
|
||||||
readonly auraType: number;
|
readonly auraType: number;
|
||||||
readonly miscValue: number;
|
readonly miscValue: number;
|
||||||
@@ -165,6 +240,9 @@ export interface AbilityRankDefinition {
|
|||||||
readonly cost: AbilityCost;
|
readonly cost: AbilityCost;
|
||||||
readonly costs?: readonly AbilityCost[];
|
readonly costs?: readonly AbilityCost[];
|
||||||
readonly effects: readonly AbilityEffect[];
|
readonly effects: readonly AbilityEffect[];
|
||||||
|
readonly targeting?: AbilityTargetingRules;
|
||||||
|
readonly castRules?: AbilityCastRules;
|
||||||
|
readonly runeCost?: AbilityRuneCost;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AbilityDefinition {
|
export interface AbilityDefinition {
|
||||||
@@ -188,6 +266,10 @@ export interface AbilityDefinition {
|
|||||||
readonly cost: AbilityCost;
|
readonly cost: AbilityCost;
|
||||||
readonly costs?: readonly AbilityCost[];
|
readonly costs?: readonly AbilityCost[];
|
||||||
readonly effects: readonly AbilityEffect[];
|
readonly effects: readonly AbilityEffect[];
|
||||||
|
readonly targeting?: AbilityTargetingRules;
|
||||||
|
readonly castRules?: AbilityCastRules;
|
||||||
|
readonly runeCost?: AbilityRuneCost;
|
||||||
|
readonly ai?: AbilityAiHints;
|
||||||
readonly passive?: boolean;
|
readonly passive?: boolean;
|
||||||
readonly source?: "core" | "wow335-progression" | "ascension-progression" | "coa-starter" | "coa-progression" | "coa-tree" | "runewaker";
|
readonly source?: "core" | "wow335-progression" | "ascension-progression" | "coa-starter" | "coa-progression" | "coa-tree" | "runewaker";
|
||||||
readonly romSkillGroup?: RomSkillGroup;
|
readonly romSkillGroup?: RomSkillGroup;
|
||||||
@@ -224,7 +306,7 @@ function defineAbility(definition: AbilityDefinitionInput): AbilityDefinition {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CLASS_RESOURCES: Readonly<Record<ClassId, ClassResourceProfile>> = {
|
const classResources: Partial<Record<ClassId, ClassResourceProfile>> = {
|
||||||
// The prototype has no auto-attack swing timer yet, so a small passive Rage
|
// The prototype has no auto-attack swing timer yet, so a small passive Rage
|
||||||
// gain stands in for white-hit generation and keeps the full kit usable.
|
// gain stands in for white-hit generation and keeps the full kit usable.
|
||||||
warrior: { type: "rage", name: "Rage", color: "#b74439", maximum: 100, initial: 0, regenerationPerSecond: 5 },
|
warrior: { type: "rage", name: "Rage", color: "#b74439", maximum: 100, initial: 0, regenerationPerSecond: 5 },
|
||||||
@@ -237,7 +319,6 @@ export const CLASS_RESOURCES: Readonly<Record<ClassId, ClassResourceProfile>> =
|
|||||||
mage: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
|
mage: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
|
||||||
warlock: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
|
warlock: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
|
||||||
druid: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
|
druid: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
|
||||||
...COA_CLASS_RESOURCES,
|
|
||||||
"rom-warrior": { type: "rage", name: "Rage", color: "#b74439", maximum: 100, initial: 0, regenerationPerSecond: 0 },
|
"rom-warrior": { type: "rage", name: "Rage", color: "#b74439", maximum: 100, initial: 0, regenerationPerSecond: 0 },
|
||||||
"rom-scout": { type: "focus", name: "Focus", color: "#55b995", maximum: 100, initial: 100, regenerationPerSecond: 6 },
|
"rom-scout": { type: "focus", name: "Focus", color: "#55b995", maximum: 100, initial: 100, regenerationPerSecond: 6 },
|
||||||
"rom-rogue": { type: "energy", name: "Energy", color: "#d8c43f", maximum: 100, initial: 100, regenerationPerSecond: 10 },
|
"rom-rogue": { type: "energy", name: "Energy", color: "#d8c43f", maximum: 100, initial: 100, regenerationPerSecond: 10 },
|
||||||
@@ -250,6 +331,9 @@ export const CLASS_RESOURCES: Readonly<Record<ClassId, ClassResourceProfile>> =
|
|||||||
"rom-champion": { type: "rage", name: "Rage", color: "#bb604c", maximum: 100, initial: 0, regenerationPerSecond: 0 },
|
"rom-champion": { type: "rage", name: "Rage", color: "#bb604c", maximum: 100, initial: 0, regenerationPerSecond: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Populated per mode/class by preloadAbilityCatalogsForClasses before gameplay mounts. */
|
||||||
|
export const CLASS_RESOURCES = classResources as Readonly<Record<ClassId, ClassResourceProfile>>;
|
||||||
|
|
||||||
const WARRIOR = [
|
const WARRIOR = [
|
||||||
defineAbility({
|
defineAbility({
|
||||||
id: "warrior-heroic-strike", classId: "warrior", dbcSpellId: 78, name: "Heroic Strike",
|
id: "warrior-heroic-strike", classId: "warrior", dbcSpellId: 78, name: "Heroic Strike",
|
||||||
@@ -712,48 +796,131 @@ function mergeWow335ClassicAbilities(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ABILITIES_BY_CLASS: Readonly<Record<ClassId, readonly AbilityDefinition[]>> = {
|
const CLASSIC_CORE_ABILITIES: Readonly<Record<BaseClassId, readonly AbilityDefinition[]>> = {
|
||||||
warrior: mergeWow335ClassicAbilities(WARRIOR, WOW335_ABILITIES_BY_CLASS.warrior),
|
warrior: WARRIOR,
|
||||||
paladin: mergeWow335ClassicAbilities(PALADIN, WOW335_ABILITIES_BY_CLASS.paladin),
|
paladin: PALADIN,
|
||||||
hunter: mergeWow335ClassicAbilities(HUNTER, WOW335_ABILITIES_BY_CLASS.hunter),
|
hunter: HUNTER,
|
||||||
rogue: mergeWow335ClassicAbilities(ROGUE, WOW335_ABILITIES_BY_CLASS.rogue),
|
rogue: ROGUE,
|
||||||
priest: mergeWow335ClassicAbilities(PRIEST, WOW335_ABILITIES_BY_CLASS.priest),
|
priest: PRIEST,
|
||||||
"death-knight": mergeWow335ClassicAbilities(DEATH_KNIGHT, WOW335_ABILITIES_BY_CLASS["death-knight"]),
|
"death-knight": DEATH_KNIGHT,
|
||||||
shaman: mergeWow335ClassicAbilities(SHAMAN, WOW335_ABILITIES_BY_CLASS.shaman),
|
shaman: SHAMAN,
|
||||||
mage: mergeWow335ClassicAbilities(MAGE, WOW335_ABILITIES_BY_CLASS.mage),
|
mage: MAGE,
|
||||||
warlock: mergeWow335ClassicAbilities(WARLOCK, WOW335_ABILITIES_BY_CLASS.warlock),
|
warlock: WARLOCK,
|
||||||
druid: mergeWow335ClassicAbilities(DRUID, WOW335_ABILITIES_BY_CLASS.druid),
|
druid: DRUID,
|
||||||
...COA_ABILITIES_BY_CLASS,
|
|
||||||
"rom-warrior": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-warrior"],
|
|
||||||
"rom-scout": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-scout"],
|
|
||||||
"rom-rogue": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-rogue"],
|
|
||||||
"rom-mage": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-mage"],
|
|
||||||
"rom-priest": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-priest"],
|
|
||||||
"rom-knight": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-knight"],
|
|
||||||
"rom-warden": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-warden"],
|
|
||||||
"rom-druid": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-druid"],
|
|
||||||
"rom-warlock": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-warlock"],
|
|
||||||
"rom-champion": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-champion"],
|
|
||||||
};
|
};
|
||||||
|
const abilitiesByClass: Partial<Record<ClassId, readonly AbilityDefinition[]>> = {
|
||||||
|
...CLASSIC_CORE_ABILITIES,
|
||||||
|
};
|
||||||
|
const abilityCatalog: Record<string, AbilityDefinition> = {};
|
||||||
|
let romEliteAbilities: readonly AbilityDefinition[] = [];
|
||||||
|
let romTriggeredAbilityResolver: ((spellId: number) => AbilityDefinition | null) | null = null;
|
||||||
|
const coaTriggeredAbilities = new Map<number, AbilityDefinition>();
|
||||||
|
let classicLoad: Promise<void> | null = null;
|
||||||
|
let romLoad: Promise<void> | null = null;
|
||||||
|
const coaLoads = new Map<CoaClassId, Promise<void>>();
|
||||||
|
|
||||||
export const ABILITY_CATALOG: Readonly<Record<string, AbilityDefinition>> = Object.freeze(
|
/** Live read-only views retained for catalog consumers and validation tests. */
|
||||||
Object.fromEntries([...Object.values(ABILITIES_BY_CLASS).flat(), ...ROM_ALL_ABILITIES].map((ability) => [ability.id, ability])),
|
export const ABILITIES_BY_CLASS = abilitiesByClass as Readonly<Record<ClassId, readonly AbilityDefinition[]>>;
|
||||||
);
|
export const ABILITY_CATALOG = abilityCatalog as Readonly<Record<string, AbilityDefinition>>;
|
||||||
|
|
||||||
|
function registerClassAbilities(classId: ClassId, abilities: readonly AbilityDefinition[]): void {
|
||||||
|
for (const previous of abilitiesByClass[classId] ?? []) delete abilityCatalog[previous.id];
|
||||||
|
abilitiesByClass[classId] = abilities;
|
||||||
|
for (const ability of abilities) abilityCatalog[ability.id] = ability;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const classId of BASE_CLASS_IDS) {
|
||||||
|
registerClassAbilities(classId, CLASSIC_CORE_ABILITIES[classId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preloadClassicAbilityCatalog(): Promise<void> {
|
||||||
|
if (classicLoad) return classicLoad;
|
||||||
|
classicLoad = import("./wow335AbilityCatalog").then(({ WOW335_ABILITIES_BY_CLASS }) => {
|
||||||
|
for (const classId of BASE_CLASS_IDS) {
|
||||||
|
registerClassAbilities(
|
||||||
|
classId,
|
||||||
|
mergeWow335ClassicAbilities(CLASSIC_CORE_ABILITIES[classId], WOW335_ABILITIES_BY_CLASS[classId]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
classicLoad = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return classicLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preloadRomAbilityCatalog(): Promise<void> {
|
||||||
|
if (romLoad) return romLoad;
|
||||||
|
romLoad = import("./romAbilityCatalog").then((catalog) => {
|
||||||
|
for (const classId of ROM_CLASS_IDS) {
|
||||||
|
registerClassAbilities(classId, catalog.ROM_ORDINARY_ABILITIES_BY_CLASS[classId]);
|
||||||
|
}
|
||||||
|
romEliteAbilities = catalog.ROM_ELITE_ABILITIES;
|
||||||
|
romTriggeredAbilityResolver = catalog.romTriggeredAbilityBySpellId;
|
||||||
|
for (const ability of catalog.ROM_ALL_ABILITIES) abilityCatalog[ability.id] = ability;
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
romLoad = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return romLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preloadCoaAbilityCatalog(classId: CoaClassId): Promise<void> {
|
||||||
|
const cached = coaLoads.get(classId);
|
||||||
|
if (cached) return cached;
|
||||||
|
const pending = loadCoaAbilityCatalogShard(classId).then((shard) => {
|
||||||
|
registerClassAbilities(classId, shard.abilities);
|
||||||
|
classResources[classId] = shard.resource;
|
||||||
|
for (const [spellId, ability] of Object.entries(shard.triggeredAbilitiesBySpellId)) {
|
||||||
|
coaTriggeredAbilities.set(Number(spellId), ability);
|
||||||
|
}
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
coaLoads.delete(classId);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
coaLoads.set(classId, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads only the executable catalogs needed by the selected character and its
|
||||||
|
* party mode. Callers gate gameplay mounting on this promise so combat APIs can
|
||||||
|
* stay synchronous inside the simulation loop.
|
||||||
|
*/
|
||||||
|
export async function preloadAbilityCatalogsForClasses(classIds: readonly ClassId[]): Promise<void> {
|
||||||
|
const requested = new Set(classIds);
|
||||||
|
const loads: Promise<void>[] = [];
|
||||||
|
if (BASE_CLASS_IDS.some((classId) => requested.has(classId))) loads.push(preloadClassicAbilityCatalog());
|
||||||
|
if (ROM_CLASS_IDS.some((classId) => requested.has(classId))) loads.push(preloadRomAbilityCatalog());
|
||||||
|
for (const classId of COA_CLASS_IDS) {
|
||||||
|
if (requested.has(classId)) loads.push(preloadCoaAbilityCatalog(classId));
|
||||||
|
}
|
||||||
|
await Promise.all(loads);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggeredAbilityBySpellId(
|
||||||
|
spellId: number,
|
||||||
|
includeCoa = true,
|
||||||
|
): AbilityDefinition | null {
|
||||||
|
return romTriggeredAbilityResolver?.(spellId)
|
||||||
|
?? (includeCoa ? coaTriggeredAbilities.get(spellId) : undefined)
|
||||||
|
?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function abilitiesForClass(classId: ClassId): readonly AbilityDefinition[] {
|
export function abilitiesForClass(classId: ClassId): readonly AbilityDefinition[] {
|
||||||
return ABILITIES_BY_CLASS[classId];
|
return abilitiesByClass[classId] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function abilitiesForCharacter(
|
export function abilitiesForCharacter(
|
||||||
classId: ClassId,
|
classId: ClassId,
|
||||||
secondaryClassId: ClassId | null | undefined,
|
secondaryClassId: ClassId | null | undefined,
|
||||||
): readonly AbilityDefinition[] {
|
): readonly AbilityDefinition[] {
|
||||||
if (!classId.startsWith("rom-") || !secondaryClassId?.startsWith("rom-")) return ABILITIES_BY_CLASS[classId];
|
if (!classId.startsWith("rom-") || !secondaryClassId?.startsWith("rom-")) return abilitiesForClass(classId);
|
||||||
const primary = ABILITIES_BY_CLASS[classId];
|
const primary = abilitiesForClass(classId);
|
||||||
const secondaryGeneral = ABILITIES_BY_CLASS[secondaryClassId].filter((ability) => (
|
const secondaryGeneral = abilitiesForClass(secondaryClassId).filter((ability) => (
|
||||||
ability.romSkillGroup === "general" || (ability.romSkillGroup === "passive" && ability.id.includes("-general-"))
|
ability.romSkillGroup === "general" || (ability.romSkillGroup === "passive" && ability.id.includes("-general-"))
|
||||||
));
|
));
|
||||||
const elites = ROM_ELITE_ABILITIES.filter((ability) => ability.classId === classId && ability.secondaryClassId === secondaryClassId);
|
const elites = romEliteAbilities.filter((ability) => ability.classId === classId && ability.secondaryClassId === secondaryClassId);
|
||||||
return [...primary, ...secondaryGeneral, ...elites];
|
return [...primary, ...secondaryGeneral, ...elites];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,6 +960,9 @@ export function abilityAtLevel(
|
|||||||
cost: rank.cost,
|
cost: rank.cost,
|
||||||
costs: rank.costs ?? [rank.cost],
|
costs: rank.costs ?? [rank.cost],
|
||||||
effects: rank.effects,
|
effects: rank.effects,
|
||||||
|
targeting: rank.targeting ?? ability.targeting,
|
||||||
|
castRules: rank.castRules ?? ability.castRules,
|
||||||
|
runeCost: rank.runeCost ?? ability.runeCost,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,7 +975,7 @@ export function abilitiesUnlockedBetweenLevels(
|
|||||||
previousLevel: number,
|
previousLevel: number,
|
||||||
currentLevel: number,
|
currentLevel: number,
|
||||||
): readonly AbilityDefinition[] {
|
): readonly AbilityDefinition[] {
|
||||||
return ABILITIES_BY_CLASS[classId].filter((ability) => (
|
return abilitiesForClass(classId).filter((ability) => (
|
||||||
ability.unlockLevel > previousLevel && ability.unlockLevel <= currentLevel
|
ability.unlockLevel > previousLevel && ability.unlockLevel <= currentLevel
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -840,7 +1010,7 @@ export function defaultActionBarForClass(classId: ClassId): readonly string[] {
|
|||||||
|| effect.kind === "rune"
|
|| effect.kind === "rune"
|
||||||
|| effect.kind === "trigger-spell"
|
|| effect.kind === "trigger-spell"
|
||||||
)) ? 0 : 1;
|
)) ? 0 : 1;
|
||||||
const ordered = ABILITIES_BY_CLASS[classId]
|
const ordered = abilitiesForClass(classId)
|
||||||
.map((ability, index) => ({ ability, index }))
|
.map((ability, index) => ({ ability, index }))
|
||||||
.sort((left, right) => (
|
.sort((left, right) => (
|
||||||
left.ability.unlockLevel - right.ability.unlockLevel
|
left.ability.unlockLevel - right.ability.unlockLevel
|
||||||
@@ -882,5 +1052,15 @@ export function defaultActionBarForCharacter(classId: ClassId, secondaryClassId?
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resourceProfileForClass(classId: ClassId): ClassResourceProfile {
|
export function resourceProfileForClass(classId: ClassId): ClassResourceProfile {
|
||||||
return CLASS_RESOURCES[classId];
|
const profile = classResources[classId];
|
||||||
|
if (!profile) throw new Error(`Ability catalog for ${classId} was used before it was loaded.`);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.MODE === "test") {
|
||||||
|
await preloadAbilityCatalogsForClasses([
|
||||||
|
...BASE_CLASS_IDS,
|
||||||
|
...COA_CLASS_IDS,
|
||||||
|
...ROM_CLASS_IDS,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type {
|
||||||
|
AbilityAiHints,
|
||||||
|
AbilityDefinition,
|
||||||
|
AbilityRuneCost,
|
||||||
|
AbilityTargetingRules,
|
||||||
|
} from "./abilityCatalog";
|
||||||
|
|
||||||
|
const CHAIN_ABILITIES = new Set(["chain heal", "chain lightning", "prayer of mending"]);
|
||||||
|
const GROUND_ABILITIES = new Set([
|
||||||
|
"blizzard", "rain of fire", "flamestrike", "death and decay", "consecration",
|
||||||
|
"volley", "hurricane", "earthquake", "meteor", "healing rain",
|
||||||
|
]);
|
||||||
|
const CONE_ABILITIES = new Set([
|
||||||
|
"cone of cold", "dragon's breath", "dragons breath", "shockwave", "cleave",
|
||||||
|
"swipe", "swipe (cat)", "fan of knives", "whirlwind",
|
||||||
|
]);
|
||||||
|
const BEHIND_ABILITIES = new Set(["backstab", "ambush", "garrote", "ravage", "shred", "pounce"]);
|
||||||
|
const MOVEMENT_CAST_ABILITIES = new Set(["scorch", "steady shot", "spiritwalker's grace"]);
|
||||||
|
const EMERGENCY_HEAL_ABILITIES = new Set([
|
||||||
|
"flash heal", "flash of light", "swiftmend", "lay on hands", "nature's swiftness",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RUNE_COSTS_BY_SPELL_ID: Readonly<Record<number, AbilityRuneCost>> = Object.freeze({
|
||||||
|
49909: { frost: 1 }, 45524: { frost: 1 }, 49184: { frost: 1 }, 47528: { frost: 1 },
|
||||||
|
49921: { unholy: 1 }, 55271: { unholy: 1 }, 49222: { unholy: 1 }, 51052: { unholy: 1 },
|
||||||
|
49930: { blood: 1 }, 55262: { blood: 1 }, 49941: { blood: 1 }, 50842: { blood: 1 },
|
||||||
|
48982: { blood: 1 }, 49005: { blood: 1 },
|
||||||
|
49924: { frost: 1, unholy: 1 }, 51425: { frost: 1, unholy: 1 },
|
||||||
|
49938: { blood: 1, frost: 1, unholy: 1 },
|
||||||
|
});
|
||||||
|
const RUNE_COSTS_BY_NAME: Readonly<Record<string, AbilityRuneCost>> = Object.freeze({
|
||||||
|
"icy touch": { frost: 1 }, "chains of ice": { frost: 1 }, "howling blast": { frost: 1 }, "mind freeze": { frost: 1 },
|
||||||
|
"plague strike": { unholy: 1 }, "scourge strike": { unholy: 1 }, "bone shield": { unholy: 1 }, "anti-magic zone": { unholy: 1 },
|
||||||
|
"blood strike": { blood: 1 }, "heart strike": { blood: 1 }, "blood boil": { blood: 1 }, "pestilence": { blood: 1 },
|
||||||
|
"rune tap": { blood: 1 }, "mark of blood": { blood: 1 },
|
||||||
|
"death strike": { frost: 1, unholy: 1 }, "obliterate": { frost: 1, unholy: 1 },
|
||||||
|
"death and decay": { blood: 1, frost: 1, unholy: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export function targetingRulesForAbility(ability: AbilityDefinition): AbilityTargetingRules {
|
||||||
|
if (ability.targeting) return ability.targeting;
|
||||||
|
const name = ability.name.toLowerCase();
|
||||||
|
const shape = CHAIN_ABILITIES.has(name)
|
||||||
|
? "chain"
|
||||||
|
: GROUND_ABILITIES.has(name) || name.endsWith(" trap")
|
||||||
|
? "ground"
|
||||||
|
: CONE_ABILITIES.has(name) || name.endsWith(" breath")
|
||||||
|
? "cone"
|
||||||
|
: ability.radius && ability.radius > 0
|
||||||
|
? "circle"
|
||||||
|
: "unit";
|
||||||
|
const hostile = ability.target === "hostile" || ability.target === "any";
|
||||||
|
return Object.freeze({
|
||||||
|
shape,
|
||||||
|
requiresLineOfSight: ability.target !== "self",
|
||||||
|
// WoW friendly spells are deliberately facing independent.
|
||||||
|
requiresFacing: hostile && shape !== "circle" && shape !== "ground",
|
||||||
|
requiresBehindTarget: hostile && BEHIND_ABILITIES.has(name),
|
||||||
|
attackArcRadians: shape === "cone" ? Math.PI / 2 : Math.PI,
|
||||||
|
...(ability.radius ? { radius: ability.radius } : {}),
|
||||||
|
...(shape === "chain" ? { maximumTargets: 3, jumpRange: 12 } : {}),
|
||||||
|
...(shape === "ground" ? { groundRange: ability.range.max, radius: ability.radius ?? 8 } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAbilityCastWhileMoving(ability: AbilityDefinition): boolean {
|
||||||
|
return ability.castRules?.canCastWhileMoving ?? MOVEMENT_CAST_ABILITIES.has(ability.name.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function abilityPushbackResistance(ability: AbilityDefinition): number {
|
||||||
|
if (ability.castRules?.pushbackImmune) return 1;
|
||||||
|
return Math.max(0, Math.min(1, ability.castRules?.pushbackResistance ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WotLK 3.3.5 rune costs for the active DK toolkit imported by the game. */
|
||||||
|
export function runeCostForAbility(ability: AbilityDefinition): AbilityRuneCost | null {
|
||||||
|
if (ability.runeCost) return ability.runeCost;
|
||||||
|
return RUNE_COSTS_BY_SPELL_ID[ability.dbcSpellId]
|
||||||
|
?? RUNE_COSTS_BY_NAME[ability.name.toLowerCase()]
|
||||||
|
?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aiHintsForAbility(ability: AbilityDefinition): AbilityAiHints {
|
||||||
|
if (ability.ai) return ability.ai;
|
||||||
|
const name = ability.name.toLowerCase();
|
||||||
|
const categories = new Set<AbilityAiHints["categories"][number]>();
|
||||||
|
for (const effect of ability.effects) {
|
||||||
|
if (["damage", "dot", "finisher-damage"].includes(effect.kind)) categories.add("damage");
|
||||||
|
if (effect.kind === "execute") categories.add("execute");
|
||||||
|
if (effect.kind === "heal" || effect.kind === "hot") categories.add("heal");
|
||||||
|
if (effect.kind === "shield") categories.add("mitigation");
|
||||||
|
if (effect.kind === "taunt") categories.add("taunt");
|
||||||
|
if (effect.kind === "interrupt") categories.add("interrupt");
|
||||||
|
if (effect.kind === "dispel" || effect.kind === "dispel-mechanic") categories.add("dispel");
|
||||||
|
if (effect.kind === "resurrection") categories.add("resurrection");
|
||||||
|
if (effect.kind === "status") {
|
||||||
|
categories.add(effect.status === "interrupt" ? "interrupt" : "control");
|
||||||
|
}
|
||||||
|
if (effect.kind === "dot" || (effect.kind === "apply-aura" && effect.aura.disposition === "debuff")) categories.add("debuff");
|
||||||
|
}
|
||||||
|
if (EMERGENCY_HEAL_ABILITIES.has(name)) categories.add("emergency-heal");
|
||||||
|
if ((ability.radius ?? 0) > 0 && categories.has("heal")) categories.add("area-heal");
|
||||||
|
return Object.freeze({ categories: Object.freeze([...categories]) });
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { COA_CLASS_IDS, type CoaClassId } from "../app/characterCatalog";
|
||||||
|
import type { AbilityDefinition, AbilityEffect, AbilityRankDefinition } from "./abilityCatalog";
|
||||||
|
import {
|
||||||
|
COA_ABILITIES_BY_CLASS,
|
||||||
|
COA_CLASS_RESOURCES,
|
||||||
|
COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||||
|
} from "./coaAbilityCatalog";
|
||||||
|
import {
|
||||||
|
COA_PROGRESSION_CHAINS,
|
||||||
|
COA_TREE_ENTRIES,
|
||||||
|
coaClassIdFromDbcId,
|
||||||
|
coaSpellById,
|
||||||
|
} from "./coaLiveCatalog";
|
||||||
|
|
||||||
|
const abilities = Object.values(COA_ABILITIES_BY_CLASS).flat();
|
||||||
|
const ranksFor = (ability: AbilityDefinition): readonly AbilityRankDefinition[] => ability.ranks ?? [{
|
||||||
|
rank: 1,
|
||||||
|
spellId: ability.dbcSpellId,
|
||||||
|
level: ability.unlockLevel,
|
||||||
|
description: ability.description,
|
||||||
|
castMode: ability.castMode,
|
||||||
|
castTimeMs: ability.castTimeMs,
|
||||||
|
cooldownMs: ability.cooldownMs,
|
||||||
|
gcdMs: ability.gcdMs,
|
||||||
|
cost: ability.cost,
|
||||||
|
effects: ability.effects,
|
||||||
|
}];
|
||||||
|
const ranks = abilities.flatMap((ability) => ranksFor(ability).map((rank) => ({ ability, rank })));
|
||||||
|
const byName = (classId: CoaClassId, name: string): AbilityDefinition => {
|
||||||
|
const ability = COA_ABILITIES_BY_CLASS[classId].find((candidate) => candidate.name === name);
|
||||||
|
if (!ability) throw new Error(`Missing ${classId} ability ${name}`);
|
||||||
|
return ability;
|
||||||
|
};
|
||||||
|
const rankEffectsForSpell = (spellId: number): readonly AbilityEffect[] => ranks
|
||||||
|
.find(({ rank }) => rank.spellId === spellId)?.rank.effects ?? [];
|
||||||
|
|
||||||
|
describe("Ascension Conquest ability catalog", () => {
|
||||||
|
it("covers every class, source family, and playable rank", () => {
|
||||||
|
expect(Object.keys(COA_ABILITIES_BY_CLASS).sort()).toEqual([...COA_CLASS_IDS].sort());
|
||||||
|
expect(Object.keys(COA_CLASS_RESOURCES).sort()).toEqual([...COA_CLASS_IDS].sort());
|
||||||
|
expect(abilities).toHaveLength(1_275);
|
||||||
|
expect(ranks).toHaveLength(3_394);
|
||||||
|
expect(new Set(ranks.map(({ rank }) => rank.spellId)).size).toBe(3_347);
|
||||||
|
|
||||||
|
const runtimeSpellIds = new Set(ranks.map(({ rank }) => rank.spellId));
|
||||||
|
for (const chain of COA_PROGRESSION_CHAINS) {
|
||||||
|
const classId = coaClassIdFromDbcId(chain.classId);
|
||||||
|
expect(classId, chain.name).not.toBeNull();
|
||||||
|
for (const rank of chain.ranks) {
|
||||||
|
expect(runtimeSpellIds.has(rank.spellId), `${classId}: ${chain.name} rank ${rank.rank}`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const entry of COA_TREE_ENTRIES.filter((candidate) => candidate.entryType === "Ability")) {
|
||||||
|
const classId = coaClassIdFromDbcId(entry.classId);
|
||||||
|
if (!classId) continue;
|
||||||
|
for (const rank of entry.rankDescriptions) {
|
||||||
|
expect(runtimeSpellIds.has(rank.spellId), `${classId}: ${entry.name} rank ${rank.rank}`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives every family and rank finite, executable runtime data", () => {
|
||||||
|
expect(new Set(abilities.map((ability) => ability.id)).size).toBe(abilities.length);
|
||||||
|
for (const ability of abilities) {
|
||||||
|
expect(ability.classId, ability.id).toBeTruthy();
|
||||||
|
expect(ability.name.length, ability.id).toBeGreaterThan(0);
|
||||||
|
expect(ability.icon.startsWith("/assets/ui/spells/"), ability.id).toBe(true);
|
||||||
|
expect(Number.isFinite(ability.range.min), ability.id).toBe(true);
|
||||||
|
expect(Number.isFinite(ability.range.max), ability.id).toBe(true);
|
||||||
|
expect(ability.range.max, ability.id).toBeGreaterThanOrEqual(ability.range.min);
|
||||||
|
expect(ability.cooldownMs, ability.id).toBeGreaterThanOrEqual(0);
|
||||||
|
for (const rank of ranksFor(ability)) {
|
||||||
|
const label = `${ability.id} rank ${rank.rank} (${rank.spellId})`;
|
||||||
|
expect(rank.spellId, label).toBeGreaterThan(0);
|
||||||
|
expect(rank.level, label).toBeGreaterThan(0);
|
||||||
|
expect(rank.description.length, label).toBeGreaterThan(0);
|
||||||
|
expect(rank.castTimeMs, label).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(rank.cooldownMs, label).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(rank.gcdMs, label).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(Number.isFinite(rank.cost.amount), label).toBe(true);
|
||||||
|
expect(rank.cost.amount, label).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(rank.effects.length, label).toBeGreaterThan(0);
|
||||||
|
expect(rank.effects.some((effect) => effect.kind === "scripted" || effect.kind === "unsupported"), label).toBe(false);
|
||||||
|
if (!ability.passive) {
|
||||||
|
expect(rank.effects.some((effect) => effect.kind !== "utility" || effect.action !== "class-script"), label).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("translates every standard DBC combat effect into its executable semantic", () => {
|
||||||
|
const applyAuraTypes = new Set([0, 3, 6, 27, 35, 65, 119, 128, 129, 143, 190]);
|
||||||
|
const triggerTypes = new Set([32, 64, 140, 141, 142, 148, 151, 160, 165, 169, 175, 178, 183]);
|
||||||
|
const movementTypes = new Set([5, 29, 41, 42, 43, 79, 96, 98, 138, 144, 149]);
|
||||||
|
for (const { ability, rank } of ranks) {
|
||||||
|
const dbc = coaSpellById(rank.spellId)?.dbc;
|
||||||
|
if (!dbc) continue;
|
||||||
|
const kinds = new Set(rank.effects.map((effect) => effect.kind));
|
||||||
|
for (const effect of dbc.effects) {
|
||||||
|
const label = `${ability.classId}: ${ability.name} rank ${rank.rank}, DBC ${effect.effectType}/${effect.auraType}`;
|
||||||
|
if (effect.effectType === 2 || [17, 31, 58, 121].includes(effect.effectType)) expect(kinds.has("damage"), label).toBe(true);
|
||||||
|
if ([10, 67, 136].includes(effect.effectType)) expect(kinds.has("heal"), label).toBe(true);
|
||||||
|
if (applyAuraTypes.has(effect.effectType) && effect.auraType > 0) expect(kinds.has("apply-aura"), label).toBe(true);
|
||||||
|
if (effect.auraType === 3 && effect.periodMs > 0) expect(kinds.has("dot"), label).toBe(true);
|
||||||
|
if ([8, 20].includes(effect.auraType) && effect.periodMs > 0) expect(kinds.has("hot"), label).toBe(true);
|
||||||
|
if (triggerTypes.has(effect.effectType) && effect.triggerSpellId > 0) expect(kinds.has("trigger-spell"), label).toBe(true);
|
||||||
|
if (movementTypes.has(effect.effectType)) expect(kinds.has("movement"), label).toBe(true);
|
||||||
|
if (effect.effectType === 38) expect(kinds.has("dispel"), label).toBe(true);
|
||||||
|
if (effect.effectType === 68) expect(kinds.has("interrupt"), label).toBe(true);
|
||||||
|
if ([18, 94, 113, 117].includes(effect.effectType)) expect(kinds.has("resurrection"), label).toBe(true);
|
||||||
|
if ([192, 195].includes(effect.effectType)) expect(kinds.has("cooldown-reset"), label).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves percentages, actual power pools, and shared cooldown groups", () => {
|
||||||
|
const percentageCosts = ranks.filter(({ rank }) => rank.cost.percentage);
|
||||||
|
expect(percentageCosts.length).toBeGreaterThan(1_500);
|
||||||
|
expect(ranks.some(({ rank }) => rank.cost.resource === "health")).toBe(true);
|
||||||
|
expect(ranks.some(({ rank }) => rank.cost.resource === "energy")).toBe(true);
|
||||||
|
expect(ranks.some(({ rank }) => rank.cost.resource === "rage")).toBe(true);
|
||||||
|
expect(ranks.some(({ rank }) => rank.cost.resource === "runic-power")).toBe(true);
|
||||||
|
|
||||||
|
const ascension = byName("sun-cleric", "Solar Invocation: Ascension");
|
||||||
|
const revelation = byName("sun-cleric", "Solar Invocation: Revelation");
|
||||||
|
expect(ascension.cooldownGroup).toBe("coa:solar-invocation-spells");
|
||||||
|
expect(revelation.cooldownGroup).toBe(ascension.cooldownGroup);
|
||||||
|
expect(revelation.target).toBe("any");
|
||||||
|
|
||||||
|
const profound = byName("templar", "Profound Enlightenment");
|
||||||
|
expect(profound.effects.filter((effect) => effect.kind === "cooldown-reset")).toEqual([
|
||||||
|
expect.objectContaining({ mode: "reduce", amountPercent: 0.5 }),
|
||||||
|
expect.objectContaining({ mode: "reduce", amountPercent: 0.5 }),
|
||||||
|
expect.objectContaining({ mode: "reduce", amountPercent: 0.5 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps representative healing, protection, control, proc, trigger, and reset spells functional", () => {
|
||||||
|
expect(byName("sun-cleric", "Solar Invocation: Ascension")).toMatchObject({ target: "self", radius: 40 });
|
||||||
|
expect(byName("guardian", "Feats of Strength")).toMatchObject({ target: "friendly" });
|
||||||
|
expect(byName("knight-of-xoroth", "Black Shield").ranks?.every((rank) => rank.effects.some((effect) => effect.kind === "shield"))).toBe(true);
|
||||||
|
expect(byName("witch-hunter", "Brand of the Unworthy").effects).toContainEqual(expect.objectContaining({ kind: "status", status: "sleep" }));
|
||||||
|
expect(byName("necromancer", "Plaguestorm").ranks?.every((rank) => rank.effects.some((effect) => effect.kind === "dot"))).toBe(true);
|
||||||
|
expect(byName("templar", "Profound Enlightenment").effects.filter((effect) => effect.kind === "cooldown-reset")).toHaveLength(3);
|
||||||
|
expect(byName("sun-cleric", "New Day").effects.filter((effect) => effect.kind === "cooldown-reset")).toHaveLength(3);
|
||||||
|
|
||||||
|
const gaze = byName("cultist", "Gaze of C'Thun");
|
||||||
|
expect(gaze.target).toBe("any");
|
||||||
|
expect(gaze.effects.some((effect) => effect.kind === "trigger-spell")).toBe(true);
|
||||||
|
expect(gaze.effects.some((effect) => effect.kind === "damage")).toBe(true);
|
||||||
|
expect(gaze.effects.some((effect) => effect.kind === "heal")).toBe(true);
|
||||||
|
|
||||||
|
const razorice = byName("necromancer", "Razorice");
|
||||||
|
const procs = razorice.effects
|
||||||
|
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
|
||||||
|
.flatMap((effect) => effect.aura.procs ?? []);
|
||||||
|
expect(procs.length).toBeGreaterThan(0);
|
||||||
|
expect(procs.every((proc) => proc.action.kind === "damage" || proc.action.kind === "custom")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every rank spell lookup executable, including recursively expanded trigger spells", () => {
|
||||||
|
for (const { ability, rank } of ranks) {
|
||||||
|
expect(rankEffectsForSpell(rank.spellId).length, `${ability.name} ${rank.spellId}`).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
const gaze = byName("cultist", "Gaze of C'Thun");
|
||||||
|
expect(gaze.effects.filter((effect) => effect.kind === "trigger-spell")).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ spellId: 520334 }),
|
||||||
|
expect.objectContaining({ spellId: 520335 }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ships executable hidden payloads for proc-triggered DBC spell IDs", () => {
|
||||||
|
expect(Object.keys(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)).toHaveLength(585);
|
||||||
|
expect(COA_TRIGGERED_ABILITIES_BY_SPELL_ID[800951]).toMatchObject({
|
||||||
|
name: "Battle Rhythm",
|
||||||
|
effects: expect.arrayContaining([{ kind: "resource", amount: 5 }]),
|
||||||
|
});
|
||||||
|
expect(Object.values(COA_TRIGGERED_ABILITIES_BY_SPELL_ID).filter((ability) => (
|
||||||
|
ability.effects.some((effect) => effect.kind !== "utility")
|
||||||
|
))).toHaveLength(547);
|
||||||
|
for (const [spellId, ability] of Object.entries(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)) {
|
||||||
|
expect(ability.dbcSpellId, spellId).toBe(Number(spellId));
|
||||||
|
expect(ability.effects.length, spellId).toBeGreaterThan(0);
|
||||||
|
expect(ability.effects.some((effect) => effect.kind === "scripted" || effect.kind === "unsupported"), spellId).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+893
-59
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { loadCoaAbilityCatalogShard } from "./coaAbilityCatalogLoader";
|
||||||
|
import {
|
||||||
|
COA_RUNTIME_ABILITIES_BY_CLASS,
|
||||||
|
COA_RUNTIME_CLASS_RESOURCES,
|
||||||
|
} from "./coaAbilityRuntimeCatalog";
|
||||||
|
|
||||||
|
describe("Conquest ability catalog shards", () => {
|
||||||
|
it.each(["barbarian", "runemaster"] as const)("loads only the %s executable catalog", async (classId) => {
|
||||||
|
const shard = await loadCoaAbilityCatalogShard(classId);
|
||||||
|
expect(shard.classId).toBe(classId);
|
||||||
|
expect(shard.abilities).toEqual(COA_RUNTIME_ABILITIES_BY_CLASS[classId]);
|
||||||
|
expect(shard.resource).toEqual(COA_RUNTIME_CLASS_RESOURCES[classId]);
|
||||||
|
expect(Object.values(shard.triggeredAbilitiesBySpellId).every((ability) => (
|
||||||
|
ability.classId === classId
|
||||||
|
))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { CoaClassId } from "../app/characterCatalog";
|
||||||
|
import type { AbilityDefinition, ClassResourceProfile } from "./abilityCatalog";
|
||||||
|
|
||||||
|
export interface CoaAbilityCatalogShard {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly classId: CoaClassId;
|
||||||
|
readonly abilities: readonly AbilityDefinition[];
|
||||||
|
readonly resource: ClassResourceProfile;
|
||||||
|
readonly triggeredAbilitiesBySpellId: Readonly<Record<number, AbilityDefinition>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoaAbilityCatalogShardModule {
|
||||||
|
readonly default: CoaAbilityCatalogShard;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shardLoaders = import.meta.glob<CoaAbilityCatalogShardModule>("./generated/coaAbilities/*.json");
|
||||||
|
const loadCache = new Map<CoaClassId, Promise<CoaAbilityCatalogShard>>();
|
||||||
|
|
||||||
|
export function loadCoaAbilityCatalogShard(classId: CoaClassId): Promise<CoaAbilityCatalogShard> {
|
||||||
|
const cached = loadCache.get(classId);
|
||||||
|
if (cached) return cached;
|
||||||
|
const key = `./generated/coaAbilities/${classId}.json`;
|
||||||
|
const loader = shardLoaders[key];
|
||||||
|
if (!loader) return Promise.reject(new Error(`No Conquest ability catalog exists for ${classId}.`));
|
||||||
|
const pending = loader().then((module) => {
|
||||||
|
if (module.default.schemaVersion !== 1 || module.default.classId !== classId) {
|
||||||
|
throw new Error(`Conquest ability catalog ${classId} has incompatible metadata.`);
|
||||||
|
}
|
||||||
|
return module.default;
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
loadCache.delete(classId);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
loadCache.set(classId, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
@@ -6,10 +6,15 @@ interface CoaAbilityRuntimeSnapshot {
|
|||||||
readonly schemaVersion: 1;
|
readonly schemaVersion: 1;
|
||||||
readonly abilitiesByClass: Readonly<Record<CoaClassId, readonly AbilityDefinition[]>>;
|
readonly abilitiesByClass: Readonly<Record<CoaClassId, readonly AbilityDefinition[]>>;
|
||||||
readonly resourcesByClass: Readonly<Record<CoaClassId, ClassResourceProfile>>;
|
readonly resourcesByClass: Readonly<Record<CoaClassId, ClassResourceProfile>>;
|
||||||
|
readonly triggeredAbilitiesBySpellId: Readonly<Record<number, AbilityDefinition>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = generated as unknown as CoaAbilityRuntimeSnapshot;
|
const snapshot = generated as unknown as CoaAbilityRuntimeSnapshot;
|
||||||
|
|
||||||
export const COA_RUNTIME_ABILITIES_BY_CLASS = snapshot.abilitiesByClass;
|
export const COA_RUNTIME_ABILITIES_BY_CLASS = snapshot.abilitiesByClass;
|
||||||
export const COA_RUNTIME_CLASS_RESOURCES = snapshot.resourcesByClass;
|
export const COA_RUNTIME_CLASS_RESOURCES = snapshot.resourcesByClass;
|
||||||
|
export const COA_RUNTIME_TRIGGERED_ABILITIES_BY_SPELL_ID = snapshot.triggeredAbilitiesBySpellId;
|
||||||
|
|
||||||
|
export function coaTriggeredAbilityBySpellId(spellId: number): AbilityDefinition | null {
|
||||||
|
return COA_RUNTIME_TRIGGERED_ABILITIES_BY_SPELL_ID[spellId] ?? null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
|
||||||
|
describe("Ascension Conquest combat runtime", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("heals a selected ally with an enemy-or-ally spell", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "cultist", raceId: "human", level: 20 });
|
||||||
|
usePartyStore.getState().initializeParty("coa-any-friendly", 20, useCombatStore.getState().health, "healer", 2, "coa");
|
||||||
|
const member = usePartyStore.getState().members[0]!;
|
||||||
|
usePartyStore.getState().damageMember(member.id, 500);
|
||||||
|
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
||||||
|
usePartyStore.getState().selectMember(member.id);
|
||||||
|
const injuredHealth = usePartyStore.getState().members[0]!.health;
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("cultist-gaze-of-cthun", undefined, 1_000)).toMatchObject({ ok: true });
|
||||||
|
|
||||||
|
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(injuredHealth);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("damages a selected enemy with the same enemy-or-ally spell", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "cultist", raceId: "human", level: 20 });
|
||||||
|
usePartyStore.getState().clearSelection();
|
||||||
|
useCombatStore.getState().registerMob("gaze-dummy", { name: "Gaze Dummy", maxHealth: 5_000 });
|
||||||
|
setMobPosition("gaze-dummy", [3, 0, 0]);
|
||||||
|
useCombatStore.getState().selectMob("gaze-dummy");
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("cultist-gaze-of-cthun", undefined, 1_000)).toMatchObject({ ok: true });
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().mobs["gaze-dummy"]!.health).toBeLessThan(5_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes a hidden DBC payload when a passive proc fires", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "pyromancer", raceId: "human", level: 20 });
|
||||||
|
usePartyStore.getState().initializeParty("coa-hidden-proc", 20, useCombatStore.getState().health, "healer", 2, "coa");
|
||||||
|
const member = usePartyStore.getState().members[0]!;
|
||||||
|
usePartyStore.getState().damageMember(member.id, 500);
|
||||||
|
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
||||||
|
usePartyStore.getState().selectMember(member.id);
|
||||||
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.name.includes("Phoenix Handler"))).toBe(true);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("coa-24-cinderheart-476-502044", undefined, 1_000)).toMatchObject({ ok: true });
|
||||||
|
useCombatStore.getState().tick(0.1, 4_100);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().auras.some((aura) => (
|
||||||
|
aura.definition.id === "coa:704865:effect:0:aura:23"
|
||||||
|
))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { resourceProfileForClass, type ResourceType } from "./abilityCatalog";
|
||||||
|
import {
|
||||||
|
normalizeActorResourcePool,
|
||||||
|
type CombatActorState,
|
||||||
|
} from "./combatActors";
|
||||||
|
import type { PartyMember } from "./partyStore";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the authoritative companion actor. Existing actor values win over
|
||||||
|
* the party-frame compatibility record once the actor has entered combat.
|
||||||
|
*/
|
||||||
|
export function companionActorFromMember(
|
||||||
|
member: PartyMember,
|
||||||
|
absorb = 0,
|
||||||
|
existing?: CombatActorState | null,
|
||||||
|
): CombatActorState {
|
||||||
|
const profile = resourceProfileForClass(member.classId);
|
||||||
|
const resources = existing?.kind === "companion"
|
||||||
|
? existing.resources
|
||||||
|
: Object.fromEntries(Object.entries(member.resourcePools).map(([id, current]) => [id,
|
||||||
|
normalizeActorResourcePool(
|
||||||
|
id,
|
||||||
|
current ?? 0,
|
||||||
|
id === member.resourceType ? member.maxResource : 100,
|
||||||
|
id === member.resourceType ? profile.regenerationPerSecond : 0,
|
||||||
|
member.lastResourceSpentAt[id as ResourceType] ?? 0,
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
const actor = existing?.kind === "companion" ? existing : null;
|
||||||
|
return Object.freeze({
|
||||||
|
id: member.id,
|
||||||
|
threatActorId: member.id,
|
||||||
|
kind: "companion" as const,
|
||||||
|
classId: member.classId,
|
||||||
|
level: member.level,
|
||||||
|
health: actor?.health ?? member.health,
|
||||||
|
maxHealth: actor?.maxHealth ?? member.maxHealth,
|
||||||
|
absorb,
|
||||||
|
resources,
|
||||||
|
cooldowns: actor?.cooldowns ?? member.cooldowns,
|
||||||
|
globalCooldownEndsAt: actor?.globalCooldownEndsAt ?? member.globalCooldownEndsAt,
|
||||||
|
cast: actor?.cast ?? member.activeCast,
|
||||||
|
controlledUntil: actor?.controlledUntil ?? member.controlledUntil,
|
||||||
|
schoolLockedUntil: actor?.schoolLockedUntil ?? member.schoolLockedUntil,
|
||||||
|
comboPoints: actor?.comboPoints ?? 0,
|
||||||
|
comboTargetId: actor?.comboTargetId ?? null,
|
||||||
|
runes: actor?.runes ?? member.runes,
|
||||||
|
abilityIds: actor?.abilityIds,
|
||||||
|
alive: (actor?.health ?? member.health) > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Party and scene consumers receive a read-compatible mirror of actor state. */
|
||||||
|
export function memberFromCompanionActor(member: PartyMember, actor?: CombatActorState | null): PartyMember {
|
||||||
|
if (!actor || actor.kind !== "companion") return member;
|
||||||
|
const resourcePools = Object.fromEntries(Object.entries(actor.resources).map(([id, pool]) => [id, pool.current])) as Partial<Record<ResourceType, number>>;
|
||||||
|
const lastResourceSpentAt = Object.fromEntries(Object.entries(actor.resources).map(([id, pool]) => [id, pool.lastSpentAt])) as Partial<Record<ResourceType, number>>;
|
||||||
|
const primary = actor.resources[member.resourceType];
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
health: actor.health,
|
||||||
|
maxHealth: actor.maxHealth,
|
||||||
|
resource: primary?.current ?? member.resource,
|
||||||
|
maxResource: primary?.maximum ?? member.maxResource,
|
||||||
|
resourcePools,
|
||||||
|
lastResourceSpentAt,
|
||||||
|
cooldowns: actor.cooldowns,
|
||||||
|
globalCooldownEndsAt: actor.globalCooldownEndsAt,
|
||||||
|
activeCast: actor.cast,
|
||||||
|
activeCastTargetId: actor.cast?.targetId ?? member.activeCastTargetId,
|
||||||
|
schoolLockedUntil: actor.schoolLockedUntil,
|
||||||
|
runes: actor.runes,
|
||||||
|
controlledUntil: actor.controlledUntil,
|
||||||
|
status: actor.health <= 0 ? "down" : member.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function companionActorWithMemberRuntime(
|
||||||
|
actor: CombatActorState,
|
||||||
|
member: PartyMember,
|
||||||
|
): CombatActorState {
|
||||||
|
const profile = resourceProfileForClass(member.classId);
|
||||||
|
const resources = Object.fromEntries(Object.entries(member.resourcePools).map(([id, current]) => [id,
|
||||||
|
normalizeActorResourcePool(
|
||||||
|
id,
|
||||||
|
current ?? 0,
|
||||||
|
id === member.resourceType ? member.maxResource : actor.resources[id]?.maximum ?? 100,
|
||||||
|
id === member.resourceType ? profile.regenerationPerSecond : actor.resources[id]?.regenerationPerSecond ?? 0,
|
||||||
|
member.lastResourceSpentAt[id as ResourceType] ?? actor.resources[id]?.lastSpentAt ?? 0,
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
return Object.freeze({
|
||||||
|
...actor,
|
||||||
|
health: member.health,
|
||||||
|
maxHealth: member.maxHealth,
|
||||||
|
resources,
|
||||||
|
cooldowns: member.cooldowns,
|
||||||
|
globalCooldownEndsAt: member.globalCooldownEndsAt,
|
||||||
|
cast: member.activeCast ? { ...member.activeCast, targetId: member.activeCastTargetId } : null,
|
||||||
|
controlledUntil: member.controlledUntil,
|
||||||
|
schoolLockedUntil: member.schoolLockedUntil,
|
||||||
|
runes: member.runes,
|
||||||
|
alive: member.health > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { ClassId } from "../app/characterCatalog";
|
||||||
|
import type { DamageSchool } from "./combatAuras";
|
||||||
|
|
||||||
|
export type CombatActorKind = "player" | "companion" | "enemy" | "pet" | "totem" | "summon";
|
||||||
|
export type CombatResourceId = string;
|
||||||
|
export type DeathKnightRuneType = "blood" | "frost" | "unholy" | "death";
|
||||||
|
export type CombatActorPosition = readonly [number, number, number];
|
||||||
|
|
||||||
|
export interface CombatResourcePoolState {
|
||||||
|
readonly id: CombatResourceId;
|
||||||
|
readonly current: number;
|
||||||
|
readonly maximum: number;
|
||||||
|
readonly regenerationPerSecond: number;
|
||||||
|
/** Mana regeneration observes the five-second rule from this timestamp. */
|
||||||
|
readonly lastSpentAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeathKnightRuneState {
|
||||||
|
readonly slot: number;
|
||||||
|
readonly baseType: Exclude<DeathKnightRuneType, "death">;
|
||||||
|
readonly type: DeathKnightRuneType;
|
||||||
|
readonly readyAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatActorCastState {
|
||||||
|
readonly abilityId: string;
|
||||||
|
readonly school: DamageSchool;
|
||||||
|
readonly mode: "cast" | "channel";
|
||||||
|
readonly startedAt: number;
|
||||||
|
readonly completesAt: number;
|
||||||
|
readonly originalDurationMs: number;
|
||||||
|
readonly pushbackHits: number;
|
||||||
|
/** Simulation-space origin used for movement cancellation and completion validation. */
|
||||||
|
readonly origin?: CombatActorPosition;
|
||||||
|
readonly targetId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renderer-free snapshot shared by every combatant. Specialized player, party,
|
||||||
|
* and mob records may retain presentation fields, but combat rules consume
|
||||||
|
* this common contract.
|
||||||
|
*/
|
||||||
|
export interface CombatActorState {
|
||||||
|
readonly id: string;
|
||||||
|
/** Stable id used by threat tables even when a renderer entity is replaced. */
|
||||||
|
readonly threatActorId?: string;
|
||||||
|
readonly kind: CombatActorKind;
|
||||||
|
readonly ownerId?: string;
|
||||||
|
readonly classId?: ClassId;
|
||||||
|
readonly level: number;
|
||||||
|
readonly health: number;
|
||||||
|
readonly maxHealth: number;
|
||||||
|
readonly absorb: number;
|
||||||
|
readonly resources: Readonly<Record<CombatResourceId, CombatResourcePoolState>>;
|
||||||
|
readonly cooldowns: Readonly<Record<string, number>>;
|
||||||
|
readonly globalCooldownEndsAt: number;
|
||||||
|
readonly cast: CombatActorCastState | null;
|
||||||
|
readonly controlledUntil: number;
|
||||||
|
readonly schoolLockedUntil: Readonly<Partial<Record<DamageSchool, number>>>;
|
||||||
|
readonly comboPoints: number;
|
||||||
|
readonly comboTargetId: string | null;
|
||||||
|
readonly runes: readonly DeathKnightRuneState[];
|
||||||
|
/** Executable catalog abilities owned by pets, summons, and companions. */
|
||||||
|
readonly abilityIds?: readonly string[];
|
||||||
|
readonly alive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDeathKnightRunes(now = 0): readonly DeathKnightRuneState[] {
|
||||||
|
const types = ["blood", "blood", "frost", "frost", "unholy", "unholy"] as const;
|
||||||
|
return Object.freeze(types.map((type, slot) => Object.freeze({
|
||||||
|
slot,
|
||||||
|
baseType: type,
|
||||||
|
type,
|
||||||
|
readyAt: Math.max(0, now),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeActorResourcePool(
|
||||||
|
id: CombatResourceId,
|
||||||
|
current: number,
|
||||||
|
maximum: number,
|
||||||
|
regenerationPerSecond = 0,
|
||||||
|
lastSpentAt = 0,
|
||||||
|
): CombatResourcePoolState {
|
||||||
|
const safeMaximum = Math.max(0, Number.isFinite(maximum) ? maximum : 0);
|
||||||
|
return Object.freeze({
|
||||||
|
id,
|
||||||
|
current: Math.max(0, Math.min(safeMaximum, Number.isFinite(current) ? current : 0)),
|
||||||
|
maximum: safeMaximum,
|
||||||
|
regenerationPerSecond: Math.max(0, Number.isFinite(regenerationPerSecond) ? regenerationPerSecond : 0),
|
||||||
|
lastSpentAt: Math.max(0, Number.isFinite(lastSpentAt) ? lastSpentAt : 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spendActorResource(
|
||||||
|
pool: CombatResourcePoolState,
|
||||||
|
amount: number,
|
||||||
|
now: number,
|
||||||
|
): CombatResourcePoolState | null {
|
||||||
|
const requested = Math.max(0, Number.isFinite(amount) ? amount : 0);
|
||||||
|
if (pool.current + Number.EPSILON < requested) return null;
|
||||||
|
return Object.freeze({
|
||||||
|
...pool,
|
||||||
|
current: Math.max(0, pool.current - requested),
|
||||||
|
lastSpentAt: Math.max(pool.lastSpentAt, Number.isFinite(now) ? now : 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreActorResource(
|
||||||
|
pool: CombatResourcePoolState,
|
||||||
|
amount: number,
|
||||||
|
): CombatResourcePoolState {
|
||||||
|
return Object.freeze({
|
||||||
|
...pool,
|
||||||
|
current: Math.min(pool.maximum, pool.current + Math.max(0, Number.isFinite(amount) ? amount : 0)),
|
||||||
|
});
|
||||||
|
}
|
||||||
+25
-4
@@ -143,6 +143,16 @@ export interface AuraDefinition {
|
|||||||
readonly stackBehavior?: AuraStackBehavior;
|
readonly stackBehavior?: AuraStackBehavior;
|
||||||
/** Defaults to true. False makes applications from all sources share one aura. */
|
/** Defaults to true. False makes applications from all sources share one aura. */
|
||||||
readonly uniqueBySource?: boolean;
|
readonly uniqueBySource?: boolean;
|
||||||
|
/** A target can have only one aura from an exclusive stance/form/seal/etc. group. */
|
||||||
|
readonly exclusiveGroup?: string;
|
||||||
|
/** Explicit non-numeric gameplay state such as stealth, tracking, flight, or water walking. */
|
||||||
|
readonly utilityTags?: readonly string[];
|
||||||
|
/** Enemy control mechanics ignored while this aura is active. */
|
||||||
|
readonly controlImmunities?: readonly ("fear" | "sleep" | "root" | "stun" | "silence")[];
|
||||||
|
/** Chance from 0 through 1 to reflect an incoming spell. */
|
||||||
|
readonly reflectSpellChance?: number;
|
||||||
|
/** Portion of incoming target damage transferred to the aura source. */
|
||||||
|
readonly redirectDamagePercent?: number;
|
||||||
readonly dispelCategory?: DispelCategory;
|
readonly dispelCategory?: DispelCategory;
|
||||||
readonly dispelPriority?: number;
|
readonly dispelPriority?: number;
|
||||||
readonly modifiers?: readonly AuraModifier[];
|
readonly modifiers?: readonly AuraModifier[];
|
||||||
@@ -309,14 +319,25 @@ export function applyAura(
|
|||||||
const maxStacks = boundedPositiveInteger(definition.maxStacks, 1);
|
const maxStacks = boundedPositiveInteger(definition.maxStacks, 1);
|
||||||
const requestedStacks = Math.min(maxStacks, boundedPositiveInteger(request.stacks, 1));
|
const requestedStacks = Math.min(maxStacks, boundedPositiveInteger(request.stacks, 1));
|
||||||
const instanceId = auraIdentity(definition, request.sourceId, request.targetId);
|
const instanceId = auraIdentity(definition, request.sourceId, request.targetId);
|
||||||
const existingIndex = current.findIndex((aura) => aura.instanceId === instanceId);
|
const exclusive = definition.exclusiveGroup
|
||||||
|
? current.filter((aura) => (
|
||||||
|
aura.targetId === request.targetId
|
||||||
|
&& aura.definition.exclusiveGroup === definition.exclusiveGroup
|
||||||
|
&& aura.instanceId !== instanceId
|
||||||
|
))
|
||||||
|
: [];
|
||||||
|
const exclusiveIds = new Set(exclusive.map((aura) => aura.instanceId));
|
||||||
|
const base = exclusiveIds.size > 0
|
||||||
|
? current.filter((aura) => !exclusiveIds.has(aura.instanceId))
|
||||||
|
: current;
|
||||||
|
const existingIndex = base.findIndex((aura) => aura.instanceId === instanceId);
|
||||||
|
|
||||||
if (existingIndex < 0) {
|
if (existingIndex < 0) {
|
||||||
const aura = makeAura(request, requestedStacks);
|
const aura = makeAura(request, requestedStacks);
|
||||||
return { auras: [...current, aura], aura };
|
return { auras: [...base, aura], aura, ...(exclusive[0] ? { replaced: exclusive[0] } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = current[existingIndex];
|
const existing = base[existingIndex];
|
||||||
const behavior = definition.stackBehavior ?? "refresh";
|
const behavior = definition.stackBehavior ?? "refresh";
|
||||||
const stacks = behavior === "add"
|
const stacks = behavior === "add"
|
||||||
? Math.min(maxStacks, existing.stacks + requestedStacks)
|
? Math.min(maxStacks, existing.stacks + requestedStacks)
|
||||||
@@ -333,7 +354,7 @@ export function applyAura(
|
|||||||
procStates: existing.procStates,
|
procStates: existing.procStates,
|
||||||
}
|
}
|
||||||
: replacement;
|
: replacement;
|
||||||
const auras = [...current];
|
const auras = [...base];
|
||||||
auras[existingIndex] = aura;
|
auras[existingIndex] = aura;
|
||||||
return { auras, aura, replaced: existing };
|
return { auras, aura, replaced: existing };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { DamageSchool } from "./combatAuras";
|
||||||
|
|
||||||
|
export type StructuredCombatEventKind =
|
||||||
|
| "damage"
|
||||||
|
| "healing"
|
||||||
|
| "absorb"
|
||||||
|
| "resource"
|
||||||
|
| "aura"
|
||||||
|
| "cast"
|
||||||
|
| "interrupt"
|
||||||
|
| "death"
|
||||||
|
| "resurrection";
|
||||||
|
|
||||||
|
export interface StructuredCombatEvent {
|
||||||
|
readonly id: number;
|
||||||
|
readonly occurredAt: number;
|
||||||
|
readonly kind: StructuredCombatEventKind;
|
||||||
|
readonly sourceActorId: string;
|
||||||
|
readonly targetActorId: string;
|
||||||
|
readonly abilityId?: string;
|
||||||
|
readonly school?: DamageSchool;
|
||||||
|
readonly rawAmount: number;
|
||||||
|
readonly effectiveAmount: number;
|
||||||
|
readonly overheal: number;
|
||||||
|
readonly absorbed: number;
|
||||||
|
readonly critical: boolean;
|
||||||
|
readonly outcome?: string;
|
||||||
|
}
|
||||||
|
export type CombatEventInput = Omit<StructuredCombatEvent, "id">;
|
||||||
|
|
||||||
|
export const MAX_COMBAT_EVENT_HISTORY = 200;
|
||||||
|
|
||||||
|
export function appendCombatEvent(
|
||||||
|
events: readonly StructuredCombatEvent[],
|
||||||
|
event: CombatEventInput,
|
||||||
|
id: number,
|
||||||
|
maximum = MAX_COMBAT_EVENT_HISTORY,
|
||||||
|
): readonly StructuredCombatEvent[] {
|
||||||
|
const next = Object.freeze({
|
||||||
|
...event,
|
||||||
|
id,
|
||||||
|
occurredAt: Math.max(0, Number.isFinite(event.occurredAt) ? event.occurredAt : 0),
|
||||||
|
rawAmount: Math.max(0, Number.isFinite(event.rawAmount) ? event.rawAmount : 0),
|
||||||
|
effectiveAmount: Math.max(0, Number.isFinite(event.effectiveAmount) ? event.effectiveAmount : 0),
|
||||||
|
overheal: Math.max(0, Number.isFinite(event.overheal) ? event.overheal : 0),
|
||||||
|
absorbed: Math.max(0, Number.isFinite(event.absorbed) ? event.absorbed : 0),
|
||||||
|
});
|
||||||
|
return Object.freeze([...events, next].slice(-Math.max(1, Math.trunc(maximum))));
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveHealing } from "./combatHealing";
|
||||||
|
|
||||||
|
describe("combat healing resolution", () => {
|
||||||
|
it("reports critical raw healing, effective healing, and overheal independently", () => {
|
||||||
|
expect(resolveHealing(100, 950, 1_000, 0.25, 0.1)).toEqual({
|
||||||
|
rawAmount: 150,
|
||||||
|
effectiveAmount: 50,
|
||||||
|
overheal: 100,
|
||||||
|
critical: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not resurrect a zero-health actor implicitly", () => {
|
||||||
|
const result = resolveHealing(100, 0, 1_000, 0, 1);
|
||||||
|
expect(result).toMatchObject({ rawAmount: 100, effectiveAmount: 100, overheal: 0, critical: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export interface HealingResolution {
|
||||||
|
readonly rawAmount: number;
|
||||||
|
readonly effectiveAmount: number;
|
||||||
|
readonly overheal: number;
|
||||||
|
readonly critical: boolean;
|
||||||
|
}
|
||||||
|
export function resolveHealing(
|
||||||
|
amount: number,
|
||||||
|
currentHealth: number,
|
||||||
|
maximumHealth: number,
|
||||||
|
criticalChance = 0,
|
||||||
|
roll = 1,
|
||||||
|
criticalMultiplier = 1.5,
|
||||||
|
): HealingResolution {
|
||||||
|
const critical = Number.isFinite(roll) && roll < Math.max(0, Math.min(1, criticalChance));
|
||||||
|
const rawAmount = Math.max(0, Math.round(
|
||||||
|
(Number.isFinite(amount) ? amount : 0) * (critical ? Math.max(1, criticalMultiplier) : 1),
|
||||||
|
));
|
||||||
|
const missing = Math.max(0, (Number.isFinite(maximumHealth) ? maximumHealth : 0)
|
||||||
|
- (Number.isFinite(currentHealth) ? currentHealth : 0));
|
||||||
|
const effectiveAmount = Math.min(rawAmount, missing);
|
||||||
|
return Object.freeze({
|
||||||
|
rawAmount,
|
||||||
|
effectiveAmount,
|
||||||
|
overheal: rawAmount - effectiveAmount,
|
||||||
|
critical,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createDeathKnightRunes, normalizeActorResourcePool } from "./combatActors";
|
||||||
|
import {
|
||||||
|
activateDeathKnightRunes,
|
||||||
|
advanceCombatResourcePool,
|
||||||
|
spendDeathKnightRunes,
|
||||||
|
} from "./combatResources";
|
||||||
|
|
||||||
|
describe("authentic combat resources", () => {
|
||||||
|
it("suppresses spirit mana regeneration for five seconds after spending", () => {
|
||||||
|
const mana = normalizeActorResourcePool("mana", 50, 100, 12, 1_000);
|
||||||
|
const insideRule = advanceCombatResourcePool(mana, {
|
||||||
|
inCombat: true,
|
||||||
|
actorLevel: 80,
|
||||||
|
now: 5_999,
|
||||||
|
deltaSeconds: 1,
|
||||||
|
manaSpiritRegenerationPerSecond: 8,
|
||||||
|
});
|
||||||
|
const outsideRule = advanceCombatResourcePool(mana, {
|
||||||
|
inCombat: true,
|
||||||
|
actorLevel: 80,
|
||||||
|
now: 6_000,
|
||||||
|
deltaSeconds: 1,
|
||||||
|
manaSpiritRegenerationPerSecond: 8,
|
||||||
|
});
|
||||||
|
expect(insideRule.current).toBe(54);
|
||||||
|
expect(outsideRule.current).toBe(62);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decays rage and runic power only out of combat", () => {
|
||||||
|
const rage = normalizeActorResourcePool("rage", 20, 100);
|
||||||
|
const runic = normalizeActorResourcePool("runic-power", 20, 100);
|
||||||
|
expect(advanceCombatResourcePool(rage, { inCombat: false, actorLevel: 80, now: 1, deltaSeconds: 1 }).current).toBe(17);
|
||||||
|
expect(advanceCombatResourcePool(runic, { inCombat: false, actorLevel: 80, now: 1, deltaSeconds: 1 }).current).toBe(17.5);
|
||||||
|
expect(advanceCombatResourcePool(rage, { inCombat: true, actorLevel: 80, now: 1, deltaSeconds: 1 }).current).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recharges six Death Knight runes independently", () => {
|
||||||
|
const initial = createDeathKnightRunes();
|
||||||
|
const first = spendDeathKnightRunes(initial, { frost: 1, unholy: 1 }, 1_000)!;
|
||||||
|
expect(first).toHaveLength(6);
|
||||||
|
expect(first.filter((rune) => rune.readyAt === 11_000)).toHaveLength(2);
|
||||||
|
expect(spendDeathKnightRunes(first, { frost: 2 }, 1_001)).toBeNull();
|
||||||
|
const activated = activateDeathKnightRunes(first, 1);
|
||||||
|
expect(activated.filter((rune) => rune.readyAt === 0)).toHaveLength(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
createDeathKnightRunes,
|
||||||
|
type CombatResourcePoolState,
|
||||||
|
type DeathKnightRuneState,
|
||||||
|
type DeathKnightRuneType,
|
||||||
|
} from "./combatActors";
|
||||||
|
|
||||||
|
export const MANA_FIVE_SECOND_RULE_MS = 5_000;
|
||||||
|
export const DEATH_KNIGHT_RUNE_RECHARGE_MS = 10_000;
|
||||||
|
|
||||||
|
export interface CombatResourceAdvanceRules {
|
||||||
|
readonly inCombat: boolean;
|
||||||
|
readonly actorLevel: number;
|
||||||
|
readonly now: number;
|
||||||
|
readonly deltaSeconds: number;
|
||||||
|
readonly manaSpiritRegenerationPerSecond?: number;
|
||||||
|
}
|
||||||
|
export function advanceCombatResourcePool(
|
||||||
|
pool: CombatResourcePoolState,
|
||||||
|
rules: CombatResourceAdvanceRules,
|
||||||
|
): CombatResourcePoolState {
|
||||||
|
const delta = Math.max(0, Math.min(1, Number.isFinite(rules.deltaSeconds) ? rules.deltaSeconds : 0));
|
||||||
|
let rate = pool.regenerationPerSecond;
|
||||||
|
if (pool.id === "mana" && rules.now - pool.lastSpentAt < MANA_FIVE_SECOND_RULE_MS) {
|
||||||
|
rate = Math.max(0, rate - Math.max(0, rules.manaSpiritRegenerationPerSecond ?? 0));
|
||||||
|
}
|
||||||
|
if ((pool.id === "rage" || pool.id === "runic-power") && !rules.inCombat) {
|
||||||
|
rate = pool.id === "rage" ? -3 : -2.5;
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
...pool,
|
||||||
|
current: Math.max(0, Math.min(pool.maximum, pool.current + rate * delta)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuneCost {
|
||||||
|
readonly blood?: number;
|
||||||
|
readonly frost?: number;
|
||||||
|
readonly unholy?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function availableDeathKnightRunes(
|
||||||
|
runes: readonly DeathKnightRuneState[],
|
||||||
|
now: number,
|
||||||
|
): readonly DeathKnightRuneState[] {
|
||||||
|
return runes.filter((rune) => rune.readyAt <= now);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runeMatches(rune: DeathKnightRuneState, type: Exclude<DeathKnightRuneType, "death">): boolean {
|
||||||
|
return rune.type === type || rune.type === "death";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spendDeathKnightRunes(
|
||||||
|
runes: readonly DeathKnightRuneState[] | undefined,
|
||||||
|
cost: RuneCost,
|
||||||
|
now: number,
|
||||||
|
rechargeMs = DEATH_KNIGHT_RUNE_RECHARGE_MS,
|
||||||
|
): readonly DeathKnightRuneState[] | null {
|
||||||
|
const current = runes?.length === 6 ? [...runes] : [...createDeathKnightRunes(now)];
|
||||||
|
const spent = new Set<number>();
|
||||||
|
for (const type of ["blood", "frost", "unholy"] as const) {
|
||||||
|
const count = Math.max(0, Math.trunc(cost[type] ?? 0));
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const candidate = current.find((rune) => (
|
||||||
|
!spent.has(rune.slot) && rune.readyAt <= now && runeMatches(rune, type)
|
||||||
|
));
|
||||||
|
if (!candidate) return null;
|
||||||
|
spent.add(candidate.slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.freeze(current.map((rune) => spent.has(rune.slot)
|
||||||
|
? Object.freeze({ ...rune, readyAt: now + Math.max(1, rechargeMs) })
|
||||||
|
: rune));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activateDeathKnightRunes(
|
||||||
|
runes: readonly DeathKnightRuneState[],
|
||||||
|
count: number,
|
||||||
|
): readonly DeathKnightRuneState[] {
|
||||||
|
let remaining = Math.max(0, Math.trunc(count));
|
||||||
|
return Object.freeze([...runes]
|
||||||
|
.sort((left, right) => right.readyAt - left.readyAt || left.slot - right.slot)
|
||||||
|
.map((rune) => {
|
||||||
|
if (remaining <= 0 || rune.readyAt <= 0) return rune;
|
||||||
|
remaining -= 1;
|
||||||
|
return Object.freeze({ ...rune, readyAt: 0 });
|
||||||
|
})
|
||||||
|
.sort((left, right) => left.slot - right.slot));
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { CLASSES, type ClassId } from "../app/characterCatalog";
|
import { CLASSES, type ClassId } from "../app/characterCatalog";
|
||||||
import { COA_ABILITIES_BY_CLASS, COA_CLASS_RESOURCES } from "./coaAbilityCatalog";
|
import {
|
||||||
|
COA_ABILITIES_BY_CLASS,
|
||||||
|
COA_CLASS_RESOURCES,
|
||||||
|
COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||||
|
} from "./coaAbilityCatalog";
|
||||||
import {
|
import {
|
||||||
COA_RUNTIME_ABILITIES_BY_CLASS,
|
COA_RUNTIME_ABILITIES_BY_CLASS,
|
||||||
COA_RUNTIME_CLASS_RESOURCES,
|
COA_RUNTIME_CLASS_RESOURCES,
|
||||||
|
COA_RUNTIME_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||||
} from "./coaAbilityRuntimeCatalog";
|
} from "./coaAbilityRuntimeCatalog";
|
||||||
import generatedTalentRuntime from "./generated/talentRuntimeCatalog.json";
|
import generatedTalentRuntime from "./generated/talentRuntimeCatalog.json";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +26,7 @@ describe("compact combat runtime catalogs", () => {
|
|||||||
it("preserves every derived COA ability and resource profile", () => {
|
it("preserves every derived COA ability and resource profile", () => {
|
||||||
expect(COA_RUNTIME_ABILITIES_BY_CLASS).toEqual(COA_ABILITIES_BY_CLASS);
|
expect(COA_RUNTIME_ABILITIES_BY_CLASS).toEqual(COA_ABILITIES_BY_CLASS);
|
||||||
expect(COA_RUNTIME_CLASS_RESOURCES).toEqual(COA_CLASS_RESOURCES);
|
expect(COA_RUNTIME_CLASS_RESOURCES).toEqual(COA_CLASS_RESOURCES);
|
||||||
|
expect(COA_RUNTIME_TRIGGERED_ABILITIES_BY_SPELL_ID).toEqual(COA_TRIGGERED_ABILITIES_BY_SPELL_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves every talent field used by runtime allocation and modifiers", () => {
|
it("preserves every talent field used by runtime allocation and modifiers", () => {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
combatHasLineOfSight,
|
||||||
|
isPositionInFacingArc,
|
||||||
|
isSourceBehindTarget,
|
||||||
|
projectCombatGroundPoint,
|
||||||
|
registerCombatSpatialProvider,
|
||||||
|
resetCombatSpatialProvider,
|
||||||
|
} from "./combatSpatial";
|
||||||
|
|
||||||
|
describe("renderer-independent combat spatial queries", () => {
|
||||||
|
afterEach(resetCombatSpatialProvider);
|
||||||
|
|
||||||
|
it("validates facing and behind arcs", () => {
|
||||||
|
const actor = { id: "actor", position: [0, 0, 0] as const, yaw: 0 };
|
||||||
|
expect(isPositionInFacingArc(actor, [0, 0, 5])).toBe(true);
|
||||||
|
expect(isPositionInFacingArc(actor, [0, 0, -5])).toBe(false);
|
||||||
|
expect(isSourceBehindTarget([0, 0, -2], actor)).toBe(true);
|
||||||
|
expect(isSourceBehindTarget([0, 0, 2], actor)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates line of sight and collision-safe ground projection", () => {
|
||||||
|
registerCombatSpatialProvider({
|
||||||
|
actor: () => null,
|
||||||
|
hasLineOfSight: (_source, target) => target[0] >= 0,
|
||||||
|
projectGroundPoint: (requested) => requested[1] > 5 ? null : [requested[0], 0, requested[2]],
|
||||||
|
});
|
||||||
|
expect(combatHasLineOfSight([0, 0, 0], [2, 0, 0])).toBe(true);
|
||||||
|
expect(combatHasLineOfSight([0, 0, 0], [-2, 0, 0])).toBe(false);
|
||||||
|
expect(projectCombatGroundPoint([2, 3, 4], 10)).toEqual([2, 0, 4]);
|
||||||
|
expect(projectCombatGroundPoint([2, 8, 4], 10)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
export type CombatSpatialPosition = readonly [x: number, y: number, z: number];
|
||||||
|
|
||||||
|
export interface CombatSpatialActorSnapshot {
|
||||||
|
readonly id: string;
|
||||||
|
readonly position: CombatSpatialPosition;
|
||||||
|
/** World-space yaw in radians; +Z is forward. */
|
||||||
|
readonly yaw: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatSpatialProvider {
|
||||||
|
readonly actor: (actorId: string) => CombatSpatialActorSnapshot | null;
|
||||||
|
readonly hasLineOfSight: (
|
||||||
|
source: CombatSpatialPosition,
|
||||||
|
target: CombatSpatialPosition,
|
||||||
|
) => boolean;
|
||||||
|
readonly projectGroundPoint: (
|
||||||
|
requested: CombatSpatialPosition,
|
||||||
|
maximumDistance: number,
|
||||||
|
origin?: CombatSpatialPosition,
|
||||||
|
) => CombatSpatialPosition | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let provider: CombatSpatialProvider | null = null;
|
||||||
|
|
||||||
|
/** Registers the loaded dungeon's read-only spatial query seam. */
|
||||||
|
export function registerCombatSpatialProvider(next: CombatSpatialProvider): () => void {
|
||||||
|
provider = next;
|
||||||
|
return () => {
|
||||||
|
if (provider === next) provider = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetCombatSpatialProvider(): void {
|
||||||
|
provider = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function combatSpatialActor(actorId: string): CombatSpatialActorSnapshot | null {
|
||||||
|
return provider?.actor(actorId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function combatHasLineOfSight(
|
||||||
|
source: CombatSpatialPosition,
|
||||||
|
target: CombatSpatialPosition,
|
||||||
|
): boolean {
|
||||||
|
return provider?.hasLineOfSight(source, target) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectCombatGroundPoint(
|
||||||
|
requested: CombatSpatialPosition,
|
||||||
|
maximumDistance: number,
|
||||||
|
origin?: CombatSpatialPosition,
|
||||||
|
): CombatSpatialPosition | null {
|
||||||
|
if (!requested.every(Number.isFinite) || !Number.isFinite(maximumDistance) || maximumDistance < 0) return null;
|
||||||
|
return provider ? provider.projectGroundPoint(requested, maximumDistance, origin) : requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedPlanarDirection(
|
||||||
|
from: CombatSpatialPosition,
|
||||||
|
to: CombatSpatialPosition,
|
||||||
|
): readonly [number, number] | null {
|
||||||
|
const dx = to[0] - from[0];
|
||||||
|
const dz = to[2] - from[2];
|
||||||
|
const length = Math.hypot(dx, dz);
|
||||||
|
return length > 1e-5 ? [dx / length, dz / length] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPositionInFacingArc(
|
||||||
|
source: CombatSpatialActorSnapshot,
|
||||||
|
target: CombatSpatialPosition,
|
||||||
|
arcRadians = Math.PI,
|
||||||
|
): boolean {
|
||||||
|
const direction = normalizedPlanarDirection(source.position, target);
|
||||||
|
if (!direction) return true;
|
||||||
|
const forwardX = Math.sin(source.yaw);
|
||||||
|
const forwardZ = Math.cos(source.yaw);
|
||||||
|
const cosine = forwardX * direction[0] + forwardZ * direction[1];
|
||||||
|
return cosine >= Math.cos(Math.max(0, Math.min(Math.PI * 2, arcRadians)) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSourceBehindTarget(
|
||||||
|
source: CombatSpatialPosition,
|
||||||
|
target: CombatSpatialActorSnapshot,
|
||||||
|
rearArcRadians = Math.PI,
|
||||||
|
): boolean {
|
||||||
|
return !isPositionInFacingArc(target, source, Math.PI * 2 - rearArcRadians);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
EMPTY_CORPSE_DESPAWN_MS,
|
EMPTY_CORPSE_DESPAWN_MS,
|
||||||
MINIMUM_CORPSE_VISIBLE_MS,
|
MINIMUM_CORPSE_VISIBLE_MS,
|
||||||
MOB_WOUND_ANIMATION_COOLDOWN_MS,
|
MOB_WOUND_ANIMATION_COOLDOWN_MS,
|
||||||
|
PLAYER_AURA_ENTITY_ID,
|
||||||
selectedMob,
|
selectedMob,
|
||||||
useCombatStore,
|
useCombatStore,
|
||||||
} from "./combatStore";
|
} from "./combatStore";
|
||||||
@@ -90,7 +91,7 @@ describe("combat store", () => {
|
|||||||
expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
|
expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
|
||||||
useCombatStore.getState().tick(0.1, 2500);
|
useCombatStore.getState().tick(0.1, 2500);
|
||||||
expect(useCombatStore.getState().mobs.ravager.health).toBeLessThan(100);
|
expect(useCombatStore.getState().mobs.ravager.health).toBeLessThan(100);
|
||||||
expect(useCombatStore.getState().resource).toBe(startingResource - 14);
|
expect(useCombatStore.getState().resource).toBe(startingResource - Math.round(startingResource * 0.14));
|
||||||
expect(useCombatStore.getState().activeCast).toBeNull();
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||||
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
|
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -124,6 +125,20 @@ describe("combat store", () => {
|
|||||||
expect(useCombatStore.getState().setActionBinding("primary", "face-bottom", abilityId)).toBe(false);
|
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", () => {
|
it("starts with attribute-derived pools including racial and automatic class passives", () => {
|
||||||
const gnomeMage = deriveCharacterStats("mage", 1, EMPTY_ITEM_STATS, "gnome");
|
const gnomeMage = deriveCharacterStats("mage", 1, EMPTY_ITEM_STATS, "gnome");
|
||||||
useCombatStore.getState().initializeCharacter({ classId: "mage", raceId: "gnome", level: 1 });
|
useCombatStore.getState().initializeCharacter({ classId: "mage", raceId: "gnome", level: 1 });
|
||||||
@@ -936,7 +951,7 @@ describe("combat store", () => {
|
|||||||
expect(useCombatStore.getState().castSlot(0, undefined, 10_000).ok).toBe(true);
|
expect(useCombatStore.getState().castSlot(0, undefined, 10_000).ok).toBe(true);
|
||||||
const boltCompletion = useCombatStore.getState().activeCast!.completesAt;
|
const boltCompletion = useCombatStore.getState().activeCast!.completesAt;
|
||||||
useCombatStore.getState().tick(0.1, boltCompletion);
|
useCombatStore.getState().tick(0.1, boltCompletion);
|
||||||
expect(useCombatStore.getState().resource).toBe(startingMana - 9);
|
expect(useCombatStore.getState().resource).toBe(startingMana - Math.round(startingMana * 0.1 * 0.85));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes a cast-time direct heal to the selected living party member", () => {
|
it("routes a cast-time direct heal to the selected living party member", () => {
|
||||||
@@ -958,6 +973,154 @@ describe("combat store", () => {
|
|||||||
expect(useCombatStore.getState().selectedTargetId).toBe("hostile-selection");
|
expect(useCombatStore.getState().selectedTargetId).toBe("hostile-selection");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fully heals the selected ally with Lay on Hands and starts its cooldown", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 10 });
|
||||||
|
usePartyStore.getState().initializeParty("lay-on-hands-test", 10, useCombatStore.getState().health);
|
||||||
|
const memberId = preparePartyMember(0, [10, 0, 0], 1, true);
|
||||||
|
const memberBefore = usePartyStore.getState().members.find((member) => member.id === memberId)!;
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility(
|
||||||
|
"wow335-paladin-lay-on-hands",
|
||||||
|
[0, 0, 0],
|
||||||
|
1_000,
|
||||||
|
)).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
healing: memberBefore.maxHealth - 1,
|
||||||
|
});
|
||||||
|
expect(usePartyStore.getState().members.find((member) => member.id === memberId)?.health)
|
||||||
|
.toBe(memberBefore.maxHealth);
|
||||||
|
expect(useCombatStore.getState().cooldowns["wow335-paladin-lay-on-hands"])
|
||||||
|
.toBe(1_201_000);
|
||||||
|
expect(useCombatStore.getState().castAbility(
|
||||||
|
"wow335-paladin-lay-on-hands",
|
||||||
|
[0, 0, 0],
|
||||||
|
2_500,
|
||||||
|
)).toMatchObject({ ok: false, reason: "cooldown" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes the selected ally immune to physical attacks with Hand of Protection", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 10 });
|
||||||
|
usePartyStore.getState().initializeParty("hand-of-protection-test", 10, useCombatStore.getState().health);
|
||||||
|
const memberId = preparePartyMember(0, [0, 0, 0], 1, true);
|
||||||
|
const memberBefore = usePartyStore.getState().members.find((member) => member.id === memberId)!;
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility(
|
||||||
|
"wow335-paladin-hand-of-protection",
|
||||||
|
[0, 0, 0],
|
||||||
|
1_000,
|
||||||
|
)).toMatchObject({ ok: true });
|
||||||
|
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
||||||
|
targetId: memberId,
|
||||||
|
expiresAt: 7_000,
|
||||||
|
definition: expect.objectContaining({ name: expect.stringContaining("Hand of Protection") }),
|
||||||
|
}));
|
||||||
|
expect(useCombatStore.getState().cooldowns["wow335-paladin-hand-of-protection"])
|
||||||
|
.toBe(301_000);
|
||||||
|
|
||||||
|
useCombatStore.getState().registerMob("party-victim", {
|
||||||
|
name: "Party victim",
|
||||||
|
maxHealth: 200,
|
||||||
|
xpReward: 0,
|
||||||
|
});
|
||||||
|
setMobPosition("party-victim", [2, 0, 0]);
|
||||||
|
useCombatStore.getState().damageMob("party-victim", 5, 1_000, {
|
||||||
|
actorId: memberId,
|
||||||
|
role: "tank",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().advanceMobCombat(1_450)).toBe(0);
|
||||||
|
expect(usePartyStore.getState().members.find((member) => member.id === memberId)?.health)
|
||||||
|
.toBe(memberBefore.health);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears roots with Hand of Freedom and replaces mutually exclusive seals", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
|
||||||
|
usePartyStore.getState().initializeParty("paladin-aura-test", 80, useCombatStore.getState().health);
|
||||||
|
const memberId = preparePartyMember(0, [0, 0, 0], Number.MAX_SAFE_INTEGER, true);
|
||||||
|
usePartyStore.getState().controlMember(memberId, "root", 30_000);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-paladin-hand-of-freedom", [0, 0, 0], 1_000).ok).toBe(true);
|
||||||
|
expect(usePartyStore.getState().members.find((member) => member.id === memberId)).toMatchObject({
|
||||||
|
controlledUntil: 0,
|
||||||
|
controlMechanic: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
usePartyStore.getState().selectSelf();
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-paladin-seal-of-righteousness", [0, 0, 0], 3_000).ok).toBe(true);
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-paladin-seal-of-wisdom", [0, 0, 0], 5_000).ok).toBe(true);
|
||||||
|
const seals = useCombatStore.getState().auras.filter((aura) => (
|
||||||
|
aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.exclusiveGroup === "paladin:seal"
|
||||||
|
));
|
||||||
|
expect(seals).toHaveLength(1);
|
||||||
|
expect(seals[0].definition.name).toContain("Seal of Wisdom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects Hand of Sacrifice damage to the paladin", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
|
||||||
|
usePartyStore.getState().initializeParty("hand-of-sacrifice-test", 80, useCombatStore.getState().health);
|
||||||
|
const memberId = preparePartyMember(0, [0, 0, 0], Number.MAX_SAFE_INTEGER, true);
|
||||||
|
const memberBefore = usePartyStore.getState().members.find((member) => member.id === memberId)!;
|
||||||
|
const playerBefore = useCombatStore.getState().health;
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-paladin-hand-of-sacrifice", [0, 0, 0], 1_000).ok).toBe(true);
|
||||||
|
useCombatStore.getState().registerMob("sacrifice-attacker", { name: "Sacrifice attacker", maxHealth: 200, xpReward: 0 });
|
||||||
|
setMobPosition("sacrifice-attacker", [2, 0, 0]);
|
||||||
|
useCombatStore.getState().damageMob("sacrifice-attacker", 5, 1_000, { actorId: memberId, role: "tank" });
|
||||||
|
expect(useCombatStore.getState().advanceMobCombat(1_450)).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const memberLoss = memberBefore.health - usePartyStore.getState().members.find((member) => member.id === memberId)!.health;
|
||||||
|
const playerLoss = playerBefore - useCombatStore.getState().health;
|
||||||
|
expect(memberLoss).toBeGreaterThan(0);
|
||||||
|
expect(playerLoss).toBeGreaterThan(0);
|
||||||
|
expect(playerLoss).toBeLessThan(memberLoss);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("taunts up to three attackers off an ally with Righteous Defense", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
|
||||||
|
usePartyStore.getState().initializeParty("righteous-defense-test", 80, useCombatStore.getState().health);
|
||||||
|
const memberId = preparePartyMember(0, [0, 0, 0], Number.MAX_SAFE_INTEGER, true);
|
||||||
|
for (const id of ["attacker-a", "attacker-b", "attacker-c", "attacker-d"]) {
|
||||||
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100, xpReward: 0 });
|
||||||
|
setMobPosition(id, [2, 0, 0]);
|
||||||
|
}
|
||||||
|
useCombatStore.setState((state) => ({
|
||||||
|
mobs: Object.fromEntries(Object.entries(state.mobs).map(([id, mob]) => [id, {
|
||||||
|
...mob,
|
||||||
|
engaged: true,
|
||||||
|
targetActorId: memberId,
|
||||||
|
threatByActor: { [memberId]: 10 },
|
||||||
|
}])),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-paladin-righteous-defense", [0, 0, 0], 1_000).ok).toBe(true);
|
||||||
|
expect(Object.values(useCombatStore.getState().mobs).filter((mob) => mob.forcedTarget?.actorId === PLAYER_AURA_ENTITY_ID)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("channels party-wide classic healing without requiring an enemy target", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||||
|
usePartyStore.getState().initializeParty("divine-hymn-test", 80, useCombatStore.getState().health);
|
||||||
|
const memberId = preparePartyMember(0, [4, 0, 0], 1);
|
||||||
|
useCombatStore.getState().damagePlayer(500);
|
||||||
|
const playerBefore = useCombatStore.getState().health;
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-priest-divine-hymn", [0, 0, 0], 1_000).ok).toBe(true);
|
||||||
|
const completion = useCombatStore.getState().activeCast?.completesAt;
|
||||||
|
if (completion) useCombatStore.getState().tick(1, completion);
|
||||||
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||||
|
expect(useCombatStore.getState().health).toBeGreaterThan(playerBefore);
|
||||||
|
expect(usePartyStore.getState().members.find((member) => member.id === memberId)!.health).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes intentional classic utility spells instead of rejecting them", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
||||||
|
useCombatStore.getState().damagePlayer(500);
|
||||||
|
const before = useCombatStore.getState().health;
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-mage-conjure-food", [0, 0, 0], 1_000).ok).toBe(true);
|
||||||
|
const completion = useCombatStore.getState().activeCast?.completesAt;
|
||||||
|
if (completion) useCombatStore.getState().tick(1, completion);
|
||||||
|
expect(useCombatStore.getState().health).toBeGreaterThan(before);
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to the player and snapshots an explicit null friendly target", () => {
|
it("falls back to the player and snapshots an explicit null friendly target", () => {
|
||||||
registerTarget("enemy", [10, 0, 0], 100, 0);
|
registerTarget("enemy", [10, 0, 0], 100, 0);
|
||||||
const memberId = preparePartyMember(0, [8, 0, 0], 1);
|
const memberId = preparePartyMember(0, [8, 0, 0], 1);
|
||||||
|
|||||||
+2869
-245
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ describe("death respawn", () => {
|
|||||||
useCombatStore.getState().selectMob("respawn-pack:a");
|
useCombatStore.getState().selectMob("respawn-pack:a");
|
||||||
useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000);
|
useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000);
|
||||||
expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true);
|
expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true);
|
||||||
expect(useCombatStore.getState().resource).toBe(startingResource - 15);
|
expect(useCombatStore.getState().resource).toBe(startingResource - Math.round(startingResource * 0.15));
|
||||||
|
|
||||||
const revisionBefore = useGameStore.getState().resetRevision;
|
const revisionBefore = useGameStore.getState().resetRevision;
|
||||||
useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 });
|
useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 });
|
||||||
|
|||||||
@@ -223,6 +223,23 @@ describe("generated dungeon campaign catalog", () => {
|
|||||||
.toEqual([-819.8267, 39.4232, -87.3708]);
|
.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", () => {
|
it("starts Razorfen Kraul inside the portal and maps its first pack into the WMO", () => {
|
||||||
const razorfenKraul = requireDungeonDefinition("razorfen-kraul");
|
const razorfenKraul = requireDungeonDefinition("razorfen-kraul");
|
||||||
const firstPackSpawn = razorfenKraul.staticSpawns.find(
|
const firstPackSpawn = razorfenKraul.staticSpawns.find(
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
||||||
|
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
|
||||||
|
function registerTarget(id = "target", position: readonly [number, number, number] = [0, 0, 10]) {
|
||||||
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
|
||||||
|
setMobPosition(id, position);
|
||||||
|
useCombatStore.getState().selectMob(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dungeon combat fidelity integration", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetCombatSpatialProvider();
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 10 });
|
||||||
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||||
|
usePartyStore.getState().initializeParty("fidelity", 10, useCombatStore.getState().health);
|
||||||
|
});
|
||||||
|
afterEach(resetCombatSpatialProvider);
|
||||||
|
|
||||||
|
it("rejects obstructed and rear-arc hostile casts while friendly healing ignores facing", () => {
|
||||||
|
registerTarget();
|
||||||
|
let blocked = true;
|
||||||
|
registerCombatSpatialProvider({
|
||||||
|
actor: (id) => id === PLAYER_AGGRO_ID
|
||||||
|
? { id, position: [0, 0, 0], yaw: Math.PI }
|
||||||
|
: { id, position: getMobPosition(id) ?? [0, 0, 0], yaw: 0 },
|
||||||
|
hasLineOfSight: () => !blocked,
|
||||||
|
projectGroundPoint: (point) => point,
|
||||||
|
});
|
||||||
|
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 })).toMatchObject({ ok: false, reason: "line-of-sight" });
|
||||||
|
blocked = false;
|
||||||
|
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 })).toMatchObject({ ok: false, reason: "not-facing" });
|
||||||
|
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 10 });
|
||||||
|
const ally = usePartyStore.getState().members[0];
|
||||||
|
registerPartyRuntimePosition(ally.id, [0, 0, 10]);
|
||||||
|
usePartyStore.getState().selectMember(ally.id);
|
||||||
|
usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === ally.id ? { ...member, health: 1 } : member) }));
|
||||||
|
const friendlyResult = useCombatStore.getState().castAbility("wow335-priest-lesser-heal", { now: 2_000 });
|
||||||
|
expect(friendlyResult.reason).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies only the first two direct-damage pushbacks and ignores fully absorbed hits", () => {
|
||||||
|
registerTarget();
|
||||||
|
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 }).ok).toBe(true);
|
||||||
|
const original = useCombatStore.getState().activeCast!.completesAt;
|
||||||
|
useCombatStore.getState().damagePlayer(1);
|
||||||
|
useCombatStore.getState().damagePlayer(1);
|
||||||
|
useCombatStore.getState().damagePlayer(1);
|
||||||
|
expect(useCombatStore.getState().activeCast).toMatchObject({ completesAt: original + 1_000, pushbackHits: 2 });
|
||||||
|
|
||||||
|
useCombatStore.getState().cancelCast();
|
||||||
|
useCombatStore.setState({ globalCooldownEndsAt: 0, shield: 10_000, shieldExpiresAt: Number.MAX_SAFE_INTEGER });
|
||||||
|
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 5_000 }).ok).toBe(true);
|
||||||
|
const absorbedOriginal = useCombatStore.getState().activeCast!.completesAt;
|
||||||
|
useCombatStore.getState().damagePlayer(100);
|
||||||
|
expect(useCombatStore.getState().activeCast!.completesAt).toBe(absorbedOriginal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates persistent collision-safe ground effects and dynamically ticks actors in their radius", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
|
||||||
|
registerTarget("late-target", [20, 0, 0]);
|
||||||
|
registerCombatSpatialProvider({
|
||||||
|
actor: (id) => ({ id, position: id === PLAYER_AGGRO_ID ? [0, 0, 0] : getMobPosition(id) ?? [0, 0, 0], yaw: 0 }),
|
||||||
|
hasLineOfSight: () => true,
|
||||||
|
projectGroundPoint: (point) => [point[0], 0, point[2]],
|
||||||
|
});
|
||||||
|
const groundResult = useCombatStore.getState().castAbility("paladin-consecration", { now: 1_000, groundPosition: [0, 3, 0] });
|
||||||
|
expect(groundResult.reason).toBeUndefined();
|
||||||
|
expect(useCombatStore.getState().areaEffects).toHaveLength(1);
|
||||||
|
setMobPosition("late-target", [2, 0, 0]);
|
||||||
|
const before = useCombatStore.getState().mobs["late-target"].health;
|
||||||
|
useCombatStore.getState().tick(1, 2_000);
|
||||||
|
expect(useCombatStore.getState().mobs["late-target"].health).toBeLessThan(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spends independent Death Knight runes and exposes every combatant through actor snapshots", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 10 });
|
||||||
|
registerTarget("rune-target", [0, 0, 10]);
|
||||||
|
expect(useCombatStore.getState().castAbility("death-knight-icy-touch", { now: 1_000 }).ok).toBe(true);
|
||||||
|
expect(useCombatStore.getState().deathKnightRunes.filter((rune) => rune.readyAt > 1_000)).toHaveLength(1);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
const actors = useCombatStore.getState().actors;
|
||||||
|
expect(actors[PLAYER_AGGRO_ID].runes).toHaveLength(6);
|
||||||
|
expect(actors["rune-target"].kind).toBe("enemy");
|
||||||
|
for (const member of usePartyStore.getState().members) expect(actors[member.id].kind).toBe("companion");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits structured effective damage and healing records", () => {
|
||||||
|
registerTarget("event-target", [0, 0, 10]);
|
||||||
|
useCombatStore.getState().damageMob("event-target", 25, 1_000);
|
||||||
|
useCombatStore.getState().damagePlayer(10);
|
||||||
|
useCombatStore.getState().healPlayer(100);
|
||||||
|
const events = useCombatStore.getState().combatEvents;
|
||||||
|
expect(events.some((event) => event.kind === "damage" && event.targetActorId === "event-target" && event.effectiveAmount > 0)).toBe(true);
|
||||||
|
expect(events.some((event) => event.kind === "healing" && event.overheal >= 0)).toBe(true);
|
||||||
|
expect(events.every((event) => event.rawAmount >= event.effectiveAmount)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { abilitiesForClass, abilityAtLevel, abilityById, type AbilityDefinition } from "./abilityCatalog";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
||||||
|
import { targetingRulesForAbility } from "./abilityRules";
|
||||||
|
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
|
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
|
||||||
|
function registerMob(id: string, position: readonly [number, number, number], boss = false): void {
|
||||||
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 10_000, xpReward: 0, boss });
|
||||||
|
setMobPosition(id, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("extended dungeon combat fidelity", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetCombatSpatialProvider();
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||||
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||||
|
usePartyStore.getState().initializeParty("actor-authority", 80, useCombatStore.getState().health);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(resetCombatSpatialProvider);
|
||||||
|
|
||||||
|
it("keeps companion vitals authoritative when compatibility party records are stale", () => {
|
||||||
|
const member = usePartyStore.getState().members[0];
|
||||||
|
const actor = useCombatStore.getState().actors[member.id];
|
||||||
|
useCombatStore.setState((state) => ({
|
||||||
|
actors: { ...state.actors, [member.id]: { ...actor, health: 7, alive: true } },
|
||||||
|
}));
|
||||||
|
usePartyStore.setState((state) => ({
|
||||||
|
members: state.members.map((candidate) => candidate.id === member.id
|
||||||
|
? { ...candidate, health: candidate.maxHealth }
|
||||||
|
: candidate),
|
||||||
|
}));
|
||||||
|
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().actors[member.id].health).toBe(7);
|
||||||
|
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)?.health).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binds combo points to the enemy that generated them", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 20 });
|
||||||
|
registerMob("combo-a", [0, 0, 3]);
|
||||||
|
registerMob("combo-b", [1, 0, 2]);
|
||||||
|
useCombatStore.getState().selectMob("combo-a");
|
||||||
|
expect(useCombatStore.getState().castAbility("rogue-sinister-strike", { now: 1_000 }).ok).toBe(true);
|
||||||
|
expect(useCombatStore.getState()).toMatchObject({ comboPoints: 1, comboTargetId: "combo-a" });
|
||||||
|
|
||||||
|
useCombatStore.setState((state) => ({
|
||||||
|
globalCooldownEndsAt: 0,
|
||||||
|
cooldowns: {},
|
||||||
|
resource: state.maxResource,
|
||||||
|
resourcePools: { ...state.resourcePools, energy: state.maxResource },
|
||||||
|
}));
|
||||||
|
useCombatStore.getState().selectMob("combo-b");
|
||||||
|
const secondStrike = useCombatStore.getState().castAbility("rogue-sinister-strike", { now: 2_000 });
|
||||||
|
expect(secondStrike.ok, secondStrike.reason).toBe(true);
|
||||||
|
expect(useCombatStore.getState()).toMatchObject({ comboPoints: 1, comboTargetId: "combo-b" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies boss control and taunt diminishing returns through immunity", () => {
|
||||||
|
registerMob("dr-boss", [0, 0, 3], true);
|
||||||
|
const durations: number[] = [];
|
||||||
|
for (const now of [1_000, 2_000, 3_000]) {
|
||||||
|
expect(useCombatStore.getState().controlMob("dr-boss", `stun-${now}`, "stun", 8_000, now)).toBe(true);
|
||||||
|
const status = useCombatStore.getState().mobs["dr-boss"].statuses.find((entry) => entry.kind === "stun")!;
|
||||||
|
durations.push(status.endsAt - now);
|
||||||
|
}
|
||||||
|
expect(durations).toEqual([3_000, 1_500, 750]);
|
||||||
|
expect(useCombatStore.getState().controlMob("dr-boss", "stun-immune", "stun", 8_000, 4_000)).toBe(false);
|
||||||
|
|
||||||
|
const tauntDurations: number[] = [];
|
||||||
|
for (const now of [5_000, 6_000, 7_000]) {
|
||||||
|
expect(useCombatStore.getState().tauntMob("dr-boss", { actorId: PLAYER_AGGRO_ID, role: "tank" }, 3_000, now)).toBe(true);
|
||||||
|
tauntDurations.push(useCombatStore.getState().mobs["dr-boss"].forcedTarget!.endsAt - now);
|
||||||
|
}
|
||||||
|
expect(tauntDurations).toEqual([3_000, 1_950, 1_268]);
|
||||||
|
expect(useCombatStore.getState().tauntMob("dr-boss", { actorId: PLAYER_AGGRO_ID, role: "tank" }, 3_000, 8_000)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves and confirms a collision-projected ground reticle", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
||||||
|
const groundAbility = abilitiesForClass("mage")
|
||||||
|
.map((ability) => abilityAtLevel(ability, 80))
|
||||||
|
.find((ability) => targetingRulesForAbility(ability).shape === "ground" && ability.range.max > 1)!;
|
||||||
|
expect(groundAbility).toBeDefined();
|
||||||
|
registerCombatSpatialProvider({
|
||||||
|
actor: (id) => ({ id, position: [0, 0, 0], yaw: 0 }),
|
||||||
|
hasLineOfSight: () => true,
|
||||||
|
projectGroundPoint: (point, maximumDistance, origin = [0, 0, 0]) => {
|
||||||
|
const dx = point[0] - origin[0];
|
||||||
|
const dz = point[2] - origin[2];
|
||||||
|
const distance = Math.hypot(dx, dz);
|
||||||
|
const scale = distance > maximumDistance ? maximumDistance / distance : 1;
|
||||||
|
return [origin[0] + dx * scale, 0, origin[2] + dz * scale];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(useCombatStore.getState().beginGroundTarget(groundAbility.id)).toBe(true);
|
||||||
|
const before = useCombatStore.getState().pendingGroundTarget!;
|
||||||
|
expect(before.valid).toBe(true);
|
||||||
|
useCombatStore.getState().moveGroundTarget(1, 0, 0, 0.1);
|
||||||
|
const moved = useCombatStore.getState().pendingGroundTarget!;
|
||||||
|
expect(moved.position).not.toEqual(before.position);
|
||||||
|
expect(Math.hypot(moved.position[0], moved.position[2])).toBeLessThanOrEqual(moved.maximumRange);
|
||||||
|
expect(useCombatStore.getState().confirmGroundTarget(undefined, 1_000).ok).toBe(true);
|
||||||
|
expect(useCombatStore.getState().pendingGroundTarget).toBeNull();
|
||||||
|
const activeCast = useCombatStore.getState().activeCast;
|
||||||
|
if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt);
|
||||||
|
expect(useCombatStore.getState().areaEffects).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps summons targetable, credits their owner, replaces totems, and expires them", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "rom-warden", raceId: "rom-elf", level: 20 });
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
registerMob("summon-target", [0, 0, 3]);
|
||||||
|
useCombatStore.getState().selectMob("summon-target");
|
||||||
|
expect(useCombatStore.getState().castAbility("rom-warden-primary-1", { now: 1_000 }).ok).toBe(true);
|
||||||
|
useCombatStore.getState().tick(0.1, 5_000);
|
||||||
|
const summon = useCombatStore.getState().summons[0];
|
||||||
|
expect(summon).toBeDefined();
|
||||||
|
expect(useCombatStore.getState().actors[summon.id]).toMatchObject({ kind: "summon", ownerId: PLAYER_AGGRO_ID, alive: true });
|
||||||
|
expect(useCombatStore.getState().selectFriendlyActor(summon.id)).toBe(true);
|
||||||
|
|
||||||
|
const beforeDamage = summon.health;
|
||||||
|
expect(useCombatStore.getState().damageSummon(summon.id, 5)).toBe(5);
|
||||||
|
expect(useCombatStore.getState().actors[summon.id].health).toBe(beforeDamage - 5);
|
||||||
|
expect(useCombatStore.getState().healActor(summon.id, 5, { sourceActorId: PLAYER_AGGRO_ID }).effectiveAmount).toBe(5);
|
||||||
|
|
||||||
|
const baseAbility = abilityById("rom-warden-primary-1")!;
|
||||||
|
const combatSummonAbility: AbilityDefinition = { ...baseAbility, id: "test-guardian", name: "Test Guardian" };
|
||||||
|
useCombatStore.getState().summonActor(
|
||||||
|
PLAYER_AGGRO_ID,
|
||||||
|
combatSummonAbility,
|
||||||
|
{ kind: "summon", creatureId: 2, coefficient: 0.5, durationMs: 10_000 },
|
||||||
|
[0, 0, 0],
|
||||||
|
"summon-target",
|
||||||
|
6_000,
|
||||||
|
);
|
||||||
|
useCombatStore.getState().tick(1, 7_500);
|
||||||
|
expect(useCombatStore.getState().mobs["summon-target"].threatByActor[PLAYER_AGGRO_ID]).toBeGreaterThan(0);
|
||||||
|
expect(useCombatStore.getState().mobs["summon-target"].threatByActor[summon.id] ?? 0).toBe(0);
|
||||||
|
|
||||||
|
const totemAbility: AbilityDefinition = { ...baseAbility, id: "test-totem", name: "Test Totem" };
|
||||||
|
const totemEffect = { kind: "summon" as const, creatureId: 1, coefficient: 0.2, durationMs: 10_000 };
|
||||||
|
useCombatStore.getState().summonActor(PLAYER_AGGRO_ID, totemAbility, totemEffect, [0, 0, 0], null, 8_000);
|
||||||
|
useCombatStore.getState().summonActor(PLAYER_AGGRO_ID, totemAbility, totemEffect, [1, 0, 0], null, 8_100);
|
||||||
|
expect(useCombatStore.getState().summons.filter((candidate) => candidate.exclusiveGroup === `${PLAYER_AGGRO_ID}:totem`)).toHaveLength(1);
|
||||||
|
|
||||||
|
useCombatStore.getState().tick(0.1, 20_001);
|
||||||
|
expect(useCombatStore.getState().summons.some((candidate) => candidate.id === summon.id)).toBe(false);
|
||||||
|
expect(useCombatStore.getState().actors[summon.id]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes damaging abilities from Classic, RuneWaker, and CoA catalogs through one runtime", () => {
|
||||||
|
const cases = [
|
||||||
|
{ classId: "mage" as const, level: 20, source: "classic" },
|
||||||
|
{ classId: "rom-mage" as const, level: 20, source: "runewaker" },
|
||||||
|
{ classId: "stormbringer" as const, level: 60, source: "coa" },
|
||||||
|
];
|
||||||
|
registerCombatSpatialProvider({
|
||||||
|
actor: (id) => ({ id, position: id === PLAYER_AGGRO_ID ? [0, 0, 0] : getMobPosition(id) ?? [0, 0, 0], yaw: 0 }),
|
||||||
|
hasLineOfSight: () => true,
|
||||||
|
projectGroundPoint: (point) => point,
|
||||||
|
});
|
||||||
|
for (const entry of cases) {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: entry.classId, level: entry.level });
|
||||||
|
const ability = abilitiesForClass(entry.classId)
|
||||||
|
.map((candidate) => abilityAtLevel(candidate, entry.level))
|
||||||
|
.find((candidate) => (
|
||||||
|
!candidate.passive
|
||||||
|
&& candidate.target === "hostile"
|
||||||
|
&& candidate.talentEntryId === undefined
|
||||||
|
&& candidate.effects.some((effect) => effect.kind === "damage")
|
||||||
|
));
|
||||||
|
expect(ability, `${entry.source} executable damage ability`).toBeDefined();
|
||||||
|
const id = `${entry.source}-target`;
|
||||||
|
const range = Math.max(1, Math.min(10, ability!.range.max));
|
||||||
|
registerMob(id, [0, 0, range]);
|
||||||
|
useCombatStore.getState().selectMob(id);
|
||||||
|
const before = useCombatStore.getState().mobs[id].health;
|
||||||
|
const cast = useCombatStore.getState().castAbility(ability!.id, { now: 1_000 });
|
||||||
|
expect(cast.ok, `${entry.source} cast: ${cast.reason ?? "ok"}`).toBe(true);
|
||||||
|
const activeCast = useCombatStore.getState().activeCast;
|
||||||
|
if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt);
|
||||||
|
expect(useCombatStore.getState().mobs[id].health, `${entry.source} damage`).toBeLessThan(before);
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,10 +8,12 @@ import { usePartyStore } from "./partyStore";
|
|||||||
import { useGameStore, type GameMode } from "./store";
|
import { useGameStore, type GameMode } from "./store";
|
||||||
import { dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
|
import { dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
|
||||||
import type { WorldSpawn } from "./dungeonTypes";
|
import type { WorldSpawn } from "./dungeonTypes";
|
||||||
|
import { useManastormAdminStore } from "./manastormAdminStore";
|
||||||
|
|
||||||
export interface DungeonSessionOptions {
|
export interface DungeonSessionOptions {
|
||||||
readonly gameMode?: GameMode;
|
readonly gameMode?: GameMode;
|
||||||
readonly spawn?: WorldSpawn;
|
readonly spawn?: WorldSpawn;
|
||||||
|
readonly gmTestMode?: boolean;
|
||||||
/** Used by in-game encounter transitions so character progression and inventory survive. */
|
/** Used by in-game encounter transitions so character progression and inventory survive. */
|
||||||
readonly preserveCharacter?: boolean;
|
readonly preserveCharacter?: boolean;
|
||||||
}
|
}
|
||||||
@@ -72,7 +74,8 @@ export function activateDungeonSession(
|
|||||||
}
|
}
|
||||||
useGameStore.getState().activateDungeon(dungeonId as DungeonId, difficultyId, {
|
useGameStore.getState().activateDungeon(dungeonId as DungeonId, difficultyId, {
|
||||||
gameMode: options.gameMode,
|
gameMode: options.gameMode,
|
||||||
spawn: options.spawn,
|
spawn: options.spawn ?? useManastormAdminStore.getState().config.dungeonSpawns[dungeonId],
|
||||||
|
gmTestMode: options.gmTestMode,
|
||||||
});
|
});
|
||||||
useCombatStore.getState().setPlayerPosition(useGameStore.getState().playerPosition);
|
useCombatStore.getState().setPlayerPosition(useGameStore.getState().playerPosition);
|
||||||
return true;
|
return true;
|
||||||
@@ -87,6 +90,7 @@ export function restartActiveDungeonSession(): boolean {
|
|||||||
return activateDungeonSession(game.activeDungeonId, game.activeDifficultyId, {
|
return activateDungeonSession(game.activeDungeonId, game.activeDifficultyId, {
|
||||||
gameMode: game.gameMode,
|
gameMode: game.gameMode,
|
||||||
spawn: game.activeSpawn,
|
spawn: game.activeSpawn,
|
||||||
|
gmTestMode: game.gmTestMode,
|
||||||
preserveCharacter: true,
|
preserveCharacter: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import { dungeonDefinitionById } from "./dungeonRegistry";
|
|||||||
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
|
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
|
||||||
import { usePartyStore } from "./partyStore";
|
import { usePartyStore } from "./partyStore";
|
||||||
import { useGameStore } from "./store";
|
import { useGameStore } from "./store";
|
||||||
|
import { DEFAULT_MANASTORM_ADMIN_CONFIG } from "./manastormAdminConfig";
|
||||||
|
import { useManastormAdminStore } from "./manastormAdminStore";
|
||||||
|
|
||||||
describe("dungeon session asset eviction", () => {
|
describe("dungeon session asset eviction", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
useManastormAdminStore.setState({ config: DEFAULT_MANASTORM_ADMIN_CONFIG, draft: DEFAULT_MANASTORM_ADMIN_CONFIG, dirty: false });
|
||||||
useGameStore.getState().activateDungeon("wailing-caverns");
|
useGameStore.getState().activateDungeon("wailing-caverns");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,4 +47,13 @@ describe("dungeon session asset eviction", () => {
|
|||||||
expect(useGameStore.getState().sessionRevision).toBe(revision + 1);
|
expect(useGameStore.getState().sessionRevision).toBe(revision + 1);
|
||||||
expect(usePartyStore.getState().members.map((member) => member.id)).toEqual(members);
|
expect(usePartyStore.getState().members.map((member) => member.id)).toEqual(members);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export type EnemyDamageSchool =
|
|||||||
| "shadow"
|
| "shadow"
|
||||||
| "holy";
|
| "holy";
|
||||||
|
|
||||||
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun";
|
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun" | "silence";
|
||||||
|
|
||||||
export type EnemyAbilityEffect =
|
export type EnemyAbilityEffect =
|
||||||
| { readonly kind: "damage"; readonly multiplier: number }
|
| { readonly kind: "damage"; readonly multiplier: number }
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user