forked from phenom/healer-man
Release Healer Man 0.1.46
This commit is contained in:
1 parent
f82daf1c13
commit
9ff574ac0b
73 files changed
+33486
-174
No files matched your search
@@ -0,0 +1,249 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user