Files
healer-man/src/ui/GmAdminScreen.tsx
T
2026-09-01 14:35:56 -04:00

298 lines
16 KiB
TypeScript

import { useMemo, useState } from "react";
import { enterGmManastormTest, enterRandomGmReview } from "../app/gmManastormRuntime";
import { installDungeonShellRuntime } from "../app/dungeonShellRuntime";
import { useShellStore } from "../app/shellStore";
import {
bossAdminRecordForStatus,
GM_REVIEW_NOTE_MAX_LENGTH,
isGameMasterSession,
resolvedBossAdminCatalog,
type ManastormBossStatus,
type ManastormGlobalOptions,
} from "../game/manastormAdminConfig";
import { useManastormAdminStore } from "../game/manastormAdminStore";
import { useGameStore } from "../game/store";
import {
configWithGmReviewNote,
gmReviewQueue,
type GmReviewKind,
} from "../game/gmContentReview";
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 randomQueueCounts = useMemo(() => Object.fromEntries(
(["dungeon", "boss", "manastorm"] as const).map((kind) => [kind, gmReviewQueue(kind, draft).length]),
) as Readonly<Record<GmReviewKind, number>>, [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(" ")} ${entry.reviewNote}`.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 setReviewNote = (key: string, note: string) => {
const next = configWithGmReviewNote(draft, { kind: "boss", bossKey: key }, note);
if (next) replaceDraft(next);
};
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">
<section className="gm-random-qa" aria-labelledby="gm-random-qa-title">
<p className="front-kicker">Random QA</p>
<h2 id="gm-random-qa-title">Review queue</h2>
<p>Needs tested first, then Not ready. Ready content stays out of the draw.</p>
{(["dungeon", "boss", "manastorm"] as const).map((kind) => {
const label = kind === "dungeon" ? "Dungeon" : kind === "boss" ? "Boss" : "Manastorm";
const count = randomQueueCounts[kind];
return (
<button
key={kind}
type="button"
className="front-button"
disabled={dirty || saving || count === 0}
title={dirty ? "Save changes before testing." : count === 0 ? `All runnable ${label.toLowerCase()} content is Ready.` : undefined}
onClick={() => enterRandomGmReview(kind)}
>
<span>Random {label}</span>
<small>{count ? `${count} queued` : "All reviewed"}</small>
</button>
);
})}
</section>
<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>
<section className="gm-review-note-editor" aria-label={`Review note for ${selected.name}`}>
<header>
<strong>Review note</strong>
<small>{selected.reviewNote.length} / {GM_REVIEW_NOTE_MAX_LENGTH}</small>
</header>
<textarea
aria-label={`Review note for ${selected.name}`}
value={selected.reviewNote}
maxLength={GM_REVIEW_NOTE_MAX_LENGTH}
rows={5}
disabled={saving}
placeholder="Record what needs testing or fixing."
onChange={(event) => setReviewNote(selected.key, event.currentTarget.value)}
/>
<footer>
<small>{selected.reviewedAt && selected.reviewedBy
? `Last reviewed by ${selected.reviewedBy} · ${new Date(selected.reviewedAt).toLocaleString()}`
: "Not yet reviewed."}</small>
<button type="button" disabled={saving || !selected.reviewNote} onClick={() => setReviewNote(selected.key, "")}>Clear note</button>
</footer>
</section>
<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>
);
}