Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55cfd43d66 |
@@ -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
|
||||||
|
|||||||
@@ -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.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"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",
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
@@ -118,6 +118,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.5",
|
||||||
"versionCode": 1004
|
"versionCode": 1005
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +1030,8 @@ 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."]
|
||||||
|
: 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.`]),
|
: [`Server positions were aligned to the packaged ${environmentMode} environment at its reviewed player anchor.`]),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -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",
|
||||||
|
|||||||
+98
-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,21 @@ 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;
|
||||||
`);
|
`);
|
||||||
|
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 +111,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 +151,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 +201,58 @@ 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
databasePath,
|
databasePath,
|
||||||
register,
|
register,
|
||||||
@@ -168,6 +261,9 @@ 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,
|
||||||
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,72 @@ 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);
|
||||||
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
export const MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
export const DEFAULT_MANASTORM_ADMIN_CONFIG = Object.freeze({
|
||||||
|
schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION,
|
||||||
|
global: Object.freeze({
|
||||||
|
enabled: true,
|
||||||
|
levelingEnabled: true,
|
||||||
|
endgameEnabled: true,
|
||||||
|
affixesEnabled: true,
|
||||||
|
chaoticLinkEnabled: true,
|
||||||
|
milestoneEncountersEnabled: true,
|
||||||
|
}),
|
||||||
|
dungeonSpawns: Object.freeze({}),
|
||||||
|
bosses: Object.freeze({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const BOSS_STATUSES = new Set(["ready", "needs-tested", "not-ready"]);
|
||||||
|
const MAX_RECORDS = 10_000;
|
||||||
|
const MAX_ID_LENGTH = 256;
|
||||||
|
|
||||||
|
function invalid(message) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = "invalid_manastorm_config";
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value, label) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw invalid(`${label} must be an object.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function identifier(value, label) {
|
||||||
|
if (typeof value !== "string" || !value.trim() || value.length > MAX_ID_LENGTH) {
|
||||||
|
throw invalid(`${label} must be a non-empty string no longer than ${MAX_ID_LENGTH} characters.`);
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalIdentifier(value, label) {
|
||||||
|
return value === undefined || value === null ? undefined : identifier(value, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value, label) {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
throw invalid(`${label} must be a finite number.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vector(value, label) {
|
||||||
|
if (!Array.isArray(value) || value.length !== 3) {
|
||||||
|
throw invalid(`${label} must contain exactly three coordinates.`);
|
||||||
|
}
|
||||||
|
return value.map((coordinate, index) => finiteNumber(coordinate, `${label}[${index}]`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function worldSpawn(value, label) {
|
||||||
|
const input = record(value, label);
|
||||||
|
return {
|
||||||
|
footPosition: vector(input.footPosition, `${label}.footPosition`),
|
||||||
|
yaw: finiteNumber(input.yaw, `${label}.yaw`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolean(value, label) {
|
||||||
|
if (typeof value !== "boolean") throw invalid(`${label} must be a boolean.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGlobal(value) {
|
||||||
|
const input = record(value, "config.global");
|
||||||
|
return {
|
||||||
|
enabled: boolean(input.enabled, "config.global.enabled"),
|
||||||
|
levelingEnabled: boolean(input.levelingEnabled, "config.global.levelingEnabled"),
|
||||||
|
endgameEnabled: boolean(input.endgameEnabled, "config.global.endgameEnabled"),
|
||||||
|
affixesEnabled: boolean(input.affixesEnabled, "config.global.affixesEnabled"),
|
||||||
|
chaoticLinkEnabled: boolean(input.chaoticLinkEnabled, "config.global.chaoticLinkEnabled"),
|
||||||
|
milestoneEncountersEnabled: boolean(
|
||||||
|
input.milestoneEncountersEnabled,
|
||||||
|
"config.global.milestoneEncountersEnabled",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStage(value, label) {
|
||||||
|
const input = record(value, label);
|
||||||
|
const linkedMobRuntimeIds = Array.isArray(input.linkedMobRuntimeIds)
|
||||||
|
? input.linkedMobRuntimeIds.map((id, index) => identifier(id, `${label}.linkedMobRuntimeIds[${index}]`))
|
||||||
|
: (() => { throw invalid(`${label}.linkedMobRuntimeIds must be an array.`); })();
|
||||||
|
if (new Set(linkedMobRuntimeIds).size !== linkedMobRuntimeIds.length) {
|
||||||
|
throw invalid(`${label}.linkedMobRuntimeIds must not contain duplicates.`);
|
||||||
|
}
|
||||||
|
const requiredKillCount = finiteNumber(input.requiredKillCount, `${label}.requiredKillCount`);
|
||||||
|
if (!Number.isInteger(requiredKillCount) || requiredKillCount < 0 || requiredKillCount > linkedMobRuntimeIds.length) {
|
||||||
|
throw invalid(`${label}.requiredKillCount must be an integer between zero and the linked mob count.`);
|
||||||
|
}
|
||||||
|
const mapId = finiteNumber(input.mapId, `${label}.mapId`);
|
||||||
|
if (!Number.isInteger(mapId) || mapId <= 0) throw invalid(`${label}.mapId must be a positive integer.`);
|
||||||
|
return {
|
||||||
|
dungeonId: identifier(input.dungeonId, `${label}.dungeonId`),
|
||||||
|
mapId,
|
||||||
|
bossRuntimeId: identifier(input.bossRuntimeId, `${label}.bossRuntimeId`),
|
||||||
|
bossEntityId: identifier(input.bossEntityId, `${label}.bossEntityId`),
|
||||||
|
bossName: identifier(input.bossName, `${label}.bossName`),
|
||||||
|
...(optionalIdentifier(input.baseStageId, `${label}.baseStageId`)
|
||||||
|
? { baseStageId: optionalIdentifier(input.baseStageId, `${label}.baseStageId`) }
|
||||||
|
: {}),
|
||||||
|
...(optionalIdentifier(input.assetPackageId, `${label}.assetPackageId`)
|
||||||
|
? { assetPackageId: optionalIdentifier(input.assetPackageId, `${label}.assetPackageId`) }
|
||||||
|
: {}),
|
||||||
|
bossPosition: vector(input.bossPosition, `${label}.bossPosition`),
|
||||||
|
bossYaw: finiteNumber(input.bossYaw, `${label}.bossYaw`),
|
||||||
|
groupSpawn: worldSpawn(input.groupSpawn, `${label}.groupSpawn`),
|
||||||
|
portalPosition: vector(input.portalPosition, `${label}.portalPosition`),
|
||||||
|
linkedMobRuntimeIds,
|
||||||
|
requiredKillCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDictionary(value, label, normalizeValue) {
|
||||||
|
const input = record(value, label);
|
||||||
|
const entries = Object.entries(input);
|
||||||
|
if (entries.length > MAX_RECORDS) throw invalid(`${label} contains too many records.`);
|
||||||
|
return Object.fromEntries(entries.map(([key, entry]) => [
|
||||||
|
identifier(key, `${label} key`),
|
||||||
|
normalizeValue(entry, `${label}.${key}`),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeManastormAdminConfig(value) {
|
||||||
|
const input = record(value, "config");
|
||||||
|
if (input.schemaVersion !== MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION) {
|
||||||
|
throw invalid(`Unsupported Manastorm admin config schema ${String(input.schemaVersion)}.`);
|
||||||
|
}
|
||||||
|
const dungeonSpawns = normalizeDictionary(input.dungeonSpawns, "config.dungeonSpawns", worldSpawn);
|
||||||
|
const bosses = normalizeDictionary(input.bosses, "config.bosses", (rawBoss, label) => {
|
||||||
|
const boss = record(rawBoss, label);
|
||||||
|
if (!BOSS_STATUSES.has(boss.status)) throw invalid(`${label}.status is invalid.`);
|
||||||
|
if (boss.status === "ready" && (boss.stage === undefined || boss.stage === null)) {
|
||||||
|
throw invalid(`${label} cannot be Ready without a complete stage.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: boss.status,
|
||||||
|
...(boss.stage === undefined || boss.stage === null
|
||||||
|
? {}
|
||||||
|
: { stage: normalizeStage(boss.stage, `${label}.stage`) }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION,
|
||||||
|
global: normalizeGlobal(input.global),
|
||||||
|
dungeonSpawns,
|
||||||
|
bosses,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneDefaultManastormAdminConfig() {
|
||||||
|
return JSON.parse(JSON.stringify(DEFAULT_MANASTORM_ADMIN_CONFIG));
|
||||||
|
}
|
||||||
@@ -209,6 +209,27 @@ export function createGameServer(options = {}) {
|
|||||||
json(response, 200, gameDatabase.putCloudSave(account.id, body.data));
|
json(response, 200, gameDatabase.putCloudSave(account.id, body.data));
|
||||||
return;
|
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;
|
||||||
|
|||||||
@@ -53,6 +53,37 @@ 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 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 });
|
||||||
|
|||||||
+10
-1
@@ -1,5 +1,6 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -10,6 +11,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,6 +21,12 @@ 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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshManastormConfig();
|
||||||
|
}, [refreshManastormConfig, sessionOwnerId]);
|
||||||
|
|
||||||
if (phase === "login") return <LoginScreen />;
|
if (phase === "login") return <LoginScreen />;
|
||||||
if (phase === "characters") return <Suspense fallback={<ShellTransition />}><CharacterSelectScreen /></Suspense>;
|
if (phase === "characters") return <Suspense fallback={<ShellTransition />}><CharacterSelectScreen /></Suspense>;
|
||||||
@@ -27,6 +35,7 @@ export default function App() {
|
|||||||
if (phase === "class-hall") return <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
|
if (phase === "class-hall") return <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
|
||||||
if (phase === "dungeons") return <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
|
if (phase === "dungeons") return <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
|
||||||
if (phase === "manastorms") return <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
|
if (phase === "manastorms") return <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
|
||||||
|
if (phase === "gm-admin") return <Suspense fallback={<ShellTransition />}><GmAdminScreen /></Suspense>;
|
||||||
if (phase !== "game") return null;
|
if (phase !== "game") return null;
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<ShellTransition />}>
|
<Suspense fallback={<ShellTransition />}>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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";
|
||||||
|
|
||||||
function InputBridge() {
|
function InputBridge() {
|
||||||
const overlay = useGameStore((state) => state.overlay);
|
const overlay = useGameStore((state) => state.overlay);
|
||||||
@@ -88,6 +89,7 @@ export default function GameRuntime() {
|
|||||||
<MapPanel />
|
<MapPanel />
|
||||||
<GameplayPanels />
|
<GameplayPanels />
|
||||||
<PauseMenu />
|
<PauseMenu />
|
||||||
|
<GmWorldEditor />
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
bottom={<LocalCompanionPanel />}
|
bottom={<LocalCompanionPanel />}
|
||||||
|
|||||||
@@ -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,7 +125,9 @@ function enterManastorm(
|
|||||||
const startingLevel = requestedStartingLevel === 1
|
const startingLevel = requestedStartingLevel === 1
|
||||||
? 1
|
? 1
|
||||||
: availableCheckpoint;
|
: availableCheckpoint;
|
||||||
const encounter = useManastormStore.getState().startRun({
|
let encounter;
|
||||||
|
try {
|
||||||
|
encounter = useManastormStore.getState().startRun({
|
||||||
partySize,
|
partySize,
|
||||||
startingLevel,
|
startingLevel,
|
||||||
unlockedThroughLevel: Math.max(startingLevel, savedParty.highestLevel + 1),
|
unlockedThroughLevel: Math.max(startingLevel, savedParty.highestLevel + 1),
|
||||||
@@ -120,6 +141,10 @@ function enterManastorm(
|
|||||||
cacheMeter: modeProgress.cacheMeter,
|
cacheMeter: modeProgress.cacheMeter,
|
||||||
rewardCaches: modeProgress.rewardCaches,
|
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." });
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ 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 +21,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 +44,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 +67,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 +75,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 +99,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 +127,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 +136,17 @@ export async function hydrateOnlineSession(session: PlayerSession): Promise<Char
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshOnlineSession(session: PlayerSession): Promise<PlayerSession> {
|
||||||
|
if (!session.accessToken) return { ...session, roles: [] };
|
||||||
|
const response = await requestOnlineJson<MeResponse>("/api/me", {}, session.accessToken);
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
ownerId: response.account.id,
|
||||||
|
displayName: response.account.username,
|
||||||
|
roles: response.account.roles ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function logoutOnlineSession(session: PlayerSession | null): Promise<void> {
|
export async function logoutOnlineSession(session: PlayerSession | null): Promise<void> {
|
||||||
if (saveTimer) {
|
if (saveTimer) {
|
||||||
clearTimeout(saveTimer);
|
clearTimeout(saveTimer);
|
||||||
@@ -139,7 +155,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,16 +124,6 @@ export function CombatBridge() {
|
|||||||
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);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { preloadManastormStage } from "./manastormStageLoader";
|
|||||||
/** 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);
|
||||||
@@ -103,7 +104,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 +117,7 @@ export function ManastormBridge() {
|
|||||||
useShellStore.getState().saveActiveManastormProgress(result.progress);
|
useShellStore.getState().saveActiveManastormProgress(result.progress);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [characterId]);
|
}, [characterId, gmTestMode]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,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 });
|
||||||
|
|||||||
@@ -520,6 +520,7 @@ export interface CombatState {
|
|||||||
tauntMob: (id: string, source: ThreatSource, durationMs: number, now?: number) => boolean;
|
tauntMob: (id: string, source: ThreatSource, durationMs: number, now?: number) => boolean;
|
||||||
damagePlayer: (amount: number, school?: DamageSchool, attackerLevel?: number) => number;
|
damagePlayer: (amount: number, school?: DamageSchool, attackerLevel?: number) => number;
|
||||||
healPlayer: (amount: number) => number;
|
healPlayer: (amount: number) => number;
|
||||||
|
revivePlayer: (percentMaxHealth?: number) => number;
|
||||||
cancelCast: (message?: string) => boolean;
|
cancelCast: (message?: string) => boolean;
|
||||||
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
|
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
|
||||||
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
|
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
|
||||||
@@ -3851,6 +3852,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
|
|
||||||
healPlayer: (amount) => {
|
healPlayer: (amount) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
|
// Ordinary healing and lingering HoTs must not raise a defeated player.
|
||||||
|
// Resurrection is an explicit combat action handled by revivePlayer.
|
||||||
|
if (state.health <= 0) return 0;
|
||||||
const modified = resolveAuraHealingValue(
|
const modified = resolveAuraHealingValue(
|
||||||
amount,
|
amount,
|
||||||
aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID),
|
aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID),
|
||||||
@@ -3867,6 +3871,22 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
return healed;
|
return healed;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
revivePlayer: (percentMaxHealth = 0.35) => {
|
||||||
|
const state = get();
|
||||||
|
if (state.health > 0 || state.maxHealth <= 0) return 0;
|
||||||
|
const restored = Math.max(1, Math.round(
|
||||||
|
state.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)),
|
||||||
|
));
|
||||||
|
set({
|
||||||
|
health: restored,
|
||||||
|
controlledUntil: 0,
|
||||||
|
controlMechanic: null,
|
||||||
|
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
||||||
|
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored }),
|
||||||
|
});
|
||||||
|
return restored;
|
||||||
|
},
|
||||||
|
|
||||||
cancelCast: (message = "Casting interrupted.") => {
|
cancelCast: (message = "Casting interrupted.") => {
|
||||||
const cast = get().activeCast;
|
const cast = get().activeCast;
|
||||||
if (!cast) return false;
|
if (!cast) return false;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
+144
-21
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
abilitiesForClass,
|
||||||
|
abilityAtLevel,
|
||||||
|
isAbilityUnlocked,
|
||||||
|
type AbilityDefinition,
|
||||||
|
} from "./abilityCatalog";
|
||||||
import type { MobCombatState } from "./combatStore";
|
import type { MobCombatState } from "./combatStore";
|
||||||
import { useCombatStore } from "./combatStore";
|
import { useCombatStore } from "./combatStore";
|
||||||
import type { MobVector3 } from "./mobPopulation";
|
import type { MobVector3 } from "./mobPopulation";
|
||||||
@@ -35,7 +41,7 @@ const TANK_TAUNT_DURATION_MS = 3_000;
|
|||||||
let lastAdvanceAt = 0;
|
let lastAdvanceAt = 0;
|
||||||
let nextRegenAt = 0;
|
let nextRegenAt = 0;
|
||||||
|
|
||||||
interface PartyPresentationIdentity {
|
export interface PartyPresentationIdentity {
|
||||||
readonly spellId: number;
|
readonly spellId: number;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly school: CombatPresentationSchool;
|
readonly school: CombatPresentationSchool;
|
||||||
@@ -265,18 +271,59 @@ export function partyCommandTargetId(
|
|||||||
export interface HealerSupportResult {
|
export interface HealerSupportResult {
|
||||||
readonly members: readonly PartyMember[];
|
readonly members: readonly PartyMember[];
|
||||||
readonly playerHealing: number;
|
readonly playerHealing: number;
|
||||||
|
readonly playerResurrectionPercent: number;
|
||||||
readonly targetId: string | null;
|
readonly targetId: string | null;
|
||||||
readonly effectiveHealing: number;
|
readonly effectiveHealing: number;
|
||||||
|
readonly sourceMemberId: string | null;
|
||||||
|
readonly action: "heal" | "resurrection" | null;
|
||||||
|
readonly presentation: PartyPresentationIdentity | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function partyHealAmount(level: number, spellPower = 0): number {
|
function partyHealAmount(level: number, spellPower = 0): number {
|
||||||
return Math.max(10, Math.round(12 + Math.max(1, level) * 2.6 + Math.max(0, spellPower) * 1.25));
|
return Math.max(10, Math.round(12 + Math.max(1, level) * 2.6 + Math.max(0, spellPower) * 1.25));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function partyResurrectionAbility(member: PartyMember): AbilityDefinition | null {
|
||||||
|
const definition = abilitiesForClass(member.classId)
|
||||||
|
.filter((ability) => !ability.passive && ability.target === "friendly")
|
||||||
|
.filter((ability) => isAbilityUnlocked(ability, member.level))
|
||||||
|
.find((ability) => ability.effects.some((effect) => effect.kind === "resurrection"));
|
||||||
|
return definition ? abilityAtLevel(definition, member.level) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function partyResurrectionPresentation(
|
||||||
|
member: PartyMember,
|
||||||
|
ability: AbilityDefinition,
|
||||||
|
): PartyPresentationIdentity {
|
||||||
|
const nature = member.classId === "druid"
|
||||||
|
|| member.classId === "shaman"
|
||||||
|
|| member.classId === "rom-druid";
|
||||||
|
return {
|
||||||
|
spellId: ability.dbcSpellId,
|
||||||
|
name: ability.name,
|
||||||
|
school: nature ? "nature" : "holy",
|
||||||
|
delivery: "projectile",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function noSupport(members: readonly PartyMember[]): HealerSupportResult {
|
||||||
|
return {
|
||||||
|
members,
|
||||||
|
playerHealing: 0,
|
||||||
|
playerResurrectionPercent: 0,
|
||||||
|
targetId: null,
|
||||||
|
effectiveHealing: 0,
|
||||||
|
sourceMemberId: null,
|
||||||
|
action: null,
|
||||||
|
presentation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gives the dedicated NPC healer one legal, level-scaled heal at a time. The
|
* Gives the party one legal support action at a time. Any living class with an
|
||||||
* runtime caller applies `playerHealing` to the combat store after this pure
|
* unlocked resurrection can raise a fallen target; ordinary healing remains
|
||||||
* party-state update.
|
* the dedicated healer's job. The runtime caller applies player-side changes
|
||||||
|
* to the combat store after this pure party-state update.
|
||||||
*/
|
*/
|
||||||
export function applyHealerSupport(
|
export function applyHealerSupport(
|
||||||
members: readonly PartyMember[],
|
members: readonly PartyMember[],
|
||||||
@@ -284,10 +331,74 @@ export function applyHealerSupport(
|
|||||||
playerMaxHealth: number,
|
playerMaxHealth: number,
|
||||||
now: number,
|
now: number,
|
||||||
): HealerSupportResult {
|
): HealerSupportResult {
|
||||||
|
const resurrector = members
|
||||||
|
.map((member, index) => ({ member, index, ability: partyResurrectionAbility(member) }))
|
||||||
|
.filter((candidate): candidate is typeof candidate & { ability: AbilityDefinition } => (
|
||||||
|
candidate.member.health > 0
|
||||||
|
&& candidate.member.controlledUntil <= now
|
||||||
|
&& candidate.member.nextActionAt <= now
|
||||||
|
&& candidate.ability !== null
|
||||||
|
))
|
||||||
|
.sort((left, right) => (
|
||||||
|
Number(right.member.role === "healer") - Number(left.member.role === "healer")
|
||||||
|
|| left.member.id.localeCompare(right.member.id)
|
||||||
|
))[0];
|
||||||
|
const fallen = [
|
||||||
|
...(playerHealth <= 0 && playerMaxHealth > 0
|
||||||
|
? [{ id: "__player__", index: -1, maxHealth: playerMaxHealth, rolePriority: -1 }]
|
||||||
|
: []),
|
||||||
|
...members
|
||||||
|
.map((member, index) => ({
|
||||||
|
id: member.id,
|
||||||
|
index,
|
||||||
|
maxHealth: member.maxHealth,
|
||||||
|
rolePriority: member.role === "healer" ? 0 : member.role === "tank" ? 1 : 2,
|
||||||
|
}))
|
||||||
|
.filter((candidate) => members[candidate.index].health <= 0),
|
||||||
|
].sort((left, right) => left.rolePriority - right.rolePriority || left.id.localeCompare(right.id));
|
||||||
|
const fallenTarget = fallen[0];
|
||||||
|
if (resurrector && fallenTarget) {
|
||||||
|
const resurrection = resurrector.ability.effects.find((effect) => effect.kind === "resurrection");
|
||||||
|
if (resurrection?.kind === "resurrection") {
|
||||||
|
const restored = Math.max(1, Math.round(fallenTarget.maxHealth * resurrection.percentMaxHealth));
|
||||||
|
const next = [...members];
|
||||||
|
if (fallenTarget.index >= 0) {
|
||||||
|
const target = next[fallenTarget.index];
|
||||||
|
next[fallenTarget.index] = {
|
||||||
|
...target,
|
||||||
|
health: restored,
|
||||||
|
status: "ready",
|
||||||
|
controlledUntil: 0,
|
||||||
|
controlMechanic: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const caster = next[resurrector.index];
|
||||||
|
next[resurrector.index] = {
|
||||||
|
...caster,
|
||||||
|
status: "healing",
|
||||||
|
nextActionAt: now + Math.max(
|
||||||
|
HEALER_ACTION_INTERVAL_MS,
|
||||||
|
resurrector.ability.castTimeMs,
|
||||||
|
resurrector.ability.gcdMs,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
members: next,
|
||||||
|
playerHealing: 0,
|
||||||
|
playerResurrectionPercent: fallenTarget.index < 0 ? resurrection.percentMaxHealth : 0,
|
||||||
|
targetId: fallenTarget.id,
|
||||||
|
effectiveHealing: restored,
|
||||||
|
sourceMemberId: resurrector.member.id,
|
||||||
|
action: "resurrection",
|
||||||
|
presentation: partyResurrectionPresentation(resurrector.member, resurrector.ability),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const healerIndex = members.findIndex((member) => member.role === "healer" && member.health > 0);
|
const healerIndex = members.findIndex((member) => member.role === "healer" && member.health > 0);
|
||||||
if (healerIndex < 0) return { members, playerHealing: 0, targetId: null, effectiveHealing: 0 };
|
if (healerIndex < 0) return noSupport(members);
|
||||||
const healer = members[healerIndex];
|
const healer = members[healerIndex];
|
||||||
if (now < healer.nextActionAt) return { members, playerHealing: 0, targetId: null, effectiveHealing: 0 };
|
if (now < healer.nextActionAt) return noSupport(members);
|
||||||
|
|
||||||
const candidates = members
|
const candidates = members
|
||||||
.map((member, index) => ({
|
.map((member, index) => ({
|
||||||
@@ -308,11 +419,11 @@ export function applyHealerSupport(
|
|||||||
const target = candidates[0];
|
const target = candidates[0];
|
||||||
if (!target) {
|
if (!target) {
|
||||||
if (healer.status !== "healing" && healer.nextActionAt === 0) {
|
if (healer.status !== "healing" && healer.nextActionAt === 0) {
|
||||||
return { members, playerHealing: 0, targetId: null, effectiveHealing: 0 };
|
return noSupport(members);
|
||||||
}
|
}
|
||||||
const next = [...members];
|
const next = [...members];
|
||||||
next[healerIndex] = { ...healer, status: "ready", nextActionAt: 0 };
|
next[healerIndex] = { ...healer, status: "ready", nextActionAt: 0 };
|
||||||
return { members: next, playerHealing: 0, targetId: null, effectiveHealing: 0 };
|
return noSupport(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
const requested = partyHealAmount(healer.level, healer.gearStats.spellPower);
|
const requested = partyHealAmount(healer.level, healer.gearStats.spellPower);
|
||||||
@@ -331,8 +442,12 @@ export function applyHealerSupport(
|
|||||||
return {
|
return {
|
||||||
members: next,
|
members: next,
|
||||||
playerHealing: target.index < 0 ? healed : 0,
|
playerHealing: target.index < 0 ? healed : 0,
|
||||||
|
playerResurrectionPercent: 0,
|
||||||
targetId: target.id,
|
targetId: target.id,
|
||||||
effectiveHealing: healed,
|
effectiveHealing: healed,
|
||||||
|
sourceMemberId: healer.id,
|
||||||
|
action: "heal",
|
||||||
|
presentation: partyHealPresentation(healer),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,7 +543,9 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
|||||||
const objectiveBossId = nextPartyBossObjective(combat.mobs)?.id ?? null;
|
const objectiveBossId = nextPartyBossObjective(combat.mobs)?.id ?? null;
|
||||||
let memberTargetIds: Record<string, string> = {};
|
let memberTargetIds: Record<string, string> = {};
|
||||||
|
|
||||||
if (targetId && useCombatStore.getState().health > 0) {
|
// A defeated player can watch the encounter continue while waiting for a
|
||||||
|
// resurrection. Living companions remain active until the whole party falls.
|
||||||
|
if (targetId && members.some((member) => member.health > 0)) {
|
||||||
const nextMembers = [...members];
|
const nextMembers = [...members];
|
||||||
for (let index = 0; index < nextMembers.length; index += 1) {
|
for (let index = 0; index < nextMembers.length; index += 1) {
|
||||||
let member = nextMembers[index];
|
let member = nextMembers[index];
|
||||||
@@ -596,18 +713,24 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
|||||||
|
|
||||||
const currentPlayer = useCombatStore.getState();
|
const currentPlayer = useCombatStore.getState();
|
||||||
const support = applyHealerSupport(members, currentPlayer.health, currentPlayer.maxHealth, now);
|
const support = applyHealerSupport(members, currentPlayer.health, currentPlayer.maxHealth, now);
|
||||||
const healer = members.find((member) => member.role === "healer" && member.health > 0) ?? null;
|
const supporter = support.sourceMemberId
|
||||||
|
? members.find((member) => member.id === support.sourceMemberId && member.health > 0) ?? null
|
||||||
|
: null;
|
||||||
members = support.members;
|
members = support.members;
|
||||||
if (support.playerHealing > 0) {
|
let effectiveSupport = support.effectiveHealing;
|
||||||
useCombatStore.getState().healPlayer(support.playerHealing);
|
if (support.playerResurrectionPercent > 0) {
|
||||||
|
effectiveSupport = useCombatStore.getState().revivePlayer(support.playerResurrectionPercent);
|
||||||
}
|
}
|
||||||
if (healer && support.effectiveHealing > 0) {
|
if (support.playerHealing > 0) {
|
||||||
|
effectiveSupport = useCombatStore.getState().healPlayer(support.playerHealing);
|
||||||
|
}
|
||||||
|
if (supporter && support.presentation && effectiveSupport > 0) {
|
||||||
useCombatStore.getState().addHealingThreat(
|
useCombatStore.getState().addHealingThreat(
|
||||||
{ actorId: healer.id, role: healer.role },
|
{ actorId: supporter.id, role: supporter.role },
|
||||||
support.effectiveHealing,
|
effectiveSupport,
|
||||||
);
|
);
|
||||||
const presentation = partyHealPresentation(healer);
|
const presentation = support.presentation;
|
||||||
const healerPosition = getPartyRuntimePosition(healer.id) ?? currentPlayer.playerPosition;
|
const supporterPosition = getPartyRuntimePosition(supporter.id) ?? currentPlayer.playerPosition;
|
||||||
const presentationTargetId = support.targetId === "__player__" ? PLAYER_AGGRO_ID : support.targetId;
|
const presentationTargetId = support.targetId === "__player__" ? PLAYER_AGGRO_ID : support.targetId;
|
||||||
const targetPosition = presentationTargetId === PLAYER_AGGRO_ID
|
const targetPosition = presentationTargetId === PLAYER_AGGRO_ID
|
||||||
? currentPlayer.playerPosition
|
? currentPlayer.playerPosition
|
||||||
@@ -617,16 +740,16 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
|||||||
const healPresentation = {
|
const healPresentation = {
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
actorGroup: "party",
|
actorGroup: "party",
|
||||||
sourceActorId: healer.id,
|
sourceActorId: supporter.id,
|
||||||
...(presentationTargetId ? { targetActorId: presentationTargetId } : {}),
|
...(presentationTargetId ? { targetActorId: presentationTargetId } : {}),
|
||||||
abilityId: `party-heal:${healer.id}`,
|
abilityId: `party-${support.action}:${supporter.id}`,
|
||||||
sourceSpellId: presentation.spellId,
|
sourceSpellId: presentation.spellId,
|
||||||
name: presentation.name,
|
name: presentation.name,
|
||||||
source: "wow335",
|
source: "wow335",
|
||||||
school: presentation.school,
|
school: presentation.school,
|
||||||
delivery: presentation.delivery,
|
delivery: presentation.delivery,
|
||||||
origin: healerPosition,
|
origin: supporterPosition,
|
||||||
targetPosition: targetPosition ?? healerPosition,
|
targetPosition: targetPosition ?? supporterPosition,
|
||||||
} as const;
|
} as const;
|
||||||
emitCombatPresentation({ ...healPresentation, phase: "release" });
|
emitCombatPresentation({ ...healPresentation, phase: "release" });
|
||||||
emitCombatPresentation({ ...healPresentation, phase: "impact" });
|
emitCombatPresentation({ ...healPresentation, phase: "impact" });
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import RAPIER from "@dimforge/rapier3d-compat";
|
||||||
|
import { NodeIO } from "@gltf-transform/core";
|
||||||
|
import { EXTMeshoptCompression, KHRMeshQuantization } from "@gltf-transform/extensions";
|
||||||
|
import { MeshoptDecoder } from "meshoptimizer";
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
PLAYER_GROUND_MIN_NORMAL_Y,
|
||||||
|
PLAYER_GROUND_PROBE_DISTANCE,
|
||||||
|
} from "./playerJump";
|
||||||
|
import {
|
||||||
|
PLAYER_CHARACTER_CONTROLLER_OFFSET,
|
||||||
|
PLAYER_GROUND_SNAP_DISTANCE,
|
||||||
|
PLAYER_GROUND_STICK_VELOCITY,
|
||||||
|
PLAYER_STEP_HEIGHT,
|
||||||
|
PLAYER_STEP_MIN_WIDTH,
|
||||||
|
airbornePlayerVerticalVelocity,
|
||||||
|
configurePlayerCharacterController,
|
||||||
|
playerFrameDeltaSeconds,
|
||||||
|
} from "./playerMovement";
|
||||||
|
|
||||||
|
const PLAYER_RADIUS = 0.36;
|
||||||
|
const PLAYER_CAPSULE_HALF_HEIGHT = 0.48;
|
||||||
|
const PLAYER_CENTER_HEIGHT = PLAYER_RADIUS + PLAYER_CAPSULE_HALF_HEIGHT;
|
||||||
|
const MOVE_SPEED = 4.8;
|
||||||
|
const FRAME_SECONDS = 1 / 60;
|
||||||
|
const STOCKADES_SPAWN = {
|
||||||
|
x: -126.4629,
|
||||||
|
y: -29.1512,
|
||||||
|
z: 21.2946,
|
||||||
|
forwardX: -0.2756962614591509,
|
||||||
|
forwardZ: 0.9612448030639477,
|
||||||
|
};
|
||||||
|
|
||||||
|
let stockadesCollision: { vertices: Float32Array; indices: Uint32Array };
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await RAPIER.init();
|
||||||
|
const io = new NodeIO()
|
||||||
|
.registerExtensions([EXTMeshoptCompression, KHRMeshQuantization])
|
||||||
|
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
|
||||||
|
const document = await io.read("public/assets/game/manastorm/34-stormwindjail/collision.glb");
|
||||||
|
const node = document.getRoot().listNodes().find((candidate) => candidate.getMesh());
|
||||||
|
const primitive = node?.getMesh()?.listPrimitives()[0];
|
||||||
|
const positions = primitive?.getAttribute("POSITION");
|
||||||
|
const sourceVertices = positions?.getArray();
|
||||||
|
const sourceIndices = primitive?.getIndices()?.getArray();
|
||||||
|
if (!node || !positions || !sourceVertices || !sourceIndices) {
|
||||||
|
throw new Error("Stormwind Stockade collision GLB is missing its trimesh primitive.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const translation = node.getWorldTranslation();
|
||||||
|
const scale = node.getWorldScale();
|
||||||
|
const normalization = positions.getNormalized() ? 32_767 : 1;
|
||||||
|
const vertices = new Float32Array(sourceVertices.length);
|
||||||
|
for (let offset = 0; offset < sourceVertices.length; offset += 3) {
|
||||||
|
vertices[offset] = sourceVertices[offset] / normalization * scale[0] + translation[0];
|
||||||
|
vertices[offset + 1] = sourceVertices[offset + 1] / normalization * scale[1] + translation[1];
|
||||||
|
vertices[offset + 2] = sourceVertices[offset + 2] / normalization * scale[2] + translation[2];
|
||||||
|
}
|
||||||
|
stockadesCollision = {
|
||||||
|
vertices,
|
||||||
|
indices: Uint32Array.from(sourceIndices),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function walkIntoStep(stepHeight: number): { x: number; y: number; z: number } {
|
||||||
|
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
||||||
|
const floor = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
|
||||||
|
world.createCollider(
|
||||||
|
RAPIER.ColliderDesc.cuboid(5, 0.1, 8).setTranslation(0, -0.1, 3),
|
||||||
|
floor,
|
||||||
|
);
|
||||||
|
const step = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
|
||||||
|
world.createCollider(
|
||||||
|
RAPIER.ColliderDesc.cuboid(2, stepHeight / 2, 5)
|
||||||
|
.setTranslation(0, stepHeight / 2, 4.75),
|
||||||
|
step,
|
||||||
|
);
|
||||||
|
const body = world.createRigidBody(
|
||||||
|
RAPIER.RigidBodyDesc.kinematicPositionBased()
|
||||||
|
.setTranslation(0, PLAYER_CENTER_HEIGHT, -0.8),
|
||||||
|
);
|
||||||
|
const collider = world.createCollider(
|
||||||
|
RAPIER.ColliderDesc.capsule(PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
world.step();
|
||||||
|
|
||||||
|
const controller = configurePlayerCharacterController(
|
||||||
|
world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET),
|
||||||
|
);
|
||||||
|
for (let frame = 0; frame < 50; frame += 1) {
|
||||||
|
controller.computeColliderMovement(
|
||||||
|
collider,
|
||||||
|
{
|
||||||
|
x: 0,
|
||||||
|
y: PLAYER_GROUND_STICK_VELOCITY * FRAME_SECONDS,
|
||||||
|
z: MOVE_SPEED * FRAME_SECONDS,
|
||||||
|
},
|
||||||
|
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||||
|
);
|
||||||
|
const translation = body.translation();
|
||||||
|
const movement = controller.computedMovement();
|
||||||
|
body.setNextKinematicTranslation({
|
||||||
|
x: translation.x + movement.x,
|
||||||
|
y: translation.y + movement.y,
|
||||||
|
z: translation.z + movement.z,
|
||||||
|
});
|
||||||
|
world.step();
|
||||||
|
}
|
||||||
|
|
||||||
|
const translation = body.translation();
|
||||||
|
const result = { x: translation.x, y: translation.y, z: translation.z };
|
||||||
|
world.removeCharacterController(controller);
|
||||||
|
world.free();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("player movement", () => {
|
||||||
|
it("configures bounded autostep and ground snapping", () => {
|
||||||
|
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
||||||
|
const controller = configurePlayerCharacterController(
|
||||||
|
world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(controller.autostepMaxHeight()).toBeCloseTo(PLAYER_STEP_HEIGHT);
|
||||||
|
expect(controller.autostepMinWidth()).toBeCloseTo(PLAYER_STEP_MIN_WIDTH);
|
||||||
|
expect(controller.autostepIncludesDynamicBodies()).toBe(false);
|
||||||
|
expect(controller.snapToGroundDistance()).toBeCloseTo(PLAYER_GROUND_SNAP_DISTANCE);
|
||||||
|
expect(controller.slideEnabled()).toBe(true);
|
||||||
|
|
||||||
|
world.removeCharacterController(controller);
|
||||||
|
world.free();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks up an ordinary half-unit stair without jumping", () => {
|
||||||
|
const translation = walkIntoStep(0.5);
|
||||||
|
expect(translation.z).toBeGreaterThan(2.5);
|
||||||
|
expect(translation.y).toBeGreaterThan(PLAYER_CENTER_HEIGHT + 0.45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not autostep over a wall taller than the traversal limit", () => {
|
||||||
|
const translation = walkIntoStep(0.8);
|
||||||
|
expect(translation.z).toBeLessThan(-0.5);
|
||||||
|
expect(translation.y).toBeLessThan(PLAYER_CENTER_HEIGHT + 0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks back up the real Stockades entrance stairs without a jump impulse", () => {
|
||||||
|
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
||||||
|
const dungeonBody = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
|
||||||
|
world.createCollider(
|
||||||
|
RAPIER.ColliderDesc.trimesh(
|
||||||
|
stockadesCollision.vertices,
|
||||||
|
stockadesCollision.indices,
|
||||||
|
),
|
||||||
|
dungeonBody,
|
||||||
|
);
|
||||||
|
const body = world.createRigidBody(
|
||||||
|
RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(
|
||||||
|
STOCKADES_SPAWN.x,
|
||||||
|
STOCKADES_SPAWN.y + PLAYER_CENTER_HEIGHT,
|
||||||
|
STOCKADES_SPAWN.z,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const collider = world.createCollider(
|
||||||
|
RAPIER.ColliderDesc.capsule(PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
world.step();
|
||||||
|
const controller = configurePlayerCharacterController(
|
||||||
|
world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET),
|
||||||
|
);
|
||||||
|
const groundRay = new RAPIER.Ray(
|
||||||
|
{ x: 0, y: 0, z: 0 },
|
||||||
|
{ x: 0, y: -1, z: 0 },
|
||||||
|
);
|
||||||
|
const groundProbeLength = PLAYER_CENTER_HEIGHT + PLAYER_GROUND_PROBE_DISTANCE;
|
||||||
|
let verticalVelocity = 0;
|
||||||
|
|
||||||
|
const walkFrame = (direction: 1 | -1) => {
|
||||||
|
const translation = body.translation();
|
||||||
|
groundRay.origin.x = translation.x;
|
||||||
|
groundRay.origin.y = translation.y;
|
||||||
|
groundRay.origin.z = translation.z;
|
||||||
|
const groundHit = world.castRayAndGetNormal(
|
||||||
|
groundRay,
|
||||||
|
groundProbeLength,
|
||||||
|
true,
|
||||||
|
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
const groundedBeforeMove = verticalVelocity <= 0.5
|
||||||
|
&& Boolean(
|
||||||
|
groundHit
|
||||||
|
&& groundHit.timeOfImpact <= groundProbeLength
|
||||||
|
&& groundHit.normal.y >= PLAYER_GROUND_MIN_NORMAL_Y,
|
||||||
|
);
|
||||||
|
verticalVelocity = groundedBeforeMove
|
||||||
|
? PLAYER_GROUND_STICK_VELOCITY
|
||||||
|
: airbornePlayerVerticalVelocity(verticalVelocity, FRAME_SECONDS);
|
||||||
|
controller.computeColliderMovement(
|
||||||
|
collider,
|
||||||
|
{
|
||||||
|
x: direction * STOCKADES_SPAWN.forwardX * MOVE_SPEED * FRAME_SECONDS,
|
||||||
|
y: verticalVelocity * FRAME_SECONDS,
|
||||||
|
z: direction * STOCKADES_SPAWN.forwardZ * MOVE_SPEED * FRAME_SECONDS,
|
||||||
|
},
|
||||||
|
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||||
|
);
|
||||||
|
const movement = controller.computedMovement();
|
||||||
|
body.setNextKinematicTranslation({
|
||||||
|
x: translation.x + movement.x,
|
||||||
|
y: translation.y + movement.y,
|
||||||
|
z: translation.z + movement.z,
|
||||||
|
});
|
||||||
|
if (controller.computedGrounded() && verticalVelocity <= 0) verticalVelocity = 0;
|
||||||
|
world.step();
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let frame = 0; frame < 190; frame += 1) walkFrame(1);
|
||||||
|
const lowerFootHeight = body.translation().y - PLAYER_CENTER_HEIGHT;
|
||||||
|
for (let frame = 0; frame < 240; frame += 1) walkFrame(-1);
|
||||||
|
const returned = body.translation();
|
||||||
|
const returnedFootHeight = returned.y - PLAYER_CENTER_HEIGHT;
|
||||||
|
|
||||||
|
expect(lowerFootHeight).toBeLessThan(-33);
|
||||||
|
expect(returnedFootHeight).toBeGreaterThan(-30);
|
||||||
|
expect(Math.hypot(
|
||||||
|
returned.x - STOCKADES_SPAWN.x,
|
||||||
|
returned.z - STOCKADES_SPAWN.z,
|
||||||
|
)).toBeLessThan(3);
|
||||||
|
|
||||||
|
world.removeCharacterController(controller);
|
||||||
|
world.free();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps frame hitches and applies bounded airborne gravity", () => {
|
||||||
|
expect(playerFrameDeltaSeconds(Number.NaN)).toBe(0);
|
||||||
|
expect(playerFrameDeltaSeconds(1)).toBe(0.05);
|
||||||
|
expect(airbornePlayerVerticalVelocity(0, 1)).toBeCloseTo(-0.9);
|
||||||
|
expect(airbornePlayerVerticalVelocity(-100, FRAME_SECONDS)).toBe(-32);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { KinematicCharacterController } from "@dimforge/rapier3d-compat";
|
||||||
|
import { PLAYER_GROUND_MIN_NORMAL_Y } from "./playerJump";
|
||||||
|
|
||||||
|
export const PLAYER_GRAVITY = -18;
|
||||||
|
export const PLAYER_CHARACTER_CONTROLLER_OFFSET = 0.02;
|
||||||
|
export const PLAYER_STEP_HEIGHT = 0.55;
|
||||||
|
export const PLAYER_STEP_MIN_WIDTH = 0.15;
|
||||||
|
export const PLAYER_GROUND_SNAP_DISTANCE = PLAYER_STEP_HEIGHT;
|
||||||
|
export const PLAYER_GROUND_STICK_VELOCITY = -0.5;
|
||||||
|
export const PLAYER_MAX_FRAME_DELTA_SECONDS = 0.05;
|
||||||
|
export const PLAYER_TERMINAL_FALL_VELOCITY = -32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures Rapier's kinematic controller for MMO-style traversal. Ordinary
|
||||||
|
* stairs and curb-sized seams are walkable, while taller obstacles remain
|
||||||
|
* blocking geometry and still require a real jump or another route.
|
||||||
|
*/
|
||||||
|
export function configurePlayerCharacterController(
|
||||||
|
controller: KinematicCharacterController,
|
||||||
|
): KinematicCharacterController {
|
||||||
|
controller.setUp({ x: 0, y: 1, z: 0 });
|
||||||
|
controller.setSlideEnabled(true);
|
||||||
|
controller.enableAutostep(PLAYER_STEP_HEIGHT, PLAYER_STEP_MIN_WIDTH, false);
|
||||||
|
controller.enableSnapToGround(PLAYER_GROUND_SNAP_DISTANCE);
|
||||||
|
controller.setMaxSlopeClimbAngle(Math.acos(PLAYER_GROUND_MIN_NORMAL_Y));
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playerFrameDeltaSeconds(deltaSeconds: number): number {
|
||||||
|
if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0;
|
||||||
|
return Math.min(deltaSeconds, PLAYER_MAX_FRAME_DELTA_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function airbornePlayerVerticalVelocity(
|
||||||
|
currentVelocity: number,
|
||||||
|
deltaSeconds: number,
|
||||||
|
): number {
|
||||||
|
const velocity = Number.isFinite(currentVelocity) ? currentVelocity : 0;
|
||||||
|
const delta = playerFrameDeltaSeconds(deltaSeconds);
|
||||||
|
return Math.max(
|
||||||
|
PLAYER_TERMINAL_FALL_VELOCITY,
|
||||||
|
velocity + PLAYER_GRAVITY * delta,
|
||||||
|
);
|
||||||
|
}
|
||||||
+6
-2
@@ -7,7 +7,7 @@ import type { ActionBindingLayer } from "./actionBindings";
|
|||||||
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
|
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
|
||||||
export type InputMode = "keyboard" | "gamepad";
|
export type InputMode = "keyboard" | "gamepad";
|
||||||
export type GameMode = "dungeon" | "manastorm";
|
export type GameMode = "dungeon" | "manastorm";
|
||||||
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "spellbook" | "talents" | "bindings" | "options" | null;
|
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "spellbook" | "talents" | "bindings" | "options" | "gm" | null;
|
||||||
|
|
||||||
interface PlayerSnapshot {
|
interface PlayerSnapshot {
|
||||||
position: Vector3Tuple;
|
position: Vector3Tuple;
|
||||||
@@ -20,6 +20,7 @@ export interface GameState {
|
|||||||
activeDungeonId: DungeonId;
|
activeDungeonId: DungeonId;
|
||||||
activeDifficultyId: string;
|
activeDifficultyId: string;
|
||||||
gameMode: GameMode;
|
gameMode: GameMode;
|
||||||
|
gmTestMode: boolean;
|
||||||
activeSpawn: WorldSpawn;
|
activeSpawn: WorldSpawn;
|
||||||
sessionRevision: number;
|
sessionRevision: number;
|
||||||
overlay: GameOverlay;
|
overlay: GameOverlay;
|
||||||
@@ -42,7 +43,7 @@ export interface GameState {
|
|||||||
activateDungeon: (
|
activateDungeon: (
|
||||||
dungeonId: DungeonId,
|
dungeonId: DungeonId,
|
||||||
difficultyId?: string,
|
difficultyId?: string,
|
||||||
options?: { readonly gameMode?: GameMode; readonly spawn?: WorldSpawn },
|
options?: { readonly gameMode?: GameMode; readonly spawn?: WorldSpawn; readonly gmTestMode?: boolean },
|
||||||
) => void;
|
) => void;
|
||||||
openOverlay: (overlay: Exclude<GameOverlay, null>) => void;
|
openOverlay: (overlay: Exclude<GameOverlay, null>) => void;
|
||||||
toggleOverlay: (overlay: Exclude<GameOverlay, null>) => void;
|
toggleOverlay: (overlay: Exclude<GameOverlay, null>) => void;
|
||||||
@@ -68,6 +69,7 @@ export const useGameStore = create<GameState>((set) => ({
|
|||||||
activeDungeonId: DEFAULT_DUNGEON_ID,
|
activeDungeonId: DEFAULT_DUNGEON_ID,
|
||||||
activeDifficultyId: initialDungeon.defaultDifficultyId,
|
activeDifficultyId: initialDungeon.defaultDifficultyId,
|
||||||
gameMode: "dungeon",
|
gameMode: "dungeon",
|
||||||
|
gmTestMode: false,
|
||||||
activeSpawn: { footPosition: entrance.footPosition, yaw: entrance.yaw },
|
activeSpawn: { footPosition: entrance.footPosition, yaw: entrance.yaw },
|
||||||
sessionRevision: 0,
|
sessionRevision: 0,
|
||||||
overlay: null,
|
overlay: null,
|
||||||
@@ -100,6 +102,7 @@ export const useGameStore = create<GameState>((set) => ({
|
|||||||
activeDungeonId,
|
activeDungeonId,
|
||||||
activeDifficultyId,
|
activeDifficultyId,
|
||||||
gameMode: options?.gameMode ?? "dungeon",
|
gameMode: options?.gameMode ?? "dungeon",
|
||||||
|
gmTestMode: options?.gmTestMode ?? false,
|
||||||
activeSpawn,
|
activeSpawn,
|
||||||
sessionRevision: state.sessionRevision + 1,
|
sessionRevision: state.sessionRevision + 1,
|
||||||
overlay: null,
|
overlay: null,
|
||||||
@@ -218,6 +221,7 @@ export const useGameStore = create<GameState>((set) => ({
|
|||||||
const activeSpawn = { footPosition: activeEntrance.footPosition, yaw: activeEntrance.yaw };
|
const activeSpawn = { footPosition: activeEntrance.footPosition, yaw: activeEntrance.yaw };
|
||||||
return {
|
return {
|
||||||
gameMode: "dungeon",
|
gameMode: "dungeon",
|
||||||
|
gmTestMode: false,
|
||||||
activeSpawn,
|
activeSpawn,
|
||||||
overlay: null,
|
overlay: null,
|
||||||
paused: false,
|
paused: false,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
rendererCanvasKey,
|
rendererCanvasKey,
|
||||||
} from "./sceneLifecycle";
|
} from "./sceneLifecycle";
|
||||||
import { manastormChaoticLinkRuntimeIds } from "../game/manastormChaoticLink";
|
import { manastormChaoticLinkRuntimeIds } from "../game/manastormChaoticLink";
|
||||||
import { manastormStagePopulation } from "../game/manastormStagePopulation";
|
import { manastormStagePopulation, manastormStageRoamingPacks } from "../game/manastormStagePopulation";
|
||||||
import { resolveStagePresentation } from "../game/manastormStagePresentation";
|
import { resolveStagePresentation } from "../game/manastormStagePresentation";
|
||||||
import {
|
import {
|
||||||
useWailingEncounterStore,
|
useWailingEncounterStore,
|
||||||
@@ -38,6 +38,8 @@ import {
|
|||||||
import { WailingNaralexEvent } from "./WailingNaralexEvent";
|
import { WailingNaralexEvent } from "./WailingNaralexEvent";
|
||||||
import { GameGltfLoaderLifecycle } from "./useGameGLTF";
|
import { GameGltfLoaderLifecycle } from "./useGameGLTF";
|
||||||
import { CombatEffects } from "./CombatEffects";
|
import { CombatEffects } from "./CombatEffects";
|
||||||
|
import { PLAYER_GRAVITY } from "../game/playerMovement";
|
||||||
|
import { GmPlacementMarkers } from "./GmPlacementMarkers";
|
||||||
|
|
||||||
export function GameScene() {
|
export function GameScene() {
|
||||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||||
@@ -73,7 +75,10 @@ export function GameScene() {
|
|||||||
: { entities: dungeon.entities, staticSpawns: dungeon.staticSpawns },
|
: { entities: dungeon.entities, staticSpawns: dungeon.staticSpawns },
|
||||||
[dungeon, gameMode, manastormEncounter],
|
[dungeon, gameMode, manastormEncounter],
|
||||||
);
|
);
|
||||||
const roamingPacks = dungeon.roamingPacks.filter((pack) => spawnMatchesDifficulty(pack.spawnMask, difficultyMask));
|
const roamingPacks = manastormStageRoamingPacks(
|
||||||
|
dungeon.roamingPacks.filter((pack) => spawnMatchesDifficulty(pack.spawnMask, difficultyMask)),
|
||||||
|
gameMode === "manastorm" ? manastormEncounter : null,
|
||||||
|
);
|
||||||
const difficultySpawns = stagePopulation.staticSpawns.filter(
|
const difficultySpawns = stagePopulation.staticSpawns.filter(
|
||||||
(spawn) => spawnMatchesDifficulty(spawn.spawnMask, difficultyMask),
|
(spawn) => spawnMatchesDifficulty(spawn.spawnMask, difficultyMask),
|
||||||
);
|
);
|
||||||
@@ -223,7 +228,7 @@ export function GameScene() {
|
|||||||
/>
|
/>
|
||||||
<Physics
|
<Physics
|
||||||
key={encounterWorldKey(activeDungeonId, sessionRevision)}
|
key={encounterWorldKey(activeDungeonId, sessionRevision)}
|
||||||
gravity={[0, -18, 0]}
|
gravity={[0, PLAYER_GRAVITY, 0]}
|
||||||
paused={simulationBlocked}
|
paused={simulationBlocked}
|
||||||
timeStep={1 / 60}
|
timeStep={1 / 60}
|
||||||
>
|
>
|
||||||
@@ -241,6 +246,7 @@ export function GameScene() {
|
|||||||
/>
|
/>
|
||||||
<WailingNaralexEvent />
|
<WailingNaralexEvent />
|
||||||
<ManastormPortal />
|
<ManastormPortal />
|
||||||
|
<GmPlacementMarkers />
|
||||||
<CombatEffects active={!simulationBlocked} safeGraphics={safeGraphics} />
|
<CombatEffects active={!simulationBlocked} safeGraphics={safeGraphics} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { useMemo, useRef } from "react";
|
||||||
|
import type { Group } from "three";
|
||||||
|
import { useShellStore } from "../app/shellStore";
|
||||||
|
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||||
|
import type { Vector3Tuple } from "../game/dungeonTypes";
|
||||||
|
import {
|
||||||
|
authoredStageConfigForBoss,
|
||||||
|
derivePartySpawnSlots,
|
||||||
|
isGameMasterSession,
|
||||||
|
resolvedBossAdminCatalog,
|
||||||
|
} from "../game/manastormAdminConfig";
|
||||||
|
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||||
|
import { getMobRuntimePosition } from "../game/mobRuntimeRegistry";
|
||||||
|
import { useGameStore } from "../game/store";
|
||||||
|
|
||||||
|
function WorldMarker(props: {
|
||||||
|
readonly position: Vector3Tuple;
|
||||||
|
readonly color: string;
|
||||||
|
readonly scale?: number;
|
||||||
|
readonly runtimeId?: string;
|
||||||
|
}) {
|
||||||
|
const group = useRef<Group>(null);
|
||||||
|
useFrame(() => {
|
||||||
|
if (!props.runtimeId || !group.current) return;
|
||||||
|
const live = getMobRuntimePosition(props.runtimeId);
|
||||||
|
if (live) group.current.position.set(live[0], live[1], live[2]);
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<group ref={group} position={props.position} scale={props.scale ?? 1}>
|
||||||
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.04, 0]} renderOrder={20}>
|
||||||
|
<ringGeometry args={[0.55, 0.76, 28]} />
|
||||||
|
<meshBasicMaterial color={props.color} transparent opacity={0.9} depthTest={false} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.72, 0]} renderOrder={20}>
|
||||||
|
<coneGeometry args={[0.16, 1.25, 10]} />
|
||||||
|
<meshBasicMaterial color={props.color} transparent opacity={0.78} depthTest={false} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackMobPosition(dungeonId: string, runtimeId: string, bossPosition: Vector3Tuple): Vector3Tuple {
|
||||||
|
const dungeon = requireDungeonDefinition(dungeonId);
|
||||||
|
const staticSpawn = dungeon.staticSpawns.find((spawn) => spawn.id === runtimeId);
|
||||||
|
if (staticSpawn) return staticSpawn.position;
|
||||||
|
for (const pack of dungeon.roamingPacks) {
|
||||||
|
const member = pack.members.find((candidate) => `${pack.id}:${candidate.id}` === runtimeId);
|
||||||
|
const origin = pack.waypoints[0];
|
||||||
|
if (member && origin) {
|
||||||
|
return [origin[0] + member.formationOffset[0], origin[1] + member.formationOffset[1], origin[2] + member.formationOffset[2]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bossPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GmPlacementMarkers() {
|
||||||
|
const overlay = useGameStore((state) => state.overlay);
|
||||||
|
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||||
|
const session = useShellStore((state) => state.session);
|
||||||
|
const draft = useManastormAdminStore((state) => state.draft);
|
||||||
|
const editingBossKey = useManastormAdminStore((state) => state.editingBossKey);
|
||||||
|
const entry = useMemo(
|
||||||
|
() => resolvedBossAdminCatalog(draft).find((candidate) => candidate.key === editingBossKey && candidate.dungeonId === activeDungeonId) ?? null,
|
||||||
|
[activeDungeonId, draft, editingBossKey],
|
||||||
|
);
|
||||||
|
const stage = useMemo(() => entry ? authoredStageConfigForBoss(entry, draft) : null, [draft, entry]);
|
||||||
|
const party = useMemo(() => stage ? derivePartySpawnSlots(stage.groupSpawn, 5) : [], [stage]);
|
||||||
|
|
||||||
|
if (overlay !== "gm" || !isGameMasterSession(session) || !entry || !stage) return null;
|
||||||
|
return (
|
||||||
|
<group name="gm-placement-markers">
|
||||||
|
{party.map((slot, index) => (
|
||||||
|
<WorldMarker key={`party-${index}`} position={slot.footPosition} color="#55e8ff" scale={index === 0 ? 1.15 : 0.8} />
|
||||||
|
))}
|
||||||
|
<WorldMarker position={stage.bossPosition} color="#ff485f" scale={1.4} />
|
||||||
|
<WorldMarker position={stage.portalPosition} color="#c26bff" scale={1.2} />
|
||||||
|
{stage.linkedMobRuntimeIds.map((runtimeId) => (
|
||||||
|
<WorldMarker
|
||||||
|
key={runtimeId}
|
||||||
|
position={fallbackMobPosition(stage.dungeonId, runtimeId, stage.bossPosition)}
|
||||||
|
color="#ffd255"
|
||||||
|
runtimeId={runtimeId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1146,9 +1146,10 @@ function RoamingPack({
|
|||||||
const cleanups = definition.members.map((member, index) => registerMobRuntime(
|
const cleanups = definition.members.map((member, index) => registerMobRuntime(
|
||||||
`${definition.id}:${member.id}`,
|
`${definition.id}:${member.id}`,
|
||||||
scratchPositions[index],
|
scratchPositions[index],
|
||||||
|
Math.atan2(scratchForwards[index][0], scratchForwards[index][2]),
|
||||||
));
|
));
|
||||||
return () => cleanups.forEach((cleanup) => cleanup());
|
return () => cleanups.forEach((cleanup) => cleanup());
|
||||||
}, [definition.id, definition.members, scratchPositions]);
|
}, [definition.id, definition.members, scratchForwards, scratchPositions]);
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
if (!nearby) return;
|
if (!nearby) return;
|
||||||
@@ -1242,16 +1243,18 @@ function RoamingPack({
|
|||||||
chasePosition[1] = patrolPosition[1];
|
chasePosition[1] = patrolPosition[1];
|
||||||
chasePosition[2] = patrolPosition[2];
|
chasePosition[2] = patrolPosition[2];
|
||||||
}
|
}
|
||||||
|
const yaw = Math.atan2(forward[0], forward[2]);
|
||||||
updateMobRuntimePosition(
|
updateMobRuntimePosition(
|
||||||
instanceId,
|
instanceId,
|
||||||
position[0],
|
position[0],
|
||||||
position[1],
|
position[1],
|
||||||
position[2],
|
position[2],
|
||||||
|
yaw,
|
||||||
);
|
);
|
||||||
const object = memberRefs.current[index];
|
const object = memberRefs.current[index];
|
||||||
if (!object) continue;
|
if (!object) continue;
|
||||||
object.position.set(position[0], position[1], position[2]);
|
object.position.set(position[0], position[1], position[2]);
|
||||||
object.rotation.y = Math.atan2(forward[0], forward[2]);
|
object.rotation.y = yaw;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1298,7 +1301,7 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
|
|||||||
const entity = entities[spawn.entityId];
|
const entity = entities[spawn.entityId];
|
||||||
const groupRef = useRef<Group>(null);
|
const groupRef = useRef<Group>(null);
|
||||||
const positionRef = useRef<MutableMobVector3>([...spawn.position]);
|
const positionRef = useRef<MutableMobVector3>([...spawn.position]);
|
||||||
useEffect(() => registerMobRuntime(spawn.id, spawn.position), [spawn.id, spawn.position]);
|
useEffect(() => registerMobRuntime(spawn.id, spawn.position, spawn.yaw ?? 0), [spawn.id, spawn.position, spawn.yaw]);
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
const mob = useCombatStore.getState().mobs[spawn.id];
|
const mob = useCombatStore.getState().mobs[spawn.id];
|
||||||
const position = positionRef.current;
|
const position = positionRef.current;
|
||||||
@@ -1334,7 +1337,6 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
|
|||||||
position[1] = spawn.position[1];
|
position[1] = spawn.position[1];
|
||||||
position[2] = spawn.position[2];
|
position[2] = spawn.position[2];
|
||||||
}
|
}
|
||||||
updateMobRuntimePosition(spawn.id, position[0], position[1], position[2]);
|
|
||||||
if (groupRef.current) {
|
if (groupRef.current) {
|
||||||
groupRef.current.position.set(position[0], position[1], position[2]);
|
groupRef.current.position.set(position[0], position[1], position[2]);
|
||||||
if (mob?.engaged) {
|
if (mob?.engaged) {
|
||||||
@@ -1345,6 +1347,13 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
updateMobRuntimePosition(
|
||||||
|
spawn.id,
|
||||||
|
position[0],
|
||||||
|
position[1],
|
||||||
|
position[2],
|
||||||
|
groupRef.current?.rotation.y ?? spawn.yaw ?? 0,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
if (!entity) return null;
|
if (!entity) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { equipmentItemsForOwner } from "../game/equipment";
|
|||||||
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
|
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
|
||||||
import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dungeonAssets";
|
import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dungeonAssets";
|
||||||
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
||||||
|
import { derivePartySpawnSlots } from "../game/manastormAdminConfig";
|
||||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||||
import type { DungeonBossObjective } from "../game/dungeonTypes";
|
import type { DungeonBossObjective } from "../game/dungeonTypes";
|
||||||
import { getMobPosition } from "../game/mobRuntimeRegistry";
|
import { getMobPosition } from "../game/mobRuntimeRegistry";
|
||||||
@@ -534,21 +535,13 @@ function PartyActors({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const game = useGameStore.getState();
|
const game = useGameStore.getState();
|
||||||
|
const spawnSlots = derivePartySpawnSlots(game.activeSpawn, members.length + 1);
|
||||||
members.forEach((member, index) => {
|
members.forEach((member, index) => {
|
||||||
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
||||||
if (game.gameMode === "manastorm" || stackAtEntrance) {
|
const slot = spawnSlots[index + 1] ?? spawnSlots[0];
|
||||||
position[0] = game.playerPosition[0];
|
position[0] = slot.footPosition[0];
|
||||||
position[1] = game.playerPosition[1];
|
position[1] = slot.footPosition[1];
|
||||||
position[2] = game.playerPosition[2];
|
position[2] = slot.footPosition[2];
|
||||||
} else {
|
|
||||||
setEntrancePosition(
|
|
||||||
index,
|
|
||||||
game.playerPosition,
|
|
||||||
Math.sin(game.cameraYaw),
|
|
||||||
Math.cos(game.cameraYaw),
|
|
||||||
position,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const navigationSpawn = navigationGraphRef.current
|
const navigationSpawn = navigationGraphRef.current
|
||||||
? findNearestPartyNavigationSurface(navigationGraphRef.current, position, 6)
|
? findNearestPartyNavigationSurface(navigationGraphRef.current, position, 6)
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
+74
-25
@@ -50,6 +50,13 @@ import {
|
|||||||
updatePlayerJumpTiming,
|
updatePlayerJumpTiming,
|
||||||
type CharacterVerticalMotion,
|
type CharacterVerticalMotion,
|
||||||
} from "../game/playerJump";
|
} from "../game/playerJump";
|
||||||
|
import {
|
||||||
|
PLAYER_CHARACTER_CONTROLLER_OFFSET,
|
||||||
|
PLAYER_GROUND_STICK_VELOCITY,
|
||||||
|
airbornePlayerVerticalVelocity,
|
||||||
|
configurePlayerCharacterController,
|
||||||
|
playerFrameDeltaSeconds,
|
||||||
|
} from "../game/playerMovement";
|
||||||
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
||||||
import { useGameStore } from "../game/store";
|
import { useGameStore } from "../game/store";
|
||||||
|
|
||||||
@@ -85,11 +92,13 @@ interface PlayerControllerProps extends PlayerRigRefs {
|
|||||||
|
|
||||||
function placeAtActiveSpawn(body: RapierRigidBody, orbit: OrbitState): void {
|
function placeAtActiveSpawn(body: RapierRigidBody, orbit: OrbitState): void {
|
||||||
const spawn = useGameStore.getState().activeSpawn;
|
const spawn = useGameStore.getState().activeSpawn;
|
||||||
body.setTranslation({
|
const translation = {
|
||||||
x: spawn.footPosition[0],
|
x: spawn.footPosition[0],
|
||||||
y: spawn.footPosition[1] + PLAYER_CENTER_HEIGHT,
|
y: spawn.footPosition[1] + PLAYER_CENTER_HEIGHT,
|
||||||
z: spawn.footPosition[2],
|
z: spawn.footPosition[2],
|
||||||
}, true);
|
};
|
||||||
|
body.setTranslation(translation, true);
|
||||||
|
body.setNextKinematicTranslation(translation);
|
||||||
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||||
body.setAngvel({ x: 0, y: 0, z: 0 }, true);
|
body.setAngvel({ x: 0, y: 0, z: 0 }, true);
|
||||||
orbit.yaw = spawn.yaw;
|
orbit.yaw = spawn.yaw;
|
||||||
@@ -122,8 +131,19 @@ function PlayerController({
|
|||||||
() => new rapier.Ray({ x: 0, y: 0, z: 0 }, { x: 0, y: -1, z: 0 }),
|
() => new rapier.Ray({ x: 0, y: 0, z: 0 }, { x: 0, y: -1, z: 0 }),
|
||||||
[rapier],
|
[rapier],
|
||||||
);
|
);
|
||||||
|
const characterController = useMemo(
|
||||||
|
() => configurePlayerCharacterController(
|
||||||
|
world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET),
|
||||||
|
),
|
||||||
|
[world],
|
||||||
|
);
|
||||||
const jumpTimingRef = useRef(createPlayerJumpTiming());
|
const jumpTimingRef = useRef(createPlayerJumpTiming());
|
||||||
const verticalPresentationTimingRef = useRef(createCharacterVerticalPresentationTiming());
|
const verticalPresentationTimingRef = useRef(createCharacterVerticalPresentationTiming());
|
||||||
|
const verticalVelocityRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
world.removeCharacterController(characterController);
|
||||||
|
}, [characterController, world]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bodyRef.current) placeAtActiveSpawn(bodyRef.current, orbitRef.current);
|
if (bodyRef.current) placeAtActiveSpawn(bodyRef.current, orbitRef.current);
|
||||||
@@ -131,6 +151,7 @@ function PlayerController({
|
|||||||
clearJumpRequest();
|
clearJumpRequest();
|
||||||
jumpTimingRef.current = createPlayerJumpTiming(performance.now());
|
jumpTimingRef.current = createPlayerJumpTiming(performance.now());
|
||||||
verticalPresentationTimingRef.current = createCharacterVerticalPresentationTiming();
|
verticalPresentationTimingRef.current = createCharacterVerticalPresentationTiming();
|
||||||
|
verticalVelocityRef.current = 0;
|
||||||
verticalMotionRef.current = "grounded";
|
verticalMotionRef.current = "grounded";
|
||||||
}, [bodyRef, orbitRef, resetRevision, spawn, verticalMotionRef]);
|
}, [bodyRef, orbitRef, resetRevision, spawn, verticalMotionRef]);
|
||||||
|
|
||||||
@@ -160,7 +181,6 @@ function PlayerController({
|
|||||||
orbit.pitch = clampCameraPitch(orbit.pitch + snapshot.lookY * delta * 1.55);
|
orbit.pitch = clampCameraPitch(orbit.pitch + snapshot.lookY * delta * 1.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
const velocity = body.linvel();
|
|
||||||
const translation = body.translation();
|
const translation = body.translation();
|
||||||
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) {
|
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) {
|
||||||
placeAtActiveSpawn(body, orbit);
|
placeAtActiveSpawn(body, orbit);
|
||||||
@@ -181,20 +201,25 @@ function PlayerController({
|
|||||||
undefined,
|
undefined,
|
||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
let grounded = velocity.y <= 0.5 && isWalkableGroundHit(groundHit, groundProbeLength);
|
const groundedBeforeMove = verticalVelocityRef.current <= 0.5
|
||||||
|
&& isWalkableGroundHit(groundHit, groundProbeLength);
|
||||||
const requestedAtMs = consumeJumpRequest();
|
const requestedAtMs = consumeJumpRequest();
|
||||||
const gameplayBlocked = paused || mapOpen || companionOpen || controlledUntil > Date.now();
|
const gameplayBlocked = paused
|
||||||
|
|| mapOpen
|
||||||
|
|| companionOpen
|
||||||
|
|| useCombatStore.getState().health <= 0
|
||||||
|
|| controlledUntil > Date.now();
|
||||||
if (gameplayBlocked) {
|
if (gameplayBlocked) {
|
||||||
cancelBufferedPlayerJump(jumpTimingRef.current);
|
cancelBufferedPlayerJump(jumpTimingRef.current);
|
||||||
movingRef.current = false;
|
movingRef.current = false;
|
||||||
verticalMotionRef.current = updateCharacterVerticalPresentation(
|
verticalMotionRef.current = updateCharacterVerticalPresentation(
|
||||||
verticalPresentationTimingRef.current,
|
verticalPresentationTimingRef.current,
|
||||||
grounded,
|
groundedBeforeMove,
|
||||||
velocity.y,
|
verticalVelocityRef.current,
|
||||||
nowMs,
|
nowMs,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
body.setLinvel({ x: 0, y: velocity.y, z: 0 }, true);
|
body.setNextKinematicTranslation(translation);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (requestedAtMs !== null) bufferPlayerJump(jumpTimingRef.current, requestedAtMs);
|
if (requestedAtMs !== null) bufferPlayerJump(jumpTimingRef.current, requestedAtMs);
|
||||||
@@ -202,11 +227,42 @@ function PlayerController({
|
|||||||
const shouldJump = updatePlayerJumpTiming(
|
const shouldJump = updatePlayerJumpTiming(
|
||||||
jumpTimingRef.current,
|
jumpTimingRef.current,
|
||||||
nowMs,
|
nowMs,
|
||||||
grounded,
|
groundedBeforeMove,
|
||||||
velocity.y,
|
verticalVelocityRef.current,
|
||||||
);
|
);
|
||||||
const verticalVelocity = shouldJump ? PLAYER_JUMP_VELOCITY : velocity.y;
|
const movementDeltaSeconds = playerFrameDeltaSeconds(delta);
|
||||||
if (shouldJump) grounded = false;
|
let verticalVelocity = shouldJump
|
||||||
|
? PLAYER_JUMP_VELOCITY
|
||||||
|
: groundedBeforeMove
|
||||||
|
? PLAYER_GROUND_STICK_VELOCITY
|
||||||
|
: airbornePlayerVerticalVelocity(verticalVelocityRef.current, movementDeltaSeconds);
|
||||||
|
|
||||||
|
const [worldX, worldZ] = cameraRelativeMovement(
|
||||||
|
snapshot.moveX,
|
||||||
|
snapshot.moveForward,
|
||||||
|
orbit.yaw,
|
||||||
|
);
|
||||||
|
if (body.numColliders() === 0) return;
|
||||||
|
characterController.computeColliderMovement(
|
||||||
|
body.collider(0),
|
||||||
|
{
|
||||||
|
x: worldX * MOVE_SPEED * movementDeltaSeconds,
|
||||||
|
y: verticalVelocity * movementDeltaSeconds,
|
||||||
|
z: worldZ * MOVE_SPEED * movementDeltaSeconds,
|
||||||
|
},
|
||||||
|
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||||
|
);
|
||||||
|
const computedMovement = characterController.computedMovement();
|
||||||
|
const nextTranslation = {
|
||||||
|
x: translation.x + computedMovement.x,
|
||||||
|
y: translation.y + computedMovement.y,
|
||||||
|
z: translation.z + computedMovement.z,
|
||||||
|
};
|
||||||
|
body.setNextKinematicTranslation(nextTranslation);
|
||||||
|
|
||||||
|
const grounded = !shouldJump && characterController.computedGrounded();
|
||||||
|
if (grounded && verticalVelocity <= 0) verticalVelocity = 0;
|
||||||
|
verticalVelocityRef.current = verticalVelocity;
|
||||||
verticalMotionRef.current = updateCharacterVerticalPresentation(
|
verticalMotionRef.current = updateCharacterVerticalPresentation(
|
||||||
verticalPresentationTimingRef.current,
|
verticalPresentationTimingRef.current,
|
||||||
grounded,
|
grounded,
|
||||||
@@ -215,13 +271,6 @@ function PlayerController({
|
|||||||
shouldJump,
|
shouldJump,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [worldX, worldZ] = cameraRelativeMovement(
|
|
||||||
snapshot.moveX,
|
|
||||||
snapshot.moveForward,
|
|
||||||
orbit.yaw,
|
|
||||||
);
|
|
||||||
body.setLinvel({ x: worldX * MOVE_SPEED, y: verticalVelocity, z: worldZ * MOVE_SPEED }, true);
|
|
||||||
|
|
||||||
const moving = Math.hypot(worldX, worldZ) > 0.05;
|
const moving = Math.hypot(worldX, worldZ) > 0.05;
|
||||||
movingRef.current = moving;
|
movingRef.current = moving;
|
||||||
if (avatarRef.current && moving) {
|
if (avatarRef.current && moving) {
|
||||||
@@ -235,11 +284,11 @@ function PlayerController({
|
|||||||
: null;
|
: null;
|
||||||
const belowRecoveryPlane = activeManastormPackage
|
const belowRecoveryPlane = activeManastormPackage
|
||||||
? isBelowManastormRecoveryPlane(
|
? isBelowManastormRecoveryPlane(
|
||||||
translation.y,
|
nextTranslation.y,
|
||||||
activeManastormPackage.anchors.map((anchor) => anchor.position[1]),
|
activeManastormPackage.anchors.map((anchor) => anchor.position[1]),
|
||||||
dungeon.bounds,
|
dungeon.bounds,
|
||||||
)
|
)
|
||||||
: isBelowDungeonRecoveryPlane(translation.y, dungeon.bounds);
|
: isBelowDungeonRecoveryPlane(nextTranslation.y, dungeon.bounds);
|
||||||
if (belowRecoveryPlane) {
|
if (belowRecoveryPlane) {
|
||||||
useGameStore.getState().resetAtActiveSpawn();
|
useGameStore.getState().resetAtActiveSpawn();
|
||||||
return;
|
return;
|
||||||
@@ -249,9 +298,9 @@ function PlayerController({
|
|||||||
if (reportAccumulator.current >= 0.1) {
|
if (reportAccumulator.current >= 0.1) {
|
||||||
reportAccumulator.current = 0;
|
reportAccumulator.current = 0;
|
||||||
const footPosition: [number, number, number] = [
|
const footPosition: [number, number, number] = [
|
||||||
translation.x,
|
nextTranslation.x,
|
||||||
translation.y - PLAYER_CENTER_HEIGHT,
|
nextTranslation.y - PLAYER_CENTER_HEIGHT,
|
||||||
translation.z,
|
nextTranslation.z,
|
||||||
];
|
];
|
||||||
const distanceDelta = planarDistance(lastReported.current, footPosition);
|
const distanceDelta = planarDistance(lastReported.current, footPosition);
|
||||||
useGameStore.getState().updatePlayerSnapshot({
|
useGameStore.getState().updatePlayerSnapshot({
|
||||||
@@ -268,7 +317,7 @@ function PlayerController({
|
|||||||
<RigidBody
|
<RigidBody
|
||||||
ref={bodyRef}
|
ref={bodyRef}
|
||||||
name="player-capsule"
|
name="player-capsule"
|
||||||
type="dynamic"
|
type="kinematicPosition"
|
||||||
colliders={false}
|
colliders={false}
|
||||||
canSleep={false}
|
canSleep={false}
|
||||||
enabledRotations={[false, false, false]}
|
enabledRotations={[false, false, false]}
|
||||||
|
|||||||
+160
@@ -443,6 +443,34 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
|||||||
}
|
}
|
||||||
.loading-screen__track span { display: block; height: 100%; background: var(--moss); transition: width 180ms ease; }
|
.loading-screen__track span { display: block; height: 100%; background: var(--moss); transition: width 180ms ease; }
|
||||||
.loading-screen__item { color: var(--muted); font-size: 0.72rem; }
|
.loading-screen__item { color: var(--muted); font-size: 0.72rem; }
|
||||||
|
.defeated-prompt {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 18px;
|
||||||
|
background: radial-gradient(circle at 50% 48%, transparent 0 18%, rgba(12, 3, 3, .26) 72%, rgba(8, 1, 1, .56));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.defeated-prompt__panel {
|
||||||
|
display: grid;
|
||||||
|
width: min(390px, calc(100vw - 28px));
|
||||||
|
justify-items: center;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border: 1px solid rgba(205, 124, 105, .34);
|
||||||
|
border-radius: 14px 5px 14px 5px;
|
||||||
|
background: linear-gradient(145deg, rgba(29, 10, 9, .94), rgba(8, 18, 13, .94));
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, .55), inset 0 0 28px rgba(135, 47, 37, .08);
|
||||||
|
text-align: center;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.defeated-prompt__panel .eyebrow { margin: 0 0 4px; color: #e49682; }
|
||||||
|
.defeated-prompt__panel h2 { margin: 0; font: 500 clamp(1.25rem, 4vw, 1.65rem) Georgia, serif; }
|
||||||
|
.defeated-prompt__panel > p:not(.eyebrow) { margin: 8px 0 14px; color: #bfc8b8; font-size: .72rem; line-height: 1.45; }
|
||||||
|
.defeated-prompt__panel .button { min-width: min(260px, 100%); }
|
||||||
|
.defeated-prompt__panel > small { margin-top: 8px; color: #a69f97; font-size: .58rem; line-height: 1.35; }
|
||||||
.graphics-recovery p:not(.eyebrow) { max-width: 550px; color: var(--muted); }
|
.graphics-recovery p:not(.eyebrow) { max-width: 550px; color: var(--muted); }
|
||||||
.graphics-recovery .button { margin-top: 10px; }
|
.graphics-recovery .button { margin-top: 10px; }
|
||||||
|
|
||||||
@@ -552,6 +580,8 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
|||||||
.pause-menu { padding: 20px; }
|
.pause-menu { padding: 20px; }
|
||||||
.pause-menu > p:not(.eyebrow) { margin: 7px 0; }
|
.pause-menu > p:not(.eyebrow) { margin: 7px 0; }
|
||||||
.pause-menu__actions { margin: 12px 0; grid-template-columns: 1fr 1fr; }
|
.pause-menu__actions { margin: 12px 0; grid-template-columns: 1fr 1fr; }
|
||||||
|
.defeated-prompt__panel { padding: 12px 16px; }
|
||||||
|
.defeated-prompt__panel > p:not(.eyebrow) { margin: 5px 0 9px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -2689,3 +2719,133 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
|||||||
.content-pack-row { grid-template-columns: 1fr auto; }
|
.content-pack-row { grid-template-columns: 1fr auto; }
|
||||||
.content-pack-row > span { grid-column: 1 / -1; }
|
.content-pack-row > span { grid-column: 1 / -1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Server-authorized Manastorm administration */
|
||||||
|
.gm-admin-screen { display: grid; grid-template-rows: auto minmax(0, 1fr); }
|
||||||
|
.gm-admin-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--front-line);
|
||||||
|
background: rgba(3, 10, 7, .72);
|
||||||
|
}
|
||||||
|
.gm-admin-header > div:nth-child(2) { display: grid; gap: 2px; }
|
||||||
|
.gm-admin-header > div:nth-child(2) span { color: var(--front-dim); font-size: 9px; letter-spacing: .09em; text-transform: uppercase; }
|
||||||
|
.gm-admin-header > div:nth-child(2) strong { font-family: Georgia, serif; font-size: 22px; font-weight: 400; }
|
||||||
|
.gm-admin-header__actions { display: flex; gap: 7px; }
|
||||||
|
.gm-admin-layout { display: grid; grid-template-columns: minmax(205px, .72fr) minmax(330px, 1.55fr) minmax(240px, .82fr); min-height: 0; }
|
||||||
|
.gm-admin-options, .gm-boss-browser, .gm-boss-detail { min-width: 0; min-height: 0; padding: 16px; }
|
||||||
|
.gm-admin-options, .gm-boss-browser { border-right: 1px solid var(--front-line); }
|
||||||
|
.gm-admin-options h2, .gm-boss-browser h2, .gm-boss-detail h2 { margin: 4px 0 13px; font-family: Georgia, serif; font-size: 20px; font-weight: 400; }
|
||||||
|
.gm-switch-row { display: grid; grid-template-columns: 25px minmax(0, 1fr); align-items: start; gap: 8px; padding: 9px 0; border-top: 1px solid rgba(190, 213, 172, .09); cursor: pointer; }
|
||||||
|
.gm-switch-row input { width: 17px; height: 17px; accent-color: #91bc75; }
|
||||||
|
.gm-switch-row span, .gm-switch-row strong, .gm-switch-row small { display: block; }
|
||||||
|
.gm-switch-row strong { font-size: 11px; }
|
||||||
|
.gm-switch-row small { margin-top: 3px; color: var(--front-dim); font-size: 8px; line-height: 1.35; }
|
||||||
|
.gm-admin-summary { display: grid; gap: 5px; margin-top: 14px; padding: 10px; border: 1px solid var(--front-line); border-radius: 8px; background: rgba(255,255,255,.025); font-size: 9px; }
|
||||||
|
.gm-admin-summary span { display: flex; justify-content: space-between; }
|
||||||
|
.gm-unsaved { color: #ffd06d; font-size: 9px; font-weight: 800; letter-spacing: .11em; text-transform: uppercase; }
|
||||||
|
.gm-boss-browser { display: grid; grid-template-rows: auto minmax(0, 1fr); }
|
||||||
|
.gm-boss-browser > header { display: grid; grid-template-columns: minmax(0, 1fr) minmax(145px, .75fr) 130px; align-items: end; gap: 8px; margin-bottom: 10px; }
|
||||||
|
.gm-boss-filters { display: grid; grid-column: 1 / -1; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
|
||||||
|
.gm-boss-browser input, .gm-boss-browser select, .gm-world-editor input, .gm-world-editor select {
|
||||||
|
width: 100%; min-width: 0; padding: 8px; border: 1px solid var(--front-line); border-radius: 6px; outline: 0; color: var(--front-cream); background: rgba(2, 9, 6, .88); font: inherit;
|
||||||
|
}
|
||||||
|
.gm-boss-browser input:focus, .gm-boss-browser select:focus, .gm-world-editor input:focus, .gm-world-editor select:focus { border-color: rgba(169, 211, 132, .65); }
|
||||||
|
.gm-boss-list { min-height: 0; overflow: auto; border: 1px solid var(--front-line); border-radius: 8px; background: rgba(1, 8, 5, .42); }
|
||||||
|
.gm-boss-row { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) auto 76px; align-items: center; gap: 10px; padding: 10px; border: 0; border-bottom: 1px solid rgba(190, 213, 172, .08); color: var(--front-cream); background: transparent; cursor: pointer; text-align: left; }
|
||||||
|
.gm-boss-row:hover, .gm-boss-row.is-active { background: rgba(149, 190, 118, .1); }
|
||||||
|
.gm-boss-row.is-active { box-shadow: inset 3px 0 #9dcc7c; }
|
||||||
|
.gm-boss-row span, .gm-boss-row strong, .gm-boss-row small { display: block; min-width: 0; }
|
||||||
|
.gm-boss-row strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.gm-boss-row small, .gm-boss-row em { margin-top: 3px; color: var(--front-dim); font-size: 7px; font-style: normal; }
|
||||||
|
.gm-boss-row > b { padding: 4px 5px; border-radius: 4px; color: #e8e3c8; background: rgba(173, 144, 76, .18); font-size: 7px; text-align: center; }
|
||||||
|
.gm-boss-row.is-ready > b { color: #c9f0b6; background: rgba(85, 146, 78, .24); }
|
||||||
|
.gm-boss-row.is-not-ready > b { color: #f0b6ac; background: rgba(151, 62, 53, .24); }
|
||||||
|
.gm-boss-detail { overflow: auto; background: rgba(3, 10, 7, .48); }
|
||||||
|
.gm-boss-detail > p:not(.front-kicker, .front-notice) { color: var(--front-dim); font-size: 9px; }
|
||||||
|
.gm-boss-detail dl div { display: flex; justify-content: space-between; gap: 8px; padding: 7px 0; border-top: 1px solid rgba(190, 213, 172, .09); font-size: 8px; }
|
||||||
|
.gm-boss-detail dd { margin: 0; color: #d7c68c; text-align: right; }
|
||||||
|
.gm-status-buttons { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; margin: 15px 0; }
|
||||||
|
.gm-status-buttons button, .gm-world-editor button { padding: 7px 9px; border: 1px solid var(--front-line); border-radius: 6px; color: var(--front-cream); background: rgba(151, 182, 127, .07); cursor: pointer; }
|
||||||
|
.gm-status-buttons button.is-active { border-color: rgba(184, 219, 144, .6); background: rgba(113, 158, 88, .26); }
|
||||||
|
.gm-status-buttons button:disabled, .gm-world-editor button:disabled { cursor: not-allowed; opacity: .4; }
|
||||||
|
.gm-boss-actions { display: grid; gap: 7px; }
|
||||||
|
.gm-admin-denied { display: grid; width: min(470px, calc(100% - 30px)); margin: auto; gap: 12px; padding: 26px; border: 1px solid var(--front-line); background: rgba(3, 10, 7, .75); }
|
||||||
|
|
||||||
|
.gm-world-editor {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
z-index: 95;
|
||||||
|
display: grid;
|
||||||
|
width: min(490px, calc(100% - 20px));
|
||||||
|
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(178, 211, 154, .36);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: rgba(5, 15, 11, .96);
|
||||||
|
box-shadow: -14px 18px 45px rgba(0,0,0,.5);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.single-display-frame .gm-world-editor { bottom: 58px; }
|
||||||
|
.gm-world-editor__header { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--edge); }
|
||||||
|
.gm-world-editor__header h2 { margin: 2px 0 0; font-family: Georgia, serif; font-size: 20px; font-weight: 400; }
|
||||||
|
.gm-world-editor__header > span { color: #adc0a6; font-size: 8px; text-transform: uppercase; letter-spacing: .09em; }
|
||||||
|
.gm-world-editor__header > span.is-dirty { color: #ffd06d; }
|
||||||
|
.gm-world-editor__telemetry { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px 12px; padding: 9px 14px; border-bottom: 1px solid var(--edge); background: rgba(104, 143, 83, .08); font-size: 8px; }
|
||||||
|
.gm-world-editor__telemetry span { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.gm-world-editor__telemetry b { color: var(--ink); font-weight: 600; }
|
||||||
|
.gm-world-editor__section { padding: 12px 14px; border-bottom: 1px solid var(--edge); }
|
||||||
|
.gm-world-editor__section h3 { margin: 0 0 4px; font-size: 12px; }
|
||||||
|
.gm-world-editor__section p { margin: 0 0 9px; color: var(--muted); font-size: 8px; line-height: 1.4; }
|
||||||
|
.gm-world-editor__boss-select { display: grid; grid-template-columns: 78px minmax(0, 1fr); align-items: center; gap: 8px; font-size: 9px; }
|
||||||
|
.gm-inline-actions { display: flex; gap: 6px; margin-top: 8px; }
|
||||||
|
.gm-world-editor__scroll { min-height: 0; overflow: auto; }
|
||||||
|
.gm-vector-editor { min-width: 0; margin: 9px 0 0; padding: 8px; border: 1px solid rgba(183, 207, 151, .16); border-radius: 7px; }
|
||||||
|
.gm-vector-editor legend { padding: 0 5px; color: #c6d9b4; font-size: 8px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
|
||||||
|
.gm-vector-editor__coordinates { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
|
||||||
|
.gm-vector-editor__coordinates > label { display: grid; grid-template-columns: 12px minmax(0, 1fr); align-items: center; gap: 4px; color: var(--muted); font-size: 8px; }
|
||||||
|
.gm-vector-editor__coordinates .gm-nudges { display: grid; grid-column: 2; grid-template-columns: 1fr 1fr; gap: 3px; }
|
||||||
|
.gm-vector-editor__coordinates .gm-nudges button { padding: 3px; }
|
||||||
|
.gm-yaw-editor { display: flex; align-items: center; gap: 7px; margin-top: 7px; color: var(--muted); font-size: 8px; }
|
||||||
|
.gm-yaw-editor label { display: flex; align-items: center; gap: 7px; }
|
||||||
|
.gm-yaw-editor input { max-width: 105px; }
|
||||||
|
.gm-yaw-editor .gm-nudges { display: flex; gap: 3px; }
|
||||||
|
.gm-yaw-editor .gm-nudges button { padding: 4px 7px; }
|
||||||
|
.gm-vector-editor > button { margin-top: 7px; }
|
||||||
|
.gm-world-editor__linked-heading { display: flex; align-items: start; justify-content: space-between; gap: 10px; }
|
||||||
|
.gm-required-kills { display: grid; grid-template-columns: auto 70px 1fr; align-items: center; gap: 8px; margin: 8px 0; font-size: 9px; }
|
||||||
|
.gm-required-kills small { color: var(--muted); }
|
||||||
|
.gm-linked-list { display: grid; gap: 5px; }
|
||||||
|
.gm-linked-list > div { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; padding: 5px 7px; border: 1px solid rgba(183, 207, 151, .1); border-radius: 5px; background: rgba(255,255,255,.02); }
|
||||||
|
.gm-linked-list code { overflow: hidden; color: #ffd774; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.gm-world-editor__empty { display: grid; min-height: 0; place-items: center; padding: 20px; color: var(--muted); text-align: center; }
|
||||||
|
.gm-world-editor__footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; border-top: 1px solid var(--edge); background: rgba(2, 8, 5, .78); }
|
||||||
|
.gm-world-editor__footer p { min-width: 0; margin: 0; overflow: hidden; color: var(--muted); font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.gm-world-editor__footer i { display: inline-block; width: 7px; height: 7px; margin: 0 2px 0 6px; border-radius: 50%; }
|
||||||
|
.gm-world-editor__footer i.is-party { background: #55e8ff; }
|
||||||
|
.gm-world-editor__footer i.is-boss { background: #ff485f; }
|
||||||
|
.gm-world-editor__footer i.is-portal { background: #c26bff; }
|
||||||
|
.gm-world-editor__footer i.is-linked { background: #ffd255; }
|
||||||
|
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
.gm-admin-layout { grid-template-columns: 190px minmax(320px, 1fr); }
|
||||||
|
.gm-boss-detail { display: none; }
|
||||||
|
.gm-boss-browser { border-right: 0; }
|
||||||
|
.gm-admin-header__actions .front-button:not(.front-button--primary) { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.gm-admin-layout { display: block; overflow: auto; }
|
||||||
|
.gm-admin-options { border-right: 0; border-bottom: 1px solid var(--front-line); }
|
||||||
|
.gm-boss-browser { min-height: 460px; }
|
||||||
|
.gm-boss-browser > header { grid-template-columns: 1fr 1fr; }
|
||||||
|
.gm-boss-browser > header > div { grid-column: 1 / -1; }
|
||||||
|
.gm-boss-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.gm-world-editor { inset: 5px; width: auto; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { enterGmManastormTest } from "../app/gmManastormRuntime";
|
||||||
|
import { installDungeonShellRuntime } from "../app/dungeonShellRuntime";
|
||||||
|
import { useShellStore } from "../app/shellStore";
|
||||||
|
import {
|
||||||
|
bossAdminRecordForStatus,
|
||||||
|
isGameMasterSession,
|
||||||
|
resolvedBossAdminCatalog,
|
||||||
|
type ManastormBossStatus,
|
||||||
|
type ManastormGlobalOptions,
|
||||||
|
} from "../game/manastormAdminConfig";
|
||||||
|
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||||
|
import { useGameStore } from "../game/store";
|
||||||
|
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||||
|
|
||||||
|
const STATUS_LABELS: Readonly<Record<ManastormBossStatus, string>> = {
|
||||||
|
ready: "Ready",
|
||||||
|
"needs-tested": "Needs tested",
|
||||||
|
"not-ready": "Not ready",
|
||||||
|
};
|
||||||
|
|
||||||
|
const GLOBAL_OPTIONS: readonly { readonly key: keyof ManastormGlobalOptions; readonly label: string; readonly detail: string }[] = [
|
||||||
|
{ key: "enabled", label: "Manastorms", detail: "Master switch for ordinary player entry." },
|
||||||
|
{ key: "levelingEnabled", label: "Leveling mode", detail: "Allow characters below the end-game threshold to enter." },
|
||||||
|
{ key: "endgameEnabled", label: "End-game mode", detail: "Allow level-cap characters to enter the end-game pool." },
|
||||||
|
{ key: "affixesEnabled", label: "Affixes", detail: "Apply catalog and modifier-derived affixes." },
|
||||||
|
{ key: "chaoticLinkEnabled", label: "Chaotic Link", detail: "Empower bosses until their linked kill requirement is met." },
|
||||||
|
{ key: "milestoneEncountersEnabled", label: "Milestone bosses", detail: "Force the milestone pool every five levels." },
|
||||||
|
];
|
||||||
|
|
||||||
|
installDungeonShellRuntime();
|
||||||
|
|
||||||
|
export function GmAdminScreen() {
|
||||||
|
const session = useShellStore((state) => state.session);
|
||||||
|
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
||||||
|
const enterDungeon = useShellStore((state) => state.enterDungeon);
|
||||||
|
const draft = useManastormAdminStore((state) => state.draft);
|
||||||
|
const revision = useManastormAdminStore((state) => state.revision);
|
||||||
|
const dirty = useManastormAdminStore((state) => state.dirty);
|
||||||
|
const saving = useManastormAdminStore((state) => state.saving);
|
||||||
|
const error = useManastormAdminStore((state) => state.error);
|
||||||
|
const publish = useManastormAdminStore((state) => state.publish);
|
||||||
|
const replaceDraft = useManastormAdminStore((state) => state.replaceDraft);
|
||||||
|
const resetDraft = useManastormAdminStore((state) => state.resetDraft);
|
||||||
|
const setEditingBossKey = useManastormAdminStore((state) => state.setEditingBossKey);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<ManastormBossStatus | "all">("all");
|
||||||
|
const [dungeonFilter, setDungeonFilter] = useState("all");
|
||||||
|
const [sourceFilter, setSourceFilter] = useState<"all" | "dungeon" | "installed-manastorm">("all");
|
||||||
|
const [runnableFilter, setRunnableFilter] = useState<"all" | "runnable" | "incomplete">("all");
|
||||||
|
const [poolFilter, setPoolFilter] = useState<"all" | "public" | "excluded">("all");
|
||||||
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||||
|
const catalog = useMemo(() => resolvedBossAdminCatalog(draft), [draft]);
|
||||||
|
const dungeonOptions = useMemo(() => [...new Map(catalog.map((entry) => [entry.dungeonTitle, entry.dungeonTitle])).keys()].sort(), [catalog]);
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const query = search.trim().toLowerCase();
|
||||||
|
return catalog.filter((entry) => (
|
||||||
|
(statusFilter === "all" || entry.status === statusFilter)
|
||||||
|
&& (dungeonFilter === "all" || entry.dungeonTitle === dungeonFilter)
|
||||||
|
&& (sourceFilter === "all" || entry.source.includes(sourceFilter))
|
||||||
|
&& (runnableFilter === "all" || (runnableFilter === "runnable" ? entry.runnable : !entry.runnable))
|
||||||
|
&& (poolFilter === "all" || (poolFilter === "public" ? entry.inPublicPool : !entry.inPublicPool))
|
||||||
|
&& (!query || `${entry.name} ${entry.dungeonTitle} ${entry.source.join(" ")}`.toLowerCase().includes(query))
|
||||||
|
));
|
||||||
|
}, [catalog, dungeonFilter, poolFilter, runnableFilter, search, sourceFilter, statusFilter]);
|
||||||
|
const selected = catalog.find((entry) => entry.key === selectedKey) ?? filtered[0] ?? null;
|
||||||
|
|
||||||
|
if (!isGameMasterSession(session)) {
|
||||||
|
return (
|
||||||
|
<FrontSurface className="gm-admin-screen" ariaLabel="GM access denied">
|
||||||
|
<section className="gm-admin-denied">
|
||||||
|
<BrandMark />
|
||||||
|
<h1>Game Master access required</h1>
|
||||||
|
<p>This administration surface is available only to server-authorized accounts.</p>
|
||||||
|
<button className="front-button" type="button" onClick={returnToMainMenu}>Return to main menu</button>
|
||||||
|
</section>
|
||||||
|
</FrontSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setGlobal = (key: keyof ManastormGlobalOptions, value: boolean) => replaceDraft({
|
||||||
|
...draft,
|
||||||
|
global: { ...draft.global, [key]: value },
|
||||||
|
});
|
||||||
|
|
||||||
|
const setStatus = (key: string, status: ManastormBossStatus) => {
|
||||||
|
const entry = catalog.find((candidate) => candidate.key === key);
|
||||||
|
if (status === "ready" && !entry?.runnable) return;
|
||||||
|
if (!entry) return;
|
||||||
|
replaceDraft({
|
||||||
|
...draft,
|
||||||
|
bosses: {
|
||||||
|
...draft.bosses,
|
||||||
|
[key]: bossAdminRecordForStatus(entry, draft, status),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const editInDungeon = () => {
|
||||||
|
if (!selected?.dungeonId) return;
|
||||||
|
setEditingBossKey(selected.key);
|
||||||
|
if (enterDungeon(selected.dungeonId)) {
|
||||||
|
useGameStore.getState().openOverlay("gm");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const enterSelectedDungeon = () => {
|
||||||
|
if (!selected?.dungeonId) return;
|
||||||
|
setEditingBossKey(null);
|
||||||
|
enterDungeon(selected.dungeonId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FrontSurface className="gm-admin-screen" ariaLabel="Manastorm GM administration">
|
||||||
|
<header className="gm-admin-header">
|
||||||
|
<BrandMark compact />
|
||||||
|
<div><span>Server authority / revision {revision}</span><strong>Manastorm GM Tools</strong></div>
|
||||||
|
<div className="gm-admin-header__actions">
|
||||||
|
<button className="front-button" type="button" disabled={!dirty || saving} onClick={resetDraft}>Discard</button>
|
||||||
|
<button className="front-button front-button--primary" type="button" disabled={!dirty || saving} onClick={() => { void publish(session); }}>
|
||||||
|
{saving ? "Publishing…" : "Save & publish"}
|
||||||
|
</button>
|
||||||
|
<button className="front-button" type="button" onClick={returnToMainMenu}>Close</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="gm-admin-layout">
|
||||||
|
<aside className="gm-admin-options">
|
||||||
|
<p className="front-kicker">Global switches</p>
|
||||||
|
<h2>Storm rules</h2>
|
||||||
|
{GLOBAL_OPTIONS.map((option) => (
|
||||||
|
<label key={option.key} className="gm-switch-row">
|
||||||
|
<input type="checkbox" checked={draft.global[option.key]} onChange={(event) => setGlobal(option.key, event.currentTarget.checked)} />
|
||||||
|
<span><strong>{option.label}</strong><small>{option.detail}</small></span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<div className="gm-admin-summary">
|
||||||
|
<span><b>{catalog.filter((entry) => entry.status === "ready").length}</b> Ready</span>
|
||||||
|
<span><b>{catalog.filter((entry) => entry.status === "needs-tested").length}</b> Needs tested</span>
|
||||||
|
<span><b>{catalog.filter((entry) => entry.status === "not-ready").length}</b> Not ready</span>
|
||||||
|
</div>
|
||||||
|
{error ? <p className="front-notice" role="alert">{error}</p> : null}
|
||||||
|
{dirty ? <p className="gm-unsaved" role="status">Unsaved changes</p> : null}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="gm-boss-browser">
|
||||||
|
<header>
|
||||||
|
<div><p className="front-kicker">Unified catalog</p><h2>All bosses</h2></div>
|
||||||
|
<input aria-label="Search bosses" value={search} onChange={(event) => setSearch(event.currentTarget.value)} placeholder="Search boss or dungeon" />
|
||||||
|
<select aria-label="Filter boss status" value={statusFilter} onChange={(event) => setStatusFilter(event.currentTarget.value as typeof statusFilter)}>
|
||||||
|
<option value="all">All statuses</option>
|
||||||
|
<option value="ready">Ready</option>
|
||||||
|
<option value="needs-tested">Needs tested</option>
|
||||||
|
<option value="not-ready">Not ready</option>
|
||||||
|
</select>
|
||||||
|
<div className="gm-boss-filters">
|
||||||
|
<select aria-label="Filter dungeon" value={dungeonFilter} onChange={(event) => setDungeonFilter(event.currentTarget.value)}>
|
||||||
|
<option value="all">All dungeons</option>
|
||||||
|
{dungeonOptions.map((title) => <option key={title} value={title}>{title}</option>)}
|
||||||
|
</select>
|
||||||
|
<select aria-label="Filter source" value={sourceFilter} onChange={(event) => setSourceFilter(event.currentTarget.value as typeof sourceFilter)}>
|
||||||
|
<option value="all">All sources</option>
|
||||||
|
<option value="dungeon">Dungeon catalog</option>
|
||||||
|
<option value="installed-manastorm">Imported Manastorm</option>
|
||||||
|
</select>
|
||||||
|
<select aria-label="Filter runnable state" value={runnableFilter} onChange={(event) => setRunnableFilter(event.currentTarget.value as typeof runnableFilter)}>
|
||||||
|
<option value="all">Any stage state</option>
|
||||||
|
<option value="runnable">Runnable</option>
|
||||||
|
<option value="incomplete">Incomplete</option>
|
||||||
|
</select>
|
||||||
|
<select aria-label="Filter pool membership" value={poolFilter} onChange={(event) => setPoolFilter(event.currentTarget.value as typeof poolFilter)}>
|
||||||
|
<option value="all">Any pool state</option>
|
||||||
|
<option value="public">Public pool</option>
|
||||||
|
<option value="excluded">Excluded</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="gm-boss-list" role="listbox" aria-label="Boss catalog">
|
||||||
|
{filtered.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.key}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={entry.key === selected?.key}
|
||||||
|
className={`gm-boss-row is-${entry.status} ${entry.key === selected?.key ? "is-active" : ""}`}
|
||||||
|
onClick={() => setSelectedKey(entry.key)}
|
||||||
|
>
|
||||||
|
<span><strong>{entry.name}</strong><small>{entry.dungeonTitle}</small></span>
|
||||||
|
<em>{entry.runnable ? "Runnable" : "Placement required"}</em>
|
||||||
|
<b>{STATUS_LABELS[entry.status]}</b>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="gm-boss-detail">
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<p className="front-kicker">Boss record</p>
|
||||||
|
<h2>{selected.name}</h2>
|
||||||
|
<p>{selected.dungeonTitle} · Map {selected.mapId}</p>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Sources</dt><dd>{selected.source.join(" + ")}</dd></div>
|
||||||
|
<div><dt>Stage</dt><dd>{selected.runnable ? "Runnable" : "Incomplete"}</dd></div>
|
||||||
|
<div><dt>Pool</dt><dd>{selected.inPublicPool ? "Public" : "Excluded"}</dd></div>
|
||||||
|
<div><dt>Linked mobs</dt><dd>{selected.stage?.trashRuntimeIds?.length ?? 0}</dd></div>
|
||||||
|
<div><dt>Required kills</dt><dd>{draft.global.chaoticLinkEnabled
|
||||||
|
? selected.stage?.requiredGuardianKills ?? Math.ceil((selected.stage?.trashRuntimeIds?.length ?? 0) / 2)
|
||||||
|
: 0}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{selected.validationIssues.map((issue) => <p key={issue} className="front-notice">{issue}</p>)}
|
||||||
|
<div className="gm-status-buttons" aria-label="Boss readiness">
|
||||||
|
{(["ready", "needs-tested", "not-ready"] as const).map((status) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
type="button"
|
||||||
|
className={selected.status === status ? "is-active" : ""}
|
||||||
|
disabled={status === "ready" && !selected.runnable}
|
||||||
|
onClick={() => setStatus(selected.key, status)}
|
||||||
|
>{STATUS_LABELS[status]}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="gm-boss-actions">
|
||||||
|
<button type="button" className="front-button" disabled={!selected.dungeonId} onClick={editInDungeon}>Edit in dungeon</button>
|
||||||
|
<button type="button" className="front-button" disabled={!selected.dungeonId} onClick={enterSelectedDungeon}>Enter dungeon</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="front-button front-button--primary"
|
||||||
|
disabled={!selected.runnable || selected.status === "not-ready" || dirty}
|
||||||
|
title={dirty ? "Save changes before testing." : undefined}
|
||||||
|
onClick={() => enterGmManastormTest(selected.key)}
|
||||||
|
>Test encounter</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : <p className="front-notice">No bosses match this filter.</p>}
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</FrontSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { useMemo, type ReactNode } from "react";
|
||||||
|
import { useShellStore } from "../app/shellStore";
|
||||||
|
import { useCombatStore } from "../game/combatStore";
|
||||||
|
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||||
|
import type { Vector3Tuple, WorldSpawn } from "../game/dungeonTypes";
|
||||||
|
import {
|
||||||
|
authoredStageConfigForBoss,
|
||||||
|
isGameMasterSession,
|
||||||
|
resolvedBossAdminCatalog,
|
||||||
|
type ManastormAdminStageConfig,
|
||||||
|
type ResolvedBossAdminEntry,
|
||||||
|
} from "../game/manastormAdminConfig";
|
||||||
|
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||||
|
import { getMobRuntimeTransform } from "../game/mobRuntimeRegistry";
|
||||||
|
import { useGameStore } from "../game/store";
|
||||||
|
|
||||||
|
const AXES = ["X", "Y", "Z"] as const;
|
||||||
|
|
||||||
|
function formatCoordinate(value: number): string {
|
||||||
|
return Number.isFinite(value) ? value.toFixed(2) : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function VectorEditor(props: {
|
||||||
|
readonly label: string;
|
||||||
|
readonly value: Vector3Tuple;
|
||||||
|
readonly onChange: (value: Vector3Tuple) => void;
|
||||||
|
readonly extra?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const setAxis = (axis: number, raw: number) => {
|
||||||
|
if (!Number.isFinite(raw)) return;
|
||||||
|
const next = [...props.value] as [number, number, number];
|
||||||
|
next[axis] = raw;
|
||||||
|
props.onChange(next);
|
||||||
|
};
|
||||||
|
const nudge = (axis: number, amount: number) => setAxis(axis, props.value[axis] + amount);
|
||||||
|
return (
|
||||||
|
<fieldset className="gm-vector-editor">
|
||||||
|
<legend>{props.label}</legend>
|
||||||
|
<div className="gm-vector-editor__coordinates">
|
||||||
|
{AXES.map((axis, index) => (
|
||||||
|
<label key={axis}>
|
||||||
|
<span>{axis}</span>
|
||||||
|
<input
|
||||||
|
aria-label={`${props.label} ${axis}`}
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={props.value[index]}
|
||||||
|
onChange={(event) => setAxis(index, event.currentTarget.valueAsNumber)}
|
||||||
|
/>
|
||||||
|
<span className="gm-nudges">
|
||||||
|
<button type="button" aria-label={`Nudge ${props.label} ${axis} down`} onClick={() => nudge(index, -0.25)}>−</button>
|
||||||
|
<button type="button" aria-label={`Nudge ${props.label} ${axis} up`} onClick={() => nudge(index, 0.25)}>+</button>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{props.extra}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetSummary(target: ReturnType<typeof useCombatStore.getState>["mobs"][string] | null, id: string | null): string {
|
||||||
|
if (!target || !id) return "No hostile target selected";
|
||||||
|
return `${target.name} · ${target.boss ? "Boss" : "Mob"} · ${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GmWorldEditor() {
|
||||||
|
const overlay = useGameStore((state) => state.overlay);
|
||||||
|
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||||
|
const playerPosition = useGameStore((state) => state.playerPosition);
|
||||||
|
const playerGrounded = useGameStore((state) => state.playerGrounded);
|
||||||
|
const cameraYaw = useGameStore((state) => state.cameraYaw);
|
||||||
|
const closeOverlay = useGameStore((state) => state.closeOverlay);
|
||||||
|
const session = useShellStore((state) => state.session);
|
||||||
|
const selectedTargetId = useCombatStore((state) => state.selectedTargetId);
|
||||||
|
const selectedTarget = useCombatStore((state) => state.selectedTargetId ? state.mobs[state.selectedTargetId] ?? null : null);
|
||||||
|
const draft = useManastormAdminStore((state) => state.draft);
|
||||||
|
const dirty = useManastormAdminStore((state) => state.dirty);
|
||||||
|
const saving = useManastormAdminStore((state) => state.saving);
|
||||||
|
const error = useManastormAdminStore((state) => state.error);
|
||||||
|
const editingBossKey = useManastormAdminStore((state) => state.editingBossKey);
|
||||||
|
const replaceDraft = useManastormAdminStore((state) => state.replaceDraft);
|
||||||
|
const publish = useManastormAdminStore((state) => state.publish);
|
||||||
|
const setEditingBossKey = useManastormAdminStore((state) => state.setEditingBossKey);
|
||||||
|
const catalog = useMemo(() => resolvedBossAdminCatalog(draft), [draft]);
|
||||||
|
const dungeon = requireDungeonDefinition(activeDungeonId);
|
||||||
|
const dungeonBosses = catalog.filter((entry) => entry.dungeonId === activeDungeonId);
|
||||||
|
const entry = catalog.find((candidate) => candidate.key === editingBossKey && candidate.dungeonId === activeDungeonId) ?? null;
|
||||||
|
const stage = entry ? authoredStageConfigForBoss(entry, draft) : null;
|
||||||
|
const targetTransform = selectedTargetId ? getMobRuntimeTransform(selectedTargetId) : null;
|
||||||
|
|
||||||
|
if (overlay !== "gm" || !isGameMasterSession(session)) return null;
|
||||||
|
|
||||||
|
const updateStage = (activeEntry: ResolvedBossAdminEntry, nextStage: ManastormAdminStageConfig) => {
|
||||||
|
const previous = draft.bosses[activeEntry.key];
|
||||||
|
replaceDraft({
|
||||||
|
...draft,
|
||||||
|
bosses: {
|
||||||
|
...draft.bosses,
|
||||||
|
[activeEntry.key]: {
|
||||||
|
status: previous?.status === "not-ready" ? "not-ready" : "needs-tested",
|
||||||
|
stage: nextStage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const captureDungeonAnchor = () => {
|
||||||
|
if (!playerGrounded) return;
|
||||||
|
replaceDraft({
|
||||||
|
...draft,
|
||||||
|
dungeonSpawns: {
|
||||||
|
...draft.dungeonSpawns,
|
||||||
|
[activeDungeonId]: { footPosition: [...playerPosition] as Vector3Tuple, yaw: cameraYaw },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const captureBoss = () => {
|
||||||
|
if (!selectedTarget?.boss || !selectedTargetId || !targetTransform) return;
|
||||||
|
const matching = dungeonBosses.find((candidate) => candidate.bossRuntimeId === selectedTargetId)
|
||||||
|
?? dungeonBosses.find((candidate) => candidate.name.toLowerCase() === selectedTarget.name.toLowerCase());
|
||||||
|
if (!matching) return;
|
||||||
|
const targetStage = authoredStageConfigForBoss(matching, draft);
|
||||||
|
if (!targetStage) return;
|
||||||
|
setEditingBossKey(matching.key);
|
||||||
|
updateStage(matching, {
|
||||||
|
...targetStage,
|
||||||
|
bossRuntimeId: selectedTargetId,
|
||||||
|
bossPosition: [...targetTransform.position] as Vector3Tuple,
|
||||||
|
bossYaw: targetTransform.yaw,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setGroupSpawn = (spawn: WorldSpawn) => {
|
||||||
|
if (!entry || !stage) return;
|
||||||
|
updateStage(entry, { ...stage, groupSpawn: spawn });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLinked = (ids: readonly string[]) => {
|
||||||
|
if (!entry || !stage) return;
|
||||||
|
const unique = [...new Set(ids)].filter((id) => id !== stage.bossRuntimeId);
|
||||||
|
updateStage(entry, {
|
||||||
|
...stage,
|
||||||
|
linkedMobRuntimeIds: unique,
|
||||||
|
requiredKillCount: Math.min(stage.requiredKillCount, unique.length),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const canAddTarget = Boolean(
|
||||||
|
entry
|
||||||
|
&& stage
|
||||||
|
&& selectedTargetId
|
||||||
|
&& selectedTarget
|
||||||
|
&& !selectedTarget.boss
|
||||||
|
&& !stage.linkedMobRuntimeIds.includes(selectedTargetId),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gm-world-editor" role="dialog" aria-modal="true" aria-labelledby="gm-world-title">
|
||||||
|
<header className="gm-world-editor__header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Live world authoring</p>
|
||||||
|
<h2 id="gm-world-title">{dungeon.title}</h2>
|
||||||
|
</div>
|
||||||
|
<span className={dirty ? "is-dirty" : ""}>{dirty ? "Unsaved changes" : "Published revision loaded"}</span>
|
||||||
|
<button className="icon-button" type="button" aria-label="Close GM editor" onClick={closeOverlay}>×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="gm-world-editor__telemetry">
|
||||||
|
<span>Player <b>{playerPosition.map(formatCoordinate).join(", ")}</b></span>
|
||||||
|
<span>Yaw <b>{formatCoordinate(cameraYaw)}</b></span>
|
||||||
|
<span>Grounded <b>{playerGrounded ? "Yes" : "No"}</b></span>
|
||||||
|
<span>Target <b>{targetSummary(selectedTarget, selectedTargetId)}</b></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="gm-world-editor__section">
|
||||||
|
<label className="gm-world-editor__boss-select">
|
||||||
|
<span>Boss record</span>
|
||||||
|
<select value={entry?.key ?? ""} onChange={(event) => setEditingBossKey(event.currentTarget.value || null)}>
|
||||||
|
<option value="">Select a boss in this dungeon</option>
|
||||||
|
{dungeonBosses.map((boss) => <option key={boss.key} value={boss.key}>{boss.name} · {boss.status}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="gm-inline-actions">
|
||||||
|
<button type="button" disabled={!selectedTarget?.boss || !targetTransform} onClick={captureBoss}>Capture targeted boss</button>
|
||||||
|
<button type="button" disabled={!playerGrounded} onClick={captureDungeonAnchor}>Set dungeon group anchor here</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{entry && stage ? (
|
||||||
|
<div className="gm-world-editor__scroll">
|
||||||
|
<section className="gm-world-editor__section">
|
||||||
|
<h3>{entry.name}</h3>
|
||||||
|
<p>{entry.status === "not-ready" ? "Not ready" : "Edits return this boss to Needs tested"} · Map {stage.mapId}</p>
|
||||||
|
<VectorEditor
|
||||||
|
label="Boss spawn"
|
||||||
|
value={stage.bossPosition}
|
||||||
|
onChange={(bossPosition) => updateStage(entry, { ...stage, bossPosition })}
|
||||||
|
extra={(
|
||||||
|
<label className="gm-yaw-editor"><span>Boss yaw</span><input type="number" step="0.05" value={stage.bossYaw} onChange={(event) => {
|
||||||
|
if (Number.isFinite(event.currentTarget.valueAsNumber)) updateStage(entry, { ...stage, bossYaw: event.currentTarget.valueAsNumber });
|
||||||
|
}} /><span className="gm-nudges"><button type="button" aria-label="Nudge boss yaw down" onClick={() => updateStage(entry, { ...stage, bossYaw: stage.bossYaw - 0.05 })}>−</button><button type="button" aria-label="Nudge boss yaw up" onClick={() => updateStage(entry, { ...stage, bossYaw: stage.bossYaw + 0.05 })}>+</button></span></label>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<VectorEditor
|
||||||
|
label="Party group anchor"
|
||||||
|
value={stage.groupSpawn.footPosition}
|
||||||
|
onChange={(footPosition) => setGroupSpawn({ ...stage.groupSpawn, footPosition })}
|
||||||
|
extra={(
|
||||||
|
<div className="gm-yaw-editor">
|
||||||
|
<label><span>Group yaw</span><input type="number" step="0.05" value={stage.groupSpawn.yaw} onChange={(event) => {
|
||||||
|
if (Number.isFinite(event.currentTarget.valueAsNumber)) setGroupSpawn({ ...stage.groupSpawn, yaw: event.currentTarget.valueAsNumber });
|
||||||
|
}} /><span className="gm-nudges"><button type="button" aria-label="Nudge group yaw down" onClick={() => setGroupSpawn({ ...stage.groupSpawn, yaw: stage.groupSpawn.yaw - 0.05 })}>−</button><button type="button" aria-label="Nudge group yaw up" onClick={() => setGroupSpawn({ ...stage.groupSpawn, yaw: stage.groupSpawn.yaw + 0.05 })}>+</button></span></label>
|
||||||
|
<button type="button" disabled={!playerGrounded} onClick={() => setGroupSpawn({ footPosition: [...playerPosition] as Vector3Tuple, yaw: cameraYaw })}>Capture player</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<VectorEditor
|
||||||
|
label="Exit portal"
|
||||||
|
value={stage.portalPosition}
|
||||||
|
onChange={(portalPosition) => updateStage(entry, { ...stage, portalPosition })}
|
||||||
|
extra={<button type="button" disabled={!playerGrounded} onClick={() => updateStage(entry, { ...stage, portalPosition: [...playerPosition] as Vector3Tuple })}>Capture player position</button>}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="gm-world-editor__section">
|
||||||
|
<div className="gm-world-editor__linked-heading">
|
||||||
|
<div><h3>Chaotic Link prerequisites</h3><p>Target an exact non-boss mob, then add its runtime ID.</p></div>
|
||||||
|
<button type="button" disabled={!canAddTarget} onClick={() => selectedTargetId && setLinked([...stage.linkedMobRuntimeIds, selectedTargetId])}>Add targeted mob</button>
|
||||||
|
</div>
|
||||||
|
<label className="gm-required-kills">
|
||||||
|
<span>Required kills</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={stage.linkedMobRuntimeIds.length}
|
||||||
|
value={stage.requiredKillCount}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.currentTarget.valueAsNumber;
|
||||||
|
if (Number.isInteger(value) && value >= 0 && value <= stage.linkedMobRuntimeIds.length) {
|
||||||
|
updateStage(entry, { ...stage, requiredKillCount: value });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<small>of {stage.linkedMobRuntimeIds.length} linked mobs</small>
|
||||||
|
</label>
|
||||||
|
<div className="gm-linked-list">
|
||||||
|
{stage.linkedMobRuntimeIds.map((id) => (
|
||||||
|
<div key={id}><code>{id}</code><button type="button" onClick={() => setLinked(stage.linkedMobRuntimeIds.filter((candidate) => candidate !== id))}>Remove</button></div>
|
||||||
|
))}
|
||||||
|
{!stage.linkedMobRuntimeIds.length ? <p>No prerequisite mobs selected. The boss begins unempowered.</p> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<section className="gm-world-editor__empty">
|
||||||
|
<p>Select a boss record, or target a live boss and capture it.</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="gm-world-editor__footer">
|
||||||
|
{error ? <p role="alert">{error}</p> : <p>Markers: <i className="is-party" /> party · <i className="is-boss" /> boss · <i className="is-portal" /> portal · <i className="is-linked" /> linked</p>}
|
||||||
|
<button type="button" disabled={!dirty || saving} onClick={() => { void publish(session); }}>{saving ? "Publishing…" : "Save & publish"}</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import { useShellStore } from "../app/shellStore";
|
|||||||
import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog";
|
import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog";
|
||||||
import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings";
|
import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings";
|
||||||
import { useCombatStore } from "../game/combatStore";
|
import { useCombatStore } from "../game/combatStore";
|
||||||
|
import { respawnDefeatedPlayer } from "../game/deathRespawn";
|
||||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||||
|
import { resurrectManastormSession } from "../game/manastormSession";
|
||||||
import { resolveStagePresentation } from "../game/manastormStagePresentation";
|
import { resolveStagePresentation } from "../game/manastormStagePresentation";
|
||||||
import { useManastormStore } from "../game/manastormStore";
|
import { useManastormStore } from "../game/manastormStore";
|
||||||
import { usePartyStore } from "../game/partyStore";
|
import { usePartyStore } from "../game/partyStore";
|
||||||
@@ -55,6 +57,46 @@ function LoadingOverlay() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DefeatedPrompt({
|
||||||
|
gameMode,
|
||||||
|
resurrectionCharges,
|
||||||
|
onReturn,
|
||||||
|
}: {
|
||||||
|
gameMode: "dungeon" | "manastorm";
|
||||||
|
resurrectionCharges: number;
|
||||||
|
onReturn: () => void;
|
||||||
|
}) {
|
||||||
|
const canReturn = gameMode === "dungeon" || resurrectionCharges > 0;
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="defeated-prompt"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="false"
|
||||||
|
aria-labelledby="defeated-prompt-title"
|
||||||
|
>
|
||||||
|
<div className="defeated-prompt__panel">
|
||||||
|
<p className="eyebrow">Defeated</p>
|
||||||
|
<h2 id="defeated-prompt-title">You can still be resurrected</h2>
|
||||||
|
<p>
|
||||||
|
Stay down and wait for a party member to resurrect you, or choose to return now.
|
||||||
|
</p>
|
||||||
|
{canReturn ? (
|
||||||
|
<button type="button" className="button button--primary" onClick={onReturn}>
|
||||||
|
{gameMode === "manastorm"
|
||||||
|
? `Resurrect now · ${resurrectionCharges} remaining`
|
||||||
|
: "Respawn at entrance"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<small>No shared resurrection charges remain. A living ally must resurrect you.</small>
|
||||||
|
)}
|
||||||
|
{gameMode === "dungeon" ? (
|
||||||
|
<small>Respawning resets the current encounter and restores the party.</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Hud() {
|
export function Hud() {
|
||||||
const character = useShellStore((state) => state.activeCharacter);
|
const character = useShellStore((state) => state.activeCharacter);
|
||||||
const gameMode = useGameStore((state) => state.gameMode);
|
const gameMode = useGameStore((state) => state.gameMode);
|
||||||
@@ -71,6 +113,7 @@ export function Hud() {
|
|||||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||||
const dungeon = requireDungeonDefinition(activeDungeonId);
|
const dungeon = requireDungeonDefinition(activeDungeonId);
|
||||||
const encounter = useManastormStore((state) => state.currentEncounter);
|
const encounter = useManastormStore((state) => state.currentEncounter);
|
||||||
|
const resurrectionCharges = useManastormStore((state) => state.resurrectionCharges);
|
||||||
const toggleMap = useGameStore((state) => state.toggleMap);
|
const toggleMap = useGameStore((state) => state.toggleMap);
|
||||||
const classId = useCombatStore((state) => state.classId);
|
const classId = useCombatStore((state) => state.classId);
|
||||||
const level = useCombatStore((state) => state.level);
|
const level = useCombatStore((state) => state.level);
|
||||||
@@ -210,6 +253,13 @@ export function Hud() {
|
|||||||
mapId: dungeon.mapId,
|
mapId: dungeon.mapId,
|
||||||
areaName,
|
areaName,
|
||||||
});
|
});
|
||||||
|
const returnFromDefeat = () => {
|
||||||
|
if (gameMode === "manastorm") {
|
||||||
|
resurrectManastormSession({ kind: "player" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
respawnDefeatedPlayer();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
|
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
|
||||||
@@ -329,6 +379,13 @@ export function Hud() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<LoadingOverlay />
|
<LoadingOverlay />
|
||||||
|
{health <= 0 ? (
|
||||||
|
<DefeatedPrompt
|
||||||
|
gameMode={gameMode}
|
||||||
|
resurrectionCharges={resurrectionCharges}
|
||||||
|
onReturn={returnFromDefeat}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
|||||||
import { ControllerButton } from "./ControllerButton";
|
import { ControllerButton } from "./ControllerButton";
|
||||||
import { ContentManagerScreen } from "./ContentManagerScreen";
|
import { ContentManagerScreen } from "./ContentManagerScreen";
|
||||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||||
|
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
||||||
|
|
||||||
const GAME_MODES = [
|
const GAME_MODES = [
|
||||||
{
|
{
|
||||||
@@ -70,8 +71,11 @@ export function MainMenuScreen() {
|
|||||||
const openClassHall = useShellStore((state) => state.openClassHall);
|
const openClassHall = useShellStore((state) => state.openClassHall);
|
||||||
const returnToCharacters = useShellStore((state) => state.returnToCharacters);
|
const returnToCharacters = useShellStore((state) => state.returnToCharacters);
|
||||||
const notice = useShellStore((state) => state.notice);
|
const notice = useShellStore((state) => state.notice);
|
||||||
|
const session = useShellStore((state) => state.session);
|
||||||
|
const openGmAdmin = useShellStore((state) => state.openGmAdmin);
|
||||||
const character = activeCharacter ?? rosterCharacter;
|
const character = activeCharacter ?? rosterCharacter;
|
||||||
const isRom = character?.categoryId === "rom";
|
const isRom = character?.categoryId === "rom";
|
||||||
|
const isGm = isGameMasterSession(session);
|
||||||
|
|
||||||
const actions = useMemo<MenuAction[]>(() => [
|
const actions = useMemo<MenuAction[]>(() => [
|
||||||
{
|
{
|
||||||
@@ -95,7 +99,8 @@ export function MainMenuScreen() {
|
|||||||
neighbors: { up: "mode-dungeons", left: "mode-dungeons" },
|
neighbors: { up: "mode-dungeons", left: "mode-dungeons" },
|
||||||
},
|
},
|
||||||
...(isRom ? [{ id: "mode-class-hall", run: openClassHall }] : []),
|
...(isRom ? [{ id: "mode-class-hall", run: openClassHall }] : []),
|
||||||
], [isRom, openClassHall, openDungeons, openManastorms, returnToCharacters]);
|
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
|
||||||
|
], [isGm, isRom, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||||
const controller = useMenuController(actions, {
|
const controller = useMenuController(actions, {
|
||||||
initialId: "mode-dungeons",
|
initialId: "mode-dungeons",
|
||||||
onBack: returnToCharacters,
|
onBack: returnToCharacters,
|
||||||
@@ -152,6 +157,20 @@ export function MainMenuScreen() {
|
|||||||
<p>Select a destination. More adventures will unlock here as they are built.</p>
|
<p>Select a destination. More adventures will unlock here as they are built.</p>
|
||||||
</header>
|
</header>
|
||||||
<nav className="main-menu-mode-grid" aria-label="Game modes">
|
<nav className="main-menu-mode-grid" aria-label="Game modes">
|
||||||
|
{isGm && (
|
||||||
|
<ControllerButton
|
||||||
|
controlId="mode-gm-tools"
|
||||||
|
selectedId={controller.selectedId}
|
||||||
|
select={controller.select}
|
||||||
|
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--gm"
|
||||||
|
type="button"
|
||||||
|
onClick={openGmAdmin}
|
||||||
|
>
|
||||||
|
<b aria-hidden="true">GM</b>
|
||||||
|
<span><strong>GM Tools</strong><small>Manage Manastorm rules, boss readiness, and encounter placement.</small></span>
|
||||||
|
<em>Authorized</em>
|
||||||
|
</ControllerButton>
|
||||||
|
)}
|
||||||
{isRom && (
|
{isRom && (
|
||||||
<ControllerButton
|
<ControllerButton
|
||||||
controlId="mode-class-hall"
|
controlId="mode-class-hall"
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { DualDisplayFrame } from "../components/DualDisplayFrame";
|
|||||||
import { MANASTORM_CATALOG_SUMMARY } from "../game/manastormCatalogSummary";
|
import { MANASTORM_CATALOG_SUMMARY } from "../game/manastormCatalogSummary";
|
||||||
import { GENERATED_MANASTORM_STAGE_ASSET_PACKAGE_COUNT } from "../game/manastormAssetRegistry";
|
import { GENERATED_MANASTORM_STAGE_ASSET_PACKAGE_COUNT } from "../game/manastormAssetRegistry";
|
||||||
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 { manastormActiveSpellLoadoutModel } from "../game/manastormSpellLoadout";
|
import { manastormActiveSpellLoadoutModel } from "../game/manastormSpellLoadout";
|
||||||
import {
|
import {
|
||||||
drawManastormStage,
|
drawManastormStage,
|
||||||
@@ -47,15 +49,26 @@ export function ManastormSelectScreen() {
|
|||||||
const saveSelectedManastormLoadout = useShellStore((state) => state.saveSelectedManastormLoadout);
|
const saveSelectedManastormLoadout = useShellStore((state) => state.saveSelectedManastormLoadout);
|
||||||
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
||||||
const notice = useShellStore((state) => state.notice);
|
const notice = useShellStore((state) => state.notice);
|
||||||
|
const adminConfig = useManastormAdminStore((state) => state.config);
|
||||||
const character = activeCharacter ?? rosterCharacter;
|
const character = activeCharacter ?? rosterCharacter;
|
||||||
const availableRoles = character ? partyRolesForClass(character.classId) : [];
|
const availableRoles = character ? partyRolesForClass(character.classId) : [];
|
||||||
const [partySize, setPartySize] = useState<ManastormPartySize>(5);
|
const [partySize, setPartySize] = useState<ManastormPartySize>(5);
|
||||||
const [resume, setResume] = useState(true);
|
const [resume, setResume] = useState(true);
|
||||||
const progress = character?.manastormProgress ?? createEmptyManastormProgress();
|
const progress = character?.manastormProgress ?? createEmptyManastormProgress();
|
||||||
|
const effectiveCatalog = useMemo(
|
||||||
|
() => effectiveManastormCatalog(adminConfig, INSTALLED_MANASTORM_RUNTIME_CATALOG),
|
||||||
|
[adminConfig],
|
||||||
|
);
|
||||||
const modeId = manastormModeForCharacterLevel(
|
const modeId = manastormModeForCharacterLevel(
|
||||||
character?.level ?? 1,
|
character?.level ?? 1,
|
||||||
INSTALLED_MANASTORM_RUNTIME_CATALOG,
|
effectiveCatalog,
|
||||||
);
|
);
|
||||||
|
const modeEnabled = adminConfig.global.enabled
|
||||||
|
&& (modeId === 2 ? adminConfig.global.endgameEnabled : adminConfig.global.levelingEnabled);
|
||||||
|
const hasReadyBosses = Boolean(effectiveCatalog.stages?.some((stage) => (
|
||||||
|
!stage.modeIds?.length || stage.modeIds.includes(modeId)
|
||||||
|
)));
|
||||||
|
const canEnter = modeEnabled && hasReadyBosses;
|
||||||
const modeProgress = manastormModeProgress(progress, modeId);
|
const modeProgress = manastormModeProgress(progress, modeId);
|
||||||
const modeLabel = modeId === 2 ? "End-game" : "Leveling";
|
const modeLabel = modeId === 2 ? "End-game" : "Leveling";
|
||||||
const partyProgress = manastormPartyProgress(progress, partySize, modeId);
|
const partyProgress = manastormPartyProgress(progress, partySize, modeId);
|
||||||
@@ -72,7 +85,7 @@ export function ManastormSelectScreen() {
|
|||||||
[],
|
[],
|
||||||
null,
|
null,
|
||||||
() => 0.5,
|
() => 0.5,
|
||||||
INSTALLED_MANASTORM_RUNTIME_CATALOG,
|
effectiveCatalog,
|
||||||
modeId,
|
modeId,
|
||||||
Math.max(startingLevel, partyProgress.highestLevel + 1),
|
Math.max(startingLevel, partyProgress.highestLevel + 1),
|
||||||
);
|
);
|
||||||
@@ -80,13 +93,13 @@ export function ManastormSelectScreen() {
|
|||||||
preview.encounter,
|
preview.encounter,
|
||||||
startingLevel,
|
startingLevel,
|
||||||
partySize,
|
partySize,
|
||||||
INSTALLED_MANASTORM_RUNTIME_CATALOG,
|
effectiveCatalog,
|
||||||
modeId,
|
modeId,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [modeId, partyProgress.highestLevel, partySize, startingLevel]);
|
}, [effectiveCatalog, modeId, partyProgress.highestLevel, partySize, startingLevel]);
|
||||||
const spellLoadoutModel = useMemo(() => character
|
const spellLoadoutModel = useMemo(() => character
|
||||||
? manastormActiveSpellLoadoutModel(character.classId, character.level, character.talentRanks)
|
? manastormActiveSpellLoadoutModel(character.classId, character.level, character.talentRanks)
|
||||||
: null, [character?.classId, character?.level, character?.talentRanks]);
|
: null, [character?.classId, character?.level, character?.talentRanks]);
|
||||||
@@ -140,11 +153,13 @@ export function ManastormSelectScreen() {
|
|||||||
})) ?? []),
|
})) ?? []),
|
||||||
...availableRoles.map((role) => ({
|
...availableRoles.map((role) => ({
|
||||||
id: `manastorm-role-${role}`,
|
id: `manastorm-role-${role}`,
|
||||||
run: () => enterManastorm(role, partySize, startingLevel),
|
run: () => canEnter && enterManastorm(role, partySize, startingLevel),
|
||||||
|
enabled: canEnter,
|
||||||
})),
|
})),
|
||||||
{ id: "manastorm-back", run: returnToMainMenu },
|
{ id: "manastorm-back", run: returnToMainMenu },
|
||||||
], [
|
], [
|
||||||
availableRoles,
|
availableRoles,
|
||||||
|
canEnter,
|
||||||
checkpoint,
|
checkpoint,
|
||||||
enterManastorm,
|
enterManastorm,
|
||||||
partySize,
|
partySize,
|
||||||
@@ -187,6 +202,13 @@ export function ManastormSelectScreen() {
|
|||||||
claim the reward, and cross the portal. Special encounters every five levels
|
claim the reward, and cross the portal. Special encounters every five levels
|
||||||
unlock the next checkpoint.
|
unlock the next checkpoint.
|
||||||
</p>
|
</p>
|
||||||
|
{!canEnter ? (
|
||||||
|
<p className="front-notice" role="status">
|
||||||
|
{!modeEnabled
|
||||||
|
? `${modeLabel} Manastorms are currently disabled by a Game Master.`
|
||||||
|
: "No bosses are currently marked Ready for this Manastorm mode."}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>Catalog maps</dt><dd>{MANASTORM_CATALOG_SUMMARY.maps}</dd></div>
|
<div><dt>Catalog maps</dt><dd>{MANASTORM_CATALOG_SUMMARY.maps}</dd></div>
|
||||||
<div><dt>Streamed maps</dt><dd>{GENERATED_MANASTORM_STAGE_ASSET_PACKAGE_COUNT}</dd></div>
|
<div><dt>Streamed maps</dt><dd>{GENERATED_MANASTORM_STAGE_ASSET_PACKAGE_COUNT}</dd></div>
|
||||||
@@ -246,6 +268,7 @@ export function ManastormSelectScreen() {
|
|||||||
select={controller.select}
|
select={controller.select}
|
||||||
className={`manastorm-entry-option ${!resume || checkpoint === 1 ? "is-active" : ""}`}
|
className={`manastorm-entry-option ${!resume || checkpoint === 1 ? "is-active" : ""}`}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={!canEnter}
|
||||||
onClick={() => setResume(false)}
|
onClick={() => setResume(false)}
|
||||||
>
|
>
|
||||||
Start at 1
|
Start at 1
|
||||||
@@ -319,9 +342,10 @@ export function ManastormSelectScreen() {
|
|||||||
select={controller.select}
|
select={controller.select}
|
||||||
className={`dungeon-role-option dungeon-role-option--${role}`}
|
className={`dungeon-role-option dungeon-role-option--${role}`}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={!canEnter}
|
||||||
onFocus={() => preloadRole(role)}
|
onFocus={() => preloadRole(role)}
|
||||||
onPointerEnter={() => preloadRole(role)}
|
onPointerEnter={() => preloadRole(role)}
|
||||||
onClick={() => enterManastorm(role, partySize, startingLevel)}
|
onClick={() => canEnter && enterManastorm(role, partySize, startingLevel)}
|
||||||
>
|
>
|
||||||
<b aria-hidden="true">{ROLE_DETAILS[role].sigil}</b>
|
<b aria-hidden="true">{ROLE_DETAILS[role].sigil}</b>
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useShellStore } from "../app/shellStore";
|
|||||||
import { useGameStore } from "../game/store";
|
import { useGameStore } from "../game/store";
|
||||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||||
import { ControllerButton } from "./ControllerButton";
|
import { ControllerButton } from "./ControllerButton";
|
||||||
|
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
||||||
|
|
||||||
function PauseDialog() {
|
function PauseDialog() {
|
||||||
const closeOverlay = useGameStore((state) => state.closeOverlay);
|
const closeOverlay = useGameStore((state) => state.closeOverlay);
|
||||||
@@ -10,6 +11,7 @@ function PauseDialog() {
|
|||||||
const reset = useGameStore((state) => state.resetAtEntrance);
|
const reset = useGameStore((state) => state.resetAtEntrance);
|
||||||
const gameMode = useGameStore((state) => state.gameMode);
|
const gameMode = useGameStore((state) => state.gameMode);
|
||||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||||
|
const session = useShellStore((state) => state.session);
|
||||||
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
||||||
const abandonManastormRun = useShellStore((state) => state.abandonManastormRun);
|
const abandonManastormRun = useShellStore((state) => state.abandonManastormRun);
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ function PauseDialog() {
|
|||||||
abandonManastormRun();
|
abandonManastormRun();
|
||||||
};
|
};
|
||||||
const skillMenuLabel = activeCharacter?.categoryId === "rom" ? "Skills" : "Talents";
|
const skillMenuLabel = activeCharacter?.categoryId === "rom" ? "Skills" : "Talents";
|
||||||
|
const isGm = isGameMasterSession(session);
|
||||||
|
|
||||||
const actions = useMemo<MenuAction[]>(() => [
|
const actions = useMemo<MenuAction[]>(() => [
|
||||||
{ id: "pause-resume", run: resume },
|
{ id: "pause-resume", run: resume },
|
||||||
@@ -38,11 +41,12 @@ function PauseDialog() {
|
|||||||
{ id: "pause-bindings", run: () => openOverlay("bindings") },
|
{ id: "pause-bindings", run: () => openOverlay("bindings") },
|
||||||
{ id: "pause-talents", run: () => openOverlay("talents") },
|
{ id: "pause-talents", run: () => openOverlay("talents") },
|
||||||
{ id: "pause-options", run: () => openOverlay("options") },
|
{ id: "pause-options", run: () => openOverlay("options") },
|
||||||
|
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
|
||||||
...(gameMode === "manastorm"
|
...(gameMode === "manastorm"
|
||||||
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
|
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
|
||||||
: [{ id: "pause-entrance", run: returnToEntrance }]),
|
: [{ id: "pause-entrance", run: returnToEntrance }]),
|
||||||
{ id: "pause-main-menu", run: leaveDungeon },
|
{ id: "pause-main-menu", run: leaveDungeon },
|
||||||
], [abandonManastormRun, closeOverlay, gameMode, openOverlay, reset, returnToMainMenu]);
|
], [abandonManastormRun, closeOverlay, gameMode, isGm, openOverlay, reset, returnToMainMenu]);
|
||||||
const controller = useMenuController(actions, { initialId: "pause-resume", onBack: resume });
|
const controller = useMenuController(actions, { initialId: "pause-resume", onBack: resume });
|
||||||
|
|
||||||
const menuButton = (id: string, label: string, run: () => void, primary = false) => (
|
const menuButton = (id: string, label: string, run: () => void, primary = false) => (
|
||||||
@@ -71,6 +75,7 @@ function PauseDialog() {
|
|||||||
{menuButton("pause-bindings", "Ability controls", () => openOverlay("bindings"))}
|
{menuButton("pause-bindings", "Ability controls", () => openOverlay("bindings"))}
|
||||||
{menuButton("pause-talents", skillMenuLabel, () => openOverlay("talents"))}
|
{menuButton("pause-talents", skillMenuLabel, () => openOverlay("talents"))}
|
||||||
{menuButton("pause-options", "Options", () => openOverlay("options"))}
|
{menuButton("pause-options", "Options", () => openOverlay("options"))}
|
||||||
|
{isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
|
||||||
{gameMode === "manastorm"
|
{gameMode === "manastorm"
|
||||||
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm)
|
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm)
|
||||||
: menuButton("pause-entrance", "Return to entrance", returnToEntrance)}
|
: menuButton("pause-entrance", "Return to entrance", returnToEntrance)}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { DefeatedPrompt } from "./Hud";
|
||||||
|
|
||||||
|
describe("defeated prompt", () => {
|
||||||
|
it("offers an explicit dungeon respawn while explaining that waiting is allowed", () => {
|
||||||
|
const markup = renderToStaticMarkup(createElement(DefeatedPrompt, {
|
||||||
|
gameMode: "dungeon",
|
||||||
|
resurrectionCharges: 0,
|
||||||
|
onReturn: () => undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(markup).toContain("wait for a party member to resurrect you");
|
||||||
|
expect(markup).toContain("Respawn at entrance");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not offer a Manastorm resurrection when no shared charges remain", () => {
|
||||||
|
const markup = renderToStaticMarkup(createElement(DefeatedPrompt, {
|
||||||
|
gameMode: "manastorm",
|
||||||
|
resurrectionCharges: 0,
|
||||||
|
onReturn: () => undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(markup).toContain("No shared resurrection charges remain");
|
||||||
|
expect(markup).not.toContain("Resurrect now");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user