Files
healer-man/src/ui/DungeonSimulationAdminScreen.tsx
T
2026-09-16 14:30:58 -04:00

250 lines
12 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import {
deleteDungeonSimulationReplay,
fetchDungeonSimulationReplay,
fetchDungeonSimulationReplays,
fetchDungeonSimulationReport,
fetchGmDungeonRoutes,
putDungeonRouteReview,
} from "../app/dungeonSimulationClient";
import { enterDungeonSimulation, enterDungeonSimulationReplay } from "../app/dungeonSimulationRuntime";
import { sessionCanUseGmTools } from "../app/releaseFeatures";
import { useShellStore } from "../app/shellStore";
import { DUNGEON_CATALOG } from "../game/dungeonCatalog";
import { DUNGEON_ROUTE_CATALOG } from "../game/dungeonRoutes";
import type {
DungeonReplaySummary,
DungeonRouteReview,
DungeonRouteReviewStatus,
DungeonSimulationMode,
DungeonSimulationReportV1,
} from "../game/dungeonSimulationTypes";
import { BrandMark, FrontSurface } from "./FrontSurface";
function saveReport(report: DungeonSimulationReportV1): void {
const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2)], { type: "application/json" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${report.dungeonId}-${report.seed}-SimulationReportV1.json`;
anchor.click();
URL.revokeObjectURL(url);
}
export function DungeonSimulationAdminScreen() {
const session = useShellStore((state) => state.session);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
const [selectedDungeonId, setSelectedDungeonId] = useState(DUNGEON_ROUTE_CATALOG.routes[0]?.dungeonId ?? "");
const [mode, setMode] = useState<DungeonSimulationMode>("navigation-only");
const [seed, setSeed] = useState("route-review-001");
const [reviews, setReviews] = useState<readonly DungeonRouteReview[]>([]);
const [replays, setReplays] = useState<readonly DungeonReplaySummary[]>([]);
const [reviewStatus, setReviewStatus] = useState<DungeonRouteReviewStatus>("Candidate");
const [reviewNote, setReviewNote] = useState("");
const [overrideNote, setOverrideNote] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const route = DUNGEON_ROUTE_CATALOG.routes.find((candidate) => candidate.dungeonId === selectedDungeonId)
?? DUNGEON_ROUTE_CATALOG.routes[0];
const dungeon = DUNGEON_CATALOG.find((candidate) => candidate.id === route?.dungeonId);
const storedReview = reviews.find((candidate) => candidate.dungeonId === route?.dungeonId
&& candidate.routeId === route?.id) ?? null;
const review = storedReview?.routeHash === route?.hash ? storedReview : null;
const routeReplays = useMemo(() => replays.filter((replay) => (
replay.dungeonId === route?.dungeonId && replay.routeId === route?.id && replay.routeHash === route?.hash
)), [replays, route?.dungeonId, route?.hash, route?.id]);
const refresh = async () => {
if (!sessionCanUseGmTools(session)) return;
setBusy(true);
try {
const [routes, recordings] = await Promise.all([
fetchGmDungeonRoutes(session),
fetchDungeonSimulationReplays(session),
]);
setReviews(routes.reviews);
setReplays(recordings);
setMessage("");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Unable to load route reviews.");
} finally {
setBusy(false);
}
};
useEffect(() => { void refresh(); }, [session]);
useEffect(() => {
setReviewStatus(review?.status ?? (route?.generationStatus === "Rejected" ? "NeedsWork" : "Candidate"));
setReviewNote(review?.note ?? "");
setOverrideNote(review?.criticalOverrideNote ?? "");
}, [review?.revision, route?.hash]);
if (!sessionCanUseGmTools(session)) {
return (
<FrontSurface className="gm-admin-screen" ariaLabel="GM access denied">
<section className="gm-admin-denied">
<BrandMark />
<h1>Game Master access required</h1>
<p>Dungeon simulation artifacts require a server-authenticated GM account.</p>
<button className="front-button" type="button" onClick={returnToMainMenu}>Return to main menu</button>
</section>
</FrontSurface>
);
}
const saveReview = async () => {
if (!route) return;
setBusy(true);
try {
const updated = await putDungeonRouteReview(session, {
dungeonId: route.dungeonId,
routeId: route.id,
routeHash: route.hash,
}, {
status: reviewStatus,
expectedRevision: storedReview?.revision ?? 0,
note: reviewNote,
criticalOverrideNote: overrideNote,
});
setReviews((current) => [...current.filter((candidate) => !(
candidate.dungeonId === updated.dungeonId && candidate.routeId === updated.routeId
)), updated]);
setMessage(`Review saved at revision ${updated.revision}.`);
} catch (error) {
setMessage(error instanceof Error ? error.message : "Unable to save review.");
} finally {
setBusy(false);
}
};
const openReplay = async (id: string) => {
setBusy(true);
try {
const replay = await fetchDungeonSimulationReplay(session, id);
if (!enterDungeonSimulationReplay(replay)) setMessage("The replay could not be opened.");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Unable to load replay.");
setBusy(false);
}
};
const removeReplay = async (id: string) => {
setBusy(true);
try {
await deleteDungeonSimulationReplay(session, id);
setReplays((current) => current.filter((candidate) => candidate.id !== id));
setMessage("Recording deleted.");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Unable to delete replay.");
} finally {
setBusy(false);
}
};
const downloadReplayReport = async (id: string) => {
setBusy(true);
try {
saveReport(await fetchDungeonSimulationReport(session, id));
setMessage("");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Unable to download report.");
} finally {
setBusy(false);
}
};
return (
<FrontSurface className="dungeon-sim-admin" ariaLabel="Dungeon simulation route review">
<header className="dungeon-sim-admin__header">
<BrandMark />
<div><p className="eyebrow">Game Master tools</p><h1>Dungeon route simulations</h1></div>
<div className="dungeon-sim-admin__header-actions">
<button className="front-button" type="button" onClick={() => void refresh()} disabled={busy}>Refresh</button>
<button className="front-button" type="button" onClick={returnToMainMenu}>Main menu</button>
</div>
</header>
<main className="dungeon-sim-admin__layout">
<section className="dungeon-sim-admin__catalog">
<label>
<span>Dungeon</span>
<select value={selectedDungeonId} onChange={(event) => setSelectedDungeonId(event.target.value)}>
{DUNGEON_ROUTE_CATALOG.routes.map((candidate) => {
const entry = DUNGEON_CATALOG.find((item) => item.id === candidate.dungeonId);
return <option key={candidate.dungeonId} value={candidate.dungeonId}>{entry?.title ?? candidate.dungeonId}</option>;
})}
</select>
</label>
<div className="dungeon-sim-admin__route-meta">
<span className={`route-review-status route-review-status--${(
review?.status ?? (route?.generationStatus === "Rejected" ? "NeedsWork" : "Candidate")
).toLowerCase()}`}>
{review?.status ?? (route?.generationStatus === "Rejected" ? "NeedsWork" : "Candidate")}
</span>
<strong>{dungeon?.title ?? route?.dungeonId}</strong>
<code>{route?.hash}</code>
<small>{route?.legs.length ?? 0} required legs · {route?.legs.reduce((sum, leg) => sum + leg.checkpoints.length, 0) ?? 0} checkpoints</small>
{route?.generationStatus === "Rejected" && <small>Offline rejection: {route.generationIssues.join(", ")}</small>}
</div>
<fieldset>
<legend>Launch live simulation</legend>
<label><span>Mode</span><select value={mode} onChange={(event) => setMode(event.target.value as DungeonSimulationMode)}>
<option value="navigation-only">Navigation only</option>
<option value="full-combat">Full combat</option>
</select></label>
<label><span>Deterministic seed</span><input value={seed} maxLength={96} onChange={(event) => setSeed(event.target.value)} /></label>
<button
className="front-button front-button--primary"
type="button"
disabled={busy || !route || !seed.trim()}
onClick={() => route && enterDungeonSimulation(route.dungeonId, mode, seed)}
>Launch spectate mode</button>
</fieldset>
<fieldset>
<legend>Exact-hash review</legend>
<label><span>Status</span><select value={reviewStatus} onChange={(event) => setReviewStatus(event.target.value as DungeonRouteReviewStatus)}>
<option>Candidate</option><option disabled={route?.generationStatus === "Rejected"}>Approved</option><option>NeedsWork</option>
</select></label>
<label><span>Reviewer note</span><textarea value={reviewNote} maxLength={1_000} onChange={(event) => setReviewNote(event.target.value)} /></label>
<label><span>Critical-finding override</span><textarea value={overrideNote} maxLength={1_000} placeholder="Required only when approving a run with critical findings." onChange={(event) => setOverrideNote(event.target.value)} /></label>
<button className="front-button" type="button" onClick={() => void saveReview()} disabled={busy || !route}>Save review</button>
</fieldset>
{message && <p className="front-notice" role="status">{message}</p>}
</section>
<section className="dungeon-sim-admin__details">
<header><div><p className="eyebrow">Server recordings</p><h2>{routeReplays.length} runs for this route</h2></div></header>
<div className="dungeon-sim-admin__replays">
{routeReplays.map((replay) => (
<article key={replay.id}>
<div>
<strong>{replay.mode.replace("-", " ")} · {replay.result}</strong>
<small>{new Date(replay.createdAt).toLocaleString()} · seed {replay.seed}</small>
<small>{Math.round(replay.durationMs / 100) / 10}s · {replay.criticalFindings} critical · {(replay.compressedBytes / 1024).toFixed(1)} KiB</small>
</div>
<div>
<button className="front-button" type="button" disabled={busy} onClick={() => void openReplay(replay.id)}>Watch</button>
<button className="front-button" type="button" disabled={busy} onClick={() => void downloadReplayReport(replay.id)}>JSON</button>
<button className="front-button" type="button" disabled={busy} onClick={() => void removeReplay(replay.id)}>Delete</button>
</div>
</article>
))}
{!routeReplays.length && <p className="front-notice">No recordings exist for this route hash yet.</p>}
</div>
<h2>Canonical route legs</h2>
<ol className="dungeon-sim-admin__legs">
{route?.legs.map((leg) => (
<li key={leg.id}>
<strong>{leg.objectiveName}</strong>
<span>{leg.checkpoints.length} checkpoints · {leg.expectedDistance.toFixed(1)} m</span>
<small>{leg.actions.length ? `${leg.actions.length} required actions` : "No explicit interaction"}</small>
</li>
))}
</ol>
</section>
</main>
</FrontSurface>
);
}