Release Healer Man 0.1.47

This commit is contained in:
phenom committed 2026-09-16 20:00:13 -04:00
1 parent 9ff574ac0b
commit 2162039b49
24 files changed
+486 -55

No files matched your search

+15 -3
View File
@@ -5,11 +5,13 @@ import {
fetchBalanceLabArtifact,
getBalanceLabJob,
getBalanceLabReport,
getBalanceLabRuntime,
listBalanceLabJobs,
startBalanceLabJob,
type BalanceLabBossCategory,
type BalanceLabJob,
type BalanceLabPartySource,
type BalanceLabRuntimeStatus,
type BalanceLabSuiteReport,
} from "../app/balanceLabClient";
import { BOSS_TESTING_CATALOG } from "../game/bossTesting";
@@ -77,6 +79,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
const [reports, setReports] = useState<Readonly<Record<string, BalanceLabSuiteReport>>>({});
const [reportLoadingId, setReportLoadingId] = useState<string | null>(null);
const [reportError, setReportError] = useState("");
const [runtimeStatus, setRuntimeStatus] = useState<BalanceLabRuntimeStatus | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
@@ -106,9 +109,10 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
if (!token) return;
let cancelled = false;
setLoading(true);
void listBalanceLabJobs(token).then((nextJobs) => {
void Promise.all([listBalanceLabJobs(token), getBalanceLabRuntime(token)]).then(([nextJobs, runtime]) => {
if (cancelled) return;
setJobs(nextJobs);
setRuntimeStatus(runtime);
setSelectedJobId((current) => current ?? nextJobs[0]?.id ?? null);
setError("");
}).catch((reason) => {
@@ -169,6 +173,10 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
};
const run = async () => {
if (!runtimeStatus?.available) {
setError(runtimeStatus?.error ?? "The server Balance Lab runtime is unavailable.");
return;
}
if (bossScope === "specific" && selectedBossKeys.length === 0) {
setError("Select at least one boss for a specific-boss run.");
return;
@@ -229,6 +237,10 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
<p className="front-kicker">New automatic sweep</p>
<h2>Simulation setup</h2>
<p className="balance-lab-help">This mode runs on the game server. The browser submits the job and may be closed while the server continues.</p>
{runtimeStatus && <div className={`balance-lab-runtime ${runtimeStatus.available ? "is-ready" : "is-unavailable"}`} role={runtimeStatus.available ? undefined : "alert"}>
<strong>{runtimeStatus.available ? "Server runtime ready" : "Server runtime unavailable"}</strong>
<span>{runtimeStatus.available ? runtimeStatus.version : `${runtimeStatus.error} Install Python 3 in the server container, set BALANCE_LAB_PYTHON=python3, and restart the app.`}</span>
</div>}
<div className="balance-lab-form-grid">
<label><span>Party source</span><select value={source} onChange={(event) => setSource(event.currentTarget.value as BalanceLabPartySource)}>
<option value="all">WoW + CoA + RoM</option><option value="wow">WoW only</option><option value="coa">CoA only</option><option value="rom">RoM only</option>
@@ -254,7 +266,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
</section>
)}
<div className="balance-lab-estimate"><span>Party configurations <b>{partyCount.toLocaleString()}</b></span><span>Bosses <b>{bossCount.toLocaleString()}</b></span><span>Total simulations <b>{estimatedRuns.toLocaleString()}</b></span></div>
<button className="front-button front-button--primary balance-lab-run" type="button" disabled={submitting || !Number.isFinite(estimatedRuns) || estimatedRuns < 1} onClick={() => void run()}>{submitting ? "Queuing…" : "Run on server"}</button>
<button className="front-button front-button--primary balance-lab-run" type="button" disabled={submitting || !runtimeStatus?.available || !Number.isFinite(estimatedRuns) || estimatedRuns < 1} onClick={() => void run()}>{submitting ? "Queuing…" : runtimeStatus && !runtimeStatus.available ? "Server runtime unavailable" : "Run on server"}</button>
<p className="balance-lab-local-note">To spend this device's CPU instead, run <code>npm run balance:lab</code> from the project checkout.</p>
{error && <p className="front-notice" role="alert">{error}</p>}
</section>
@@ -262,7 +274,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
<section className="balance-lab-jobs">
<header><div><p className="front-kicker">Saved on server</p><h2>Run history</h2></div><button type="button" disabled={loading} onClick={() => {
setLoading(true);
void listBalanceLabJobs(token).then(setJobs).catch((reason) => setError(errorMessage(reason))).finally(() => setLoading(false));
void Promise.all([listBalanceLabJobs(token), getBalanceLabRuntime(token)]).then(([nextJobs, runtime]) => { setJobs(nextJobs); setRuntimeStatus(runtime); }).catch((reason) => setError(errorMessage(reason))).finally(() => setLoading(false));
}}>Refresh</button></header>
<div className="balance-lab-history-filters"><input type="search" aria-label="Search Balance Lab run history" placeholder="Find source, level, GM…" value={jobSearch} onChange={(event) => setJobSearch(event.currentTarget.value)} /><select aria-label="Filter Balance Lab run status" value={jobStatus} onChange={(event) => setJobStatus(event.currentTarget.value as "all" | BalanceLabJob["status"])}><option value="all">All statuses</option><option value="complete">Complete</option><option value="running">Running</option><option value="queued">Queued</option><option value="failed">Failed</option></select></div>
<div className="balance-lab-job-list">
+50 -4
View File
@@ -3,6 +3,8 @@ import { dungeonRouteDefinition } from "../game/dungeonRoutes";
import type { DungeonSimulationSpeed } from "../game/dungeonSimulationTypes";
import { finalizeDungeonSimulation } from "../scene/DungeonSimulationSceneBridge";
import { useShellStore } from "../app/shellStore";
import { classById } from "../app/characterCatalog";
import { PARTY_ROLE_LABELS, usePartyStore } from "../game/partyStore";
const SPEEDS: readonly DungeonSimulationSpeed[] = [0.5, 1, 2, 4];
@@ -25,11 +27,15 @@ function downloadReport(): void {
export function DungeonSimulationHud() {
const simulation = useDungeonSimulationStore();
const partyMembers = usePartyStore((state) => state.members);
const route = simulation.dungeonId
? dungeonRouteDefinition(simulation.dungeonId, simulation.routeId ?? "primary")
: null;
const leg = route?.legs.find((candidate) => candidate.objectiveId === simulation.objectiveId) ?? null;
const replayDuration = simulation.replay?.durationMs ?? 0;
const fallbackMember = partyMembers.find((member) => member.health > 0) ?? partyMembers[0] ?? null;
const spectatedMember = partyMembers.find((member) => member.id === simulation.spectatedMemberId)
?? fallbackMember;
const returnToGmTools = () => {
useDungeonSimulationStore.getState().leave();
@@ -61,10 +67,21 @@ export function DungeonSimulationHud() {
{speed}×
</button>
))}
<button type="button" onClick={() => simulation.setCameraMode(
simulation.cameraMode === "follow-leader" ? "free-fly" : "follow-leader",
)}>
{simulation.cameraMode === "follow-leader" ? "Follow camera" : "Free-fly camera"}
<button
type="button"
className={simulation.cameraMode === "follow-member" ? "is-active" : undefined}
aria-pressed={simulation.cameraMode === "follow-member"}
onClick={() => simulation.setCameraMode("follow-member")}
>
Follow member
</button>
<button
type="button"
className={simulation.cameraMode === "free-fly" ? "is-active" : undefined}
aria-pressed={simulation.cameraMode === "free-fly"}
onClick={() => simulation.setCameraMode("free-fly")}
>
Free camera
</button>
{simulation.sessionMode === "spectate-live" && simulation.runState !== "finished" ? (
<button type="button" className="is-danger" onClick={() => void finalizeDungeonSimulation("stopped")}>
@@ -75,6 +92,35 @@ export function DungeonSimulationHud() {
{simulation.runState === "finished" && <button type="button" onClick={returnToGmTools}>Return to GM Tools</button>}
</div>
<section className="dungeon-sim-hud__spectate" aria-label="Spectate party member">
<header>
<span>{simulation.cameraMode === "free-fly" ? "Free camera" : "Following"}</span>
<strong>{simulation.cameraMode === "free-fly" ? "Detached" : spectatedMember?.name ?? "Waiting for party"}</strong>
</header>
<div>
{partyMembers.map((member) => {
const characterClass = classById(member.classId);
const selected = simulation.cameraMode === "follow-member" && member.id === spectatedMember?.id;
return (
<button
type="button"
key={member.id}
className={selected ? "is-active" : undefined}
aria-pressed={selected}
onClick={() => simulation.setSpectatedMember(member.id)}
>
<i style={{ background: characterClass.color }} aria-hidden="true" />
<span><strong>{member.name}</strong><small>{PARTY_ROLE_LABELS[member.role]} · {member.specialization}</small></span>
<em>{Math.max(0, Math.ceil(member.health))}</em>
</button>
);
})}
</div>
<p>{simulation.cameraMode === "free-fly"
? "Right-drag to look · WASD fly · E/Space up · Q/Ctrl down · Shift boost"
: "Choose a party member to follow · Right-drag to orbit"}</p>
</section>
{simulation.sessionMode === "spectate-replay" && (
<label className="dungeon-sim-hud__seek">
<span>Replay position</span>