298 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|