forked from phenom/healer-man
250 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|