Release Healer Man 0.1.5

This commit is contained in:
phenom
2026-08-18 16:22:19 -04:00
parent f4c9cf356c
commit 55cfd43d66
62 changed files with 3030 additions and 153 deletions
+35
View File
@@ -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",
+3 -2
View File
@@ -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
View File
@@ -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",
+2 -2
View File
@@ -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,7 +1030,9 @@ async function compile() {
modelCoverageWarning, modelCoverageWarning,
...(syntheticTrash ...(syntheticTrash
? ["Trash identities and placements are procedural; boss identities come from the installed Ascension encounter catalog."] ? ["Trash identities and placements are procedural; boss identities come from the installed Ascension encounter catalog."]
: [`Server positions were aligned to the packaged ${environmentMode} environment at its reviewed player anchor.`]), : coordinateAlignment === "source"
? [`Server positions use the packaged ${environmentMode} environment's native world coordinates.`]
: [`Server positions were aligned to the packaged ${environmentMode} environment at its reviewed player anchor.`]),
]; ];
definitions.push({ definitions.push({
@@ -1034,12 +1056,12 @@ async function compile() {
bounds, bounds,
entrance: { entrance: {
name: `${recipe.title} Entrance`, name: `${recipe.title} Entrance`,
footPosition: vector(playerAnchor.position), footPosition: vector(entrancePosition),
forward: [Math.sin(entranceYaw), 0, Math.cos(entranceYaw)], forward: [Math.sin(entranceYaw), 0, Math.cos(entranceYaw)],
yaw: entranceYaw, yaw: entranceYaw,
}, },
areas: [ areas: [
{ id: "entrance", name: `${recipe.title} Entrance`, center: vector(playerAnchor.position), radius: 35 }, { id: "entrance", name: `${recipe.title} Entrance`, center: vector(entrancePosition), radius: 35 },
...bosses.map((boss) => ({ ...bosses.map((boss) => ({
id: `area-${slugPart(boss.name)}`, id: `area-${slugPart(boss.name)}`,
name: `${boss.name}'s Encounter`, name: `${boss.name}'s Encounter`,
@@ -1068,6 +1090,10 @@ async function compile() {
? sourceBasis === "z-y-negative-x" ? sourceBasis === "z-y-negative-x"
? "three(x,y,z)=(wow.y,wow.z,wow.x)" ? "three(x,y,z)=(wow.y,wow.z,wow.x)"
: "three(x,y,z)=(-wow.y,wow.z,wow.x)" : "three(x,y,z)=(-wow.y,wow.z,wow.x)"
: coordinateAlignment === "source"
? sourceBasis === "flip-x"
? "three(x,y,z)=(-draft.x,draft.y,draft.z)"
: "three(x,y,z)=draft(x,y,z)"
: sourceBasis === "flip-x" : sourceBasis === "flip-x"
? "three(x,y,z)=(-draft.x,draft.y,draft.z)+package-anchor-translation" ? "three(x,y,z)=(-draft.x,draft.y,draft.z)+package-anchor-translation"
: "three(x,y,z)=draft(x,y,z)+package-anchor-translation", : "three(x,y,z)=draft(x,y,z)+package-anchor-translation",
+98 -2
View File
@@ -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(),
}; };
+70
View File
@@ -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);
}));
+161
View File
@@ -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));
}
+21
View File
@@ -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;
+31
View File
@@ -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
View File
@@ -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 />}>
+2
View File
@@ -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 />}
+12
View File
@@ -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({
+5 -1
View File
@@ -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;
} }
+102
View File
@@ -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;
}
+41 -16
View File
@@ -14,6 +14,8 @@ import { useGameStore } from "../game/store";
import { useManastormStore } from "../game/manastormStore"; import { useManastormStore } from "../game/manastormStore";
import { manastormModeForCharacterLevel, setManastormCatalog } from "../game/manastorm"; import { manastormModeForCharacterLevel, setManastormCatalog } from "../game/manastorm";
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "../game/manastormInstalledCatalog"; import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "../game/manastormInstalledCatalog";
import { effectiveManastormCatalog } from "../game/manastormAdminConfig";
import { useManastormAdminStore } from "../game/manastormAdminStore";
import { registerGeneratedManastormStageAssets } from "../game/manastormAssetRegistry"; import { registerGeneratedManastormStageAssets } from "../game/manastormAssetRegistry";
import { installedManastormStageBinding } from "../game/manastormInstalledStages"; import { installedManastormStageBinding } from "../game/manastormInstalledStages";
import { registerManastormStageBindingResolver } from "../game/manastormStagePopulation"; import { registerManastormStageBindingResolver } from "../game/manastormStagePopulation";
@@ -71,11 +73,28 @@ function enterManastorm(
const partySize = isManastormPartySize(requestedPartySize) ? requestedPartySize : 5; const partySize = isManastormPartySize(requestedPartySize) ? requestedPartySize : 5;
registerGeneratedManastormStageAssets(); registerGeneratedManastormStageAssets();
setManastormCatalog(INSTALLED_MANASTORM_RUNTIME_CATALOG); const adminConfig = useManastormAdminStore.getState().config;
if (!adminConfig.global.enabled) {
useShellStore.setState({ notice: "Manastorms are currently disabled by a Game Master." });
return false;
}
const effectiveCatalog = effectiveManastormCatalog(adminConfig, INSTALLED_MANASTORM_RUNTIME_CATALOG);
const modeId = manastormModeForCharacterLevel( const modeId = manastormModeForCharacterLevel(
activeCharacter.level, activeCharacter.level,
INSTALLED_MANASTORM_RUNTIME_CATALOG, effectiveCatalog,
); );
if (
(modeId === 0 && !adminConfig.global.levelingEnabled)
|| (modeId === 2 && !adminConfig.global.endgameEnabled)
) {
useShellStore.setState({ notice: `${modeId === 2 ? "End-game" : "Leveling"} Manastorms are currently disabled.` });
return false;
}
if (!effectiveCatalog.stages?.length) {
useShellStore.setState({ notice: "No bosses are currently marked Ready for Manastorms." });
return false;
}
setManastormCatalog(effectiveCatalog);
const storedProgress = activeCharacter.manastormProgress ?? createEmptyManastormProgress(); const storedProgress = activeCharacter.manastormProgress ?? createEmptyManastormProgress();
const progress = modeId === 2 const progress = modeId === 2
? applyManastormEndgameHandoff(storedProgress) ? applyManastormEndgameHandoff(storedProgress)
@@ -106,20 +125,26 @@ function enterManastorm(
const startingLevel = requestedStartingLevel === 1 const startingLevel = requestedStartingLevel === 1
? 1 ? 1
: availableCheckpoint; : availableCheckpoint;
const encounter = useManastormStore.getState().startRun({ let encounter;
partySize, try {
startingLevel, encounter = useManastormStore.getState().startRun({
unlockedThroughLevel: Math.max(startingLevel, savedParty.highestLevel + 1), partySize,
modeId, startingLevel,
activeSpellLoadout: { unlockedThroughLevel: Math.max(startingLevel, savedParty.highestLevel + 1),
available: spellLoadoutModel.options, modeId,
selectedIds: progress.loadout, activeSpellLoadout: {
maxSelections: spellLoadoutModel.maxSelections, available: spellLoadoutModel.options,
provenance: spellLoadoutModel.provenance, selectedIds: progress.loadout,
}, maxSelections: spellLoadoutModel.maxSelections,
cacheMeter: modeProgress.cacheMeter, provenance: spellLoadoutModel.provenance,
rewardCaches: modeProgress.rewardCaches, },
}); cacheMeter: modeProgress.cacheMeter,
rewardCaches: modeProgress.rewardCaches,
});
} catch {
useShellStore.setState({ notice: "No Ready boss is eligible for this Manastorm level and party size." });
return false;
}
if (!activateManastormStage(encounter, undefined, false).activated) { if (!activateManastormStage(encounter, undefined, false).activated) {
useManastormStore.getState().resetRun(); useManastormStore.getState().resetRun();
useShellStore.setState({ notice: "The first Manastorm could not be opened." }); useShellStore.setState({ notice: "The first Manastorm could not be opened." });
+22 -6
View File
@@ -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;
} }
+30
View File
@@ -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
View File
@@ -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
View File
@@ -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;
} }
-12
View File
@@ -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);
+3 -2
View File
@@ -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;
} }
+14
View File
@@ -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 });
+20
View File
@@ -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;
+17
View File
@@ -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(
+5 -1
View File
@@ -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,
}); });
} }
+12
View File
@@ -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
+27
View File
@@ -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
View File
@@ -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,
+93
View File
@@ -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);
});
});
+425
View File
@@ -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,
})));
}
+101
View File
@@ -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 }),
}));
+16
View File
@@ -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,
});
});
}); });
+9 -1
View File
@@ -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,
+8 -4
View File
@@ -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", () => {
+12 -6
View File
@@ -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";
} }
+2
View File
@@ -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 {
+20
View File
@@ -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"]);
});
}); });
+25 -1
View File
@@ -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,
}, },
], ],
+4 -2
View File
@@ -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();
}); });
+11 -3
View File
@@ -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;
+39
View File
@@ -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
View File
@@ -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" });
+245
View File
@@ -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);
});
});
+44
View File
@@ -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
View File
@@ -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,
+9 -3
View File
@@ -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} />
</> </>
)} )}
+88
View File
@@ -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>
);
}
+13 -4
View File
@@ -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;
+6 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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; }
}
+240
View File
@@ -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>
);
}
+268
View File
@@ -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>
);
}
+57
View File
@@ -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>
); );
} }
+20 -1
View File
@@ -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"
+30 -6
View File
@@ -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>
+6 -1
View File
@@ -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)}
+28
View File
@@ -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");
});
});