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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user