forked from phenom/healer-man
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eca6b5db9b | ||
|
|
b12fb84859 | ||
|
|
55cfd43d66 |
No files matched your search
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
- 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`;
|
||||
- releasing right mouse stops camera motion;
|
||||
- no browser mouse-capture prompt appears.
|
||||
|
||||
+2
-1
@@ -185,6 +185,7 @@ on the development PC. Stop if any test or build fails:
|
||||
|
||||
```powershell
|
||||
Set-Location F:\Projects\HealerMan
|
||||
npm run release:check
|
||||
|
||||
$VersionName = '0.1.3'
|
||||
$VersionCode = 1003
|
||||
@@ -206,8 +207,8 @@ npm run build
|
||||
npm test -- --run src/avatar
|
||||
npx vitest run src/game/inputManager.test.ts src/game/inputMath.test.ts
|
||||
|
||||
npm run release:check
|
||||
git status --short
|
||||
git diff --check
|
||||
git add -A
|
||||
git status --short
|
||||
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,
|
||||
camera, input, physics, mob-rendering, or asset-loading code changes.
|
||||
|
||||
## 2026-08-18 GM Manastorm administration - COMPLETE
|
||||
|
||||
- Server-authorized `Phenom` access, revisioned SQLite configuration, safe
|
||||
all-Needs-tested defaults, public/offline caching, optimistic conflicts, and
|
||||
denial/revision logging were exercised through the production HTTP surface.
|
||||
- The GM hub exposed all 406 deduplicated boss records, readiness and runnable
|
||||
state, pool filters, six global switches, dungeon entry, placement editing,
|
||||
and GM tests. A fresh revision correctly reported zero Ready bosses.
|
||||
- Live Wailing Caverns authoring captured and nudged the shared group anchor,
|
||||
displayed derived party, boss, portal, and exact linked-mob data, and saved a
|
||||
three-mob / two-kill Chaotic Link stage. The desktop Save control was moved
|
||||
above the Tactical control after the playtest found their hit targets
|
||||
overlapping; a normal semantic click then published the next revision.
|
||||
- Promoting an imported encounter directly to Ready initially exposed missing
|
||||
stage materialization at the server boundary. The readiness transition now
|
||||
persists its complete inherited stage. Cross-mode duplicates also merge
|
||||
their mode memberships, so a boss imported for both leveling and end-game is
|
||||
represented once and remains eligible in both modes.
|
||||
- Live pool acceptance promoted Lady Anacondra and Burning Felguard, entered a
|
||||
level-1 public Manastorm, then marked the active Lady Anacondra record Not
|
||||
ready from a parallel GM client. The existing run retained Lady Anacondra and
|
||||
its two-stack Chaotic Link snapshot; after reload, the next run selected only
|
||||
Burning Felguard. This verified new-run publication and active-run
|
||||
immutability together.
|
||||
- Desktop and Thor-preview passes rendered the DOM editor over the dungeon,
|
||||
retained camera/game separation while the panel was open, and exposed the GM
|
||||
pause-menu entry. Console inspection found no errors and only the existing
|
||||
third-party initialization deprecation warning.
|
||||
- Verification passed: server **7/7**, full Vitest **121 files / 699 tests**,
|
||||
TypeScript, production build, and the KTX2 audit of 11,839 compressed textures
|
||||
with zero fallbacks. The build retained the existing informational
|
||||
large-chunk advisory.
|
||||
- Evidence: `playtest-artifacts/2026-08-18-gm-manastorm-admin/desktop-admin-hub.png`,
|
||||
`desktop-world-editor-final.png`, and `thor-world-editor.png`.
|
||||
|
||||
## 2026-08-17 authentic combat VFX and audio - COMPLETE
|
||||
|
||||
- Player, party, and enemy combat actions now publish transient presentation
|
||||
@@ -1386,3 +1421,33 @@ Vite's existing large-chunk advisory remains informational.
|
||||
- Verification: 38 focused party/pathing/jump/animation tests passed, all 28
|
||||
avatar tests passed, and `npm run build` completed successfully with 954
|
||||
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
|
||||
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
|
||||
`healer-man-release-password.dpapi` file is present, blank signing-password
|
||||
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:
|
||||
|
||||
```powershell
|
||||
git diff --check
|
||||
npm run release:check
|
||||
git status --short
|
||||
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:
|
||||
|
||||
```powershell
|
||||
@@ -520,7 +538,7 @@ Set-Location F:\Projects\HealerMan
|
||||
npm run content:test
|
||||
npm run build
|
||||
git status --short
|
||||
git diff --check
|
||||
npm run release:check
|
||||
git add -A
|
||||
git status --short
|
||||
git diff --cached --stat
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"clientBuild": "3.3.5a-Ascension",
|
||||
"mapDirectory": "World/Maps/StormwindJail",
|
||||
"environmentMode": "global-wmo",
|
||||
"globalWmoAlignment": "source",
|
||||
"difficultyVariants": [
|
||||
{
|
||||
"id": "normal",
|
||||
|
||||
Generated
+3
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.7",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "8.4.1",
|
||||
"@dimforge/rapier3d-compat": "0.19.2",
|
||||
"@gltf-transform/cli": "^4.4.1",
|
||||
"@gltf-transform/core": "^4.4.2",
|
||||
"@recast-navigation/core": "0.43.1",
|
||||
|
||||
+7
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
@@ -20,7 +20,8 @@
|
||||
"coa:sync:live": "node scripts/sync-ascension-coa.mjs --tooltips",
|
||||
"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: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:forensics": "node scripts/manastorm-import/forensics-cli.mjs",
|
||||
"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:audit": "node scripts/asset-pipeline/audit-ktx2.mjs --visual-only --require-all",
|
||||
"assets:ktx2:refresh-metadata": "node scripts/asset-pipeline/refresh-ktx2-metadata.mjs",
|
||||
"prebuild": "npm run loot:generate && npm run assets:ktx2:audit",
|
||||
"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:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android",
|
||||
"android:sync": "npm run build:android:web && cap sync android",
|
||||
"android:apk": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleDebug",
|
||||
"android:apk:release": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleRelease",
|
||||
"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",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"pretest": "npm run loot:generate",
|
||||
@@ -118,6 +121,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "8.4.1",
|
||||
"@dimforge/rapier3d-compat": "0.19.2",
|
||||
"@gltf-transform/cli": "^4.4.1",
|
||||
"@gltf-transform/core": "^4.4.2",
|
||||
"@recast-navigation/core": "0.43.1",
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
{
|
||||
"id": "visual",
|
||||
"url": "/assets/game/manastorm/230-blackrockdepths/visual.glb",
|
||||
"checksum": "42d57961c31f54ecc7bc78829751110fc89294e6318336b8f260e132e478d13d",
|
||||
"byteLength": 10266900,
|
||||
"checksum": "7e4c7c75a898f5000fcb686dcfc6d3c1c4e675dc3dac5f7a8d48e53372b9f364",
|
||||
"byteLength": 7068884,
|
||||
"triangleCount": 248302,
|
||||
"compression": "meshopt+ktx2"
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
{
|
||||
"id": "visual",
|
||||
"url": "/assets/game/manastorm/230-blackrockdepths/visual.glb",
|
||||
"checksum": "42d57961c31f54ecc7bc78829751110fc89294e6318336b8f260e132e478d13d",
|
||||
"byteLength": 10266900,
|
||||
"checksum": "7e4c7c75a898f5000fcb686dcfc6d3c1c4e675dc3dac5f7a8d48e53372b9f364",
|
||||
"byteLength": 7068884,
|
||||
"triangleCount": 248302,
|
||||
"compression": "meshopt+ktx2"
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.4",
|
||||
"versionCode": 1004
|
||||
"versionName": "0.1.7",
|
||||
"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",
|
||||
]);
|
||||
const GLOBAL_WMO_SOURCE_BASES = new Set(["draft", "flip-x"]);
|
||||
const GLOBAL_WMO_ALIGNMENTS = new Set(["player-anchor", "source"]);
|
||||
|
||||
function sourceBasisFor(recipe, environmentMode) {
|
||||
if (environmentMode !== "adt-hybrid") {
|
||||
@@ -220,6 +221,15 @@ function sourceBasisFor(recipe, environmentMode) {
|
||||
return basis;
|
||||
}
|
||||
|
||||
function coordinateAlignmentFor(recipe, environmentMode) {
|
||||
if (environmentMode !== "global-wmo") return "player-anchor";
|
||||
const alignment = recipe.globalWmoAlignment ?? "player-anchor";
|
||||
if (!GLOBAL_WMO_ALIGNMENTS.has(alignment)) {
|
||||
throw new Error(`${recipe.slug}: unsupported global WMO alignment ${alignment}.`);
|
||||
}
|
||||
return alignment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime drafts retain the original pipeline convention. wow.export ADT
|
||||
* packages have an additional horizontal basis change baked into their GLBs.
|
||||
@@ -238,7 +248,10 @@ function sourceToPackageBasis(position, environmentMode, sourceBasis) {
|
||||
: point;
|
||||
}
|
||||
|
||||
function coordinateMapper(draft, environmentMode, playerAnchor, sourceBasis) {
|
||||
function coordinateMapper(draft, environmentMode, playerAnchor, sourceBasis, alignment) {
|
||||
if (alignment === "source") {
|
||||
return (position) => sourceToPackageBasis(position, environmentMode, sourceBasis);
|
||||
}
|
||||
const sourceEntrance = draft?.entrance
|
||||
?? draft?.spawns?.[0]?.position
|
||||
?? playerAnchor.position;
|
||||
@@ -764,6 +777,7 @@ async function compile() {
|
||||
}
|
||||
const environmentMode = inferEnvironmentMode(recipe, assetPackage);
|
||||
const sourceBasis = sourceBasisFor(recipe, environmentMode);
|
||||
const coordinateAlignment = coordinateAlignmentFor(recipe, environmentMode);
|
||||
const playerAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "player");
|
||||
const trashAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "trash");
|
||||
const bossAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "boss");
|
||||
@@ -794,6 +808,12 @@ async function compile() {
|
||||
environmentMode,
|
||||
playerAnchor,
|
||||
sourceBasis,
|
||||
coordinateAlignment,
|
||||
);
|
||||
const entrancePosition = mapPosition(
|
||||
draft?.entrance
|
||||
?? draft?.spawns?.[0]?.position
|
||||
?? playerAnchor.position,
|
||||
);
|
||||
const sourceEntities = (draft?.entities ?? [])
|
||||
.filter((entity) => (
|
||||
@@ -966,7 +986,7 @@ async function compile() {
|
||||
});
|
||||
for (let index = 0; index < 15; index += 1) {
|
||||
const segment = index < 6
|
||||
? interpolate(playerAnchor.position, trashAnchor.position, 0.25 + index * 0.1)
|
||||
? interpolate(entrancePosition, trashAnchor.position, 0.25 + index * 0.1)
|
||||
: interpolate(trashAnchor.position, bossAnchor.position, 0.08 + (index - 6) * 0.085);
|
||||
staticSpawns.push({
|
||||
id: `generated-trash-${index + 1}`,
|
||||
@@ -979,7 +999,7 @@ async function compile() {
|
||||
}
|
||||
|
||||
const allPoints = [
|
||||
playerAnchor.position,
|
||||
entrancePosition,
|
||||
trashAnchor.position,
|
||||
bossAnchor.position,
|
||||
...staticSpawns.map((spawn) => spawn.position),
|
||||
@@ -1010,7 +1030,9 @@ async function compile() {
|
||||
modelCoverageWarning,
|
||||
...(syntheticTrash
|
||||
? ["Trash identities and placements are procedural; boss identities come from the installed Ascension encounter catalog."]
|
||||
: [`Server positions were aligned to the packaged ${environmentMode} environment at its reviewed player anchor.`]),
|
||||
: coordinateAlignment === "source"
|
||||
? [`Server positions use the packaged ${environmentMode} environment's native world coordinates.`]
|
||||
: [`Server positions were aligned to the packaged ${environmentMode} environment at its reviewed player anchor.`]),
|
||||
];
|
||||
|
||||
definitions.push({
|
||||
@@ -1034,12 +1056,12 @@ async function compile() {
|
||||
bounds,
|
||||
entrance: {
|
||||
name: `${recipe.title} Entrance`,
|
||||
footPosition: vector(playerAnchor.position),
|
||||
footPosition: vector(entrancePosition),
|
||||
forward: [Math.sin(entranceYaw), 0, Math.cos(entranceYaw)],
|
||||
yaw: entranceYaw,
|
||||
},
|
||||
areas: [
|
||||
{ id: "entrance", name: `${recipe.title} Entrance`, center: vector(playerAnchor.position), radius: 35 },
|
||||
{ id: "entrance", name: `${recipe.title} Entrance`, center: vector(entrancePosition), radius: 35 },
|
||||
...bosses.map((boss) => ({
|
||||
id: `area-${slugPart(boss.name)}`,
|
||||
name: `${boss.name}'s Encounter`,
|
||||
@@ -1068,6 +1090,10 @@ async function compile() {
|
||||
? sourceBasis === "z-y-negative-x"
|
||||
? "three(x,y,z)=(wow.y,wow.z,wow.x)"
|
||||
: "three(x,y,z)=(-wow.y,wow.z,wow.x)"
|
||||
: coordinateAlignment === "source"
|
||||
? sourceBasis === "flip-x"
|
||||
? "three(x,y,z)=(-draft.x,draft.y,draft.z)"
|
||||
: "three(x,y,z)=draft(x,y,z)"
|
||||
: sourceBasis === "flip-x"
|
||||
? "three(x,y,z)=(-draft.x,draft.y,draft.z)+package-anchor-translation"
|
||||
: "three(x,y,z)=draft(x,y,z)+package-anchor-translation",
|
||||
|
||||
@@ -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 {
|
||||
COA_ABILITIES_BY_CLASS,
|
||||
COA_CLASS_RESOURCES,
|
||||
COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||
} from "../src/game/coaAbilityCatalog";
|
||||
import {
|
||||
COA_LIVE_SOURCE,
|
||||
@@ -25,6 +26,7 @@ writeJson("src/game/generated/coaAbilityRuntimeCatalog.json", {
|
||||
schemaVersion: 1,
|
||||
abilitiesByClass: COA_ABILITIES_BY_CLASS,
|
||||
resourcesByClass: COA_CLASS_RESOURCES,
|
||||
triggeredAbilitiesBySpellId: COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||
});
|
||||
|
||||
const coaBudgetsByClass = Object.fromEntries(CLASSES
|
||||
|
||||
@@ -404,15 +404,23 @@ async function optimizeStage(slug) {
|
||||
const source = path.join(staging, entry.path);
|
||||
const outputName = optimizedChunkName(role, index, entries.length);
|
||||
const output = path.join(staging, outputName);
|
||||
await run(process.execPath, [
|
||||
cli,
|
||||
"meshopt",
|
||||
source,
|
||||
output,
|
||||
"--level",
|
||||
"high",
|
||||
]);
|
||||
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, [
|
||||
path.join(projectRoot, "scripts/asset-pipeline/compress-ktx2.mjs"),
|
||||
output,
|
||||
@@ -421,6 +429,15 @@ async function optimizeStage(slug) {
|
||||
"--jobs",
|
||||
"2",
|
||||
]);
|
||||
} else {
|
||||
await run(process.execPath, [
|
||||
cli,
|
||||
"meshopt",
|
||||
source,
|
||||
output,
|
||||
"--level",
|
||||
"high",
|
||||
]);
|
||||
}
|
||||
const geometry = await inspectGlb(output);
|
||||
const descriptor = {
|
||||
|
||||
@@ -15,6 +15,7 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import webbrowser
|
||||
from dataclasses import dataclass
|
||||
@@ -226,6 +227,67 @@ def current_commit() -> str:
|
||||
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]:
|
||||
environment = os.environ.copy()
|
||||
if Path(powershell).name.lower() == "powershell.exe":
|
||||
@@ -1003,6 +1065,15 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
credentials: dict[str, str],
|
||||
) -> None:
|
||||
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")
|
||||
powershell = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
if not npm or not powershell:
|
||||
@@ -1034,7 +1105,6 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
)
|
||||
for command, label in checks:
|
||||
self._run_command(command, label)
|
||||
self._run_command(["git", "diff", "--check"], "Whitespace check")
|
||||
self._run_command(["git", "add", "-A"], "Stage release")
|
||||
staged = True
|
||||
|
||||
@@ -1371,12 +1441,28 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
action="store_true",
|
||||
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(
|
||||
"--smoke-test",
|
||||
action="store_true",
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
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:
|
||||
try:
|
||||
print(json.dumps(check_summary(), indent=2))
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import release_manager
|
||||
|
||||
@@ -45,5 +48,57 @@ class SafetyTests(unittest.TestCase):
|
||||
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__":
|
||||
unittest.main()
|
||||
@@ -162,6 +162,7 @@ function componentFor(id) {
|
||||
};
|
||||
const attack = {
|
||||
type: int32(buffers.magicObject, offset + 0x194),
|
||||
calculationType: int32(buffers.magicObject, offset + 0x2d4),
|
||||
damagePower: cleanFloat(float32(buffers.magicObject, offset + 0x198)),
|
||||
damagePowerSkillLevelArg: cleanFloat(float32(buffers.magicObject, offset + 0x19c)),
|
||||
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));
|
||||
const costs = costRows.map((cost) => ({
|
||||
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,
|
||||
}));
|
||||
const attackDistance = int32(buffers.magicCollect, offset + 0xbc);
|
||||
|
||||
+410
-2
@@ -3,6 +3,10 @@ import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
cloneDefaultManastormAdminConfig,
|
||||
normalizeManastormAdminConfig,
|
||||
} from "./manastorm-config.mjs";
|
||||
|
||||
const scrypt = promisify(scryptCallback);
|
||||
|
||||
@@ -33,8 +37,19 @@ async function derivePassword(password, salt) {
|
||||
return Buffer.from(await scrypt(password, salt, 64));
|
||||
}
|
||||
|
||||
const GM_USERNAME_KEYS = new Set(["phenom"]);
|
||||
|
||||
function accountRoles(username) {
|
||||
return GM_USERNAME_KEYS.has(String(username ?? "").trim().toLowerCase()) ? ["gm"] : [];
|
||||
}
|
||||
|
||||
function publicAccount(row) {
|
||||
return { id: row.id, username: row.username, createdAt: Number(row.created_at) };
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
createdAt: Number(row.created_at ?? row.createdAt),
|
||||
roles: accountRoles(row.username),
|
||||
};
|
||||
}
|
||||
|
||||
export function openGameDatabase(options = {}) {
|
||||
@@ -62,7 +77,49 @@ export function openGameDatabase(options = {}) {
|
||||
account_id TEXT PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
revision INTEGER NOT NULL, data_json TEXT NOT NULL, updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS manastorm_admin_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
schema_version INTEGER NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
config_json TEXT NOT NULL,
|
||||
updated_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
|
||||
updated_by_username TEXT,
|
||||
updated_at INTEGER
|
||||
) STRICT;
|
||||
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 = {
|
||||
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
|
||||
RETURNING revision, updated_at
|
||||
`),
|
||||
manastormConfig: database.prepare("SELECT * FROM manastorm_admin_config WHERE id = 1"),
|
||||
updateManastormConfig: database.prepare(`
|
||||
UPDATE manastorm_admin_config
|
||||
SET schema_version = 1,
|
||||
revision = revision + 1,
|
||||
config_json = ?,
|
||||
updated_by_account_id = ?,
|
||||
updated_by_username = ?,
|
||||
updated_at = ?
|
||||
WHERE id = 1 AND revision = ?
|
||||
RETURNING revision, updated_at
|
||||
`),
|
||||
};
|
||||
|
||||
function issueSession(accountId) {
|
||||
@@ -110,7 +179,7 @@ export function openGameDatabase(options = {}) {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return { account, ...issueSession(account.id) };
|
||||
return { account: publicAccount(account), ...issueSession(account.id) };
|
||||
}
|
||||
|
||||
async function login(usernameInput, password) {
|
||||
@@ -160,6 +229,334 @@ export function openGameDatabase(options = {}) {
|
||||
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data };
|
||||
}
|
||||
|
||||
function isGameMaster(account) {
|
||||
// Recompute authority from the server allowlist for every privileged call.
|
||||
return accountRoles(account?.username).includes("gm");
|
||||
}
|
||||
|
||||
function getManastormConfig() {
|
||||
const row = statements.manastormConfig.get();
|
||||
try {
|
||||
return {
|
||||
revision: Number(row?.revision ?? 0),
|
||||
updatedAt: row?.updated_at == null ? null : Number(row.updated_at),
|
||||
updatedBy: row?.updated_by_username ?? null,
|
||||
config: normalizeManastormAdminConfig(JSON.parse(row?.config_json ?? "null")),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Healer Man Manastorm configuration is corrupt; using safe defaults", error);
|
||||
return { revision: 0, updatedAt: null, updatedBy: null, config: cloneDefaultManastormAdminConfig() };
|
||||
}
|
||||
}
|
||||
|
||||
function putManastormConfig(account, expectedRevision, config) {
|
||||
if (!isGameMaster(account)) {
|
||||
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
||||
}
|
||||
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
|
||||
throw new GameDatabaseError("A valid expected revision is required.", 400, "invalid_revision");
|
||||
}
|
||||
let normalized;
|
||||
try {
|
||||
normalized = normalizeManastormAdminConfig(config);
|
||||
} catch (error) {
|
||||
throw new GameDatabaseError(error instanceof Error ? error.message : "Invalid Manastorm configuration.", 400, "invalid_manastorm_config");
|
||||
}
|
||||
const now = Date.now();
|
||||
const row = statements.updateManastormConfig.get(
|
||||
JSON.stringify(normalized),
|
||||
account.id,
|
||||
account.username,
|
||||
now,
|
||||
expectedRevision,
|
||||
);
|
||||
if (!row) {
|
||||
throw new GameDatabaseError("The Manastorm configuration changed. Reload and try again.", 409, "revision_conflict");
|
||||
}
|
||||
return {
|
||||
revision: Number(row.revision),
|
||||
updatedAt: Number(row.updated_at),
|
||||
updatedBy: account.username,
|
||||
config: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
databasePath,
|
||||
register,
|
||||
@@ -168,6 +565,17 @@ export function openGameDatabase(options = {}) {
|
||||
logout: (token) => { if (typeof token === "string" && token) statements.deleteSession.run(tokenHash(token)); },
|
||||
getCloudSave,
|
||||
putCloudSave,
|
||||
isGameMaster,
|
||||
getManastormConfig,
|
||||
putManastormConfig,
|
||||
onlineGroupState,
|
||||
inviteOnlineGroupMember,
|
||||
respondToOnlineGroupInvite,
|
||||
leaveOnlineGroup,
|
||||
removeOnlineGroupMember,
|
||||
updateOnlineGroupMember,
|
||||
startOnlineGroupActivity,
|
||||
clearOnlineGroupActivity,
|
||||
pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes),
|
||||
close: () => database.close(),
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
||||
|
||||
function withDatabase(run) {
|
||||
@@ -52,3 +53,129 @@ test("persists opaque cloud roster data with increasing revisions", () => withDa
|
||||
assert.equal(second.revision, 2);
|
||||
assert.equal(database.getCloudSave(registered.account.id).data.characters[0].name, "Mendbetter");
|
||||
}));
|
||||
|
||||
test("derives GM authority from the hardcoded Phenom account", () => withDatabase(async (database) => {
|
||||
const gm = await database.register("Phenom", "storm-admin");
|
||||
const player = await database.register("Ordinary", "storm-player");
|
||||
assert.deepEqual(gm.account.roles, ["gm"]);
|
||||
assert.deepEqual((await database.login("pHeNoM", "storm-admin")).account.roles, ["gm"]);
|
||||
assert.equal(database.isGameMaster(database.authenticate(gm.token)), true);
|
||||
assert.equal(database.isGameMaster(database.authenticate(player.token)), false);
|
||||
assert.equal(database.isGameMaster({ ...player.account, roles: ["gm"] }), false);
|
||||
}));
|
||||
|
||||
test("recovers Manastorm configuration after restart and falls back safely from corrupt data", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-db-restart-test-"));
|
||||
const databasePath = path.join(directory, "game.db");
|
||||
try {
|
||||
const first = openGameDatabase({ databasePath });
|
||||
const gm = await first.register("Phenom", "storm-admin");
|
||||
const current = first.getManastormConfig();
|
||||
first.putManastormConfig(first.authenticate(gm.token), 0, {
|
||||
...current.config,
|
||||
global: { ...current.config.global, enabled: false },
|
||||
});
|
||||
first.close();
|
||||
|
||||
const restarted = openGameDatabase({ databasePath });
|
||||
assert.equal(restarted.getManastormConfig().revision, 1);
|
||||
assert.equal(restarted.getManastormConfig().config.global.enabled, false);
|
||||
restarted.close();
|
||||
|
||||
const raw = new DatabaseSync(databasePath);
|
||||
raw.prepare("UPDATE manastorm_admin_config SET config_json = ? WHERE id = 1").run("{corrupt");
|
||||
raw.close();
|
||||
const recovered = openGameDatabase({ databasePath });
|
||||
assert.equal(recovered.getManastormConfig().revision, 0);
|
||||
assert.deepEqual(recovered.getManastormConfig().config.bosses, {});
|
||||
recovered.close();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("persists revisioned Manastorm GM configuration and rejects stale or unauthorized writes", () => withDatabase(async (database) => {
|
||||
const gm = await database.register("Phenom", "storm-admin");
|
||||
const player = await database.register("Ordinary", "storm-player");
|
||||
const gmAccount = database.authenticate(gm.token);
|
||||
const playerAccount = database.authenticate(player.token);
|
||||
const initial = database.getManastormConfig();
|
||||
assert.equal(initial.revision, 0);
|
||||
assert.deepEqual(initial.config.bosses, {});
|
||||
|
||||
const configured = {
|
||||
...initial.config,
|
||||
global: { ...initial.config.global, affixesEnabled: false },
|
||||
};
|
||||
const saved = database.putManastormConfig(gmAccount, 0, configured);
|
||||
assert.equal(saved.revision, 1);
|
||||
assert.equal(saved.config.global.affixesEnabled, false);
|
||||
assert.equal(database.getManastormConfig().updatedBy, "Phenom");
|
||||
assert.throws(() => database.putManastormConfig(gmAccount, 0, configured), (error) => (
|
||||
error instanceof GameDatabaseError && error.status === 409
|
||||
));
|
||||
assert.throws(() => database.putManastormConfig(playerAccount, 1, configured), (error) => (
|
||||
error instanceof GameDatabaseError && error.status === 403
|
||||
));
|
||||
assert.throws(() => database.putManastormConfig(gmAccount, 1, {
|
||||
...configured,
|
||||
bosses: { "boss:incomplete": { status: "ready" } },
|
||||
}), (error) => error instanceof GameDatabaseError && error.status === 400);
|
||||
}));
|
||||
|
||||
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 { fileURLToPath } from "node:url";
|
||||
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
||||
import { createOnlineSessionManager } from "./online-session.mjs";
|
||||
|
||||
const MIME_TYPES = new Map([
|
||||
[".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 trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
|
||||
const gameDatabase = options.database ?? openGameDatabase(options);
|
||||
const onlineSessions = createOnlineSessionManager();
|
||||
const allowAuthRequest = createAuthLimiter();
|
||||
gameDatabase.pruneExpiredSessions();
|
||||
|
||||
@@ -209,6 +211,79 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, gameDatabase.putCloudSave(account.id, body.data));
|
||||
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/")) {
|
||||
json(response, 404, { error: "API route not found.", code: "not_found" });
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,115 @@ test("serves the game and authenticated cloud-save API", async () => {
|
||||
});
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal((await saved.json()).revision, 1);
|
||||
|
||||
const publicConfig = await fetch(`${origin}/api/manastorm-config`);
|
||||
assert.equal(publicConfig.status, 200);
|
||||
assert.equal((await publicConfig.json()).revision, 0);
|
||||
|
||||
const denied = await fetch(`${origin}/api/gm/manastorm-config`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||
body: JSON.stringify({ expectedRevision: 0, config: { schemaVersion: 1 } }),
|
||||
});
|
||||
assert.equal(denied.status, 403);
|
||||
|
||||
const 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 {
|
||||
await new Promise((resolve) => runtime.server.close(resolve));
|
||||
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 { preloadAbilityCatalogsForClasses } from "./game/abilityCatalog";
|
||||
import { useManastormAdminStore } from "./game/manastormAdminStore";
|
||||
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
||||
import { LoginScreen } from "./ui/LoginScreen";
|
||||
import { OnlineGroupCoordinator } from "./app/OnlineGroupCoordinator";
|
||||
|
||||
const GameRuntime = lazy(() => import("./GameRuntime"));
|
||||
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 MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
|
||||
const ManastormSelectScreen = lazy(() => import("./ui/ManastormSelectScreen").then((module) => ({ default: module.ManastormSelectScreen })));
|
||||
const GmAdminScreen = lazy(() => import("./ui/GmAdminScreen").then((module) => ({ default: module.GmAdminScreen })));
|
||||
|
||||
function ShellTransition() {
|
||||
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
|
||||
}
|
||||
|
||||
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() {
|
||||
useForcedThorDisplays();
|
||||
useAuthoritativeDualScreenSync();
|
||||
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 />;
|
||||
if (phase === "characters") return <Suspense fallback={<ShellTransition />}><CharacterSelectScreen /></Suspense>;
|
||||
if (phase === "create-character") return <Suspense fallback={<ShellTransition />}><CharacterCreateScreen /></Suspense>;
|
||||
if (phase === "main-menu") return <Suspense fallback={<ShellTransition />}><MainMenuScreen /></Suspense>;
|
||||
if (phase === "class-hall") return <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
|
||||
if (phase === "dungeons") return <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
|
||||
if (phase === "manastorms") return <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
|
||||
if (phase !== "game") return null;
|
||||
return (
|
||||
<Suspense fallback={<ShellTransition />}>
|
||||
<GameRuntime />
|
||||
</Suspense>
|
||||
);
|
||||
useEffect(() => {
|
||||
void refreshManastormConfig();
|
||||
}, [refreshManastormConfig, sessionOwnerId]);
|
||||
|
||||
let screen = null;
|
||||
if (phase === "login") screen = <LoginScreen />;
|
||||
else if (phase === "characters") screen = <Suspense fallback={<ShellTransition />}><CharacterSelectScreen /></Suspense>;
|
||||
else if (phase === "create-character") screen = <Suspense fallback={<ShellTransition />}><CharacterCreateScreen /></Suspense>;
|
||||
else if (phase === "main-menu") screen = <Suspense fallback={<ShellTransition />}><MainMenuScreen /></Suspense>;
|
||||
else if (phase === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
|
||||
else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
|
||||
else if (phase === "manastorms") screen = <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
|
||||
else if (phase === "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 { PauseMenu } from "./ui/PauseMenu";
|
||||
import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
|
||||
import { GmWorldEditor } from "./ui/GmWorldEditor";
|
||||
import { OnlineSessionBridge } from "./game/OnlineSessionBridge";
|
||||
|
||||
function InputBridge() {
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
@@ -50,6 +52,9 @@ function InputBridge() {
|
||||
useCombatStore.getState().clearTarget();
|
||||
usePartyStore.getState().clearSelection();
|
||||
},
|
||||
groundTargetActive: () => useCombatStore.getState().pendingGroundTarget !== null,
|
||||
confirmGroundTarget: () => { useCombatStore.getState().confirmGroundTarget(); },
|
||||
cancelGroundTarget: () => useCombatStore.getState().cancelGroundTarget(),
|
||||
gameplayActionsBlocked: () => {
|
||||
const game = useGameStore.getState();
|
||||
return game.overlay !== null || game.companionOpen;
|
||||
@@ -80,6 +85,7 @@ export default function GameRuntime() {
|
||||
top={
|
||||
<main className="game-shell">
|
||||
<InputBridge />
|
||||
<OnlineSessionBridge />
|
||||
<CombatBridge />
|
||||
<PartyBridge />
|
||||
<ManastormBridge />
|
||||
@@ -88,6 +94,7 @@ export default function GameRuntime() {
|
||||
<MapPanel />
|
||||
<GameplayPanels />
|
||||
<PauseMenu />
|
||||
<GmWorldEditor />
|
||||
</main>
|
||||
}
|
||||
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,
|
||||
loadCombatPresentationSettings,
|
||||
loginLocalAccount,
|
||||
restoreSession,
|
||||
updateCharacterActionBindings,
|
||||
updateCharacterEquipment,
|
||||
updateCharacterInventory,
|
||||
@@ -86,6 +87,17 @@ describe("local profile repository", () => {
|
||||
expect(listCharacters("offline-roster", storage)).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops spoofed GM roles from offline sessions", () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem(PROFILE_STORAGE_KEYS.session, JSON.stringify({
|
||||
kind: "offline",
|
||||
ownerId: "offline-roster",
|
||||
displayName: "Offline",
|
||||
roles: ["gm"],
|
||||
}));
|
||||
expect(restoreSession(storage)?.roles).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizes legacy profiles and persists progression atomically", () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
|
||||
|
||||
@@ -374,7 +374,11 @@ export function restoreSession(storage: StorageLike | null = browserSessionStora
|
||||
if (session.kind === "account"
|
||||
&& typeof session.accessToken !== "string"
|
||||
&& !readDatabase().accounts.some((account) => account.id === session.ownerId)) return null;
|
||||
return session as PlayerSession;
|
||||
return {
|
||||
...(session as PlayerSession),
|
||||
// Session storage is client-controlled. /api/me re-derives online roles.
|
||||
roles: [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { touchCharacter } from "./accountRepository";
|
||||
import { useShellStore } from "./shellStore";
|
||||
import { registerGeneratedManastormStageAssets } from "../game/manastormAssetRegistry";
|
||||
import {
|
||||
isGameMasterSession,
|
||||
resolvedBossAdminCatalog,
|
||||
} from "../game/manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "../game/manastormInstalledCatalog";
|
||||
import { installedManastormStageBinding } from "../game/manastormInstalledStages";
|
||||
import { registerManastormStageBindingResolver } from "../game/manastormStagePopulation";
|
||||
import { activateManastormStage } from "../game/manastormStageLoader";
|
||||
import { manastormModeForCharacterLevel, setManastormCatalog } from "../game/manastorm";
|
||||
import { createEmptyManastormProgress, manastormModeProgress } from "../game/manastormProgress";
|
||||
import { manastormActiveSpellLoadoutModel } from "../game/manastormSpellLoadout";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { defaultPartyRoleForClass } from "../game/partyRoles";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
export function enterGmManastormTest(bossKey: string): boolean {
|
||||
const shell = useShellStore.getState();
|
||||
if (!isGameMasterSession(shell.session) || !shell.selectedCharacterId) {
|
||||
useShellStore.setState({ notice: "Game Master access is required." });
|
||||
return false;
|
||||
}
|
||||
const activeCharacter = touchCharacter(shell.session!.ownerId, shell.selectedCharacterId)
|
||||
?? shell.characters.find((character) => character.id === shell.selectedCharacterId)
|
||||
?? null;
|
||||
if (!activeCharacter) {
|
||||
useShellStore.setState({ notice: "Select a character before testing a boss." });
|
||||
return false;
|
||||
}
|
||||
const adminConfig = useManastormAdminStore.getState().config;
|
||||
const entry = resolvedBossAdminCatalog(adminConfig).find((candidate) => candidate.key === bossKey);
|
||||
if (!entry || entry.status === "not-ready" || !entry.runnable || !entry.stage) {
|
||||
useShellStore.setState({ notice: entry?.validationIssues[0] ?? "That boss is not available for GM testing." });
|
||||
return false;
|
||||
}
|
||||
|
||||
registerGeneratedManastormStageAssets();
|
||||
registerManastormStageBindingResolver(installedManastormStageBinding);
|
||||
const modeId = manastormModeForCharacterLevel(activeCharacter.level, INSTALLED_MANASTORM_RUNTIME_CATALOG);
|
||||
const { milestoneLevel: _milestoneLevel, ...stageWithoutMilestone } = entry.stage;
|
||||
const testStage = Object.freeze({
|
||||
...stageWithoutMilestone,
|
||||
modeIds: [modeId],
|
||||
stageKind: "standard" as const,
|
||||
unlockLevel: 1,
|
||||
chaoticLinkEnabled: adminConfig.global.chaoticLinkEnabled,
|
||||
});
|
||||
const testCatalog = Object.freeze({
|
||||
...INSTALLED_MANASTORM_RUNTIME_CATALOG,
|
||||
stages: [testStage],
|
||||
fallbackPolicy: "none" as const,
|
||||
runtimeOptions: Object.freeze({ ...adminConfig.global }),
|
||||
});
|
||||
setManastormCatalog(testCatalog);
|
||||
useManastormStore.getState().resetRun();
|
||||
const progress = activeCharacter.manastormProgress ?? createEmptyManastormProgress();
|
||||
const modeProgress = manastormModeProgress(progress, modeId);
|
||||
const spellLoadout = manastormActiveSpellLoadoutModel(
|
||||
activeCharacter.classId,
|
||||
activeCharacter.level,
|
||||
activeCharacter.talentRanks,
|
||||
);
|
||||
let encounter;
|
||||
try {
|
||||
encounter = useManastormStore.getState().startRun({
|
||||
partySize: 5,
|
||||
startingLevel: 1,
|
||||
unlockedThroughLevel: 1,
|
||||
modeId,
|
||||
activeSpellLoadout: {
|
||||
available: spellLoadout.options,
|
||||
selectedIds: progress.loadout,
|
||||
maxSelections: spellLoadout.maxSelections,
|
||||
provenance: spellLoadout.provenance,
|
||||
},
|
||||
cacheMeter: modeProgress.cacheMeter,
|
||||
rewardCaches: modeProgress.rewardCaches,
|
||||
random: () => 0,
|
||||
});
|
||||
} catch {
|
||||
useShellStore.setState({ notice: "The configured boss could not be prepared for testing." });
|
||||
return false;
|
||||
}
|
||||
if (!activateManastormStage(encounter, undefined, false, true).activated) {
|
||||
useManastormStore.getState().resetRun();
|
||||
useShellStore.setState({ notice: "The configured dungeon or stage package is not loadable." });
|
||||
return false;
|
||||
}
|
||||
|
||||
useGameStore.getState().setCompanionOpen(false);
|
||||
useShellStore.setState({
|
||||
phase: "game",
|
||||
activeCharacter,
|
||||
activeDungeonId: encounter.dungeonId,
|
||||
activeDungeonRole: defaultPartyRoleForClass(activeCharacter.classId),
|
||||
notice: `GM test: ${entry.name}`,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import { useGameStore } from "../game/store";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { manastormModeForCharacterLevel, setManastormCatalog } from "../game/manastorm";
|
||||
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "../game/manastormInstalledCatalog";
|
||||
import { effectiveManastormCatalog } from "../game/manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||
import { registerGeneratedManastormStageAssets } from "../game/manastormAssetRegistry";
|
||||
import { installedManastormStageBinding } from "../game/manastormInstalledStages";
|
||||
import { registerManastormStageBindingResolver } from "../game/manastormStagePopulation";
|
||||
@@ -71,11 +73,28 @@ function enterManastorm(
|
||||
|
||||
const partySize = isManastormPartySize(requestedPartySize) ? requestedPartySize : 5;
|
||||
registerGeneratedManastormStageAssets();
|
||||
setManastormCatalog(INSTALLED_MANASTORM_RUNTIME_CATALOG);
|
||||
const adminConfig = useManastormAdminStore.getState().config;
|
||||
if (!adminConfig.global.enabled) {
|
||||
useShellStore.setState({ notice: "Manastorms are currently disabled by a Game Master." });
|
||||
return false;
|
||||
}
|
||||
const effectiveCatalog = effectiveManastormCatalog(adminConfig, INSTALLED_MANASTORM_RUNTIME_CATALOG);
|
||||
const modeId = manastormModeForCharacterLevel(
|
||||
activeCharacter.level,
|
||||
INSTALLED_MANASTORM_RUNTIME_CATALOG,
|
||||
effectiveCatalog,
|
||||
);
|
||||
if (
|
||||
(modeId === 0 && !adminConfig.global.levelingEnabled)
|
||||
|| (modeId === 2 && !adminConfig.global.endgameEnabled)
|
||||
) {
|
||||
useShellStore.setState({ notice: `${modeId === 2 ? "End-game" : "Leveling"} Manastorms are currently disabled.` });
|
||||
return false;
|
||||
}
|
||||
if (!effectiveCatalog.stages?.length) {
|
||||
useShellStore.setState({ notice: "No bosses are currently marked Ready for Manastorms." });
|
||||
return false;
|
||||
}
|
||||
setManastormCatalog(effectiveCatalog);
|
||||
const storedProgress = activeCharacter.manastormProgress ?? createEmptyManastormProgress();
|
||||
const progress = modeId === 2
|
||||
? applyManastormEndgameHandoff(storedProgress)
|
||||
@@ -106,20 +125,26 @@ function enterManastorm(
|
||||
const startingLevel = requestedStartingLevel === 1
|
||||
? 1
|
||||
: availableCheckpoint;
|
||||
const encounter = useManastormStore.getState().startRun({
|
||||
partySize,
|
||||
startingLevel,
|
||||
unlockedThroughLevel: Math.max(startingLevel, savedParty.highestLevel + 1),
|
||||
modeId,
|
||||
activeSpellLoadout: {
|
||||
available: spellLoadoutModel.options,
|
||||
selectedIds: progress.loadout,
|
||||
maxSelections: spellLoadoutModel.maxSelections,
|
||||
provenance: spellLoadoutModel.provenance,
|
||||
},
|
||||
cacheMeter: modeProgress.cacheMeter,
|
||||
rewardCaches: modeProgress.rewardCaches,
|
||||
});
|
||||
let encounter;
|
||||
try {
|
||||
encounter = useManastormStore.getState().startRun({
|
||||
partySize,
|
||||
startingLevel,
|
||||
unlockedThroughLevel: Math.max(startingLevel, savedParty.highestLevel + 1),
|
||||
modeId,
|
||||
activeSpellLoadout: {
|
||||
available: spellLoadoutModel.options,
|
||||
selectedIds: progress.loadout,
|
||||
maxSelections: spellLoadoutModel.maxSelections,
|
||||
provenance: spellLoadoutModel.provenance,
|
||||
},
|
||||
cacheMeter: modeProgress.cacheMeter,
|
||||
rewardCaches: modeProgress.rewardCaches,
|
||||
});
|
||||
} catch {
|
||||
useShellStore.setState({ notice: "No Ready boss is eligible for this Manastorm level and party size." });
|
||||
return false;
|
||||
}
|
||||
if (!activateManastormStage(encounter, undefined, false).activated) {
|
||||
useManastormStore.getState().resetRun();
|
||||
useShellStore.setState({ notice: "The first Manastorm could not be opened." });
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
type AccountResult,
|
||||
} from "./accountRepository";
|
||||
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 SAVE_DEBOUNCE_MS = 500;
|
||||
|
||||
interface AuthResponse {
|
||||
account: { id: string; username: string };
|
||||
account: { id: string; username: string; roles?: readonly "gm"[] };
|
||||
token: string;
|
||||
}
|
||||
|
||||
@@ -21,6 +23,10 @@ interface CloudSaveResponse {
|
||||
data: { version?: number; characters?: unknown };
|
||||
}
|
||||
|
||||
interface MeResponse {
|
||||
account: { id: string; username: string; roles?: readonly "gm"[] };
|
||||
}
|
||||
|
||||
class OnlineApiError extends Error {
|
||||
constructor(message: string, readonly status: number) {
|
||||
super(message);
|
||||
@@ -40,7 +46,7 @@ function apiOrigin(): string {
|
||||
return Capacitor.isNativePlatform() ? NATIVE_API_ORIGIN : "";
|
||||
}
|
||||
|
||||
async function requestJson<T>(pathname: string, init: RequestInit = {}, token?: string): Promise<T> {
|
||||
export async function requestOnlineJson<T>(pathname: string, init: RequestInit = {}, token?: string): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("Accept", "application/json");
|
||||
if (init.body !== undefined) headers.set("Content-Type", "application/json");
|
||||
@@ -63,6 +69,7 @@ function accountResult(response: AuthResponse): AccountResult {
|
||||
kind: "account",
|
||||
ownerId: response.account.id,
|
||||
displayName: response.account.username,
|
||||
roles: response.account.roles ?? [],
|
||||
accessToken: response.token,
|
||||
},
|
||||
};
|
||||
@@ -70,7 +77,7 @@ function accountResult(response: AuthResponse): AccountResult {
|
||||
|
||||
async function authenticate(pathname: string, username: string, password: string): Promise<AccountResult> {
|
||||
try {
|
||||
const response = await requestJson<AuthResponse>(pathname, {
|
||||
const response = await requestOnlineJson<AuthResponse>(pathname, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
@@ -94,7 +101,7 @@ function flushCloudSave(): void {
|
||||
pendingCharacters = null;
|
||||
if (!session?.accessToken || !characters) return;
|
||||
saveQueue = saveQueue.then(async () => {
|
||||
await requestJson<CloudSaveResponse>("/api/cloud-save", {
|
||||
await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ data: { version: 1, characters } }),
|
||||
}, session.accessToken);
|
||||
@@ -122,7 +129,7 @@ export function activateOnlineSession(session: PlayerSession): void {
|
||||
export async function hydrateOnlineSession(session: PlayerSession): Promise<CharacterProfile[]> {
|
||||
if (!session.accessToken) return listCharacters(session.ownerId);
|
||||
activateOnlineSession(session);
|
||||
const cloud = await requestJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
|
||||
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
|
||||
suppressUpload = true;
|
||||
try {
|
||||
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
|
||||
@@ -131,6 +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> {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
@@ -139,7 +157,81 @@ export async function logoutOnlineSession(session: PlayerSession | null): Promis
|
||||
}
|
||||
if (session?.accessToken) {
|
||||
await saveQueue;
|
||||
await requestJson("/api/auth/logout", { method: "POST" }, session.accessToken).catch(() => undefined);
|
||||
await requestOnlineJson("/api/auth/logout", { method: "POST" }, session.accessToken).catch(() => undefined);
|
||||
}
|
||||
if (activeSession?.ownerId === session?.ownerId) activeSession = null;
|
||||
}
|
||||
|
||||
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,
|
||||
setManastormLoadout,
|
||||
} from "../game/manastormProgress";
|
||||
import {
|
||||
DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
UNIFIED_MANASTORM_BOSS_CATALOG,
|
||||
resolveBossAdminEntry,
|
||||
} from "../game/manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||
|
||||
installDungeonShellRuntime();
|
||||
installManastormShellRuntime();
|
||||
@@ -37,6 +43,29 @@ const character = {
|
||||
lastPlayedAt: 1,
|
||||
};
|
||||
|
||||
function readyBossForMode(modeId: number) {
|
||||
return UNIFIED_MANASTORM_BOSS_CATALOG.find((entry) => (
|
||||
(entry.baseStage?.unlockLevel ?? 1) <= 1
|
||||
&& (entry.baseStage?.minimumPartySize ?? 1) <= 1
|
||||
&& (entry.baseStage?.maximumPartySize ?? 5) >= 5
|
||||
&& (!entry.baseStage?.modeIds?.length || entry.baseStage.modeIds.includes(modeId))
|
||||
&& resolveBossAdminEntry(entry, {
|
||||
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
bosses: { [entry.key]: { status: "ready" } },
|
||||
}).runnable
|
||||
));
|
||||
}
|
||||
const readyLevelingBoss = readyBossForMode(0);
|
||||
const readyEndgameBoss = readyBossForMode(2);
|
||||
if (!readyLevelingBoss || !readyEndgameBoss) throw new Error("Shell tests require runnable Manastorm bosses for both modes.");
|
||||
const readyTestConfig = {
|
||||
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
bosses: {
|
||||
[readyLevelingBoss.key]: { status: "ready" as const },
|
||||
[readyEndgameBoss.key]: { status: "ready" as const },
|
||||
},
|
||||
};
|
||||
|
||||
function prepareSelectedCharacter(): void {
|
||||
useShellStore.setState({
|
||||
phase: "characters",
|
||||
@@ -61,6 +90,7 @@ describe("shell store", () => {
|
||||
});
|
||||
useGameStore.getState().resetAtEntrance();
|
||||
useManastormStore.getState().resetRun();
|
||||
useManastormAdminStore.setState({ config: readyTestConfig, draft: readyTestConfig, dirty: false });
|
||||
});
|
||||
|
||||
it("does not open the character hub or a dungeon without a selected character", () => {
|
||||
|
||||
+22
-1
@@ -51,7 +51,9 @@ import {
|
||||
activateOnlineSession,
|
||||
hydrateOnlineSession,
|
||||
logoutOnlineSession,
|
||||
refreshOnlineSession,
|
||||
} from "./onlineAccountClient";
|
||||
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
||||
|
||||
export interface ShellState {
|
||||
phase: ShellPhase;
|
||||
@@ -77,6 +79,7 @@ export interface ShellState {
|
||||
openMainMenu: () => boolean;
|
||||
openDungeons: () => boolean;
|
||||
openManastorms: () => boolean;
|
||||
openGmAdmin: () => boolean;
|
||||
openClassHall: () => boolean;
|
||||
chooseRomSecondaryClass: (classId: RomClassId) => boolean;
|
||||
swapSelectedRomClasses: () => boolean;
|
||||
@@ -294,6 +297,19 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({ phase: "manastorms", activeCharacter: null, notice: "" });
|
||||
return true;
|
||||
},
|
||||
openGmAdmin: () => {
|
||||
const { session, selectedCharacterId, characters } = get();
|
||||
if (!isGameMasterSession(session)) {
|
||||
set({ notice: "Game Master access is required." });
|
||||
return false;
|
||||
}
|
||||
if (!selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
|
||||
set({ phase: "characters", notice: "Select a character before opening GM Tools." });
|
||||
return false;
|
||||
}
|
||||
set({ phase: "gm-admin", activeCharacter: null, notice: "" });
|
||||
return true;
|
||||
},
|
||||
openClassHall: () => {
|
||||
const { session, selectedCharacterId, characters } = get();
|
||||
const character = characters.find((entry) => entry.id === selectedCharacterId);
|
||||
@@ -500,10 +516,15 @@ export function selectedCharacter(state: ShellState): CharacterProfile | null {
|
||||
|
||||
if (restored.session?.accessToken) {
|
||||
activateOnlineSession(restored.session);
|
||||
void hydrateOnlineSession(restored.session).then((characters) => {
|
||||
void Promise.all([
|
||||
hydrateOnlineSession(restored.session),
|
||||
refreshOnlineSession(restored.session),
|
||||
]).then(([characters, refreshedSession]) => {
|
||||
const current = useShellStore.getState().session;
|
||||
if (current?.ownerId !== restored.session?.ownerId) return;
|
||||
saveSession(refreshedSession);
|
||||
useShellStore.setState({
|
||||
session: refreshedSession,
|
||||
characters,
|
||||
selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId)
|
||||
? useShellStore.getState().selectedCharacterId
|
||||
|
||||
+3
-1
@@ -5,13 +5,15 @@ import type { EquipmentAssignments } from "../game/equipment";
|
||||
import type { GameplaySettings } from "../game/combatStore";
|
||||
import type { ManastormProgress } from "../game/manastormProgress";
|
||||
|
||||
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "game";
|
||||
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "gm-admin" | "game";
|
||||
export type SessionKind = "account" | "offline";
|
||||
export type AccountRole = "gm";
|
||||
|
||||
export interface PlayerSession {
|
||||
kind: SessionKind;
|
||||
ownerId: string;
|
||||
displayName: string;
|
||||
roles?: readonly AccountRole[];
|
||||
/** Present for server-backed accounts; kept in session storage only. */
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
+18
-17
@@ -1,13 +1,15 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { respawnDefeatedPlayer } from "./deathRespawn";
|
||||
import { advanceDungeonPartyCombat } from "./partyRuntime";
|
||||
import { useGameStore } from "./store";
|
||||
import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout";
|
||||
import { useManastormStore } from "./manastormStore";
|
||||
import { resolveManastormActorDown } from "./manastormSession";
|
||||
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
||||
import {
|
||||
onlineSessionIsActive,
|
||||
onlineSessionOwnsWorldSimulation,
|
||||
} from "./onlineSessionRuntime";
|
||||
|
||||
/**
|
||||
* Connects the pure combat runtime to the active character and persistent
|
||||
@@ -23,6 +25,7 @@ export function CombatBridge() {
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
useCombatStore.getState().initializeCharacter(character);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
if (gameMode === "manastorm") {
|
||||
activateManastormActiveSpellLoadout(
|
||||
useManastormStore.getState().activeSpellLoadout,
|
||||
@@ -110,32 +113,30 @@ export function CombatBridge() {
|
||||
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
|
||||
previous = now;
|
||||
const game = useGameStore.getState();
|
||||
if (!game.paused && !game.companionOpen) {
|
||||
const sharedOnline = onlineSessionIsActive();
|
||||
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
|
||||
const epochNow = Date.now();
|
||||
const combat = useCombatStore.getState();
|
||||
combat.setPlayerPosition(game.playerPosition);
|
||||
combat.tick(delta, epochNow);
|
||||
if (game.gameMode === "dungeon") {
|
||||
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
|
||||
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
|
||||
}
|
||||
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
|
||||
advanceDungeonPartyCombat(epochNow);
|
||||
if (game.gameMode === "dungeon" && game.activeDungeonId === "wailing-caverns") {
|
||||
if (onlineSessionOwnsWorldSimulation()) {
|
||||
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
|
||||
advanceDungeonPartyCombat(epochNow);
|
||||
}
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
if (
|
||||
onlineSessionOwnsWorldSimulation()
|
||||
&& game.gameMode === "dungeon"
|
||||
&& game.activeDungeonId === "wailing-caverns"
|
||||
) {
|
||||
useWailingEncounterStore.getState().advance(
|
||||
useCombatStore.getState().mobs,
|
||||
epochNow,
|
||||
);
|
||||
}
|
||||
if (useCombatStore.getState().health <= 0 && game.gameMode === "manastorm") {
|
||||
const resolution = resolveManastormActorDown({ kind: "player" });
|
||||
if (resolution === "failed") useShellStore.getState().failActiveManastorm();
|
||||
return;
|
||||
}
|
||||
if (respawnDefeatedPlayer()) {
|
||||
// resetAtEntrance increments this revision. Mark it consumed because
|
||||
// respawnDefeatedPlayer already reset combat atomically.
|
||||
mountedResetRevision.current = useGameStore.getState().resetRevision;
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
return () => window.clearInterval(timer);
|
||||
|
||||
@@ -14,10 +14,12 @@ import { getManastormCatalog, manastormEncounterById } from "./manastorm";
|
||||
import { synchronizeManastormProgress } from "./manastormProgressSync";
|
||||
import { synchronizeManastormAffixes } from "./manastormAffixRuntime";
|
||||
import { preloadManastormStage } from "./manastormStageLoader";
|
||||
import { onlineSessionIsActive, onlineSessionOwnsWorldSimulation } from "./onlineSessionRuntime";
|
||||
|
||||
/** Connects staged combat, party sizing, and shared resurrections to Manastorm state. */
|
||||
export function ManastormBridge() {
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const gmTestMode = useGameStore((state) => state.gmTestMode);
|
||||
const status = useManastormStore((state) => state.status);
|
||||
const partySize = useManastormStore((state) => state.partySize);
|
||||
const encounter = useManastormStore((state) => state.currentEncounter);
|
||||
@@ -29,11 +31,12 @@ export function ManastormBridge() {
|
||||
|
||||
useEffect(() => {
|
||||
if (gameMode !== "manastorm") return;
|
||||
if (!onlineSessionOwnsWorldSimulation()) return;
|
||||
synchronizeManastormPartySize();
|
||||
}, [gameMode, partyMemberCount, partySize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameMode !== "manastorm" || status !== "entering" || !encounter) return;
|
||||
if (gameMode !== "manastorm" || status !== "entering" || !encounter || !onlineSessionOwnsWorldSimulation()) return;
|
||||
useManastormStore.getState().enterStage();
|
||||
}, [encounter, gameMode, status]);
|
||||
|
||||
@@ -41,12 +44,14 @@ export function ManastormBridge() {
|
||||
if (
|
||||
gameMode !== "manastorm"
|
||||
|| !encounter
|
||||
|| !onlineSessionOwnsWorldSimulation()
|
||||
) return;
|
||||
synchronizeManastormCombat();
|
||||
}, [encounter, gameMode, mobs, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameMode !== "manastorm") return;
|
||||
if (!onlineSessionOwnsWorldSimulation()) return;
|
||||
const timer = window.setInterval(() => synchronizeManastormAffixes(Date.now()), 250);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [gameMode]);
|
||||
@@ -70,6 +75,7 @@ export function ManastormBridge() {
|
||||
|| previous.health <= 0
|
||||
|| useGameStore.getState().gameMode !== "manastorm"
|
||||
|| resolvingDefeatRef.current
|
||||
|| onlineSessionIsActive()
|
||||
) return;
|
||||
resolvingDefeatRef.current = true;
|
||||
try {
|
||||
@@ -81,7 +87,11 @@ export function ManastormBridge() {
|
||||
}), []);
|
||||
|
||||
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 downedMemberIds = state.members.filter((member) => (
|
||||
member.health <= 0 && (previousById.get(member.id)?.health ?? 0) > 0
|
||||
@@ -103,7 +113,7 @@ export function ManastormBridge() {
|
||||
useEffect(() => {
|
||||
if (!characterId) return;
|
||||
return useManastormStore.subscribe((state, previous) => {
|
||||
if (useGameStore.getState().gameMode !== "manastorm") return;
|
||||
if (useGameStore.getState().gameMode !== "manastorm" || useGameStore.getState().gmTestMode) return;
|
||||
const character = useShellStore.getState().activeCharacter;
|
||||
if (!character || character.id !== characterId) return;
|
||||
const result = synchronizeManastormProgress(
|
||||
@@ -116,7 +126,7 @@ export function ManastormBridge() {
|
||||
useShellStore.getState().saveActiveManastormProgress(result.progress);
|
||||
}
|
||||
});
|
||||
}, [characterId]);
|
||||
}, [characterId, gmTestMode]);
|
||||
|
||||
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 { useGameStore } from "./store";
|
||||
import { useManastormStore } from "./manastormStore";
|
||||
import { onlineGroupAiPartySize, useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
|
||||
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
|
||||
export function PartyBridge() {
|
||||
@@ -12,6 +13,7 @@ export function PartyBridge() {
|
||||
const playerRole = useShellStore((state) => state.activeDungeonRole);
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const manastormPartySize = useManastormStore((state) => state.partySize);
|
||||
const onlineActivity = useOnlineGroupStore((state) => state.activeActivity);
|
||||
const level = useCombatStore((state) => state.level);
|
||||
const resetRevision = useGameStore((state) => state.resetRevision);
|
||||
const mountedResetRevision = useRef(resetRevision);
|
||||
@@ -20,17 +22,22 @@ export function PartyBridge() {
|
||||
if (!character) return;
|
||||
const combat = useCombatStore.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(
|
||||
character.id,
|
||||
combat.level,
|
||||
combat.health,
|
||||
playerRole,
|
||||
gameMode === "manastorm" ? manastormPartySize : 5,
|
||||
aiPartySize,
|
||||
character.categoryId ?? "wow",
|
||||
);
|
||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||
resetPartyRuntimeClock();
|
||||
}, [character?.id, gameMode, manastormPartySize, playerRole]);
|
||||
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
|
||||
|
||||
useEffect(() => {
|
||||
usePartyStore.getState().syncLevel(level);
|
||||
|
||||
@@ -38,7 +38,20 @@ describe("baseline ability catalog", () => {
|
||||
const iconPath = path.join(process.cwd(), "public", ability.icon.slice(1));
|
||||
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(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.effects.length).toBeGreaterThan(0);
|
||||
expect(allIds.has(ability.id)).toBe(false);
|
||||
|
||||
+227
-47
@@ -1,16 +1,78 @@
|
||||
import type { ClassId } from "../app/characterCatalog";
|
||||
import {
|
||||
COA_RUNTIME_ABILITIES_BY_CLASS as COA_ABILITIES_BY_CLASS,
|
||||
COA_RUNTIME_CLASS_RESOURCES as COA_CLASS_RESOURCES,
|
||||
} from "./coaAbilityRuntimeCatalog";
|
||||
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
||||
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES, ROM_ORDINARY_ABILITIES_BY_CLASS, type RomSkillGroup } from "./romAbilityCatalog";
|
||||
BASE_CLASS_IDS,
|
||||
COA_CLASS_IDS,
|
||||
ROM_CLASS_IDS,
|
||||
type BaseClassId,
|
||||
type ClassId,
|
||||
type CoaClassId,
|
||||
type RomClassId,
|
||||
} from "../app/characterCatalog";
|
||||
import { loadCoaAbilityCatalogShard } from "./coaAbilityCatalogLoader";
|
||||
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 AbilityTarget = "hostile" | "friendly" | "self";
|
||||
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
|
||||
|
||||
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 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 =
|
||||
| "slow"
|
||||
| "stun"
|
||||
@@ -63,6 +125,8 @@ export interface AbilityAmountScaling {
|
||||
readonly basePoints?: number;
|
||||
readonly dieSides?: number;
|
||||
readonly pointsPerLevel?: number;
|
||||
/** Additional power coefficient gained per source skill level. */
|
||||
readonly coefficientPerLevel?: number;
|
||||
readonly bonusPowerCoefficient?: number;
|
||||
readonly sourceLevel?: 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: "resurrection"; readonly percentMaxHealth: 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: "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 aura: AuraDefinition;
|
||||
/** Some hostile spells apply their beneficial rider to the 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 sourceEffectType: number;
|
||||
readonly sourceAuraType: number;
|
||||
@@ -109,6 +175,7 @@ export type AbilityEffect =
|
||||
readonly kind: "threat";
|
||||
readonly mode: "flat" | "percent" | "redirect";
|
||||
readonly amount: number;
|
||||
readonly recipient?: "caster" | "ability-target";
|
||||
}
|
||||
| {
|
||||
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: "totem"; readonly action: "destroy-all" }
|
||||
| { 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 }
|
||||
| {
|
||||
/** A known combat-side DBC script hook that requires a spell-specific handler. */
|
||||
@@ -140,6 +214,7 @@ export type AbilityEffect =
|
||||
}
|
||||
| {
|
||||
readonly kind: "utility";
|
||||
readonly action?: "create-item" | "open-lock" | "portal" | "weapon-enchant" | "pickpocket" | "farsight" | "class-script";
|
||||
readonly effectType: number;
|
||||
readonly auraType: number;
|
||||
readonly miscValue: number;
|
||||
@@ -165,6 +240,9 @@ export interface AbilityRankDefinition {
|
||||
readonly cost: AbilityCost;
|
||||
readonly costs?: readonly AbilityCost[];
|
||||
readonly effects: readonly AbilityEffect[];
|
||||
readonly targeting?: AbilityTargetingRules;
|
||||
readonly castRules?: AbilityCastRules;
|
||||
readonly runeCost?: AbilityRuneCost;
|
||||
}
|
||||
|
||||
export interface AbilityDefinition {
|
||||
@@ -188,6 +266,10 @@ export interface AbilityDefinition {
|
||||
readonly cost: AbilityCost;
|
||||
readonly costs?: readonly AbilityCost[];
|
||||
readonly effects: readonly AbilityEffect[];
|
||||
readonly targeting?: AbilityTargetingRules;
|
||||
readonly castRules?: AbilityCastRules;
|
||||
readonly runeCost?: AbilityRuneCost;
|
||||
readonly ai?: AbilityAiHints;
|
||||
readonly passive?: boolean;
|
||||
readonly source?: "core" | "wow335-progression" | "ascension-progression" | "coa-starter" | "coa-progression" | "coa-tree" | "runewaker";
|
||||
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
|
||||
// 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 },
|
||||
@@ -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 },
|
||||
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 },
|
||||
...COA_CLASS_RESOURCES,
|
||||
"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-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 },
|
||||
};
|
||||
|
||||
/** Populated per mode/class by preloadAbilityCatalogsForClasses before gameplay mounts. */
|
||||
export const CLASS_RESOURCES = classResources as Readonly<Record<ClassId, ClassResourceProfile>>;
|
||||
|
||||
const WARRIOR = [
|
||||
defineAbility({
|
||||
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[]>> = {
|
||||
warrior: mergeWow335ClassicAbilities(WARRIOR, WOW335_ABILITIES_BY_CLASS.warrior),
|
||||
paladin: mergeWow335ClassicAbilities(PALADIN, WOW335_ABILITIES_BY_CLASS.paladin),
|
||||
hunter: mergeWow335ClassicAbilities(HUNTER, WOW335_ABILITIES_BY_CLASS.hunter),
|
||||
rogue: mergeWow335ClassicAbilities(ROGUE, WOW335_ABILITIES_BY_CLASS.rogue),
|
||||
priest: mergeWow335ClassicAbilities(PRIEST, WOW335_ABILITIES_BY_CLASS.priest),
|
||||
"death-knight": mergeWow335ClassicAbilities(DEATH_KNIGHT, WOW335_ABILITIES_BY_CLASS["death-knight"]),
|
||||
shaman: mergeWow335ClassicAbilities(SHAMAN, WOW335_ABILITIES_BY_CLASS.shaman),
|
||||
mage: mergeWow335ClassicAbilities(MAGE, WOW335_ABILITIES_BY_CLASS.mage),
|
||||
warlock: mergeWow335ClassicAbilities(WARLOCK, WOW335_ABILITIES_BY_CLASS.warlock),
|
||||
druid: mergeWow335ClassicAbilities(DRUID, WOW335_ABILITIES_BY_CLASS.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 CLASSIC_CORE_ABILITIES: Readonly<Record<BaseClassId, readonly AbilityDefinition[]>> = {
|
||||
warrior: WARRIOR,
|
||||
paladin: PALADIN,
|
||||
hunter: HUNTER,
|
||||
rogue: ROGUE,
|
||||
priest: PRIEST,
|
||||
"death-knight": DEATH_KNIGHT,
|
||||
shaman: SHAMAN,
|
||||
mage: MAGE,
|
||||
warlock: WARLOCK,
|
||||
druid: DRUID,
|
||||
};
|
||||
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(
|
||||
Object.fromEntries([...Object.values(ABILITIES_BY_CLASS).flat(), ...ROM_ALL_ABILITIES].map((ability) => [ability.id, ability])),
|
||||
);
|
||||
/** Live read-only views retained for catalog consumers and validation tests. */
|
||||
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[] {
|
||||
return ABILITIES_BY_CLASS[classId];
|
||||
return abilitiesByClass[classId] ?? [];
|
||||
}
|
||||
|
||||
export function abilitiesForCharacter(
|
||||
classId: ClassId,
|
||||
secondaryClassId: ClassId | null | undefined,
|
||||
): readonly AbilityDefinition[] {
|
||||
if (!classId.startsWith("rom-") || !secondaryClassId?.startsWith("rom-")) return ABILITIES_BY_CLASS[classId];
|
||||
const primary = ABILITIES_BY_CLASS[classId];
|
||||
const secondaryGeneral = ABILITIES_BY_CLASS[secondaryClassId].filter((ability) => (
|
||||
if (!classId.startsWith("rom-") || !secondaryClassId?.startsWith("rom-")) return abilitiesForClass(classId);
|
||||
const primary = abilitiesForClass(classId);
|
||||
const secondaryGeneral = abilitiesForClass(secondaryClassId).filter((ability) => (
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -793,6 +960,9 @@ export function abilityAtLevel(
|
||||
cost: rank.cost,
|
||||
costs: rank.costs ?? [rank.cost],
|
||||
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,
|
||||
currentLevel: number,
|
||||
): readonly AbilityDefinition[] {
|
||||
return ABILITIES_BY_CLASS[classId].filter((ability) => (
|
||||
return abilitiesForClass(classId).filter((ability) => (
|
||||
ability.unlockLevel > previousLevel && ability.unlockLevel <= currentLevel
|
||||
));
|
||||
}
|
||||
@@ -840,7 +1010,7 @@ export function defaultActionBarForClass(classId: ClassId): readonly string[] {
|
||||
|| effect.kind === "rune"
|
||||
|| effect.kind === "trigger-spell"
|
||||
)) ? 0 : 1;
|
||||
const ordered = ABILITIES_BY_CLASS[classId]
|
||||
const ordered = abilitiesForClass(classId)
|
||||
.map((ability, index) => ({ ability, index }))
|
||||
.sort((left, right) => (
|
||||
left.ability.unlockLevel - right.ability.unlockLevel
|
||||
@@ -882,5 +1052,15 @@ export function defaultActionBarForCharacter(classId: ClassId, secondaryClassId?
|
||||
}
|
||||
|
||||
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 abilitiesByClass: Readonly<Record<CoaClassId, readonly AbilityDefinition[]>>;
|
||||
readonly resourcesByClass: Readonly<Record<CoaClassId, ClassResourceProfile>>;
|
||||
readonly triggeredAbilitiesBySpellId: Readonly<Record<number, AbilityDefinition>>;
|
||||
}
|
||||
|
||||
const snapshot = generated as unknown as CoaAbilityRuntimeSnapshot;
|
||||
|
||||
export const COA_RUNTIME_ABILITIES_BY_CLASS = snapshot.abilitiesByClass;
|
||||
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;
|
||||
/** Defaults to true. False makes applications from all sources share one aura. */
|
||||
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 dispelPriority?: number;
|
||||
readonly modifiers?: readonly AuraModifier[];
|
||||
@@ -309,14 +319,25 @@ export function applyAura(
|
||||
const maxStacks = boundedPositiveInteger(definition.maxStacks, 1);
|
||||
const requestedStacks = Math.min(maxStacks, boundedPositiveInteger(request.stacks, 1));
|
||||
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) {
|
||||
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 stacks = behavior === "add"
|
||||
? Math.min(maxStacks, existing.stacks + requestedStacks)
|
||||
@@ -333,7 +354,7 @@ export function applyAura(
|
||||
procStates: existing.procStates,
|
||||
}
|
||||
: replacement;
|
||||
const auras = [...current];
|
||||
const auras = [...base];
|
||||
auras[existingIndex] = aura;
|
||||
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 { 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 {
|
||||
COA_RUNTIME_ABILITIES_BY_CLASS,
|
||||
COA_RUNTIME_CLASS_RESOURCES,
|
||||
COA_RUNTIME_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||
} from "./coaAbilityRuntimeCatalog";
|
||||
import generatedTalentRuntime from "./generated/talentRuntimeCatalog.json";
|
||||
import {
|
||||
@@ -21,6 +26,7 @@ describe("compact combat runtime catalogs", () => {
|
||||
it("preserves every derived COA ability and resource profile", () => {
|
||||
expect(COA_RUNTIME_ABILITIES_BY_CLASS).toEqual(COA_ABILITIES_BY_CLASS);
|
||||
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", () => {
|
||||
|
||||
@@ -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,
|
||||
MINIMUM_CORPSE_VISIBLE_MS,
|
||||
MOB_WOUND_ANIMATION_COOLDOWN_MS,
|
||||
PLAYER_AURA_ENTITY_ID,
|
||||
selectedMob,
|
||||
useCombatStore,
|
||||
} from "./combatStore";
|
||||
@@ -90,7 +91,7 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
|
||||
useCombatStore.getState().tick(0.1, 2500);
|
||||
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().mobs.ravager.engaged).toBe(true);
|
||||
});
|
||||
@@ -124,6 +125,20 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().setActionBinding("primary", "face-bottom", abilityId)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires an explicit resurrection action to restore a defeated player", () => {
|
||||
const maxHealth = useCombatStore.getState().maxHealth;
|
||||
useCombatStore.getState().damagePlayer(maxHealth * 10);
|
||||
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().healPlayer(maxHealth)).toBe(0);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
|
||||
const restored = useCombatStore.getState().revivePlayer(0.35);
|
||||
expect(restored).toBe(Math.max(1, Math.round(maxHealth * 0.35)));
|
||||
expect(useCombatStore.getState().health).toBe(restored);
|
||||
expect(useCombatStore.getState().revivePlayer(1)).toBe(0);
|
||||
});
|
||||
|
||||
it("starts with attribute-derived pools including racial and automatic class passives", () => {
|
||||
const gnomeMage = deriveCharacterStats("mage", 1, EMPTY_ITEM_STATS, "gnome");
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", raceId: "gnome", level: 1 });
|
||||
@@ -936,7 +951,7 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().castSlot(0, undefined, 10_000).ok).toBe(true);
|
||||
const boltCompletion = useCombatStore.getState().activeCast!.completesAt;
|
||||
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", () => {
|
||||
@@ -958,6 +973,154 @@ describe("combat store", () => {
|
||||
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", () => {
|
||||
registerTarget("enemy", [10, 0, 0], 100, 0);
|
||||
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().damageMob("respawn-pack:a", 1, 1000);
|
||||
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;
|
||||
useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 });
|
||||
|
||||
@@ -223,6 +223,23 @@ describe("generated dungeon campaign catalog", () => {
|
||||
.toEqual([-819.8267, 39.4232, -87.3708]);
|
||||
});
|
||||
|
||||
it("keeps Stockades creatures in the package's native world coordinates", () => {
|
||||
const stockades = requireDungeonDefinition("stormwind-stockade");
|
||||
const firstSpawn = stockades.staticSpawns.find(
|
||||
(spawn) => spawn.id === "creature-79035",
|
||||
);
|
||||
const targorr = stockades.staticSpawns.find(
|
||||
(spawn) => spawn.id === "creature-84027",
|
||||
);
|
||||
|
||||
expect(stockades.environmentMode).toBe("global-wmo");
|
||||
expect(stockades.provenance.coordinateTransform)
|
||||
.toBe("three(x,y,z)=draft(x,y,z)");
|
||||
expect(stockades.entrance.footPosition).toEqual([-54.23, -18.34, 0.28]);
|
||||
expect(firstSpawn?.position).toEqual([-82.3245, -26.4396, -10.6106]);
|
||||
expect(targorr?.position).toEqual([-159.582, -25.6062, 1.2531]);
|
||||
});
|
||||
|
||||
it("starts Razorfen Kraul inside the portal and maps its first pack into the WMO", () => {
|
||||
const razorfenKraul = requireDungeonDefinition("razorfen-kraul");
|
||||
const firstPackSpawn = razorfenKraul.staticSpawns.find(
|
||||
|
||||
@@ -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 { dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
|
||||
import type { WorldSpawn } from "./dungeonTypes";
|
||||
import { useManastormAdminStore } from "./manastormAdminStore";
|
||||
|
||||
export interface DungeonSessionOptions {
|
||||
readonly gameMode?: GameMode;
|
||||
readonly spawn?: WorldSpawn;
|
||||
readonly gmTestMode?: boolean;
|
||||
/** Used by in-game encounter transitions so character progression and inventory survive. */
|
||||
readonly preserveCharacter?: boolean;
|
||||
}
|
||||
@@ -72,7 +74,8 @@ export function activateDungeonSession(
|
||||
}
|
||||
useGameStore.getState().activateDungeon(dungeonId as DungeonId, difficultyId, {
|
||||
gameMode: options.gameMode,
|
||||
spawn: options.spawn,
|
||||
spawn: options.spawn ?? useManastormAdminStore.getState().config.dungeonSpawns[dungeonId],
|
||||
gmTestMode: options.gmTestMode,
|
||||
});
|
||||
useCombatStore.getState().setPlayerPosition(useGameStore.getState().playerPosition);
|
||||
return true;
|
||||
@@ -87,6 +90,7 @@ export function restartActiveDungeonSession(): boolean {
|
||||
return activateDungeonSession(game.activeDungeonId, game.activeDifficultyId, {
|
||||
gameMode: game.gameMode,
|
||||
spawn: game.activeSpawn,
|
||||
gmTestMode: game.gmTestMode,
|
||||
preserveCharacter: true,
|
||||
});
|
||||
}
|
||||
@@ -5,10 +5,13 @@ import { dungeonDefinitionById } from "./dungeonRegistry";
|
||||
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { DEFAULT_MANASTORM_ADMIN_CONFIG } from "./manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "./manastormAdminStore";
|
||||
|
||||
describe("dungeon session asset eviction", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useManastormAdminStore.setState({ config: DEFAULT_MANASTORM_ADMIN_CONFIG, draft: DEFAULT_MANASTORM_ADMIN_CONFIG, dirty: false });
|
||||
useGameStore.getState().activateDungeon("wailing-caverns");
|
||||
});
|
||||
|
||||
@@ -44,4 +47,13 @@ describe("dungeon session asset eviction", () => {
|
||||
expect(useGameStore.getState().sessionRevision).toBe(revision + 1);
|
||||
expect(usePartyStore.getState().members.map((member) => member.id)).toEqual(members);
|
||||
});
|
||||
|
||||
it("applies the published dungeon group anchor at the session boundary", () => {
|
||||
const spawn = { footPosition: [12, 3, -8] as const, yaw: 1.25 };
|
||||
useManastormAdminStore.setState({
|
||||
config: { ...DEFAULT_MANASTORM_ADMIN_CONFIG, dungeonSpawns: { "wailing-caverns": spawn } },
|
||||
});
|
||||
expect(activateDungeonSession("wailing-caverns")).toBe(true);
|
||||
expect(useGameStore.getState().activeSpawn).toEqual(spawn);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ export type EnemyDamageSchool =
|
||||
| "shadow"
|
||||
| "holy";
|
||||
|
||||
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun";
|
||||
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun" | "silence";
|
||||
|
||||
export type EnemyAbilityEffect =
|
||||
| { readonly kind: "damage"; readonly multiplier: number }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"classId":"barbarian","abilities":[{"id":"coa-tree-30762","classId":"barbarian","dbcSpellId":500918,"name":"Ancestral Roar","unlockLevel":1,"icon":"/assets/ui/spells/_d3threateningshout.png","sigil":"AR","description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500918,"level":1,"description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}]}],"talentEntryId":30762},{"id":"coa-12-ancestral-spirit-47-705153","classId":"barbarian","dbcSpellId":705153,"name":"Ancestral Spirit","unlockLevel":1,"icon":"/assets/ui/spells/inv_misc_questionmark.png","sigil":"AS","description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705153,"level":1,"description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]},{"rank":2,"spellId":705154,"level":1,"description":"Ancestral SpiritRank 2Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 100%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705154:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705154:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]}]},{"id":"coa-tree-34279","classId":"barbarian","dbcSpellId":560456,"name":"Athleticism","unlockLevel":1,"icon":"/assets/ui/spells/5_run_border.png","sigil":"AT","descriptiLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"classId":"bloodmage","abilities":[{"id":"coa-tree-29871","classId":"bloodmage","dbcSpellId":504285,"name":"Alacrity","unlockLevel":1,"icon":"/assets/ui/spells/5_run_border.png","sigil":"AL","description":"Increases all primary stats by 4%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504285:effect:0:aura:137","name":"Alacrity (Aura 137)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"all-primary","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":137,"miscValue":-1,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":504285,"level":1,"description":"Increases all primary stats by 4%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504285:effect:0:aura:137","name":"Alacrity (Aura 137)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"all-primary","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":137,"miscValue":-1,"triggerSpellId":0}]},{"rank":2,"spellId":504893,"level":1,"description":"Increases all primary stats by 8%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504893:effect:0:aura:137","name":"Alacrity (Aura 137)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"all-primary","operation":"percent","value":0.08}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":137,"miscValue":-1,"triggerSpellId":0}]}],"talentEntryId":29871},{"id":"coa-20-atheranns-wickedness-73-704642","classId":"bloodmage","dbcSpellId":704642,"name":"Atherann's Wickedness","unlockLevel":1,"icon":"/assets/ui/spells/spell_shadow_destructivesoul.png","sigil":"AS","description":"Atherann's WickednessRank 1Increases your Shadow damage dealt by 4% and increases your hit chance by 3%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704642:effect:0:aura:79","name":"Atherann's Wickedness (Aura 79)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"dealt","school":"shadow","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":79,"miscValue":32,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:704642:effect:1:aura:333","name":"Atherann's Wickedness (Aura 333)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":333,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":704642,"level":1,"description":"Atherann's WickednessRank 1Increases your Shadow damage dealt by 4% and increases your hit chance by 3%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704642:effect:0:aura:79","name":"Atherann's Wickedness (Aura 79)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"dealt","school":"shadow","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":79,"miscValue":32,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:704642:effect:1:aura:333","name":"Atherann's Wickedness (Aura 333)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":333,"miscValue":0,"triggerSpellId":0}]},{"rank":2,"spellId":704643,"level":1,"description":"Atherann's WickednessRank 2Increases the damage of Atherann's Anguish by 20%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704643:effect:0:aura:108","name":"Atherann's Wickedness (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}]}]},{"id":"coa-tree-8215","classId":"bloodmage","dbcSpellId":504627,"name":"Blood Bond","unlockLevel":1,"icon":"/assets/ui/spells/nhi_bloodsacrifice_border.png","sigil":"BB","description":"Bond yourself to an ally for 30 minutes, causing direct damage taken by tLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"classId":"chronomancer","abilities":[{"id":"coa-tree-30277","classId":"chronomancer","dbcSpellId":800857,"name":"Accelerated Recovery","unlockLevel":1,"icon":"/assets/ui/spells/spell_holy_healingfocus.png","sigil":"AR","description":"Accelerated RecoveryRank 118% of base mana Instant castAccelerates the bodies natural healing process, healing the target for (15*1000/3)*(12+0+Heal*0.23) over 15 sec. Periodic healing done by this scales with your spell haste.","target":"friendly","range":{"min":0,"max":40},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":18,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:800857:effect:0:aura:8","name":"Accelerated Recovery (Aura 8)","disposition":"buff","hideFromAuraStrip":true,"durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"sourceEffectType":6,"sourceAuraType":8,"miscValue":0,"triggerSpellId":0},{"kind":"hot","coefficient":0.24070173263549804,"basePoints":12,"dieSides":1,"pointsPerLevel":0.6052629947662354,"bonusPowerCoefficient":0,"sourceLevel":10,"maxScalingLevel":16,"ticks":5,"intervalMs":3000},{"kind":"apply-aura","aura":{"id":"coa:800857:effect:1:aura:283","name":"Accelerated Recovery (Aura 283)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"sourceEffectType":3,"sourceAuraType":283,"miscValue":0,"triggerSpellId":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":800857,"level":1,"description":"Accelerated RecoveryRank 118% of base mana Instant castAccelerates the bodies natural healing process, healing the target for (15*1000/3)*(12+0+Heal*0.23) over 15 sec. Periodic healing done by this scales with your spell haste.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":18,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:800857:effect:0:aura:8","name":"Accelerated Recovery (Aura 8)","disposition":"buff","hideFromAuraStrip":true,"durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"sourceEffectType":6,"sourceAuraType":8,"miscValue":0,"triggerSpellId":0},{"kind":"hot","coefficient":0.24070173263549804,"basePoints":12,"dieSides":1,"pointsPerLevel":0.6052629947662354,"bonusPowerCoefficient":0,"sourceLevel":10,"maxScalingLevel":16,"ticks":5,"intervalMs":3000},{"kind":"apply-aura","aura":{"id":"coa:800857:effect:1:aura:283","name":"Accelerated Recovery (Aura 283)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"sourceEffectType":3,"sourceAuraType":283,"miscValue":0,"triggerSpellId":0}]}],"talentEntryId":30277},{"id":"coa-tree-4032","classId":"chronomancer","dbcSpellId":92119,"name":"Aeon of Resilience","unlockLevel":1,"icon":"/assets/ui/spells/inv_trinket_80_kultiras02a.png","sigil":"AO","description":"Level 10 Passive Teaches you Aeon of Resilience.Aeon of ResilienceReduces Epoch's cast time by -25% and it's mana cost by -10%, and healing done by it now reduces the target's damage taken by -5% for 6 seconds.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:92119:effect:0:aura:4","name":"Aeon of Resilience (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":92119,"level":1,"description":"Level 10 Passive Teaches you Aeon of Resilience.Aeon of ResilienceReduces Epoch's cast time by -25% and it's mana cost by -10%, and healing done by it now reduces the target's damage taken by -5% for 6 seconds.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:92119:effect:0:aura:4","name":"Aeon of Resilience (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":0,"triggerSpellId":0}]}],"talentEntryId":4032},{"id":"coa-tree-6214","classId":"chronomancer","dbcSpellId":560948,"name":"Ahead of the Game","unlockLevel":1,"icon":"/assets/ui/spells/achievement_bg_win_av_x_times.png","sigil":"AO","description":"Every 10th instance of your damage dealt or healing now gives you 60 increased spell power for 10 seconds.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:560948:effect:0:aura:42","name":"AheadLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"classId":"cultist","abilities":[{"id":"coa-25-abyssal-decay-89-500705","classId":"cultist","dbcSpellId":500705,"name":"Abyssal Decay","unlockLevel":1,"icon":"/assets/ui/spells/spell_priest_void_blast.png","sigil":"AD","description":"Abyssal DecayRank 19% of base mana Instant castApplies an Eldritch curse to an enemy, causing 9+(PL*0.6) Shadow damage every 3 sec for 18 sec. Generates 5 Insanity.","target":"hostile","range":{"min":0,"max":30},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":9,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:500705:effect:0:aura:3","name":"Abyssal Decay (Aura 3)","disposition":"debuff","hideFromAuraStrip":true,"durationMs":18000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse"},"sourceEffectType":6,"sourceAuraType":3,"miscValue":0,"triggerSpellId":0},{"kind":"dot","coefficient":0.24166666716337204,"basePoints":9,"dieSides":5,"pointsPerLevel":0.6000000238418579,"bonusPowerCoefficient":0,"sourceLevel":1,"maxScalingLevel":9,"ticks":6,"intervalMs":3000,"tag":"spell-500705"},{"kind":"utility","action":"class-script","effectType":168,"auraType":0,"miscValue":120640,"triggerSpellId":0,"durationMs":18000}],"passive":false,"source":"coa-progression","ranks":[{"rank":1,"spellId":500705,"level":1,"description":"Abyssal DecayRank 19% of base mana Instant castApplies an Eldritch curse to an enemy, causing 9+(PL*0.6) Shadow damage every 3 sec for 18 sec. Generates 5 Insanity.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":9,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:500705:effect:0:aura:3","name":"Abyssal Decay (Aura 3)","disposition":"debuff","hideFromAuraStrip":true,"durationMs":18000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse"},"sourceEffectType":6,"sourceAuraType":3,"miscValue":0,"triggerSpellId":0},{"kind":"dot","coefficient":0.24166666716337204,"basePoints":9,"dieSides":5,"pointsPerLevel":0.6000000238418579,"bonusPowerCoefficient":0,"sourceLevel":1,"maxScalingLevel":9,"ticks":6,"intervalMs":3000,"tag":"spell-500705"},{"kind":"utility","action":"class-script","effectType":168,"auraType":0,"miscValue":120640,"triggerSpellId":0,"durationMs":18000}]},{"rank":2,"spellId":502144,"level":10,"description":"Abyssal DecayRank 29% of base mana Instant castApplies an Eldritch curse to an enemy, causing 17+(PL*1-10*1) Shadow damage every 3 sec for 18 sec. Generates 5 Insanity.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":9,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:502144:effect:0:aura:3","name":"Abyssal Decay (Aura 3)","disposition":"debuff","hideFromAuraStrip":true,"durationMs":18000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse"},"sourceEffectType":6,"sourceAuraType":3,"miscValue":0,"triggerSpellId":0},{"kind":"dot","coefficient":0.36066666603088376,"basePoints":17,"dieSides":1,"pointsPerLevel":1.0049999952316284,"bonusPowerCoefficient":0,"sourceLevel":10,"maxScalingLevel":18,"ticks":6,"intervalMs":3000,"tag":"spell-502144"},{"kind":"utility","action":"class-script","effectType":168,"auraType":0,"miscValue":120640,"triggerSpellId":0,"durationMs":18000}]},{"rank":3,"spellId":502145,"level":19,"description":"Abyssal DecayRank 39% of base mana Instant castApplies an Eldritch curse to an enemy, causing 31+(PL*1.4-19*1.4) Shadow damage every 3 sec for 18 sec. Generates 5 Insanity.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":9,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:502145:effect:0:aura:3","name":"Abyssal Decay (Aura 3)","disposition":"debuff","hideFromAuraStrip":true,"durationMs":18000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse"},"sourceEffectType":6,"sourceAuraType":3,"miscValue":0,"triggerSpellId":0},{"kind":"dot","coefficient":0.5590245174426659,"basePoints":31,"dieSides":1,"pointsPerLevel":1.3695000410079956,"bonusPowerCoefficient":0,"sourceLevel":19,"maxScalingLevel":27,"ticks":6,"intervalMs":3000,"tag":"spell-502145"},{"kind":"utility","action":"class-script","effectType":168,"auraType":0,"miscValue":120640,"triggerSpellId":0,"durationMs":18000}]},{"rank":4,"spellId":502146,"level":28,"description":"Abyssal DecayRank 49% of base mana Instant castApplies an Eldritch curse to an enemy, causing 55+(PL*1.7-28*1.7) Shadow damage every 3 sec for18 sec. Generates 5 Insanity.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":9,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:502146:effect:0:aura:3","name":"Abyssal Decay (Aura 3)","disposition":"debuff","hideFromAuraStrip":true,"durationMs":18000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse"},"sourceEffectType":6,"sourceAuraType":3,"miscValue":0,"triggerSpellIdLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"classId":"felsworn","abilities":[{"id":"coa-tree-29686","classId":"felsworn","dbcSpellId":803904,"name":"Annihilation","unlockLevel":1,"icon":"/assets/ui/spells/ability_boss_lordanthricystgreen.png","sigil":"AN","description":"Gorge on fel energy, guaranteeing your next direct instance of damage to critically strike, but causing it to consume 5% of your maximum health. Lasts for 5 attacks or 12 seconds.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:803904:effect:0:aura:290","name":"Annihilation (Aura 290)","disposition":"buff","durationMs":12000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:803904:effect:1:aura:42","name":"Annihilation (Aura 42)","disposition":"buff","durationMs":12000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:803904:proc:1:807961","triggers":["crit","hit"],"chance":1,"charges":5,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":807961}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":807961}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":803904,"level":1,"description":"Gorge on fel energy, guaranteeing your next direct instance of damage to critically strike, but causing it to consume 5% of your maximum health. Lasts for 5 attacks or 12 seconds.","castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:803904:effect:0:aura:290","name":"Annihilation (Aura 290)","disposition":"buff","durationMs":12000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:803904:effect:1:aura:42","name":"Annihilation (Aura 42)","disposition":"buff","durationMs":12000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:803904:proc:1:807961","triggers":["crit","hit"],"chance":1,"charges":5,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":807961}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":807961}]}],"talentEntryId":29686},{"id":"coa-tree-30301","classId":"felsworn","dbcSpellId":560822,"name":"Anti-Magic","unlockLevel":1,"icon":"/assets/ui/spells/spell_shadow_antimagicshell.png","sigil":"AM","description":"When Hateforged Barrier is removed from you, you now reduce -10% of all Magic damage taken for 8 seconds. In addition, Hateforged Barrier now heals for an additional 2% of your maximum health each tick.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:560822:effect:0:aura:4","name":"Anti-Magic (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:560822:effect:1:aura:107","name":"Anti-Magic (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.02}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":3,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":560822,"level":1,"description":"When Hateforged Barrier is removed from you, you now reduce -10% of all Magic damage taken for 8 seconds. In addition, Hateforged Barrier now heals for an additional 2% of your maximum health each tick.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:560822:effect:0:aura:4","name":"Anti-Magic (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:560822:effect:1:aura:107","name":"Anti-Magic (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.02}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":3,"triggerSpellId":0}]}],"talentEntryId":30301},{"id":"coa-tree-33671","classId":"felsworn","dbcSpellId":706266,"namLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"classId":"guardian","abilities":[{"id":"coa-18-banner-boy-68-705358","classId":"guardian","dbcSpellId":705358,"name":"Banner Boy","unlockLevel":1,"icon":"/assets/ui/spells/achievement_bg_captureflag_wsg.png","sigil":"BB","description":"Banner BoyRank 1Reduces the Energy cost of Banners by 10.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705358:effect:0:aura:107","name":"Banner Boy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":-0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:1:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:2:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":3,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705358,"level":1,"description":"Banner BoyRank 1Reduces the Energy cost of Banners by 10.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705358:effect:0:aura:107","name":"Banner Boy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":-0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:1:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:2:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":3,"triggerSpellId":0}]}]},{"id":"coa-tree-11797","classId":"guardian","dbcSpellId":803683,"name":"Battle Drums","unlockLevel":1,"icon":"/assets/ui/spells/inv_archaeology_70_tauren_drum.png","sigil":"BD","description":"Place down 3 Battle Drums around you for 1 minute. Allies may click on the drums to increase their critical strike chance, haste, damage, and healing done by 10% and causes their damage dealt to trigger a Sound of War for their next 5 instances of damage or 30 seconds.","target":"hostile","range":{"min":0,"max":0},"radius":3,"castMode":"instant","castTimeMs":0,"cooldownMs":180000,"gcdMs":1500,"cost":{"resource":"energy","amount":50},"effects":[{"kind":"damage","coefficient":1},{"kind":"heal","coefficient":1},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":803683,"level":1,"description":"Place down 3 Battle Drums around you for 1 minute. Allies may click on the drums to increase their critical strike chance, haste, damage, and healing done by 10% and causes their damage dealt to trigger a Sound of War for their next 5 instances of damage or 30 seconds.","castMode":"instant","castTimeMs":0,"cooldownMs":180000,"gcdMs":1500,"cost":{"resource":"energy","amount":50},"effects":[{"kind":"damage","coefficient":1},{"kind":"heal","coefficient":1},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000}]}],"talentEntryId":11797},{"id":"guardian-battle-rush","classId":"guardian","dbcSpellId":802197,"name":"Battle Rush","unlockLevel":1,"icon":"/assets/ui/spells/warrior_talent_icon_skirmisher.png","sigil":"BR","description":"Battle RushRank 1Instant cast30 sec cooldownCharge an enemy, dealing 16+0+AP*0.09996 Physical damage and stunnLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"classId":"knight-of-xoroth","abilities":[{"id":"coa-tree-30701","classId":"knight-of-xoroth","dbcSpellId":805679,"name":"Black Shield","unlockLevel":1,"icon":"/assets/ui/spells/spell_warlock_soulburn_haunt.png","sigil":"BS","description":"Consume all of your Demon's Blood stacks to create a demonic shield that absorbs 860 damage, increased by 20% per Demon's Blood stack, for 30 seconds.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"shield","coefficient":1,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":0,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":805679,"level":1,"description":"Consume all of your Demon's Blood stacks to create a demonic shield that absorbs 860 damage, increased by 20% per Demon's Blood stack, for 30 seconds.","castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"shield","coefficient":1,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":0,"triggerSpellId":0,"durationMs":0}]}],"talentEntryId":30701},{"id":"coa-tree-34064","classId":"knight-of-xoroth","dbcSpellId":300395,"name":"Boiling Blood","unlockLevel":1,"icon":"/assets/ui/spells/nhi_bloodboil_border.png","sigil":"BB","description":"Casting Suffuse now increases your healing received by 25% for the first 6 seconds of it's duration.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:300395:effect:0:aura:42","name":"Boiling Blood (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:300395:proc:0:300394","triggers":["cast","heal"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":300394}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":300394}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":300395,"level":1,"description":"Casting Suffuse now increases your healing received by 25% for the first 6 seconds of it's duration.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:300395:effect:0:aura:42","name":"Boiling Blood (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:300395:proc:0:300394","triggers":["cast","heal"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":300394}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":300394}]}],"talentEntryId":34064},{"id":"coa-tree-4018","classId":"knight-of-xoroth","dbcSpellId":92104,"name":"Brimstone Buckler","unlockLevel":1,"icon":"/assets/ui/spells/ability_warlock_improveddemonictactics.png","sigil":"BB","description":"Level 10 Passive Successful blocks now have a 30% chance to summon 1 Hellfire Imp to aid you in battle for 12 seconds. Can only occur once every 3 sec.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:92104:effect:0:aura:23","name":"Brimstone Buckler (Aura 23)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:92104:proc:0:562029","triggers":["periodic"],"chance":0.3,"internalCooldownMs":3000,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":562029}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":23,"miscValue":0,"triggerSpellId":562029},{"kind":"apply-aura","aura":{"id":"coa:92104:effect:1:aura:42","name":"Brimstone Buckler (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:92104:proc:1:805966","triggers":["block"],"chance":0.3,"internalCooldownMs":3000,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":805966}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":805966}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":92104,"level":1,"description":"Level 10 Passive Successful blocks now have a 30% chance to summon 1 Hellfire Imp to aid you in battle for 12 seconds. Can only occur once every 3 sec.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:92104:effect:0:aura:23","name":"Brimstone Buckler (Aura 23)","disposition":"buff","durationMs":null,"maxSLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"classId":"necromancer","abilities":[{"id":"coa-tree-30600","classId":"necromancer","dbcSpellId":805428,"name":"Animate: Frost Wyrm","unlockLevel":1,"icon":"/assets/ui/spells/5_dragoncoldbreath_border.png","sigil":"AF","description":"Animate a Frost Wyrm that flies behind the Necromancer that blasts nearby enemies every 2 sec for 30 seconds, dealing 1500 + 14.88% frost SP + 14.88% Intellect Frost damage.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":31,"percentage":true},"effects":[{"kind":"summon","creatureId":50177,"coefficient":0.08,"durationMs":30000}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":805428,"level":1,"description":"Animate a Frost Wyrm that flies behind the Necromancer that blasts nearby enemies every 2 sec for 30 seconds, dealing 1500 + 14.88% frost SP + 14.88% Intellect Frost damage.","castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":31,"percentage":true},"effects":[{"kind":"summon","creatureId":50177,"coefficient":0.08,"durationMs":30000}]}],"talentEntryId":30600},{"id":"coa-23-animate-zombies-84-504315","classId":"necromancer","dbcSpellId":504315,"name":"Animate: Zombies","unlockLevel":1,"icon":"/assets/ui/spells/zombiewc3.png","sigil":"AZ","description":"Animate: ZombiesRank 121% of base mana 3 sec cast15 sec cooldownAnimate 2 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","target":"hostile","range":{"min":0,"max":0},"radius":3,"castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":21,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":2},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}],"passive":false,"source":"coa-progression","ranks":[{"rank":1,"spellId":504315,"level":1,"description":"Animate: ZombiesRank 121% of base mana 3 sec cast15 sec cooldownAnimate 2 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":21,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":2},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]},{"rank":2,"spellId":525378,"level":30,"description":"Animate: ZombiesRank 223% of base mana 3 sec cast15 sec cooldownAnimate 3 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":23,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":3},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]},{"rank":3,"spellId":525381,"level":51,"description":"Animate: ZombiesRank 324% of base mana 3 sec cast15 sec cooldownAnimate 4 Zombies to aid you in combat for 10 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":24,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":4},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]}]},{"id":"coa-tree-29774","classId":"necromancer","dbcSpellId":802121,"name":"Bone Tithe","unlockLevel":1,"icon":"/assets/ui/spells/ossuary_bond.png","sigil":"BT","description":"Siphon life from your undead minions, sacrificing 40% of their health to grant yourself an absorb shield equal to half of the health stolen for 20 seconds. Usable while stunned.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":25,"percentage":true},"effects":[{"kind":"shield","coefficient":1,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":77,"auraType":0,"miscValue":0,"triggerSpellId"Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"classId":"primalist","abilities":[{"id":"coa-tree-29362","classId":"primalist","dbcSpellId":800094,"name":"Bearskin","unlockLevel":1,"icon":"/assets/ui/spells/spell_druid_primaltenacity.png","sigil":"BE","description":"Reduces all damage taken by -60% and reduces the duration of root, stun, and incapacitate effects on you by -30% for 10 seconds.","target":"hostile","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":300000,"gcdMs":1500,"cost":{"resource":"mana","amount":24,"percentage":true},"effects":[{"kind":"status","status":"sleep","durationMs":10000},{"kind":"apply-aura","aura":{"id":"coa:800094:effect:0:aura:87","name":"Bearskin (Aura 87)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":-0.6}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:800094:effect:1:aura:232","name":"Bearskin (Aura 232)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":232,"miscValue":5,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:800094:effect:2:aura:232","name":"Bearskin (Aura 232)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":232,"miscValue":12,"triggerSpellId":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":800094,"level":1,"description":"Reduces all damage taken by -60% and reduces the duration of root, stun, and incapacitate effects on you by -30% for 10 seconds.","castMode":"instant","castTimeMs":0,"cooldownMs":300000,"gcdMs":1500,"cost":{"resource":"mana","amount":24,"percentage":true},"effects":[{"kind":"status","status":"sleep","durationMs":10000},{"kind":"apply-aura","aura":{"id":"coa:800094:effect:0:aura:87","name":"Bearskin (Aura 87)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":-0.6}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:800094:effect:1:aura:232","name":"Bearskin (Aura 232)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":232,"miscValue":5,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:800094:effect:2:aura:232","name":"Bearskin (Aura 232)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":232,"miscValue":12,"triggerSpellId":0}]}],"talentEntryId":29362},{"id":"coa-tree-29252","classId":"primalist","dbcSpellId":500692,"name":"Boulder Dash","unlockLevel":1,"icon":"/assets/ui/spells/custom_elementl(earth)_b_01_border.png","sigil":"BD","description":"Raise a boulder out of the ground and encase yourself within it, preventing you from attacking or casting spells. For 10 seconds, you roll forward at 70% increased movement speed, dealing 1 + 50% AP + 100% SP Physical damage to enemies you roll over, and you are immune to crowd control effects.","target":"hostile","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":180000,"gcdMs":1250,"cost":{"resource":"mana","amount":35,"percentage":true},"effects":[{"kind":"damage","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:500692:effect:0:aura:23","name":"Boulder Dash (Aura 23)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:500692:proc:0:500693","triggers":["periodic"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":500693}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":23,"miscValue":0,"triggerSpellId":500693},{"kind":"apply-aura","aura":{"id":"coa:500692:effect:1:aura:31","name":"Boulder Dash (Aura 31)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"movement-speed","operation":"percent","value":0.7}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":31,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:500692:effect:2:aura:241","name":"Boulder Dash (Aura 241)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":241,"miscValue":0,"triggerSpellId":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500692,"level":1,"description":"Raise a boulder out of the ground aLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"classId":"pyromancer","abilities":[{"id":"coa-tree-33843","classId":"pyromancer","dbcSpellId":300921,"name":"Al Dente","unlockLevel":1,"icon":"/assets/ui/spells/achievement_cooking_masteroftheoven.png","sigil":"AD","description":"Targets affected by Gaze of Ysera now take 15% increased Fire damage.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:300921:effect:0:aura:107","name":"Al Dente (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.15}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":23,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":300921,"level":1,"description":"Targets affected by Gaze of Ysera now take 15% increased Fire damage.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:300921:effect:0:aura:107","name":"Al Dente (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.15}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":23,"triggerSpellId":0}]}],"talentEntryId":33843},{"id":"coa-tree-33882","classId":"pyromancer","dbcSpellId":504397,"name":"Blessing of the Firelands","unlockLevel":1,"icon":"/assets/ui/spells/nhi_fire_soul_border.png","sigil":"BO","description":"Each stack of Heat now increases the damage dealt by Dragon Leap by 6%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504397:effect:0:aura:107","name":"Blessing of the Firelands (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.06}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":12,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:504397:effect:1:aura:107","name":"Blessing of the Firelands (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":23,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":504397,"level":1,"description":"Each stack of Heat now increases the damage dealt by Dragon Leap by 6%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504397:effect:0:aura:107","name":"Blessing of the Firelands (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.06}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":12,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:504397:effect:1:aura:107","name":"Blessing of the Firelands (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":23,"triggerSpellId":0}]}],"talentEntryId":33882},{"id":"coa-tree-32499","classId":"pyromancer","dbcSpellId":300759,"name":"Bright Flames","unlockLevel":1,"icon":"/assets/ui/spells/spell_frost_fireresistancetotem.png","sigil":"BF","description":"Increases the amount absorbed by Inferno Barrier by 10% and the damage it deals by 15%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:300759:effect:0:aura:317","name":"Bright Flames (Aura 317)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":317,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:300759:effect:1:aura:108","name":"Bright Flames (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.15}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":300759,"level":1,"description":"Increases the amount absorbed by Inferno Barrier by 10% and the damage it dLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"classId":"ranger","abilities":[{"id":"coa-21-agile-none-560796","classId":"ranger","dbcSpellId":560796,"name":"Agile","unlockLevel":1,"icon":"/assets/ui/spells/inv_sword_38.png","sigil":"AG","description":"AgileRank 2Increases the damage of Wild Strike, Whipvine Arrow, and Ravage by 10%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:560796:effect:0:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:560796:effect:1:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":22,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":2,"spellId":560796,"level":1,"description":"AgileRank 2Increases the damage of Wild Strike, Whipvine Arrow, and Ravage by 10%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:560796:effect:0:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:560796:effect:1:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":22,"triggerSpellId":0}]}]},{"id":"coa-21-archery-training-76-705036","classId":"ranger","dbcSpellId":705036,"name":"Archery Training","unlockLevel":1,"icon":"/assets/ui/spells/inv_bow_1h_draenorcrafted_d_01_a_horde.png","sigil":"AT","description":"Archery TrainingRank 1Increases the damage of Skullpiercer by 3% and your critical strike chance by 1%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705036:effect:0:aura:290","name":"Archery Training (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705036:effect:1:aura:108","name":"Archery Training (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.03}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705036,"level":1,"description":"Archery TrainingRank 1Increases the damage of Skullpiercer by 3% and your critical strike chance by 1%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705036:effect:0:aura:290","name":"Archery Training (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705036:effect:1:aura:108","name":"Archery Training (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.03}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}]},{"rank":2,"spellId":705037,"level":1,"description":"Archery TrainingRank 2Increases the damage of Skullpiercer by 6% and your critical strike chance by 2%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705037:effect:0:aura:290","name":"Archery Training (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705037:effect:1:aura:108","name":"Archery Training (Aura 108)","disposition":"buff","dLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"classId":"reaper","abilities":[{"id":"coa-tree-6708","classId":"reaper","dbcSpellId":706790,"name":"Backswing","unlockLevel":1,"icon":"/assets/ui/spells/novart_magicspell_(32)_border.png","sigil":"BA","description":"Increases the Runic Power generated by Reap by 5.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:706790:effect:0:aura:107","name":"Backswing (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.5}]},"sourceEffectType":6,"sourceAuraType":107,"miscValue":3,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":706790,"level":1,"description":"Increases the Runic Power generated by Reap by 5.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:706790:effect:0:aura:107","name":"Backswing (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.5}]},"sourceEffectType":6,"sourceAuraType":107,"miscValue":3,"triggerSpellId":0}]}],"talentEntryId":6708},{"id":"coa-tree-34500","classId":"reaper","dbcSpellId":560412,"name":"Between Shadows","unlockLevel":1,"icon":"/assets/ui/spells/5_shadowworld_border.png","sigil":"BS","description":"Casting Spectre Stride now generates 1 Reaped Soul.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:560412:effect:0:aura:42","name":"Between Shadows (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:560412:proc:0:504390","triggers":["cast"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":504390}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":504390}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":560412,"level":1,"description":"Casting Spectre Stride now generates 1 Reaped Soul.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:560412:effect:0:aura:42","name":"Between Shadows (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:560412:proc:0:504390","triggers":["cast"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":504390}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":504390}]}],"talentEntryId":34500},{"id":"coa-tree-31289","classId":"reaper","dbcSpellId":680337,"name":"Bolstered Form","unlockLevel":1,"icon":"/assets/ui/spells/achievement_raid_torghast_shadowscourge_prisonofnerzhul.png","sigil":"BF","description":"Assume a bolstered form for 15 seconds, generating 30 Runic Power, increasing your Armor and your parry chance by 30%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:680337:effect:0:aura:101","name":"Bolstered Form (Aura 101)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"resistance:physical","operation":"percent","value":0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":101,"miscValue":1,"triggerSpellId":0},{"kind":"trigger-spell","spellId":359505},{"kind":"resource","amount":300},{"kind":"apply-aura","aura":{"id":"coa:680337:effect:2:aura:47","name":"Bolstered Form (Aura 47)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"parry","operation":"percent","value":0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":47,"miscValue":127,"triggerSpellId":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":680337,"level":1,"description":"Assume a bolstered form for 15 seconds, generating 30 Runic Power, increasing your Armor and your parry chance by 30%.","castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:680337:effect:0:aura:101","name":"Bolstered Form (Aura 101)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"resistance:physical","operation":"percent","value":0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":1Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"classId":"runemaster","abilities":[{"id":"coa-32-ancient-carvings-117-705616","classId":"runemaster","dbcSpellId":705616,"name":"Ancient Carvings","unlockLevel":1,"icon":"/assets/ui/spells/inv_relics_6orunestone_ogremissive.png","sigil":"AC","description":"Ancient CarvingsRank 2InstantReduces the cooldown of Words by 20%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705616:effect:0:aura:108","name":"Ancient Carvings (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.2}]},"recipient":"caster","sourceEffectType":3,"sourceAuraType":108,"miscValue":11,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705616:effect:1:aura:4","name":"Ancient Carvings (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705616:effect:2:aura:4","name":"Ancient Carvings (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":34,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":2,"spellId":705616,"level":1,"description":"Ancient CarvingsRank 2InstantReduces the cooldown of Words by 20%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705616:effect:0:aura:108","name":"Ancient Carvings (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.2}]},"recipient":"caster","sourceEffectType":3,"sourceAuraType":108,"miscValue":11,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705616:effect:1:aura:4","name":"Ancient Carvings (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705616:effect:2:aura:4","name":"Ancient Carvings (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":34,"triggerSpellId":0}]}]},{"id":"coa-tree-29226","classId":"runemaster","dbcSpellId":806797,"name":"Ancient Teachings","unlockLevel":1,"icon":"/assets/ui/spells/ability_mage_potentspirit.png","sigil":"AT","description":"Increases the effectiveness of Etching of the Dextrous by 20%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:806797:effect:0:aura:216","name":"Ancient Teachings (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":126,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:806797:effect:1:aura:192","name":"Ancient Teachings (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:806797:effect:2:aura:108","name":"Ancient Teachings (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":806797,"level":1,"description":"Increases the effectiveness of Etching of the Dextrous by 20%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:806797:effect:0:aura:216","name":"Ancient Teachings (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":126,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:806797:effect:1:aura:192","name":"Ancient Teachings (Aura 192)","disposition":Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"classId":"starcaller","abilities":[{"id":"coa-26-arrows-of-starlight-93-704733","classId":"starcaller","dbcSpellId":704733,"name":"Arrows of Starlight","unlockLevel":1,"icon":"/assets/ui/spells/ability_ironmaidens_ironshot.png","sigil":"AO","description":"Arrows of StarlightRank 1Increases the damage of Moon Arrow by 20%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704733:effect:0:aura:108","name":"Arrows of Starlight (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":704733,"level":1,"description":"Arrows of StarlightRank 1Increases the damage of Moon Arrow by 20%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704733:effect:0:aura:108","name":"Arrows of Starlight (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}]}]},{"id":"coa-tree-30838","classId":"starcaller","dbcSpellId":806155,"name":"Astral Aegis","unlockLevel":1,"icon":"/assets/ui/spells/ability_bastion_monk.png","sigil":"AA","description":"Drains 30% Maximum Mana For 10 seconds, direct Magic damage taken will silence the attacker for 1 seconds and deal 57 + 18% arcane SP Arcane damage to them.","target":"hostile","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":30000,"gcdMs":1500,"cost":{"resource":"mana","amount":19,"percentage":true},"effects":[{"kind":"damage","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:806155:effect:0:aura:87","name":"Astral Aegis (Aura 87)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:806155:effect:1:aura:42","name":"Astral Aegis (Aura 42)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:806155:proc:1:806453","triggers":["damage-taken","hit"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":806453}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":806453}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":806155,"level":1,"description":"Drains 30% Maximum Mana For 10 seconds, direct Magic damage taken will silence the attacker for 1 seconds and deal 57 + 18% arcane SP Arcane damage to them.","castMode":"instant","castTimeMs":0,"cooldownMs":30000,"gcdMs":1500,"cost":{"resource":"mana","amount":19,"percentage":true},"effects":[{"kind":"damage","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:806155:effect:0:aura:87","name":"Astral Aegis (Aura 87)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:806155:effect:1:aura:42","name":"Astral Aegis (Aura 42)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:806155:proc:1:806453","triggers":["damage-taken","hit"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":806453}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":806453}]}],"talentEntryId":30838},{"id":"starcaller-astral-flare","classId":"starcaller","dbcSpellId":800499,"name":"Astral Flare","unlockLevel":1,"icon":"/assets/ui/spells/spell_shadow_manaburn.png","sigil":"AF","description":"Astral FlareRank 12 sec castYou call an Astral Flare down from the sky, dealing 1 Arcane Damage to an enemy and regenerating 5% of your Maximum Mana.","target":"hostile","range":{"min":0,"max":40},"castMode":"cast","castTimeMs":2000,"cooldownMs":0,"gcdMs":1250,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"trigger-spell","spellId":800507,"withValue":10},{"kind":"damage","coefficient":0.08,"basePoints":1,"dieSides":1,"pointsPerLevel":0,"bonusPowerCoefficient":0,"sourceLevel":6},{"kind":"trigger-spell","spellId":804096,"withValLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"classId":"stormbringer","abilities":[{"id":"coa-tree-34916","classId":"stormbringer","dbcSpellId":807462,"name":"Aether Mastery","unlockLevel":1,"icon":"/assets/ui/spells/achievement_zone_netherstorm_01.png","sigil":"AM","description":"Allows 30% of your mana regeneration to continue while casting and increases your critical strike chance by 2%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:807462:effect:0:aura:134","name":"Aether Mastery (Aura 134)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"resource","resource":"mana","property":"regeneration","operation":"percent","value":0.3}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":134,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:807462:effect:1:aura:290","name":"Aether Mastery (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":807462,"level":1,"description":"Allows 30% of your mana regeneration to continue while casting and increases your critical strike chance by 2%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:807462:effect:0:aura:134","name":"Aether Mastery (Aura 134)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"resource","resource":"mana","property":"regeneration","operation":"percent","value":0.3}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":134,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:807462:effect:1:aura:290","name":"Aether Mastery (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0}]},{"rank":2,"spellId":807887,"level":1,"description":"Allows 60% of your mana regeneration to continue while casting and increases your critical strike chance by 4%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:807887:effect:0:aura:134","name":"Aether Mastery (Aura 134)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"resource","resource":"mana","property":"regeneration","operation":"percent","value":0.6}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":134,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:807887:effect:1:aura:290","name":"Aether Mastery (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0}]}],"talentEntryId":34916},{"id":"coa-16-aethermancy-62-705690","classId":"stormbringer","dbcSpellId":705690,"name":"Aethermancy","unlockLevel":1,"icon":"/assets/ui/spells/spell_shaman_improvedreincarnation.png","sigil":"AE","description":"AethermancyRank 1Increases the critical strike chance of your Invigorating Surge by 20%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705690:effect:0:aura:107","name":"Aethermancy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":7,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705690:effect:1:aura:107","name":"Aethermancy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":3,"sourceAuraType":107,"miscValue":7,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705690,"level":1,"description":"AethermancyRank 1Increases the critical strike chance of your Invigorating Surge by 20%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705690:effect:0:aura:107","name":"Aethermancy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":7,"triggerSpellId":0},{"kinLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"classId":"sun-cleric","abilities":[{"id":"coa-tree-4048","classId":"sun-cleric","dbcSpellId":92137,"name":"Angelic Presence","unlockLevel":1,"icon":"/assets/ui/spells/ability_paladin_conviction.png","sigil":"AP","description":"Level 10 Passive Avoiding an attack now has a 20% chance to grant you Angelic Presence.Angelic PresenceYour next Illumination within 15 seconds is instant cast and free of cost.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:92137:effect:0:aura:42","name":"Angelic Presence (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:92137:proc:0:800722","triggers":["cast","hit"],"chance":0.2,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":800722}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":800722},{"kind":"apply-aura","aura":{"id":"coa:92137:effect:1:aura:79","name":"Angelic Presence (Aura 79)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"dealt","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":79,"miscValue":127,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":92137,"level":1,"description":"Level 10 Passive Avoiding an attack now has a 20% chance to grant you Angelic Presence.Angelic PresenceYour next Illumination within 15 seconds is instant cast and free of cost.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:92137:effect:0:aura:42","name":"Angelic Presence (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:92137:proc:0:800722","triggers":["cast","hit"],"chance":0.2,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":800722}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":800722},{"kind":"apply-aura","aura":{"id":"coa:92137:effect:1:aura:79","name":"Angelic Presence (Aura 79)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"dealt","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":79,"miscValue":127,"triggerSpellId":0}]}],"talentEntryId":4048},{"id":"coa-27-bastion-of-vengeance-97-704925","classId":"sun-cleric","dbcSpellId":704925,"name":"Bastion of Vengeance","unlockLevel":1,"icon":"/assets/ui/spells/inv_shield_1h_artifactnorgannon_d_06.png","sigil":"BO","description":"Bastion of VengeanceRank 1Increases your block chance by 2% and gives successful blocks a 50% chance to reduce the remaining cooldown of Vengeful Slam by 0.01 sec.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704925:effect:0:aura:42","name":"Bastion of Vengeance (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:704925:proc:0:704926","triggers":["block"],"chance":0.5,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":704926}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":704926},{"kind":"apply-aura","aura":{"id":"coa:704925:effect:1:aura:51","name":"Bastion of Vengeance (Aura 51)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"block","operation":"percent","value":0.02}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":51,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":704925,"level":1,"description":"Bastion of VengeanceRank 1Increases your block chance by 2% and gives successful blocks a 50% chance to reduce the remaining cooldown of Vengeful Slam by 0.01 sec.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704925:effect:0:aura:42","name":"Bastion of Vengeance (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:704925:proc:0:704926","triggers":["block"],"chance":0.5,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":704926}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":704926},{"kind":"apply-aura","aura":{"id":"coa:704925:effect:1:aura:51","name":"Bastion of Vengeance (Aura 51)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","statLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"classId":"templar","abilities":[{"id":"coa-19-alertness-71-706321","classId":"templar","dbcSpellId":706321,"name":"Alertness","unlockLevel":1,"icon":"/assets/ui/spells/spell_holy_powerinfusion.png","sigil":"AL","description":"AlertnessRank 1Reduces damage taken by 5%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:706321:effect:0:aura:87","name":"Alertness (Aura 87)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":-0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:706321:effect:1:aura:107","name":"Alertness (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":5,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":706321,"level":1,"description":"AlertnessRank 1Reduces damage taken by 5%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:706321:effect:0:aura:87","name":"Alertness (Aura 87)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":-0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:706321:effect:1:aura:107","name":"Alertness (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":5,"triggerSpellId":0}]},{"rank":2,"spellId":706322,"level":1,"description":"AlertnessRank 2Reduces Physical Damage taken by 4% and increases the range of Benediction by 10 yds.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:706322:effect:0:aura:87","name":"Alertness (Aura 87)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":-0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:706322:effect:1:aura:107","name":"Alertness (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":5,"triggerSpellId":0}]}]},{"id":"coa-tree-34375","classId":"templar","dbcSpellId":801457,"name":"Binding Scripture","unlockLevel":1,"icon":"/assets/ui/spells/_d3breathofheaven.png","sigil":"BS","description":"Healing an ally now redirects 10% of their damage taken to you and reduces your damage taken by -5% for 10 seconds, stacking 3 times.","target":"hostile","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"damage","coefficient":1},{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:801457:effect:0:aura:42","name":"Binding Scripture (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:801457:proc:0:705309","triggers":["heal","damage-taken"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":705309}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":705309}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":801457,"level":1,"description":"Healing an ally now redirects 10% of their damage taken to you and reduces your damage taken by -5% for 10 seconds, stacking 3 times.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"damage","coefficient":1},{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:801457:effect:0:aura:42","name":"Binding Scripture (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:801457:proc:0:705309","triggers":["heal","damage-taken"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":705309}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":705309}]}],"taleLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"classId":"tinker","abilities":[{"id":"coa-tree-29440","classId":"tinker","dbcSpellId":801744,"name":"Air Strike","unlockLevel":1,"icon":"/assets/ui/spells/ability_mount_gyrocoptor.png","sigil":"AS","description":"Mark the target location for an Air Strike. After 7 seconds, it deals 1250 Fire damage to up to 12 enemies caught within the blast. This ability always critically strikes.","target":"hostile","range":{"min":0,"max":60},"radius":20,"castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":1500,"cost":{"resource":"mana","amount":25,"percentage":true},"effects":[{"kind":"damage","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:801744:effect:0:aura:23","name":"Air Strike (Aura 23)","disposition":"debuff","durationMs":7000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:801744:proc:0:801745","triggers":["periodic"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":801745}}}]},"sourceEffectType":27,"sourceAuraType":23,"miscValue":0,"triggerSpellId":801745}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":801744,"level":1,"description":"Mark the target location for an Air Strike. After 7 seconds, it deals 1250 Fire damage to up to 12 enemies caught within the blast. This ability always critically strikes.","castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":1500,"cost":{"resource":"mana","amount":25,"percentage":true},"effects":[{"kind":"damage","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:801744:effect:0:aura:23","name":"Air Strike (Aura 23)","disposition":"debuff","durationMs":7000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:801744:proc:0:801745","triggers":["periodic"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":801745}}}]},"sourceEffectType":27,"sourceAuraType":23,"miscValue":0,"triggerSpellId":801745}]}],"talentEntryId":29440},{"id":"coa-tree-5997","classId":"tinker","dbcSpellId":520445,"name":"Auto Resuscitation Device","unlockLevel":1,"icon":"/assets/ui/spells/inv_misc_enggizmos_15.png","sigil":"AR","description":"Attach a resuscitation device to your target ally for 20 seconds. When the target would otherwise die, they are instead healed for 50% of their maximum health.","target":"friendly","range":{"min":0,"max":20},"castMode":"cast","castTimeMs":1500,"cooldownMs":180000,"gcdMs":1500,"cost":{"resource":"mana","amount":40,"percentage":true},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:520445:effect:0:aura:69","name":"Auto Resuscitation Device (Aura 69)","disposition":"buff","durationMs":20000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","absorbs":[{"id":"coa:520445:absorb:0","amount":20,"perStack":false}]},"sourceEffectType":6,"sourceAuraType":69,"miscValue":127,"triggerSpellId":524780}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":520445,"level":1,"description":"Attach a resuscitation device to your target ally for 20 seconds. When the target would otherwise die, they are instead healed for 50% of their maximum health.","castMode":"cast","castTimeMs":1500,"cooldownMs":180000,"gcdMs":1500,"cost":{"resource":"mana","amount":40,"percentage":true},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:520445:effect:0:aura:69","name":"Auto Resuscitation Device (Aura 69)","disposition":"buff","durationMs":20000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","absorbs":[{"id":"coa:520445:absorb:0","amount":20,"perStack":false}]},"sourceEffectType":6,"sourceAuraType":69,"miscValue":127,"triggerSpellId":524780}]}],"talentEntryId":5997},{"id":"coa-28-blasting-barrels-99-705835","classId":"tinker","dbcSpellId":705835,"name":"Blasting Barrels","unlockLevel":1,"icon":"/assets/ui/spells/5_gunshot_(3)_border.png","sigil":"BB","description":"Blasting BarrelsRank 1InstantIncreases critical strike damage by 0% and increases damage dealt by Rounds by 0%.","target":"hostile","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"damage","coefficient":1}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705835,"level":1,"description":"Blasting BarrelsRank 1InstantIncreases critical strike damage by 0% and increases damage dealt by Rounds by 0%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"damage","coefficient":1}]}]},{"id":"coa-tree-30583","classId":"tinker","dbcSpellId":560754,"name":"Build: Alarm Beacon","unlockLevel":1,"icon":"/assets/ui/spells/inv_lightforgedmechsuit.png","sigil":"BA","description":"3 Charges, 15 sec recharge Throw down an alarm beacon for 10 seconds, removing fear, charm and sleep effects from up to 1 ally within 20 yds every 1 sec.","target":"hostile","range":{"min":0,Line truncated
|
||||
Loaded 100 of 222 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user