forked from phenom/healer-man
Release Healer Man 0.1.5
This commit is contained in:
1 parent
f4c9cf356c
commit
55cfd43d66
62 files changed
+3030
-153
No files matched your search
@@ -0,0 +1,240 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { enterGmManastormTest } from "../app/gmManastormRuntime";
|
||||
import { installDungeonShellRuntime } from "../app/dungeonShellRuntime";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import {
|
||||
bossAdminRecordForStatus,
|
||||
isGameMasterSession,
|
||||
resolvedBossAdminCatalog,
|
||||
type ManastormBossStatus,
|
||||
type ManastormGlobalOptions,
|
||||
} from "../game/manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
|
||||
const STATUS_LABELS: Readonly<Record<ManastormBossStatus, string>> = {
|
||||
ready: "Ready",
|
||||
"needs-tested": "Needs tested",
|
||||
"not-ready": "Not ready",
|
||||
};
|
||||
|
||||
const GLOBAL_OPTIONS: readonly { readonly key: keyof ManastormGlobalOptions; readonly label: string; readonly detail: string }[] = [
|
||||
{ key: "enabled", label: "Manastorms", detail: "Master switch for ordinary player entry." },
|
||||
{ key: "levelingEnabled", label: "Leveling mode", detail: "Allow characters below the end-game threshold to enter." },
|
||||
{ key: "endgameEnabled", label: "End-game mode", detail: "Allow level-cap characters to enter the end-game pool." },
|
||||
{ key: "affixesEnabled", label: "Affixes", detail: "Apply catalog and modifier-derived affixes." },
|
||||
{ key: "chaoticLinkEnabled", label: "Chaotic Link", detail: "Empower bosses until their linked kill requirement is met." },
|
||||
{ key: "milestoneEncountersEnabled", label: "Milestone bosses", detail: "Force the milestone pool every five levels." },
|
||||
];
|
||||
|
||||
installDungeonShellRuntime();
|
||||
|
||||
export function GmAdminScreen() {
|
||||
const session = useShellStore((state) => state.session);
|
||||
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
||||
const enterDungeon = useShellStore((state) => state.enterDungeon);
|
||||
const draft = useManastormAdminStore((state) => state.draft);
|
||||
const revision = useManastormAdminStore((state) => state.revision);
|
||||
const dirty = useManastormAdminStore((state) => state.dirty);
|
||||
const saving = useManastormAdminStore((state) => state.saving);
|
||||
const error = useManastormAdminStore((state) => state.error);
|
||||
const publish = useManastormAdminStore((state) => state.publish);
|
||||
const replaceDraft = useManastormAdminStore((state) => state.replaceDraft);
|
||||
const resetDraft = useManastormAdminStore((state) => state.resetDraft);
|
||||
const setEditingBossKey = useManastormAdminStore((state) => state.setEditingBossKey);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<ManastormBossStatus | "all">("all");
|
||||
const [dungeonFilter, setDungeonFilter] = useState("all");
|
||||
const [sourceFilter, setSourceFilter] = useState<"all" | "dungeon" | "installed-manastorm">("all");
|
||||
const [runnableFilter, setRunnableFilter] = useState<"all" | "runnable" | "incomplete">("all");
|
||||
const [poolFilter, setPoolFilter] = useState<"all" | "public" | "excluded">("all");
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const catalog = useMemo(() => resolvedBossAdminCatalog(draft), [draft]);
|
||||
const dungeonOptions = useMemo(() => [...new Map(catalog.map((entry) => [entry.dungeonTitle, entry.dungeonTitle])).keys()].sort(), [catalog]);
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return catalog.filter((entry) => (
|
||||
(statusFilter === "all" || entry.status === statusFilter)
|
||||
&& (dungeonFilter === "all" || entry.dungeonTitle === dungeonFilter)
|
||||
&& (sourceFilter === "all" || entry.source.includes(sourceFilter))
|
||||
&& (runnableFilter === "all" || (runnableFilter === "runnable" ? entry.runnable : !entry.runnable))
|
||||
&& (poolFilter === "all" || (poolFilter === "public" ? entry.inPublicPool : !entry.inPublicPool))
|
||||
&& (!query || `${entry.name} ${entry.dungeonTitle} ${entry.source.join(" ")}`.toLowerCase().includes(query))
|
||||
));
|
||||
}, [catalog, dungeonFilter, poolFilter, runnableFilter, search, sourceFilter, statusFilter]);
|
||||
const selected = catalog.find((entry) => entry.key === selectedKey) ?? filtered[0] ?? null;
|
||||
|
||||
if (!isGameMasterSession(session)) {
|
||||
return (
|
||||
<FrontSurface className="gm-admin-screen" ariaLabel="GM access denied">
|
||||
<section className="gm-admin-denied">
|
||||
<BrandMark />
|
||||
<h1>Game Master access required</h1>
|
||||
<p>This administration surface is available only to server-authorized accounts.</p>
|
||||
<button className="front-button" type="button" onClick={returnToMainMenu}>Return to main menu</button>
|
||||
</section>
|
||||
</FrontSurface>
|
||||
);
|
||||
}
|
||||
|
||||
const setGlobal = (key: keyof ManastormGlobalOptions, value: boolean) => replaceDraft({
|
||||
...draft,
|
||||
global: { ...draft.global, [key]: value },
|
||||
});
|
||||
|
||||
const setStatus = (key: string, status: ManastormBossStatus) => {
|
||||
const entry = catalog.find((candidate) => candidate.key === key);
|
||||
if (status === "ready" && !entry?.runnable) return;
|
||||
if (!entry) return;
|
||||
replaceDraft({
|
||||
...draft,
|
||||
bosses: {
|
||||
...draft.bosses,
|
||||
[key]: bossAdminRecordForStatus(entry, draft, status),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const editInDungeon = () => {
|
||||
if (!selected?.dungeonId) return;
|
||||
setEditingBossKey(selected.key);
|
||||
if (enterDungeon(selected.dungeonId)) {
|
||||
useGameStore.getState().openOverlay("gm");
|
||||
}
|
||||
};
|
||||
|
||||
const enterSelectedDungeon = () => {
|
||||
if (!selected?.dungeonId) return;
|
||||
setEditingBossKey(null);
|
||||
enterDungeon(selected.dungeonId);
|
||||
};
|
||||
|
||||
return (
|
||||
<FrontSurface className="gm-admin-screen" ariaLabel="Manastorm GM administration">
|
||||
<header className="gm-admin-header">
|
||||
<BrandMark compact />
|
||||
<div><span>Server authority / revision {revision}</span><strong>Manastorm GM Tools</strong></div>
|
||||
<div className="gm-admin-header__actions">
|
||||
<button className="front-button" type="button" disabled={!dirty || saving} onClick={resetDraft}>Discard</button>
|
||||
<button className="front-button front-button--primary" type="button" disabled={!dirty || saving} onClick={() => { void publish(session); }}>
|
||||
{saving ? "Publishing…" : "Save & publish"}
|
||||
</button>
|
||||
<button className="front-button" type="button" onClick={returnToMainMenu}>Close</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="gm-admin-layout">
|
||||
<aside className="gm-admin-options">
|
||||
<p className="front-kicker">Global switches</p>
|
||||
<h2>Storm rules</h2>
|
||||
{GLOBAL_OPTIONS.map((option) => (
|
||||
<label key={option.key} className="gm-switch-row">
|
||||
<input type="checkbox" checked={draft.global[option.key]} onChange={(event) => setGlobal(option.key, event.currentTarget.checked)} />
|
||||
<span><strong>{option.label}</strong><small>{option.detail}</small></span>
|
||||
</label>
|
||||
))}
|
||||
<div className="gm-admin-summary">
|
||||
<span><b>{catalog.filter((entry) => entry.status === "ready").length}</b> Ready</span>
|
||||
<span><b>{catalog.filter((entry) => entry.status === "needs-tested").length}</b> Needs tested</span>
|
||||
<span><b>{catalog.filter((entry) => entry.status === "not-ready").length}</b> Not ready</span>
|
||||
</div>
|
||||
{error ? <p className="front-notice" role="alert">{error}</p> : null}
|
||||
{dirty ? <p className="gm-unsaved" role="status">Unsaved changes</p> : null}
|
||||
</aside>
|
||||
|
||||
<section className="gm-boss-browser">
|
||||
<header>
|
||||
<div><p className="front-kicker">Unified catalog</p><h2>All bosses</h2></div>
|
||||
<input aria-label="Search bosses" value={search} onChange={(event) => setSearch(event.currentTarget.value)} placeholder="Search boss or dungeon" />
|
||||
<select aria-label="Filter boss status" value={statusFilter} onChange={(event) => setStatusFilter(event.currentTarget.value as typeof statusFilter)}>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="ready">Ready</option>
|
||||
<option value="needs-tested">Needs tested</option>
|
||||
<option value="not-ready">Not ready</option>
|
||||
</select>
|
||||
<div className="gm-boss-filters">
|
||||
<select aria-label="Filter dungeon" value={dungeonFilter} onChange={(event) => setDungeonFilter(event.currentTarget.value)}>
|
||||
<option value="all">All dungeons</option>
|
||||
{dungeonOptions.map((title) => <option key={title} value={title}>{title}</option>)}
|
||||
</select>
|
||||
<select aria-label="Filter source" value={sourceFilter} onChange={(event) => setSourceFilter(event.currentTarget.value as typeof sourceFilter)}>
|
||||
<option value="all">All sources</option>
|
||||
<option value="dungeon">Dungeon catalog</option>
|
||||
<option value="installed-manastorm">Imported Manastorm</option>
|
||||
</select>
|
||||
<select aria-label="Filter runnable state" value={runnableFilter} onChange={(event) => setRunnableFilter(event.currentTarget.value as typeof runnableFilter)}>
|
||||
<option value="all">Any stage state</option>
|
||||
<option value="runnable">Runnable</option>
|
||||
<option value="incomplete">Incomplete</option>
|
||||
</select>
|
||||
<select aria-label="Filter pool membership" value={poolFilter} onChange={(event) => setPoolFilter(event.currentTarget.value as typeof poolFilter)}>
|
||||
<option value="all">Any pool state</option>
|
||||
<option value="public">Public pool</option>
|
||||
<option value="excluded">Excluded</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
<div className="gm-boss-list" role="listbox" aria-label="Boss catalog">
|
||||
{filtered.map((entry) => (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={entry.key === selected?.key}
|
||||
className={`gm-boss-row is-${entry.status} ${entry.key === selected?.key ? "is-active" : ""}`}
|
||||
onClick={() => setSelectedKey(entry.key)}
|
||||
>
|
||||
<span><strong>{entry.name}</strong><small>{entry.dungeonTitle}</small></span>
|
||||
<em>{entry.runnable ? "Runnable" : "Placement required"}</em>
|
||||
<b>{STATUS_LABELS[entry.status]}</b>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="gm-boss-detail">
|
||||
{selected ? (
|
||||
<>
|
||||
<p className="front-kicker">Boss record</p>
|
||||
<h2>{selected.name}</h2>
|
||||
<p>{selected.dungeonTitle} · Map {selected.mapId}</p>
|
||||
<dl>
|
||||
<div><dt>Sources</dt><dd>{selected.source.join(" + ")}</dd></div>
|
||||
<div><dt>Stage</dt><dd>{selected.runnable ? "Runnable" : "Incomplete"}</dd></div>
|
||||
<div><dt>Pool</dt><dd>{selected.inPublicPool ? "Public" : "Excluded"}</dd></div>
|
||||
<div><dt>Linked mobs</dt><dd>{selected.stage?.trashRuntimeIds?.length ?? 0}</dd></div>
|
||||
<div><dt>Required kills</dt><dd>{draft.global.chaoticLinkEnabled
|
||||
? selected.stage?.requiredGuardianKills ?? Math.ceil((selected.stage?.trashRuntimeIds?.length ?? 0) / 2)
|
||||
: 0}</dd></div>
|
||||
</dl>
|
||||
{selected.validationIssues.map((issue) => <p key={issue} className="front-notice">{issue}</p>)}
|
||||
<div className="gm-status-buttons" aria-label="Boss readiness">
|
||||
{(["ready", "needs-tested", "not-ready"] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
className={selected.status === status ? "is-active" : ""}
|
||||
disabled={status === "ready" && !selected.runnable}
|
||||
onClick={() => setStatus(selected.key, status)}
|
||||
>{STATUS_LABELS[status]}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="gm-boss-actions">
|
||||
<button type="button" className="front-button" disabled={!selected.dungeonId} onClick={editInDungeon}>Edit in dungeon</button>
|
||||
<button type="button" className="front-button" disabled={!selected.dungeonId} onClick={enterSelectedDungeon}>Enter dungeon</button>
|
||||
<button
|
||||
type="button"
|
||||
className="front-button front-button--primary"
|
||||
disabled={!selected.runnable || selected.status === "not-ready" || dirty}
|
||||
title={dirty ? "Save changes before testing." : undefined}
|
||||
onClick={() => enterGmManastormTest(selected.key)}
|
||||
>Test encounter</button>
|
||||
</div>
|
||||
</>
|
||||
) : <p className="front-notice">No bosses match this filter.</p>}
|
||||
</aside>
|
||||
</main>
|
||||
</FrontSurface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import type { Vector3Tuple, WorldSpawn } from "../game/dungeonTypes";
|
||||
import {
|
||||
authoredStageConfigForBoss,
|
||||
isGameMasterSession,
|
||||
resolvedBossAdminCatalog,
|
||||
type ManastormAdminStageConfig,
|
||||
type ResolvedBossAdminEntry,
|
||||
} from "../game/manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||
import { getMobRuntimeTransform } from "../game/mobRuntimeRegistry";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
const AXES = ["X", "Y", "Z"] as const;
|
||||
|
||||
function formatCoordinate(value: number): string {
|
||||
return Number.isFinite(value) ? value.toFixed(2) : "—";
|
||||
}
|
||||
|
||||
function VectorEditor(props: {
|
||||
readonly label: string;
|
||||
readonly value: Vector3Tuple;
|
||||
readonly onChange: (value: Vector3Tuple) => void;
|
||||
readonly extra?: ReactNode;
|
||||
}) {
|
||||
const setAxis = (axis: number, raw: number) => {
|
||||
if (!Number.isFinite(raw)) return;
|
||||
const next = [...props.value] as [number, number, number];
|
||||
next[axis] = raw;
|
||||
props.onChange(next);
|
||||
};
|
||||
const nudge = (axis: number, amount: number) => setAxis(axis, props.value[axis] + amount);
|
||||
return (
|
||||
<fieldset className="gm-vector-editor">
|
||||
<legend>{props.label}</legend>
|
||||
<div className="gm-vector-editor__coordinates">
|
||||
{AXES.map((axis, index) => (
|
||||
<label key={axis}>
|
||||
<span>{axis}</span>
|
||||
<input
|
||||
aria-label={`${props.label} ${axis}`}
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={props.value[index]}
|
||||
onChange={(event) => setAxis(index, event.currentTarget.valueAsNumber)}
|
||||
/>
|
||||
<span className="gm-nudges">
|
||||
<button type="button" aria-label={`Nudge ${props.label} ${axis} down`} onClick={() => nudge(index, -0.25)}>−</button>
|
||||
<button type="button" aria-label={`Nudge ${props.label} ${axis} up`} onClick={() => nudge(index, 0.25)}>+</button>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{props.extra}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function targetSummary(target: ReturnType<typeof useCombatStore.getState>["mobs"][string] | null, id: string | null): string {
|
||||
if (!target || !id) return "No hostile target selected";
|
||||
return `${target.name} · ${target.boss ? "Boss" : "Mob"} · ${id}`;
|
||||
}
|
||||
|
||||
export function GmWorldEditor() {
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const playerPosition = useGameStore((state) => state.playerPosition);
|
||||
const playerGrounded = useGameStore((state) => state.playerGrounded);
|
||||
const cameraYaw = useGameStore((state) => state.cameraYaw);
|
||||
const closeOverlay = useGameStore((state) => state.closeOverlay);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const selectedTargetId = useCombatStore((state) => state.selectedTargetId);
|
||||
const selectedTarget = useCombatStore((state) => state.selectedTargetId ? state.mobs[state.selectedTargetId] ?? null : null);
|
||||
const draft = useManastormAdminStore((state) => state.draft);
|
||||
const dirty = useManastormAdminStore((state) => state.dirty);
|
||||
const saving = useManastormAdminStore((state) => state.saving);
|
||||
const error = useManastormAdminStore((state) => state.error);
|
||||
const editingBossKey = useManastormAdminStore((state) => state.editingBossKey);
|
||||
const replaceDraft = useManastormAdminStore((state) => state.replaceDraft);
|
||||
const publish = useManastormAdminStore((state) => state.publish);
|
||||
const setEditingBossKey = useManastormAdminStore((state) => state.setEditingBossKey);
|
||||
const catalog = useMemo(() => resolvedBossAdminCatalog(draft), [draft]);
|
||||
const dungeon = requireDungeonDefinition(activeDungeonId);
|
||||
const dungeonBosses = catalog.filter((entry) => entry.dungeonId === activeDungeonId);
|
||||
const entry = catalog.find((candidate) => candidate.key === editingBossKey && candidate.dungeonId === activeDungeonId) ?? null;
|
||||
const stage = entry ? authoredStageConfigForBoss(entry, draft) : null;
|
||||
const targetTransform = selectedTargetId ? getMobRuntimeTransform(selectedTargetId) : null;
|
||||
|
||||
if (overlay !== "gm" || !isGameMasterSession(session)) return null;
|
||||
|
||||
const updateStage = (activeEntry: ResolvedBossAdminEntry, nextStage: ManastormAdminStageConfig) => {
|
||||
const previous = draft.bosses[activeEntry.key];
|
||||
replaceDraft({
|
||||
...draft,
|
||||
bosses: {
|
||||
...draft.bosses,
|
||||
[activeEntry.key]: {
|
||||
status: previous?.status === "not-ready" ? "not-ready" : "needs-tested",
|
||||
stage: nextStage,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const captureDungeonAnchor = () => {
|
||||
if (!playerGrounded) return;
|
||||
replaceDraft({
|
||||
...draft,
|
||||
dungeonSpawns: {
|
||||
...draft.dungeonSpawns,
|
||||
[activeDungeonId]: { footPosition: [...playerPosition] as Vector3Tuple, yaw: cameraYaw },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const captureBoss = () => {
|
||||
if (!selectedTarget?.boss || !selectedTargetId || !targetTransform) return;
|
||||
const matching = dungeonBosses.find((candidate) => candidate.bossRuntimeId === selectedTargetId)
|
||||
?? dungeonBosses.find((candidate) => candidate.name.toLowerCase() === selectedTarget.name.toLowerCase());
|
||||
if (!matching) return;
|
||||
const targetStage = authoredStageConfigForBoss(matching, draft);
|
||||
if (!targetStage) return;
|
||||
setEditingBossKey(matching.key);
|
||||
updateStage(matching, {
|
||||
...targetStage,
|
||||
bossRuntimeId: selectedTargetId,
|
||||
bossPosition: [...targetTransform.position] as Vector3Tuple,
|
||||
bossYaw: targetTransform.yaw,
|
||||
});
|
||||
};
|
||||
|
||||
const setGroupSpawn = (spawn: WorldSpawn) => {
|
||||
if (!entry || !stage) return;
|
||||
updateStage(entry, { ...stage, groupSpawn: spawn });
|
||||
};
|
||||
|
||||
const setLinked = (ids: readonly string[]) => {
|
||||
if (!entry || !stage) return;
|
||||
const unique = [...new Set(ids)].filter((id) => id !== stage.bossRuntimeId);
|
||||
updateStage(entry, {
|
||||
...stage,
|
||||
linkedMobRuntimeIds: unique,
|
||||
requiredKillCount: Math.min(stage.requiredKillCount, unique.length),
|
||||
});
|
||||
};
|
||||
|
||||
const canAddTarget = Boolean(
|
||||
entry
|
||||
&& stage
|
||||
&& selectedTargetId
|
||||
&& selectedTarget
|
||||
&& !selectedTarget.boss
|
||||
&& !stage.linkedMobRuntimeIds.includes(selectedTargetId),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="gm-world-editor" role="dialog" aria-modal="true" aria-labelledby="gm-world-title">
|
||||
<header className="gm-world-editor__header">
|
||||
<div>
|
||||
<p className="eyebrow">Live world authoring</p>
|
||||
<h2 id="gm-world-title">{dungeon.title}</h2>
|
||||
</div>
|
||||
<span className={dirty ? "is-dirty" : ""}>{dirty ? "Unsaved changes" : "Published revision loaded"}</span>
|
||||
<button className="icon-button" type="button" aria-label="Close GM editor" onClick={closeOverlay}>×</button>
|
||||
</header>
|
||||
|
||||
<div className="gm-world-editor__telemetry">
|
||||
<span>Player <b>{playerPosition.map(formatCoordinate).join(", ")}</b></span>
|
||||
<span>Yaw <b>{formatCoordinate(cameraYaw)}</b></span>
|
||||
<span>Grounded <b>{playerGrounded ? "Yes" : "No"}</b></span>
|
||||
<span>Target <b>{targetSummary(selectedTarget, selectedTargetId)}</b></span>
|
||||
</div>
|
||||
|
||||
<section className="gm-world-editor__section">
|
||||
<label className="gm-world-editor__boss-select">
|
||||
<span>Boss record</span>
|
||||
<select value={entry?.key ?? ""} onChange={(event) => setEditingBossKey(event.currentTarget.value || null)}>
|
||||
<option value="">Select a boss in this dungeon</option>
|
||||
{dungeonBosses.map((boss) => <option key={boss.key} value={boss.key}>{boss.name} · {boss.status}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="gm-inline-actions">
|
||||
<button type="button" disabled={!selectedTarget?.boss || !targetTransform} onClick={captureBoss}>Capture targeted boss</button>
|
||||
<button type="button" disabled={!playerGrounded} onClick={captureDungeonAnchor}>Set dungeon group anchor here</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{entry && stage ? (
|
||||
<div className="gm-world-editor__scroll">
|
||||
<section className="gm-world-editor__section">
|
||||
<h3>{entry.name}</h3>
|
||||
<p>{entry.status === "not-ready" ? "Not ready" : "Edits return this boss to Needs tested"} · Map {stage.mapId}</p>
|
||||
<VectorEditor
|
||||
label="Boss spawn"
|
||||
value={stage.bossPosition}
|
||||
onChange={(bossPosition) => updateStage(entry, { ...stage, bossPosition })}
|
||||
extra={(
|
||||
<label className="gm-yaw-editor"><span>Boss yaw</span><input type="number" step="0.05" value={stage.bossYaw} onChange={(event) => {
|
||||
if (Number.isFinite(event.currentTarget.valueAsNumber)) updateStage(entry, { ...stage, bossYaw: event.currentTarget.valueAsNumber });
|
||||
}} /><span className="gm-nudges"><button type="button" aria-label="Nudge boss yaw down" onClick={() => updateStage(entry, { ...stage, bossYaw: stage.bossYaw - 0.05 })}>−</button><button type="button" aria-label="Nudge boss yaw up" onClick={() => updateStage(entry, { ...stage, bossYaw: stage.bossYaw + 0.05 })}>+</button></span></label>
|
||||
)}
|
||||
/>
|
||||
<VectorEditor
|
||||
label="Party group anchor"
|
||||
value={stage.groupSpawn.footPosition}
|
||||
onChange={(footPosition) => setGroupSpawn({ ...stage.groupSpawn, footPosition })}
|
||||
extra={(
|
||||
<div className="gm-yaw-editor">
|
||||
<label><span>Group yaw</span><input type="number" step="0.05" value={stage.groupSpawn.yaw} onChange={(event) => {
|
||||
if (Number.isFinite(event.currentTarget.valueAsNumber)) setGroupSpawn({ ...stage.groupSpawn, yaw: event.currentTarget.valueAsNumber });
|
||||
}} /><span className="gm-nudges"><button type="button" aria-label="Nudge group yaw down" onClick={() => setGroupSpawn({ ...stage.groupSpawn, yaw: stage.groupSpawn.yaw - 0.05 })}>−</button><button type="button" aria-label="Nudge group yaw up" onClick={() => setGroupSpawn({ ...stage.groupSpawn, yaw: stage.groupSpawn.yaw + 0.05 })}>+</button></span></label>
|
||||
<button type="button" disabled={!playerGrounded} onClick={() => setGroupSpawn({ footPosition: [...playerPosition] as Vector3Tuple, yaw: cameraYaw })}>Capture player</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<VectorEditor
|
||||
label="Exit portal"
|
||||
value={stage.portalPosition}
|
||||
onChange={(portalPosition) => updateStage(entry, { ...stage, portalPosition })}
|
||||
extra={<button type="button" disabled={!playerGrounded} onClick={() => updateStage(entry, { ...stage, portalPosition: [...playerPosition] as Vector3Tuple })}>Capture player position</button>}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="gm-world-editor__section">
|
||||
<div className="gm-world-editor__linked-heading">
|
||||
<div><h3>Chaotic Link prerequisites</h3><p>Target an exact non-boss mob, then add its runtime ID.</p></div>
|
||||
<button type="button" disabled={!canAddTarget} onClick={() => selectedTargetId && setLinked([...stage.linkedMobRuntimeIds, selectedTargetId])}>Add targeted mob</button>
|
||||
</div>
|
||||
<label className="gm-required-kills">
|
||||
<span>Required kills</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={stage.linkedMobRuntimeIds.length}
|
||||
value={stage.requiredKillCount}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.valueAsNumber;
|
||||
if (Number.isInteger(value) && value >= 0 && value <= stage.linkedMobRuntimeIds.length) {
|
||||
updateStage(entry, { ...stage, requiredKillCount: value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<small>of {stage.linkedMobRuntimeIds.length} linked mobs</small>
|
||||
</label>
|
||||
<div className="gm-linked-list">
|
||||
{stage.linkedMobRuntimeIds.map((id) => (
|
||||
<div key={id}><code>{id}</code><button type="button" onClick={() => setLinked(stage.linkedMobRuntimeIds.filter((candidate) => candidate !== id))}>Remove</button></div>
|
||||
))}
|
||||
{!stage.linkedMobRuntimeIds.length ? <p>No prerequisite mobs selected. The boss begins unempowered.</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<section className="gm-world-editor__empty">
|
||||
<p>Select a boss record, or target a live boss and capture it.</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<footer className="gm-world-editor__footer">
|
||||
{error ? <p role="alert">{error}</p> : <p>Markers: <i className="is-party" /> party · <i className="is-boss" /> boss · <i className="is-portal" /> portal · <i className="is-linked" /> linked</p>}
|
||||
<button type="button" disabled={!dirty || saving} onClick={() => { void publish(session); }}>{saving ? "Publishing…" : "Save & publish"}</button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import { useShellStore } from "../app/shellStore";
|
||||
import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog";
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DefeatedPrompt } from "./Hud";
|
||||
|
||||
describe("defeated prompt", () => {
|
||||
it("offers an explicit dungeon respawn while explaining that waiting is allowed", () => {
|
||||
const markup = renderToStaticMarkup(createElement(DefeatedPrompt, {
|
||||
gameMode: "dungeon",
|
||||
resurrectionCharges: 0,
|
||||
onReturn: () => undefined,
|
||||
}));
|
||||
|
||||
expect(markup).toContain("wait for a party member to resurrect you");
|
||||
expect(markup).toContain("Respawn at entrance");
|
||||
});
|
||||
|
||||
it("does not offer a Manastorm resurrection when no shared charges remain", () => {
|
||||
const markup = renderToStaticMarkup(createElement(DefeatedPrompt, {
|
||||
gameMode: "manastorm",
|
||||
resurrectionCharges: 0,
|
||||
onReturn: () => undefined,
|
||||
}));
|
||||
|
||||
expect(markup).toContain("No shared resurrection charges remain");
|
||||
expect(markup).not.toContain("Resurrect now");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user