Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b12fb84859 | ||
|
|
55cfd43d66 |
@@ -0,0 +1,10 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.{bat,cmd}]
|
||||||
|
end_of_line = crlf
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"files.eol": "\n",
|
||||||
|
"files.insertFinalNewline": true,
|
||||||
|
"files.trimFinalNewlines": true,
|
||||||
|
"files.trimTrailingWhitespace": true
|
||||||
|
}
|
||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
These rules apply to every change under this project root.
|
These rules apply to every change under this project root.
|
||||||
|
|
||||||
|
## Text-file hygiene
|
||||||
|
|
||||||
|
- Every text file must end with exactly one newline, with no extra blank line
|
||||||
|
at EOF and no trailing whitespace.
|
||||||
|
- Before handing off changes, run `npm run release:check`. This check includes
|
||||||
|
untracked files; `git diff --check` alone does not.
|
||||||
|
|
||||||
## Workspace and reference boundaries
|
## Workspace and reference boundaries
|
||||||
|
|
||||||
- The active project root is `D:\Projects\HealerMan`. All implementation,
|
- The active project root is `D:\Projects\HealerMan`. All implementation,
|
||||||
@@ -44,4 +51,3 @@ These rules apply to every change under this project root.
|
|||||||
- `document.pointerLockElement` remains `null`;
|
- `document.pointerLockElement` remains `null`;
|
||||||
- releasing right mouse stops camera motion;
|
- releasing right mouse stops camera motion;
|
||||||
- no browser mouse-capture prompt appears.
|
- no browser mouse-capture prompt appears.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -185,6 +185,7 @@ on the development PC. Stop if any test or build fails:
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Set-Location F:\Projects\HealerMan
|
Set-Location F:\Projects\HealerMan
|
||||||
|
npm run release:check
|
||||||
|
|
||||||
$VersionName = '0.1.3'
|
$VersionName = '0.1.3'
|
||||||
$VersionCode = 1003
|
$VersionCode = 1003
|
||||||
@@ -206,8 +207,8 @@ npm run build
|
|||||||
npm test -- --run src/avatar
|
npm test -- --run src/avatar
|
||||||
npx vitest run src/game/inputManager.test.ts src/game/inputMath.test.ts
|
npx vitest run src/game/inputManager.test.ts src/game/inputMath.test.ts
|
||||||
|
|
||||||
|
npm run release:check
|
||||||
git status --short
|
git status --short
|
||||||
git diff --check
|
|
||||||
git add -A
|
git add -A
|
||||||
git status --short
|
git status --short
|
||||||
git diff --cached --stat
|
git diff --cached --stat
|
||||||
|
|||||||
@@ -4,6 +4,41 @@ This file is the durable record of runtime behavior, regressions, and the
|
|||||||
evidence required before calling a visual bug fixed. Update it whenever scene,
|
evidence required before calling a visual bug fixed. Update it whenever scene,
|
||||||
camera, input, physics, mob-rendering, or asset-loading code changes.
|
camera, input, physics, mob-rendering, or asset-loading code changes.
|
||||||
|
|
||||||
|
## 2026-08-18 GM Manastorm administration - COMPLETE
|
||||||
|
|
||||||
|
- Server-authorized `Phenom` access, revisioned SQLite configuration, safe
|
||||||
|
all-Needs-tested defaults, public/offline caching, optimistic conflicts, and
|
||||||
|
denial/revision logging were exercised through the production HTTP surface.
|
||||||
|
- The GM hub exposed all 406 deduplicated boss records, readiness and runnable
|
||||||
|
state, pool filters, six global switches, dungeon entry, placement editing,
|
||||||
|
and GM tests. A fresh revision correctly reported zero Ready bosses.
|
||||||
|
- Live Wailing Caverns authoring captured and nudged the shared group anchor,
|
||||||
|
displayed derived party, boss, portal, and exact linked-mob data, and saved a
|
||||||
|
three-mob / two-kill Chaotic Link stage. The desktop Save control was moved
|
||||||
|
above the Tactical control after the playtest found their hit targets
|
||||||
|
overlapping; a normal semantic click then published the next revision.
|
||||||
|
- Promoting an imported encounter directly to Ready initially exposed missing
|
||||||
|
stage materialization at the server boundary. The readiness transition now
|
||||||
|
persists its complete inherited stage. Cross-mode duplicates also merge
|
||||||
|
their mode memberships, so a boss imported for both leveling and end-game is
|
||||||
|
represented once and remains eligible in both modes.
|
||||||
|
- Live pool acceptance promoted Lady Anacondra and Burning Felguard, entered a
|
||||||
|
level-1 public Manastorm, then marked the active Lady Anacondra record Not
|
||||||
|
ready from a parallel GM client. The existing run retained Lady Anacondra and
|
||||||
|
its two-stack Chaotic Link snapshot; after reload, the next run selected only
|
||||||
|
Burning Felguard. This verified new-run publication and active-run
|
||||||
|
immutability together.
|
||||||
|
- Desktop and Thor-preview passes rendered the DOM editor over the dungeon,
|
||||||
|
retained camera/game separation while the panel was open, and exposed the GM
|
||||||
|
pause-menu entry. Console inspection found no errors and only the existing
|
||||||
|
third-party initialization deprecation warning.
|
||||||
|
- Verification passed: server **7/7**, full Vitest **121 files / 699 tests**,
|
||||||
|
TypeScript, production build, and the KTX2 audit of 11,839 compressed textures
|
||||||
|
with zero fallbacks. The build retained the existing informational
|
||||||
|
large-chunk advisory.
|
||||||
|
- Evidence: `playtest-artifacts/2026-08-18-gm-manastorm-admin/desktop-admin-hub.png`,
|
||||||
|
`desktop-world-editor-final.png`, and `thor-world-editor.png`.
|
||||||
|
|
||||||
## 2026-08-17 authentic combat VFX and audio - COMPLETE
|
## 2026-08-17 authentic combat VFX and audio - COMPLETE
|
||||||
|
|
||||||
- Player, party, and enemy combat actions now publish transient presentation
|
- Player, party, and enemy combat actions now publish transient presentation
|
||||||
@@ -1386,3 +1421,33 @@ Vite's existing large-chunk advisory remains informational.
|
|||||||
- Verification: 38 focused party/pathing/jump/animation tests passed, all 28
|
- Verification: 38 focused party/pathing/jump/animation tests passed, all 28
|
||||||
avatar tests passed, and `npm run build` completed successfully with 954
|
avatar tests passed, and `npm run build` completed successfully with 954
|
||||||
modules transformed. The existing large-chunk advisory remains informational.
|
modules transformed. The existing large-chunk advisory remains informational.
|
||||||
|
|
||||||
|
# 2026-08-20 — shared online dungeon session with AI fill
|
||||||
|
|
||||||
|
- Ran two signed-in production clients against the local account server on
|
||||||
|
separate browser origins. **SharedLeader / Bulwarka** invited
|
||||||
|
**SharedMember / Mendara**; the accepted group roster showed both selected
|
||||||
|
characters and their Tank and Healer roles as ready.
|
||||||
|
- The leader launched Wailing Caverns with **Fill open slots with AI** enabled.
|
||||||
|
Both clients automatically entered the same activity, each rendered the
|
||||||
|
other player through the normal equipped `CharacterModel` path, and each
|
||||||
|
showed a live, targetable remote party frame. Three AI companions filled the
|
||||||
|
remaining slots, producing the expected five-member party on both clients.
|
||||||
|
- The reciprocal scene captures showed Bulwarka and Mendara at the same shared
|
||||||
|
dungeon position with matching environment, objective, AI roster, mob state,
|
||||||
|
and remote role labels. The leader client continued encounter simulation;
|
||||||
|
the member client received the synchronized mob and AI state without running
|
||||||
|
a second competing world simulation.
|
||||||
|
- From the member client, selected Bulwarka's online party frame and completed
|
||||||
|
a Lesser Heal cast. The member's mana/resource change propagated to the
|
||||||
|
leader client and both clients retained the same live remote health state.
|
||||||
|
Enemy combat and AI resource/health changes also continued to propagate while
|
||||||
|
the two clients remained connected.
|
||||||
|
- Evidence reviewed during the browser run: invitation/group roster DOM,
|
||||||
|
coordinated-launch DOM from both clients, reciprocal full-window dungeon
|
||||||
|
captures, and before/cast-complete remote-healing DOM snapshots. No missing
|
||||||
|
character-body or equipment fallback appeared in either dungeon capture.
|
||||||
|
- Verification: server/API tests passed all 11 cases, the complete Vitest suite
|
||||||
|
passed all 755 cases across 132 files (including the 28 avatar cases), and the
|
||||||
|
TypeScript production build completed successfully with 983 modules
|
||||||
|
transformed. The existing large-chunk advisory remains informational.
|
||||||
|
|||||||
+20
-2
@@ -18,6 +18,21 @@ approval before it commits, pushes `main`, builds the signed APK, and publishes
|
|||||||
the Gitea release. Signing passwords and the Gitea token are passed to the
|
the Gitea release. Signing passwords and the Gitea token are passed to the
|
||||||
child process in memory rather than on its command line.
|
child process in memory rather than on its command line.
|
||||||
|
|
||||||
|
Before changing versions or running the longer test/build sequence, the manager
|
||||||
|
runs an isolated release-candidate check. It validates tracked and untracked
|
||||||
|
files without changing the real Git index, so extra blank lines at EOF and
|
||||||
|
other whitespace errors fail immediately. Run the same fast check at any time:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run release:check
|
||||||
|
```
|
||||||
|
|
||||||
|
Editors should honor the repository's `.editorconfig`. The checked-in VS Code
|
||||||
|
settings also trim extra final newlines whenever a file is saved.
|
||||||
|
Git may print a one-time CRLF-to-LF conversion warning when an existing Windows
|
||||||
|
working copy is first staged under `.gitattributes`; that warning is not a
|
||||||
|
release failure, and subsequent repository text stays consistently LF.
|
||||||
|
|
||||||
The local release key is `F:\secure\healer-man-release.jks`. When the companion
|
The local release key is `F:\secure\healer-man-release.jks`. When the companion
|
||||||
`healer-man-release-password.dpapi` file is present, blank signing-password
|
`healer-man-release-password.dpapi` file is present, blank signing-password
|
||||||
fields are filled from that Windows user-encrypted credential in memory. The
|
fields are filled from that Windows user-encrypted credential in memory. The
|
||||||
@@ -254,11 +269,14 @@ npm run dev
|
|||||||
First check for whitespace errors and review the final file list:
|
First check for whitespace errors and review the final file list:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git diff --check
|
npm run release:check
|
||||||
git status --short
|
git status --short
|
||||||
git diff --stat
|
git diff --stat
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use `npm run release:check` before the longer release validation. Unlike
|
||||||
|
`git diff --check`, it includes new, untracked files.
|
||||||
|
|
||||||
Stage the intended release:
|
Stage the intended release:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -520,7 +538,7 @@ Set-Location F:\Projects\HealerMan
|
|||||||
npm run content:test
|
npm run content:test
|
||||||
npm run build
|
npm run build
|
||||||
git status --short
|
git status --short
|
||||||
git diff --check
|
npm run release:check
|
||||||
git add -A
|
git add -A
|
||||||
git status --short
|
git status --short
|
||||||
git diff --cached --stat
|
git diff --cached --stat
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"clientBuild": "3.3.5a-Ascension",
|
"clientBuild": "3.3.5a-Ascension",
|
||||||
"mapDirectory": "World/Maps/StormwindJail",
|
"mapDirectory": "World/Maps/StormwindJail",
|
||||||
"environmentMode": "global-wmo",
|
"environmentMode": "global-wmo",
|
||||||
|
"globalWmoAlignment": "source",
|
||||||
"difficultyVariants": [
|
"difficultyVariants": [
|
||||||
{
|
{
|
||||||
"id": "normal",
|
"id": "normal",
|
||||||
|
|||||||
Generated
+3
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.4",
|
"version": "0.1.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.4",
|
"version": "0.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "8.4.1",
|
"@capacitor/cli": "8.4.1",
|
||||||
|
"@dimforge/rapier3d-compat": "0.19.2",
|
||||||
"@gltf-transform/cli": "^4.4.1",
|
"@gltf-transform/cli": "^4.4.1",
|
||||||
"@gltf-transform/core": "^4.4.2",
|
"@gltf-transform/core": "^4.4.2",
|
||||||
"@recast-navigation/core": "0.43.1",
|
"@recast-navigation/core": "0.43.1",
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.4",
|
"version": "0.1.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
@@ -98,6 +98,7 @@
|
|||||||
"android:apk": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleDebug",
|
"android:apk": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleDebug",
|
||||||
"android:apk:release": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleRelease",
|
"android:apk:release": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleRelease",
|
||||||
"android:install": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 installDebug",
|
"android:install": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 installDebug",
|
||||||
|
"release:check": "python scripts/release_manager.py --check-release",
|
||||||
"release:gui": "python scripts/release_manager.py",
|
"release:gui": "python scripts/release_manager.py",
|
||||||
"preview": "vite preview --host 0.0.0.0",
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
"pretest": "npm run loot:generate",
|
"pretest": "npm run loot:generate",
|
||||||
@@ -118,6 +119,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "8.4.1",
|
"@capacitor/cli": "8.4.1",
|
||||||
|
"@dimforge/rapier3d-compat": "0.19.2",
|
||||||
"@gltf-transform/cli": "^4.4.1",
|
"@gltf-transform/cli": "^4.4.1",
|
||||||
"@gltf-transform/core": "^4.4.2",
|
"@gltf-transform/core": "^4.4.2",
|
||||||
"@recast-navigation/core": "0.43.1",
|
"@recast-navigation/core": "0.43.1",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.4",
|
"versionName": "0.1.6",
|
||||||
"versionCode": 1004
|
"versionCode": 1006
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ const ADT_SOURCE_BASES = new Set([
|
|||||||
"z-y-negative-x",
|
"z-y-negative-x",
|
||||||
]);
|
]);
|
||||||
const GLOBAL_WMO_SOURCE_BASES = new Set(["draft", "flip-x"]);
|
const GLOBAL_WMO_SOURCE_BASES = new Set(["draft", "flip-x"]);
|
||||||
|
const GLOBAL_WMO_ALIGNMENTS = new Set(["player-anchor", "source"]);
|
||||||
|
|
||||||
function sourceBasisFor(recipe, environmentMode) {
|
function sourceBasisFor(recipe, environmentMode) {
|
||||||
if (environmentMode !== "adt-hybrid") {
|
if (environmentMode !== "adt-hybrid") {
|
||||||
@@ -220,6 +221,15 @@ function sourceBasisFor(recipe, environmentMode) {
|
|||||||
return basis;
|
return basis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function coordinateAlignmentFor(recipe, environmentMode) {
|
||||||
|
if (environmentMode !== "global-wmo") return "player-anchor";
|
||||||
|
const alignment = recipe.globalWmoAlignment ?? "player-anchor";
|
||||||
|
if (!GLOBAL_WMO_ALIGNMENTS.has(alignment)) {
|
||||||
|
throw new Error(`${recipe.slug}: unsupported global WMO alignment ${alignment}.`);
|
||||||
|
}
|
||||||
|
return alignment;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime drafts retain the original pipeline convention. wow.export ADT
|
* Runtime drafts retain the original pipeline convention. wow.export ADT
|
||||||
* packages have an additional horizontal basis change baked into their GLBs.
|
* packages have an additional horizontal basis change baked into their GLBs.
|
||||||
@@ -238,7 +248,10 @@ function sourceToPackageBasis(position, environmentMode, sourceBasis) {
|
|||||||
: point;
|
: point;
|
||||||
}
|
}
|
||||||
|
|
||||||
function coordinateMapper(draft, environmentMode, playerAnchor, sourceBasis) {
|
function coordinateMapper(draft, environmentMode, playerAnchor, sourceBasis, alignment) {
|
||||||
|
if (alignment === "source") {
|
||||||
|
return (position) => sourceToPackageBasis(position, environmentMode, sourceBasis);
|
||||||
|
}
|
||||||
const sourceEntrance = draft?.entrance
|
const sourceEntrance = draft?.entrance
|
||||||
?? draft?.spawns?.[0]?.position
|
?? draft?.spawns?.[0]?.position
|
||||||
?? playerAnchor.position;
|
?? playerAnchor.position;
|
||||||
@@ -764,6 +777,7 @@ async function compile() {
|
|||||||
}
|
}
|
||||||
const environmentMode = inferEnvironmentMode(recipe, assetPackage);
|
const environmentMode = inferEnvironmentMode(recipe, assetPackage);
|
||||||
const sourceBasis = sourceBasisFor(recipe, environmentMode);
|
const sourceBasis = sourceBasisFor(recipe, environmentMode);
|
||||||
|
const coordinateAlignment = coordinateAlignmentFor(recipe, environmentMode);
|
||||||
const playerAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "player");
|
const playerAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "player");
|
||||||
const trashAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "trash");
|
const trashAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "trash");
|
||||||
const bossAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "boss");
|
const bossAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "boss");
|
||||||
@@ -794,6 +808,12 @@ async function compile() {
|
|||||||
environmentMode,
|
environmentMode,
|
||||||
playerAnchor,
|
playerAnchor,
|
||||||
sourceBasis,
|
sourceBasis,
|
||||||
|
coordinateAlignment,
|
||||||
|
);
|
||||||
|
const entrancePosition = mapPosition(
|
||||||
|
draft?.entrance
|
||||||
|
?? draft?.spawns?.[0]?.position
|
||||||
|
?? playerAnchor.position,
|
||||||
);
|
);
|
||||||
const sourceEntities = (draft?.entities ?? [])
|
const sourceEntities = (draft?.entities ?? [])
|
||||||
.filter((entity) => (
|
.filter((entity) => (
|
||||||
@@ -966,7 +986,7 @@ async function compile() {
|
|||||||
});
|
});
|
||||||
for (let index = 0; index < 15; index += 1) {
|
for (let index = 0; index < 15; index += 1) {
|
||||||
const segment = index < 6
|
const segment = index < 6
|
||||||
? interpolate(playerAnchor.position, trashAnchor.position, 0.25 + index * 0.1)
|
? interpolate(entrancePosition, trashAnchor.position, 0.25 + index * 0.1)
|
||||||
: interpolate(trashAnchor.position, bossAnchor.position, 0.08 + (index - 6) * 0.085);
|
: interpolate(trashAnchor.position, bossAnchor.position, 0.08 + (index - 6) * 0.085);
|
||||||
staticSpawns.push({
|
staticSpawns.push({
|
||||||
id: `generated-trash-${index + 1}`,
|
id: `generated-trash-${index + 1}`,
|
||||||
@@ -979,7 +999,7 @@ async function compile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allPoints = [
|
const allPoints = [
|
||||||
playerAnchor.position,
|
entrancePosition,
|
||||||
trashAnchor.position,
|
trashAnchor.position,
|
||||||
bossAnchor.position,
|
bossAnchor.position,
|
||||||
...staticSpawns.map((spawn) => spawn.position),
|
...staticSpawns.map((spawn) => spawn.position),
|
||||||
@@ -1010,7 +1030,9 @@ async function compile() {
|
|||||||
modelCoverageWarning,
|
modelCoverageWarning,
|
||||||
...(syntheticTrash
|
...(syntheticTrash
|
||||||
? ["Trash identities and placements are procedural; boss identities come from the installed Ascension encounter catalog."]
|
? ["Trash identities and placements are procedural; boss identities come from the installed Ascension encounter catalog."]
|
||||||
: [`Server positions were aligned to the packaged ${environmentMode} environment at its reviewed player anchor.`]),
|
: coordinateAlignment === "source"
|
||||||
|
? [`Server positions use the packaged ${environmentMode} environment's native world coordinates.`]
|
||||||
|
: [`Server positions were aligned to the packaged ${environmentMode} environment at its reviewed player anchor.`]),
|
||||||
];
|
];
|
||||||
|
|
||||||
definitions.push({
|
definitions.push({
|
||||||
@@ -1034,12 +1056,12 @@ async function compile() {
|
|||||||
bounds,
|
bounds,
|
||||||
entrance: {
|
entrance: {
|
||||||
name: `${recipe.title} Entrance`,
|
name: `${recipe.title} Entrance`,
|
||||||
footPosition: vector(playerAnchor.position),
|
footPosition: vector(entrancePosition),
|
||||||
forward: [Math.sin(entranceYaw), 0, Math.cos(entranceYaw)],
|
forward: [Math.sin(entranceYaw), 0, Math.cos(entranceYaw)],
|
||||||
yaw: entranceYaw,
|
yaw: entranceYaw,
|
||||||
},
|
},
|
||||||
areas: [
|
areas: [
|
||||||
{ id: "entrance", name: `${recipe.title} Entrance`, center: vector(playerAnchor.position), radius: 35 },
|
{ id: "entrance", name: `${recipe.title} Entrance`, center: vector(entrancePosition), radius: 35 },
|
||||||
...bosses.map((boss) => ({
|
...bosses.map((boss) => ({
|
||||||
id: `area-${slugPart(boss.name)}`,
|
id: `area-${slugPart(boss.name)}`,
|
||||||
name: `${boss.name}'s Encounter`,
|
name: `${boss.name}'s Encounter`,
|
||||||
@@ -1068,6 +1090,10 @@ async function compile() {
|
|||||||
? sourceBasis === "z-y-negative-x"
|
? sourceBasis === "z-y-negative-x"
|
||||||
? "three(x,y,z)=(wow.y,wow.z,wow.x)"
|
? "three(x,y,z)=(wow.y,wow.z,wow.x)"
|
||||||
: "three(x,y,z)=(-wow.y,wow.z,wow.x)"
|
: "three(x,y,z)=(-wow.y,wow.z,wow.x)"
|
||||||
|
: coordinateAlignment === "source"
|
||||||
|
? sourceBasis === "flip-x"
|
||||||
|
? "three(x,y,z)=(-draft.x,draft.y,draft.z)"
|
||||||
|
: "three(x,y,z)=draft(x,y,z)"
|
||||||
: sourceBasis === "flip-x"
|
: sourceBasis === "flip-x"
|
||||||
? "three(x,y,z)=(-draft.x,draft.y,draft.z)+package-anchor-translation"
|
? "three(x,y,z)=(-draft.x,draft.y,draft.z)+package-anchor-translation"
|
||||||
: "three(x,y,z)=draft(x,y,z)+package-anchor-translation",
|
: "three(x,y,z)=draft(x,y,z)+package-anchor-translation",
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(import.meta.dirname, "..");
|
||||||
|
const clientRoot = path.resolve(projectRoot, "..", "LadiksMPQEditor", "client-current");
|
||||||
|
const spellPath = path.join(clientRoot, "area-52", "patch-D", "DBFilesClient", "Spell.dbc");
|
||||||
|
const supportRoot = path.join(clientRoot, "patch-S", "DBFilesClient");
|
||||||
|
const runtimePath = path.join(projectRoot, "src", "game", "generated", "coaAbilityRuntimeCatalog.json");
|
||||||
|
const outputPath = path.join(projectRoot, "src", "game", "generated", "coaTriggeredSpellCatalog.json");
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbcNumberTable(filePath, valueField, floating = false) {
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
assert(buffer.toString("ascii", 0, 4) === "WDBC", `${path.basename(filePath)} is not a WDBC file`);
|
||||||
|
const recordCount = buffer.readUInt32LE(4);
|
||||||
|
const recordSize = buffer.readUInt32LE(12);
|
||||||
|
return new Map(Array.from({ length: recordCount }, (_, index) => {
|
||||||
|
const base = 20 + index * recordSize;
|
||||||
|
return [
|
||||||
|
buffer.readUInt32LE(base),
|
||||||
|
floating ? buffer.readFloatLE(base + valueField * 4) : buffer.readInt32LE(base + valueField * 4),
|
||||||
|
];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbcRangeTable(filePath) {
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
assert(buffer.toString("ascii", 0, 4) === "WDBC", `${path.basename(filePath)} is not a WDBC file`);
|
||||||
|
const recordCount = buffer.readUInt32LE(4);
|
||||||
|
const recordSize = buffer.readUInt32LE(12);
|
||||||
|
return new Map(Array.from({ length: recordCount }, (_, index) => {
|
||||||
|
const base = 20 + index * recordSize;
|
||||||
|
return [buffer.readUInt32LE(base), {
|
||||||
|
min: Math.max(0, buffer.readFloatLE(base + 4), buffer.readFloatLE(base + 8)),
|
||||||
|
max: Math.max(0, buffer.readFloatLE(base + 12), buffer.readFloatLE(base + 16)),
|
||||||
|
}];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function referencedSpellIds(runtime) {
|
||||||
|
const ids = new Set();
|
||||||
|
const inspectEffects = (effects) => {
|
||||||
|
for (const effect of effects ?? []) {
|
||||||
|
if (effect.kind === "trigger-spell" && effect.spellId > 0) ids.add(effect.spellId);
|
||||||
|
if (effect.kind !== "apply-aura") continue;
|
||||||
|
for (const proc of effect.aura?.procs ?? []) {
|
||||||
|
const spellId = proc.action?.kind === "custom" ? proc.action.data?.spellId : 0;
|
||||||
|
if (typeof spellId === "number" && spellId > 0) ids.add(spellId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const abilities of Object.values(runtime.abilitiesByClass)) {
|
||||||
|
for (const ability of abilities) {
|
||||||
|
inspectEffects(ability.effects);
|
||||||
|
for (const rank of ability.ranks ?? []) inspectEffects(rank.effects);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanText(value) {
|
||||||
|
return value.replace(/\r/g, "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function spellParser(buffer, castTimes, durations, radii, ranges) {
|
||||||
|
assert(buffer.toString("ascii", 0, 4) === "WDBC", "Spell.dbc is not a WDBC file");
|
||||||
|
const recordCount = buffer.readUInt32LE(4);
|
||||||
|
const fieldCount = buffer.readUInt32LE(8);
|
||||||
|
const recordSize = buffer.readUInt32LE(12);
|
||||||
|
const stringSize = buffer.readUInt32LE(16);
|
||||||
|
assert(fieldCount >= 234 && recordSize >= fieldCount * 4, "Unexpected Spell.dbc schema");
|
||||||
|
const stringsOffset = 20 + recordCount * recordSize;
|
||||||
|
const offsets = new Map(Array.from({ length: recordCount }, (_, index) => {
|
||||||
|
const base = 20 + index * recordSize;
|
||||||
|
return [buffer.readUInt32LE(base), base];
|
||||||
|
}));
|
||||||
|
const readString = (offset) => {
|
||||||
|
if (!offset || offset >= stringSize) return "";
|
||||||
|
const start = stringsOffset + offset;
|
||||||
|
const end = buffer.indexOf(0, start);
|
||||||
|
return buffer.toString("utf8", start, end < 0 ? buffer.length : end);
|
||||||
|
};
|
||||||
|
return (id) => {
|
||||||
|
const base = offsets.get(id);
|
||||||
|
if (base === undefined) return null;
|
||||||
|
const unsigned = (field) => buffer.readUInt32LE(base + field * 4);
|
||||||
|
const signed = (field) => buffer.readInt32LE(base + field * 4);
|
||||||
|
const float = (field) => buffer.readFloatLE(base + field * 4);
|
||||||
|
const localized = (firstField) => {
|
||||||
|
for (let locale = 0; locale < 16; locale += 1) {
|
||||||
|
const value = readString(unsigned(firstField + locale));
|
||||||
|
if (value) return cleanText(value);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
const effects = Array.from({ length: 3 }, (_, effectIndex) => ({
|
||||||
|
index: effectIndex,
|
||||||
|
effectType: unsigned(71 + effectIndex),
|
||||||
|
auraType: unsigned(95 + effectIndex),
|
||||||
|
basePoints: signed(80 + effectIndex) + 1,
|
||||||
|
dieSides: signed(74 + effectIndex),
|
||||||
|
pointsPerLevel: float(77 + effectIndex),
|
||||||
|
mechanic: unsigned(83 + effectIndex),
|
||||||
|
implicitTargetA: unsigned(86 + effectIndex),
|
||||||
|
implicitTargetB: unsigned(89 + effectIndex),
|
||||||
|
radiusIndex: unsigned(92 + effectIndex),
|
||||||
|
radius: radii.get(unsigned(92 + effectIndex)) ?? 0,
|
||||||
|
periodMs: unsigned(98 + effectIndex),
|
||||||
|
valueMultiplier: float(101 + effectIndex),
|
||||||
|
chainTargets: unsigned(104 + effectIndex),
|
||||||
|
itemType: unsigned(107 + effectIndex),
|
||||||
|
miscValue: signed(110 + effectIndex),
|
||||||
|
miscValueB: signed(113 + effectIndex),
|
||||||
|
triggerSpellId: unsigned(116 + effectIndex),
|
||||||
|
pointsPerCombo: float(119 + effectIndex),
|
||||||
|
coefficient: float(229 + effectIndex),
|
||||||
|
})).filter((effect) => effect.effectType || effect.auraType || effect.basePoints !== 1 || effect.triggerSpellId);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: localized(136),
|
||||||
|
rankText: localized(153),
|
||||||
|
description: localized(170) || localized(187),
|
||||||
|
categoryId: unsigned(1),
|
||||||
|
dispelType: unsigned(2),
|
||||||
|
mechanic: unsigned(3),
|
||||||
|
attributes: Array.from({ length: 8 }, (_, index) => unsigned(4 + index)),
|
||||||
|
castTimeIndex: unsigned(28),
|
||||||
|
castTimeMs: Math.max(0, castTimes.get(unsigned(28)) ?? 0),
|
||||||
|
recoveryTimeMs: unsigned(29),
|
||||||
|
categoryRecoveryTimeMs: unsigned(30),
|
||||||
|
interruptFlags: unsigned(31),
|
||||||
|
auraInterruptFlags: unsigned(32),
|
||||||
|
channelInterruptFlags: unsigned(33),
|
||||||
|
procFlags: unsigned(34),
|
||||||
|
procChance: unsigned(35),
|
||||||
|
procCharges: unsigned(36),
|
||||||
|
maxLevel: unsigned(37),
|
||||||
|
baseLevel: unsigned(38),
|
||||||
|
spellLevel: unsigned(39),
|
||||||
|
durationIndex: unsigned(40),
|
||||||
|
durationMs: durations.get(unsigned(40)) ?? 0,
|
||||||
|
powerType: signed(41),
|
||||||
|
powerCost: unsigned(42),
|
||||||
|
powerCostPerLevel: unsigned(43),
|
||||||
|
powerCostPerSecond: unsigned(44),
|
||||||
|
powerCostPerSecondPerLevel: unsigned(45),
|
||||||
|
rangeIndex: unsigned(46),
|
||||||
|
rangeMin: ranges.get(unsigned(46))?.min ?? 0,
|
||||||
|
rangeMax: ranges.get(unsigned(46))?.max ?? 0,
|
||||||
|
projectileSpeed: float(47),
|
||||||
|
stackAmount: unsigned(49),
|
||||||
|
equippedItemClass: signed(67),
|
||||||
|
equippedItemSubclassMask: signed(68),
|
||||||
|
equippedItemInventoryMask: signed(69),
|
||||||
|
iconId: unsigned(133),
|
||||||
|
powerCostPercentage: unsigned(204),
|
||||||
|
startRecoveryCategory: unsigned(205),
|
||||||
|
startRecoveryTimeMs: unsigned(206),
|
||||||
|
maximumTargets: unsigned(212),
|
||||||
|
damageClass: unsigned(213),
|
||||||
|
preventionType: unsigned(214),
|
||||||
|
schoolMask: unsigned(225),
|
||||||
|
runeCostId: unsigned(226),
|
||||||
|
powerDisplayId: unsigned(228),
|
||||||
|
effects,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(fs.existsSync(spellPath), `CoA Spell.dbc was not found at ${spellPath}`);
|
||||||
|
const runtime = JSON.parse(fs.readFileSync(runtimePath, "utf8"));
|
||||||
|
const castTimes = dbcNumberTable(path.join(supportRoot, "SpellCastTimes.dbc"), 1);
|
||||||
|
const durations = dbcNumberTable(path.join(supportRoot, "SpellDuration.dbc"), 3);
|
||||||
|
const radii = dbcNumberTable(path.join(supportRoot, "SpellRadius.dbc"), 3, true);
|
||||||
|
const ranges = dbcRangeTable(path.join(supportRoot, "SpellRange.dbc"));
|
||||||
|
const parseSpell = spellParser(fs.readFileSync(spellPath), castTimes, durations, radii, ranges);
|
||||||
|
const pending = [...referencedSpellIds(runtime)];
|
||||||
|
const spells = new Map();
|
||||||
|
while (pending.length) {
|
||||||
|
const id = pending.shift();
|
||||||
|
if (spells.has(id)) continue;
|
||||||
|
const spell = parseSpell(id);
|
||||||
|
if (!spell) continue;
|
||||||
|
spells.set(id, spell);
|
||||||
|
for (const effect of spell.effects) {
|
||||||
|
if (effect.triggerSpellId > 0 && !spells.has(effect.triggerSpellId)) pending.push(effect.triggerSpellId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(outputPath, `${JSON.stringify({ schemaVersion: 1, spells: [...spells.values()] })}\n`, "utf8");
|
||||||
|
console.log(`Wrote ${outputPath} with ${spells.size} triggered spell records.`);
|
||||||
@@ -4,6 +4,7 @@ import { CLASSES, type CoaClassId } from "../src/app/characterCatalog";
|
|||||||
import {
|
import {
|
||||||
COA_ABILITIES_BY_CLASS,
|
COA_ABILITIES_BY_CLASS,
|
||||||
COA_CLASS_RESOURCES,
|
COA_CLASS_RESOURCES,
|
||||||
|
COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||||
} from "../src/game/coaAbilityCatalog";
|
} from "../src/game/coaAbilityCatalog";
|
||||||
import {
|
import {
|
||||||
COA_LIVE_SOURCE,
|
COA_LIVE_SOURCE,
|
||||||
@@ -25,6 +26,7 @@ writeJson("src/game/generated/coaAbilityRuntimeCatalog.json", {
|
|||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
abilitiesByClass: COA_ABILITIES_BY_CLASS,
|
abilitiesByClass: COA_ABILITIES_BY_CLASS,
|
||||||
resourcesByClass: COA_CLASS_RESOURCES,
|
resourcesByClass: COA_CLASS_RESOURCES,
|
||||||
|
triggeredAbilitiesBySpellId: COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||||
});
|
});
|
||||||
|
|
||||||
const coaBudgetsByClass = Object.fromEntries(CLASSES
|
const coaBudgetsByClass = Object.fromEntries(CLASSES
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -226,6 +227,67 @@ def current_commit() -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_release_candidates(project_root: Path = PROJECT_ROOT) -> list[str]:
|
||||||
|
"""Check tracked and untracked release files without changing the real index."""
|
||||||
|
with tempfile.TemporaryDirectory(prefix="healer-man-release-index-") as temp_dir:
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["GIT_INDEX_FILE"] = str(Path(temp_dir) / "index")
|
||||||
|
|
||||||
|
def run_temporary_index_command(*args: str) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=project_root,
|
||||||
|
env=environment,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
output = result.stdout.decode("utf-8", errors="replace").strip()
|
||||||
|
raise ReleaseError(
|
||||||
|
f"Release-candidate Git check failed ({result.returncode}): "
|
||||||
|
f"git {' '.join(args)}\n{output}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
run_temporary_index_command("read-tree", "HEAD")
|
||||||
|
run_temporary_index_command("add", "-A")
|
||||||
|
raw_paths = run_temporary_index_command(
|
||||||
|
"diff", "--cached", "--name-only", "-z"
|
||||||
|
).stdout
|
||||||
|
staged_paths = [
|
||||||
|
path.decode("utf-8", errors="replace")
|
||||||
|
for path in raw_paths.split(b"\0")
|
||||||
|
if path
|
||||||
|
]
|
||||||
|
|
||||||
|
whitespace_result = subprocess.run(
|
||||||
|
["git", "diff", "--cached", "--check"],
|
||||||
|
cwd=project_root,
|
||||||
|
env=environment,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if whitespace_result.returncode != 0:
|
||||||
|
detail = whitespace_result.stdout.strip()
|
||||||
|
raise ReleaseError(
|
||||||
|
"Release-candidate whitespace check failed. This check includes "
|
||||||
|
f"tracked and untracked files:\n{detail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
dangerous = find_dangerous_staged_paths(staged_paths)
|
||||||
|
if dangerous:
|
||||||
|
raise ReleaseError(
|
||||||
|
"Refusing to release forbidden output or a possible secret:\n"
|
||||||
|
+ "\n".join(f" - {path}" for path in dangerous)
|
||||||
|
)
|
||||||
|
return staged_paths
|
||||||
|
|
||||||
|
|
||||||
def windows_powershell_environment(powershell: str) -> dict[str, str]:
|
def windows_powershell_environment(powershell: str) -> dict[str, str]:
|
||||||
environment = os.environ.copy()
|
environment = os.environ.copy()
|
||||||
if Path(powershell).name.lower() == "powershell.exe":
|
if Path(powershell).name.lower() == "powershell.exe":
|
||||||
@@ -1003,6 +1065,15 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
|||||||
credentials: dict[str, str],
|
credentials: dict[str, str],
|
||||||
) -> None:
|
) -> None:
|
||||||
self._preflight_git(require_clean=False)
|
self._preflight_git(require_clean=False)
|
||||||
|
self._log_event(
|
||||||
|
"\n--- Early release-candidate check ---\n"
|
||||||
|
"$ npm run release:check"
|
||||||
|
)
|
||||||
|
candidate_paths = validate_release_candidates()
|
||||||
|
self._log_event(
|
||||||
|
f"✓ Early release-candidate check passed "
|
||||||
|
f"({len(candidate_paths)} changed path(s), including untracked files)"
|
||||||
|
)
|
||||||
npm = shutil.which("npm")
|
npm = shutil.which("npm")
|
||||||
powershell = shutil.which("powershell.exe") or shutil.which("powershell")
|
powershell = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||||
if not npm or not powershell:
|
if not npm or not powershell:
|
||||||
@@ -1034,7 +1105,6 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
|||||||
)
|
)
|
||||||
for command, label in checks:
|
for command, label in checks:
|
||||||
self._run_command(command, label)
|
self._run_command(command, label)
|
||||||
self._run_command(["git", "diff", "--check"], "Whitespace check")
|
|
||||||
self._run_command(["git", "add", "-A"], "Stage release")
|
self._run_command(["git", "add", "-A"], "Stage release")
|
||||||
staged = True
|
staged = True
|
||||||
|
|
||||||
@@ -1371,12 +1441,28 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="print release/version/Git readiness without opening the GUI",
|
help="print release/version/Git readiness without opening the GUI",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--check-release",
|
||||||
|
action="store_true",
|
||||||
|
help="check all release candidates, including untracked files",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--smoke-test",
|
"--smoke-test",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help=argparse.SUPPRESS,
|
help=argparse.SUPPRESS,
|
||||||
)
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
if args.check_release:
|
||||||
|
try:
|
||||||
|
paths = validate_release_candidates()
|
||||||
|
print(
|
||||||
|
f"Release candidate check passed: {len(paths)} changed path(s), "
|
||||||
|
"including untracked files."
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Release candidate check failed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
if args.check:
|
if args.check:
|
||||||
try:
|
try:
|
||||||
print(json.dumps(check_summary(), indent=2))
|
print(json.dumps(check_summary(), indent=2))
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import release_manager
|
import release_manager
|
||||||
|
|
||||||
@@ -45,5 +48,57 @@ class SafetyTests(unittest.TestCase):
|
|||||||
self.assertIn('test "$ACTUAL_COMMIT" = "$EXPECTED_COMMIT"', block)
|
self.assertIn('test "$ACTUAL_COMMIT" = "$EXPECTED_COMMIT"', block)
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseCandidateTests(unittest.TestCase):
|
||||||
|
def test_checks_untracked_files_without_changing_the_real_index(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
project_root = Path(temp_dir)
|
||||||
|
|
||||||
|
def git(*args):
|
||||||
|
subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=project_root,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
git("init", "--quiet")
|
||||||
|
(project_root / "tracked.ts").write_text("export const tracked = true;\n")
|
||||||
|
git("add", "tracked.ts")
|
||||||
|
git(
|
||||||
|
"-c",
|
||||||
|
"user.name=Healer Man Tests",
|
||||||
|
"-c",
|
||||||
|
"user.email=tests@example.invalid",
|
||||||
|
"commit",
|
||||||
|
"--quiet",
|
||||||
|
"-m",
|
||||||
|
"Initial commit",
|
||||||
|
)
|
||||||
|
|
||||||
|
untracked = project_root / "untracked.ts"
|
||||||
|
untracked.write_text("export const untracked = true;\n\n")
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
release_manager.ReleaseError, "untracked.ts.*new blank line at EOF"
|
||||||
|
):
|
||||||
|
release_manager.validate_release_candidates(project_root)
|
||||||
|
|
||||||
|
untracked.write_text("export const untracked = true;\n")
|
||||||
|
self.assertEqual(
|
||||||
|
release_manager.validate_release_candidates(project_root),
|
||||||
|
["untracked.ts"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
subprocess.run(
|
||||||
|
["git", "diff", "--cached", "--name-only"],
|
||||||
|
cwd=project_root,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
check=True,
|
||||||
|
).stdout,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ function componentFor(id) {
|
|||||||
};
|
};
|
||||||
const attack = {
|
const attack = {
|
||||||
type: int32(buffers.magicObject, offset + 0x194),
|
type: int32(buffers.magicObject, offset + 0x194),
|
||||||
|
calculationType: int32(buffers.magicObject, offset + 0x2d4),
|
||||||
damagePower: cleanFloat(float32(buffers.magicObject, offset + 0x198)),
|
damagePower: cleanFloat(float32(buffers.magicObject, offset + 0x198)),
|
||||||
damagePowerSkillLevelArg: cleanFloat(float32(buffers.magicObject, offset + 0x19c)),
|
damagePowerSkillLevelArg: cleanFloat(float32(buffers.magicObject, offset + 0x19c)),
|
||||||
fixedValue: cleanFloat(float32(buffers.magicObject, offset + 0x1a0)),
|
fixedValue: cleanFloat(float32(buffers.magicObject, offset + 0x1a0)),
|
||||||
@@ -297,7 +298,9 @@ function sourceSpell(spellId, classIndex) {
|
|||||||
})).filter((cost) => cost.type > 0 && cost.value !== 0 && ![9, 10, 11, 12, 13, 14, 15].includes(cost.type));
|
})).filter((cost) => cost.type > 0 && cost.value !== 0 && ![9, 10, 11, 12, 13, 14, 15].includes(cost.type));
|
||||||
const costs = costRows.map((cost) => ({
|
const costs = costRows.map((cost) => ({
|
||||||
resource: resourceForCostType(cost.type, classIndex),
|
resource: resourceForCostType(cost.type, classIndex),
|
||||||
amount: Math.max(0, Math.min(100, Math.abs(cost.value))),
|
amount: Math.max(0, cost.type === 3 || cost.type === 4
|
||||||
|
? Math.min(100, Math.abs(cost.value))
|
||||||
|
: Math.abs(cost.value)),
|
||||||
percentage: cost.type === 3 || cost.type === 4,
|
percentage: cost.type === 3 || cost.type === 4,
|
||||||
}));
|
}));
|
||||||
const attackDistance = int32(buffers.magicCollect, offset + 0xbc);
|
const attackDistance = int32(buffers.magicCollect, offset + 0xbc);
|
||||||
|
|||||||
+410
-2
@@ -3,6 +3,10 @@ import { mkdirSync } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { DatabaseSync } from "node:sqlite";
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import {
|
||||||
|
cloneDefaultManastormAdminConfig,
|
||||||
|
normalizeManastormAdminConfig,
|
||||||
|
} from "./manastorm-config.mjs";
|
||||||
|
|
||||||
const scrypt = promisify(scryptCallback);
|
const scrypt = promisify(scryptCallback);
|
||||||
|
|
||||||
@@ -33,8 +37,19 @@ async function derivePassword(password, salt) {
|
|||||||
return Buffer.from(await scrypt(password, salt, 64));
|
return Buffer.from(await scrypt(password, salt, 64));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GM_USERNAME_KEYS = new Set(["phenom"]);
|
||||||
|
|
||||||
|
function accountRoles(username) {
|
||||||
|
return GM_USERNAME_KEYS.has(String(username ?? "").trim().toLowerCase()) ? ["gm"] : [];
|
||||||
|
}
|
||||||
|
|
||||||
function publicAccount(row) {
|
function publicAccount(row) {
|
||||||
return { id: row.id, username: row.username, createdAt: Number(row.created_at) };
|
return {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
createdAt: Number(row.created_at ?? row.createdAt),
|
||||||
|
roles: accountRoles(row.username),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openGameDatabase(options = {}) {
|
export function openGameDatabase(options = {}) {
|
||||||
@@ -62,7 +77,49 @@ export function openGameDatabase(options = {}) {
|
|||||||
account_id TEXT PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
account_id TEXT PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
revision INTEGER NOT NULL, data_json TEXT NOT NULL, updated_at INTEGER NOT NULL
|
revision INTEGER NOT NULL, data_json TEXT NOT NULL, updated_at INTEGER NOT NULL
|
||||||
) STRICT;
|
) STRICT;
|
||||||
|
CREATE TABLE IF NOT EXISTS manastorm_admin_config (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
schema_version INTEGER NOT NULL,
|
||||||
|
revision INTEGER NOT NULL,
|
||||||
|
config_json TEXT NOT NULL,
|
||||||
|
updated_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
|
||||||
|
updated_by_username TEXT,
|
||||||
|
updated_at INTEGER
|
||||||
|
) STRICT;
|
||||||
|
CREATE TABLE IF NOT EXISTS online_groups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
leader_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
activity_json TEXT,
|
||||||
|
activity_revision INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE TABLE IF NOT EXISTS online_group_members (
|
||||||
|
group_id TEXT NOT NULL REFERENCES online_groups(id) ON DELETE CASCADE,
|
||||||
|
account_id TEXT NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
character_json TEXT,
|
||||||
|
role TEXT,
|
||||||
|
joined_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (group_id, account_id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX IF NOT EXISTS online_group_members_group_idx ON online_group_members(group_id, joined_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS online_group_invites (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
group_id TEXT NOT NULL REFERENCES online_groups(id) ON DELETE CASCADE,
|
||||||
|
inviter_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
invitee_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX IF NOT EXISTS online_group_invites_invitee_idx
|
||||||
|
ON online_group_invites(invitee_account_id, status, expires_at);
|
||||||
`);
|
`);
|
||||||
|
database.prepare(`
|
||||||
|
INSERT OR IGNORE INTO manastorm_admin_config (
|
||||||
|
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
|
||||||
|
) VALUES (1, 1, 0, ?, NULL, NULL, NULL)
|
||||||
|
`).run(JSON.stringify(cloneDefaultManastormAdminConfig()));
|
||||||
|
|
||||||
const statements = {
|
const statements = {
|
||||||
accountByUsername: database.prepare("SELECT * FROM accounts WHERE username_key = ?"),
|
accountByUsername: database.prepare("SELECT * FROM accounts WHERE username_key = ?"),
|
||||||
@@ -82,6 +139,18 @@ export function openGameDatabase(options = {}) {
|
|||||||
data_json = excluded.data_json, updated_at = excluded.updated_at
|
data_json = excluded.data_json, updated_at = excluded.updated_at
|
||||||
RETURNING revision, updated_at
|
RETURNING revision, updated_at
|
||||||
`),
|
`),
|
||||||
|
manastormConfig: database.prepare("SELECT * FROM manastorm_admin_config WHERE id = 1"),
|
||||||
|
updateManastormConfig: database.prepare(`
|
||||||
|
UPDATE manastorm_admin_config
|
||||||
|
SET schema_version = 1,
|
||||||
|
revision = revision + 1,
|
||||||
|
config_json = ?,
|
||||||
|
updated_by_account_id = ?,
|
||||||
|
updated_by_username = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = 1 AND revision = ?
|
||||||
|
RETURNING revision, updated_at
|
||||||
|
`),
|
||||||
};
|
};
|
||||||
|
|
||||||
function issueSession(accountId) {
|
function issueSession(accountId) {
|
||||||
@@ -110,7 +179,7 @@ export function openGameDatabase(options = {}) {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return { account, ...issueSession(account.id) };
|
return { account: publicAccount(account), ...issueSession(account.id) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(usernameInput, password) {
|
async function login(usernameInput, password) {
|
||||||
@@ -160,6 +229,334 @@ export function openGameDatabase(options = {}) {
|
|||||||
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data };
|
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGameMaster(account) {
|
||||||
|
// Recompute authority from the server allowlist for every privileged call.
|
||||||
|
return accountRoles(account?.username).includes("gm");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getManastormConfig() {
|
||||||
|
const row = statements.manastormConfig.get();
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
revision: Number(row?.revision ?? 0),
|
||||||
|
updatedAt: row?.updated_at == null ? null : Number(row.updated_at),
|
||||||
|
updatedBy: row?.updated_by_username ?? null,
|
||||||
|
config: normalizeManastormAdminConfig(JSON.parse(row?.config_json ?? "null")),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Healer Man Manastorm configuration is corrupt; using safe defaults", error);
|
||||||
|
return { revision: 0, updatedAt: null, updatedBy: null, config: cloneDefaultManastormAdminConfig() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function putManastormConfig(account, expectedRevision, config) {
|
||||||
|
if (!isGameMaster(account)) {
|
||||||
|
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
|
||||||
|
throw new GameDatabaseError("A valid expected revision is required.", 400, "invalid_revision");
|
||||||
|
}
|
||||||
|
let normalized;
|
||||||
|
try {
|
||||||
|
normalized = normalizeManastormAdminConfig(config);
|
||||||
|
} catch (error) {
|
||||||
|
throw new GameDatabaseError(error instanceof Error ? error.message : "Invalid Manastorm configuration.", 400, "invalid_manastorm_config");
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const row = statements.updateManastormConfig.get(
|
||||||
|
JSON.stringify(normalized),
|
||||||
|
account.id,
|
||||||
|
account.username,
|
||||||
|
now,
|
||||||
|
expectedRevision,
|
||||||
|
);
|
||||||
|
if (!row) {
|
||||||
|
throw new GameDatabaseError("The Manastorm configuration changed. Reload and try again.", 409, "revision_conflict");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
revision: Number(row.revision),
|
||||||
|
updatedAt: Number(row.updated_at),
|
||||||
|
updatedBy: account.username,
|
||||||
|
config: normalized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneExpiredGroupInvites() {
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_group_invites SET status = 'expired'
|
||||||
|
WHERE status = 'pending' AND expires_at <= ?
|
||||||
|
`).run(Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoredJson(value, fallback = null) {
|
||||||
|
if (typeof value !== "string" || !value) return fallback;
|
||||||
|
try { return JSON.parse(value); } catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupMembership(accountId) {
|
||||||
|
return database.prepare(`
|
||||||
|
SELECT online_groups.*
|
||||||
|
FROM online_group_members
|
||||||
|
JOIN online_groups ON online_groups.id = online_group_members.group_id
|
||||||
|
WHERE online_group_members.account_id = ?
|
||||||
|
`).get(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicGroup(groupId) {
|
||||||
|
const group = database.prepare("SELECT * FROM online_groups WHERE id = ?").get(groupId);
|
||||||
|
if (!group) return null;
|
||||||
|
const members = database.prepare(`
|
||||||
|
SELECT accounts.id AS account_id, accounts.username,
|
||||||
|
online_group_members.character_json, online_group_members.role,
|
||||||
|
online_group_members.joined_at
|
||||||
|
FROM online_group_members
|
||||||
|
JOIN accounts ON accounts.id = online_group_members.account_id
|
||||||
|
WHERE online_group_members.group_id = ?
|
||||||
|
ORDER BY online_group_members.joined_at, accounts.username_key
|
||||||
|
`).all(groupId).map((member) => ({
|
||||||
|
accountId: member.account_id,
|
||||||
|
username: member.username,
|
||||||
|
character: parseStoredJson(member.character_json),
|
||||||
|
role: ["tank", "healer", "damage"].includes(member.role) ? member.role : null,
|
||||||
|
joinedAt: Number(member.joined_at),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
id: group.id,
|
||||||
|
leaderAccountId: group.leader_account_id,
|
||||||
|
members,
|
||||||
|
activity: parseStoredJson(group.activity_json),
|
||||||
|
activityRevision: Number(group.activity_revision),
|
||||||
|
createdAt: Number(group.created_at),
|
||||||
|
updatedAt: Number(group.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onlineGroupState(accountId) {
|
||||||
|
pruneExpiredGroupInvites();
|
||||||
|
const membership = groupMembership(accountId);
|
||||||
|
const invitations = database.prepare(`
|
||||||
|
SELECT online_group_invites.id, online_group_invites.group_id,
|
||||||
|
online_group_invites.created_at, online_group_invites.expires_at,
|
||||||
|
accounts.id AS inviter_account_id, accounts.username AS inviter_username
|
||||||
|
FROM online_group_invites
|
||||||
|
JOIN accounts ON accounts.id = online_group_invites.inviter_account_id
|
||||||
|
WHERE online_group_invites.invitee_account_id = ?
|
||||||
|
AND online_group_invites.status = 'pending'
|
||||||
|
AND online_group_invites.expires_at > ?
|
||||||
|
ORDER BY online_group_invites.created_at DESC
|
||||||
|
`).all(accountId, Date.now()).map((invite) => ({
|
||||||
|
id: invite.id,
|
||||||
|
groupId: invite.group_id,
|
||||||
|
inviterAccountId: invite.inviter_account_id,
|
||||||
|
inviterUsername: invite.inviter_username,
|
||||||
|
createdAt: Number(invite.created_at),
|
||||||
|
expiresAt: Number(invite.expires_at),
|
||||||
|
}));
|
||||||
|
return { group: membership ? publicGroup(membership.id) : null, invitations };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOnlineGroup(accountId) {
|
||||||
|
const existing = groupMembership(accountId);
|
||||||
|
if (existing) return existing;
|
||||||
|
const id = `group-${randomUUID()}`;
|
||||||
|
const now = Date.now();
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO online_groups (id, leader_account_id, activity_json, activity_revision, created_at, updated_at)
|
||||||
|
VALUES (?, ?, NULL, 0, ?, ?)
|
||||||
|
`).run(id, accountId, now, now);
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO online_group_members (group_id, account_id, character_json, role, joined_at)
|
||||||
|
VALUES (?, ?, NULL, NULL, ?)
|
||||||
|
`).run(id, accountId, now);
|
||||||
|
return database.prepare("SELECT * FROM online_groups WHERE id = ?").get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inviteOnlineGroupMember(account, usernameInput) {
|
||||||
|
pruneExpiredGroupInvites();
|
||||||
|
const username = typeof usernameInput === "string" ? usernameInput.trim() : "";
|
||||||
|
if (!username) throw new GameDatabaseError("Enter the player's account name.", 400, "missing_username");
|
||||||
|
const invitee = statements.accountByUsername.get(username.toLowerCase());
|
||||||
|
if (!invitee) throw new GameDatabaseError("No online player has that account name.", 404, "player_not_found");
|
||||||
|
if (invitee.id === account.id) throw new GameDatabaseError("You cannot invite yourself.", 400, "cannot_invite_self");
|
||||||
|
if (groupMembership(invitee.id)) throw new GameDatabaseError("That player is already in a group.", 409, "player_already_grouped");
|
||||||
|
const group = createOnlineGroup(account.id);
|
||||||
|
const memberCount = Number(database.prepare("SELECT COUNT(*) AS count FROM online_group_members WHERE group_id = ?").get(group.id).count);
|
||||||
|
if (memberCount >= 5) throw new GameDatabaseError("Your group is already full.", 409, "group_full");
|
||||||
|
const existing = database.prepare(`
|
||||||
|
SELECT id FROM online_group_invites
|
||||||
|
WHERE group_id = ? AND invitee_account_id = ? AND status = 'pending' AND expires_at > ?
|
||||||
|
`).get(group.id, invitee.id, Date.now());
|
||||||
|
if (existing) throw new GameDatabaseError("That player already has an invitation from your group.", 409, "invite_exists");
|
||||||
|
const id = `invite-${randomUUID()}`;
|
||||||
|
const createdAt = Date.now();
|
||||||
|
const expiresAt = createdAt + 10 * 60 * 1000;
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO online_group_invites (
|
||||||
|
id, group_id, inviter_account_id, invitee_account_id, status, created_at, expires_at
|
||||||
|
) VALUES (?, ?, ?, ?, 'pending', ?, ?)
|
||||||
|
`).run(id, group.id, account.id, invitee.id, createdAt, expiresAt);
|
||||||
|
return { id, expiresAt, state: onlineGroupState(account.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function respondToOnlineGroupInvite(accountId, inviteId, accept) {
|
||||||
|
pruneExpiredGroupInvites();
|
||||||
|
const invite = database.prepare(`
|
||||||
|
SELECT * FROM online_group_invites
|
||||||
|
WHERE id = ? AND invitee_account_id = ? AND status = 'pending' AND expires_at > ?
|
||||||
|
`).get(inviteId, accountId, Date.now());
|
||||||
|
if (!invite) throw new GameDatabaseError("That group invitation is no longer available.", 404, "invite_not_found");
|
||||||
|
if (!accept) {
|
||||||
|
database.prepare("UPDATE online_group_invites SET status = 'declined' WHERE id = ?").run(invite.id);
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
if (groupMembership(accountId)) throw new GameDatabaseError("Leave your current group before accepting another invitation.", 409, "already_grouped");
|
||||||
|
const group = database.prepare("SELECT * FROM online_groups WHERE id = ?").get(invite.group_id);
|
||||||
|
if (!group) throw new GameDatabaseError("That group no longer exists.", 404, "group_not_found");
|
||||||
|
const memberCount = Number(database.prepare("SELECT COUNT(*) AS count FROM online_group_members WHERE group_id = ?").get(group.id).count);
|
||||||
|
if (memberCount >= 5) throw new GameDatabaseError("That group is already full.", 409, "group_full");
|
||||||
|
const now = Date.now();
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO online_group_members (group_id, account_id, character_json, role, joined_at)
|
||||||
|
VALUES (?, ?, NULL, NULL, ?)
|
||||||
|
`).run(group.id, accountId, now);
|
||||||
|
database.prepare("UPDATE online_group_invites SET status = 'accepted' WHERE id = ?").run(invite.id);
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_group_invites SET status = 'expired'
|
||||||
|
WHERE invitee_account_id = ? AND status = 'pending'
|
||||||
|
`).run(accountId);
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_groups SET activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ?
|
||||||
|
`).run(now, group.id);
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaveOnlineGroup(accountId) {
|
||||||
|
const group = groupMembership(accountId);
|
||||||
|
if (!group) return onlineGroupState(accountId);
|
||||||
|
database.prepare("DELETE FROM online_group_members WHERE group_id = ? AND account_id = ?").run(group.id, accountId);
|
||||||
|
const remaining = database.prepare(`
|
||||||
|
SELECT account_id FROM online_group_members WHERE group_id = ? ORDER BY joined_at LIMIT 1
|
||||||
|
`).get(group.id);
|
||||||
|
if (!remaining) {
|
||||||
|
database.prepare("DELETE FROM online_groups WHERE id = ?").run(group.id);
|
||||||
|
} else {
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_groups
|
||||||
|
SET leader_account_id = CASE WHEN leader_account_id = ? THEN ? ELSE leader_account_id END,
|
||||||
|
activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(accountId, remaining.account_id, Date.now(), group.id);
|
||||||
|
}
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOnlineGroupMember(accountId, targetAccountId) {
|
||||||
|
const group = groupMembership(accountId);
|
||||||
|
if (!group || group.leader_account_id !== accountId) {
|
||||||
|
throw new GameDatabaseError("Only the group leader can remove players.", 403, "leader_required");
|
||||||
|
}
|
||||||
|
if (targetAccountId === accountId) return leaveOnlineGroup(accountId);
|
||||||
|
const removed = database.prepare(`
|
||||||
|
DELETE FROM online_group_members WHERE group_id = ? AND account_id = ?
|
||||||
|
`).run(group.id, targetAccountId);
|
||||||
|
if (!removed.changes) throw new GameDatabaseError("That player is not in your group.", 404, "member_not_found");
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_groups SET activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ?
|
||||||
|
`).run(Date.now(), group.id);
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGroupCharacter(character) {
|
||||||
|
if (!character || typeof character !== "object" || Array.isArray(character)) return null;
|
||||||
|
const id = String(character.id ?? "").slice(0, 100);
|
||||||
|
const name = String(character.name ?? "").trim().slice(0, 30);
|
||||||
|
const classId = String(character.classId ?? "").slice(0, 50);
|
||||||
|
if (!id || !name || !classId) throw new GameDatabaseError("Select a valid character for the group.", 400, "invalid_group_character");
|
||||||
|
let appearance = {};
|
||||||
|
if (character.appearance && typeof character.appearance === "object" && !Array.isArray(character.appearance)) {
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(character.appearance);
|
||||||
|
if (serialized.length <= 8 * 1024) appearance = JSON.parse(serialized);
|
||||||
|
} catch {
|
||||||
|
appearance = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
classId,
|
||||||
|
categoryId: String(character.categoryId ?? "wow").slice(0, 20),
|
||||||
|
raceId: String(character.raceId ?? "human").slice(0, 50),
|
||||||
|
gender: character.gender === "female" ? "female" : "male",
|
||||||
|
level: Math.max(1, Math.min(1000, Math.trunc(Number(character.level) || 1))),
|
||||||
|
appearance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOnlineGroupMember(accountId, character, role) {
|
||||||
|
const group = groupMembership(accountId);
|
||||||
|
if (!group) throw new GameDatabaseError("Join or create a group first.", 409, "group_required");
|
||||||
|
if (!["tank", "healer", "damage"].includes(role)) {
|
||||||
|
throw new GameDatabaseError("Choose a valid party role.", 400, "invalid_party_role");
|
||||||
|
}
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_group_members SET character_json = ?, role = ?
|
||||||
|
WHERE group_id = ? AND account_id = ?
|
||||||
|
`).run(JSON.stringify(normalizeGroupCharacter(character)), role, group.id, accountId);
|
||||||
|
database.prepare("UPDATE online_groups SET updated_at = ? WHERE id = ?").run(Date.now(), group.id);
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOnlineGroupActivity(accountId, input) {
|
||||||
|
const group = groupMembership(accountId);
|
||||||
|
if (!group || group.leader_account_id !== accountId) {
|
||||||
|
throw new GameDatabaseError("Only the group leader can start an activity.", 403, "leader_required");
|
||||||
|
}
|
||||||
|
const type = input?.type;
|
||||||
|
if (type !== "dungeon" && type !== "manastorm") {
|
||||||
|
throw new GameDatabaseError("Choose a Dungeon or Manastorm.", 400, "invalid_activity_type");
|
||||||
|
}
|
||||||
|
const contentId = String(input?.contentId ?? "").trim().slice(0, 120);
|
||||||
|
if (!contentId) throw new GameDatabaseError("Choose content before starting.", 400, "missing_content");
|
||||||
|
const members = database.prepare(`
|
||||||
|
SELECT account_id, character_json, role FROM online_group_members WHERE group_id = ? ORDER BY joined_at
|
||||||
|
`).all(group.id);
|
||||||
|
if (members.some((member) => !member.character_json || !["tank", "healer", "damage"].includes(member.role))) {
|
||||||
|
throw new GameDatabaseError("Every player must select a character and party role before the group can start.", 409, "group_not_ready");
|
||||||
|
}
|
||||||
|
const fillWithAi = input?.fillWithAi !== false;
|
||||||
|
const requestedSize = Math.max(1, Math.min(5, Math.trunc(Number(input?.partySize) || 5)));
|
||||||
|
const partySize = fillWithAi ? Math.max(members.length, requestedSize) : members.length;
|
||||||
|
const activity = {
|
||||||
|
id: `activity-${randomUUID()}`,
|
||||||
|
type,
|
||||||
|
contentId,
|
||||||
|
fillWithAi,
|
||||||
|
partySize,
|
||||||
|
startingLevel: type === "manastorm"
|
||||||
|
? Math.max(1, Math.trunc(Number(input?.startingLevel) || 1))
|
||||||
|
: null,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
};
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_groups
|
||||||
|
SET activity_json = ?, activity_revision = activity_revision + 1, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(JSON.stringify(activity), activity.startedAt, group.id);
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearOnlineGroupActivity(accountId) {
|
||||||
|
const group = groupMembership(accountId);
|
||||||
|
if (!group || group.leader_account_id !== accountId) {
|
||||||
|
throw new GameDatabaseError("Only the group leader can reset the activity.", 403, "leader_required");
|
||||||
|
}
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_groups SET activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ?
|
||||||
|
`).run(Date.now(), group.id);
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
databasePath,
|
databasePath,
|
||||||
register,
|
register,
|
||||||
@@ -168,6 +565,17 @@ export function openGameDatabase(options = {}) {
|
|||||||
logout: (token) => { if (typeof token === "string" && token) statements.deleteSession.run(tokenHash(token)); },
|
logout: (token) => { if (typeof token === "string" && token) statements.deleteSession.run(tokenHash(token)); },
|
||||||
getCloudSave,
|
getCloudSave,
|
||||||
putCloudSave,
|
putCloudSave,
|
||||||
|
isGameMaster,
|
||||||
|
getManastormConfig,
|
||||||
|
putManastormConfig,
|
||||||
|
onlineGroupState,
|
||||||
|
inviteOnlineGroupMember,
|
||||||
|
respondToOnlineGroupInvite,
|
||||||
|
leaveOnlineGroup,
|
||||||
|
removeOnlineGroupMember,
|
||||||
|
updateOnlineGroupMember,
|
||||||
|
startOnlineGroupActivity,
|
||||||
|
clearOnlineGroupActivity,
|
||||||
pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes),
|
pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes),
|
||||||
close: () => database.close(),
|
close: () => database.close(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
||||||
|
|
||||||
function withDatabase(run) {
|
function withDatabase(run) {
|
||||||
@@ -52,3 +53,129 @@ test("persists opaque cloud roster data with increasing revisions", () => withDa
|
|||||||
assert.equal(second.revision, 2);
|
assert.equal(second.revision, 2);
|
||||||
assert.equal(database.getCloudSave(registered.account.id).data.characters[0].name, "Mendbetter");
|
assert.equal(database.getCloudSave(registered.account.id).data.characters[0].name, "Mendbetter");
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
test("derives GM authority from the hardcoded Phenom account", () => withDatabase(async (database) => {
|
||||||
|
const gm = await database.register("Phenom", "storm-admin");
|
||||||
|
const player = await database.register("Ordinary", "storm-player");
|
||||||
|
assert.deepEqual(gm.account.roles, ["gm"]);
|
||||||
|
assert.deepEqual((await database.login("pHeNoM", "storm-admin")).account.roles, ["gm"]);
|
||||||
|
assert.equal(database.isGameMaster(database.authenticate(gm.token)), true);
|
||||||
|
assert.equal(database.isGameMaster(database.authenticate(player.token)), false);
|
||||||
|
assert.equal(database.isGameMaster({ ...player.account, roles: ["gm"] }), false);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("recovers Manastorm configuration after restart and falls back safely from corrupt data", async () => {
|
||||||
|
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-db-restart-test-"));
|
||||||
|
const databasePath = path.join(directory, "game.db");
|
||||||
|
try {
|
||||||
|
const first = openGameDatabase({ databasePath });
|
||||||
|
const gm = await first.register("Phenom", "storm-admin");
|
||||||
|
const current = first.getManastormConfig();
|
||||||
|
first.putManastormConfig(first.authenticate(gm.token), 0, {
|
||||||
|
...current.config,
|
||||||
|
global: { ...current.config.global, enabled: false },
|
||||||
|
});
|
||||||
|
first.close();
|
||||||
|
|
||||||
|
const restarted = openGameDatabase({ databasePath });
|
||||||
|
assert.equal(restarted.getManastormConfig().revision, 1);
|
||||||
|
assert.equal(restarted.getManastormConfig().config.global.enabled, false);
|
||||||
|
restarted.close();
|
||||||
|
|
||||||
|
const raw = new DatabaseSync(databasePath);
|
||||||
|
raw.prepare("UPDATE manastorm_admin_config SET config_json = ? WHERE id = 1").run("{corrupt");
|
||||||
|
raw.close();
|
||||||
|
const recovered = openGameDatabase({ databasePath });
|
||||||
|
assert.equal(recovered.getManastormConfig().revision, 0);
|
||||||
|
assert.deepEqual(recovered.getManastormConfig().config.bosses, {});
|
||||||
|
recovered.close();
|
||||||
|
} finally {
|
||||||
|
rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("persists revisioned Manastorm GM configuration and rejects stale or unauthorized writes", () => withDatabase(async (database) => {
|
||||||
|
const gm = await database.register("Phenom", "storm-admin");
|
||||||
|
const player = await database.register("Ordinary", "storm-player");
|
||||||
|
const gmAccount = database.authenticate(gm.token);
|
||||||
|
const playerAccount = database.authenticate(player.token);
|
||||||
|
const initial = database.getManastormConfig();
|
||||||
|
assert.equal(initial.revision, 0);
|
||||||
|
assert.deepEqual(initial.config.bosses, {});
|
||||||
|
|
||||||
|
const configured = {
|
||||||
|
...initial.config,
|
||||||
|
global: { ...initial.config.global, affixesEnabled: false },
|
||||||
|
};
|
||||||
|
const saved = database.putManastormConfig(gmAccount, 0, configured);
|
||||||
|
assert.equal(saved.revision, 1);
|
||||||
|
assert.equal(saved.config.global.affixesEnabled, false);
|
||||||
|
assert.equal(database.getManastormConfig().updatedBy, "Phenom");
|
||||||
|
assert.throws(() => database.putManastormConfig(gmAccount, 0, configured), (error) => (
|
||||||
|
error instanceof GameDatabaseError && error.status === 409
|
||||||
|
));
|
||||||
|
assert.throws(() => database.putManastormConfig(playerAccount, 1, configured), (error) => (
|
||||||
|
error instanceof GameDatabaseError && error.status === 403
|
||||||
|
));
|
||||||
|
assert.throws(() => database.putManastormConfig(gmAccount, 1, {
|
||||||
|
...configured,
|
||||||
|
bosses: { "boss:incomplete": { status: "ready" } },
|
||||||
|
}), (error) => error instanceof GameDatabaseError && error.status === 400);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("creates online groups through invitations and transfers leadership when the leader leaves", () => withDatabase(async (database) => {
|
||||||
|
const leaderAuth = await database.register("GroupLeader", "group-pass");
|
||||||
|
const healerAuth = await database.register("GroupHealer", "healer-pass");
|
||||||
|
const leader = database.authenticate(leaderAuth.token);
|
||||||
|
const healer = database.authenticate(healerAuth.token);
|
||||||
|
|
||||||
|
const invitation = database.inviteOnlineGroupMember(leader, "grouphealer");
|
||||||
|
assert.equal(invitation.state.group.members.length, 1);
|
||||||
|
const incoming = database.onlineGroupState(healer.id).invitations;
|
||||||
|
assert.equal(incoming.length, 1);
|
||||||
|
assert.equal(incoming[0].inviterUsername, "GroupLeader");
|
||||||
|
|
||||||
|
const accepted = database.respondToOnlineGroupInvite(healer.id, incoming[0].id, true);
|
||||||
|
assert.equal(accepted.group.members.length, 2);
|
||||||
|
assert.equal(accepted.group.leaderAccountId, leader.id);
|
||||||
|
|
||||||
|
database.leaveOnlineGroup(leader.id);
|
||||||
|
const transferred = database.onlineGroupState(healer.id);
|
||||||
|
assert.equal(transferred.group.leaderAccountId, healer.id);
|
||||||
|
assert.deepEqual(transferred.group.members.map((member) => member.username), ["GroupHealer"]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("launches players-only and AI-filled group activities only when every member is ready", () => withDatabase(async (database) => {
|
||||||
|
const leaderAuth = await database.register("ReadyLeader", "group-pass");
|
||||||
|
const memberAuth = await database.register("ReadyMember", "member-pass");
|
||||||
|
const leader = database.authenticate(leaderAuth.token);
|
||||||
|
const member = database.authenticate(memberAuth.token);
|
||||||
|
database.inviteOnlineGroupMember(leader, member.username);
|
||||||
|
const invite = database.onlineGroupState(member.id).invitations[0];
|
||||||
|
database.respondToOnlineGroupInvite(member.id, invite.id, true);
|
||||||
|
|
||||||
|
database.updateOnlineGroupMember(leader.id, {
|
||||||
|
id: "leader-character", name: "Leadwell", classId: "warrior", raceId: "human", gender: "male", level: 20,
|
||||||
|
}, "tank");
|
||||||
|
assert.throws(() => database.startOnlineGroupActivity(leader.id, {
|
||||||
|
type: "dungeon", contentId: "wailing-caverns", fillWithAi: true, partySize: 5,
|
||||||
|
}), (error) => error instanceof GameDatabaseError && error.code === "group_not_ready");
|
||||||
|
|
||||||
|
database.updateOnlineGroupMember(member.id, {
|
||||||
|
id: "member-character", name: "Mendwell", classId: "priest", raceId: "human", gender: "female", level: 20,
|
||||||
|
}, "healer");
|
||||||
|
const playersOnly = database.startOnlineGroupActivity(leader.id, {
|
||||||
|
type: "dungeon", contentId: "wailing-caverns", fillWithAi: false, partySize: 5,
|
||||||
|
});
|
||||||
|
assert.equal(playersOnly.group.activity.partySize, 2);
|
||||||
|
assert.equal(playersOnly.group.activity.fillWithAi, false);
|
||||||
|
|
||||||
|
const filled = database.startOnlineGroupActivity(leader.id, {
|
||||||
|
type: "manastorm", contentId: "manastorm", fillWithAi: true, partySize: 5, startingLevel: 10,
|
||||||
|
});
|
||||||
|
assert.equal(filled.group.activity.partySize, 5);
|
||||||
|
assert.equal(filled.group.activity.startingLevel, 10);
|
||||||
|
assert.throws(() => database.startOnlineGroupActivity(member.id, {
|
||||||
|
type: "dungeon", contentId: "wailing-caverns",
|
||||||
|
}), (error) => error instanceof GameDatabaseError && error.status === 403);
|
||||||
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
export const MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
export const DEFAULT_MANASTORM_ADMIN_CONFIG = Object.freeze({
|
||||||
|
schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION,
|
||||||
|
global: Object.freeze({
|
||||||
|
enabled: true,
|
||||||
|
levelingEnabled: true,
|
||||||
|
endgameEnabled: true,
|
||||||
|
affixesEnabled: true,
|
||||||
|
chaoticLinkEnabled: true,
|
||||||
|
milestoneEncountersEnabled: true,
|
||||||
|
}),
|
||||||
|
dungeonSpawns: Object.freeze({}),
|
||||||
|
bosses: Object.freeze({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const BOSS_STATUSES = new Set(["ready", "needs-tested", "not-ready"]);
|
||||||
|
const MAX_RECORDS = 10_000;
|
||||||
|
const MAX_ID_LENGTH = 256;
|
||||||
|
|
||||||
|
function invalid(message) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = "invalid_manastorm_config";
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value, label) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw invalid(`${label} must be an object.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function identifier(value, label) {
|
||||||
|
if (typeof value !== "string" || !value.trim() || value.length > MAX_ID_LENGTH) {
|
||||||
|
throw invalid(`${label} must be a non-empty string no longer than ${MAX_ID_LENGTH} characters.`);
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalIdentifier(value, label) {
|
||||||
|
return value === undefined || value === null ? undefined : identifier(value, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value, label) {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
throw invalid(`${label} must be a finite number.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vector(value, label) {
|
||||||
|
if (!Array.isArray(value) || value.length !== 3) {
|
||||||
|
throw invalid(`${label} must contain exactly three coordinates.`);
|
||||||
|
}
|
||||||
|
return value.map((coordinate, index) => finiteNumber(coordinate, `${label}[${index}]`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function worldSpawn(value, label) {
|
||||||
|
const input = record(value, label);
|
||||||
|
return {
|
||||||
|
footPosition: vector(input.footPosition, `${label}.footPosition`),
|
||||||
|
yaw: finiteNumber(input.yaw, `${label}.yaw`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolean(value, label) {
|
||||||
|
if (typeof value !== "boolean") throw invalid(`${label} must be a boolean.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGlobal(value) {
|
||||||
|
const input = record(value, "config.global");
|
||||||
|
return {
|
||||||
|
enabled: boolean(input.enabled, "config.global.enabled"),
|
||||||
|
levelingEnabled: boolean(input.levelingEnabled, "config.global.levelingEnabled"),
|
||||||
|
endgameEnabled: boolean(input.endgameEnabled, "config.global.endgameEnabled"),
|
||||||
|
affixesEnabled: boolean(input.affixesEnabled, "config.global.affixesEnabled"),
|
||||||
|
chaoticLinkEnabled: boolean(input.chaoticLinkEnabled, "config.global.chaoticLinkEnabled"),
|
||||||
|
milestoneEncountersEnabled: boolean(
|
||||||
|
input.milestoneEncountersEnabled,
|
||||||
|
"config.global.milestoneEncountersEnabled",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStage(value, label) {
|
||||||
|
const input = record(value, label);
|
||||||
|
const linkedMobRuntimeIds = Array.isArray(input.linkedMobRuntimeIds)
|
||||||
|
? input.linkedMobRuntimeIds.map((id, index) => identifier(id, `${label}.linkedMobRuntimeIds[${index}]`))
|
||||||
|
: (() => { throw invalid(`${label}.linkedMobRuntimeIds must be an array.`); })();
|
||||||
|
if (new Set(linkedMobRuntimeIds).size !== linkedMobRuntimeIds.length) {
|
||||||
|
throw invalid(`${label}.linkedMobRuntimeIds must not contain duplicates.`);
|
||||||
|
}
|
||||||
|
const requiredKillCount = finiteNumber(input.requiredKillCount, `${label}.requiredKillCount`);
|
||||||
|
if (!Number.isInteger(requiredKillCount) || requiredKillCount < 0 || requiredKillCount > linkedMobRuntimeIds.length) {
|
||||||
|
throw invalid(`${label}.requiredKillCount must be an integer between zero and the linked mob count.`);
|
||||||
|
}
|
||||||
|
const mapId = finiteNumber(input.mapId, `${label}.mapId`);
|
||||||
|
if (!Number.isInteger(mapId) || mapId <= 0) throw invalid(`${label}.mapId must be a positive integer.`);
|
||||||
|
return {
|
||||||
|
dungeonId: identifier(input.dungeonId, `${label}.dungeonId`),
|
||||||
|
mapId,
|
||||||
|
bossRuntimeId: identifier(input.bossRuntimeId, `${label}.bossRuntimeId`),
|
||||||
|
bossEntityId: identifier(input.bossEntityId, `${label}.bossEntityId`),
|
||||||
|
bossName: identifier(input.bossName, `${label}.bossName`),
|
||||||
|
...(optionalIdentifier(input.baseStageId, `${label}.baseStageId`)
|
||||||
|
? { baseStageId: optionalIdentifier(input.baseStageId, `${label}.baseStageId`) }
|
||||||
|
: {}),
|
||||||
|
...(optionalIdentifier(input.assetPackageId, `${label}.assetPackageId`)
|
||||||
|
? { assetPackageId: optionalIdentifier(input.assetPackageId, `${label}.assetPackageId`) }
|
||||||
|
: {}),
|
||||||
|
bossPosition: vector(input.bossPosition, `${label}.bossPosition`),
|
||||||
|
bossYaw: finiteNumber(input.bossYaw, `${label}.bossYaw`),
|
||||||
|
groupSpawn: worldSpawn(input.groupSpawn, `${label}.groupSpawn`),
|
||||||
|
portalPosition: vector(input.portalPosition, `${label}.portalPosition`),
|
||||||
|
linkedMobRuntimeIds,
|
||||||
|
requiredKillCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDictionary(value, label, normalizeValue) {
|
||||||
|
const input = record(value, label);
|
||||||
|
const entries = Object.entries(input);
|
||||||
|
if (entries.length > MAX_RECORDS) throw invalid(`${label} contains too many records.`);
|
||||||
|
return Object.fromEntries(entries.map(([key, entry]) => [
|
||||||
|
identifier(key, `${label} key`),
|
||||||
|
normalizeValue(entry, `${label}.${key}`),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeManastormAdminConfig(value) {
|
||||||
|
const input = record(value, "config");
|
||||||
|
if (input.schemaVersion !== MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION) {
|
||||||
|
throw invalid(`Unsupported Manastorm admin config schema ${String(input.schemaVersion)}.`);
|
||||||
|
}
|
||||||
|
const dungeonSpawns = normalizeDictionary(input.dungeonSpawns, "config.dungeonSpawns", worldSpawn);
|
||||||
|
const bosses = normalizeDictionary(input.bosses, "config.bosses", (rawBoss, label) => {
|
||||||
|
const boss = record(rawBoss, label);
|
||||||
|
if (!BOSS_STATUSES.has(boss.status)) throw invalid(`${label}.status is invalid.`);
|
||||||
|
if (boss.status === "ready" && (boss.stage === undefined || boss.stage === null)) {
|
||||||
|
throw invalid(`${label} cannot be Ready without a complete stage.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: boss.status,
|
||||||
|
...(boss.stage === undefined || boss.stage === null
|
||||||
|
? {}
|
||||||
|
: { stage: normalizeStage(boss.stage, `${label}.stage`) }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION,
|
||||||
|
global: normalizeGlobal(input.global),
|
||||||
|
dungeonSpawns,
|
||||||
|
bosses,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneDefaultManastormAdminConfig() {
|
||||||
|
return JSON.parse(JSON.stringify(DEFAULT_MANASTORM_ADMIN_CONFIG));
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { GameDatabaseError } from "./database.mjs";
|
||||||
|
|
||||||
|
const PLAYER_TIMEOUT_MS = 15_000;
|
||||||
|
const ROOM_TIMEOUT_MS = 30 * 60_000;
|
||||||
|
const MAX_EVENTS = 256;
|
||||||
|
const MAX_BATCH_EVENTS = 48;
|
||||||
|
const ALLOWED_EVENT_KINDS = new Set([
|
||||||
|
"damage",
|
||||||
|
"healing",
|
||||||
|
"resurrection",
|
||||||
|
"loot",
|
||||||
|
"portal",
|
||||||
|
]);
|
||||||
|
const ALLOWED_SCHOOLS = new Set([
|
||||||
|
"physical",
|
||||||
|
"holy",
|
||||||
|
"fire",
|
||||||
|
"nature",
|
||||||
|
"frost",
|
||||||
|
"shadow",
|
||||||
|
"arcane",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function finite(value, fallback = 0) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedString(value, maximum = 160) {
|
||||||
|
return String(value ?? "").slice(0, maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vector3(value) {
|
||||||
|
if (!Array.isArray(value) || value.length < 3) return [0, 0, 0];
|
||||||
|
return value.slice(0, 3).map((entry) => Math.max(-100_000, Math.min(100_000, finite(entry))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonClone(value, fallback = null) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireActivity(account, snapshot) {
|
||||||
|
const group = snapshot?.group;
|
||||||
|
const activity = group?.activity;
|
||||||
|
const member = group?.members?.find((candidate) => candidate.accountId === account.id);
|
||||||
|
if (!group || !activity || !member) {
|
||||||
|
throw new GameDatabaseError("The shared activity is no longer active.", 409, "online_activity_required");
|
||||||
|
}
|
||||||
|
return { group, activity, member };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePresence(input, member, now) {
|
||||||
|
const maximumHealth = Math.max(1, Math.min(1_000_000_000, Math.round(finite(input?.maxHealth, 1))));
|
||||||
|
const maximumResource = Math.max(0, Math.min(1_000_000_000, Math.round(finite(input?.maxResource))));
|
||||||
|
return {
|
||||||
|
accountId: member.accountId,
|
||||||
|
username: member.username,
|
||||||
|
character: member.character,
|
||||||
|
role: member.role,
|
||||||
|
position: vector3(input?.position),
|
||||||
|
yaw: Math.max(-Math.PI * 8, Math.min(Math.PI * 8, finite(input?.yaw))),
|
||||||
|
grounded: input?.grounded !== false,
|
||||||
|
health: Math.max(0, Math.min(maximumHealth, Math.round(finite(input?.health, maximumHealth)))),
|
||||||
|
maxHealth: maximumHealth,
|
||||||
|
resource: Math.max(0, Math.min(maximumResource, finite(input?.resource))),
|
||||||
|
maxResource: maximumResource,
|
||||||
|
resourceName: boundedString(input?.resourceName, 40),
|
||||||
|
selectedTargetId: input?.selectedTargetId ? boundedString(input.selectedTargetId) : null,
|
||||||
|
activeCast: input?.activeCast && typeof input.activeCast === "object"
|
||||||
|
? jsonClone(input.activeCast)
|
||||||
|
: null,
|
||||||
|
animationEvent: input?.animationEvent && typeof input.animationEvent === "object"
|
||||||
|
? jsonClone(input.animationEvent)
|
||||||
|
: null,
|
||||||
|
equipment: Array.isArray(input?.equipment)
|
||||||
|
? jsonClone(input.equipment.slice(0, 24), [])
|
||||||
|
: [],
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeEvent(input, accountId, leaderAccountId) {
|
||||||
|
const kind = boundedString(input?.kind, 32);
|
||||||
|
if (!ALLOWED_EVENT_KINDS.has(kind)) return null;
|
||||||
|
const targetActorId = boundedString(input?.targetActorId);
|
||||||
|
if (!targetActorId && kind !== "portal") return null;
|
||||||
|
const sourceActorId = boundedString(input?.sourceActorId);
|
||||||
|
const localPlayerActorId = `online-player:${accountId}`;
|
||||||
|
// Non-leaders may submit only their own player actions. Enemy and AI events
|
||||||
|
// are produced by the activity authority and cannot be forged by members.
|
||||||
|
if (accountId !== leaderAccountId && sourceActorId !== localPlayerActorId) return null;
|
||||||
|
const school = boundedString(input?.school, 24);
|
||||||
|
return {
|
||||||
|
clientEventId: boundedString(input?.clientEventId, 200),
|
||||||
|
kind,
|
||||||
|
sourceActorId: sourceActorId || localPlayerActorId,
|
||||||
|
targetActorId,
|
||||||
|
abilityId: input?.abilityId ? boundedString(input.abilityId) : null,
|
||||||
|
school: ALLOWED_SCHOOLS.has(school) ? school : null,
|
||||||
|
rawAmount: Math.max(0, Math.min(1_000_000_000, finite(input?.rawAmount))),
|
||||||
|
effectiveAmount: Math.max(0, Math.min(1_000_000_000, finite(input?.effectiveAmount))),
|
||||||
|
critical: input?.critical === true,
|
||||||
|
occurredAt: Math.max(0, Math.trunc(finite(input?.occurredAt, Date.now()))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOnlineSessionManager() {
|
||||||
|
const rooms = new Map();
|
||||||
|
|
||||||
|
function prune(now) {
|
||||||
|
for (const [activityId, room] of rooms) {
|
||||||
|
if (now - room.updatedAt > ROOM_TIMEOUT_MS) rooms.delete(activityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomFor(group, activity, now) {
|
||||||
|
prune(now);
|
||||||
|
let room = rooms.get(activity.id);
|
||||||
|
if (!room || room.groupId !== group.id) {
|
||||||
|
room = {
|
||||||
|
activityId: activity.id,
|
||||||
|
groupId: group.id,
|
||||||
|
revision: 0,
|
||||||
|
nextEventId: 1,
|
||||||
|
players: new Map(),
|
||||||
|
events: [],
|
||||||
|
eventKeys: new Set(),
|
||||||
|
world: null,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
rooms.set(activity.id, room);
|
||||||
|
}
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync(account, groupSnapshot, input = {}) {
|
||||||
|
const now = Date.now();
|
||||||
|
const { group, activity, member } = requireActivity(account, groupSnapshot);
|
||||||
|
const room = roomFor(group, activity, now);
|
||||||
|
const presence = sanitizePresence(input.presence, member, now);
|
||||||
|
room.players.set(account.id, presence);
|
||||||
|
room.revision += 1;
|
||||||
|
room.updatedAt = now;
|
||||||
|
|
||||||
|
if (input.world !== undefined) {
|
||||||
|
if (group.leaderAccountId !== account.id) {
|
||||||
|
throw new GameDatabaseError("Only the activity leader can publish shared world state.", 403, "activity_authority_required");
|
||||||
|
}
|
||||||
|
if (!input.world || typeof input.world !== "object" || Array.isArray(input.world)) {
|
||||||
|
throw new GameDatabaseError("Shared world state must be an object.", 400, "invalid_world_state");
|
||||||
|
}
|
||||||
|
room.world = {
|
||||||
|
...jsonClone(input.world, {}),
|
||||||
|
publishedAt: now,
|
||||||
|
publishedBy: account.id,
|
||||||
|
};
|
||||||
|
room.revision += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch = Array.isArray(input.events) ? input.events.slice(0, MAX_BATCH_EVENTS) : [];
|
||||||
|
for (const candidate of batch) {
|
||||||
|
const event = sanitizeEvent(candidate, account.id, group.leaderAccountId);
|
||||||
|
if (!event?.clientEventId) continue;
|
||||||
|
const eventKey = `${account.id}:${event.clientEventId}`;
|
||||||
|
if (room.eventKeys.has(eventKey)) continue;
|
||||||
|
room.eventKeys.add(eventKey);
|
||||||
|
room.events.push({
|
||||||
|
...event,
|
||||||
|
id: room.nextEventId,
|
||||||
|
sourceAccountId: account.id,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
room.nextEventId += 1;
|
||||||
|
room.revision += 1;
|
||||||
|
}
|
||||||
|
if (room.events.length > MAX_EVENTS) {
|
||||||
|
const removed = room.events.splice(0, room.events.length - MAX_EVENTS);
|
||||||
|
for (const event of removed) room.eventKeys.delete(`${event.sourceAccountId}:${event.clientEventId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberIds = new Set(group.members.map((candidate) => candidate.accountId));
|
||||||
|
for (const [accountId, player] of room.players) {
|
||||||
|
if (!memberIds.has(accountId) || now - player.updatedAt > PLAYER_TIMEOUT_MS) {
|
||||||
|
room.players.delete(accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const afterEventId = Math.max(0, Math.trunc(finite(input.afterEventId)));
|
||||||
|
return {
|
||||||
|
activity,
|
||||||
|
groupId: group.id,
|
||||||
|
leaderAccountId: group.leaderAccountId,
|
||||||
|
localAccountId: account.id,
|
||||||
|
authority: group.leaderAccountId === account.id,
|
||||||
|
revision: room.revision,
|
||||||
|
serverTime: now,
|
||||||
|
players: [...room.players.values()].sort((left, right) => left.accountId.localeCompare(right.accountId)),
|
||||||
|
world: room.world,
|
||||||
|
events: room.events.filter((event) => event.id > afterEventId),
|
||||||
|
latestEventId: room.nextEventId - 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sync,
|
||||||
|
clear: () => rooms.clear(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { GameDatabaseError } from "./database.mjs";
|
||||||
|
import { createOnlineSessionManager } from "./online-session.mjs";
|
||||||
|
|
||||||
|
function groupSnapshot() {
|
||||||
|
return {
|
||||||
|
invitations: [],
|
||||||
|
group: {
|
||||||
|
id: "group-one",
|
||||||
|
leaderAccountId: "leader",
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
accountId: "leader",
|
||||||
|
username: "Leader",
|
||||||
|
character: { id: "lead", name: "Bulwark", classId: "warrior", raceId: "human", gender: "male", level: 20 },
|
||||||
|
role: "tank",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: "healer",
|
||||||
|
username: "Healer",
|
||||||
|
character: { id: "heal", name: "Mendara", classId: "priest", raceId: "human", gender: "female", level: 20 },
|
||||||
|
role: "healer",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activity: {
|
||||||
|
id: "activity-one",
|
||||||
|
type: "dungeon",
|
||||||
|
contentId: "wailing-caverns",
|
||||||
|
fillWithAi: false,
|
||||||
|
partySize: 2,
|
||||||
|
startingLevel: null,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const presence = {
|
||||||
|
position: [1, 2, 3],
|
||||||
|
yaw: 0.5,
|
||||||
|
health: 900,
|
||||||
|
maxHealth: 1_000,
|
||||||
|
resource: 400,
|
||||||
|
maxResource: 500,
|
||||||
|
resourceName: "Mana",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("synchronizes player presence, leader world state, and ordered combat events", () => {
|
||||||
|
const manager = createOnlineSessionManager();
|
||||||
|
const snapshot = groupSnapshot();
|
||||||
|
const leader = manager.sync({ id: "leader" }, snapshot, {
|
||||||
|
presence,
|
||||||
|
world: { mobs: { boss: { health: 500, maxHealth: 500 } }, encounter: { phase: "boss" } },
|
||||||
|
});
|
||||||
|
assert.equal(leader.authority, true);
|
||||||
|
assert.equal(leader.players[0].character.name, "Bulwark");
|
||||||
|
assert.equal(leader.world.mobs.boss.health, 500);
|
||||||
|
|
||||||
|
const healer = manager.sync({ id: "healer" }, snapshot, {
|
||||||
|
presence: { ...presence, position: [4, 5, 6] },
|
||||||
|
afterEventId: 0,
|
||||||
|
events: [{
|
||||||
|
clientEventId: "heal:1",
|
||||||
|
kind: "damage",
|
||||||
|
sourceActorId: "online-player:healer",
|
||||||
|
targetActorId: "boss",
|
||||||
|
school: "holy",
|
||||||
|
rawAmount: 75,
|
||||||
|
effectiveAmount: 60,
|
||||||
|
occurredAt: 123,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(healer.players.length, 2);
|
||||||
|
assert.equal(healer.events.length, 1);
|
||||||
|
assert.equal(healer.events[0].id, 1);
|
||||||
|
assert.equal(healer.events[0].sourceAccountId, "healer");
|
||||||
|
|
||||||
|
const deduplicated = manager.sync({ id: "healer" }, snapshot, {
|
||||||
|
presence,
|
||||||
|
afterEventId: 1,
|
||||||
|
events: [{
|
||||||
|
clientEventId: "heal:1",
|
||||||
|
kind: "damage",
|
||||||
|
sourceActorId: "online-player:healer",
|
||||||
|
targetActorId: "boss",
|
||||||
|
rawAmount: 75,
|
||||||
|
effectiveAmount: 60,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(deduplicated.events.length, 0);
|
||||||
|
assert.equal(deduplicated.latestEventId, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects non-leader world updates and forged enemy events", () => {
|
||||||
|
const manager = createOnlineSessionManager();
|
||||||
|
const snapshot = groupSnapshot();
|
||||||
|
assert.throws(() => manager.sync({ id: "healer" }, snapshot, {
|
||||||
|
presence,
|
||||||
|
world: { mobs: {} },
|
||||||
|
}), (error) => error instanceof GameDatabaseError && error.code === "activity_authority_required");
|
||||||
|
|
||||||
|
const state = manager.sync({ id: "healer" }, snapshot, {
|
||||||
|
presence,
|
||||||
|
events: [{
|
||||||
|
clientEventId: "forged",
|
||||||
|
kind: "damage",
|
||||||
|
sourceActorId: "boss",
|
||||||
|
targetActorId: "online-player:leader",
|
||||||
|
rawAmount: 1_000_000,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(state.events.length, 0);
|
||||||
|
});
|
||||||
+79
-1
@@ -3,6 +3,7 @@ import { createServer } from "node:http";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
||||||
|
import { createOnlineSessionManager } from "./online-session.mjs";
|
||||||
|
|
||||||
const MIME_TYPES = new Map([
|
const MIME_TYPES = new Map([
|
||||||
[".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"],
|
[".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"],
|
||||||
@@ -148,6 +149,7 @@ export function createGameServer(options = {}) {
|
|||||||
const bodyLimit = Math.max(1024, Number(options.bodyLimit ?? process.env.MAX_JSON_BODY_BYTES ?? 5 * 1024 * 1024));
|
const bodyLimit = Math.max(1024, Number(options.bodyLimit ?? process.env.MAX_JSON_BODY_BYTES ?? 5 * 1024 * 1024));
|
||||||
const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
|
const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
|
||||||
const gameDatabase = options.database ?? openGameDatabase(options);
|
const gameDatabase = options.database ?? openGameDatabase(options);
|
||||||
|
const onlineSessions = createOnlineSessionManager();
|
||||||
const allowAuthRequest = createAuthLimiter();
|
const allowAuthRequest = createAuthLimiter();
|
||||||
gameDatabase.pruneExpiredSessions();
|
gameDatabase.pruneExpiredSessions();
|
||||||
|
|
||||||
@@ -209,6 +211,79 @@ export function createGameServer(options = {}) {
|
|||||||
json(response, 200, gameDatabase.putCloudSave(account.id, body.data));
|
json(response, 200, gameDatabase.putCloudSave(account.id, body.data));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (url.pathname === "/api/online-group" && request.method === "GET") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
json(response, 200, gameDatabase.onlineGroupState(account.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/invite" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, 64 * 1024);
|
||||||
|
json(response, 201, gameDatabase.inviteOnlineGroupMember(account, body.username));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/invite/respond" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, 64 * 1024);
|
||||||
|
json(response, 200, gameDatabase.respondToOnlineGroupInvite(account.id, body.inviteId, body.accept === true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/leave" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
json(response, 200, gameDatabase.leaveOnlineGroup(account.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/remove" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, 64 * 1024);
|
||||||
|
json(response, 200, gameDatabase.removeOnlineGroupMember(account.id, body.accountId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/member" && request.method === "PUT") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, 64 * 1024);
|
||||||
|
json(response, 200, gameDatabase.updateOnlineGroupMember(account.id, body.character, body.role));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/activity" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, 64 * 1024);
|
||||||
|
json(response, 200, gameDatabase.startOnlineGroupActivity(account.id, body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/activity/clear" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
json(response, 200, gameDatabase.clearOnlineGroupActivity(account.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-session/sync" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, bodyLimit);
|
||||||
|
const groupSnapshot = gameDatabase.onlineGroupState(account.id);
|
||||||
|
json(response, 200, onlineSessions.sync(account, groupSnapshot, body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/manastorm-config" && request.method === "GET") {
|
||||||
|
const current = gameDatabase.getManastormConfig();
|
||||||
|
json(response, 200, {
|
||||||
|
revision: current.revision,
|
||||||
|
updatedAt: current.updatedAt,
|
||||||
|
config: current.config,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/gm/manastorm-config" && request.method === "PUT") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
if (!gameDatabase.isGameMaster(account)) {
|
||||||
|
console.warn(`Denied Manastorm GM write for ${account.username} (${account.id})`);
|
||||||
|
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
||||||
|
}
|
||||||
|
const body = await readJson(request, bodyLimit);
|
||||||
|
const result = gameDatabase.putManastormConfig(account, body.expectedRevision, body.config);
|
||||||
|
console.info(`Manastorm configuration revision ${result.revision} published by ${account.username}`);
|
||||||
|
json(response, 200, result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (url.pathname.startsWith("/api/")) {
|
if (url.pathname.startsWith("/api/")) {
|
||||||
json(response, 404, { error: "API route not found.", code: "not_found" });
|
json(response, 404, { error: "API route not found.", code: "not_found" });
|
||||||
return;
|
return;
|
||||||
@@ -238,7 +313,10 @@ export function createGameServer(options = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on("close", () => gameDatabase.close());
|
server.on("close", () => {
|
||||||
|
onlineSessions.clear();
|
||||||
|
gameDatabase.close();
|
||||||
|
});
|
||||||
return { server, database: gameDatabase, staticDir, contentDir };
|
return { server, database: gameDatabase, staticDir, contentDir };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,115 @@ test("serves the game and authenticated cloud-save API", async () => {
|
|||||||
});
|
});
|
||||||
assert.equal(saved.status, 200);
|
assert.equal(saved.status, 200);
|
||||||
assert.equal((await saved.json()).revision, 1);
|
assert.equal((await saved.json()).revision, 1);
|
||||||
|
|
||||||
|
const publicConfig = await fetch(`${origin}/api/manastorm-config`);
|
||||||
|
assert.equal(publicConfig.status, 200);
|
||||||
|
assert.equal((await publicConfig.json()).revision, 0);
|
||||||
|
|
||||||
|
const denied = await fetch(`${origin}/api/gm/manastorm-config`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||||
|
body: JSON.stringify({ expectedRevision: 0, config: { schemaVersion: 1 } }),
|
||||||
|
});
|
||||||
|
assert.equal(denied.status, 403);
|
||||||
|
|
||||||
|
const memberRegistration = await fetch(`${origin}/api/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: "WebTank", password: "tanking-pass" }),
|
||||||
|
});
|
||||||
|
const memberAuth = await memberRegistration.json();
|
||||||
|
const invited = await fetch(`${origin}/api/online-group/invite`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||||
|
body: JSON.stringify({ username: "WebTank" }),
|
||||||
|
});
|
||||||
|
assert.equal(invited.status, 201);
|
||||||
|
const memberGroupState = await (await fetch(`${origin}/api/online-group`, {
|
||||||
|
headers: { Authorization: `Bearer ${memberAuth.token}` },
|
||||||
|
})).json();
|
||||||
|
assert.equal(memberGroupState.invitations.length, 1);
|
||||||
|
const accepted = await fetch(`${origin}/api/online-group/invite/respond`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${memberAuth.token}` },
|
||||||
|
body: JSON.stringify({ inviteId: memberGroupState.invitations[0].id, accept: true }),
|
||||||
|
});
|
||||||
|
assert.equal(accepted.status, 200);
|
||||||
|
assert.equal((await accepted.json()).group.members.length, 2);
|
||||||
|
|
||||||
|
for (const [token, character, role] of [
|
||||||
|
[auth.token, { id: "web-healer", name: "Mendara", classId: "priest", raceId: "human", gender: "female", level: 20 }, "healer"],
|
||||||
|
[memberAuth.token, { id: "web-tank", name: "Bulwarka", classId: "warrior", raceId: "human", gender: "male", level: 20 }, "tank"],
|
||||||
|
]) {
|
||||||
|
const ready = await fetch(`${origin}/api/online-group/member`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ character, role }),
|
||||||
|
});
|
||||||
|
assert.equal(ready.status, 200);
|
||||||
|
}
|
||||||
|
const activityResponse = await fetch(`${origin}/api/online-group/activity`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: "dungeon",
|
||||||
|
contentId: "wailing-caverns",
|
||||||
|
fillWithAi: false,
|
||||||
|
partySize: 2,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(activityResponse.status, 200);
|
||||||
|
const leaderSync = await fetch(`${origin}/api/online-session/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
afterEventId: 0,
|
||||||
|
presence: { position: [1, 0, 2], health: 900, maxHealth: 1_000 },
|
||||||
|
world: { mobs: { boss: { health: 500 } }, mobTransforms: {} },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(leaderSync.status, 200);
|
||||||
|
assert.equal((await leaderSync.json()).authority, true);
|
||||||
|
const memberSync = await fetch(`${origin}/api/online-session/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${memberAuth.token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
afterEventId: 0,
|
||||||
|
presence: { position: [3, 0, 4], health: 800, maxHealth: 1_000 },
|
||||||
|
events: [{
|
||||||
|
clientEventId: "web-tank-hit-1",
|
||||||
|
kind: "damage",
|
||||||
|
sourceActorId: `online-player:${memberAuth.account.id}`,
|
||||||
|
targetActorId: "boss",
|
||||||
|
rawAmount: 50,
|
||||||
|
effectiveAmount: 45,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(memberSync.status, 200);
|
||||||
|
const memberSession = await memberSync.json();
|
||||||
|
assert.equal(memberSession.players.length, 2);
|
||||||
|
assert.equal(memberSession.events[0].targetActorId, "boss");
|
||||||
|
|
||||||
|
const gmRegistration = await fetch(`${origin}/api/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: "Phenom", password: "storm-admin" }),
|
||||||
|
});
|
||||||
|
assert.equal(gmRegistration.status, 201);
|
||||||
|
const gmAuth = await gmRegistration.json();
|
||||||
|
assert.deepEqual(gmAuth.account.roles, ["gm"]);
|
||||||
|
const current = await (await fetch(`${origin}/api/manastorm-config`)).json();
|
||||||
|
const published = await fetch(`${origin}/api/gm/manastorm-config`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
expectedRevision: current.revision,
|
||||||
|
config: { ...current.config, global: { ...current.config.global, chaoticLinkEnabled: false } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(published.status, 200);
|
||||||
|
assert.equal((await published.json()).config.global.chaoticLinkEnabled, false);
|
||||||
} finally {
|
} finally {
|
||||||
await new Promise((resolve) => runtime.server.close(resolve));
|
await new Promise((resolve) => runtime.server.close(resolve));
|
||||||
rmSync(directory, { recursive: true, force: true });
|
rmSync(directory, { recursive: true, force: true });
|
||||||
|
|||||||
+21
-14
@@ -1,7 +1,9 @@
|
|||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense, useEffect } from "react";
|
||||||
import { useShellStore } from "./app/shellStore";
|
import { useShellStore } from "./app/shellStore";
|
||||||
|
import { useManastormAdminStore } from "./game/manastormAdminStore";
|
||||||
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
||||||
import { LoginScreen } from "./ui/LoginScreen";
|
import { LoginScreen } from "./ui/LoginScreen";
|
||||||
|
import { OnlineGroupCoordinator } from "./app/OnlineGroupCoordinator";
|
||||||
|
|
||||||
const GameRuntime = lazy(() => import("./GameRuntime"));
|
const GameRuntime = lazy(() => import("./GameRuntime"));
|
||||||
const CharacterCreateScreen = lazy(() => import("./ui/CharacterCreateScreen").then((module) => ({ default: module.CharacterCreateScreen })));
|
const CharacterCreateScreen = lazy(() => import("./ui/CharacterCreateScreen").then((module) => ({ default: module.CharacterCreateScreen })));
|
||||||
@@ -10,6 +12,7 @@ const ClassHallScreen = lazy(() => import("./ui/ClassHallScreen").then((module)
|
|||||||
const DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen })));
|
const DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen })));
|
||||||
const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
|
const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
|
||||||
const ManastormSelectScreen = lazy(() => import("./ui/ManastormSelectScreen").then((module) => ({ default: module.ManastormSelectScreen })));
|
const ManastormSelectScreen = lazy(() => import("./ui/ManastormSelectScreen").then((module) => ({ default: module.ManastormSelectScreen })));
|
||||||
|
const GmAdminScreen = lazy(() => import("./ui/GmAdminScreen").then((module) => ({ default: module.GmAdminScreen })));
|
||||||
|
|
||||||
function ShellTransition() {
|
function ShellTransition() {
|
||||||
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
|
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
|
||||||
@@ -19,18 +22,22 @@ export default function App() {
|
|||||||
useForcedThorDisplays();
|
useForcedThorDisplays();
|
||||||
useAuthoritativeDualScreenSync();
|
useAuthoritativeDualScreenSync();
|
||||||
const phase = useShellStore((state) => state.phase);
|
const phase = useShellStore((state) => state.phase);
|
||||||
|
const sessionOwnerId = useShellStore((state) => state.session?.ownerId ?? null);
|
||||||
|
const refreshManastormConfig = useManastormAdminStore((state) => state.refresh);
|
||||||
|
|
||||||
if (phase === "login") return <LoginScreen />;
|
useEffect(() => {
|
||||||
if (phase === "characters") return <Suspense fallback={<ShellTransition />}><CharacterSelectScreen /></Suspense>;
|
void refreshManastormConfig();
|
||||||
if (phase === "create-character") return <Suspense fallback={<ShellTransition />}><CharacterCreateScreen /></Suspense>;
|
}, [refreshManastormConfig, sessionOwnerId]);
|
||||||
if (phase === "main-menu") return <Suspense fallback={<ShellTransition />}><MainMenuScreen /></Suspense>;
|
|
||||||
if (phase === "class-hall") return <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
|
let screen = null;
|
||||||
if (phase === "dungeons") return <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
|
if (phase === "login") screen = <LoginScreen />;
|
||||||
if (phase === "manastorms") return <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
|
else if (phase === "characters") screen = <Suspense fallback={<ShellTransition />}><CharacterSelectScreen /></Suspense>;
|
||||||
if (phase !== "game") return null;
|
else if (phase === "create-character") screen = <Suspense fallback={<ShellTransition />}><CharacterCreateScreen /></Suspense>;
|
||||||
return (
|
else if (phase === "main-menu") screen = <Suspense fallback={<ShellTransition />}><MainMenuScreen /></Suspense>;
|
||||||
<Suspense fallback={<ShellTransition />}>
|
else if (phase === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
|
||||||
<GameRuntime />
|
else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
|
||||||
</Suspense>
|
else if (phase === "manastorms") screen = <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
|
||||||
);
|
else if (phase === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><GmAdminScreen /></Suspense>;
|
||||||
|
else if (phase === "game") screen = <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>;
|
||||||
|
return <><OnlineGroupCoordinator />{screen}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { GameplayPanels } from "./ui/GameplayPanels";
|
|||||||
import { MapPanel } from "./ui/MapPanel";
|
import { MapPanel } from "./ui/MapPanel";
|
||||||
import { PauseMenu } from "./ui/PauseMenu";
|
import { PauseMenu } from "./ui/PauseMenu";
|
||||||
import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
|
import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
|
||||||
|
import { GmWorldEditor } from "./ui/GmWorldEditor";
|
||||||
|
import { OnlineSessionBridge } from "./game/OnlineSessionBridge";
|
||||||
|
|
||||||
function InputBridge() {
|
function InputBridge() {
|
||||||
const overlay = useGameStore((state) => state.overlay);
|
const overlay = useGameStore((state) => state.overlay);
|
||||||
@@ -50,6 +52,9 @@ function InputBridge() {
|
|||||||
useCombatStore.getState().clearTarget();
|
useCombatStore.getState().clearTarget();
|
||||||
usePartyStore.getState().clearSelection();
|
usePartyStore.getState().clearSelection();
|
||||||
},
|
},
|
||||||
|
groundTargetActive: () => useCombatStore.getState().pendingGroundTarget !== null,
|
||||||
|
confirmGroundTarget: () => { useCombatStore.getState().confirmGroundTarget(); },
|
||||||
|
cancelGroundTarget: () => useCombatStore.getState().cancelGroundTarget(),
|
||||||
gameplayActionsBlocked: () => {
|
gameplayActionsBlocked: () => {
|
||||||
const game = useGameStore.getState();
|
const game = useGameStore.getState();
|
||||||
return game.overlay !== null || game.companionOpen;
|
return game.overlay !== null || game.companionOpen;
|
||||||
@@ -80,6 +85,7 @@ export default function GameRuntime() {
|
|||||||
top={
|
top={
|
||||||
<main className="game-shell">
|
<main className="game-shell">
|
||||||
<InputBridge />
|
<InputBridge />
|
||||||
|
<OnlineSessionBridge />
|
||||||
<CombatBridge />
|
<CombatBridge />
|
||||||
<PartyBridge />
|
<PartyBridge />
|
||||||
<ManastormBridge />
|
<ManastormBridge />
|
||||||
@@ -88,6 +94,7 @@ export default function GameRuntime() {
|
|||||||
<MapPanel />
|
<MapPanel />
|
||||||
<GameplayPanels />
|
<GameplayPanels />
|
||||||
<PauseMenu />
|
<PauseMenu />
|
||||||
|
<GmWorldEditor />
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
bottom={<LocalCompanionPanel />}
|
bottom={<LocalCompanionPanel />}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { selectedCharacter, useShellStore } from "./shellStore";
|
||||||
|
import { useOnlineGroupStore } from "./onlineGroupStore";
|
||||||
|
import {
|
||||||
|
classCanFillPartyRole,
|
||||||
|
defaultPartyRoleForClass,
|
||||||
|
type PartyRole,
|
||||||
|
} from "../game/partyRoles";
|
||||||
|
import { isManastormPartySize } from "../game/manastormProgress";
|
||||||
|
|
||||||
|
const GROUP_POLL_MS = 2_500;
|
||||||
|
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
export function OnlineGroupCoordinator() {
|
||||||
|
const session = useShellStore((state) => state.session);
|
||||||
|
const character = useShellStore(selectedCharacter);
|
||||||
|
const phase = useShellStore((state) => state.phase);
|
||||||
|
const snapshot = useOnlineGroupStore((state) => state.snapshot);
|
||||||
|
const consumedActivityIds = useOnlineGroupStore((state) => state.consumedActivityIds);
|
||||||
|
const refresh = useOnlineGroupStore((state) => state.refresh);
|
||||||
|
const updateMember = useOnlineGroupStore((state) => state.updateMember);
|
||||||
|
const consumeActivity = useOnlineGroupStore((state) => state.consumeActivity);
|
||||||
|
const reset = useOnlineGroupStore((state) => state.reset);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session || session.kind !== "account" || !session.accessToken) {
|
||||||
|
reset();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
void refresh(session);
|
||||||
|
const timer = window.setInterval(() => void refresh(session, true), GROUP_POLL_MS);
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.visibilityState === "visible") void refresh(session, true);
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
};
|
||||||
|
}, [refresh, reset, session?.accessToken, session?.ownerId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const group = snapshot.group;
|
||||||
|
if (!session || !character || !group || group.activity) return;
|
||||||
|
const member = group.members.find((candidate) => candidate.accountId === session.ownerId);
|
||||||
|
if (!member) return;
|
||||||
|
const role: PartyRole = member.role && classCanFillPartyRole(character.classId, member.role)
|
||||||
|
? member.role
|
||||||
|
: defaultPartyRoleForClass(character.classId);
|
||||||
|
const characterChanged = member.character?.id !== character.id
|
||||||
|
|| member.character?.level !== character.level
|
||||||
|
|| member.character?.classId !== character.classId;
|
||||||
|
if (!characterChanged && member.role === role) return;
|
||||||
|
void updateMember(session, character, role);
|
||||||
|
}, [character?.classId, character?.id, character?.level, session?.ownerId, snapshot.group?.id, snapshot.group?.updatedAt, updateMember]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activity = snapshot.group?.activity;
|
||||||
|
if (!session || !activity || phase === "game" || consumedActivityIds.includes(activity.id)) return;
|
||||||
|
if (Date.now() - activity.startedAt > ACTIVITY_JOIN_WINDOW_MS) return;
|
||||||
|
const member = snapshot.group?.members.find((candidate) => candidate.accountId === session.ownerId);
|
||||||
|
if (!member?.character || !member.role) return;
|
||||||
|
consumeActivity(activity.id);
|
||||||
|
void (async () => {
|
||||||
|
if (activity.type === "dungeon") {
|
||||||
|
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
|
||||||
|
installDungeonShellRuntime();
|
||||||
|
useShellStore.getState().enterDungeon(activity.contentId, member.role ?? undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { installManastormShellRuntime } = await import("./manastormShellRuntime");
|
||||||
|
installManastormShellRuntime();
|
||||||
|
const partySize = isManastormPartySize(activity.partySize) ? activity.partySize : 5;
|
||||||
|
useShellStore.getState().enterManastorm(
|
||||||
|
member.role ?? undefined,
|
||||||
|
partySize,
|
||||||
|
activity.startingLevel ?? undefined,
|
||||||
|
);
|
||||||
|
})().catch((error) => {
|
||||||
|
useShellStore.getState().setNotice(error instanceof Error ? error.message : "The group activity could not be opened.");
|
||||||
|
});
|
||||||
|
}, [consumeActivity, consumedActivityIds, phase, session?.ownerId, snapshot.group?.activity?.id]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
listCharacters,
|
listCharacters,
|
||||||
loadCombatPresentationSettings,
|
loadCombatPresentationSettings,
|
||||||
loginLocalAccount,
|
loginLocalAccount,
|
||||||
|
restoreSession,
|
||||||
updateCharacterActionBindings,
|
updateCharacterActionBindings,
|
||||||
updateCharacterEquipment,
|
updateCharacterEquipment,
|
||||||
updateCharacterInventory,
|
updateCharacterInventory,
|
||||||
@@ -86,6 +87,17 @@ describe("local profile repository", () => {
|
|||||||
expect(listCharacters("offline-roster", storage)).toEqual([]);
|
expect(listCharacters("offline-roster", storage)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drops spoofed GM roles from offline sessions", () => {
|
||||||
|
const storage = new MemoryStorage();
|
||||||
|
storage.setItem(PROFILE_STORAGE_KEYS.session, JSON.stringify({
|
||||||
|
kind: "offline",
|
||||||
|
ownerId: "offline-roster",
|
||||||
|
displayName: "Offline",
|
||||||
|
roles: ["gm"],
|
||||||
|
}));
|
||||||
|
expect(restoreSession(storage)?.roles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("normalizes legacy profiles and persists progression atomically", () => {
|
it("normalizes legacy profiles and persists progression atomically", () => {
|
||||||
const storage = new MemoryStorage();
|
const storage = new MemoryStorage();
|
||||||
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
|
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
|
||||||
|
|||||||
@@ -374,7 +374,11 @@ export function restoreSession(storage: StorageLike | null = browserSessionStora
|
|||||||
if (session.kind === "account"
|
if (session.kind === "account"
|
||||||
&& typeof session.accessToken !== "string"
|
&& typeof session.accessToken !== "string"
|
||||||
&& !readDatabase().accounts.some((account) => account.id === session.ownerId)) return null;
|
&& !readDatabase().accounts.some((account) => account.id === session.ownerId)) return null;
|
||||||
return session as PlayerSession;
|
return {
|
||||||
|
...(session as PlayerSession),
|
||||||
|
// Session storage is client-controlled. /api/me re-derives online roles.
|
||||||
|
roles: [],
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { touchCharacter } from "./accountRepository";
|
||||||
|
import { useShellStore } from "./shellStore";
|
||||||
|
import { registerGeneratedManastormStageAssets } from "../game/manastormAssetRegistry";
|
||||||
|
import {
|
||||||
|
isGameMasterSession,
|
||||||
|
resolvedBossAdminCatalog,
|
||||||
|
} from "../game/manastormAdminConfig";
|
||||||
|
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||||
|
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "../game/manastormInstalledCatalog";
|
||||||
|
import { installedManastormStageBinding } from "../game/manastormInstalledStages";
|
||||||
|
import { registerManastormStageBindingResolver } from "../game/manastormStagePopulation";
|
||||||
|
import { activateManastormStage } from "../game/manastormStageLoader";
|
||||||
|
import { manastormModeForCharacterLevel, setManastormCatalog } from "../game/manastorm";
|
||||||
|
import { createEmptyManastormProgress, manastormModeProgress } from "../game/manastormProgress";
|
||||||
|
import { manastormActiveSpellLoadoutModel } from "../game/manastormSpellLoadout";
|
||||||
|
import { useManastormStore } from "../game/manastormStore";
|
||||||
|
import { defaultPartyRoleForClass } from "../game/partyRoles";
|
||||||
|
import { useGameStore } from "../game/store";
|
||||||
|
|
||||||
|
export function enterGmManastormTest(bossKey: string): boolean {
|
||||||
|
const shell = useShellStore.getState();
|
||||||
|
if (!isGameMasterSession(shell.session) || !shell.selectedCharacterId) {
|
||||||
|
useShellStore.setState({ notice: "Game Master access is required." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const activeCharacter = touchCharacter(shell.session!.ownerId, shell.selectedCharacterId)
|
||||||
|
?? shell.characters.find((character) => character.id === shell.selectedCharacterId)
|
||||||
|
?? null;
|
||||||
|
if (!activeCharacter) {
|
||||||
|
useShellStore.setState({ notice: "Select a character before testing a boss." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const adminConfig = useManastormAdminStore.getState().config;
|
||||||
|
const entry = resolvedBossAdminCatalog(adminConfig).find((candidate) => candidate.key === bossKey);
|
||||||
|
if (!entry || entry.status === "not-ready" || !entry.runnable || !entry.stage) {
|
||||||
|
useShellStore.setState({ notice: entry?.validationIssues[0] ?? "That boss is not available for GM testing." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerGeneratedManastormStageAssets();
|
||||||
|
registerManastormStageBindingResolver(installedManastormStageBinding);
|
||||||
|
const modeId = manastormModeForCharacterLevel(activeCharacter.level, INSTALLED_MANASTORM_RUNTIME_CATALOG);
|
||||||
|
const { milestoneLevel: _milestoneLevel, ...stageWithoutMilestone } = entry.stage;
|
||||||
|
const testStage = Object.freeze({
|
||||||
|
...stageWithoutMilestone,
|
||||||
|
modeIds: [modeId],
|
||||||
|
stageKind: "standard" as const,
|
||||||
|
unlockLevel: 1,
|
||||||
|
chaoticLinkEnabled: adminConfig.global.chaoticLinkEnabled,
|
||||||
|
});
|
||||||
|
const testCatalog = Object.freeze({
|
||||||
|
...INSTALLED_MANASTORM_RUNTIME_CATALOG,
|
||||||
|
stages: [testStage],
|
||||||
|
fallbackPolicy: "none" as const,
|
||||||
|
runtimeOptions: Object.freeze({ ...adminConfig.global }),
|
||||||
|
});
|
||||||
|
setManastormCatalog(testCatalog);
|
||||||
|
useManastormStore.getState().resetRun();
|
||||||
|
const progress = activeCharacter.manastormProgress ?? createEmptyManastormProgress();
|
||||||
|
const modeProgress = manastormModeProgress(progress, modeId);
|
||||||
|
const spellLoadout = manastormActiveSpellLoadoutModel(
|
||||||
|
activeCharacter.classId,
|
||||||
|
activeCharacter.level,
|
||||||
|
activeCharacter.talentRanks,
|
||||||
|
);
|
||||||
|
let encounter;
|
||||||
|
try {
|
||||||
|
encounter = useManastormStore.getState().startRun({
|
||||||
|
partySize: 5,
|
||||||
|
startingLevel: 1,
|
||||||
|
unlockedThroughLevel: 1,
|
||||||
|
modeId,
|
||||||
|
activeSpellLoadout: {
|
||||||
|
available: spellLoadout.options,
|
||||||
|
selectedIds: progress.loadout,
|
||||||
|
maxSelections: spellLoadout.maxSelections,
|
||||||
|
provenance: spellLoadout.provenance,
|
||||||
|
},
|
||||||
|
cacheMeter: modeProgress.cacheMeter,
|
||||||
|
rewardCaches: modeProgress.rewardCaches,
|
||||||
|
random: () => 0,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
useShellStore.setState({ notice: "The configured boss could not be prepared for testing." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!activateManastormStage(encounter, undefined, false, true).activated) {
|
||||||
|
useManastormStore.getState().resetRun();
|
||||||
|
useShellStore.setState({ notice: "The configured dungeon or stage package is not loadable." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
useGameStore.getState().setCompanionOpen(false);
|
||||||
|
useShellStore.setState({
|
||||||
|
phase: "game",
|
||||||
|
activeCharacter,
|
||||||
|
activeDungeonId: encounter.dungeonId,
|
||||||
|
activeDungeonRole: defaultPartyRoleForClass(activeCharacter.classId),
|
||||||
|
notice: `GM test: ${entry.name}`,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import { useGameStore } from "../game/store";
|
|||||||
import { useManastormStore } from "../game/manastormStore";
|
import { useManastormStore } from "../game/manastormStore";
|
||||||
import { manastormModeForCharacterLevel, setManastormCatalog } from "../game/manastorm";
|
import { manastormModeForCharacterLevel, setManastormCatalog } from "../game/manastorm";
|
||||||
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "../game/manastormInstalledCatalog";
|
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "../game/manastormInstalledCatalog";
|
||||||
|
import { effectiveManastormCatalog } from "../game/manastormAdminConfig";
|
||||||
|
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||||
import { registerGeneratedManastormStageAssets } from "../game/manastormAssetRegistry";
|
import { registerGeneratedManastormStageAssets } from "../game/manastormAssetRegistry";
|
||||||
import { installedManastormStageBinding } from "../game/manastormInstalledStages";
|
import { installedManastormStageBinding } from "../game/manastormInstalledStages";
|
||||||
import { registerManastormStageBindingResolver } from "../game/manastormStagePopulation";
|
import { registerManastormStageBindingResolver } from "../game/manastormStagePopulation";
|
||||||
@@ -71,11 +73,28 @@ function enterManastorm(
|
|||||||
|
|
||||||
const partySize = isManastormPartySize(requestedPartySize) ? requestedPartySize : 5;
|
const partySize = isManastormPartySize(requestedPartySize) ? requestedPartySize : 5;
|
||||||
registerGeneratedManastormStageAssets();
|
registerGeneratedManastormStageAssets();
|
||||||
setManastormCatalog(INSTALLED_MANASTORM_RUNTIME_CATALOG);
|
const adminConfig = useManastormAdminStore.getState().config;
|
||||||
|
if (!adminConfig.global.enabled) {
|
||||||
|
useShellStore.setState({ notice: "Manastorms are currently disabled by a Game Master." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const effectiveCatalog = effectiveManastormCatalog(adminConfig, INSTALLED_MANASTORM_RUNTIME_CATALOG);
|
||||||
const modeId = manastormModeForCharacterLevel(
|
const modeId = manastormModeForCharacterLevel(
|
||||||
activeCharacter.level,
|
activeCharacter.level,
|
||||||
INSTALLED_MANASTORM_RUNTIME_CATALOG,
|
effectiveCatalog,
|
||||||
);
|
);
|
||||||
|
if (
|
||||||
|
(modeId === 0 && !adminConfig.global.levelingEnabled)
|
||||||
|
|| (modeId === 2 && !adminConfig.global.endgameEnabled)
|
||||||
|
) {
|
||||||
|
useShellStore.setState({ notice: `${modeId === 2 ? "End-game" : "Leveling"} Manastorms are currently disabled.` });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!effectiveCatalog.stages?.length) {
|
||||||
|
useShellStore.setState({ notice: "No bosses are currently marked Ready for Manastorms." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setManastormCatalog(effectiveCatalog);
|
||||||
const storedProgress = activeCharacter.manastormProgress ?? createEmptyManastormProgress();
|
const storedProgress = activeCharacter.manastormProgress ?? createEmptyManastormProgress();
|
||||||
const progress = modeId === 2
|
const progress = modeId === 2
|
||||||
? applyManastormEndgameHandoff(storedProgress)
|
? applyManastormEndgameHandoff(storedProgress)
|
||||||
@@ -106,20 +125,26 @@ function enterManastorm(
|
|||||||
const startingLevel = requestedStartingLevel === 1
|
const startingLevel = requestedStartingLevel === 1
|
||||||
? 1
|
? 1
|
||||||
: availableCheckpoint;
|
: availableCheckpoint;
|
||||||
const encounter = useManastormStore.getState().startRun({
|
let encounter;
|
||||||
partySize,
|
try {
|
||||||
startingLevel,
|
encounter = useManastormStore.getState().startRun({
|
||||||
unlockedThroughLevel: Math.max(startingLevel, savedParty.highestLevel + 1),
|
partySize,
|
||||||
modeId,
|
startingLevel,
|
||||||
activeSpellLoadout: {
|
unlockedThroughLevel: Math.max(startingLevel, savedParty.highestLevel + 1),
|
||||||
available: spellLoadoutModel.options,
|
modeId,
|
||||||
selectedIds: progress.loadout,
|
activeSpellLoadout: {
|
||||||
maxSelections: spellLoadoutModel.maxSelections,
|
available: spellLoadoutModel.options,
|
||||||
provenance: spellLoadoutModel.provenance,
|
selectedIds: progress.loadout,
|
||||||
},
|
maxSelections: spellLoadoutModel.maxSelections,
|
||||||
cacheMeter: modeProgress.cacheMeter,
|
provenance: spellLoadoutModel.provenance,
|
||||||
rewardCaches: modeProgress.rewardCaches,
|
},
|
||||||
});
|
cacheMeter: modeProgress.cacheMeter,
|
||||||
|
rewardCaches: modeProgress.rewardCaches,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
useShellStore.setState({ notice: "No Ready boss is eligible for this Manastorm level and party size." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!activateManastormStage(encounter, undefined, false).activated) {
|
if (!activateManastormStage(encounter, undefined, false).activated) {
|
||||||
useManastormStore.getState().resetRun();
|
useManastormStore.getState().resetRun();
|
||||||
useShellStore.setState({ notice: "The first Manastorm could not be opened." });
|
useShellStore.setState({ notice: "The first Manastorm could not be opened." });
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import {
|
|||||||
type AccountResult,
|
type AccountResult,
|
||||||
} from "./accountRepository";
|
} from "./accountRepository";
|
||||||
import type { CharacterProfile, PlayerSession } from "./types";
|
import type { CharacterProfile, PlayerSession } from "./types";
|
||||||
|
import type { OnlineGroupSnapshot, StartOnlineGroupActivityInput } from "./onlineGroupTypes";
|
||||||
|
import type { OnlineSessionSnapshot, OnlineSessionSyncInput } from "./onlineSessionTypes";
|
||||||
|
|
||||||
const NATIVE_API_ORIGIN = "https://iwanttoheal.phenomrom.com";
|
const NATIVE_API_ORIGIN = "https://iwanttoheal.phenomrom.com";
|
||||||
const SAVE_DEBOUNCE_MS = 500;
|
const SAVE_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
interface AuthResponse {
|
interface AuthResponse {
|
||||||
account: { id: string; username: string };
|
account: { id: string; username: string; roles?: readonly "gm"[] };
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +23,10 @@ interface CloudSaveResponse {
|
|||||||
data: { version?: number; characters?: unknown };
|
data: { version?: number; characters?: unknown };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MeResponse {
|
||||||
|
account: { id: string; username: string; roles?: readonly "gm"[] };
|
||||||
|
}
|
||||||
|
|
||||||
class OnlineApiError extends Error {
|
class OnlineApiError extends Error {
|
||||||
constructor(message: string, readonly status: number) {
|
constructor(message: string, readonly status: number) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -40,7 +46,7 @@ function apiOrigin(): string {
|
|||||||
return Capacitor.isNativePlatform() ? NATIVE_API_ORIGIN : "";
|
return Capacitor.isNativePlatform() ? NATIVE_API_ORIGIN : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestJson<T>(pathname: string, init: RequestInit = {}, token?: string): Promise<T> {
|
export async function requestOnlineJson<T>(pathname: string, init: RequestInit = {}, token?: string): Promise<T> {
|
||||||
const headers = new Headers(init.headers);
|
const headers = new Headers(init.headers);
|
||||||
headers.set("Accept", "application/json");
|
headers.set("Accept", "application/json");
|
||||||
if (init.body !== undefined) headers.set("Content-Type", "application/json");
|
if (init.body !== undefined) headers.set("Content-Type", "application/json");
|
||||||
@@ -63,6 +69,7 @@ function accountResult(response: AuthResponse): AccountResult {
|
|||||||
kind: "account",
|
kind: "account",
|
||||||
ownerId: response.account.id,
|
ownerId: response.account.id,
|
||||||
displayName: response.account.username,
|
displayName: response.account.username,
|
||||||
|
roles: response.account.roles ?? [],
|
||||||
accessToken: response.token,
|
accessToken: response.token,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -70,7 +77,7 @@ function accountResult(response: AuthResponse): AccountResult {
|
|||||||
|
|
||||||
async function authenticate(pathname: string, username: string, password: string): Promise<AccountResult> {
|
async function authenticate(pathname: string, username: string, password: string): Promise<AccountResult> {
|
||||||
try {
|
try {
|
||||||
const response = await requestJson<AuthResponse>(pathname, {
|
const response = await requestOnlineJson<AuthResponse>(pathname, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
@@ -94,7 +101,7 @@ function flushCloudSave(): void {
|
|||||||
pendingCharacters = null;
|
pendingCharacters = null;
|
||||||
if (!session?.accessToken || !characters) return;
|
if (!session?.accessToken || !characters) return;
|
||||||
saveQueue = saveQueue.then(async () => {
|
saveQueue = saveQueue.then(async () => {
|
||||||
await requestJson<CloudSaveResponse>("/api/cloud-save", {
|
await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ data: { version: 1, characters } }),
|
body: JSON.stringify({ data: { version: 1, characters } }),
|
||||||
}, session.accessToken);
|
}, session.accessToken);
|
||||||
@@ -122,7 +129,7 @@ export function activateOnlineSession(session: PlayerSession): void {
|
|||||||
export async function hydrateOnlineSession(session: PlayerSession): Promise<CharacterProfile[]> {
|
export async function hydrateOnlineSession(session: PlayerSession): Promise<CharacterProfile[]> {
|
||||||
if (!session.accessToken) return listCharacters(session.ownerId);
|
if (!session.accessToken) return listCharacters(session.ownerId);
|
||||||
activateOnlineSession(session);
|
activateOnlineSession(session);
|
||||||
const cloud = await requestJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
|
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
|
||||||
suppressUpload = true;
|
suppressUpload = true;
|
||||||
try {
|
try {
|
||||||
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
|
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
|
||||||
@@ -131,6 +138,17 @@ export async function hydrateOnlineSession(session: PlayerSession): Promise<Char
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshOnlineSession(session: PlayerSession): Promise<PlayerSession> {
|
||||||
|
if (!session.accessToken) return { ...session, roles: [] };
|
||||||
|
const response = await requestOnlineJson<MeResponse>("/api/me", {}, session.accessToken);
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
ownerId: response.account.id,
|
||||||
|
displayName: response.account.username,
|
||||||
|
roles: response.account.roles ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function logoutOnlineSession(session: PlayerSession | null): Promise<void> {
|
export async function logoutOnlineSession(session: PlayerSession | null): Promise<void> {
|
||||||
if (saveTimer) {
|
if (saveTimer) {
|
||||||
clearTimeout(saveTimer);
|
clearTimeout(saveTimer);
|
||||||
@@ -139,7 +157,81 @@ export async function logoutOnlineSession(session: PlayerSession | null): Promis
|
|||||||
}
|
}
|
||||||
if (session?.accessToken) {
|
if (session?.accessToken) {
|
||||||
await saveQueue;
|
await saveQueue;
|
||||||
await requestJson("/api/auth/logout", { method: "POST" }, session.accessToken).catch(() => undefined);
|
await requestOnlineJson("/api/auth/logout", { method: "POST" }, session.accessToken).catch(() => undefined);
|
||||||
}
|
}
|
||||||
if (activeSession?.ownerId === session?.ownerId) activeSession = null;
|
if (activeSession?.ownerId === session?.ownerId) activeSession = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onlineGroupRequest<T>(session: PlayerSession, pathname: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
if (!session.accessToken) throw new OnlineApiError("Online grouping requires a signed-in account.", 401);
|
||||||
|
return requestOnlineJson<T>(pathname, init, session.accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchOnlineGroup(session: PlayerSession): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inviteOnlineGroupPlayer(session: PlayerSession, username: string): Promise<OnlineGroupSnapshot> {
|
||||||
|
const response = await onlineGroupRequest<{ state: OnlineGroupSnapshot }>(session, "/api/online-group/invite", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
});
|
||||||
|
return response.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function respondToOnlineGroupInvite(
|
||||||
|
session: PlayerSession,
|
||||||
|
inviteId: string,
|
||||||
|
accept: boolean,
|
||||||
|
): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group/invite/respond", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ inviteId, accept }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function leaveOnlineGroup(session: PlayerSession): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group/leave", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeOnlineGroupPlayer(session: PlayerSession, accountId: string): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group/remove", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ accountId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateOnlineGroupMember(
|
||||||
|
session: PlayerSession,
|
||||||
|
character: CharacterProfile,
|
||||||
|
role: string,
|
||||||
|
): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group/member", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ character, role }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOnlineGroupActivity(
|
||||||
|
session: PlayerSession,
|
||||||
|
activity: StartOnlineGroupActivityInput,
|
||||||
|
): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group/activity", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(activity),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearOnlineGroupActivity(session: PlayerSession): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group/activity/clear", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function synchronizeOnlineSession(
|
||||||
|
session: PlayerSession,
|
||||||
|
input: OnlineSessionSyncInput,
|
||||||
|
): Promise<OnlineSessionSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-session/sync", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { onlineGroupAiPartySize, useOnlineGroupStore } from "./onlineGroupStore";
|
||||||
|
|
||||||
|
describe("online group AI fill sizing", () => {
|
||||||
|
beforeEach(() => useOnlineGroupStore.getState().reset());
|
||||||
|
|
||||||
|
it("reserves one simulated slot for every grouped human other than the local player", () => {
|
||||||
|
useOnlineGroupStore.setState({
|
||||||
|
snapshot: {
|
||||||
|
invitations: [],
|
||||||
|
group: {
|
||||||
|
id: "group-test",
|
||||||
|
leaderAccountId: "one",
|
||||||
|
activity: null,
|
||||||
|
activityRevision: 0,
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
members: [
|
||||||
|
{ accountId: "one", username: "One", character: null, role: null, joinedAt: 1 },
|
||||||
|
{ accountId: "two", username: "Two", character: null, role: null, joinedAt: 2 },
|
||||||
|
{ accountId: "three", username: "Three", character: null, role: null, joinedAt: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onlineGroupAiPartySize(5)).toBe(3);
|
||||||
|
expect(onlineGroupAiPartySize(3)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps ordinary solo activity sizes unchanged", () => {
|
||||||
|
expect(onlineGroupAiPartySize(5)).toBe(5);
|
||||||
|
expect(onlineGroupAiPartySize(1)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { CharacterProfile, PlayerSession } from "./types";
|
||||||
|
import type { PartyRole } from "../game/partyRoles";
|
||||||
|
import type { PartySize } from "../game/partyRoles";
|
||||||
|
import {
|
||||||
|
clearOnlineGroupActivity,
|
||||||
|
fetchOnlineGroup,
|
||||||
|
inviteOnlineGroupPlayer,
|
||||||
|
leaveOnlineGroup,
|
||||||
|
removeOnlineGroupPlayer,
|
||||||
|
respondToOnlineGroupInvite,
|
||||||
|
startOnlineGroupActivity,
|
||||||
|
updateOnlineGroupMember,
|
||||||
|
} from "./onlineAccountClient";
|
||||||
|
import type { OnlineGroupActivity, OnlineGroupSnapshot, StartOnlineGroupActivityInput } from "./onlineGroupTypes";
|
||||||
|
|
||||||
|
const EMPTY_SNAPSHOT: OnlineGroupSnapshot = Object.freeze({ group: null, invitations: Object.freeze([]) });
|
||||||
|
|
||||||
|
interface OnlineGroupState {
|
||||||
|
snapshot: OnlineGroupSnapshot;
|
||||||
|
loading: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
error: string;
|
||||||
|
fillWithAi: boolean;
|
||||||
|
activeActivity: OnlineGroupActivity | null;
|
||||||
|
consumedActivityIds: readonly string[];
|
||||||
|
refresh: (session: PlayerSession, silent?: boolean) => Promise<void>;
|
||||||
|
invite: (session: PlayerSession, username: string) => Promise<boolean>;
|
||||||
|
respond: (session: PlayerSession, inviteId: string, accept: boolean) => Promise<boolean>;
|
||||||
|
leave: (session: PlayerSession) => Promise<boolean>;
|
||||||
|
remove: (session: PlayerSession, accountId: string) => Promise<boolean>;
|
||||||
|
updateMember: (session: PlayerSession, character: CharacterProfile, role: PartyRole) => Promise<boolean>;
|
||||||
|
startActivity: (session: PlayerSession, input: Omit<StartOnlineGroupActivityInput, "fillWithAi">) => Promise<boolean>;
|
||||||
|
clearActivity: (session: PlayerSession) => Promise<boolean>;
|
||||||
|
setFillWithAi: (fillWithAi: boolean) => void;
|
||||||
|
consumeActivity: (activityId: string) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageFor(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : "The online group could not be updated.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOnlineGroupStore = create<OnlineGroupState>((set, get) => ({
|
||||||
|
snapshot: EMPTY_SNAPSHOT,
|
||||||
|
loading: false,
|
||||||
|
busy: false,
|
||||||
|
error: "",
|
||||||
|
fillWithAi: true,
|
||||||
|
activeActivity: null,
|
||||||
|
consumedActivityIds: [],
|
||||||
|
|
||||||
|
refresh: async (session, silent = false) => {
|
||||||
|
if (session.kind !== "account" || !session.accessToken) {
|
||||||
|
set({ snapshot: EMPTY_SNAPSHOT, loading: false, busy: false, error: "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!silent) set({ loading: true });
|
||||||
|
try {
|
||||||
|
const snapshot = await fetchOnlineGroup(session);
|
||||||
|
set({ snapshot, loading: false, error: "" });
|
||||||
|
} catch (error) {
|
||||||
|
set({ loading: false, error: messageFor(error) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
invite: async (session, username) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await inviteOnlineGroupPlayer(session, username);
|
||||||
|
set({ snapshot, busy: false, activeActivity: null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
respond: async (session, inviteId, accept) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await respondToOnlineGroupInvite(session, inviteId, accept);
|
||||||
|
set({ snapshot, busy: false, activeActivity: null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
leave: async (session) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await leaveOnlineGroup(session);
|
||||||
|
set({ snapshot, busy: false, activeActivity: null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: async (session, accountId) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await removeOnlineGroupPlayer(session, accountId);
|
||||||
|
set({ snapshot, busy: false, activeActivity: null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMember: async (session, character, role) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await updateOnlineGroupMember(session, character, role);
|
||||||
|
set({ snapshot, busy: false, activeActivity: null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startActivity: async (session, input) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await startOnlineGroupActivity(session, { ...input, fillWithAi: get().fillWithAi });
|
||||||
|
set({ snapshot, busy: false });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearActivity: async (session) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await clearOnlineGroupActivity(session);
|
||||||
|
set({ snapshot, busy: false, activeActivity: null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setFillWithAi: (fillWithAi) => set({ fillWithAi }),
|
||||||
|
consumeActivity: (activityId) => set((state) => {
|
||||||
|
if (state.consumedActivityIds.includes(activityId)) return state;
|
||||||
|
return {
|
||||||
|
activeActivity: state.snapshot.group?.activity?.id === activityId
|
||||||
|
? state.snapshot.group.activity
|
||||||
|
: state.activeActivity,
|
||||||
|
consumedActivityIds: [...state.consumedActivityIds.slice(-7), activityId],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
reset: () => set({
|
||||||
|
snapshot: EMPTY_SNAPSHOT,
|
||||||
|
loading: false,
|
||||||
|
busy: false,
|
||||||
|
error: "",
|
||||||
|
activeActivity: null,
|
||||||
|
consumedActivityIds: [],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function onlineGroupHumanCount(): number {
|
||||||
|
return useOnlineGroupStore.getState().snapshot.group?.members.length ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlineGroupAiPartySize(activityPartySize: number): PartySize {
|
||||||
|
return Math.max(1, Math.min(5, activityPartySize - Math.max(0, onlineGroupHumanCount() - 1))) as PartySize;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { PartyRole } from "../game/partyRoles";
|
||||||
|
import type { CharacterAppearance } from "./characterCatalog";
|
||||||
|
|
||||||
|
export interface OnlineGroupCharacter {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly classId: string;
|
||||||
|
readonly categoryId: string;
|
||||||
|
readonly raceId: string;
|
||||||
|
readonly gender: "female" | "male";
|
||||||
|
readonly level: number;
|
||||||
|
readonly appearance?: CharacterAppearance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineGroupMember {
|
||||||
|
readonly accountId: string;
|
||||||
|
readonly username: string;
|
||||||
|
readonly character: OnlineGroupCharacter | null;
|
||||||
|
readonly role: PartyRole | null;
|
||||||
|
readonly joinedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineGroupActivity {
|
||||||
|
readonly id: string;
|
||||||
|
readonly type: "dungeon" | "manastorm";
|
||||||
|
readonly contentId: string;
|
||||||
|
readonly fillWithAi: boolean;
|
||||||
|
readonly partySize: number;
|
||||||
|
readonly startingLevel: number | null;
|
||||||
|
readonly startedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineGroup {
|
||||||
|
readonly id: string;
|
||||||
|
readonly leaderAccountId: string;
|
||||||
|
readonly members: readonly OnlineGroupMember[];
|
||||||
|
readonly activity: OnlineGroupActivity | null;
|
||||||
|
readonly activityRevision: number;
|
||||||
|
readonly createdAt: number;
|
||||||
|
readonly updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineGroupInvitation {
|
||||||
|
readonly id: string;
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly inviterAccountId: string;
|
||||||
|
readonly inviterUsername: string;
|
||||||
|
readonly createdAt: number;
|
||||||
|
readonly expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineGroupSnapshot {
|
||||||
|
readonly group: OnlineGroup | null;
|
||||||
|
readonly invitations: readonly OnlineGroupInvitation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartOnlineGroupActivityInput {
|
||||||
|
readonly type: OnlineGroupActivity["type"];
|
||||||
|
readonly contentId: string;
|
||||||
|
readonly fillWithAi: boolean;
|
||||||
|
readonly partySize: number;
|
||||||
|
readonly startingLevel?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { OnlineSessionSnapshot } from "./onlineSessionTypes";
|
||||||
|
|
||||||
|
interface OnlineSessionState {
|
||||||
|
snapshot: OnlineSessionSnapshot | null;
|
||||||
|
connected: boolean;
|
||||||
|
synchronizing: boolean;
|
||||||
|
error: string;
|
||||||
|
setSynchronizing: (synchronizing: boolean) => void;
|
||||||
|
accept: (snapshot: OnlineSessionSnapshot) => void;
|
||||||
|
fail: (message: string) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOnlineSessionStore = create<OnlineSessionState>((set) => ({
|
||||||
|
snapshot: null,
|
||||||
|
connected: false,
|
||||||
|
synchronizing: false,
|
||||||
|
error: "",
|
||||||
|
setSynchronizing: (synchronizing) => set({ synchronizing }),
|
||||||
|
accept: (snapshot) => set({ snapshot, connected: true, synchronizing: false, error: "" }),
|
||||||
|
fail: (error) => set({ connected: false, synchronizing: false, error }),
|
||||||
|
reset: () => set({ snapshot: null, connected: false, synchronizing: false, error: "" }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
|
||||||
|
import type { ActiveCast, MobCombatState } from "../game/combatStore";
|
||||||
|
import type { DamageSchool } from "../game/combatAuras";
|
||||||
|
import type { InventoryItem } from "../game/lootTypes";
|
||||||
|
import type { PartyMember } from "../game/partyStore";
|
||||||
|
import type { OnlineGroupActivity, OnlineGroupCharacter } from "./onlineGroupTypes";
|
||||||
|
import type { PartyRole } from "../game/partyRoles";
|
||||||
|
|
||||||
|
export type OnlineSessionVector = readonly [number, number, number];
|
||||||
|
|
||||||
|
export interface OnlineSessionPlayer {
|
||||||
|
readonly accountId: string;
|
||||||
|
readonly username: string;
|
||||||
|
readonly character: OnlineGroupCharacter | null;
|
||||||
|
readonly role: PartyRole | null;
|
||||||
|
readonly position: OnlineSessionVector;
|
||||||
|
readonly yaw: number;
|
||||||
|
readonly grounded: boolean;
|
||||||
|
readonly health: number;
|
||||||
|
readonly maxHealth: number;
|
||||||
|
readonly resource: number;
|
||||||
|
readonly maxResource: number;
|
||||||
|
readonly resourceName: string;
|
||||||
|
readonly selectedTargetId: string | null;
|
||||||
|
readonly activeCast: ActiveCast | null;
|
||||||
|
readonly animationEvent: CharacterCombatAnimationEvent | null;
|
||||||
|
readonly equipment: readonly InventoryItem[];
|
||||||
|
readonly updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineMobTransform {
|
||||||
|
readonly position: OnlineSessionVector;
|
||||||
|
readonly yaw: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineSharedWorld {
|
||||||
|
readonly mobs: Readonly<Record<string, MobCombatState>>;
|
||||||
|
readonly mobTransforms: Readonly<Record<string, OnlineMobTransform>>;
|
||||||
|
readonly partyMembers: readonly PartyMember[];
|
||||||
|
readonly partyPositions: Readonly<Record<string, OnlineSessionVector>>;
|
||||||
|
readonly partyRuntime: {
|
||||||
|
readonly command: string;
|
||||||
|
readonly activeMobId: string | null;
|
||||||
|
readonly objectiveBossId: string | null;
|
||||||
|
readonly memberTargetIds: Readonly<Record<string, string>>;
|
||||||
|
};
|
||||||
|
readonly wailing: Readonly<Record<string, unknown>> | null;
|
||||||
|
readonly manastorm: Readonly<Record<string, unknown>> | null;
|
||||||
|
readonly publishedAt?: number;
|
||||||
|
readonly publishedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OnlineRelayEventKind = "damage" | "healing" | "resurrection" | "loot" | "portal";
|
||||||
|
|
||||||
|
export interface OnlineRelayEventInput {
|
||||||
|
readonly clientEventId: string;
|
||||||
|
readonly kind: OnlineRelayEventKind;
|
||||||
|
readonly sourceActorId: string;
|
||||||
|
readonly targetActorId: string;
|
||||||
|
readonly abilityId?: string | null;
|
||||||
|
readonly school?: DamageSchool | null;
|
||||||
|
readonly rawAmount?: number;
|
||||||
|
readonly effectiveAmount?: number;
|
||||||
|
readonly critical?: boolean;
|
||||||
|
readonly occurredAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineRelayEvent extends OnlineRelayEventInput {
|
||||||
|
readonly id: number;
|
||||||
|
readonly sourceAccountId: string;
|
||||||
|
readonly createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineSessionSnapshot {
|
||||||
|
readonly activity: OnlineGroupActivity;
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly leaderAccountId: string;
|
||||||
|
readonly localAccountId: string;
|
||||||
|
readonly authority: boolean;
|
||||||
|
readonly revision: number;
|
||||||
|
readonly serverTime: number;
|
||||||
|
readonly players: readonly OnlineSessionPlayer[];
|
||||||
|
readonly world: OnlineSharedWorld | null;
|
||||||
|
readonly events: readonly OnlineRelayEvent[];
|
||||||
|
readonly latestEventId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlinePresenceInput {
|
||||||
|
readonly position: OnlineSessionVector;
|
||||||
|
readonly yaw: number;
|
||||||
|
readonly grounded: boolean;
|
||||||
|
readonly health: number;
|
||||||
|
readonly maxHealth: number;
|
||||||
|
readonly resource: number;
|
||||||
|
readonly maxResource: number;
|
||||||
|
readonly resourceName: string;
|
||||||
|
readonly selectedTargetId: string | null;
|
||||||
|
readonly activeCast: ActiveCast | null;
|
||||||
|
readonly animationEvent: CharacterCombatAnimationEvent | null;
|
||||||
|
readonly equipment: readonly InventoryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineSessionSyncInput {
|
||||||
|
readonly afterEventId: number;
|
||||||
|
readonly presence: OnlinePresenceInput;
|
||||||
|
readonly world?: OnlineSharedWorld;
|
||||||
|
readonly events?: readonly OnlineRelayEventInput[];
|
||||||
|
}
|
||||||
@@ -14,6 +14,12 @@ import {
|
|||||||
mergeManastormUnlockedContent,
|
mergeManastormUnlockedContent,
|
||||||
setManastormLoadout,
|
setManastormLoadout,
|
||||||
} from "../game/manastormProgress";
|
} from "../game/manastormProgress";
|
||||||
|
import {
|
||||||
|
DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||||
|
UNIFIED_MANASTORM_BOSS_CATALOG,
|
||||||
|
resolveBossAdminEntry,
|
||||||
|
} from "../game/manastormAdminConfig";
|
||||||
|
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||||
|
|
||||||
installDungeonShellRuntime();
|
installDungeonShellRuntime();
|
||||||
installManastormShellRuntime();
|
installManastormShellRuntime();
|
||||||
@@ -37,6 +43,29 @@ const character = {
|
|||||||
lastPlayedAt: 1,
|
lastPlayedAt: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function readyBossForMode(modeId: number) {
|
||||||
|
return UNIFIED_MANASTORM_BOSS_CATALOG.find((entry) => (
|
||||||
|
(entry.baseStage?.unlockLevel ?? 1) <= 1
|
||||||
|
&& (entry.baseStage?.minimumPartySize ?? 1) <= 1
|
||||||
|
&& (entry.baseStage?.maximumPartySize ?? 5) >= 5
|
||||||
|
&& (!entry.baseStage?.modeIds?.length || entry.baseStage.modeIds.includes(modeId))
|
||||||
|
&& resolveBossAdminEntry(entry, {
|
||||||
|
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||||
|
bosses: { [entry.key]: { status: "ready" } },
|
||||||
|
}).runnable
|
||||||
|
));
|
||||||
|
}
|
||||||
|
const readyLevelingBoss = readyBossForMode(0);
|
||||||
|
const readyEndgameBoss = readyBossForMode(2);
|
||||||
|
if (!readyLevelingBoss || !readyEndgameBoss) throw new Error("Shell tests require runnable Manastorm bosses for both modes.");
|
||||||
|
const readyTestConfig = {
|
||||||
|
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||||
|
bosses: {
|
||||||
|
[readyLevelingBoss.key]: { status: "ready" as const },
|
||||||
|
[readyEndgameBoss.key]: { status: "ready" as const },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function prepareSelectedCharacter(): void {
|
function prepareSelectedCharacter(): void {
|
||||||
useShellStore.setState({
|
useShellStore.setState({
|
||||||
phase: "characters",
|
phase: "characters",
|
||||||
@@ -61,6 +90,7 @@ describe("shell store", () => {
|
|||||||
});
|
});
|
||||||
useGameStore.getState().resetAtEntrance();
|
useGameStore.getState().resetAtEntrance();
|
||||||
useManastormStore.getState().resetRun();
|
useManastormStore.getState().resetRun();
|
||||||
|
useManastormAdminStore.setState({ config: readyTestConfig, draft: readyTestConfig, dirty: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not open the character hub or a dungeon without a selected character", () => {
|
it("does not open the character hub or a dungeon without a selected character", () => {
|
||||||
|
|||||||
+22
-1
@@ -51,7 +51,9 @@ import {
|
|||||||
activateOnlineSession,
|
activateOnlineSession,
|
||||||
hydrateOnlineSession,
|
hydrateOnlineSession,
|
||||||
logoutOnlineSession,
|
logoutOnlineSession,
|
||||||
|
refreshOnlineSession,
|
||||||
} from "./onlineAccountClient";
|
} from "./onlineAccountClient";
|
||||||
|
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
||||||
|
|
||||||
export interface ShellState {
|
export interface ShellState {
|
||||||
phase: ShellPhase;
|
phase: ShellPhase;
|
||||||
@@ -77,6 +79,7 @@ export interface ShellState {
|
|||||||
openMainMenu: () => boolean;
|
openMainMenu: () => boolean;
|
||||||
openDungeons: () => boolean;
|
openDungeons: () => boolean;
|
||||||
openManastorms: () => boolean;
|
openManastorms: () => boolean;
|
||||||
|
openGmAdmin: () => boolean;
|
||||||
openClassHall: () => boolean;
|
openClassHall: () => boolean;
|
||||||
chooseRomSecondaryClass: (classId: RomClassId) => boolean;
|
chooseRomSecondaryClass: (classId: RomClassId) => boolean;
|
||||||
swapSelectedRomClasses: () => boolean;
|
swapSelectedRomClasses: () => boolean;
|
||||||
@@ -294,6 +297,19 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
|||||||
set({ phase: "manastorms", activeCharacter: null, notice: "" });
|
set({ phase: "manastorms", activeCharacter: null, notice: "" });
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
openGmAdmin: () => {
|
||||||
|
const { session, selectedCharacterId, characters } = get();
|
||||||
|
if (!isGameMasterSession(session)) {
|
||||||
|
set({ notice: "Game Master access is required." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
|
||||||
|
set({ phase: "characters", notice: "Select a character before opening GM Tools." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
set({ phase: "gm-admin", activeCharacter: null, notice: "" });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
openClassHall: () => {
|
openClassHall: () => {
|
||||||
const { session, selectedCharacterId, characters } = get();
|
const { session, selectedCharacterId, characters } = get();
|
||||||
const character = characters.find((entry) => entry.id === selectedCharacterId);
|
const character = characters.find((entry) => entry.id === selectedCharacterId);
|
||||||
@@ -500,10 +516,15 @@ export function selectedCharacter(state: ShellState): CharacterProfile | null {
|
|||||||
|
|
||||||
if (restored.session?.accessToken) {
|
if (restored.session?.accessToken) {
|
||||||
activateOnlineSession(restored.session);
|
activateOnlineSession(restored.session);
|
||||||
void hydrateOnlineSession(restored.session).then((characters) => {
|
void Promise.all([
|
||||||
|
hydrateOnlineSession(restored.session),
|
||||||
|
refreshOnlineSession(restored.session),
|
||||||
|
]).then(([characters, refreshedSession]) => {
|
||||||
const current = useShellStore.getState().session;
|
const current = useShellStore.getState().session;
|
||||||
if (current?.ownerId !== restored.session?.ownerId) return;
|
if (current?.ownerId !== restored.session?.ownerId) return;
|
||||||
|
saveSession(refreshedSession);
|
||||||
useShellStore.setState({
|
useShellStore.setState({
|
||||||
|
session: refreshedSession,
|
||||||
characters,
|
characters,
|
||||||
selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId)
|
selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId)
|
||||||
? useShellStore.getState().selectedCharacterId
|
? useShellStore.getState().selectedCharacterId
|
||||||
|
|||||||
+3
-1
@@ -5,13 +5,15 @@ import type { EquipmentAssignments } from "../game/equipment";
|
|||||||
import type { GameplaySettings } from "../game/combatStore";
|
import type { GameplaySettings } from "../game/combatStore";
|
||||||
import type { ManastormProgress } from "../game/manastormProgress";
|
import type { ManastormProgress } from "../game/manastormProgress";
|
||||||
|
|
||||||
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "game";
|
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "gm-admin" | "game";
|
||||||
export type SessionKind = "account" | "offline";
|
export type SessionKind = "account" | "offline";
|
||||||
|
export type AccountRole = "gm";
|
||||||
|
|
||||||
export interface PlayerSession {
|
export interface PlayerSession {
|
||||||
kind: SessionKind;
|
kind: SessionKind;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
roles?: readonly AccountRole[];
|
||||||
/** Present for server-backed accounts; kept in session storage only. */
|
/** Present for server-backed accounts; kept in session storage only. */
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-17
@@ -1,13 +1,15 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useShellStore } from "../app/shellStore";
|
import { useShellStore } from "../app/shellStore";
|
||||||
import { useCombatStore } from "./combatStore";
|
import { useCombatStore } from "./combatStore";
|
||||||
import { respawnDefeatedPlayer } from "./deathRespawn";
|
|
||||||
import { advanceDungeonPartyCombat } from "./partyRuntime";
|
import { advanceDungeonPartyCombat } from "./partyRuntime";
|
||||||
import { useGameStore } from "./store";
|
import { useGameStore } from "./store";
|
||||||
import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout";
|
import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout";
|
||||||
import { useManastormStore } from "./manastormStore";
|
import { useManastormStore } from "./manastormStore";
|
||||||
import { resolveManastormActorDown } from "./manastormSession";
|
|
||||||
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
||||||
|
import {
|
||||||
|
onlineSessionIsActive,
|
||||||
|
onlineSessionOwnsWorldSimulation,
|
||||||
|
} from "./onlineSessionRuntime";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connects the pure combat runtime to the active character and persistent
|
* Connects the pure combat runtime to the active character and persistent
|
||||||
@@ -23,6 +25,7 @@ export function CombatBridge() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!character) return;
|
if (!character) return;
|
||||||
useCombatStore.getState().initializeCharacter(character);
|
useCombatStore.getState().initializeCharacter(character);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
if (gameMode === "manastorm") {
|
if (gameMode === "manastorm") {
|
||||||
activateManastormActiveSpellLoadout(
|
activateManastormActiveSpellLoadout(
|
||||||
useManastormStore.getState().activeSpellLoadout,
|
useManastormStore.getState().activeSpellLoadout,
|
||||||
@@ -110,32 +113,30 @@ export function CombatBridge() {
|
|||||||
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
|
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
|
||||||
previous = now;
|
previous = now;
|
||||||
const game = useGameStore.getState();
|
const game = useGameStore.getState();
|
||||||
if (!game.paused && !game.companionOpen) {
|
const sharedOnline = onlineSessionIsActive();
|
||||||
|
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
|
||||||
const epochNow = Date.now();
|
const epochNow = Date.now();
|
||||||
const combat = useCombatStore.getState();
|
const combat = useCombatStore.getState();
|
||||||
combat.setPlayerPosition(game.playerPosition);
|
combat.setPlayerPosition(game.playerPosition);
|
||||||
combat.tick(delta, epochNow);
|
combat.tick(delta, epochNow);
|
||||||
if (game.gameMode === "dungeon") {
|
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
|
||||||
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
|
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
|
||||||
}
|
}
|
||||||
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
|
if (onlineSessionOwnsWorldSimulation()) {
|
||||||
advanceDungeonPartyCombat(epochNow);
|
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
|
||||||
if (game.gameMode === "dungeon" && game.activeDungeonId === "wailing-caverns") {
|
advanceDungeonPartyCombat(epochNow);
|
||||||
|
}
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
if (
|
||||||
|
onlineSessionOwnsWorldSimulation()
|
||||||
|
&& game.gameMode === "dungeon"
|
||||||
|
&& game.activeDungeonId === "wailing-caverns"
|
||||||
|
) {
|
||||||
useWailingEncounterStore.getState().advance(
|
useWailingEncounterStore.getState().advance(
|
||||||
useCombatStore.getState().mobs,
|
useCombatStore.getState().mobs,
|
||||||
epochNow,
|
epochNow,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (useCombatStore.getState().health <= 0 && game.gameMode === "manastorm") {
|
|
||||||
const resolution = resolveManastormActorDown({ kind: "player" });
|
|
||||||
if (resolution === "failed") useShellStore.getState().failActiveManastorm();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (respawnDefeatedPlayer()) {
|
|
||||||
// resetAtEntrance increments this revision. Mark it consumed because
|
|
||||||
// respawnDefeatedPlayer already reset combat atomically.
|
|
||||||
mountedResetRevision.current = useGameStore.getState().resetRevision;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import { getManastormCatalog, manastormEncounterById } from "./manastorm";
|
|||||||
import { synchronizeManastormProgress } from "./manastormProgressSync";
|
import { synchronizeManastormProgress } from "./manastormProgressSync";
|
||||||
import { synchronizeManastormAffixes } from "./manastormAffixRuntime";
|
import { synchronizeManastormAffixes } from "./manastormAffixRuntime";
|
||||||
import { preloadManastormStage } from "./manastormStageLoader";
|
import { preloadManastormStage } from "./manastormStageLoader";
|
||||||
|
import { onlineSessionIsActive, onlineSessionOwnsWorldSimulation } from "./onlineSessionRuntime";
|
||||||
|
|
||||||
/** Connects staged combat, party sizing, and shared resurrections to Manastorm state. */
|
/** Connects staged combat, party sizing, and shared resurrections to Manastorm state. */
|
||||||
export function ManastormBridge() {
|
export function ManastormBridge() {
|
||||||
const gameMode = useGameStore((state) => state.gameMode);
|
const gameMode = useGameStore((state) => state.gameMode);
|
||||||
|
const gmTestMode = useGameStore((state) => state.gmTestMode);
|
||||||
const status = useManastormStore((state) => state.status);
|
const status = useManastormStore((state) => state.status);
|
||||||
const partySize = useManastormStore((state) => state.partySize);
|
const partySize = useManastormStore((state) => state.partySize);
|
||||||
const encounter = useManastormStore((state) => state.currentEncounter);
|
const encounter = useManastormStore((state) => state.currentEncounter);
|
||||||
@@ -29,11 +31,12 @@ export function ManastormBridge() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameMode !== "manastorm") return;
|
if (gameMode !== "manastorm") return;
|
||||||
|
if (!onlineSessionOwnsWorldSimulation()) return;
|
||||||
synchronizeManastormPartySize();
|
synchronizeManastormPartySize();
|
||||||
}, [gameMode, partyMemberCount, partySize]);
|
}, [gameMode, partyMemberCount, partySize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameMode !== "manastorm" || status !== "entering" || !encounter) return;
|
if (gameMode !== "manastorm" || status !== "entering" || !encounter || !onlineSessionOwnsWorldSimulation()) return;
|
||||||
useManastormStore.getState().enterStage();
|
useManastormStore.getState().enterStage();
|
||||||
}, [encounter, gameMode, status]);
|
}, [encounter, gameMode, status]);
|
||||||
|
|
||||||
@@ -41,12 +44,14 @@ export function ManastormBridge() {
|
|||||||
if (
|
if (
|
||||||
gameMode !== "manastorm"
|
gameMode !== "manastorm"
|
||||||
|| !encounter
|
|| !encounter
|
||||||
|
|| !onlineSessionOwnsWorldSimulation()
|
||||||
) return;
|
) return;
|
||||||
synchronizeManastormCombat();
|
synchronizeManastormCombat();
|
||||||
}, [encounter, gameMode, mobs, status]);
|
}, [encounter, gameMode, mobs, status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameMode !== "manastorm") return;
|
if (gameMode !== "manastorm") return;
|
||||||
|
if (!onlineSessionOwnsWorldSimulation()) return;
|
||||||
const timer = window.setInterval(() => synchronizeManastormAffixes(Date.now()), 250);
|
const timer = window.setInterval(() => synchronizeManastormAffixes(Date.now()), 250);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [gameMode]);
|
}, [gameMode]);
|
||||||
@@ -70,6 +75,7 @@ export function ManastormBridge() {
|
|||||||
|| previous.health <= 0
|
|| previous.health <= 0
|
||||||
|| useGameStore.getState().gameMode !== "manastorm"
|
|| useGameStore.getState().gameMode !== "manastorm"
|
||||||
|| resolvingDefeatRef.current
|
|| resolvingDefeatRef.current
|
||||||
|
|| onlineSessionIsActive()
|
||||||
) return;
|
) return;
|
||||||
resolvingDefeatRef.current = true;
|
resolvingDefeatRef.current = true;
|
||||||
try {
|
try {
|
||||||
@@ -81,7 +87,11 @@ export function ManastormBridge() {
|
|||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
useEffect(() => usePartyStore.subscribe((state, previous) => {
|
useEffect(() => usePartyStore.subscribe((state, previous) => {
|
||||||
if (useGameStore.getState().gameMode !== "manastorm" || resolvingDefeatRef.current) return;
|
if (
|
||||||
|
useGameStore.getState().gameMode !== "manastorm"
|
||||||
|
|| resolvingDefeatRef.current
|
||||||
|
|| !onlineSessionOwnsWorldSimulation()
|
||||||
|
) return;
|
||||||
const previousById = new Map(previous.members.map((member) => [member.id, member]));
|
const previousById = new Map(previous.members.map((member) => [member.id, member]));
|
||||||
const downedMemberIds = state.members.filter((member) => (
|
const downedMemberIds = state.members.filter((member) => (
|
||||||
member.health <= 0 && (previousById.get(member.id)?.health ?? 0) > 0
|
member.health <= 0 && (previousById.get(member.id)?.health ?? 0) > 0
|
||||||
@@ -103,7 +113,7 @@ export function ManastormBridge() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!characterId) return;
|
if (!characterId) return;
|
||||||
return useManastormStore.subscribe((state, previous) => {
|
return useManastormStore.subscribe((state, previous) => {
|
||||||
if (useGameStore.getState().gameMode !== "manastorm") return;
|
if (useGameStore.getState().gameMode !== "manastorm" || useGameStore.getState().gmTestMode) return;
|
||||||
const character = useShellStore.getState().activeCharacter;
|
const character = useShellStore.getState().activeCharacter;
|
||||||
if (!character || character.id !== characterId) return;
|
if (!character || character.id !== characterId) return;
|
||||||
const result = synchronizeManastormProgress(
|
const result = synchronizeManastormProgress(
|
||||||
@@ -116,7 +126,7 @@ export function ManastormBridge() {
|
|||||||
useShellStore.getState().saveActiveManastormProgress(result.progress);
|
useShellStore.getState().saveActiveManastormProgress(result.progress);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [characterId]);
|
}, [characterId, gmTestMode]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { synchronizeOnlineSession } from "../app/onlineAccountClient";
|
||||||
|
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||||
|
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||||
|
import type {
|
||||||
|
OnlineRelayEvent,
|
||||||
|
OnlineRelayEventInput,
|
||||||
|
OnlineSessionSnapshot,
|
||||||
|
OnlineSharedWorld,
|
||||||
|
} from "../app/onlineSessionTypes";
|
||||||
|
import { useShellStore } from "../app/shellStore";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
|
||||||
|
import { getMobRuntimeTransform } from "./mobRuntimeRegistry";
|
||||||
|
import {
|
||||||
|
configureOnlineSessionRuntime,
|
||||||
|
drainOnlineInteractions,
|
||||||
|
isOnlinePlayerActorId,
|
||||||
|
onlineAccountIdFromActor,
|
||||||
|
onlinePlayerActorId,
|
||||||
|
} from "./onlineSessionRuntime";
|
||||||
|
import {
|
||||||
|
getPartyRuntimePosition,
|
||||||
|
removePartyRuntimePosition,
|
||||||
|
updatePartyRuntimePosition,
|
||||||
|
} from "./partyRuntimeRegistry";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
|
import { useManastormStore } from "./manastormStore";
|
||||||
|
import { advanceManastormSession } from "./manastormSession";
|
||||||
|
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
||||||
|
|
||||||
|
const SYNC_INTERVAL_MS = 100;
|
||||||
|
const WORLD_INTERVAL_MS = 200;
|
||||||
|
|
||||||
|
function serializableState<T extends object>(state: T): Readonly<Record<string, unknown>> {
|
||||||
|
return JSON.parse(JSON.stringify(state)) as Readonly<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sharedWorld(): OnlineSharedWorld {
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
const party = usePartyStore.getState();
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
const mobTransforms = Object.fromEntries(
|
||||||
|
Object.keys(combat.mobs).flatMap((id) => {
|
||||||
|
const transform = getMobRuntimeTransform(id);
|
||||||
|
return transform ? [[id, transform]] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const partyPositions = Object.fromEntries(
|
||||||
|
party.members.flatMap((member) => {
|
||||||
|
const position = getPartyRuntimePosition(member.id);
|
||||||
|
return position ? [[member.id, position]] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
mobs: combat.mobs,
|
||||||
|
mobTransforms,
|
||||||
|
partyMembers: party.members,
|
||||||
|
partyPositions,
|
||||||
|
partyRuntime: {
|
||||||
|
command: party.command,
|
||||||
|
activeMobId: party.activeMobId,
|
||||||
|
objectiveBossId: party.objectiveBossId,
|
||||||
|
memberTargetIds: party.memberTargetIds,
|
||||||
|
},
|
||||||
|
wailing: game.gameMode === "dungeon"
|
||||||
|
? serializableState(useWailingEncounterStore.getState())
|
||||||
|
: null,
|
||||||
|
manastorm: game.gameMode === "manastorm"
|
||||||
|
? serializableState(useManastormStore.getState())
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySharedWorld(world: OnlineSharedWorld): void {
|
||||||
|
useCombatStore.setState({ mobs: world.mobs });
|
||||||
|
usePartyStore.setState({
|
||||||
|
members: world.partyMembers,
|
||||||
|
command: world.partyRuntime.command as ReturnType<typeof usePartyStore.getState>["command"],
|
||||||
|
activeMobId: world.partyRuntime.activeMobId,
|
||||||
|
objectiveBossId: world.partyRuntime.objectiveBossId,
|
||||||
|
memberTargetIds: world.partyRuntime.memberTargetIds,
|
||||||
|
});
|
||||||
|
for (const [id, position] of Object.entries(world.partyPositions)) {
|
||||||
|
updatePartyRuntimePosition(id, position[0], position[1], position[2]);
|
||||||
|
}
|
||||||
|
if (world.wailing) useWailingEncounterStore.setState(world.wailing as never);
|
||||||
|
if (world.manastorm) useManastormStore.setState(world.manastorm as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventForRelay(
|
||||||
|
event: ReturnType<typeof useCombatStore.getState>["combatEvents"][number],
|
||||||
|
localAccountId: string,
|
||||||
|
authority: boolean,
|
||||||
|
): OnlineRelayEventInput | null {
|
||||||
|
if (!["damage", "healing", "resurrection"].includes(event.kind)) return null;
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
const playerOwnedSummon = combat.summons.some((summon) => (
|
||||||
|
summon.id === event.sourceActorId && summon.ownerActorId === PLAYER_AGGRO_ID
|
||||||
|
));
|
||||||
|
const sourceIsLocal = event.sourceActorId === PLAYER_AGGRO_ID || playerOwnedSummon;
|
||||||
|
const targetsRemotePlayer = isOnlinePlayerActorId(event.targetActorId);
|
||||||
|
if (!sourceIsLocal && !(authority && targetsRemotePlayer)) return null;
|
||||||
|
const localActorId = onlinePlayerActorId(localAccountId);
|
||||||
|
const targetActorId = event.targetActorId === PLAYER_AGGRO_ID
|
||||||
|
? localActorId
|
||||||
|
: event.targetActorId;
|
||||||
|
if (sourceIsLocal && targetActorId === localActorId) return null;
|
||||||
|
return {
|
||||||
|
clientEventId: `combat:${localAccountId}:${event.occurredAt}:${event.id}:${event.kind}`,
|
||||||
|
kind: event.kind as "damage" | "healing" | "resurrection",
|
||||||
|
sourceActorId: sourceIsLocal ? localActorId : event.sourceActorId,
|
||||||
|
targetActorId,
|
||||||
|
abilityId: event.abilityId,
|
||||||
|
school: event.school,
|
||||||
|
rawAmount: event.rawAmount,
|
||||||
|
effectiveAmount: event.effectiveAmount,
|
||||||
|
critical: event.critical,
|
||||||
|
occurredAt: event.occurredAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRelayEvent(
|
||||||
|
event: OnlineRelayEvent,
|
||||||
|
snapshot: OnlineSessionSnapshot,
|
||||||
|
applyingRef: React.MutableRefObject<boolean>,
|
||||||
|
): void {
|
||||||
|
if (event.sourceAccountId === snapshot.localAccountId) return;
|
||||||
|
const localActorId = onlinePlayerActorId(snapshot.localAccountId);
|
||||||
|
applyingRef.current = true;
|
||||||
|
try {
|
||||||
|
if (event.kind === "portal" && snapshot.authority) {
|
||||||
|
advanceManastormSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.kind === "loot" && snapshot.authority && event.targetActorId) {
|
||||||
|
useCombatStore.getState().lootMob(event.targetActorId, event.occurredAt || Date.now());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.targetActorId === localActorId) {
|
||||||
|
if (event.kind === "damage") {
|
||||||
|
const sourceLevel = useCombatStore.getState().mobs[event.sourceActorId]?.level;
|
||||||
|
useCombatStore.getState().damagePlayer(
|
||||||
|
event.rawAmount ?? event.effectiveAmount ?? 0,
|
||||||
|
event.school ?? "physical",
|
||||||
|
sourceLevel,
|
||||||
|
);
|
||||||
|
} else if (event.kind === "healing") {
|
||||||
|
useCombatStore.getState().healActor(PLAYER_AGGRO_ID, event.effectiveAmount ?? 0, {
|
||||||
|
sourceActorId: event.sourceActorId,
|
||||||
|
abilityId: event.abilityId ?? undefined,
|
||||||
|
school: event.school ?? undefined,
|
||||||
|
now: event.occurredAt,
|
||||||
|
amountAlreadyModified: true,
|
||||||
|
});
|
||||||
|
} else if (event.kind === "resurrection") {
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
combat.revivePlayer((event.effectiveAmount ?? 1) / Math.max(1, combat.maxHealth));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!snapshot.authority) return;
|
||||||
|
const partyMember = usePartyStore.getState().members.find((member) => member.id === event.targetActorId);
|
||||||
|
if (partyMember) {
|
||||||
|
if (event.kind === "healing") {
|
||||||
|
useCombatStore.getState().healActor(event.targetActorId, event.effectiveAmount ?? 0, {
|
||||||
|
sourceActorId: event.sourceActorId,
|
||||||
|
abilityId: event.abilityId ?? undefined,
|
||||||
|
school: event.school ?? undefined,
|
||||||
|
now: event.occurredAt,
|
||||||
|
amountAlreadyModified: true,
|
||||||
|
});
|
||||||
|
} else if (event.kind === "resurrection") {
|
||||||
|
useCombatStore.getState().reviveActor(
|
||||||
|
event.targetActorId,
|
||||||
|
(event.effectiveAmount ?? 1) / Math.max(1, partyMember.maxHealth),
|
||||||
|
event.sourceActorId,
|
||||||
|
event.abilityId ?? undefined,
|
||||||
|
event.occurredAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mob = useCombatStore.getState().mobs[event.targetActorId];
|
||||||
|
if (mob && event.kind === "damage") {
|
||||||
|
const sourceMember = snapshot.players.find((player) => player.accountId === event.sourceAccountId);
|
||||||
|
useCombatStore.getState().damageMob(
|
||||||
|
mob.id,
|
||||||
|
event.rawAmount ?? event.effectiveAmount ?? 0,
|
||||||
|
event.occurredAt,
|
||||||
|
{
|
||||||
|
actorId: event.sourceActorId,
|
||||||
|
role: sourceMember?.role ?? "damage",
|
||||||
|
abilityId: event.abilityId ?? undefined,
|
||||||
|
},
|
||||||
|
event.school ?? "physical",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
applyingRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnlineSessionBridge() {
|
||||||
|
const session = useShellStore((state) => state.session);
|
||||||
|
const activeActivity = useOnlineGroupStore((state) => state.activeActivity);
|
||||||
|
const group = useOnlineGroupStore((state) => state.snapshot.group);
|
||||||
|
const accept = useOnlineSessionStore((state) => state.accept);
|
||||||
|
const fail = useOnlineSessionStore((state) => state.fail);
|
||||||
|
const reset = useOnlineSessionStore((state) => state.reset);
|
||||||
|
const pendingEventsRef = useRef<OnlineRelayEventInput[]>([]);
|
||||||
|
const applyingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session?.accessToken || !activeActivity || !group) {
|
||||||
|
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||||
|
reset();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const localAccountId = session.ownerId;
|
||||||
|
const immediateAuthority = group.leaderAccountId === localAccountId;
|
||||||
|
configureOnlineSessionRuntime({
|
||||||
|
active: true,
|
||||||
|
authority: immediateAuthority,
|
||||||
|
localAccountId,
|
||||||
|
});
|
||||||
|
let disposed = false;
|
||||||
|
let syncing = false;
|
||||||
|
let latestEventId = 0;
|
||||||
|
let lastWorldSentAt = 0;
|
||||||
|
const remotePositionIds = new Set<string>();
|
||||||
|
|
||||||
|
const unsubscribeCombat = useCombatStore.subscribe((state, previous) => {
|
||||||
|
if (applyingRef.current || state.combatEvents === previous.combatEvents) return;
|
||||||
|
const previousKeys = new Set(previous.combatEvents.map((event) => `${event.id}:${event.occurredAt}:${event.kind}`));
|
||||||
|
for (const event of state.combatEvents) {
|
||||||
|
if (previousKeys.has(`${event.id}:${event.occurredAt}:${event.kind}`)) continue;
|
||||||
|
const relay = eventForRelay(event, localAccountId, group.leaderAccountId === localAccountId);
|
||||||
|
if (relay) pendingEventsRef.current.push(relay);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const synchronize = async () => {
|
||||||
|
if (disposed || syncing) return;
|
||||||
|
syncing = true;
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
const outgoingEvents = [...pendingEventsRef.current.splice(0), ...drainOnlineInteractions()];
|
||||||
|
const now = Date.now();
|
||||||
|
const publishWorld = group.leaderAccountId === localAccountId && now - lastWorldSentAt >= WORLD_INTERVAL_MS;
|
||||||
|
if (publishWorld) lastWorldSentAt = now;
|
||||||
|
try {
|
||||||
|
const snapshot = await synchronizeOnlineSession(session, {
|
||||||
|
afterEventId: latestEventId,
|
||||||
|
presence: {
|
||||||
|
position: game.playerPosition,
|
||||||
|
yaw: game.cameraYaw,
|
||||||
|
grounded: game.playerGrounded,
|
||||||
|
health: combat.health,
|
||||||
|
maxHealth: combat.maxHealth,
|
||||||
|
resource: combat.resource,
|
||||||
|
maxResource: combat.maxResource,
|
||||||
|
resourceName: combat.resourceName,
|
||||||
|
selectedTargetId: combat.selectedTargetId,
|
||||||
|
activeCast: combat.activeCast,
|
||||||
|
animationEvent: combat.playerAnimationEvent,
|
||||||
|
equipment: equipmentItemsForOwner(
|
||||||
|
combat.equipment,
|
||||||
|
combat.inventory,
|
||||||
|
PLAYER_EQUIPMENT_OWNER_ID,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(publishWorld ? { world: sharedWorld() } : {}),
|
||||||
|
...(outgoingEvents.length ? { events: outgoingEvents } : {}),
|
||||||
|
});
|
||||||
|
if (disposed) return;
|
||||||
|
latestEventId = Math.max(latestEventId, snapshot.latestEventId);
|
||||||
|
configureOnlineSessionRuntime({
|
||||||
|
active: true,
|
||||||
|
authority: snapshot.authority,
|
||||||
|
localAccountId,
|
||||||
|
players: snapshot.players,
|
||||||
|
mobTransforms: snapshot.world?.mobTransforms,
|
||||||
|
});
|
||||||
|
for (const player of snapshot.players) {
|
||||||
|
if (player.accountId === localAccountId) continue;
|
||||||
|
const actorId = onlinePlayerActorId(player.accountId);
|
||||||
|
remotePositionIds.add(actorId);
|
||||||
|
updatePartyRuntimePosition(actorId, player.position[0], player.position[1], player.position[2]);
|
||||||
|
}
|
||||||
|
if (!snapshot.authority && snapshot.world) applySharedWorld(snapshot.world);
|
||||||
|
for (const event of snapshot.events) applyRelayEvent(event, snapshot, applyingRef);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
accept(snapshot);
|
||||||
|
} catch (error) {
|
||||||
|
if (outgoingEvents.length) pendingEventsRef.current.unshift(...outgoingEvents);
|
||||||
|
if (!disposed) fail(error instanceof Error ? error.message : "The shared activity lost connection.");
|
||||||
|
} finally {
|
||||||
|
syncing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void synchronize();
|
||||||
|
const timer = window.setInterval(() => void synchronize(), SYNC_INTERVAL_MS);
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
unsubscribeCombat();
|
||||||
|
for (const id of remotePositionIds) removePartyRuntimePosition(id);
|
||||||
|
pendingEventsRef.current = [];
|
||||||
|
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
}, [accept, activeActivity?.id, fail, group?.id, group?.leaderAccountId, reset, session?.accessToken, session?.ownerId]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { resetPartyRuntimeClock } from "./partyRuntime";
|
|||||||
import { usePartyStore } from "./partyStore";
|
import { usePartyStore } from "./partyStore";
|
||||||
import { useGameStore } from "./store";
|
import { useGameStore } from "./store";
|
||||||
import { useManastormStore } from "./manastormStore";
|
import { useManastormStore } from "./manastormStore";
|
||||||
|
import { onlineGroupAiPartySize, useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||||
|
|
||||||
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
|
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
|
||||||
export function PartyBridge() {
|
export function PartyBridge() {
|
||||||
@@ -12,6 +13,7 @@ export function PartyBridge() {
|
|||||||
const playerRole = useShellStore((state) => state.activeDungeonRole);
|
const playerRole = useShellStore((state) => state.activeDungeonRole);
|
||||||
const gameMode = useGameStore((state) => state.gameMode);
|
const gameMode = useGameStore((state) => state.gameMode);
|
||||||
const manastormPartySize = useManastormStore((state) => state.partySize);
|
const manastormPartySize = useManastormStore((state) => state.partySize);
|
||||||
|
const onlineActivity = useOnlineGroupStore((state) => state.activeActivity);
|
||||||
const level = useCombatStore((state) => state.level);
|
const level = useCombatStore((state) => state.level);
|
||||||
const resetRevision = useGameStore((state) => state.resetRevision);
|
const resetRevision = useGameStore((state) => state.resetRevision);
|
||||||
const mountedResetRevision = useRef(resetRevision);
|
const mountedResetRevision = useRef(resetRevision);
|
||||||
@@ -20,17 +22,22 @@ export function PartyBridge() {
|
|||||||
if (!character) return;
|
if (!character) return;
|
||||||
const combat = useCombatStore.getState();
|
const combat = useCombatStore.getState();
|
||||||
const party = usePartyStore.getState();
|
const party = usePartyStore.getState();
|
||||||
|
const requestedPartySize = gameMode === "manastorm" ? manastormPartySize : 5;
|
||||||
|
const aiPartySize = onlineActivity
|
||||||
|
&& onlineActivity.type === (gameMode === "manastorm" ? "manastorm" : "dungeon")
|
||||||
|
? onlineGroupAiPartySize(onlineActivity.partySize)
|
||||||
|
: requestedPartySize;
|
||||||
party.initializeParty(
|
party.initializeParty(
|
||||||
character.id,
|
character.id,
|
||||||
combat.level,
|
combat.level,
|
||||||
combat.health,
|
combat.health,
|
||||||
playerRole,
|
playerRole,
|
||||||
gameMode === "manastorm" ? manastormPartySize : 5,
|
aiPartySize,
|
||||||
character.categoryId ?? "wow",
|
character.categoryId ?? "wow",
|
||||||
);
|
);
|
||||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||||
resetPartyRuntimeClock();
|
resetPartyRuntimeClock();
|
||||||
}, [character?.id, gameMode, manastormPartySize, playerRole]);
|
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
usePartyStore.getState().syncLevel(level);
|
usePartyStore.getState().syncLevel(level);
|
||||||
|
|||||||
@@ -38,7 +38,20 @@ describe("baseline ability catalog", () => {
|
|||||||
const iconPath = path.join(process.cwd(), "public", ability.icon.slice(1));
|
const iconPath = path.join(process.cwd(), "public", ability.icon.slice(1));
|
||||||
expect(existsSync(iconPath), `${ability.name} icon should exist at ${iconPath}`).toBe(true);
|
expect(existsSync(iconPath), `${ability.name} icon should exist at ${iconPath}`).toBe(true);
|
||||||
expect(statSync(iconPath).size, `${ability.name} icon should not be empty`).toBeGreaterThan(0);
|
expect(statSync(iconPath).size, `${ability.name} icon should not be empty`).toBeGreaterThan(0);
|
||||||
expect(ability.cost.resource).toBe(resource.type);
|
if (characterClass.mode !== "classic" || ability.cost.amount > 0) {
|
||||||
|
const classicResources: Partial<Record<typeof characterClass.id, readonly string[]>> = {
|
||||||
|
warrior: ["rage", "health"],
|
||||||
|
rogue: ["energy"],
|
||||||
|
"death-knight": ["runic-power", "health"],
|
||||||
|
druid: ["mana", "rage", "energy"],
|
||||||
|
warlock: ["mana", "health"],
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
characterClass.mode === "classic"
|
||||||
|
? classicResources[characterClass.id] ?? [resource.type]
|
||||||
|
: ["mana", "rage", "energy", "runic-power", "focus", "health"],
|
||||||
|
).toContain(ability.cost.resource);
|
||||||
|
}
|
||||||
expect(ability.cost.amount).toBeGreaterThanOrEqual(0);
|
expect(ability.cost.amount).toBeGreaterThanOrEqual(0);
|
||||||
expect(ability.effects.length).toBeGreaterThan(0);
|
expect(ability.effects.length).toBeGreaterThan(0);
|
||||||
expect(allIds.has(ability.id)).toBe(false);
|
expect(allIds.has(ability.id)).toBe(false);
|
||||||
|
|||||||
@@ -6,11 +6,68 @@ import {
|
|||||||
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
||||||
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES, ROM_ORDINARY_ABILITIES_BY_CLASS, type RomSkillGroup } from "./romAbilityCatalog";
|
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES, ROM_ORDINARY_ABILITIES_BY_CLASS, type RomSkillGroup } from "./romAbilityCatalog";
|
||||||
import { registerAbilityAnimationLookup } from "./abilityAnimationLookup";
|
import { registerAbilityAnimationLookup } from "./abilityAnimationLookup";
|
||||||
import type { AuraDefinition, DispelCategory } from "./combatAuras";
|
import type { AuraDefinition, DamageSchool, DispelCategory } from "./combatAuras";
|
||||||
|
|
||||||
export type ResourceType = "mana" | "rage" | "energy" | "runic-power" | "focus" | "nature-power" | "psi" | "health";
|
export type ResourceType =
|
||||||
export type AbilityTarget = "hostile" | "friendly" | "self";
|
| "mana"
|
||||||
|
| "rage"
|
||||||
|
| "energy"
|
||||||
|
| "runic-power"
|
||||||
|
| "focus"
|
||||||
|
| "nature-power"
|
||||||
|
| "psi"
|
||||||
|
| "health"
|
||||||
|
| "holy-power"
|
||||||
|
| "chi"
|
||||||
|
| "soul-power"
|
||||||
|
| `custom:${string}`;
|
||||||
|
export type AbilityTarget = "hostile" | "friendly" | "any" | "self";
|
||||||
export type AbilityCastMode = "instant" | "cast" | "channel";
|
export type AbilityCastMode = "instant" | "cast" | "channel";
|
||||||
|
export type AbilityTargetShape = "unit" | "circle" | "cone" | "chain" | "ground";
|
||||||
|
|
||||||
|
export interface AbilityTargetingRules {
|
||||||
|
readonly shape: AbilityTargetShape;
|
||||||
|
readonly requiresLineOfSight?: boolean;
|
||||||
|
readonly requiresFacing?: boolean;
|
||||||
|
readonly requiresBehindTarget?: boolean;
|
||||||
|
readonly attackArcRadians?: number;
|
||||||
|
readonly radius?: number;
|
||||||
|
readonly maximumTargets?: number;
|
||||||
|
readonly jumpRange?: number;
|
||||||
|
readonly groundRange?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AbilityCastRules {
|
||||||
|
readonly school?: DamageSchool;
|
||||||
|
readonly canCastWhileMoving?: boolean;
|
||||||
|
readonly pushbackImmune?: boolean;
|
||||||
|
/** A fraction from zero to one, applied independently to each pushback hit. */
|
||||||
|
readonly pushbackResistance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AbilityRuneCost {
|
||||||
|
readonly blood?: number;
|
||||||
|
readonly frost?: number;
|
||||||
|
readonly unholy?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AbilityAiHints {
|
||||||
|
readonly categories: readonly (
|
||||||
|
| "damage"
|
||||||
|
| "execute"
|
||||||
|
| "heal"
|
||||||
|
| "emergency-heal"
|
||||||
|
| "area-heal"
|
||||||
|
| "mitigation"
|
||||||
|
| "taunt"
|
||||||
|
| "interrupt"
|
||||||
|
| "dispel"
|
||||||
|
| "resurrection"
|
||||||
|
| "control"
|
||||||
|
| "debuff"
|
||||||
|
)[];
|
||||||
|
readonly priority?: number;
|
||||||
|
}
|
||||||
export type MobStatusKind =
|
export type MobStatusKind =
|
||||||
| "slow"
|
| "slow"
|
||||||
| "stun"
|
| "stun"
|
||||||
@@ -63,6 +120,8 @@ export interface AbilityAmountScaling {
|
|||||||
readonly basePoints?: number;
|
readonly basePoints?: number;
|
||||||
readonly dieSides?: number;
|
readonly dieSides?: number;
|
||||||
readonly pointsPerLevel?: number;
|
readonly pointsPerLevel?: number;
|
||||||
|
/** Additional power coefficient gained per source skill level. */
|
||||||
|
readonly coefficientPerLevel?: number;
|
||||||
readonly bonusPowerCoefficient?: number;
|
readonly bonusPowerCoefficient?: number;
|
||||||
readonly sourceLevel?: number;
|
readonly sourceLevel?: number;
|
||||||
readonly maxScalingLevel?: number;
|
readonly maxScalingLevel?: number;
|
||||||
@@ -77,15 +136,17 @@ export type AbilityEffect =
|
|||||||
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number } & AbilityAmountScaling)
|
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number } & AbilityAmountScaling)
|
||||||
| { readonly kind: "resurrection"; readonly percentMaxHealth: number }
|
| { readonly kind: "resurrection"; readonly percentMaxHealth: number }
|
||||||
| { readonly kind: "summon"; readonly creatureId: number; readonly coefficient: number; readonly durationMs: number }
|
| { readonly kind: "summon"; readonly creatureId: number; readonly coefficient: number; readonly durationMs: number }
|
||||||
| { readonly kind: "resource"; readonly amount: number; readonly resource?: ResourceType }
|
| { readonly kind: "resource"; readonly amount: number; readonly resource?: ResourceType; readonly percentage?: boolean }
|
||||||
| { readonly kind: "combo"; readonly amount: number }
|
| { readonly kind: "combo"; readonly amount: number }
|
||||||
| { readonly kind: "taunt"; readonly durationMs: number; readonly maxTargets?: number }
|
| { readonly kind: "taunt"; readonly durationMs: number; readonly maxTargets?: number }
|
||||||
| { readonly kind: "status"; readonly status: MobStatusKind; readonly durationMs: number; readonly magnitude?: number; readonly maxTargets?: number }
|
| { readonly kind: "status"; readonly status: MobStatusKind; readonly durationMs: number; readonly magnitude?: number; readonly maxTargets?: number; readonly breakOnDamage?: boolean }
|
||||||
| {
|
| {
|
||||||
readonly kind: "apply-aura";
|
readonly kind: "apply-aura";
|
||||||
readonly aura: AuraDefinition;
|
readonly aura: AuraDefinition;
|
||||||
/** Some hostile spells apply their beneficial rider to the caster. */
|
/** Some hostile spells apply their beneficial rider to the caster. */
|
||||||
readonly recipient?: "ability-target" | "caster";
|
readonly recipient?: "ability-target" | "caster";
|
||||||
|
/** Party/raid area auras are applied to the player and every active party member. */
|
||||||
|
readonly scope?: "target" | "party";
|
||||||
readonly control?: AbilityAuraControl;
|
readonly control?: AbilityAuraControl;
|
||||||
readonly sourceEffectType: number;
|
readonly sourceEffectType: number;
|
||||||
readonly sourceAuraType: number;
|
readonly sourceAuraType: number;
|
||||||
@@ -109,6 +170,7 @@ export type AbilityEffect =
|
|||||||
readonly kind: "threat";
|
readonly kind: "threat";
|
||||||
readonly mode: "flat" | "percent" | "redirect";
|
readonly mode: "flat" | "percent" | "redirect";
|
||||||
readonly amount: number;
|
readonly amount: number;
|
||||||
|
readonly recipient?: "caster" | "ability-target";
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly kind: "movement";
|
readonly kind: "movement";
|
||||||
@@ -119,7 +181,14 @@ export type AbilityEffect =
|
|||||||
| { readonly kind: "pet"; readonly action: "summon" | "call" | "recall" | "dismiss" | "resurrect" | "feed" | "tame"; readonly creatureId?: number }
|
| { readonly kind: "pet"; readonly action: "summon" | "call" | "recall" | "dismiss" | "resurrect" | "feed" | "tame"; readonly creatureId?: number }
|
||||||
| { readonly kind: "totem"; readonly action: "destroy-all" }
|
| { readonly kind: "totem"; readonly action: "destroy-all" }
|
||||||
| { readonly kind: "rune"; readonly action: "activate"; readonly count: number }
|
| { readonly kind: "rune"; readonly action: "activate"; readonly count: number }
|
||||||
| { readonly kind: "cooldown-reset"; readonly mode: "reset" | "reduce"; readonly abilityName?: string; readonly amountMs?: number }
|
| {
|
||||||
|
readonly kind: "cooldown-reset";
|
||||||
|
readonly mode: "reset" | "reduce";
|
||||||
|
readonly abilityName?: string;
|
||||||
|
readonly amountMs?: number;
|
||||||
|
/** Fraction of the target ability's remaining cooldown removed at execution time. */
|
||||||
|
readonly amountPercent?: number;
|
||||||
|
}
|
||||||
| { readonly kind: "trigger-spell"; readonly spellId: number; readonly withValue?: number }
|
| { readonly kind: "trigger-spell"; readonly spellId: number; readonly withValue?: number }
|
||||||
| {
|
| {
|
||||||
/** A known combat-side DBC script hook that requires a spell-specific handler. */
|
/** A known combat-side DBC script hook that requires a spell-specific handler. */
|
||||||
@@ -140,6 +209,7 @@ export type AbilityEffect =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly kind: "utility";
|
readonly kind: "utility";
|
||||||
|
readonly action?: "create-item" | "open-lock" | "portal" | "weapon-enchant" | "pickpocket" | "farsight" | "class-script";
|
||||||
readonly effectType: number;
|
readonly effectType: number;
|
||||||
readonly auraType: number;
|
readonly auraType: number;
|
||||||
readonly miscValue: number;
|
readonly miscValue: number;
|
||||||
@@ -165,6 +235,9 @@ export interface AbilityRankDefinition {
|
|||||||
readonly cost: AbilityCost;
|
readonly cost: AbilityCost;
|
||||||
readonly costs?: readonly AbilityCost[];
|
readonly costs?: readonly AbilityCost[];
|
||||||
readonly effects: readonly AbilityEffect[];
|
readonly effects: readonly AbilityEffect[];
|
||||||
|
readonly targeting?: AbilityTargetingRules;
|
||||||
|
readonly castRules?: AbilityCastRules;
|
||||||
|
readonly runeCost?: AbilityRuneCost;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AbilityDefinition {
|
export interface AbilityDefinition {
|
||||||
@@ -188,6 +261,10 @@ export interface AbilityDefinition {
|
|||||||
readonly cost: AbilityCost;
|
readonly cost: AbilityCost;
|
||||||
readonly costs?: readonly AbilityCost[];
|
readonly costs?: readonly AbilityCost[];
|
||||||
readonly effects: readonly AbilityEffect[];
|
readonly effects: readonly AbilityEffect[];
|
||||||
|
readonly targeting?: AbilityTargetingRules;
|
||||||
|
readonly castRules?: AbilityCastRules;
|
||||||
|
readonly runeCost?: AbilityRuneCost;
|
||||||
|
readonly ai?: AbilityAiHints;
|
||||||
readonly passive?: boolean;
|
readonly passive?: boolean;
|
||||||
readonly source?: "core" | "wow335-progression" | "ascension-progression" | "coa-starter" | "coa-progression" | "coa-tree" | "runewaker";
|
readonly source?: "core" | "wow335-progression" | "ascension-progression" | "coa-starter" | "coa-progression" | "coa-tree" | "runewaker";
|
||||||
readonly romSkillGroup?: RomSkillGroup;
|
readonly romSkillGroup?: RomSkillGroup;
|
||||||
@@ -793,6 +870,9 @@ export function abilityAtLevel(
|
|||||||
cost: rank.cost,
|
cost: rank.cost,
|
||||||
costs: rank.costs ?? [rank.cost],
|
costs: rank.costs ?? [rank.cost],
|
||||||
effects: rank.effects,
|
effects: rank.effects,
|
||||||
|
targeting: rank.targeting ?? ability.targeting,
|
||||||
|
castRules: rank.castRules ?? ability.castRules,
|
||||||
|
runeCost: rank.runeCost ?? ability.runeCost,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -6,10 +6,15 @@ interface CoaAbilityRuntimeSnapshot {
|
|||||||
readonly schemaVersion: 1;
|
readonly schemaVersion: 1;
|
||||||
readonly abilitiesByClass: Readonly<Record<CoaClassId, readonly AbilityDefinition[]>>;
|
readonly abilitiesByClass: Readonly<Record<CoaClassId, readonly AbilityDefinition[]>>;
|
||||||
readonly resourcesByClass: Readonly<Record<CoaClassId, ClassResourceProfile>>;
|
readonly resourcesByClass: Readonly<Record<CoaClassId, ClassResourceProfile>>;
|
||||||
|
readonly triggeredAbilitiesBySpellId: Readonly<Record<number, AbilityDefinition>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = generated as unknown as CoaAbilityRuntimeSnapshot;
|
const snapshot = generated as unknown as CoaAbilityRuntimeSnapshot;
|
||||||
|
|
||||||
export const COA_RUNTIME_ABILITIES_BY_CLASS = snapshot.abilitiesByClass;
|
export const COA_RUNTIME_ABILITIES_BY_CLASS = snapshot.abilitiesByClass;
|
||||||
export const COA_RUNTIME_CLASS_RESOURCES = snapshot.resourcesByClass;
|
export const COA_RUNTIME_CLASS_RESOURCES = snapshot.resourcesByClass;
|
||||||
|
export const COA_RUNTIME_TRIGGERED_ABILITIES_BY_SPELL_ID = snapshot.triggeredAbilitiesBySpellId;
|
||||||
|
|
||||||
|
export function coaTriggeredAbilityBySpellId(spellId: number): AbilityDefinition | null {
|
||||||
|
return COA_RUNTIME_TRIGGERED_ABILITIES_BY_SPELL_ID[spellId] ?? null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
|
||||||
|
describe("Ascension Conquest combat runtime", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("heals a selected ally with an enemy-or-ally spell", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "cultist", raceId: "human", level: 20 });
|
||||||
|
usePartyStore.getState().initializeParty("coa-any-friendly", 20, useCombatStore.getState().health, "healer", 2, "coa");
|
||||||
|
const member = usePartyStore.getState().members[0]!;
|
||||||
|
usePartyStore.getState().damageMember(member.id, 500);
|
||||||
|
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
||||||
|
usePartyStore.getState().selectMember(member.id);
|
||||||
|
const injuredHealth = usePartyStore.getState().members[0]!.health;
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("cultist-gaze-of-cthun", undefined, 1_000)).toMatchObject({ ok: true });
|
||||||
|
|
||||||
|
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(injuredHealth);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("damages a selected enemy with the same enemy-or-ally spell", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "cultist", raceId: "human", level: 20 });
|
||||||
|
usePartyStore.getState().clearSelection();
|
||||||
|
useCombatStore.getState().registerMob("gaze-dummy", { name: "Gaze Dummy", maxHealth: 5_000 });
|
||||||
|
setMobPosition("gaze-dummy", [3, 0, 0]);
|
||||||
|
useCombatStore.getState().selectMob("gaze-dummy");
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("cultist-gaze-of-cthun", undefined, 1_000)).toMatchObject({ ok: true });
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().mobs["gaze-dummy"]!.health).toBeLessThan(5_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes a hidden DBC payload when a passive proc fires", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "pyromancer", raceId: "human", level: 20 });
|
||||||
|
usePartyStore.getState().initializeParty("coa-hidden-proc", 20, useCombatStore.getState().health, "healer", 2, "coa");
|
||||||
|
const member = usePartyStore.getState().members[0]!;
|
||||||
|
usePartyStore.getState().damageMember(member.id, 500);
|
||||||
|
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
||||||
|
usePartyStore.getState().selectMember(member.id);
|
||||||
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.name.includes("Phoenix Handler"))).toBe(true);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("coa-24-cinderheart-476-502044", undefined, 1_000)).toMatchObject({ ok: true });
|
||||||
|
useCombatStore.getState().tick(0.1, 4_100);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().auras.some((aura) => (
|
||||||
|
aura.definition.id === "coa:704865:effect:0:aura:23"
|
||||||
|
))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { resourceProfileForClass, type ResourceType } from "./abilityCatalog";
|
||||||
|
import {
|
||||||
|
normalizeActorResourcePool,
|
||||||
|
type CombatActorState,
|
||||||
|
} from "./combatActors";
|
||||||
|
import type { PartyMember } from "./partyStore";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the authoritative companion actor. Existing actor values win over
|
||||||
|
* the party-frame compatibility record once the actor has entered combat.
|
||||||
|
*/
|
||||||
|
export function companionActorFromMember(
|
||||||
|
member: PartyMember,
|
||||||
|
absorb = 0,
|
||||||
|
existing?: CombatActorState | null,
|
||||||
|
): CombatActorState {
|
||||||
|
const profile = resourceProfileForClass(member.classId);
|
||||||
|
const resources = existing?.kind === "companion"
|
||||||
|
? existing.resources
|
||||||
|
: Object.fromEntries(Object.entries(member.resourcePools).map(([id, current]) => [id,
|
||||||
|
normalizeActorResourcePool(
|
||||||
|
id,
|
||||||
|
current ?? 0,
|
||||||
|
id === member.resourceType ? member.maxResource : 100,
|
||||||
|
id === member.resourceType ? profile.regenerationPerSecond : 0,
|
||||||
|
member.lastResourceSpentAt[id as ResourceType] ?? 0,
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
const actor = existing?.kind === "companion" ? existing : null;
|
||||||
|
return Object.freeze({
|
||||||
|
id: member.id,
|
||||||
|
threatActorId: member.id,
|
||||||
|
kind: "companion" as const,
|
||||||
|
classId: member.classId,
|
||||||
|
level: member.level,
|
||||||
|
health: actor?.health ?? member.health,
|
||||||
|
maxHealth: actor?.maxHealth ?? member.maxHealth,
|
||||||
|
absorb,
|
||||||
|
resources,
|
||||||
|
cooldowns: actor?.cooldowns ?? member.cooldowns,
|
||||||
|
globalCooldownEndsAt: actor?.globalCooldownEndsAt ?? member.globalCooldownEndsAt,
|
||||||
|
cast: actor?.cast ?? member.activeCast,
|
||||||
|
controlledUntil: actor?.controlledUntil ?? member.controlledUntil,
|
||||||
|
schoolLockedUntil: actor?.schoolLockedUntil ?? member.schoolLockedUntil,
|
||||||
|
comboPoints: actor?.comboPoints ?? 0,
|
||||||
|
comboTargetId: actor?.comboTargetId ?? null,
|
||||||
|
runes: actor?.runes ?? member.runes,
|
||||||
|
abilityIds: actor?.abilityIds,
|
||||||
|
alive: (actor?.health ?? member.health) > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Party and scene consumers receive a read-compatible mirror of actor state. */
|
||||||
|
export function memberFromCompanionActor(member: PartyMember, actor?: CombatActorState | null): PartyMember {
|
||||||
|
if (!actor || actor.kind !== "companion") return member;
|
||||||
|
const resourcePools = Object.fromEntries(Object.entries(actor.resources).map(([id, pool]) => [id, pool.current])) as Partial<Record<ResourceType, number>>;
|
||||||
|
const lastResourceSpentAt = Object.fromEntries(Object.entries(actor.resources).map(([id, pool]) => [id, pool.lastSpentAt])) as Partial<Record<ResourceType, number>>;
|
||||||
|
const primary = actor.resources[member.resourceType];
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
health: actor.health,
|
||||||
|
maxHealth: actor.maxHealth,
|
||||||
|
resource: primary?.current ?? member.resource,
|
||||||
|
maxResource: primary?.maximum ?? member.maxResource,
|
||||||
|
resourcePools,
|
||||||
|
lastResourceSpentAt,
|
||||||
|
cooldowns: actor.cooldowns,
|
||||||
|
globalCooldownEndsAt: actor.globalCooldownEndsAt,
|
||||||
|
activeCast: actor.cast,
|
||||||
|
activeCastTargetId: actor.cast?.targetId ?? member.activeCastTargetId,
|
||||||
|
schoolLockedUntil: actor.schoolLockedUntil,
|
||||||
|
runes: actor.runes,
|
||||||
|
controlledUntil: actor.controlledUntil,
|
||||||
|
status: actor.health <= 0 ? "down" : member.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function companionActorWithMemberRuntime(
|
||||||
|
actor: CombatActorState,
|
||||||
|
member: PartyMember,
|
||||||
|
): CombatActorState {
|
||||||
|
const profile = resourceProfileForClass(member.classId);
|
||||||
|
const resources = Object.fromEntries(Object.entries(member.resourcePools).map(([id, current]) => [id,
|
||||||
|
normalizeActorResourcePool(
|
||||||
|
id,
|
||||||
|
current ?? 0,
|
||||||
|
id === member.resourceType ? member.maxResource : actor.resources[id]?.maximum ?? 100,
|
||||||
|
id === member.resourceType ? profile.regenerationPerSecond : actor.resources[id]?.regenerationPerSecond ?? 0,
|
||||||
|
member.lastResourceSpentAt[id as ResourceType] ?? actor.resources[id]?.lastSpentAt ?? 0,
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
return Object.freeze({
|
||||||
|
...actor,
|
||||||
|
health: member.health,
|
||||||
|
maxHealth: member.maxHealth,
|
||||||
|
resources,
|
||||||
|
cooldowns: member.cooldowns,
|
||||||
|
globalCooldownEndsAt: member.globalCooldownEndsAt,
|
||||||
|
cast: member.activeCast ? { ...member.activeCast, targetId: member.activeCastTargetId } : null,
|
||||||
|
controlledUntil: member.controlledUntil,
|
||||||
|
schoolLockedUntil: member.schoolLockedUntil,
|
||||||
|
runes: member.runes,
|
||||||
|
alive: member.health > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { ClassId } from "../app/characterCatalog";
|
||||||
|
import type { DamageSchool } from "./combatAuras";
|
||||||
|
|
||||||
|
export type CombatActorKind = "player" | "companion" | "enemy" | "pet" | "totem" | "summon";
|
||||||
|
export type CombatResourceId = string;
|
||||||
|
export type DeathKnightRuneType = "blood" | "frost" | "unholy" | "death";
|
||||||
|
export type CombatActorPosition = readonly [number, number, number];
|
||||||
|
|
||||||
|
export interface CombatResourcePoolState {
|
||||||
|
readonly id: CombatResourceId;
|
||||||
|
readonly current: number;
|
||||||
|
readonly maximum: number;
|
||||||
|
readonly regenerationPerSecond: number;
|
||||||
|
/** Mana regeneration observes the five-second rule from this timestamp. */
|
||||||
|
readonly lastSpentAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeathKnightRuneState {
|
||||||
|
readonly slot: number;
|
||||||
|
readonly baseType: Exclude<DeathKnightRuneType, "death">;
|
||||||
|
readonly type: DeathKnightRuneType;
|
||||||
|
readonly readyAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatActorCastState {
|
||||||
|
readonly abilityId: string;
|
||||||
|
readonly school: DamageSchool;
|
||||||
|
readonly mode: "cast" | "channel";
|
||||||
|
readonly startedAt: number;
|
||||||
|
readonly completesAt: number;
|
||||||
|
readonly originalDurationMs: number;
|
||||||
|
readonly pushbackHits: number;
|
||||||
|
/** Simulation-space origin used for movement cancellation and completion validation. */
|
||||||
|
readonly origin?: CombatActorPosition;
|
||||||
|
readonly targetId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renderer-free snapshot shared by every combatant. Specialized player, party,
|
||||||
|
* and mob records may retain presentation fields, but combat rules consume
|
||||||
|
* this common contract.
|
||||||
|
*/
|
||||||
|
export interface CombatActorState {
|
||||||
|
readonly id: string;
|
||||||
|
/** Stable id used by threat tables even when a renderer entity is replaced. */
|
||||||
|
readonly threatActorId?: string;
|
||||||
|
readonly kind: CombatActorKind;
|
||||||
|
readonly ownerId?: string;
|
||||||
|
readonly classId?: ClassId;
|
||||||
|
readonly level: number;
|
||||||
|
readonly health: number;
|
||||||
|
readonly maxHealth: number;
|
||||||
|
readonly absorb: number;
|
||||||
|
readonly resources: Readonly<Record<CombatResourceId, CombatResourcePoolState>>;
|
||||||
|
readonly cooldowns: Readonly<Record<string, number>>;
|
||||||
|
readonly globalCooldownEndsAt: number;
|
||||||
|
readonly cast: CombatActorCastState | null;
|
||||||
|
readonly controlledUntil: number;
|
||||||
|
readonly schoolLockedUntil: Readonly<Partial<Record<DamageSchool, number>>>;
|
||||||
|
readonly comboPoints: number;
|
||||||
|
readonly comboTargetId: string | null;
|
||||||
|
readonly runes: readonly DeathKnightRuneState[];
|
||||||
|
/** Executable catalog abilities owned by pets, summons, and companions. */
|
||||||
|
readonly abilityIds?: readonly string[];
|
||||||
|
readonly alive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDeathKnightRunes(now = 0): readonly DeathKnightRuneState[] {
|
||||||
|
const types = ["blood", "blood", "frost", "frost", "unholy", "unholy"] as const;
|
||||||
|
return Object.freeze(types.map((type, slot) => Object.freeze({
|
||||||
|
slot,
|
||||||
|
baseType: type,
|
||||||
|
type,
|
||||||
|
readyAt: Math.max(0, now),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeActorResourcePool(
|
||||||
|
id: CombatResourceId,
|
||||||
|
current: number,
|
||||||
|
maximum: number,
|
||||||
|
regenerationPerSecond = 0,
|
||||||
|
lastSpentAt = 0,
|
||||||
|
): CombatResourcePoolState {
|
||||||
|
const safeMaximum = Math.max(0, Number.isFinite(maximum) ? maximum : 0);
|
||||||
|
return Object.freeze({
|
||||||
|
id,
|
||||||
|
current: Math.max(0, Math.min(safeMaximum, Number.isFinite(current) ? current : 0)),
|
||||||
|
maximum: safeMaximum,
|
||||||
|
regenerationPerSecond: Math.max(0, Number.isFinite(regenerationPerSecond) ? regenerationPerSecond : 0),
|
||||||
|
lastSpentAt: Math.max(0, Number.isFinite(lastSpentAt) ? lastSpentAt : 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spendActorResource(
|
||||||
|
pool: CombatResourcePoolState,
|
||||||
|
amount: number,
|
||||||
|
now: number,
|
||||||
|
): CombatResourcePoolState | null {
|
||||||
|
const requested = Math.max(0, Number.isFinite(amount) ? amount : 0);
|
||||||
|
if (pool.current + Number.EPSILON < requested) return null;
|
||||||
|
return Object.freeze({
|
||||||
|
...pool,
|
||||||
|
current: Math.max(0, pool.current - requested),
|
||||||
|
lastSpentAt: Math.max(pool.lastSpentAt, Number.isFinite(now) ? now : 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreActorResource(
|
||||||
|
pool: CombatResourcePoolState,
|
||||||
|
amount: number,
|
||||||
|
): CombatResourcePoolState {
|
||||||
|
return Object.freeze({
|
||||||
|
...pool,
|
||||||
|
current: Math.min(pool.maximum, pool.current + Math.max(0, Number.isFinite(amount) ? amount : 0)),
|
||||||
|
});
|
||||||
|
}
|
||||||
+25
-4
@@ -143,6 +143,16 @@ export interface AuraDefinition {
|
|||||||
readonly stackBehavior?: AuraStackBehavior;
|
readonly stackBehavior?: AuraStackBehavior;
|
||||||
/** Defaults to true. False makes applications from all sources share one aura. */
|
/** Defaults to true. False makes applications from all sources share one aura. */
|
||||||
readonly uniqueBySource?: boolean;
|
readonly uniqueBySource?: boolean;
|
||||||
|
/** A target can have only one aura from an exclusive stance/form/seal/etc. group. */
|
||||||
|
readonly exclusiveGroup?: string;
|
||||||
|
/** Explicit non-numeric gameplay state such as stealth, tracking, flight, or water walking. */
|
||||||
|
readonly utilityTags?: readonly string[];
|
||||||
|
/** Enemy control mechanics ignored while this aura is active. */
|
||||||
|
readonly controlImmunities?: readonly ("fear" | "sleep" | "root" | "stun" | "silence")[];
|
||||||
|
/** Chance from 0 through 1 to reflect an incoming spell. */
|
||||||
|
readonly reflectSpellChance?: number;
|
||||||
|
/** Portion of incoming target damage transferred to the aura source. */
|
||||||
|
readonly redirectDamagePercent?: number;
|
||||||
readonly dispelCategory?: DispelCategory;
|
readonly dispelCategory?: DispelCategory;
|
||||||
readonly dispelPriority?: number;
|
readonly dispelPriority?: number;
|
||||||
readonly modifiers?: readonly AuraModifier[];
|
readonly modifiers?: readonly AuraModifier[];
|
||||||
@@ -309,14 +319,25 @@ export function applyAura(
|
|||||||
const maxStacks = boundedPositiveInteger(definition.maxStacks, 1);
|
const maxStacks = boundedPositiveInteger(definition.maxStacks, 1);
|
||||||
const requestedStacks = Math.min(maxStacks, boundedPositiveInteger(request.stacks, 1));
|
const requestedStacks = Math.min(maxStacks, boundedPositiveInteger(request.stacks, 1));
|
||||||
const instanceId = auraIdentity(definition, request.sourceId, request.targetId);
|
const instanceId = auraIdentity(definition, request.sourceId, request.targetId);
|
||||||
const existingIndex = current.findIndex((aura) => aura.instanceId === instanceId);
|
const exclusive = definition.exclusiveGroup
|
||||||
|
? current.filter((aura) => (
|
||||||
|
aura.targetId === request.targetId
|
||||||
|
&& aura.definition.exclusiveGroup === definition.exclusiveGroup
|
||||||
|
&& aura.instanceId !== instanceId
|
||||||
|
))
|
||||||
|
: [];
|
||||||
|
const exclusiveIds = new Set(exclusive.map((aura) => aura.instanceId));
|
||||||
|
const base = exclusiveIds.size > 0
|
||||||
|
? current.filter((aura) => !exclusiveIds.has(aura.instanceId))
|
||||||
|
: current;
|
||||||
|
const existingIndex = base.findIndex((aura) => aura.instanceId === instanceId);
|
||||||
|
|
||||||
if (existingIndex < 0) {
|
if (existingIndex < 0) {
|
||||||
const aura = makeAura(request, requestedStacks);
|
const aura = makeAura(request, requestedStacks);
|
||||||
return { auras: [...current, aura], aura };
|
return { auras: [...base, aura], aura, ...(exclusive[0] ? { replaced: exclusive[0] } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = current[existingIndex];
|
const existing = base[existingIndex];
|
||||||
const behavior = definition.stackBehavior ?? "refresh";
|
const behavior = definition.stackBehavior ?? "refresh";
|
||||||
const stacks = behavior === "add"
|
const stacks = behavior === "add"
|
||||||
? Math.min(maxStacks, existing.stacks + requestedStacks)
|
? Math.min(maxStacks, existing.stacks + requestedStacks)
|
||||||
@@ -333,7 +354,7 @@ export function applyAura(
|
|||||||
procStates: existing.procStates,
|
procStates: existing.procStates,
|
||||||
}
|
}
|
||||||
: replacement;
|
: replacement;
|
||||||
const auras = [...current];
|
const auras = [...base];
|
||||||
auras[existingIndex] = aura;
|
auras[existingIndex] = aura;
|
||||||
return { auras, aura, replaced: existing };
|
return { auras, aura, replaced: existing };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { DamageSchool } from "./combatAuras";
|
||||||
|
|
||||||
|
export type StructuredCombatEventKind =
|
||||||
|
| "damage"
|
||||||
|
| "healing"
|
||||||
|
| "absorb"
|
||||||
|
| "resource"
|
||||||
|
| "aura"
|
||||||
|
| "cast"
|
||||||
|
| "interrupt"
|
||||||
|
| "death"
|
||||||
|
| "resurrection";
|
||||||
|
|
||||||
|
export interface StructuredCombatEvent {
|
||||||
|
readonly id: number;
|
||||||
|
readonly occurredAt: number;
|
||||||
|
readonly kind: StructuredCombatEventKind;
|
||||||
|
readonly sourceActorId: string;
|
||||||
|
readonly targetActorId: string;
|
||||||
|
readonly abilityId?: string;
|
||||||
|
readonly school?: DamageSchool;
|
||||||
|
readonly rawAmount: number;
|
||||||
|
readonly effectiveAmount: number;
|
||||||
|
readonly overheal: number;
|
||||||
|
readonly absorbed: number;
|
||||||
|
readonly critical: boolean;
|
||||||
|
readonly outcome?: string;
|
||||||
|
}
|
||||||
|
export type CombatEventInput = Omit<StructuredCombatEvent, "id">;
|
||||||
|
|
||||||
|
export const MAX_COMBAT_EVENT_HISTORY = 200;
|
||||||
|
|
||||||
|
export function appendCombatEvent(
|
||||||
|
events: readonly StructuredCombatEvent[],
|
||||||
|
event: CombatEventInput,
|
||||||
|
id: number,
|
||||||
|
maximum = MAX_COMBAT_EVENT_HISTORY,
|
||||||
|
): readonly StructuredCombatEvent[] {
|
||||||
|
const next = Object.freeze({
|
||||||
|
...event,
|
||||||
|
id,
|
||||||
|
occurredAt: Math.max(0, Number.isFinite(event.occurredAt) ? event.occurredAt : 0),
|
||||||
|
rawAmount: Math.max(0, Number.isFinite(event.rawAmount) ? event.rawAmount : 0),
|
||||||
|
effectiveAmount: Math.max(0, Number.isFinite(event.effectiveAmount) ? event.effectiveAmount : 0),
|
||||||
|
overheal: Math.max(0, Number.isFinite(event.overheal) ? event.overheal : 0),
|
||||||
|
absorbed: Math.max(0, Number.isFinite(event.absorbed) ? event.absorbed : 0),
|
||||||
|
});
|
||||||
|
return Object.freeze([...events, next].slice(-Math.max(1, Math.trunc(maximum))));
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveHealing } from "./combatHealing";
|
||||||
|
|
||||||
|
describe("combat healing resolution", () => {
|
||||||
|
it("reports critical raw healing, effective healing, and overheal independently", () => {
|
||||||
|
expect(resolveHealing(100, 950, 1_000, 0.25, 0.1)).toEqual({
|
||||||
|
rawAmount: 150,
|
||||||
|
effectiveAmount: 50,
|
||||||
|
overheal: 100,
|
||||||
|
critical: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not resurrect a zero-health actor implicitly", () => {
|
||||||
|
const result = resolveHealing(100, 0, 1_000, 0, 1);
|
||||||
|
expect(result).toMatchObject({ rawAmount: 100, effectiveAmount: 100, overheal: 0, critical: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export interface HealingResolution {
|
||||||
|
readonly rawAmount: number;
|
||||||
|
readonly effectiveAmount: number;
|
||||||
|
readonly overheal: number;
|
||||||
|
readonly critical: boolean;
|
||||||
|
}
|
||||||
|
export function resolveHealing(
|
||||||
|
amount: number,
|
||||||
|
currentHealth: number,
|
||||||
|
maximumHealth: number,
|
||||||
|
criticalChance = 0,
|
||||||
|
roll = 1,
|
||||||
|
criticalMultiplier = 1.5,
|
||||||
|
): HealingResolution {
|
||||||
|
const critical = Number.isFinite(roll) && roll < Math.max(0, Math.min(1, criticalChance));
|
||||||
|
const rawAmount = Math.max(0, Math.round(
|
||||||
|
(Number.isFinite(amount) ? amount : 0) * (critical ? Math.max(1, criticalMultiplier) : 1),
|
||||||
|
));
|
||||||
|
const missing = Math.max(0, (Number.isFinite(maximumHealth) ? maximumHealth : 0)
|
||||||
|
- (Number.isFinite(currentHealth) ? currentHealth : 0));
|
||||||
|
const effectiveAmount = Math.min(rawAmount, missing);
|
||||||
|
return Object.freeze({
|
||||||
|
rawAmount,
|
||||||
|
effectiveAmount,
|
||||||
|
overheal: rawAmount - effectiveAmount,
|
||||||
|
critical,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createDeathKnightRunes, normalizeActorResourcePool } from "./combatActors";
|
||||||
|
import {
|
||||||
|
activateDeathKnightRunes,
|
||||||
|
advanceCombatResourcePool,
|
||||||
|
spendDeathKnightRunes,
|
||||||
|
} from "./combatResources";
|
||||||
|
|
||||||
|
describe("authentic combat resources", () => {
|
||||||
|
it("suppresses spirit mana regeneration for five seconds after spending", () => {
|
||||||
|
const mana = normalizeActorResourcePool("mana", 50, 100, 12, 1_000);
|
||||||
|
const insideRule = advanceCombatResourcePool(mana, {
|
||||||
|
inCombat: true,
|
||||||
|
actorLevel: 80,
|
||||||
|
now: 5_999,
|
||||||
|
deltaSeconds: 1,
|
||||||
|
manaSpiritRegenerationPerSecond: 8,
|
||||||
|
});
|
||||||
|
const outsideRule = advanceCombatResourcePool(mana, {
|
||||||
|
inCombat: true,
|
||||||
|
actorLevel: 80,
|
||||||
|
now: 6_000,
|
||||||
|
deltaSeconds: 1,
|
||||||
|
manaSpiritRegenerationPerSecond: 8,
|
||||||
|
});
|
||||||
|
expect(insideRule.current).toBe(54);
|
||||||
|
expect(outsideRule.current).toBe(62);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decays rage and runic power only out of combat", () => {
|
||||||
|
const rage = normalizeActorResourcePool("rage", 20, 100);
|
||||||
|
const runic = normalizeActorResourcePool("runic-power", 20, 100);
|
||||||
|
expect(advanceCombatResourcePool(rage, { inCombat: false, actorLevel: 80, now: 1, deltaSeconds: 1 }).current).toBe(17);
|
||||||
|
expect(advanceCombatResourcePool(runic, { inCombat: false, actorLevel: 80, now: 1, deltaSeconds: 1 }).current).toBe(17.5);
|
||||||
|
expect(advanceCombatResourcePool(rage, { inCombat: true, actorLevel: 80, now: 1, deltaSeconds: 1 }).current).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recharges six Death Knight runes independently", () => {
|
||||||
|
const initial = createDeathKnightRunes();
|
||||||
|
const first = spendDeathKnightRunes(initial, { frost: 1, unholy: 1 }, 1_000)!;
|
||||||
|
expect(first).toHaveLength(6);
|
||||||
|
expect(first.filter((rune) => rune.readyAt === 11_000)).toHaveLength(2);
|
||||||
|
expect(spendDeathKnightRunes(first, { frost: 2 }, 1_001)).toBeNull();
|
||||||
|
const activated = activateDeathKnightRunes(first, 1);
|
||||||
|
expect(activated.filter((rune) => rune.readyAt === 0)).toHaveLength(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
createDeathKnightRunes,
|
||||||
|
type CombatResourcePoolState,
|
||||||
|
type DeathKnightRuneState,
|
||||||
|
type DeathKnightRuneType,
|
||||||
|
} from "./combatActors";
|
||||||
|
|
||||||
|
export const MANA_FIVE_SECOND_RULE_MS = 5_000;
|
||||||
|
export const DEATH_KNIGHT_RUNE_RECHARGE_MS = 10_000;
|
||||||
|
|
||||||
|
export interface CombatResourceAdvanceRules {
|
||||||
|
readonly inCombat: boolean;
|
||||||
|
readonly actorLevel: number;
|
||||||
|
readonly now: number;
|
||||||
|
readonly deltaSeconds: number;
|
||||||
|
readonly manaSpiritRegenerationPerSecond?: number;
|
||||||
|
}
|
||||||
|
export function advanceCombatResourcePool(
|
||||||
|
pool: CombatResourcePoolState,
|
||||||
|
rules: CombatResourceAdvanceRules,
|
||||||
|
): CombatResourcePoolState {
|
||||||
|
const delta = Math.max(0, Math.min(1, Number.isFinite(rules.deltaSeconds) ? rules.deltaSeconds : 0));
|
||||||
|
let rate = pool.regenerationPerSecond;
|
||||||
|
if (pool.id === "mana" && rules.now - pool.lastSpentAt < MANA_FIVE_SECOND_RULE_MS) {
|
||||||
|
rate = Math.max(0, rate - Math.max(0, rules.manaSpiritRegenerationPerSecond ?? 0));
|
||||||
|
}
|
||||||
|
if ((pool.id === "rage" || pool.id === "runic-power") && !rules.inCombat) {
|
||||||
|
rate = pool.id === "rage" ? -3 : -2.5;
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
...pool,
|
||||||
|
current: Math.max(0, Math.min(pool.maximum, pool.current + rate * delta)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuneCost {
|
||||||
|
readonly blood?: number;
|
||||||
|
readonly frost?: number;
|
||||||
|
readonly unholy?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function availableDeathKnightRunes(
|
||||||
|
runes: readonly DeathKnightRuneState[],
|
||||||
|
now: number,
|
||||||
|
): readonly DeathKnightRuneState[] {
|
||||||
|
return runes.filter((rune) => rune.readyAt <= now);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runeMatches(rune: DeathKnightRuneState, type: Exclude<DeathKnightRuneType, "death">): boolean {
|
||||||
|
return rune.type === type || rune.type === "death";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spendDeathKnightRunes(
|
||||||
|
runes: readonly DeathKnightRuneState[] | undefined,
|
||||||
|
cost: RuneCost,
|
||||||
|
now: number,
|
||||||
|
rechargeMs = DEATH_KNIGHT_RUNE_RECHARGE_MS,
|
||||||
|
): readonly DeathKnightRuneState[] | null {
|
||||||
|
const current = runes?.length === 6 ? [...runes] : [...createDeathKnightRunes(now)];
|
||||||
|
const spent = new Set<number>();
|
||||||
|
for (const type of ["blood", "frost", "unholy"] as const) {
|
||||||
|
const count = Math.max(0, Math.trunc(cost[type] ?? 0));
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const candidate = current.find((rune) => (
|
||||||
|
!spent.has(rune.slot) && rune.readyAt <= now && runeMatches(rune, type)
|
||||||
|
));
|
||||||
|
if (!candidate) return null;
|
||||||
|
spent.add(candidate.slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.freeze(current.map((rune) => spent.has(rune.slot)
|
||||||
|
? Object.freeze({ ...rune, readyAt: now + Math.max(1, rechargeMs) })
|
||||||
|
: rune));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activateDeathKnightRunes(
|
||||||
|
runes: readonly DeathKnightRuneState[],
|
||||||
|
count: number,
|
||||||
|
): readonly DeathKnightRuneState[] {
|
||||||
|
let remaining = Math.max(0, Math.trunc(count));
|
||||||
|
return Object.freeze([...runes]
|
||||||
|
.sort((left, right) => right.readyAt - left.readyAt || left.slot - right.slot)
|
||||||
|
.map((rune) => {
|
||||||
|
if (remaining <= 0 || rune.readyAt <= 0) return rune;
|
||||||
|
remaining -= 1;
|
||||||
|
return Object.freeze({ ...rune, readyAt: 0 });
|
||||||
|
})
|
||||||
|
.sort((left, right) => left.slot - right.slot));
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { CLASSES, type ClassId } from "../app/characterCatalog";
|
import { CLASSES, type ClassId } from "../app/characterCatalog";
|
||||||
import { COA_ABILITIES_BY_CLASS, COA_CLASS_RESOURCES } from "./coaAbilityCatalog";
|
import {
|
||||||
|
COA_ABILITIES_BY_CLASS,
|
||||||
|
COA_CLASS_RESOURCES,
|
||||||
|
COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||||
|
} from "./coaAbilityCatalog";
|
||||||
import {
|
import {
|
||||||
COA_RUNTIME_ABILITIES_BY_CLASS,
|
COA_RUNTIME_ABILITIES_BY_CLASS,
|
||||||
COA_RUNTIME_CLASS_RESOURCES,
|
COA_RUNTIME_CLASS_RESOURCES,
|
||||||
|
COA_RUNTIME_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||||
} from "./coaAbilityRuntimeCatalog";
|
} from "./coaAbilityRuntimeCatalog";
|
||||||
import generatedTalentRuntime from "./generated/talentRuntimeCatalog.json";
|
import generatedTalentRuntime from "./generated/talentRuntimeCatalog.json";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +26,7 @@ describe("compact combat runtime catalogs", () => {
|
|||||||
it("preserves every derived COA ability and resource profile", () => {
|
it("preserves every derived COA ability and resource profile", () => {
|
||||||
expect(COA_RUNTIME_ABILITIES_BY_CLASS).toEqual(COA_ABILITIES_BY_CLASS);
|
expect(COA_RUNTIME_ABILITIES_BY_CLASS).toEqual(COA_ABILITIES_BY_CLASS);
|
||||||
expect(COA_RUNTIME_CLASS_RESOURCES).toEqual(COA_CLASS_RESOURCES);
|
expect(COA_RUNTIME_CLASS_RESOURCES).toEqual(COA_CLASS_RESOURCES);
|
||||||
|
expect(COA_RUNTIME_TRIGGERED_ABILITIES_BY_SPELL_ID).toEqual(COA_TRIGGERED_ABILITIES_BY_SPELL_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves every talent field used by runtime allocation and modifiers", () => {
|
it("preserves every talent field used by runtime allocation and modifiers", () => {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
combatHasLineOfSight,
|
||||||
|
isPositionInFacingArc,
|
||||||
|
isSourceBehindTarget,
|
||||||
|
projectCombatGroundPoint,
|
||||||
|
registerCombatSpatialProvider,
|
||||||
|
resetCombatSpatialProvider,
|
||||||
|
} from "./combatSpatial";
|
||||||
|
|
||||||
|
describe("renderer-independent combat spatial queries", () => {
|
||||||
|
afterEach(resetCombatSpatialProvider);
|
||||||
|
|
||||||
|
it("validates facing and behind arcs", () => {
|
||||||
|
const actor = { id: "actor", position: [0, 0, 0] as const, yaw: 0 };
|
||||||
|
expect(isPositionInFacingArc(actor, [0, 0, 5])).toBe(true);
|
||||||
|
expect(isPositionInFacingArc(actor, [0, 0, -5])).toBe(false);
|
||||||
|
expect(isSourceBehindTarget([0, 0, -2], actor)).toBe(true);
|
||||||
|
expect(isSourceBehindTarget([0, 0, 2], actor)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates line of sight and collision-safe ground projection", () => {
|
||||||
|
registerCombatSpatialProvider({
|
||||||
|
actor: () => null,
|
||||||
|
hasLineOfSight: (_source, target) => target[0] >= 0,
|
||||||
|
projectGroundPoint: (requested) => requested[1] > 5 ? null : [requested[0], 0, requested[2]],
|
||||||
|
});
|
||||||
|
expect(combatHasLineOfSight([0, 0, 0], [2, 0, 0])).toBe(true);
|
||||||
|
expect(combatHasLineOfSight([0, 0, 0], [-2, 0, 0])).toBe(false);
|
||||||
|
expect(projectCombatGroundPoint([2, 3, 4], 10)).toEqual([2, 0, 4]);
|
||||||
|
expect(projectCombatGroundPoint([2, 8, 4], 10)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
export type CombatSpatialPosition = readonly [x: number, y: number, z: number];
|
||||||
|
|
||||||
|
export interface CombatSpatialActorSnapshot {
|
||||||
|
readonly id: string;
|
||||||
|
readonly position: CombatSpatialPosition;
|
||||||
|
/** World-space yaw in radians; +Z is forward. */
|
||||||
|
readonly yaw: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatSpatialProvider {
|
||||||
|
readonly actor: (actorId: string) => CombatSpatialActorSnapshot | null;
|
||||||
|
readonly hasLineOfSight: (
|
||||||
|
source: CombatSpatialPosition,
|
||||||
|
target: CombatSpatialPosition,
|
||||||
|
) => boolean;
|
||||||
|
readonly projectGroundPoint: (
|
||||||
|
requested: CombatSpatialPosition,
|
||||||
|
maximumDistance: number,
|
||||||
|
origin?: CombatSpatialPosition,
|
||||||
|
) => CombatSpatialPosition | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let provider: CombatSpatialProvider | null = null;
|
||||||
|
|
||||||
|
/** Registers the loaded dungeon's read-only spatial query seam. */
|
||||||
|
export function registerCombatSpatialProvider(next: CombatSpatialProvider): () => void {
|
||||||
|
provider = next;
|
||||||
|
return () => {
|
||||||
|
if (provider === next) provider = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetCombatSpatialProvider(): void {
|
||||||
|
provider = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function combatSpatialActor(actorId: string): CombatSpatialActorSnapshot | null {
|
||||||
|
return provider?.actor(actorId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function combatHasLineOfSight(
|
||||||
|
source: CombatSpatialPosition,
|
||||||
|
target: CombatSpatialPosition,
|
||||||
|
): boolean {
|
||||||
|
return provider?.hasLineOfSight(source, target) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectCombatGroundPoint(
|
||||||
|
requested: CombatSpatialPosition,
|
||||||
|
maximumDistance: number,
|
||||||
|
origin?: CombatSpatialPosition,
|
||||||
|
): CombatSpatialPosition | null {
|
||||||
|
if (!requested.every(Number.isFinite) || !Number.isFinite(maximumDistance) || maximumDistance < 0) return null;
|
||||||
|
return provider ? provider.projectGroundPoint(requested, maximumDistance, origin) : requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedPlanarDirection(
|
||||||
|
from: CombatSpatialPosition,
|
||||||
|
to: CombatSpatialPosition,
|
||||||
|
): readonly [number, number] | null {
|
||||||
|
const dx = to[0] - from[0];
|
||||||
|
const dz = to[2] - from[2];
|
||||||
|
const length = Math.hypot(dx, dz);
|
||||||
|
return length > 1e-5 ? [dx / length, dz / length] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPositionInFacingArc(
|
||||||
|
source: CombatSpatialActorSnapshot,
|
||||||
|
target: CombatSpatialPosition,
|
||||||
|
arcRadians = Math.PI,
|
||||||
|
): boolean {
|
||||||
|
const direction = normalizedPlanarDirection(source.position, target);
|
||||||
|
if (!direction) return true;
|
||||||
|
const forwardX = Math.sin(source.yaw);
|
||||||
|
const forwardZ = Math.cos(source.yaw);
|
||||||
|
const cosine = forwardX * direction[0] + forwardZ * direction[1];
|
||||||
|
return cosine >= Math.cos(Math.max(0, Math.min(Math.PI * 2, arcRadians)) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSourceBehindTarget(
|
||||||
|
source: CombatSpatialPosition,
|
||||||
|
target: CombatSpatialActorSnapshot,
|
||||||
|
rearArcRadians = Math.PI,
|
||||||
|
): boolean {
|
||||||
|
return !isPositionInFacingArc(target, source, Math.PI * 2 - rearArcRadians);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
EMPTY_CORPSE_DESPAWN_MS,
|
EMPTY_CORPSE_DESPAWN_MS,
|
||||||
MINIMUM_CORPSE_VISIBLE_MS,
|
MINIMUM_CORPSE_VISIBLE_MS,
|
||||||
MOB_WOUND_ANIMATION_COOLDOWN_MS,
|
MOB_WOUND_ANIMATION_COOLDOWN_MS,
|
||||||
|
PLAYER_AURA_ENTITY_ID,
|
||||||
selectedMob,
|
selectedMob,
|
||||||
useCombatStore,
|
useCombatStore,
|
||||||
} from "./combatStore";
|
} from "./combatStore";
|
||||||
@@ -90,7 +91,7 @@ describe("combat store", () => {
|
|||||||
expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
|
expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
|
||||||
useCombatStore.getState().tick(0.1, 2500);
|
useCombatStore.getState().tick(0.1, 2500);
|
||||||
expect(useCombatStore.getState().mobs.ravager.health).toBeLessThan(100);
|
expect(useCombatStore.getState().mobs.ravager.health).toBeLessThan(100);
|
||||||
expect(useCombatStore.getState().resource).toBe(startingResource - 14);
|
expect(useCombatStore.getState().resource).toBe(startingResource - Math.round(startingResource * 0.14));
|
||||||
expect(useCombatStore.getState().activeCast).toBeNull();
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||||
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
|
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -124,6 +125,20 @@ describe("combat store", () => {
|
|||||||
expect(useCombatStore.getState().setActionBinding("primary", "face-bottom", abilityId)).toBe(false);
|
expect(useCombatStore.getState().setActionBinding("primary", "face-bottom", abilityId)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires an explicit resurrection action to restore a defeated player", () => {
|
||||||
|
const maxHealth = useCombatStore.getState().maxHealth;
|
||||||
|
useCombatStore.getState().damagePlayer(maxHealth * 10);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(useCombatStore.getState().healPlayer(maxHealth)).toBe(0);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
|
||||||
|
const restored = useCombatStore.getState().revivePlayer(0.35);
|
||||||
|
expect(restored).toBe(Math.max(1, Math.round(maxHealth * 0.35)));
|
||||||
|
expect(useCombatStore.getState().health).toBe(restored);
|
||||||
|
expect(useCombatStore.getState().revivePlayer(1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("starts with attribute-derived pools including racial and automatic class passives", () => {
|
it("starts with attribute-derived pools including racial and automatic class passives", () => {
|
||||||
const gnomeMage = deriveCharacterStats("mage", 1, EMPTY_ITEM_STATS, "gnome");
|
const gnomeMage = deriveCharacterStats("mage", 1, EMPTY_ITEM_STATS, "gnome");
|
||||||
useCombatStore.getState().initializeCharacter({ classId: "mage", raceId: "gnome", level: 1 });
|
useCombatStore.getState().initializeCharacter({ classId: "mage", raceId: "gnome", level: 1 });
|
||||||
@@ -936,7 +951,7 @@ describe("combat store", () => {
|
|||||||
expect(useCombatStore.getState().castSlot(0, undefined, 10_000).ok).toBe(true);
|
expect(useCombatStore.getState().castSlot(0, undefined, 10_000).ok).toBe(true);
|
||||||
const boltCompletion = useCombatStore.getState().activeCast!.completesAt;
|
const boltCompletion = useCombatStore.getState().activeCast!.completesAt;
|
||||||
useCombatStore.getState().tick(0.1, boltCompletion);
|
useCombatStore.getState().tick(0.1, boltCompletion);
|
||||||
expect(useCombatStore.getState().resource).toBe(startingMana - 9);
|
expect(useCombatStore.getState().resource).toBe(startingMana - Math.round(startingMana * 0.1 * 0.85));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes a cast-time direct heal to the selected living party member", () => {
|
it("routes a cast-time direct heal to the selected living party member", () => {
|
||||||
@@ -958,6 +973,154 @@ describe("combat store", () => {
|
|||||||
expect(useCombatStore.getState().selectedTargetId).toBe("hostile-selection");
|
expect(useCombatStore.getState().selectedTargetId).toBe("hostile-selection");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fully heals the selected ally with Lay on Hands and starts its cooldown", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 10 });
|
||||||
|
usePartyStore.getState().initializeParty("lay-on-hands-test", 10, useCombatStore.getState().health);
|
||||||
|
const memberId = preparePartyMember(0, [10, 0, 0], 1, true);
|
||||||
|
const memberBefore = usePartyStore.getState().members.find((member) => member.id === memberId)!;
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility(
|
||||||
|
"wow335-paladin-lay-on-hands",
|
||||||
|
[0, 0, 0],
|
||||||
|
1_000,
|
||||||
|
)).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
healing: memberBefore.maxHealth - 1,
|
||||||
|
});
|
||||||
|
expect(usePartyStore.getState().members.find((member) => member.id === memberId)?.health)
|
||||||
|
.toBe(memberBefore.maxHealth);
|
||||||
|
expect(useCombatStore.getState().cooldowns["wow335-paladin-lay-on-hands"])
|
||||||
|
.toBe(1_201_000);
|
||||||
|
expect(useCombatStore.getState().castAbility(
|
||||||
|
"wow335-paladin-lay-on-hands",
|
||||||
|
[0, 0, 0],
|
||||||
|
2_500,
|
||||||
|
)).toMatchObject({ ok: false, reason: "cooldown" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes the selected ally immune to physical attacks with Hand of Protection", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 10 });
|
||||||
|
usePartyStore.getState().initializeParty("hand-of-protection-test", 10, useCombatStore.getState().health);
|
||||||
|
const memberId = preparePartyMember(0, [0, 0, 0], 1, true);
|
||||||
|
const memberBefore = usePartyStore.getState().members.find((member) => member.id === memberId)!;
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility(
|
||||||
|
"wow335-paladin-hand-of-protection",
|
||||||
|
[0, 0, 0],
|
||||||
|
1_000,
|
||||||
|
)).toMatchObject({ ok: true });
|
||||||
|
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
||||||
|
targetId: memberId,
|
||||||
|
expiresAt: 7_000,
|
||||||
|
definition: expect.objectContaining({ name: expect.stringContaining("Hand of Protection") }),
|
||||||
|
}));
|
||||||
|
expect(useCombatStore.getState().cooldowns["wow335-paladin-hand-of-protection"])
|
||||||
|
.toBe(301_000);
|
||||||
|
|
||||||
|
useCombatStore.getState().registerMob("party-victim", {
|
||||||
|
name: "Party victim",
|
||||||
|
maxHealth: 200,
|
||||||
|
xpReward: 0,
|
||||||
|
});
|
||||||
|
setMobPosition("party-victim", [2, 0, 0]);
|
||||||
|
useCombatStore.getState().damageMob("party-victim", 5, 1_000, {
|
||||||
|
actorId: memberId,
|
||||||
|
role: "tank",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().advanceMobCombat(1_450)).toBe(0);
|
||||||
|
expect(usePartyStore.getState().members.find((member) => member.id === memberId)?.health)
|
||||||
|
.toBe(memberBefore.health);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears roots with Hand of Freedom and replaces mutually exclusive seals", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
|
||||||
|
usePartyStore.getState().initializeParty("paladin-aura-test", 80, useCombatStore.getState().health);
|
||||||
|
const memberId = preparePartyMember(0, [0, 0, 0], Number.MAX_SAFE_INTEGER, true);
|
||||||
|
usePartyStore.getState().controlMember(memberId, "root", 30_000);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-paladin-hand-of-freedom", [0, 0, 0], 1_000).ok).toBe(true);
|
||||||
|
expect(usePartyStore.getState().members.find((member) => member.id === memberId)).toMatchObject({
|
||||||
|
controlledUntil: 0,
|
||||||
|
controlMechanic: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
usePartyStore.getState().selectSelf();
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-paladin-seal-of-righteousness", [0, 0, 0], 3_000).ok).toBe(true);
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-paladin-seal-of-wisdom", [0, 0, 0], 5_000).ok).toBe(true);
|
||||||
|
const seals = useCombatStore.getState().auras.filter((aura) => (
|
||||||
|
aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.exclusiveGroup === "paladin:seal"
|
||||||
|
));
|
||||||
|
expect(seals).toHaveLength(1);
|
||||||
|
expect(seals[0].definition.name).toContain("Seal of Wisdom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects Hand of Sacrifice damage to the paladin", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
|
||||||
|
usePartyStore.getState().initializeParty("hand-of-sacrifice-test", 80, useCombatStore.getState().health);
|
||||||
|
const memberId = preparePartyMember(0, [0, 0, 0], Number.MAX_SAFE_INTEGER, true);
|
||||||
|
const memberBefore = usePartyStore.getState().members.find((member) => member.id === memberId)!;
|
||||||
|
const playerBefore = useCombatStore.getState().health;
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-paladin-hand-of-sacrifice", [0, 0, 0], 1_000).ok).toBe(true);
|
||||||
|
useCombatStore.getState().registerMob("sacrifice-attacker", { name: "Sacrifice attacker", maxHealth: 200, xpReward: 0 });
|
||||||
|
setMobPosition("sacrifice-attacker", [2, 0, 0]);
|
||||||
|
useCombatStore.getState().damageMob("sacrifice-attacker", 5, 1_000, { actorId: memberId, role: "tank" });
|
||||||
|
expect(useCombatStore.getState().advanceMobCombat(1_450)).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const memberLoss = memberBefore.health - usePartyStore.getState().members.find((member) => member.id === memberId)!.health;
|
||||||
|
const playerLoss = playerBefore - useCombatStore.getState().health;
|
||||||
|
expect(memberLoss).toBeGreaterThan(0);
|
||||||
|
expect(playerLoss).toBeGreaterThan(0);
|
||||||
|
expect(playerLoss).toBeLessThan(memberLoss);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("taunts up to three attackers off an ally with Righteous Defense", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
|
||||||
|
usePartyStore.getState().initializeParty("righteous-defense-test", 80, useCombatStore.getState().health);
|
||||||
|
const memberId = preparePartyMember(0, [0, 0, 0], Number.MAX_SAFE_INTEGER, true);
|
||||||
|
for (const id of ["attacker-a", "attacker-b", "attacker-c", "attacker-d"]) {
|
||||||
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100, xpReward: 0 });
|
||||||
|
setMobPosition(id, [2, 0, 0]);
|
||||||
|
}
|
||||||
|
useCombatStore.setState((state) => ({
|
||||||
|
mobs: Object.fromEntries(Object.entries(state.mobs).map(([id, mob]) => [id, {
|
||||||
|
...mob,
|
||||||
|
engaged: true,
|
||||||
|
targetActorId: memberId,
|
||||||
|
threatByActor: { [memberId]: 10 },
|
||||||
|
}])),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-paladin-righteous-defense", [0, 0, 0], 1_000).ok).toBe(true);
|
||||||
|
expect(Object.values(useCombatStore.getState().mobs).filter((mob) => mob.forcedTarget?.actorId === PLAYER_AURA_ENTITY_ID)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("channels party-wide classic healing without requiring an enemy target", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||||
|
usePartyStore.getState().initializeParty("divine-hymn-test", 80, useCombatStore.getState().health);
|
||||||
|
const memberId = preparePartyMember(0, [4, 0, 0], 1);
|
||||||
|
useCombatStore.getState().damagePlayer(500);
|
||||||
|
const playerBefore = useCombatStore.getState().health;
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-priest-divine-hymn", [0, 0, 0], 1_000).ok).toBe(true);
|
||||||
|
const completion = useCombatStore.getState().activeCast?.completesAt;
|
||||||
|
if (completion) useCombatStore.getState().tick(1, completion);
|
||||||
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||||
|
expect(useCombatStore.getState().health).toBeGreaterThan(playerBefore);
|
||||||
|
expect(usePartyStore.getState().members.find((member) => member.id === memberId)!.health).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes intentional classic utility spells instead of rejecting them", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
||||||
|
useCombatStore.getState().damagePlayer(500);
|
||||||
|
const before = useCombatStore.getState().health;
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-mage-conjure-food", [0, 0, 0], 1_000).ok).toBe(true);
|
||||||
|
const completion = useCombatStore.getState().activeCast?.completesAt;
|
||||||
|
if (completion) useCombatStore.getState().tick(1, completion);
|
||||||
|
expect(useCombatStore.getState().health).toBeGreaterThan(before);
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to the player and snapshots an explicit null friendly target", () => {
|
it("falls back to the player and snapshots an explicit null friendly target", () => {
|
||||||
registerTarget("enemy", [10, 0, 0], 100, 0);
|
registerTarget("enemy", [10, 0, 0], 100, 0);
|
||||||
const memberId = preparePartyMember(0, [8, 0, 0], 1);
|
const memberId = preparePartyMember(0, [8, 0, 0], 1);
|
||||||
|
|||||||
+2870
-245
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ describe("death respawn", () => {
|
|||||||
useCombatStore.getState().selectMob("respawn-pack:a");
|
useCombatStore.getState().selectMob("respawn-pack:a");
|
||||||
useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000);
|
useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000);
|
||||||
expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true);
|
expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true);
|
||||||
expect(useCombatStore.getState().resource).toBe(startingResource - 15);
|
expect(useCombatStore.getState().resource).toBe(startingResource - Math.round(startingResource * 0.15));
|
||||||
|
|
||||||
const revisionBefore = useGameStore.getState().resetRevision;
|
const revisionBefore = useGameStore.getState().resetRevision;
|
||||||
useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 });
|
useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 });
|
||||||
|
|||||||
@@ -223,6 +223,23 @@ describe("generated dungeon campaign catalog", () => {
|
|||||||
.toEqual([-819.8267, 39.4232, -87.3708]);
|
.toEqual([-819.8267, 39.4232, -87.3708]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps Stockades creatures in the package's native world coordinates", () => {
|
||||||
|
const stockades = requireDungeonDefinition("stormwind-stockade");
|
||||||
|
const firstSpawn = stockades.staticSpawns.find(
|
||||||
|
(spawn) => spawn.id === "creature-79035",
|
||||||
|
);
|
||||||
|
const targorr = stockades.staticSpawns.find(
|
||||||
|
(spawn) => spawn.id === "creature-84027",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stockades.environmentMode).toBe("global-wmo");
|
||||||
|
expect(stockades.provenance.coordinateTransform)
|
||||||
|
.toBe("three(x,y,z)=draft(x,y,z)");
|
||||||
|
expect(stockades.entrance.footPosition).toEqual([-54.23, -18.34, 0.28]);
|
||||||
|
expect(firstSpawn?.position).toEqual([-82.3245, -26.4396, -10.6106]);
|
||||||
|
expect(targorr?.position).toEqual([-159.582, -25.6062, 1.2531]);
|
||||||
|
});
|
||||||
|
|
||||||
it("starts Razorfen Kraul inside the portal and maps its first pack into the WMO", () => {
|
it("starts Razorfen Kraul inside the portal and maps its first pack into the WMO", () => {
|
||||||
const razorfenKraul = requireDungeonDefinition("razorfen-kraul");
|
const razorfenKraul = requireDungeonDefinition("razorfen-kraul");
|
||||||
const firstPackSpawn = razorfenKraul.staticSpawns.find(
|
const firstPackSpawn = razorfenKraul.staticSpawns.find(
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
||||||
|
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
|
||||||
|
function registerTarget(id = "target", position: readonly [number, number, number] = [0, 0, 10]) {
|
||||||
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
|
||||||
|
setMobPosition(id, position);
|
||||||
|
useCombatStore.getState().selectMob(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dungeon combat fidelity integration", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetCombatSpatialProvider();
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 10 });
|
||||||
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||||
|
usePartyStore.getState().initializeParty("fidelity", 10, useCombatStore.getState().health);
|
||||||
|
});
|
||||||
|
afterEach(resetCombatSpatialProvider);
|
||||||
|
|
||||||
|
it("rejects obstructed and rear-arc hostile casts while friendly healing ignores facing", () => {
|
||||||
|
registerTarget();
|
||||||
|
let blocked = true;
|
||||||
|
registerCombatSpatialProvider({
|
||||||
|
actor: (id) => id === PLAYER_AGGRO_ID
|
||||||
|
? { id, position: [0, 0, 0], yaw: Math.PI }
|
||||||
|
: { id, position: getMobPosition(id) ?? [0, 0, 0], yaw: 0 },
|
||||||
|
hasLineOfSight: () => !blocked,
|
||||||
|
projectGroundPoint: (point) => point,
|
||||||
|
});
|
||||||
|
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 })).toMatchObject({ ok: false, reason: "line-of-sight" });
|
||||||
|
blocked = false;
|
||||||
|
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 })).toMatchObject({ ok: false, reason: "not-facing" });
|
||||||
|
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 10 });
|
||||||
|
const ally = usePartyStore.getState().members[0];
|
||||||
|
registerPartyRuntimePosition(ally.id, [0, 0, 10]);
|
||||||
|
usePartyStore.getState().selectMember(ally.id);
|
||||||
|
usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === ally.id ? { ...member, health: 1 } : member) }));
|
||||||
|
const friendlyResult = useCombatStore.getState().castAbility("wow335-priest-lesser-heal", { now: 2_000 });
|
||||||
|
expect(friendlyResult.reason).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies only the first two direct-damage pushbacks and ignores fully absorbed hits", () => {
|
||||||
|
registerTarget();
|
||||||
|
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 }).ok).toBe(true);
|
||||||
|
const original = useCombatStore.getState().activeCast!.completesAt;
|
||||||
|
useCombatStore.getState().damagePlayer(1);
|
||||||
|
useCombatStore.getState().damagePlayer(1);
|
||||||
|
useCombatStore.getState().damagePlayer(1);
|
||||||
|
expect(useCombatStore.getState().activeCast).toMatchObject({ completesAt: original + 1_000, pushbackHits: 2 });
|
||||||
|
|
||||||
|
useCombatStore.getState().cancelCast();
|
||||||
|
useCombatStore.setState({ globalCooldownEndsAt: 0, shield: 10_000, shieldExpiresAt: Number.MAX_SAFE_INTEGER });
|
||||||
|
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 5_000 }).ok).toBe(true);
|
||||||
|
const absorbedOriginal = useCombatStore.getState().activeCast!.completesAt;
|
||||||
|
useCombatStore.getState().damagePlayer(100);
|
||||||
|
expect(useCombatStore.getState().activeCast!.completesAt).toBe(absorbedOriginal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates persistent collision-safe ground effects and dynamically ticks actors in their radius", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
|
||||||
|
registerTarget("late-target", [20, 0, 0]);
|
||||||
|
registerCombatSpatialProvider({
|
||||||
|
actor: (id) => ({ id, position: id === PLAYER_AGGRO_ID ? [0, 0, 0] : getMobPosition(id) ?? [0, 0, 0], yaw: 0 }),
|
||||||
|
hasLineOfSight: () => true,
|
||||||
|
projectGroundPoint: (point) => [point[0], 0, point[2]],
|
||||||
|
});
|
||||||
|
const groundResult = useCombatStore.getState().castAbility("paladin-consecration", { now: 1_000, groundPosition: [0, 3, 0] });
|
||||||
|
expect(groundResult.reason).toBeUndefined();
|
||||||
|
expect(useCombatStore.getState().areaEffects).toHaveLength(1);
|
||||||
|
setMobPosition("late-target", [2, 0, 0]);
|
||||||
|
const before = useCombatStore.getState().mobs["late-target"].health;
|
||||||
|
useCombatStore.getState().tick(1, 2_000);
|
||||||
|
expect(useCombatStore.getState().mobs["late-target"].health).toBeLessThan(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spends independent Death Knight runes and exposes every combatant through actor snapshots", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 10 });
|
||||||
|
registerTarget("rune-target", [0, 0, 10]);
|
||||||
|
expect(useCombatStore.getState().castAbility("death-knight-icy-touch", { now: 1_000 }).ok).toBe(true);
|
||||||
|
expect(useCombatStore.getState().deathKnightRunes.filter((rune) => rune.readyAt > 1_000)).toHaveLength(1);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
const actors = useCombatStore.getState().actors;
|
||||||
|
expect(actors[PLAYER_AGGRO_ID].runes).toHaveLength(6);
|
||||||
|
expect(actors["rune-target"].kind).toBe("enemy");
|
||||||
|
for (const member of usePartyStore.getState().members) expect(actors[member.id].kind).toBe("companion");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits structured effective damage and healing records", () => {
|
||||||
|
registerTarget("event-target", [0, 0, 10]);
|
||||||
|
useCombatStore.getState().damageMob("event-target", 25, 1_000);
|
||||||
|
useCombatStore.getState().damagePlayer(10);
|
||||||
|
useCombatStore.getState().healPlayer(100);
|
||||||
|
const events = useCombatStore.getState().combatEvents;
|
||||||
|
expect(events.some((event) => event.kind === "damage" && event.targetActorId === "event-target" && event.effectiveAmount > 0)).toBe(true);
|
||||||
|
expect(events.some((event) => event.kind === "healing" && event.overheal >= 0)).toBe(true);
|
||||||
|
expect(events.every((event) => event.rawAmount >= event.effectiveAmount)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { abilitiesForClass, abilityAtLevel, abilityById, type AbilityDefinition } from "./abilityCatalog";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
||||||
|
import { targetingRulesForAbility } from "./abilityRules";
|
||||||
|
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
|
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
|
||||||
|
function registerMob(id: string, position: readonly [number, number, number], boss = false): void {
|
||||||
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 10_000, xpReward: 0, boss });
|
||||||
|
setMobPosition(id, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("extended dungeon combat fidelity", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetCombatSpatialProvider();
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||||
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||||
|
usePartyStore.getState().initializeParty("actor-authority", 80, useCombatStore.getState().health);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(resetCombatSpatialProvider);
|
||||||
|
|
||||||
|
it("keeps companion vitals authoritative when compatibility party records are stale", () => {
|
||||||
|
const member = usePartyStore.getState().members[0];
|
||||||
|
const actor = useCombatStore.getState().actors[member.id];
|
||||||
|
useCombatStore.setState((state) => ({
|
||||||
|
actors: { ...state.actors, [member.id]: { ...actor, health: 7, alive: true } },
|
||||||
|
}));
|
||||||
|
usePartyStore.setState((state) => ({
|
||||||
|
members: state.members.map((candidate) => candidate.id === member.id
|
||||||
|
? { ...candidate, health: candidate.maxHealth }
|
||||||
|
: candidate),
|
||||||
|
}));
|
||||||
|
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().actors[member.id].health).toBe(7);
|
||||||
|
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)?.health).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binds combo points to the enemy that generated them", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 20 });
|
||||||
|
registerMob("combo-a", [0, 0, 3]);
|
||||||
|
registerMob("combo-b", [1, 0, 2]);
|
||||||
|
useCombatStore.getState().selectMob("combo-a");
|
||||||
|
expect(useCombatStore.getState().castAbility("rogue-sinister-strike", { now: 1_000 }).ok).toBe(true);
|
||||||
|
expect(useCombatStore.getState()).toMatchObject({ comboPoints: 1, comboTargetId: "combo-a" });
|
||||||
|
|
||||||
|
useCombatStore.setState((state) => ({
|
||||||
|
globalCooldownEndsAt: 0,
|
||||||
|
cooldowns: {},
|
||||||
|
resource: state.maxResource,
|
||||||
|
resourcePools: { ...state.resourcePools, energy: state.maxResource },
|
||||||
|
}));
|
||||||
|
useCombatStore.getState().selectMob("combo-b");
|
||||||
|
const secondStrike = useCombatStore.getState().castAbility("rogue-sinister-strike", { now: 2_000 });
|
||||||
|
expect(secondStrike.ok, secondStrike.reason).toBe(true);
|
||||||
|
expect(useCombatStore.getState()).toMatchObject({ comboPoints: 1, comboTargetId: "combo-b" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies boss control and taunt diminishing returns through immunity", () => {
|
||||||
|
registerMob("dr-boss", [0, 0, 3], true);
|
||||||
|
const durations: number[] = [];
|
||||||
|
for (const now of [1_000, 2_000, 3_000]) {
|
||||||
|
expect(useCombatStore.getState().controlMob("dr-boss", `stun-${now}`, "stun", 8_000, now)).toBe(true);
|
||||||
|
const status = useCombatStore.getState().mobs["dr-boss"].statuses.find((entry) => entry.kind === "stun")!;
|
||||||
|
durations.push(status.endsAt - now);
|
||||||
|
}
|
||||||
|
expect(durations).toEqual([3_000, 1_500, 750]);
|
||||||
|
expect(useCombatStore.getState().controlMob("dr-boss", "stun-immune", "stun", 8_000, 4_000)).toBe(false);
|
||||||
|
|
||||||
|
const tauntDurations: number[] = [];
|
||||||
|
for (const now of [5_000, 6_000, 7_000]) {
|
||||||
|
expect(useCombatStore.getState().tauntMob("dr-boss", { actorId: PLAYER_AGGRO_ID, role: "tank" }, 3_000, now)).toBe(true);
|
||||||
|
tauntDurations.push(useCombatStore.getState().mobs["dr-boss"].forcedTarget!.endsAt - now);
|
||||||
|
}
|
||||||
|
expect(tauntDurations).toEqual([3_000, 1_950, 1_268]);
|
||||||
|
expect(useCombatStore.getState().tauntMob("dr-boss", { actorId: PLAYER_AGGRO_ID, role: "tank" }, 3_000, 8_000)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves and confirms a collision-projected ground reticle", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
||||||
|
const groundAbility = abilitiesForClass("mage")
|
||||||
|
.map((ability) => abilityAtLevel(ability, 80))
|
||||||
|
.find((ability) => targetingRulesForAbility(ability).shape === "ground" && ability.range.max > 1)!;
|
||||||
|
expect(groundAbility).toBeDefined();
|
||||||
|
registerCombatSpatialProvider({
|
||||||
|
actor: (id) => ({ id, position: [0, 0, 0], yaw: 0 }),
|
||||||
|
hasLineOfSight: () => true,
|
||||||
|
projectGroundPoint: (point, maximumDistance, origin = [0, 0, 0]) => {
|
||||||
|
const dx = point[0] - origin[0];
|
||||||
|
const dz = point[2] - origin[2];
|
||||||
|
const distance = Math.hypot(dx, dz);
|
||||||
|
const scale = distance > maximumDistance ? maximumDistance / distance : 1;
|
||||||
|
return [origin[0] + dx * scale, 0, origin[2] + dz * scale];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(useCombatStore.getState().beginGroundTarget(groundAbility.id)).toBe(true);
|
||||||
|
const before = useCombatStore.getState().pendingGroundTarget!;
|
||||||
|
expect(before.valid).toBe(true);
|
||||||
|
useCombatStore.getState().moveGroundTarget(1, 0, 0, 0.1);
|
||||||
|
const moved = useCombatStore.getState().pendingGroundTarget!;
|
||||||
|
expect(moved.position).not.toEqual(before.position);
|
||||||
|
expect(Math.hypot(moved.position[0], moved.position[2])).toBeLessThanOrEqual(moved.maximumRange);
|
||||||
|
expect(useCombatStore.getState().confirmGroundTarget(undefined, 1_000).ok).toBe(true);
|
||||||
|
expect(useCombatStore.getState().pendingGroundTarget).toBeNull();
|
||||||
|
const activeCast = useCombatStore.getState().activeCast;
|
||||||
|
if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt);
|
||||||
|
expect(useCombatStore.getState().areaEffects).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps summons targetable, credits their owner, replaces totems, and expires them", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "rom-warden", raceId: "rom-elf", level: 20 });
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
registerMob("summon-target", [0, 0, 3]);
|
||||||
|
useCombatStore.getState().selectMob("summon-target");
|
||||||
|
expect(useCombatStore.getState().castAbility("rom-warden-primary-1", { now: 1_000 }).ok).toBe(true);
|
||||||
|
useCombatStore.getState().tick(0.1, 5_000);
|
||||||
|
const summon = useCombatStore.getState().summons[0];
|
||||||
|
expect(summon).toBeDefined();
|
||||||
|
expect(useCombatStore.getState().actors[summon.id]).toMatchObject({ kind: "summon", ownerId: PLAYER_AGGRO_ID, alive: true });
|
||||||
|
expect(useCombatStore.getState().selectFriendlyActor(summon.id)).toBe(true);
|
||||||
|
|
||||||
|
const beforeDamage = summon.health;
|
||||||
|
expect(useCombatStore.getState().damageSummon(summon.id, 5)).toBe(5);
|
||||||
|
expect(useCombatStore.getState().actors[summon.id].health).toBe(beforeDamage - 5);
|
||||||
|
expect(useCombatStore.getState().healActor(summon.id, 5, { sourceActorId: PLAYER_AGGRO_ID }).effectiveAmount).toBe(5);
|
||||||
|
|
||||||
|
const baseAbility = abilityById("rom-warden-primary-1")!;
|
||||||
|
const combatSummonAbility: AbilityDefinition = { ...baseAbility, id: "test-guardian", name: "Test Guardian" };
|
||||||
|
useCombatStore.getState().summonActor(
|
||||||
|
PLAYER_AGGRO_ID,
|
||||||
|
combatSummonAbility,
|
||||||
|
{ kind: "summon", creatureId: 2, coefficient: 0.5, durationMs: 10_000 },
|
||||||
|
[0, 0, 0],
|
||||||
|
"summon-target",
|
||||||
|
6_000,
|
||||||
|
);
|
||||||
|
useCombatStore.getState().tick(1, 7_500);
|
||||||
|
expect(useCombatStore.getState().mobs["summon-target"].threatByActor[PLAYER_AGGRO_ID]).toBeGreaterThan(0);
|
||||||
|
expect(useCombatStore.getState().mobs["summon-target"].threatByActor[summon.id] ?? 0).toBe(0);
|
||||||
|
|
||||||
|
const totemAbility: AbilityDefinition = { ...baseAbility, id: "test-totem", name: "Test Totem" };
|
||||||
|
const totemEffect = { kind: "summon" as const, creatureId: 1, coefficient: 0.2, durationMs: 10_000 };
|
||||||
|
useCombatStore.getState().summonActor(PLAYER_AGGRO_ID, totemAbility, totemEffect, [0, 0, 0], null, 8_000);
|
||||||
|
useCombatStore.getState().summonActor(PLAYER_AGGRO_ID, totemAbility, totemEffect, [1, 0, 0], null, 8_100);
|
||||||
|
expect(useCombatStore.getState().summons.filter((candidate) => candidate.exclusiveGroup === `${PLAYER_AGGRO_ID}:totem`)).toHaveLength(1);
|
||||||
|
|
||||||
|
useCombatStore.getState().tick(0.1, 20_001);
|
||||||
|
expect(useCombatStore.getState().summons.some((candidate) => candidate.id === summon.id)).toBe(false);
|
||||||
|
expect(useCombatStore.getState().actors[summon.id]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes damaging abilities from Classic, RuneWaker, and CoA catalogs through one runtime", () => {
|
||||||
|
const cases = [
|
||||||
|
{ classId: "mage" as const, level: 20, source: "classic" },
|
||||||
|
{ classId: "rom-mage" as const, level: 20, source: "runewaker" },
|
||||||
|
{ classId: "stormbringer" as const, level: 60, source: "coa" },
|
||||||
|
];
|
||||||
|
registerCombatSpatialProvider({
|
||||||
|
actor: (id) => ({ id, position: id === PLAYER_AGGRO_ID ? [0, 0, 0] : getMobPosition(id) ?? [0, 0, 0], yaw: 0 }),
|
||||||
|
hasLineOfSight: () => true,
|
||||||
|
projectGroundPoint: (point) => point,
|
||||||
|
});
|
||||||
|
for (const entry of cases) {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: entry.classId, level: entry.level });
|
||||||
|
const ability = abilitiesForClass(entry.classId)
|
||||||
|
.map((candidate) => abilityAtLevel(candidate, entry.level))
|
||||||
|
.find((candidate) => (
|
||||||
|
!candidate.passive
|
||||||
|
&& candidate.target === "hostile"
|
||||||
|
&& candidate.talentEntryId === undefined
|
||||||
|
&& candidate.effects.some((effect) => effect.kind === "damage")
|
||||||
|
));
|
||||||
|
expect(ability, `${entry.source} executable damage ability`).toBeDefined();
|
||||||
|
const id = `${entry.source}-target`;
|
||||||
|
const range = Math.max(1, Math.min(10, ability!.range.max));
|
||||||
|
registerMob(id, [0, 0, range]);
|
||||||
|
useCombatStore.getState().selectMob(id);
|
||||||
|
const before = useCombatStore.getState().mobs[id].health;
|
||||||
|
const cast = useCombatStore.getState().castAbility(ability!.id, { now: 1_000 });
|
||||||
|
expect(cast.ok, `${entry.source} cast: ${cast.reason ?? "ok"}`).toBe(true);
|
||||||
|
const activeCast = useCombatStore.getState().activeCast;
|
||||||
|
if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt);
|
||||||
|
expect(useCombatStore.getState().mobs[id].health, `${entry.source} damage`).toBeLessThan(before);
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,10 +8,12 @@ import { usePartyStore } from "./partyStore";
|
|||||||
import { useGameStore, type GameMode } from "./store";
|
import { useGameStore, type GameMode } from "./store";
|
||||||
import { dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
|
import { dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
|
||||||
import type { WorldSpawn } from "./dungeonTypes";
|
import type { WorldSpawn } from "./dungeonTypes";
|
||||||
|
import { useManastormAdminStore } from "./manastormAdminStore";
|
||||||
|
|
||||||
export interface DungeonSessionOptions {
|
export interface DungeonSessionOptions {
|
||||||
readonly gameMode?: GameMode;
|
readonly gameMode?: GameMode;
|
||||||
readonly spawn?: WorldSpawn;
|
readonly spawn?: WorldSpawn;
|
||||||
|
readonly gmTestMode?: boolean;
|
||||||
/** Used by in-game encounter transitions so character progression and inventory survive. */
|
/** Used by in-game encounter transitions so character progression and inventory survive. */
|
||||||
readonly preserveCharacter?: boolean;
|
readonly preserveCharacter?: boolean;
|
||||||
}
|
}
|
||||||
@@ -72,7 +74,8 @@ export function activateDungeonSession(
|
|||||||
}
|
}
|
||||||
useGameStore.getState().activateDungeon(dungeonId as DungeonId, difficultyId, {
|
useGameStore.getState().activateDungeon(dungeonId as DungeonId, difficultyId, {
|
||||||
gameMode: options.gameMode,
|
gameMode: options.gameMode,
|
||||||
spawn: options.spawn,
|
spawn: options.spawn ?? useManastormAdminStore.getState().config.dungeonSpawns[dungeonId],
|
||||||
|
gmTestMode: options.gmTestMode,
|
||||||
});
|
});
|
||||||
useCombatStore.getState().setPlayerPosition(useGameStore.getState().playerPosition);
|
useCombatStore.getState().setPlayerPosition(useGameStore.getState().playerPosition);
|
||||||
return true;
|
return true;
|
||||||
@@ -87,6 +90,7 @@ export function restartActiveDungeonSession(): boolean {
|
|||||||
return activateDungeonSession(game.activeDungeonId, game.activeDifficultyId, {
|
return activateDungeonSession(game.activeDungeonId, game.activeDifficultyId, {
|
||||||
gameMode: game.gameMode,
|
gameMode: game.gameMode,
|
||||||
spawn: game.activeSpawn,
|
spawn: game.activeSpawn,
|
||||||
|
gmTestMode: game.gmTestMode,
|
||||||
preserveCharacter: true,
|
preserveCharacter: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import { dungeonDefinitionById } from "./dungeonRegistry";
|
|||||||
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
|
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
|
||||||
import { usePartyStore } from "./partyStore";
|
import { usePartyStore } from "./partyStore";
|
||||||
import { useGameStore } from "./store";
|
import { useGameStore } from "./store";
|
||||||
|
import { DEFAULT_MANASTORM_ADMIN_CONFIG } from "./manastormAdminConfig";
|
||||||
|
import { useManastormAdminStore } from "./manastormAdminStore";
|
||||||
|
|
||||||
describe("dungeon session asset eviction", () => {
|
describe("dungeon session asset eviction", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
useManastormAdminStore.setState({ config: DEFAULT_MANASTORM_ADMIN_CONFIG, draft: DEFAULT_MANASTORM_ADMIN_CONFIG, dirty: false });
|
||||||
useGameStore.getState().activateDungeon("wailing-caverns");
|
useGameStore.getState().activateDungeon("wailing-caverns");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,4 +47,13 @@ describe("dungeon session asset eviction", () => {
|
|||||||
expect(useGameStore.getState().sessionRevision).toBe(revision + 1);
|
expect(useGameStore.getState().sessionRevision).toBe(revision + 1);
|
||||||
expect(usePartyStore.getState().members.map((member) => member.id)).toEqual(members);
|
expect(usePartyStore.getState().members.map((member) => member.id)).toEqual(members);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies the published dungeon group anchor at the session boundary", () => {
|
||||||
|
const spawn = { footPosition: [12, 3, -8] as const, yaw: 1.25 };
|
||||||
|
useManastormAdminStore.setState({
|
||||||
|
config: { ...DEFAULT_MANASTORM_ADMIN_CONFIG, dungeonSpawns: { "wailing-caverns": spawn } },
|
||||||
|
});
|
||||||
|
expect(activateDungeonSession("wailing-caverns")).toBe(true);
|
||||||
|
expect(useGameStore.getState().activeSpawn).toEqual(spawn);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export type EnemyDamageSchool =
|
|||||||
| "shadow"
|
| "shadow"
|
||||||
| "holy";
|
| "holy";
|
||||||
|
|
||||||
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun";
|
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun" | "silence";
|
||||||
|
|
||||||
export type EnemyAbilityEffect =
|
export type EnemyAbilityEffect =
|
||||||
| { readonly kind: "damage"; readonly multiplier: number }
|
| { readonly kind: "damage"; readonly multiplier: number }
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -48,6 +48,9 @@ function inputActionSpies(blocked = false): InputActions {
|
|||||||
setPartyCommand: vi.fn(),
|
setPartyCommand: vi.fn(),
|
||||||
recallParty: vi.fn(),
|
recallParty: vi.fn(),
|
||||||
clearTarget: vi.fn(),
|
clearTarget: vi.fn(),
|
||||||
|
groundTargetActive: vi.fn(() => false),
|
||||||
|
confirmGroundTarget: vi.fn(),
|
||||||
|
cancelGroundTarget: vi.fn(),
|
||||||
gameplayActionsBlocked: vi.fn(() => blocked),
|
gameplayActionsBlocked: vi.fn(() => blocked),
|
||||||
setCameraLookActive: vi.fn(),
|
setCameraLookActive: vi.fn(),
|
||||||
setInputMode: vi.fn(),
|
setInputMode: vi.fn(),
|
||||||
@@ -90,6 +93,24 @@ describe("gameplay input bindings", () => {
|
|||||||
dispose();
|
dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("confirms and cancels an active ground reticle before face-button casts", () => {
|
||||||
|
stubInputEventTargets();
|
||||||
|
const nextActions = inputActionSpies();
|
||||||
|
(nextActions.groundTargetActive as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||||
|
const dispose = installInput(nextActions);
|
||||||
|
|
||||||
|
emitControllerToken({ token: "Button0", repeat: false, pressed: true });
|
||||||
|
emitControllerToken({ token: "Button0", repeat: false, pressed: false });
|
||||||
|
emitControllerToken({ token: "Button1", repeat: false, pressed: true });
|
||||||
|
|
||||||
|
expect(nextActions.confirmGroundTarget).toHaveBeenCalledTimes(1);
|
||||||
|
expect(nextActions.cancelGroundTarget).toHaveBeenCalledTimes(1);
|
||||||
|
expect(nextActions.lootActiveBoss).not.toHaveBeenCalled();
|
||||||
|
expect(nextActions.castActionBinding).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("maps Thor controls without stealing Select from display routing", () => {
|
it("maps Thor controls without stealing Select from display routing", () => {
|
||||||
expect(controllerCommandForToken("Button9")).toBe("pause");
|
expect(controllerCommandForToken("Button9")).toBe("pause");
|
||||||
expect(controllerCommandForToken("Button11")).toBe("target-next");
|
expect(controllerCommandForToken("Button11")).toBe("target-next");
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export interface InputActions {
|
|||||||
setPartyCommand: (command: PartyCommand) => void;
|
setPartyCommand: (command: PartyCommand) => void;
|
||||||
recallParty: () => void;
|
recallParty: () => void;
|
||||||
clearTarget: () => void;
|
clearTarget: () => void;
|
||||||
|
groundTargetActive: () => boolean;
|
||||||
|
confirmGroundTarget: () => void;
|
||||||
|
cancelGroundTarget: () => void;
|
||||||
gameplayActionsBlocked: () => boolean;
|
gameplayActionsBlocked: () => boolean;
|
||||||
setCameraLookActive: (active: boolean) => void;
|
setCameraLookActive: (active: boolean) => void;
|
||||||
setInputMode: (mode: "keyboard" | "gamepad") => void;
|
setInputMode: (mode: "keyboard" | "gamepad") => void;
|
||||||
@@ -246,6 +249,16 @@ export function installInput(nextActions: InputActions): () => void {
|
|||||||
if (!isPressed) return;
|
if (!isPressed) return;
|
||||||
nextActions.setInputMode("gamepad");
|
nextActions.setInputMode("gamepad");
|
||||||
if (repeat) return;
|
if (repeat) return;
|
||||||
|
if (nextActions.groundTargetActive()) {
|
||||||
|
if (token === "Button0") {
|
||||||
|
nextActions.confirmGroundTarget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (token === "Button1" || token === CONTROLLER_SYSTEM_BACK_TOKEN) {
|
||||||
|
nextActions.cancelGroundTarget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
token === "Button0"
|
token === "Button0"
|
||||||
&& !nextActions.gameplayActionsBlocked()
|
&& !nextActions.gameplayActionsBlocked()
|
||||||
@@ -292,6 +305,13 @@ export function installInput(nextActions: InputActions): () => void {
|
|||||||
actions?.setInputMode("keyboard");
|
actions?.setInputMode("keyboard");
|
||||||
}
|
}
|
||||||
if (event.repeat) return;
|
if (event.repeat) return;
|
||||||
|
if (nextActions.groundTargetActive() && (event.code === "Escape" || event.code === "Enter")) {
|
||||||
|
event.preventDefault();
|
||||||
|
nextActions.setInputMode("keyboard");
|
||||||
|
if (event.code === "Enter") nextActions.confirmGroundTarget();
|
||||||
|
else nextActions.cancelGroundTarget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const actionControl = actionControlForKeyboardCode(event.code);
|
const actionControl = actionControlForKeyboardCode(event.code);
|
||||||
if (actionControl) {
|
if (actionControl) {
|
||||||
if (nextActions.gameplayActionsBlocked()) return;
|
if (nextActions.gameplayActionsBlocked()) return;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
manastormCheckpointForLevel,
|
manastormCheckpointForLevel,
|
||||||
manastormModeForCharacterLevel,
|
manastormModeForCharacterLevel,
|
||||||
manastormExperienceReward,
|
manastormExperienceReward,
|
||||||
|
manastormAffixes,
|
||||||
manastormScaling,
|
manastormScaling,
|
||||||
setManastormCatalog,
|
setManastormCatalog,
|
||||||
shuffleManastormEncounterIds,
|
shuffleManastormEncounterIds,
|
||||||
@@ -155,6 +156,32 @@ describe("Manastorm rules", () => {
|
|||||||
.toBe(fullGroup.encounter.id);
|
.toBe(fullGroup.encounter.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not restore fallback content when a published Ready pool is empty", () => {
|
||||||
|
const emptyPublishedCatalog: ManastormCatalogLike = { stages: [], fallbackPolicy: "none" };
|
||||||
|
expect(() => drawManastormStage(1, 1, [], null, () => 0, emptyPublishedCatalog))
|
||||||
|
.toThrow("no unlocked encounter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the standard pool at milestone levels when forced milestones are disabled", () => {
|
||||||
|
const catalog: ManastormCatalogLike = {
|
||||||
|
stages: [
|
||||||
|
stage("standard", { unlockLevel: 1 }),
|
||||||
|
stage("milestone", { unlockLevel: 5, stageKind: "milestone", milestoneLevel: 5 }),
|
||||||
|
],
|
||||||
|
runtimeOptions: { milestoneEncountersEnabled: false },
|
||||||
|
};
|
||||||
|
expect(drawManastormStage(5, 1, [], null, () => 0, catalog).encounter.id).toBe("standard");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables both explicit and modifier-derived affixes", () => {
|
||||||
|
const catalog: ManastormCatalogLike = {
|
||||||
|
affixes: [{ id: "explicit", name: "Explicit", description: "Fixture", unlockLevel: 1 }],
|
||||||
|
modifiers: [{ id: "fallback", modeId: 0, level: 1, rawFlag: 7, tuning: [] }],
|
||||||
|
runtimeOptions: { affixesEnabled: false },
|
||||||
|
};
|
||||||
|
expect(manastormAffixes(1, catalog, 0)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps leveling and end-game encounter pools separate by character state", () => {
|
it("keeps leveling and end-game encounter pools separate by character state", () => {
|
||||||
const catalog: ManastormCatalogLike = {
|
const catalog: ManastormCatalogLike = {
|
||||||
id: "split-mode-fixture",
|
id: "split-mode-fixture",
|
||||||
|
|||||||
+22
-4
@@ -32,6 +32,7 @@ export type ManastormPhase =
|
|||||||
|
|
||||||
export interface ManastormEncounterDefinition {
|
export interface ManastormEncounterDefinition {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
|
readonly encounterId?: number;
|
||||||
readonly dungeonId: DungeonId;
|
readonly dungeonId: DungeonId;
|
||||||
/** Authoritative package map identity; dungeonId may only select fallback scene geometry. */
|
/** Authoritative package map identity; dungeonId may only select fallback scene geometry. */
|
||||||
readonly mapId?: number;
|
readonly mapId?: number;
|
||||||
@@ -42,9 +43,12 @@ export interface ManastormEncounterDefinition {
|
|||||||
readonly bossEntityId: string;
|
readonly bossEntityId: string;
|
||||||
readonly bossName: string;
|
readonly bossName: string;
|
||||||
readonly bossPosition: MobVector3;
|
readonly bossPosition: MobVector3;
|
||||||
|
readonly bossYaw?: number;
|
||||||
readonly playerSpawn: WorldSpawn;
|
readonly playerSpawn: WorldSpawn;
|
||||||
readonly portalPosition: MobVector3;
|
readonly portalPosition: MobVector3;
|
||||||
readonly trashRuntimeIds?: readonly string[];
|
readonly trashRuntimeIds?: readonly string[];
|
||||||
|
readonly requiredGuardianKills?: number;
|
||||||
|
readonly chaoticLinkEnabled?: boolean;
|
||||||
readonly modeIds?: readonly number[];
|
readonly modeIds?: readonly number[];
|
||||||
readonly stageKind?: ManastormStageKind;
|
readonly stageKind?: ManastormStageKind;
|
||||||
readonly unlockLevel?: number;
|
readonly unlockLevel?: number;
|
||||||
@@ -186,9 +190,12 @@ export interface ManastormCatalogEncounterLike {
|
|||||||
readonly bossEntityId?: string;
|
readonly bossEntityId?: string;
|
||||||
readonly bossName?: string;
|
readonly bossName?: string;
|
||||||
readonly bossPosition?: MobVector3;
|
readonly bossPosition?: MobVector3;
|
||||||
|
readonly bossYaw?: number;
|
||||||
readonly playerSpawn?: WorldSpawn;
|
readonly playerSpawn?: WorldSpawn;
|
||||||
readonly portalPosition?: MobVector3;
|
readonly portalPosition?: MobVector3;
|
||||||
readonly trashRuntimeIds?: readonly string[];
|
readonly trashRuntimeIds?: readonly string[];
|
||||||
|
readonly requiredGuardianKills?: number;
|
||||||
|
readonly chaoticLinkEnabled?: boolean;
|
||||||
readonly stageKind?: ManastormStageKind;
|
readonly stageKind?: ManastormStageKind;
|
||||||
readonly unlockLevel?: number;
|
readonly unlockLevel?: number;
|
||||||
readonly milestoneLevel?: ManastormSpecialLevel;
|
readonly milestoneLevel?: ManastormSpecialLevel;
|
||||||
@@ -299,6 +306,15 @@ export interface ManastormCatalogLike {
|
|||||||
readonly rewards?: readonly ManastormCatalogRewardLike[];
|
readonly rewards?: readonly ManastormCatalogRewardLike[];
|
||||||
readonly loadouts?: readonly ManastormCatalogLoadoutLike[];
|
readonly loadouts?: readonly ManastormCatalogLoadoutLike[];
|
||||||
readonly defaultLoadoutId?: string;
|
readonly defaultLoadoutId?: string;
|
||||||
|
readonly fallbackPolicy?: "legacy" | "none";
|
||||||
|
readonly runtimeOptions?: {
|
||||||
|
readonly enabled?: boolean;
|
||||||
|
readonly levelingEnabled?: boolean;
|
||||||
|
readonly endgameEnabled?: boolean;
|
||||||
|
readonly affixesEnabled?: boolean;
|
||||||
|
readonly chaoticLinkEnabled?: boolean;
|
||||||
|
readonly milestoneEncountersEnabled?: boolean;
|
||||||
|
};
|
||||||
readonly scaling?: {
|
readonly scaling?: {
|
||||||
readonly perLevel?: number;
|
readonly perLevel?: number;
|
||||||
readonly bonusLootPerLevel?: number;
|
readonly bonusLootPerLevel?: number;
|
||||||
@@ -583,7 +599,7 @@ export function runtimeStages(catalog: ManastormCatalogLike = getManastormCatalo
|
|||||||
minimumPartySize: normalizeManastormPartySize(stage.minimumPartySize ?? 1),
|
minimumPartySize: normalizeManastormPartySize(stage.minimumPartySize ?? 1),
|
||||||
maximumPartySize: normalizeManastormPartySize(stage.maximumPartySize ?? 5),
|
maximumPartySize: normalizeManastormPartySize(stage.maximumPartySize ?? 5),
|
||||||
}));
|
}));
|
||||||
return stages.length ? stages : MANASTORM_ENCOUNTERS;
|
return stages.length || catalog.fallbackPolicy === "none" ? stages : MANASTORM_ENCOUNTERS;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function drawManastormStage(
|
export function drawManastormStage(
|
||||||
@@ -609,7 +625,8 @@ export function drawManastormStage(
|
|||||||
&& (stage.minimumPartySize ?? 1) <= safePartySize
|
&& (stage.minimumPartySize ?? 1) <= safePartySize
|
||||||
&& (stage.maximumPartySize ?? 5) >= safePartySize
|
&& (stage.maximumPartySize ?? 5) >= safePartySize
|
||||||
));
|
));
|
||||||
const special = isManastormSpecialLevel(safeLevel);
|
const special = catalog.runtimeOptions?.milestoneEncountersEnabled !== false
|
||||||
|
&& isManastormSpecialLevel(safeLevel);
|
||||||
const milestoneStages = unlocked.filter((stage) => stage.milestoneLevel === safeLevel);
|
const milestoneStages = unlocked.filter((stage) => stage.milestoneLevel === safeLevel);
|
||||||
const milestonePool = unlocked.filter((stage) => (
|
const milestonePool = unlocked.filter((stage) => (
|
||||||
stage.stageKind === "milestone" || stage.milestoneLevel !== undefined
|
stage.stageKind === "milestone" || stage.milestoneLevel !== undefined
|
||||||
@@ -621,7 +638,7 @@ export function drawManastormStage(
|
|||||||
let eligible = special
|
let eligible = special
|
||||||
? (milestoneStages.length ? milestoneStages : milestonePool)
|
? (milestoneStages.length ? milestoneStages : milestonePool)
|
||||||
: standardStages;
|
: standardStages;
|
||||||
if (!eligible.length) {
|
if (!eligible.length && catalog.fallbackPolicy !== "none") {
|
||||||
eligible = MANASTORM_ENCOUNTERS.filter((stage) => (
|
eligible = MANASTORM_ENCOUNTERS.filter((stage) => (
|
||||||
(stage.unlockLevel ?? 1) <= safeLevel
|
(stage.unlockLevel ?? 1) <= safeLevel
|
||||||
&& (special
|
&& (special
|
||||||
@@ -629,7 +646,7 @@ export function drawManastormStage(
|
|||||||
: stage.stageKind !== "milestone" && !stage.milestoneLevel)
|
: stage.stageKind !== "milestone" && !stage.milestoneLevel)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if (!eligible.length) eligible = [MANASTORM_ENCOUNTERS[0]];
|
if (!eligible.length && catalog.fallbackPolicy !== "none") eligible = [MANASTORM_ENCOUNTERS[0]];
|
||||||
|
|
||||||
const byId = new Map(eligible.map((stage) => [stage.id, stage]));
|
const byId = new Map(eligible.map((stage) => [stage.id, stage]));
|
||||||
let available = bag.filter((id) => byId.has(id));
|
let available = bag.filter((id) => byId.has(id));
|
||||||
@@ -839,6 +856,7 @@ export function manastormAffixes(
|
|||||||
modeId = manastormModeId(catalog),
|
modeId = manastormModeId(catalog),
|
||||||
unlockedThroughLevel = level,
|
unlockedThroughLevel = level,
|
||||||
): readonly ManastormAffixState[] {
|
): readonly ManastormAffixState[] {
|
||||||
|
if (catalog.runtimeOptions?.affixesEnabled === false) return [];
|
||||||
const safeLevel = Math.max(1, Math.trunc(Number.isFinite(level) ? level : 1));
|
const safeLevel = Math.max(1, Math.trunc(Number.isFinite(level) ? level : 1));
|
||||||
const safeUnlockedThroughLevel = Math.max(
|
const safeUnlockedThroughLevel = Math.max(
|
||||||
safeLevel,
|
safeLevel,
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { runtimeStages } from "./manastorm";
|
||||||
|
import {
|
||||||
|
DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||||
|
UNIFIED_MANASTORM_BOSS_CATALOG,
|
||||||
|
authoredStageConfigForBoss,
|
||||||
|
bossAdminRecordForStatus,
|
||||||
|
derivePartySpawnSlots,
|
||||||
|
effectiveManastormCatalog,
|
||||||
|
isGameMasterSession,
|
||||||
|
resolveBossAdminEntry,
|
||||||
|
resolvedBossAdminCatalog,
|
||||||
|
type ManastormAdminConfig,
|
||||||
|
} from "./manastormAdminConfig";
|
||||||
|
|
||||||
|
function configWithBoss(key: string, record: ManastormAdminConfig["bosses"][string]): ManastormAdminConfig {
|
||||||
|
return {
|
||||||
|
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||||
|
bosses: { [key]: record },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Manastorm GM catalog", () => {
|
||||||
|
it("deduplicates map/name records and defaults every boss to Needs tested", () => {
|
||||||
|
const identities = UNIFIED_MANASTORM_BOSS_CATALOG.map((entry) => `${entry.mapId}:${entry.name.toLowerCase()}`);
|
||||||
|
expect(new Set(identities).size).toBe(identities.length);
|
||||||
|
expect(UNIFIED_MANASTORM_BOSS_CATALOG.length).toBeGreaterThan(0);
|
||||||
|
expect(resolvedBossAdminCatalog(DEFAULT_MANASTORM_ADMIN_CONFIG).every((entry) => (
|
||||||
|
entry.status === "needs-tested" && !entry.inPublicPool
|
||||||
|
))).toBe(true);
|
||||||
|
const crossMode = UNIFIED_MANASTORM_BOSS_CATALOG.find((entry) => entry.name === "Burning Felguard");
|
||||||
|
expect(crossMode?.baseStage?.modeIds).toEqual(expect.arrayContaining([0, 2]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes only runnable Ready bosses and never restores the legacy pool", () => {
|
||||||
|
const candidate = UNIFIED_MANASTORM_BOSS_CATALOG.find((entry) => (
|
||||||
|
resolveBossAdminEntry(entry, configWithBoss(entry.key, { status: "ready" })).runnable
|
||||||
|
));
|
||||||
|
expect(candidate).toBeTruthy();
|
||||||
|
const config = configWithBoss(candidate!.key, { status: "ready" });
|
||||||
|
const effective = effectiveManastormCatalog(config);
|
||||||
|
expect(effective.fallbackPolicy).toBe("none");
|
||||||
|
expect(runtimeStages(effective)).toHaveLength(1);
|
||||||
|
expect(runtimeStages(effective)[0].bossName).toBe(candidate!.name);
|
||||||
|
|
||||||
|
const empty = effectiveManastormCatalog(DEFAULT_MANASTORM_ADMIN_CONFIG);
|
||||||
|
expect(empty.fallbackPolicy).toBe("none");
|
||||||
|
expect(runtimeStages(empty)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("materializes an inherited imported stage when a runnable boss becomes Ready", () => {
|
||||||
|
const candidate = UNIFIED_MANASTORM_BOSS_CATALOG.find((entry) => entry.baseStage);
|
||||||
|
expect(candidate).toBeTruthy();
|
||||||
|
const record = bossAdminRecordForStatus(candidate!, DEFAULT_MANASTORM_ADMIN_CONFIG, "ready");
|
||||||
|
expect(record.status).toBe("ready");
|
||||||
|
expect(record.stage).toMatchObject({
|
||||||
|
dungeonId: candidate!.baseStage!.dungeonId,
|
||||||
|
bossRuntimeId: candidate!.baseStage!.bossRuntimeId,
|
||||||
|
bossPosition: candidate!.baseStage!.bossPosition,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects stale exact-mob IDs and bounds required kills", () => {
|
||||||
|
const candidate = UNIFIED_MANASTORM_BOSS_CATALOG.find((entry) => authoredStageConfigForBoss(entry, DEFAULT_MANASTORM_ADMIN_CONFIG));
|
||||||
|
expect(candidate).toBeTruthy();
|
||||||
|
const stage = authoredStageConfigForBoss(candidate!, DEFAULT_MANASTORM_ADMIN_CONFIG)!;
|
||||||
|
const stale = configWithBoss(candidate!.key, {
|
||||||
|
status: "ready",
|
||||||
|
stage: {
|
||||||
|
...stage,
|
||||||
|
linkedMobRuntimeIds: ["missing:runtime-id"],
|
||||||
|
requiredKillCount: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const resolved = resolveBossAdminEntry(candidate!, stale);
|
||||||
|
expect(resolved.runnable).toBe(false);
|
||||||
|
expect(resolved.validationIssues.join(" ")).toContain("missing:runtime-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives a stable five-person formation from one anchor and yaw", () => {
|
||||||
|
const slots = derivePartySpawnSlots({ footPosition: [10, 2, 20], yaw: Math.PI / 2 }, 5);
|
||||||
|
expect(slots).toHaveLength(5);
|
||||||
|
expect(slots[0]).toEqual({ footPosition: [10, 2, 20], yaw: Math.PI / 2 });
|
||||||
|
expect(new Set(slots.map((slot) => slot.footPosition.join(","))).size).toBe(5);
|
||||||
|
expect(slots.every((slot) => slot.yaw === Math.PI / 2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never grants GM UI authority to offline or local-only sessions", () => {
|
||||||
|
expect(isGameMasterSession({ kind: "offline", roles: ["gm"] })).toBe(false);
|
||||||
|
expect(isGameMasterSession({ kind: "account", roles: ["gm"] })).toBe(false);
|
||||||
|
expect(isGameMasterSession({ kind: "account", accessToken: "server-token", roles: ["gm"] })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
import { DUNGEON_DEFINITIONS, dungeonDefinitionById } from "./dungeonRegistry";
|
||||||
|
import type { DungeonBossObjective, Vector3Tuple, WorldSpawn } from "./dungeonTypes";
|
||||||
|
import {
|
||||||
|
runtimeStages,
|
||||||
|
type ManastormCatalogLike,
|
||||||
|
type ManastormEncounterDefinition,
|
||||||
|
} from "./manastorm";
|
||||||
|
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "./manastormInstalledCatalog";
|
||||||
|
|
||||||
|
export const MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION = 1;
|
||||||
|
export const MANASTORM_ADMIN_CACHE_KEY = "healer-man.manastorm-admin-config.v1";
|
||||||
|
|
||||||
|
export type ManastormBossStatus = "ready" | "needs-tested" | "not-ready";
|
||||||
|
|
||||||
|
export interface ManastormGlobalOptions {
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly levelingEnabled: boolean;
|
||||||
|
readonly endgameEnabled: boolean;
|
||||||
|
readonly affixesEnabled: boolean;
|
||||||
|
readonly chaoticLinkEnabled: boolean;
|
||||||
|
readonly milestoneEncountersEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManastormAdminStageConfig {
|
||||||
|
readonly dungeonId: string;
|
||||||
|
readonly mapId: number;
|
||||||
|
readonly baseStageId?: string;
|
||||||
|
readonly assetPackageId?: string;
|
||||||
|
readonly bossRuntimeId: string;
|
||||||
|
readonly bossEntityId: string;
|
||||||
|
readonly bossName: string;
|
||||||
|
readonly bossPosition: Vector3Tuple;
|
||||||
|
readonly bossYaw: number;
|
||||||
|
readonly groupSpawn: WorldSpawn;
|
||||||
|
readonly portalPosition: Vector3Tuple;
|
||||||
|
readonly linkedMobRuntimeIds: readonly string[];
|
||||||
|
readonly requiredKillCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManastormBossAdminRecord {
|
||||||
|
readonly status: ManastormBossStatus;
|
||||||
|
readonly stage?: ManastormAdminStageConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManastormAdminConfig {
|
||||||
|
readonly schemaVersion: typeof MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION;
|
||||||
|
readonly global: ManastormGlobalOptions;
|
||||||
|
readonly dungeonSpawns: Readonly<Record<string, WorldSpawn>>;
|
||||||
|
readonly bosses: Readonly<Record<string, ManastormBossAdminRecord>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishedManastormAdminConfig {
|
||||||
|
readonly revision: number;
|
||||||
|
readonly updatedAt: number | null;
|
||||||
|
readonly config: ManastormAdminConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedBossCatalogEntry {
|
||||||
|
readonly key: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly dungeonId: string | null;
|
||||||
|
readonly dungeonTitle: string;
|
||||||
|
readonly mapId: number;
|
||||||
|
readonly bossRuntimeId: string;
|
||||||
|
readonly bossEntityId: string;
|
||||||
|
readonly source: readonly ("dungeon" | "installed-manastorm")[];
|
||||||
|
readonly sourceIds: readonly string[];
|
||||||
|
readonly baseStage: ManastormEncounterDefinition | null;
|
||||||
|
readonly objective: DungeonBossObjective | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedBossAdminEntry extends UnifiedBossCatalogEntry {
|
||||||
|
readonly status: ManastormBossStatus;
|
||||||
|
readonly stage: ManastormEncounterDefinition | null;
|
||||||
|
readonly runnable: boolean;
|
||||||
|
readonly validationIssues: readonly string[];
|
||||||
|
readonly inPublicPool: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_MANASTORM_ADMIN_CONFIG: ManastormAdminConfig = Object.freeze({
|
||||||
|
schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION,
|
||||||
|
global: Object.freeze({
|
||||||
|
enabled: true,
|
||||||
|
levelingEnabled: true,
|
||||||
|
endgameEnabled: true,
|
||||||
|
affixesEnabled: true,
|
||||||
|
chaoticLinkEnabled: true,
|
||||||
|
milestoneEncountersEnabled: true,
|
||||||
|
}),
|
||||||
|
dungeonSpawns: Object.freeze({}),
|
||||||
|
bosses: Object.freeze({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizedName(value: string): string {
|
||||||
|
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteVector(value: unknown): value is Vector3Tuple {
|
||||||
|
return Array.isArray(value) && value.length === 3 && value.every((coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedSpawn(value: unknown): WorldSpawn | null {
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
const input = value as Partial<WorldSpawn>;
|
||||||
|
if (!finiteVector(input.footPosition) || typeof input.yaw !== "number" || !Number.isFinite(input.yaw)) return null;
|
||||||
|
return { footPosition: [...input.footPosition] as Vector3Tuple, yaw: input.yaw };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedStage(value: unknown): ManastormAdminStageConfig | null {
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
const input = value as Partial<ManastormAdminStageConfig>;
|
||||||
|
if (
|
||||||
|
typeof input.dungeonId !== "string"
|
||||||
|
|| !Number.isInteger(input.mapId)
|
||||||
|
|| typeof input.bossRuntimeId !== "string"
|
||||||
|
|| typeof input.bossEntityId !== "string"
|
||||||
|
|| typeof input.bossName !== "string"
|
||||||
|
|| !finiteVector(input.bossPosition)
|
||||||
|
|| typeof input.bossYaw !== "number"
|
||||||
|
|| !Number.isFinite(input.bossYaw)
|
||||||
|
|| !finiteVector(input.portalPosition)
|
||||||
|
|| !Array.isArray(input.linkedMobRuntimeIds)
|
||||||
|
|| !input.linkedMobRuntimeIds.every((id) => typeof id === "string" && Boolean(id))
|
||||||
|
|| !Number.isInteger(input.requiredKillCount)
|
||||||
|
) return null;
|
||||||
|
const groupSpawn = normalizedSpawn(input.groupSpawn);
|
||||||
|
const linkedMobRuntimeIds = [...new Set(input.linkedMobRuntimeIds)];
|
||||||
|
if (!groupSpawn || input.requiredKillCount! < 0 || input.requiredKillCount! > linkedMobRuntimeIds.length) return null;
|
||||||
|
return {
|
||||||
|
dungeonId: input.dungeonId,
|
||||||
|
mapId: input.mapId!,
|
||||||
|
...(typeof input.baseStageId === "string" && input.baseStageId ? { baseStageId: input.baseStageId } : {}),
|
||||||
|
...(typeof input.assetPackageId === "string" && input.assetPackageId ? { assetPackageId: input.assetPackageId } : {}),
|
||||||
|
bossRuntimeId: input.bossRuntimeId,
|
||||||
|
bossEntityId: input.bossEntityId,
|
||||||
|
bossName: input.bossName,
|
||||||
|
bossPosition: [...input.bossPosition] as Vector3Tuple,
|
||||||
|
bossYaw: input.bossYaw,
|
||||||
|
groupSpawn,
|
||||||
|
portalPosition: [...input.portalPosition] as Vector3Tuple,
|
||||||
|
linkedMobRuntimeIds,
|
||||||
|
requiredKillCount: input.requiredKillCount!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeManastormAdminConfig(value: unknown): ManastormAdminConfig {
|
||||||
|
if (!value || typeof value !== "object") return DEFAULT_MANASTORM_ADMIN_CONFIG;
|
||||||
|
const input = value as Partial<ManastormAdminConfig>;
|
||||||
|
if (input.schemaVersion !== MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION) return DEFAULT_MANASTORM_ADMIN_CONFIG;
|
||||||
|
const globalInput = input.global as Partial<ManastormGlobalOptions> | undefined;
|
||||||
|
const global: ManastormGlobalOptions = {
|
||||||
|
enabled: globalInput?.enabled !== false,
|
||||||
|
levelingEnabled: globalInput?.levelingEnabled !== false,
|
||||||
|
endgameEnabled: globalInput?.endgameEnabled !== false,
|
||||||
|
affixesEnabled: globalInput?.affixesEnabled !== false,
|
||||||
|
chaoticLinkEnabled: globalInput?.chaoticLinkEnabled !== false,
|
||||||
|
milestoneEncountersEnabled: globalInput?.milestoneEncountersEnabled !== false,
|
||||||
|
};
|
||||||
|
const dungeonSpawns = Object.fromEntries(Object.entries(input.dungeonSpawns ?? {}).flatMap(([id, value]) => {
|
||||||
|
const spawn = normalizedSpawn(value);
|
||||||
|
return id && spawn ? [[id, spawn]] : [];
|
||||||
|
}));
|
||||||
|
const bosses = Object.fromEntries(Object.entries(input.bosses ?? {}).flatMap(([key, value]) => {
|
||||||
|
if (!key || !value || typeof value !== "object") return [];
|
||||||
|
const candidate = value as Partial<ManastormBossAdminRecord>;
|
||||||
|
const status: ManastormBossStatus = candidate.status === "ready" || candidate.status === "not-ready" ? candidate.status : "needs-tested";
|
||||||
|
const stage = normalizedStage(candidate.stage);
|
||||||
|
return [[key, { status, ...(stage ? { stage } : {}) }]];
|
||||||
|
}));
|
||||||
|
return { schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION, global, dungeonSpawns, bosses };
|
||||||
|
}
|
||||||
|
|
||||||
|
function dungeonBossEntityId(dungeonId: string, objectiveId: string): string {
|
||||||
|
const dungeon = dungeonDefinitionById(dungeonId);
|
||||||
|
const staticSpawn = dungeon?.staticSpawns.find((spawn) => spawn.id === objectiveId);
|
||||||
|
if (staticSpawn) return staticSpawn.entityId;
|
||||||
|
for (const pack of dungeon?.roamingPacks ?? []) {
|
||||||
|
const member = pack.members.find((candidate) => `${pack.id}:${candidate.id}` === objectiveId);
|
||||||
|
if (member) return member.entityId;
|
||||||
|
}
|
||||||
|
return objectiveId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryMergeKey(mapId: number, name: string): string {
|
||||||
|
return `${mapId}:${normalizedName(name)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUnifiedBossCatalog(): readonly UnifiedBossCatalogEntry[] {
|
||||||
|
const entries = new Map<string, UnifiedBossCatalogEntry>();
|
||||||
|
for (const dungeon of DUNGEON_DEFINITIONS) {
|
||||||
|
for (const objective of dungeon.bosses) {
|
||||||
|
const mergeKey = entryMergeKey(dungeon.mapId, objective.name);
|
||||||
|
entries.set(mergeKey, {
|
||||||
|
key: `dungeon:${dungeon.id}:boss:${objective.id}`,
|
||||||
|
name: objective.name,
|
||||||
|
dungeonId: dungeon.id,
|
||||||
|
dungeonTitle: dungeon.title,
|
||||||
|
mapId: dungeon.mapId,
|
||||||
|
bossRuntimeId: objective.id,
|
||||||
|
bossEntityId: dungeonBossEntityId(dungeon.id, objective.id),
|
||||||
|
source: ["dungeon"],
|
||||||
|
sourceIds: [objective.id],
|
||||||
|
baseStage: null,
|
||||||
|
objective,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const stage of runtimeStages(INSTALLED_MANASTORM_RUNTIME_CATALOG)) {
|
||||||
|
const mapId = stage.mapId ?? dungeonDefinitionById(stage.dungeonId)?.mapId ?? 0;
|
||||||
|
const mergeKey = entryMergeKey(mapId, stage.bossName);
|
||||||
|
const matchingDungeon = DUNGEON_DEFINITIONS.find((dungeon) => dungeon.mapId === mapId) ?? null;
|
||||||
|
const existing = entries.get(mergeKey);
|
||||||
|
const mergedStage = existing?.baseStage
|
||||||
|
? {
|
||||||
|
...stage,
|
||||||
|
modeIds: [...new Set([...(existing.baseStage.modeIds ?? []), ...(stage.modeIds ?? [])])],
|
||||||
|
}
|
||||||
|
: stage;
|
||||||
|
const key = stage.encounterId !== undefined
|
||||||
|
? `map:${mapId}:encounter:${stage.encounterId}`
|
||||||
|
: existing?.key ?? `manastorm:${stage.id}`;
|
||||||
|
entries.set(mergeKey, {
|
||||||
|
key,
|
||||||
|
name: stage.bossName,
|
||||||
|
dungeonId: matchingDungeon?.id ?? existing?.dungeonId ?? null,
|
||||||
|
dungeonTitle: matchingDungeon?.title ?? stage.location ?? existing?.dungeonTitle ?? `Map ${mapId}`,
|
||||||
|
mapId,
|
||||||
|
bossRuntimeId: existing?.bossRuntimeId ?? stage.bossRuntimeId,
|
||||||
|
bossEntityId: existing?.bossEntityId ?? stage.bossEntityId,
|
||||||
|
source: [...new Set([...(existing?.source ?? []), "installed-manastorm" as const])],
|
||||||
|
sourceIds: [...new Set([...(existing?.sourceIds ?? []), stage.id])],
|
||||||
|
baseStage: mergedStage,
|
||||||
|
objective: existing?.objective ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Object.freeze([...entries.values()].sort((left, right) => (
|
||||||
|
left.dungeonTitle.localeCompare(right.dungeonTitle) || left.name.localeCompare(right.name)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UNIFIED_MANASTORM_BOSS_CATALOG = buildUnifiedBossCatalog();
|
||||||
|
|
||||||
|
function knownMobRuntimeIds(entry: UnifiedBossCatalogEntry): ReadonlySet<string> {
|
||||||
|
const ids = new Set<string>(entry.baseStage?.trashRuntimeIds ?? []);
|
||||||
|
const dungeon = entry.dungeonId ? dungeonDefinitionById(entry.dungeonId) : null;
|
||||||
|
for (const spawn of dungeon?.staticSpawns ?? []) ids.add(spawn.id);
|
||||||
|
for (const pack of dungeon?.roamingPacks ?? []) {
|
||||||
|
for (const member of pack.members) ids.add(`${pack.id}:${member.id}`);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configuredStageForBoss(
|
||||||
|
entry: UnifiedBossCatalogEntry,
|
||||||
|
config: ManastormAdminConfig,
|
||||||
|
): ManastormEncounterDefinition | null {
|
||||||
|
const override = config.bosses[entry.key]?.stage;
|
||||||
|
const base = entry.baseStage;
|
||||||
|
if (!override && !base) return null;
|
||||||
|
const dungeonId = override?.dungeonId ?? entry.dungeonId ?? base?.dungeonId;
|
||||||
|
if (!dungeonId) return null;
|
||||||
|
return {
|
||||||
|
...(base ?? {}),
|
||||||
|
id: base?.id ?? `gm:stage:${entry.key}`,
|
||||||
|
dungeonId,
|
||||||
|
mapId: override?.mapId ?? base?.mapId ?? entry.mapId,
|
||||||
|
location: base?.location ?? entry.dungeonTitle,
|
||||||
|
...(override?.assetPackageId || base?.assetPackageId
|
||||||
|
? { assetPackageId: override?.assetPackageId ?? base?.assetPackageId }
|
||||||
|
: {}),
|
||||||
|
bossRuntimeId: override?.bossRuntimeId ?? base?.bossRuntimeId ?? entry.bossRuntimeId,
|
||||||
|
bossEntityId: override?.bossEntityId ?? base?.bossEntityId ?? entry.bossEntityId,
|
||||||
|
bossName: override?.bossName ?? base?.bossName ?? entry.name,
|
||||||
|
bossPosition: override?.bossPosition ?? base?.bossPosition ?? entry.objective?.position ?? [0, 0, 0],
|
||||||
|
bossYaw: override?.bossYaw ?? base?.bossYaw ?? 0,
|
||||||
|
playerSpawn: override?.groupSpawn ?? base?.playerSpawn ?? config.dungeonSpawns[dungeonId] ?? dungeonDefinitionById(dungeonId)?.entrance ?? { footPosition: [0, 0, 0], yaw: 0 },
|
||||||
|
portalPosition: override?.portalPosition ?? base?.portalPosition ?? config.dungeonSpawns[dungeonId]?.footPosition ?? dungeonDefinitionById(dungeonId)?.entrance.footPosition ?? [0, 0, 0],
|
||||||
|
trashRuntimeIds: override?.linkedMobRuntimeIds ?? base?.trashRuntimeIds ?? [],
|
||||||
|
requiredGuardianKills: override?.requiredKillCount ?? base?.requiredGuardianKills,
|
||||||
|
modeIds: base?.modeIds ?? [0, 2],
|
||||||
|
stageKind: base?.stageKind ?? "standard",
|
||||||
|
unlockLevel: base?.unlockLevel ?? 1,
|
||||||
|
minimumPartySize: base?.minimumPartySize ?? 1,
|
||||||
|
maximumPartySize: base?.maximumPartySize ?? 5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Produces the complete persisted override edited by GM tooling. */
|
||||||
|
export function authoredStageConfigForBoss(
|
||||||
|
entry: UnifiedBossCatalogEntry,
|
||||||
|
config: ManastormAdminConfig,
|
||||||
|
): ManastormAdminStageConfig | null {
|
||||||
|
const stage = configuredStageForBoss(entry, config);
|
||||||
|
if (!stage) {
|
||||||
|
if (!entry.dungeonId || !entry.objective) return null;
|
||||||
|
const dungeon = dungeonDefinitionById(entry.dungeonId);
|
||||||
|
if (!dungeon) return null;
|
||||||
|
const groupSpawn = config.dungeonSpawns[entry.dungeonId] ?? dungeon.entrance;
|
||||||
|
const staticBoss = dungeon.staticSpawns.find((spawn) => spawn.id === entry.bossRuntimeId);
|
||||||
|
return {
|
||||||
|
dungeonId: entry.dungeonId,
|
||||||
|
mapId: entry.mapId,
|
||||||
|
bossRuntimeId: entry.bossRuntimeId,
|
||||||
|
bossEntityId: entry.bossEntityId,
|
||||||
|
bossName: entry.name,
|
||||||
|
bossPosition: [...entry.objective.position] as Vector3Tuple,
|
||||||
|
bossYaw: staticBoss?.yaw ?? 0,
|
||||||
|
groupSpawn: { footPosition: [...groupSpawn.footPosition] as Vector3Tuple, yaw: groupSpawn.yaw },
|
||||||
|
portalPosition: [...groupSpawn.footPosition] as Vector3Tuple,
|
||||||
|
linkedMobRuntimeIds: [],
|
||||||
|
requiredKillCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
dungeonId: stage.dungeonId,
|
||||||
|
mapId: stage.mapId ?? entry.mapId,
|
||||||
|
baseStageId: stage.id,
|
||||||
|
...(stage.assetPackageId ? { assetPackageId: stage.assetPackageId } : {}),
|
||||||
|
bossRuntimeId: stage.bossRuntimeId,
|
||||||
|
bossEntityId: stage.bossEntityId,
|
||||||
|
bossName: stage.bossName,
|
||||||
|
bossPosition: [...stage.bossPosition] as Vector3Tuple,
|
||||||
|
bossYaw: stage.bossYaw ?? 0,
|
||||||
|
groupSpawn: {
|
||||||
|
footPosition: [...stage.playerSpawn.footPosition] as Vector3Tuple,
|
||||||
|
yaw: stage.playerSpawn.yaw,
|
||||||
|
},
|
||||||
|
portalPosition: [...stage.portalPosition] as Vector3Tuple,
|
||||||
|
linkedMobRuntimeIds: [...(stage.trashRuntimeIds ?? [])],
|
||||||
|
requiredKillCount: Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(stage.trashRuntimeIds?.length ?? 0, stage.requiredGuardianKills ?? Math.ceil((stage.trashRuntimeIds?.length ?? 0) / 2)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Materializes inherited stage data when a catalog boss is promoted to Ready. */
|
||||||
|
export function bossAdminRecordForStatus(
|
||||||
|
entry: UnifiedBossCatalogEntry,
|
||||||
|
config: ManastormAdminConfig,
|
||||||
|
status: ManastormBossStatus,
|
||||||
|
): ManastormBossAdminRecord {
|
||||||
|
const existing = config.bosses[entry.key];
|
||||||
|
const stage = status === "ready"
|
||||||
|
? (existing?.stage ?? authoredStageConfigForBoss(entry, config) ?? undefined)
|
||||||
|
: existing?.stage;
|
||||||
|
return { ...existing, status, ...(stage ? { stage } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBossAdminEntry(
|
||||||
|
entry: UnifiedBossCatalogEntry,
|
||||||
|
config: ManastormAdminConfig,
|
||||||
|
): ResolvedBossAdminEntry {
|
||||||
|
const status = config.bosses[entry.key]?.status ?? "needs-tested";
|
||||||
|
const stage = configuredStageForBoss(entry, config);
|
||||||
|
const issues: string[] = [];
|
||||||
|
if (!stage) issues.push("No runnable Manastorm stage has been configured.");
|
||||||
|
if (stage && !dungeonDefinitionById(stage.dungeonId)) issues.push("The configured dungeon is not installed.");
|
||||||
|
if (stage) {
|
||||||
|
const dungeon = dungeonDefinitionById(stage.dungeonId);
|
||||||
|
if (entry.dungeonId && stage.dungeonId !== entry.dungeonId) issues.push("The stage references a different dungeon than its boss record.");
|
||||||
|
if (dungeon && stage.mapId !== dungeon.mapId) issues.push("The stage map does not match its dungeon.");
|
||||||
|
const knownIds = knownMobRuntimeIds(entry);
|
||||||
|
const missingIds = (stage.trashRuntimeIds ?? []).filter((id) => !knownIds.has(id));
|
||||||
|
if (missingIds.length) issues.push(`Missing linked mobs: ${missingIds.join(", ")}.`);
|
||||||
|
const required = stage.requiredGuardianKills ?? Math.ceil((stage.trashRuntimeIds?.length ?? 0) / 2);
|
||||||
|
if (required < 0 || required > (stage.trashRuntimeIds?.length ?? 0)) issues.push("The required kill count is outside the linked mob set.");
|
||||||
|
}
|
||||||
|
const runnable = Boolean(stage && issues.length === 0);
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
status,
|
||||||
|
stage,
|
||||||
|
runnable,
|
||||||
|
validationIssues: Object.freeze(issues),
|
||||||
|
inPublicPool: status === "ready" && runnable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvedBossAdminCatalog(config: ManastormAdminConfig): readonly ResolvedBossAdminEntry[] {
|
||||||
|
return UNIFIED_MANASTORM_BOSS_CATALOG.map((entry) => resolveBossAdminEntry(entry, config));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveManastormCatalog(
|
||||||
|
config: ManastormAdminConfig,
|
||||||
|
base: ManastormCatalogLike = INSTALLED_MANASTORM_RUNTIME_CATALOG,
|
||||||
|
): ManastormCatalogLike {
|
||||||
|
const stages = resolvedBossAdminCatalog(config)
|
||||||
|
.filter((entry) => entry.inPublicPool)
|
||||||
|
.flatMap((entry) => entry.stage ? [entry.stage] : []);
|
||||||
|
return Object.freeze({
|
||||||
|
...base,
|
||||||
|
stages: Object.freeze(stages.map((stage) => Object.freeze({
|
||||||
|
...stage,
|
||||||
|
chaoticLinkEnabled: config.global.chaoticLinkEnabled,
|
||||||
|
}))),
|
||||||
|
fallbackPolicy: "none" as const,
|
||||||
|
runtimeOptions: Object.freeze({ ...config.global }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGameMasterSession(session: { readonly kind: string; readonly accessToken?: string; readonly roles?: readonly string[] } | null): boolean {
|
||||||
|
return Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function derivePartySpawnSlots(spawn: WorldSpawn, partySize = 5): readonly WorldSpawn[] {
|
||||||
|
const size = Math.max(1, Math.min(5, Math.trunc(partySize)));
|
||||||
|
const offsets = [
|
||||||
|
[0, 0],
|
||||||
|
[-1.65, -1.8],
|
||||||
|
[1.65, -1.8],
|
||||||
|
[-3.05, -3.5],
|
||||||
|
[3.05, -3.5],
|
||||||
|
] as const;
|
||||||
|
const sin = Math.sin(spawn.yaw);
|
||||||
|
const cos = Math.cos(spawn.yaw);
|
||||||
|
return Object.freeze(offsets.slice(0, size).map(([lateral, forward]) => ({
|
||||||
|
footPosition: [
|
||||||
|
spawn.footPosition[0] + lateral * cos + forward * sin,
|
||||||
|
spawn.footPosition[1],
|
||||||
|
spawn.footPosition[2] - lateral * sin + forward * cos,
|
||||||
|
] as const,
|
||||||
|
yaw: spawn.yaw,
|
||||||
|
})));
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { PlayerSession } from "../app/types";
|
||||||
|
import { requestOnlineJson } from "../app/onlineAccountClient";
|
||||||
|
import {
|
||||||
|
DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||||
|
MANASTORM_ADMIN_CACHE_KEY,
|
||||||
|
normalizeManastormAdminConfig,
|
||||||
|
resolvedBossAdminCatalog,
|
||||||
|
type ManastormAdminConfig,
|
||||||
|
type PublishedManastormAdminConfig,
|
||||||
|
} from "./manastormAdminConfig";
|
||||||
|
|
||||||
|
interface CachedPayload extends PublishedManastormAdminConfig {}
|
||||||
|
|
||||||
|
function readCache(): CachedPayload {
|
||||||
|
try {
|
||||||
|
const raw = typeof window !== "undefined" ? window.localStorage.getItem(MANASTORM_ADMIN_CACHE_KEY) : null;
|
||||||
|
const parsed = raw ? JSON.parse(raw) as Partial<CachedPayload> : null;
|
||||||
|
if (!parsed || !Number.isInteger(parsed.revision) || (parsed.revision ?? -1) < 0) throw new Error("missing cache");
|
||||||
|
return {
|
||||||
|
revision: parsed.revision!,
|
||||||
|
updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : null,
|
||||||
|
config: normalizeManastormAdminConfig(parsed.config),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { revision: 0, updatedAt: null, config: DEFAULT_MANASTORM_ADMIN_CONFIG };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCache(payload: PublishedManastormAdminConfig): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(MANASTORM_ADMIN_CACHE_KEY, JSON.stringify(payload));
|
||||||
|
} catch {
|
||||||
|
// Private browsing and storage quotas must not prevent play.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManastormAdminState extends PublishedManastormAdminConfig {
|
||||||
|
readonly draft: ManastormAdminConfig;
|
||||||
|
readonly loading: boolean;
|
||||||
|
readonly saving: boolean;
|
||||||
|
readonly error: string | null;
|
||||||
|
readonly editingBossKey: string | null;
|
||||||
|
readonly dirty: boolean;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
replaceDraft: (config: ManastormAdminConfig) => void;
|
||||||
|
resetDraft: () => void;
|
||||||
|
publish: (session: PlayerSession | null) => Promise<boolean>;
|
||||||
|
setEditingBossKey: (key: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = readCache();
|
||||||
|
|
||||||
|
export const useManastormAdminStore = create<ManastormAdminState>((set, get) => ({
|
||||||
|
...cached,
|
||||||
|
draft: cached.config,
|
||||||
|
loading: false,
|
||||||
|
saving: false,
|
||||||
|
error: null,
|
||||||
|
editingBossKey: null,
|
||||||
|
dirty: false,
|
||||||
|
refresh: async () => {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const payload = await requestOnlineJson<PublishedManastormAdminConfig>("/api/manastorm-config");
|
||||||
|
const normalized = { ...payload, config: normalizeManastormAdminConfig(payload.config) };
|
||||||
|
writeCache(normalized);
|
||||||
|
set({ ...normalized, draft: normalized.config, dirty: false, loading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ loading: false, error: error instanceof Error ? error.message : "Could not refresh Manastorm configuration." });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
replaceDraft: (draft) => set({ draft: normalizeManastormAdminConfig(draft), dirty: true, error: null }),
|
||||||
|
resetDraft: () => set((state) => ({ draft: state.config, dirty: false, error: null })),
|
||||||
|
publish: async (session) => {
|
||||||
|
if (!session?.accessToken || !session.roles?.includes("gm")) {
|
||||||
|
set({ error: "Game Master access is required." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const invalidReadyBoss = resolvedBossAdminCatalog(get().draft).find((entry) => entry.status === "ready" && !entry.runnable);
|
||||||
|
if (invalidReadyBoss) {
|
||||||
|
set({ error: `${invalidReadyBoss.name} cannot be published as Ready: ${invalidReadyBoss.validationIssues.join(" ")}` });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
set({ saving: true, error: null });
|
||||||
|
try {
|
||||||
|
const payload = await requestOnlineJson<PublishedManastormAdminConfig>("/api/gm/manastorm-config", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ expectedRevision: get().revision, config: get().draft }),
|
||||||
|
}, session.accessToken);
|
||||||
|
const normalized = { ...payload, config: normalizeManastormAdminConfig(payload.config) };
|
||||||
|
writeCache(normalized);
|
||||||
|
set({ ...normalized, draft: normalized.config, dirty: false, saving: false });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ saving: false, error: error instanceof Error ? error.message : "Could not publish Manastorm configuration." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setEditingBossKey: (editingBossKey) => set({ editingBossKey }),
|
||||||
|
}));
|
||||||
@@ -77,4 +77,20 @@ describe("Manastorm Chaotic Link", () => {
|
|||||||
expect(manastormChaoticLinkRuntimeIds(encounter, "boss")).toEqual(expected);
|
expect(manastormChaoticLinkRuntimeIds(encounter, "boss")).toEqual(expected);
|
||||||
expect(manastormChaoticLinkRuntimeIds(encounter, "next")).toEqual([]);
|
expect(manastormChaoticLinkRuntimeIds(encounter, "next")).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses an authored exact requirement and disables empowerment with the global switch", () => {
|
||||||
|
expect(manastormChaoticLinkState({ ...encounter, requiredGuardianKills: 1 }, {})).toMatchObject({
|
||||||
|
requiredGuardianKills: 1,
|
||||||
|
activeStacks: 1,
|
||||||
|
broken: false,
|
||||||
|
});
|
||||||
|
expect(manastormChaoticLinkState({ ...encounter, requiredGuardianKills: 5, chaoticLinkEnabled: false }, {})).toMatchObject({
|
||||||
|
guardianCount: 5,
|
||||||
|
requiredGuardianKills: 0,
|
||||||
|
activeStacks: 0,
|
||||||
|
broken: true,
|
||||||
|
bossHealthMultiplier: 1,
|
||||||
|
bossDamageMultiplier: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,15 @@ export function manastormChaoticLinkState(
|
|||||||
const defeatedGuardians = guardianIds.filter(
|
const defeatedGuardians = guardianIds.filter(
|
||||||
(runtimeId) => mobs[runtimeId]?.dead === true,
|
(runtimeId) => mobs[runtimeId]?.dead === true,
|
||||||
).length;
|
).length;
|
||||||
const requiredGuardianKills = Math.ceil(guardianCount / 2);
|
const requiredGuardianKills = encounter.chaoticLinkEnabled === false
|
||||||
|
? 0
|
||||||
|
: Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
guardianCount,
|
||||||
|
Math.trunc(encounter.requiredGuardianKills ?? Math.ceil(guardianCount / 2)),
|
||||||
|
),
|
||||||
|
);
|
||||||
const activeStacks = Math.max(0, requiredGuardianKills - defeatedGuardians);
|
const activeStacks = Math.max(0, requiredGuardianKills - defeatedGuardians);
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
guardianCount,
|
guardianCount,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { LOOT_CATALOG, rollBossLoot } from "./lootCatalog";
|
|||||||
import { useManastormStore } from "./manastormStore";
|
import { useManastormStore } from "./manastormStore";
|
||||||
import { useGameStore } from "./store";
|
import { useGameStore } from "./store";
|
||||||
import { getManastormCatalog, manastormScaling } from "./manastorm";
|
import { getManastormCatalog, manastormScaling } from "./manastorm";
|
||||||
|
import { requestOnlineInteraction } from "./onlineSessionRuntime";
|
||||||
|
|
||||||
/** Shared keyboard, controller, touch, and HUD interaction for the active corpse. */
|
/** Shared keyboard, controller, touch, and HUD interaction for the active corpse. */
|
||||||
export function lootCurrentManastormBoss(now = Date.now()): boolean {
|
export function lootCurrentManastormBoss(now = Date.now()): boolean {
|
||||||
@@ -11,6 +12,7 @@ export function lootCurrentManastormBoss(now = Date.now()): boolean {
|
|||||||
if (game.gameMode !== "manastorm" || manastorm.status !== "awaiting-loot") return false;
|
if (game.gameMode !== "manastorm" || manastorm.status !== "awaiting-loot") return false;
|
||||||
const bossId = manastorm.currentEncounter?.bossRuntimeId;
|
const bossId = manastorm.currentEncounter?.bossRuntimeId;
|
||||||
if (!bossId) return false;
|
if (!bossId) return false;
|
||||||
|
if (requestOnlineInteraction("loot", bossId)) return true;
|
||||||
return useCombatStore.getState().lootMob(bossId, now);
|
return useCombatStore.getState().lootMob(bossId, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ describe("Manastorm encounter sessions", () => {
|
|||||||
expect(useManastormStore.getState().status).toBe("failed");
|
expect(useManastormStore.getState().status).toBe("failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("spends a remaining charge instead of failing a full-party down event", () => {
|
it("waits for an explicit shared resurrection when the full party is down", () => {
|
||||||
useCombatStore.setState({ health: 0 });
|
useCombatStore.setState({ health: 0 });
|
||||||
const companions = usePartyStore.getState().members;
|
const companions = usePartyStore.getState().members;
|
||||||
for (const companion of companions) {
|
for (const companion of companions) {
|
||||||
@@ -184,13 +184,17 @@ describe("Manastorm encounter sessions", () => {
|
|||||||
expect(resolveManastormActorDown({
|
expect(resolveManastormActorDown({
|
||||||
kind: "companion",
|
kind: "companion",
|
||||||
memberId: companion.id,
|
memberId: companion.id,
|
||||||
})).toBe("revived");
|
})).toBe("downed");
|
||||||
expect(usePartyStore.getState().members.at(-1)!.health).toBe(companion.maxHealth);
|
expect(usePartyStore.getState().members.at(-1)!.health).toBe(0);
|
||||||
expect(useCombatStore.getState().health).toBe(0);
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
expect(useManastormStore.getState()).toMatchObject({
|
expect(useManastormStore.getState()).toMatchObject({
|
||||||
status: "entering",
|
status: "entering",
|
||||||
resurrectionCharges: 4,
|
resurrectionCharges: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(resurrectManastormSession({ kind: "player" })).toBe(true);
|
||||||
|
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
|
||||||
|
expect(useManastormStore.getState().resurrectionCharges).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drives real trash, boss, loot, portal, and next-stage state end to end", () => {
|
it("drives real trash, boss, loot, portal, and next-stage state end to end", () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useCombatStore } from "./combatStore";
|
|||||||
import { synchronizeManastormAffixes } from "./manastormAffixRuntime";
|
import { synchronizeManastormAffixes } from "./manastormAffixRuntime";
|
||||||
import { useManastormStore } from "./manastormStore";
|
import { useManastormStore } from "./manastormStore";
|
||||||
import { usePartyStore } from "./partyStore";
|
import { usePartyStore } from "./partyStore";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
import {
|
import {
|
||||||
manastormChaoticLinkState,
|
manastormChaoticLinkState,
|
||||||
type ManastormChaoticLinkState,
|
type ManastormChaoticLinkState,
|
||||||
@@ -153,7 +154,12 @@ export function synchronizeManastormPartySize(): void {
|
|||||||
function activateCurrentStage(preserveCharacter: boolean): boolean {
|
function activateCurrentStage(preserveCharacter: boolean): boolean {
|
||||||
const encounter = useManastormStore.getState().currentEncounter;
|
const encounter = useManastormStore.getState().currentEncounter;
|
||||||
if (!encounter) return false;
|
if (!encounter) return false;
|
||||||
const result = activateManastormStage(encounter, undefined, preserveCharacter);
|
const result = activateManastormStage(
|
||||||
|
encounter,
|
||||||
|
undefined,
|
||||||
|
preserveCharacter,
|
||||||
|
useGameStore.getState().gmTestMode,
|
||||||
|
);
|
||||||
if (result.activated) synchronizeManastormPartySize();
|
if (result.activated) synchronizeManastormPartySize();
|
||||||
return result.activated;
|
return result.activated;
|
||||||
}
|
}
|
||||||
@@ -234,7 +240,7 @@ function reviveTarget(target: ManastormResurrectionTarget): boolean {
|
|||||||
if (target.kind === "player") {
|
if (target.kind === "player") {
|
||||||
const combat = useCombatStore.getState();
|
const combat = useCombatStore.getState();
|
||||||
if (combat.health > 0 || combat.maxHealth <= 0) return false;
|
if (combat.health > 0 || combat.maxHealth <= 0) return false;
|
||||||
combat.healPlayer(combat.maxHealth);
|
combat.revivePlayer(1);
|
||||||
return useCombatStore.getState().health > 0;
|
return useCombatStore.getState().health > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,16 +283,16 @@ export function manastormPartyIsDefeated(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a newly downed actor without resetting the floor. An actor remains
|
* Resolves a newly downed actor without spending a shared resurrection on the
|
||||||
* down when the shared pool is empty while an ally is still standing. Only a
|
* player's behalf. A charge can be used explicitly from the defeated prompt;
|
||||||
* full-party defeat with no valid charge-backed revive fails the run.
|
* only a full-party defeat with no remaining charge fails the run.
|
||||||
*/
|
*/
|
||||||
export function resolveManastormActorDown(
|
export function resolveManastormActorDown(
|
||||||
target: ManastormResurrectionTarget,
|
target: ManastormResurrectionTarget,
|
||||||
): ManastormDownResolution {
|
): ManastormDownResolution {
|
||||||
if (!targetIsFallen(target)) return "ignored";
|
if (!targetIsFallen(target)) return "ignored";
|
||||||
if (resurrectManastormSession(target)) return "revived";
|
|
||||||
if (!manastormPartyIsDefeated()) return "downed";
|
if (!manastormPartyIsDefeated()) return "downed";
|
||||||
|
if (useManastormStore.getState().resurrectionCharges > 0) return "downed";
|
||||||
useManastormStore.getState().failRun();
|
useManastormStore.getState().failRun();
|
||||||
return "failed";
|
return "failed";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export function activateManastormStage(
|
|||||||
stage: ManastormLoadableStage,
|
stage: ManastormLoadableStage,
|
||||||
loader: ManastormStageLoader = defaultLoader,
|
loader: ManastormStageLoader = defaultLoader,
|
||||||
preserveCharacter = true,
|
preserveCharacter = true,
|
||||||
|
gmTestMode = false,
|
||||||
): ManastormStageLoadResult {
|
): ManastormStageLoadResult {
|
||||||
const nextPackage = stage.assetPackageId
|
const nextPackage = stage.assetPackageId
|
||||||
? manastormStageAssetPackageById(stage.assetPackageId)
|
? manastormStageAssetPackageById(stage.assetPackageId)
|
||||||
@@ -76,6 +77,7 @@ export function activateManastormStage(
|
|||||||
gameMode: "manastorm",
|
gameMode: "manastorm",
|
||||||
spawn: stage.playerSpawn,
|
spawn: stage.playerSpawn,
|
||||||
preserveCharacter,
|
preserveCharacter,
|
||||||
|
gmTestMode,
|
||||||
});
|
});
|
||||||
if (!activated) {
|
if (!activated) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "./manastormInstalledStages";
|
} from "./manastormInstalledStages";
|
||||||
import {
|
import {
|
||||||
manastormStagePopulation,
|
manastormStagePopulation,
|
||||||
|
manastormStageRoamingPacks,
|
||||||
registerManastormStageBindingResolver,
|
registerManastormStageBindingResolver,
|
||||||
} from "./manastormStagePopulation";
|
} from "./manastormStagePopulation";
|
||||||
|
|
||||||
@@ -82,4 +83,23 @@ describe("installed Manastorm stage population", () => {
|
|||||||
.toBeGreaterThan(1);
|
.toBeGreaterThan(1);
|
||||||
expect(presentations.every((visual) => visual && !visual.model)).toBe(true);
|
expect(presentations.every((visual) => visual && !visual.model)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes a configured roaming boss from its patrol so its fixed spawn is authoritative", () => {
|
||||||
|
const packs = [{
|
||||||
|
id: "boss-patrol",
|
||||||
|
name: "Boss patrol",
|
||||||
|
speed: 1,
|
||||||
|
formationSource: "database-solo" as const,
|
||||||
|
waypoints: [[0, 0, 0], [2, 0, 0]] as const,
|
||||||
|
members: [
|
||||||
|
{ id: "boss", entityId: "boss-entity", role: "leader" as const, formationOffset: [0, 0, 0] as const },
|
||||||
|
{ id: "guard", entityId: "guard-entity", role: "follower" as const, formationOffset: [1, 0, 1] as const },
|
||||||
|
],
|
||||||
|
}];
|
||||||
|
const filtered = manastormStageRoamingPacks(packs, {
|
||||||
|
...stages[0]!,
|
||||||
|
bossRuntimeId: "boss-patrol:boss",
|
||||||
|
});
|
||||||
|
expect(filtered[0].members.map((member) => member.id)).toEqual(["guard"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ManastormEncounterDefinition } from "./manastorm";
|
|||||||
import type {
|
import type {
|
||||||
BossDefinition,
|
BossDefinition,
|
||||||
MobDefinition,
|
MobDefinition,
|
||||||
|
MobPackDefinition,
|
||||||
PopulationDefinitionMap,
|
PopulationDefinitionMap,
|
||||||
StaticMobSpawnDefinition,
|
StaticMobSpawnDefinition,
|
||||||
} from "./mobPopulation";
|
} from "./mobPopulation";
|
||||||
@@ -12,6 +13,19 @@ export interface ManastormStagePopulation {
|
|||||||
readonly staticSpawns: readonly StaticMobSpawnDefinition[];
|
readonly staticSpawns: readonly StaticMobSpawnDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Removes a dungeon-native roaming boss from its patrol when a fixed authored spawn replaces it. */
|
||||||
|
export function manastormStageRoamingPacks(
|
||||||
|
packs: readonly MobPackDefinition[],
|
||||||
|
encounter: ManastormEncounterDefinition | null,
|
||||||
|
): readonly MobPackDefinition[] {
|
||||||
|
if (!encounter) return packs;
|
||||||
|
return packs.flatMap((pack) => {
|
||||||
|
const members = pack.members.filter((member) => `${pack.id}:${member.id}` !== encounter.bossRuntimeId);
|
||||||
|
if (!members.length) return [];
|
||||||
|
return members.length === pack.members.length ? [pack] : [{ ...pack, members }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type ManastormStageBindingResolver = (
|
type ManastormStageBindingResolver = (
|
||||||
stageId: string,
|
stageId: string,
|
||||||
) => ManastormInstalledStageBinding | null;
|
) => ManastormInstalledStageBinding | null;
|
||||||
@@ -111,7 +125,16 @@ export function manastormStagePopulation(
|
|||||||
const existingEntity = baseEntities[encounter.bossEntityId];
|
const existingEntity = baseEntities[encounter.bossEntityId];
|
||||||
const existingSpawn = baseStaticSpawns.find((spawn) => spawn.id === encounter.bossRuntimeId);
|
const existingSpawn = baseStaticSpawns.find((spawn) => spawn.id === encounter.bossRuntimeId);
|
||||||
if (existingEntity && existingSpawn) {
|
if (existingEntity && existingSpawn) {
|
||||||
return { entities: baseEntities, staticSpawns: baseStaticSpawns };
|
return {
|
||||||
|
entities: baseEntities,
|
||||||
|
staticSpawns: baseStaticSpawns.map((spawn) => spawn.id === encounter.bossRuntimeId
|
||||||
|
? {
|
||||||
|
...spawn,
|
||||||
|
position: encounter.bossPosition,
|
||||||
|
...(Number.isFinite(encounter.bossYaw) ? { yaw: encounter.bossYaw } : {}),
|
||||||
|
}
|
||||||
|
: spawn),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const exactNameVisual = Object.values(baseEntities).find(
|
const exactNameVisual = Object.values(baseEntities).find(
|
||||||
@@ -145,6 +168,7 @@ export function manastormStagePopulation(
|
|||||||
id: encounter.bossRuntimeId,
|
id: encounter.bossRuntimeId,
|
||||||
entityId: encounter.bossEntityId,
|
entityId: encounter.bossEntityId,
|
||||||
position: encounter.bossPosition,
|
position: encounter.bossPosition,
|
||||||
|
...(Number.isFinite(encounter.bossYaw) ? { yaw: encounter.bossYaw } : {}),
|
||||||
spawnMask: 1,
|
spawnMask: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
clearMobRuntimeRegistry,
|
clearMobRuntimeRegistry,
|
||||||
getMobPosition,
|
getMobPosition,
|
||||||
|
getMobRuntimeTransform,
|
||||||
nearbyMobRuntimeIds,
|
nearbyMobRuntimeIds,
|
||||||
readMobRuntimePosition,
|
readMobRuntimePosition,
|
||||||
registerMobRuntime,
|
registerMobRuntime,
|
||||||
@@ -14,12 +15,13 @@ describe("mob runtime position registry", () => {
|
|||||||
beforeEach(clearMobRuntimeRegistry);
|
beforeEach(clearMobRuntimeRegistry);
|
||||||
|
|
||||||
it("supports allocation-free frame updates and snapshot reads", () => {
|
it("supports allocation-free frame updates and snapshot reads", () => {
|
||||||
const unregister = registerMobRuntime("mob-a", [1, 2, 3]);
|
const unregister = registerMobRuntime("mob-a", [1, 2, 3], 0.4);
|
||||||
const out: [number, number, number] = [0, 0, 0];
|
const out: [number, number, number] = [0, 0, 0];
|
||||||
expect(readMobRuntimePosition("mob-a", out)).toBe(true);
|
expect(readMobRuntimePosition("mob-a", out)).toBe(true);
|
||||||
expect(out).toEqual([1, 2, 3]);
|
expect(out).toEqual([1, 2, 3]);
|
||||||
updateMobRuntimePosition("mob-a", 4, 5, 6);
|
updateMobRuntimePosition("mob-a", 4, 5, 6, 1.2);
|
||||||
expect(getMobPosition("mob-a")).toEqual([4, 5, 6]);
|
expect(getMobPosition("mob-a")).toEqual([4, 5, 6]);
|
||||||
|
expect(getMobRuntimeTransform("mob-a")).toEqual({ position: [4, 5, 6], yaw: 1.2 });
|
||||||
unregister();
|
unregister();
|
||||||
expect(getMobPosition("mob-a")).toBeNull();
|
expect(getMobPosition("mob-a")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type MutableCombatPosition = [x: number, y: number, z: number];
|
|||||||
|
|
||||||
interface RuntimeMobPosition {
|
interface RuntimeMobPosition {
|
||||||
readonly value: MutableCombatPosition;
|
readonly value: MutableCombatPosition;
|
||||||
|
yaw: number;
|
||||||
mounted: boolean;
|
mounted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,9 +13,10 @@ function finite(value: number): number {
|
|||||||
return Number.isFinite(value) ? value : 0;
|
return Number.isFinite(value) ? value : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerMobRuntime(id: string, initial: CombatPosition = [0, 0, 0]): () => void {
|
export function registerMobRuntime(id: string, initial: CombatPosition = [0, 0, 0], yaw = 0): () => void {
|
||||||
const record: RuntimeMobPosition = {
|
const record: RuntimeMobPosition = {
|
||||||
value: [finite(initial[0]), finite(initial[1]), finite(initial[2])],
|
value: [finite(initial[0]), finite(initial[1]), finite(initial[2])],
|
||||||
|
yaw: finite(yaw),
|
||||||
mounted: true,
|
mounted: true,
|
||||||
};
|
};
|
||||||
runtimePositions.set(id, record);
|
runtimePositions.set(id, record);
|
||||||
@@ -24,16 +26,17 @@ export function registerMobRuntime(id: string, initial: CombatPosition = [0, 0,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Allocation-free update intended for roaming-pack useFrame callbacks. */
|
/** Allocation-free update intended for roaming-pack useFrame callbacks. */
|
||||||
export function updateMobRuntimePosition(id: string, x: number, y: number, z: number): void {
|
export function updateMobRuntimePosition(id: string, x: number, y: number, z: number, yaw?: number): void {
|
||||||
const record = runtimePositions.get(id);
|
const record = runtimePositions.get(id);
|
||||||
if (record) {
|
if (record) {
|
||||||
record.value[0] = finite(x);
|
record.value[0] = finite(x);
|
||||||
record.value[1] = finite(y);
|
record.value[1] = finite(y);
|
||||||
record.value[2] = finite(z);
|
record.value[2] = finite(z);
|
||||||
|
if (yaw !== undefined) record.yaw = finite(yaw);
|
||||||
record.mounted = true;
|
record.mounted = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runtimePositions.set(id, { value: [finite(x), finite(y), finite(z)], mounted: true });
|
runtimePositions.set(id, { value: [finite(x), finite(y), finite(z)], yaw: finite(yaw ?? 0), mounted: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unregisterMobRuntime(id: string): void {
|
export function unregisterMobRuntime(id: string): void {
|
||||||
@@ -56,6 +59,11 @@ export function getMobRuntimePosition(id: string): CombatPosition | null {
|
|||||||
return record?.mounted ? [...record.value] as MutableCombatPosition : null;
|
return record?.mounted ? [...record.value] as MutableCombatPosition : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMobRuntimeTransform(id: string): { readonly position: CombatPosition; readonly yaw: number } | null {
|
||||||
|
const record = runtimePositions.get(id);
|
||||||
|
return record?.mounted ? { position: [...record.value] as MutableCombatPosition, yaw: record.yaw } : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function mobRuntimeDistanceSquared(id: string, origin: CombatPosition): number {
|
export function mobRuntimeDistanceSquared(id: string, origin: CombatPosition): number {
|
||||||
const record = runtimePositions.get(id);
|
const record = runtimePositions.get(id);
|
||||||
if (!record?.mounted) return Number.POSITIVE_INFINITY;
|
if (!record?.mounted) return Number.POSITIVE_INFINITY;
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { clearMobRuntimeRegistry, registerMobRuntime } from "./mobRuntimeRegistry";
|
||||||
|
import {
|
||||||
|
configureOnlineSessionRuntime,
|
||||||
|
onlinePlayerActorId,
|
||||||
|
} from "./onlineSessionRuntime";
|
||||||
|
import {
|
||||||
|
clearPartyRuntimeRegistry,
|
||||||
|
registerPartyRuntimePosition,
|
||||||
|
} from "./partyRuntimeRegistry";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
|
||||||
|
function remotePlayer(health = 1): OnlineSessionPlayer {
|
||||||
|
return {
|
||||||
|
accountId: "remote",
|
||||||
|
username: "Remote",
|
||||||
|
character: {
|
||||||
|
id: "remote-character",
|
||||||
|
name: "Bulwarka",
|
||||||
|
classId: "warrior",
|
||||||
|
categoryId: "wow",
|
||||||
|
raceId: "human",
|
||||||
|
gender: "female",
|
||||||
|
level: 20,
|
||||||
|
},
|
||||||
|
role: "tank",
|
||||||
|
position: [8, 0, 0],
|
||||||
|
yaw: 0,
|
||||||
|
grounded: true,
|
||||||
|
health,
|
||||||
|
maxHealth: 1_000,
|
||||||
|
resource: 0,
|
||||||
|
maxResource: 100,
|
||||||
|
resourceName: "Rage",
|
||||||
|
selectedTargetId: null,
|
||||||
|
activeCast: null,
|
||||||
|
animationEvent: null,
|
||||||
|
equipment: [],
|
||||||
|
updatedAt: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 20 });
|
||||||
|
useCombatStore.getState().resetEncounter();
|
||||||
|
usePartyStore.setState({ members: [], selectedMemberId: null, selfSelected: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shared online combat actors", () => {
|
||||||
|
it("casts a normal player heal on a remote online player", () => {
|
||||||
|
const player = remotePlayer();
|
||||||
|
const actorId = onlinePlayerActorId(player.accountId);
|
||||||
|
configureOnlineSessionRuntime({
|
||||||
|
active: true,
|
||||||
|
authority: true,
|
||||||
|
localAccountId: "local",
|
||||||
|
players: [player],
|
||||||
|
});
|
||||||
|
registerPartyRuntimePosition(actorId, player.position);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
expect(useCombatStore.getState().selectFriendlyActor(actorId)).toBe(true);
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-priest-lesser-heal", [0, 0, 0], 1_000).ok).toBe(true);
|
||||||
|
const completion = useCombatStore.getState().activeCast?.completesAt ?? 1_000;
|
||||||
|
useCombatStore.getState().tick(0.1, completion);
|
||||||
|
expect(useCombatStore.getState().actors[actorId]?.health).toBeGreaterThan(1);
|
||||||
|
expect(useCombatStore.getState().combatEvents).toContainEqual(
|
||||||
|
expect.objectContaining({ kind: "healing", targetActorId: actorId }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the leader's enemies acquire a nearby remote player", () => {
|
||||||
|
const player = { ...remotePlayer(1_000), position: [1, 0, 0] as const };
|
||||||
|
const actorId = onlinePlayerActorId(player.accountId);
|
||||||
|
configureOnlineSessionRuntime({
|
||||||
|
active: true,
|
||||||
|
authority: true,
|
||||||
|
localAccountId: "local",
|
||||||
|
players: [player],
|
||||||
|
});
|
||||||
|
registerPartyRuntimePosition(actorId, player.position);
|
||||||
|
registerMobRuntime("shared-enemy", [0, 0, 0]);
|
||||||
|
useCombatStore.getState().setPlayerPosition([100, 0, 100]);
|
||||||
|
useCombatStore.getState().registerMob("shared-enemy", {
|
||||||
|
name: "Shared enemy",
|
||||||
|
maxHealth: 100,
|
||||||
|
aggroRange: 5,
|
||||||
|
leashRange: 20,
|
||||||
|
});
|
||||||
|
expect(useCombatStore.getState().engageNearbyMobs(1_000)).toBe(1);
|
||||||
|
expect(useCombatStore.getState().mobs["shared-enemy"].targetActorId).toBe(actorId);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
|
||||||
|
import {
|
||||||
|
configureOnlineSessionRuntime,
|
||||||
|
drainOnlineInteractions,
|
||||||
|
onlinePlayerActorId,
|
||||||
|
onlinePlayerRuntimeActor,
|
||||||
|
onlineSessionIsActive,
|
||||||
|
onlineSessionOwnsWorldSimulation,
|
||||||
|
onlineSynchronizedMobTransform,
|
||||||
|
requestOnlineInteraction,
|
||||||
|
} from "./onlineSessionRuntime";
|
||||||
|
|
||||||
|
const remotePlayer: OnlineSessionPlayer = {
|
||||||
|
accountId: "remote",
|
||||||
|
username: "Remote",
|
||||||
|
character: {
|
||||||
|
id: "remote-character",
|
||||||
|
name: "Mendara",
|
||||||
|
classId: "priest",
|
||||||
|
categoryId: "wow",
|
||||||
|
raceId: "human",
|
||||||
|
gender: "female",
|
||||||
|
level: 20,
|
||||||
|
},
|
||||||
|
role: "healer",
|
||||||
|
position: [4, 0, 6],
|
||||||
|
yaw: 1,
|
||||||
|
grounded: true,
|
||||||
|
health: 800,
|
||||||
|
maxHealth: 1_000,
|
||||||
|
resource: 400,
|
||||||
|
maxResource: 500,
|
||||||
|
resourceName: "Mana",
|
||||||
|
selectedTargetId: null,
|
||||||
|
activeCast: null,
|
||||||
|
animationEvent: null,
|
||||||
|
equipment: [],
|
||||||
|
updatedAt: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => configureOnlineSessionRuntime({
|
||||||
|
active: false,
|
||||||
|
authority: true,
|
||||||
|
localAccountId: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("online session runtime", () => {
|
||||||
|
it("exposes remote players as live combat actors", () => {
|
||||||
|
configureOnlineSessionRuntime({
|
||||||
|
active: true,
|
||||||
|
authority: true,
|
||||||
|
localAccountId: "local",
|
||||||
|
players: [remotePlayer],
|
||||||
|
});
|
||||||
|
const actor = onlinePlayerRuntimeActor(onlinePlayerActorId("remote"));
|
||||||
|
expect(actor).toMatchObject({ health: 800, maxHealth: 1_000, alive: true, kind: "companion" });
|
||||||
|
expect(onlineSessionIsActive()).toBe(true);
|
||||||
|
expect(onlineSessionOwnsWorldSimulation()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses leader transforms and queues follower interactions", () => {
|
||||||
|
configureOnlineSessionRuntime({
|
||||||
|
active: true,
|
||||||
|
authority: false,
|
||||||
|
localAccountId: "local",
|
||||||
|
players: [remotePlayer],
|
||||||
|
mobTransforms: { boss: { position: [7, 0, 9], yaw: 0.75 } },
|
||||||
|
});
|
||||||
|
expect(onlineSessionOwnsWorldSimulation()).toBe(false);
|
||||||
|
expect(onlineSynchronizedMobTransform("boss")?.position).toEqual([7, 0, 9]);
|
||||||
|
expect(requestOnlineInteraction("loot", "boss")).toBe(true);
|
||||||
|
expect(drainOnlineInteractions()).toEqual([
|
||||||
|
expect.objectContaining({ kind: "loot", targetActorId: "boss", sourceActorId: onlinePlayerActorId("local") }),
|
||||||
|
]);
|
||||||
|
expect(drainOnlineInteractions()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import type { ClassId } from "../app/characterCatalog";
|
||||||
|
import type {
|
||||||
|
OnlineMobTransform,
|
||||||
|
OnlineRelayEventInput,
|
||||||
|
OnlineSessionPlayer,
|
||||||
|
} from "../app/onlineSessionTypes";
|
||||||
|
import {
|
||||||
|
normalizeActorResourcePool,
|
||||||
|
type CombatActorState,
|
||||||
|
} from "./combatActors";
|
||||||
|
|
||||||
|
const ONLINE_PLAYER_PREFIX = "online-player:";
|
||||||
|
let active = false;
|
||||||
|
let authority = true;
|
||||||
|
let localAccountId: string | null = null;
|
||||||
|
let playerActors = new Map<string, CombatActorState>();
|
||||||
|
let players = new Map<string, OnlineSessionPlayer>();
|
||||||
|
let mobTransforms = new Map<string, OnlineMobTransform>();
|
||||||
|
let interactionSequence = 0;
|
||||||
|
let interactions: OnlineRelayEventInput[] = [];
|
||||||
|
|
||||||
|
export function onlinePlayerActorId(accountId: string): string {
|
||||||
|
return `${ONLINE_PLAYER_PREFIX}${accountId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlineAccountIdFromActor(actorId: string): string | null {
|
||||||
|
return actorId.startsWith(ONLINE_PLAYER_PREFIX)
|
||||||
|
? actorId.slice(ONLINE_PLAYER_PREFIX.length)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOnlinePlayerActorId(actorId: string): boolean {
|
||||||
|
return actorId.startsWith(ONLINE_PLAYER_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureOnlineSessionRuntime(input: {
|
||||||
|
readonly active: boolean;
|
||||||
|
readonly authority: boolean;
|
||||||
|
readonly localAccountId: string | null;
|
||||||
|
readonly players?: readonly OnlineSessionPlayer[];
|
||||||
|
readonly mobTransforms?: Readonly<Record<string, OnlineMobTransform>>;
|
||||||
|
}): void {
|
||||||
|
active = input.active;
|
||||||
|
authority = !input.active || input.authority;
|
||||||
|
localAccountId = input.localAccountId;
|
||||||
|
players = new Map((input.players ?? []).map((player) => [player.accountId, player]));
|
||||||
|
mobTransforms = new Map(Object.entries(input.mobTransforms ?? {}));
|
||||||
|
const nextActors = new Map<string, CombatActorState>();
|
||||||
|
for (const player of input.players ?? []) {
|
||||||
|
if (player.accountId === input.localAccountId || !player.character) continue;
|
||||||
|
const id = onlinePlayerActorId(player.accountId);
|
||||||
|
const maximum = Math.max(0, player.maxResource);
|
||||||
|
const resourceId = player.resourceName.toLowerCase() || "resource";
|
||||||
|
nextActors.set(id, {
|
||||||
|
id,
|
||||||
|
threatActorId: id,
|
||||||
|
kind: "companion",
|
||||||
|
ownerId: player.accountId,
|
||||||
|
classId: player.character.classId as ClassId,
|
||||||
|
level: player.character.level,
|
||||||
|
health: player.health,
|
||||||
|
maxHealth: player.maxHealth,
|
||||||
|
absorb: 0,
|
||||||
|
resources: maximum > 0
|
||||||
|
? { [resourceId]: normalizeActorResourcePool(resourceId, player.resource, maximum) }
|
||||||
|
: {},
|
||||||
|
cooldowns: {},
|
||||||
|
globalCooldownEndsAt: 0,
|
||||||
|
cast: player.activeCast ? {
|
||||||
|
abilityId: player.activeCast.abilityId,
|
||||||
|
school: player.activeCast.school,
|
||||||
|
mode: player.activeCast.mode,
|
||||||
|
startedAt: player.activeCast.startedAt,
|
||||||
|
completesAt: player.activeCast.completesAt,
|
||||||
|
originalDurationMs: player.activeCast.originalDurationMs,
|
||||||
|
pushbackHits: player.activeCast.pushbackHits,
|
||||||
|
origin: player.activeCast.origin,
|
||||||
|
targetId: player.activeCast.targetId,
|
||||||
|
} : null,
|
||||||
|
controlledUntil: 0,
|
||||||
|
schoolLockedUntil: {},
|
||||||
|
comboPoints: 0,
|
||||||
|
comboTargetId: null,
|
||||||
|
runes: [],
|
||||||
|
alive: player.health > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
playerActors = nextActors;
|
||||||
|
if (!input.active) interactions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlineSessionIsActive(): boolean {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlineSessionOwnsWorldSimulation(): boolean {
|
||||||
|
return !active || authority;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlineSessionLocalAccountId(): string | null {
|
||||||
|
return localAccountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlinePlayerRuntimeActors(): readonly CombatActorState[] {
|
||||||
|
return [...playerActors.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlinePlayerRuntimeActor(actorId: string): CombatActorState | null {
|
||||||
|
return playerActors.get(actorId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlineSessionPlayer(accountId: string): OnlineSessionPlayer | null {
|
||||||
|
return players.get(accountId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlineSynchronizedMobTransform(id: string): OnlineMobTransform | null {
|
||||||
|
return active && !authority ? mobTransforms.get(id) ?? null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestOnlineInteraction(kind: "loot" | "portal", targetActorId = ""): boolean {
|
||||||
|
if (!active || authority) return false;
|
||||||
|
interactionSequence += 1;
|
||||||
|
interactions.push({
|
||||||
|
clientEventId: `interaction:${kind}:${Date.now()}:${interactionSequence}`,
|
||||||
|
kind,
|
||||||
|
sourceActorId: localAccountId ? onlinePlayerActorId(localAccountId) : "",
|
||||||
|
targetActorId,
|
||||||
|
occurredAt: Date.now(),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drainOnlineInteractions(): readonly OnlineRelayEventInput[] {
|
||||||
|
const pending = interactions;
|
||||||
|
interactions = [];
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { abilitiesForClass, abilityAtLevel, resourceProfileForClass } from "./abilityCatalog";
|
||||||
|
import { aiHintsForAbility } from "./abilityRules";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { commitPartyAbilityUse, choosePartyAbility, partyAbilityIsReady } from "./partyAbilityAi";
|
||||||
|
import { usePartyStore, type PartyMember } from "./partyStore";
|
||||||
|
|
||||||
|
function memberFor(classId: PartyMember["classId"], role: PartyMember["role"]): PartyMember {
|
||||||
|
const base = usePartyStore.getState().members[0];
|
||||||
|
const profile = resourceProfileForClass(classId);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
classId,
|
||||||
|
role,
|
||||||
|
level: 80,
|
||||||
|
resourceType: profile.type,
|
||||||
|
resourceName: profile.name,
|
||||||
|
resource: profile.maximum,
|
||||||
|
maxResource: profile.maximum,
|
||||||
|
resourcePools: { [profile.type]: profile.maximum },
|
||||||
|
cooldowns: {},
|
||||||
|
globalCooldownEndsAt: 0,
|
||||||
|
nextActionAt: 0,
|
||||||
|
tauntReadyAt: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("catalog-driven party rotations", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||||
|
usePartyStore.getState().initializeParty("party-ai", 80, useCombatStore.getState().health);
|
||||||
|
useCombatStore.getState().registerMob("caster", { name: "Caster", maxHealth: 1_000, xpReward: 0 });
|
||||||
|
useCombatStore.setState((state) => ({
|
||||||
|
mobs: {
|
||||||
|
...state.mobs,
|
||||||
|
caster: {
|
||||||
|
...state.mobs.caster,
|
||||||
|
activeCast: {
|
||||||
|
attackId: "test-cast",
|
||||||
|
name: "Test Cast",
|
||||||
|
school: "shadow",
|
||||||
|
interruptible: true,
|
||||||
|
targetActorId: "__player__",
|
||||||
|
startedAt: 1_000,
|
||||||
|
completesAt: 5_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritizes an interrupt over ordinary damage deterministically", () => {
|
||||||
|
const member = memberFor("rogue", "damage");
|
||||||
|
const context = { now: 2_000, target: useCombatStore.getState().mobs.caster };
|
||||||
|
const first = choosePartyAbility(member, context);
|
||||||
|
const second = choosePartyAbility(member, context);
|
||||||
|
expect(first?.id).toBe(second?.id);
|
||||||
|
expect(first && aiHintsForAbility(first).categories).toContain("interrupt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects a real healer catalog spell and spends its resource/GCD", () => {
|
||||||
|
const member = memberFor("priest", "healer");
|
||||||
|
const ability = choosePartyAbility(member, {
|
||||||
|
now: 2_000,
|
||||||
|
target: null,
|
||||||
|
injuredAllies: 1,
|
||||||
|
lowestHealthFraction: 0.2,
|
||||||
|
});
|
||||||
|
expect(ability && aiHintsForAbility(ability).categories).toContain("heal");
|
||||||
|
const committed = commitPartyAbilityUse(member, ability!, 2_000)!;
|
||||||
|
expect(committed.resource).toBeLessThan(member.resource);
|
||||||
|
expect(committed.globalCooldownEndsAt).toBeGreaterThan(2_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("will not cast a mana spell after mana exhaustion", () => {
|
||||||
|
const member = { ...memberFor("priest", "healer"), resource: 0, resourcePools: { mana: 0 } };
|
||||||
|
const heal = abilitiesForClass("priest")
|
||||||
|
.map((ability) => abilityAtLevel(ability, 80))
|
||||||
|
.find((ability) => aiHintsForAbility(ability).categories.includes("heal") && ability.cost.amount > 0)!;
|
||||||
|
expect(partyAbilityIsReady(member, heal, 2_000)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import {
|
||||||
|
abilitiesForClass,
|
||||||
|
abilityAtLevel,
|
||||||
|
isAbilityUnlocked,
|
||||||
|
type AbilityDefinition,
|
||||||
|
type AbilityEffect,
|
||||||
|
type ResourceType,
|
||||||
|
} from "./abilityCatalog";
|
||||||
|
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
|
||||||
|
import { spendDeathKnightRunes } from "./combatResources";
|
||||||
|
import type { MobCombatState } from "./combatStore";
|
||||||
|
import type { PartyMember } from "./partyStore";
|
||||||
|
|
||||||
|
export interface PartyAbilityContext {
|
||||||
|
readonly now: number;
|
||||||
|
readonly target: MobCombatState | null;
|
||||||
|
readonly targetIsLoose?: boolean;
|
||||||
|
readonly requiredControl?: boolean;
|
||||||
|
readonly allyNeedsDispel?: boolean;
|
||||||
|
readonly fallenAlly?: boolean;
|
||||||
|
readonly injuredAllies?: number;
|
||||||
|
readonly lowestHealthFraction?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentResource(member: PartyMember, type: ResourceType): number {
|
||||||
|
return member.resourcePools[type] ?? (type === member.resourceType ? member.resource : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function abilityCosts(ability: AbilityDefinition): readonly { resource: ResourceType; amount: number }[] {
|
||||||
|
return (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => ({
|
||||||
|
resource: cost.resource,
|
||||||
|
amount: cost.percentage ? cost.amount / 100 : cost.amount,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinition, now: number): boolean {
|
||||||
|
if (ability.passive || !isAbilityUnlocked(ability, member.level)) return false;
|
||||||
|
if (member.globalCooldownEndsAt > now || (member.cooldowns[ability.id] ?? 0) > now) return false;
|
||||||
|
if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
|
||||||
|
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
|
||||||
|
if (abilityCosts(ability).some((cost) => {
|
||||||
|
const amount = cost.amount <= 1 && ability.cost.percentage
|
||||||
|
? (cost.resource === member.resourceType ? member.maxResource : 100) * cost.amount
|
||||||
|
: cost.amount;
|
||||||
|
return currentResource(member, cost.resource) < amount;
|
||||||
|
})) return false;
|
||||||
|
const runeCost = member.classId === "death-knight" ? runeCostForAbility(ability) : null;
|
||||||
|
return !runeCost || spendDeathKnightRunes(member.runes, runeCost, now) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidateAbilities(member: PartyMember, now: number): readonly AbilityDefinition[] {
|
||||||
|
return abilitiesForClass(member.classId)
|
||||||
|
.filter((ability) => partyAbilityIsReady(member, abilityAtLevel(ability, member.level), now))
|
||||||
|
.map((ability) => abilityAtLevel(ability, member.level));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreAbility(member: PartyMember, ability: AbilityDefinition, context: PartyAbilityContext): number {
|
||||||
|
const categories = new Set(aiHintsForAbility(ability).categories);
|
||||||
|
let score = ability.ai?.priority ?? 0;
|
||||||
|
if (member.role === "tank") {
|
||||||
|
if (context.target?.activeCast && categories.has("interrupt")) score += 1_000;
|
||||||
|
if (context.targetIsLoose && categories.has("taunt")) score += 900;
|
||||||
|
if (categories.has("mitigation") && member.health / member.maxHealth < 0.55) score += 760;
|
||||||
|
if (categories.has("control") && context.requiredControl) score += 700;
|
||||||
|
if (categories.has("debuff")) score += 300;
|
||||||
|
if (categories.has("damage")) score += 180;
|
||||||
|
} else if (member.role === "healer") {
|
||||||
|
if (context.fallenAlly && categories.has("resurrection")) score += 1_200;
|
||||||
|
if (context.allyNeedsDispel && categories.has("dispel")) score += 1_050;
|
||||||
|
if ((context.lowestHealthFraction ?? 1) < 0.3 && categories.has("emergency-heal")) score += 950;
|
||||||
|
if ((context.injuredAllies ?? 0) >= 3 && categories.has("area-heal")) score += 850;
|
||||||
|
if ((context.lowestHealthFraction ?? 1) < 0.9 && categories.has("heal")) score += 600;
|
||||||
|
if (categories.has("mitigation")) score += 350;
|
||||||
|
if (context.target?.activeCast && categories.has("interrupt")) score += 250;
|
||||||
|
if (categories.has("damage")) score += 80;
|
||||||
|
} else {
|
||||||
|
if (context.target?.activeCast && categories.has("interrupt")) score += 1_000;
|
||||||
|
if (context.requiredControl && categories.has("control")) score += 900;
|
||||||
|
if ((context.target?.health ?? 1) / Math.max(1, context.target?.maxHealth ?? 1) <= 0.2 && categories.has("execute")) score += 800;
|
||||||
|
if (categories.has("debuff")) score += 520;
|
||||||
|
if (categories.has("damage")) score += 300;
|
||||||
|
}
|
||||||
|
if (ability.castTimeMs > 0 && context.target?.activeCast && categories.has("interrupt")) score -= 800;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function choosePartyAbility(
|
||||||
|
member: PartyMember,
|
||||||
|
context: PartyAbilityContext,
|
||||||
|
): AbilityDefinition | null {
|
||||||
|
return candidateAbilities(member, context.now)
|
||||||
|
.map((ability) => ({ ability, score: scoreAbility(member, ability, context) }))
|
||||||
|
.filter((candidate) => candidate.score > 0)
|
||||||
|
.sort((left, right) => right.score - left.score
|
||||||
|
|| left.ability.cooldownMs - right.ability.cooldownMs
|
||||||
|
|| left.ability.id.localeCompare(right.ability.id))[0]?.ability ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commitPartyAbilityUse(
|
||||||
|
member: PartyMember,
|
||||||
|
ability: AbilityDefinition,
|
||||||
|
now: number,
|
||||||
|
): PartyMember | null {
|
||||||
|
if (!partyAbilityIsReady(member, ability, now)) return null;
|
||||||
|
const resourcePools = { ...member.resourcePools };
|
||||||
|
const lastResourceSpentAt = { ...member.lastResourceSpentAt };
|
||||||
|
let resource = member.resource;
|
||||||
|
for (const cost of abilityCosts(ability)) {
|
||||||
|
const maximum = cost.resource === member.resourceType ? member.maxResource : 100;
|
||||||
|
const amount = cost.amount <= 1 && ability.cost.percentage ? maximum * cost.amount : cost.amount;
|
||||||
|
const current = currentResource(member, cost.resource);
|
||||||
|
resourcePools[cost.resource] = Math.max(0, current - amount);
|
||||||
|
if (amount > 0) lastResourceSpentAt[cost.resource] = now;
|
||||||
|
if (cost.resource === member.resourceType) resource = resourcePools[cost.resource] ?? resource;
|
||||||
|
}
|
||||||
|
const runeCost = member.classId === "death-knight" ? runeCostForAbility(ability) : null;
|
||||||
|
const runes = runeCost ? spendDeathKnightRunes(member.runes, runeCost, now) : member.runes;
|
||||||
|
if (!runes) return null;
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
resource,
|
||||||
|
resourcePools,
|
||||||
|
lastResourceSpentAt,
|
||||||
|
runes,
|
||||||
|
cooldowns: ability.cooldownMs > 0
|
||||||
|
? { ...member.cooldowns, [ability.id]: now + ability.cooldownMs }
|
||||||
|
: member.cooldowns,
|
||||||
|
globalCooldownEndsAt: now + ability.gcdMs,
|
||||||
|
tauntReadyAt: aiHintsForAbility(ability).categories.includes("taunt")
|
||||||
|
? now + Math.max(8_000, ability.cooldownMs)
|
||||||
|
: member.tauntReadyAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function partyAbilityAmount(
|
||||||
|
member: PartyMember,
|
||||||
|
ability: AbilityDefinition,
|
||||||
|
effect: AbilityEffect,
|
||||||
|
): number {
|
||||||
|
const coefficient = "coefficient" in effect
|
||||||
|
? effect.coefficient
|
||||||
|
: effect.kind === "finisher-damage"
|
||||||
|
? effect.baseCoefficient + effect.perComboCoefficient * 5
|
||||||
|
: 0;
|
||||||
|
const power = member.level * 4
|
||||||
|
+ member.gearStats.spellPower
|
||||||
|
+ member.gearStats.attackPower * 0.35
|
||||||
|
+ member.gearStats.rangedAttackPower * 0.35;
|
||||||
|
return Math.max(1, Math.round(Math.max(0.1, coefficient) * Math.max(8, power)));
|
||||||
|
}
|
||||||
@@ -75,6 +75,20 @@ describe("party combat runtime", () => {
|
|||||||
expect(usePartyStore.getState().members.map((member) => member.nextActionAt)).toEqual(actionRevisions);
|
expect(usePartyStore.getState().members.map((member) => member.nextActionAt)).toEqual(actionRevisions);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps living companions in the fight while the player waits for resurrection", () => {
|
||||||
|
registerEngagedMob();
|
||||||
|
for (const member of usePartyStore.getState().members) {
|
||||||
|
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||||
|
}
|
||||||
|
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||||
|
const beforeMobHealth = useCombatStore.getState().mobs["pack:a"].health;
|
||||||
|
|
||||||
|
advanceDungeonPartyCombat(1_000);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(useCombatStore.getState().mobs["pack:a"].health).toBeLessThan(beforeMobHealth);
|
||||||
|
});
|
||||||
|
|
||||||
it("assigns the tank to a loose mob attacking the healer before one attacking DPS", () => {
|
it("assigns the tank to a loose mob attacking the healer before one attacking DPS", () => {
|
||||||
const party = usePartyStore.getState();
|
const party = usePartyStore.getState();
|
||||||
const tank = party.members.find((member) => member.role === "tank")!;
|
const tank = party.members.find((member) => member.role === "tank")!;
|
||||||
@@ -177,6 +191,31 @@ describe("party combat runtime", () => {
|
|||||||
expect(coolingDown.targetId).toBeNull();
|
expect(coolingDown.targetId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses an unlocked party resurrection instead of treating a defeated player as a heal target", () => {
|
||||||
|
usePartyStore.getState().initializeParty("resurrection-runtime", 80, 100, "damage");
|
||||||
|
const support = applyHealerSupport(usePartyStore.getState().members, 0, 100, 1_000);
|
||||||
|
|
||||||
|
expect(support).toMatchObject({
|
||||||
|
action: "resurrection",
|
||||||
|
targetId: "__player__",
|
||||||
|
playerHealing: 0,
|
||||||
|
});
|
||||||
|
expect(support.playerResurrectionPercent).toBeGreaterThan(0);
|
||||||
|
expect(support.presentation?.name.toLowerCase()).toMatch(/rebirth|redemption|resurrect|ancestral|revive/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the party raise the player in place while combat remains active", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
||||||
|
usePartyStore.getState().initializeParty("resurrection-runtime", 80, useCombatStore.getState().health, "damage");
|
||||||
|
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
|
||||||
|
advanceDungeonPartyCombat(1_000);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().health).toBeGreaterThan(0);
|
||||||
|
expect(useCombatStore.getState().feedback?.message).toContain("Resurrected");
|
||||||
|
});
|
||||||
|
|
||||||
it("classifies ranged classes and elemental shaman into a stand-off range band", () => {
|
it("classifies ranged classes and elemental shaman into a stand-off range band", () => {
|
||||||
const ranged = [
|
const ranged = [
|
||||||
{ classId: "hunter", specialization: "Marksmanship", role: "damage" },
|
{ classId: "hunter", specialization: "Marksmanship", role: "damage" },
|
||||||
|
|||||||
+848
-131
File diff suppressed because it is too large
Load Diff
@@ -108,6 +108,11 @@ export function updatePartyRuntimePosition(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removePartyRuntimePosition(id: string): void {
|
||||||
|
positions.delete(id);
|
||||||
|
targetVisibility.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
export function getPartyRuntimePosition(id: string): PartyWorldPosition | null {
|
export function getPartyRuntimePosition(id: string): PartyWorldPosition | null {
|
||||||
const position = positions.get(id);
|
const position = positions.get(id);
|
||||||
return position ? [...position] as MutablePartyWorldPosition : null;
|
return position ? [...position] as MutablePartyWorldPosition : null;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user