Release Healer Man 0.1.5

This commit is contained in:
phenom committed 2026-08-18 16:22:19 -04:00
1 parent f4c9cf356c
commit 55cfd43d66
62 files changed
+3030 -153

No files matched your search

+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 { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings";
import { useCombatStore } from "../game/combatStore";
import { respawnDefeatedPlayer } from "../game/deathRespawn";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { resurrectManastormSession } from "../game/manastormSession";
import { resolveStagePresentation } from "../game/manastormStagePresentation";
import { useManastormStore } from "../game/manastormStore";
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() {
const character = useShellStore((state) => state.activeCharacter);
const gameMode = useGameStore((state) => state.gameMode);
@@ -71,6 +113,7 @@ export function Hud() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const dungeon = requireDungeonDefinition(activeDungeonId);
const encounter = useManastormStore((state) => state.currentEncounter);
const resurrectionCharges = useManastormStore((state) => state.resurrectionCharges);
const toggleMap = useGameStore((state) => state.toggleMap);
const classId = useCombatStore((state) => state.classId);
const level = useCombatStore((state) => state.level);
@@ -210,6 +253,13 @@ export function Hud() {
mapId: dungeon.mapId,
areaName,
});
const returnFromDefeat = () => {
if (gameMode === "manastorm") {
resurrectManastormSession({ kind: "player" });
return;
}
respawnDefeatedPlayer();
};
return (
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
@@ -329,6 +379,13 @@ export function Hud() {
</div>
)}
<LoadingOverlay />
{health <= 0 ? (
<DefeatedPrompt
gameMode={gameMode}
resurrectionCharges={resurrectionCharges}
onReturn={returnFromDefeat}
/>
) : null}
</div>
);
}
+20 -1
View File
@@ -8,6 +8,7 @@ import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { ControllerButton } from "./ControllerButton";
import { ContentManagerScreen } from "./ContentManagerScreen";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { isGameMasterSession } from "../game/manastormAdminConfig";
const GAME_MODES = [
{
@@ -70,8 +71,11 @@ export function MainMenuScreen() {
const openClassHall = useShellStore((state) => state.openClassHall);
const returnToCharacters = useShellStore((state) => state.returnToCharacters);
const notice = useShellStore((state) => state.notice);
const session = useShellStore((state) => state.session);
const openGmAdmin = useShellStore((state) => state.openGmAdmin);
const character = activeCharacter ?? rosterCharacter;
const isRom = character?.categoryId === "rom";
const isGm = isGameMasterSession(session);
const actions = useMemo<MenuAction[]>(() => [
{
@@ -95,7 +99,8 @@ export function MainMenuScreen() {
neighbors: { up: "mode-dungeons", left: "mode-dungeons" },
},
...(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, {
initialId: "mode-dungeons",
onBack: returnToCharacters,
@@ -152,6 +157,20 @@ export function MainMenuScreen() {
<p>Select a destination. More adventures will unlock here as they are built.</p>
</header>
<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 && (
<ControllerButton
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 { GENERATED_MANASTORM_STAGE_ASSET_PACKAGE_COUNT } from "../game/manastormAssetRegistry";
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "../game/manastormInstalledCatalog";
import { effectiveManastormCatalog } from "../game/manastormAdminConfig";
import { useManastormAdminStore } from "../game/manastormAdminStore";
import { manastormActiveSpellLoadoutModel } from "../game/manastormSpellLoadout";
import {
drawManastormStage,
@@ -47,15 +49,26 @@ export function ManastormSelectScreen() {
const saveSelectedManastormLoadout = useShellStore((state) => state.saveSelectedManastormLoadout);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
const notice = useShellStore((state) => state.notice);
const adminConfig = useManastormAdminStore((state) => state.config);
const character = activeCharacter ?? rosterCharacter;
const availableRoles = character ? partyRolesForClass(character.classId) : [];
const [partySize, setPartySize] = useState<ManastormPartySize>(5);
const [resume, setResume] = useState(true);
const progress = character?.manastormProgress ?? createEmptyManastormProgress();
const effectiveCatalog = useMemo(
() => effectiveManastormCatalog(adminConfig, INSTALLED_MANASTORM_RUNTIME_CATALOG),
[adminConfig],
);
const modeId = manastormModeForCharacterLevel(
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 modeLabel = modeId === 2 ? "End-game" : "Leveling";
const partyProgress = manastormPartyProgress(progress, partySize, modeId);
@@ -72,7 +85,7 @@ export function ManastormSelectScreen() {
[],
null,
() => 0.5,
INSTALLED_MANASTORM_RUNTIME_CATALOG,
effectiveCatalog,
modeId,
Math.max(startingLevel, partyProgress.highestLevel + 1),
);
@@ -80,13 +93,13 @@ export function ManastormSelectScreen() {
preview.encounter,
startingLevel,
partySize,
INSTALLED_MANASTORM_RUNTIME_CATALOG,
effectiveCatalog,
modeId,
);
} catch {
return null;
}
}, [modeId, partyProgress.highestLevel, partySize, startingLevel]);
}, [effectiveCatalog, modeId, partyProgress.highestLevel, partySize, startingLevel]);
const spellLoadoutModel = useMemo(() => character
? manastormActiveSpellLoadoutModel(character.classId, character.level, character.talentRanks)
: null, [character?.classId, character?.level, character?.talentRanks]);
@@ -140,11 +153,13 @@ export function ManastormSelectScreen() {
})) ?? []),
...availableRoles.map((role) => ({
id: `manastorm-role-${role}`,
run: () => enterManastorm(role, partySize, startingLevel),
run: () => canEnter && enterManastorm(role, partySize, startingLevel),
enabled: canEnter,
})),
{ id: "manastorm-back", run: returnToMainMenu },
], [
availableRoles,
canEnter,
checkpoint,
enterManastorm,
partySize,
@@ -187,6 +202,13 @@ export function ManastormSelectScreen() {
claim the reward, and cross the portal. Special encounters every five levels
unlock the next checkpoint.
</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>
<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>
@@ -246,6 +268,7 @@ export function ManastormSelectScreen() {
select={controller.select}
className={`manastorm-entry-option ${!resume || checkpoint === 1 ? "is-active" : ""}`}
type="button"
disabled={!canEnter}
onClick={() => setResume(false)}
>
Start at 1
@@ -319,9 +342,10 @@ export function ManastormSelectScreen() {
select={controller.select}
className={`dungeon-role-option dungeon-role-option--${role}`}
type="button"
disabled={!canEnter}
onFocus={() => 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>
<span>
+6 -1
View File
@@ -3,6 +3,7 @@ import { useShellStore } from "../app/shellStore";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import { isGameMasterSession } from "../game/manastormAdminConfig";
function PauseDialog() {
const closeOverlay = useGameStore((state) => state.closeOverlay);
@@ -10,6 +11,7 @@ function PauseDialog() {
const reset = useGameStore((state) => state.resetAtEntrance);
const gameMode = useGameStore((state) => state.gameMode);
const activeCharacter = useShellStore((state) => state.activeCharacter);
const session = useShellStore((state) => state.session);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
const abandonManastormRun = useShellStore((state) => state.abandonManastormRun);
@@ -28,6 +30,7 @@ function PauseDialog() {
abandonManastormRun();
};
const skillMenuLabel = activeCharacter?.categoryId === "rom" ? "Skills" : "Talents";
const isGm = isGameMasterSession(session);
const actions = useMemo<MenuAction[]>(() => [
{ id: "pause-resume", run: resume },
@@ -38,11 +41,12 @@ function PauseDialog() {
{ id: "pause-bindings", run: () => openOverlay("bindings") },
{ id: "pause-talents", run: () => openOverlay("talents") },
{ id: "pause-options", run: () => openOverlay("options") },
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
...(gameMode === "manastorm"
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
: [{ id: "pause-entrance", run: returnToEntrance }]),
{ 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 menuButton = (id: string, label: string, run: () => void, primary = false) => (
@@ -71,6 +75,7 @@ function PauseDialog() {
{menuButton("pause-bindings", "Ability controls", () => openOverlay("bindings"))}
{menuButton("pause-talents", skillMenuLabel, () => openOverlay("talents"))}
{menuButton("pause-options", "Options", () => openOverlay("options"))}
{isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
{gameMode === "manastorm"
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm)
: 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");
});
});