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

+2 -1
View File
@@ -44,7 +44,8 @@ function InputBridge() {
useEffect(() => installInput({
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""),
inputSuspended: () => useDungeonSimulationStore.getState().sessionMode !== "play"
|| ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""),
getBindings: (device) => {
const preferences = useUiLayoutStore.getState().preferences;
const bindings = profileInputBindings(preferences, device);
+15
View File
@@ -4,6 +4,13 @@ export type BalanceLabPartySource = "wow" | "coa" | "rom" | "all";
export type BalanceLabBossCategory = "all" | "wow" | "rom";
export type BalanceLabJobStatus = "queued" | "running" | "complete" | "failed";
export interface BalanceLabRuntimeStatus {
readonly available: boolean;
readonly executable: string;
readonly version: string | null;
readonly error: string | null;
}
export interface BalanceLabJobSpec {
readonly source: BalanceLabPartySource;
readonly level: number;
@@ -155,6 +162,14 @@ export async function listBalanceLabJobs(token: string): Promise<readonly Balanc
)).jobs;
}
export async function getBalanceLabRuntime(token: string): Promise<BalanceLabRuntimeStatus> {
return (await requestOnlineJson<{ runtime: BalanceLabRuntimeStatus }>(
"/api/gm/balance-lab/runtime",
{},
token,
)).runtime;
}
export async function startBalanceLabJob(spec: BalanceLabJobSpec, token: string): Promise<BalanceLabJob> {
return (await requestOnlineJson<{ job: BalanceLabJob }>(
"/api/gm/balance-lab/jobs",
+17
View File
@@ -54,6 +54,23 @@ describe("dungeon simulation fixed clock", () => {
});
describe("spectator roster and exact-hash approvals", () => {
it("tracks the chosen party member and returns to follow mode when one is picked", () => {
const store = useDungeonSimulationStore.getState();
store.startLive({ dungeonId: route.dungeonId, route, mode: "navigation-only", seed: "camera" });
expect(useDungeonSimulationStore.getState()).toMatchObject({
cameraMode: "follow-member",
spectatedMemberId: null,
});
store.setCameraMode("free-fly");
store.setSpectatedMember("party-healer");
expect(useDungeonSimulationStore.getState()).toMatchObject({
cameraMode: "follow-member",
spectatedMemberId: "party-healer",
});
store.leave();
expect(useDungeonSimulationStore.getState().spectatedMemberId).toBeNull();
});
it("creates a stable tank, healer, and three damage dealers", () => {
const first = createSpectatorDungeonParty("same-seed", 80, "wow");
const second = createSpectatorDungeonParty("same-seed", 80, "wow");
+5 -1
View File
@@ -33,6 +33,7 @@ export interface DungeonSimulationState {
runState: "idle" | "running" | "paused" | "finished";
speed: DungeonSimulationSpeed;
cameraMode: DungeonSpectatorCameraMode;
spectatedMemberId: string | null;
dungeonId: string | null;
routeId: string | null;
routeHash: string | null;
@@ -56,6 +57,7 @@ export interface DungeonSimulationState {
toggleRunning: () => void;
setSpeed: (speed: DungeonSimulationSpeed) => void;
setCameraMode: (mode: DungeonSpectatorCameraMode) => void;
setSpectatedMember: (memberId: string | null) => void;
step: () => void;
seekReplay: (elapsedMs: number) => void;
consumeTicks: (wallDeltaMs: number) => readonly number[];
@@ -72,7 +74,8 @@ const initialState = {
mode: "full-combat" as DungeonSimulationMode,
runState: "idle" as const,
speed: 1 as DungeonSimulationSpeed,
cameraMode: "follow-leader" as DungeonSpectatorCameraMode,
cameraMode: "follow-member" as DungeonSpectatorCameraMode,
spectatedMemberId: null as string | null,
dungeonId: null as string | null,
routeId: null as string | null,
routeHash: null as string | null,
@@ -146,6 +149,7 @@ export const useDungeonSimulationStore = create<DungeonSimulationState>((set, ge
},
setSpeed: (speed) => set({ speed }),
setCameraMode: (cameraMode) => set({ cameraMode }),
setSpectatedMember: (spectatedMemberId) => set({ spectatedMemberId, cameraMode: "follow-member" }),
step: () => set((state) => {
if (state.runState !== "paused") return state;
if (state.sessionMode === "spectate-replay" && state.replay) {
+1 -1
View File
@@ -3,7 +3,7 @@ import type { Vector3Tuple } from "./dungeonTypes";
export type DungeonSessionMode = "play" | "spectate-live" | "spectate-replay";
export type DungeonSimulationMode = "full-combat" | "navigation-only";
export type DungeonSimulationSpeed = 0.5 | 1 | 2 | 4;
export type DungeonSpectatorCameraMode = "follow-leader" | "free-fly";
export type DungeonSpectatorCameraMode = "follow-member" | "free-fly";
export type DungeonRouteReviewStatus = "Candidate" | "Approved" | "NeedsWork";
export type DungeonSimulationResult = "completed" | "wiped" | "stopped" | "recording-limit";
export type DungeonSimulationFindingSeverity = "info" | "warning" | "critical";
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import {
spectatorCameraBoosted,
spectatorCameraTranslation,
spectatorKeyboardAxes,
} from "./spectatorCameraMath";
describe("dungeon spectator free camera", () => {
it("maps desktop flight controls independently from player movement state", () => {
const held = new Set(["KeyW", "KeyD", "KeyE", "ShiftLeft"]);
expect(spectatorKeyboardAxes(held)).toEqual({ forward: 1, right: 1, vertical: 1 });
expect(spectatorCameraBoosted(held)).toBe(true);
expect(spectatorKeyboardAxes(new Set(["KeyS", "KeyA", "KeyQ"]))).toEqual({
forward: -1,
right: -1,
vertical: -1,
});
});
it("flies along the view direction and uses world-relative vertical movement", () => {
expect(spectatorCameraTranslation(0, 0, { forward: 1, right: 0, vertical: 0 }, 0.1, false))
.toEqual([0, 0, 1]);
expect(spectatorCameraTranslation(0, 0, { forward: 0, right: 0, vertical: 1 }, 0.1, false))
.toEqual([0, 1, 0]);
const pitched = spectatorCameraTranslation(0, Math.PI / 6, { forward: 1, right: 0, vertical: 0 }, 0.1, false);
expect(pitched[1]).toBeCloseTo(0.5, 6);
expect(pitched[2]).toBeCloseTo(Math.sqrt(3) / 2, 6);
});
it("normalizes diagonal speed and clamps long render-frame gaps", () => {
const diagonal = spectatorCameraTranslation(0, 0, { forward: 1, right: 1, vertical: 1 }, 1, false);
expect(Math.hypot(...diagonal)).toBeCloseTo(1, 6);
const boosted = spectatorCameraTranslation(0, 0, { forward: 1, right: 0, vertical: 0 }, 1, true);
expect(Math.hypot(...boosted)).toBeCloseTo(2.8, 6);
expect(spectatorCameraTranslation(Number.NaN, 0, { forward: 1, right: 0, vertical: 0 }, 0.1, false))
.toEqual([0, 0, 0]);
});
});
+51
View File
@@ -0,0 +1,51 @@
import type { Vector3Tuple } from "./dungeonTypes";
export interface SpectatorCameraAxes {
readonly forward: number;
readonly right: number;
readonly vertical: number;
}
const MAX_CAMERA_FRAME_SECONDS = 0.1;
function held(codes: ReadonlySet<string>, ...candidates: readonly string[]): number {
return Number(candidates.some((code) => codes.has(code)));
}
export function spectatorKeyboardAxes(codes: ReadonlySet<string>): SpectatorCameraAxes {
return {
forward: held(codes, "KeyW") - held(codes, "KeyS"),
right: held(codes, "KeyD") - held(codes, "KeyA"),
vertical: held(codes, "KeyE", "Space") - held(codes, "KeyQ", "ControlLeft", "ControlRight"),
};
}
export function spectatorCameraBoosted(codes: ReadonlySet<string>): boolean {
return codes.has("ShiftLeft") || codes.has("ShiftRight");
}
/** Returns a bounded, normalized free-flight translation for one render frame. */
export function spectatorCameraTranslation(
yaw: number,
pitch: number,
axes: SpectatorCameraAxes,
deltaSeconds: number,
boosted: boolean,
): Vector3Tuple {
if (![yaw, pitch, deltaSeconds, axes.forward, axes.right, axes.vertical].every(Number.isFinite)) return [0, 0, 0];
const forwardX = Math.sin(yaw) * Math.cos(pitch);
const forwardY = Math.sin(pitch);
const forwardZ = Math.cos(yaw) * Math.cos(pitch);
const rightX = Math.cos(yaw);
const rightZ = -Math.sin(yaw);
let x = forwardX * axes.forward + rightX * axes.right;
let y = forwardY * axes.forward + axes.vertical;
let z = forwardZ * axes.forward + rightZ * axes.right;
const length = Math.hypot(x, y, z);
if (length <= 1e-6) return [0, 0, 0];
const scale = (boosted ? 28 : 10) * Math.max(0, Math.min(MAX_CAMERA_FRAME_SECONDS, deltaSeconds)) / Math.max(1, length);
x *= scale;
y *= scale;
z *= scale;
return [x, y, z];
}
+52 -20
View File
@@ -2,15 +2,24 @@ import { useFrame, useThree } from "@react-three/fiber";
import { useRapier } from "@react-three/rapier";
import { useEffect, useRef } from "react";
import { Vector3 } from "three";
import { consumeMouseLook, readInputSnapshot } from "../game/inputManager";
import { consumeMouseLook } from "../game/inputManager";
import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
import { usePartyStore } from "../game/partyStore";
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import { useGameStore } from "../game/store";
import {
spectatorCameraBoosted,
spectatorCameraTranslation,
spectatorKeyboardAxes,
} from "../game/spectatorCameraMath";
const FOLLOW_DISTANCE = 7;
const FOLLOW_HEIGHT = 2.4;
const LOOK_SENSITIVITY = 0.0021;
const SPECTATOR_FLIGHT_KEYS = new Set([
"KeyW", "KeyA", "KeyS", "KeyD", "KeyQ", "KeyE", "Space",
"ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight",
]);
export function DungeonSpectatorCamera() {
const camera = useThree((state) => state.camera);
@@ -18,30 +27,39 @@ export function DungeonSpectatorCamera() {
const yawRef = useRef(useGameStore.getState().cameraYaw);
const pitchRef = useRef(-0.22);
const freePositionRef = useRef(new Vector3().copy(camera.position));
const boostRef = useRef(false);
const pressedKeysRef = useRef(new Set<string>());
const initializedFreeRef = useRef(false);
const followedMemberRef = useRef<string | null>(null);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
boostRef.current = event.shiftKey;
const onKeyDown = (event: KeyboardEvent) => {
if (!SPECTATOR_FLIGHT_KEYS.has(event.code)) return;
const target = event.target as HTMLElement | null;
if (target?.closest?.("input, textarea, select, [contenteditable='true']")) return;
pressedKeysRef.current.add(event.code);
if (useDungeonSimulationStore.getState().cameraMode === "free-fly") event.preventDefault();
};
window.addEventListener("keydown", onKey);
window.addEventListener("keyup", onKey);
const onKeyUp = (event: KeyboardEvent) => {
pressedKeysRef.current.delete(event.code);
};
const clearKeys = () => pressedKeysRef.current.clear();
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", clearKeys);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("keyup", onKey);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", clearKeys);
clearKeys();
};
}, []);
useFrame((_, delta) => {
const simulation = useDungeonSimulationStore.getState();
if (simulation.sessionMode === "play") return;
const input = readInputSnapshot();
const [mouseX, mouseY] = consumeMouseLook();
yawRef.current -= mouseX * LOOK_SENSITIVITY;
pitchRef.current = Math.max(-1.35, Math.min(1.15, pitchRef.current - mouseY * LOOK_SENSITIVITY));
yawRef.current -= input.lookX * delta * 2.25;
pitchRef.current = Math.max(-1.35, Math.min(1.15, pitchRef.current - input.lookY * delta * 1.65));
useGameStore.setState({ cameraYaw: yawRef.current });
if (simulation.cameraMode === "free-fly") {
@@ -49,15 +67,23 @@ export function DungeonSpectatorCamera() {
freePositionRef.current.copy(camera.position);
initializedFreeRef.current = true;
}
followedMemberRef.current = null;
const forward = new Vector3(
Math.sin(yawRef.current) * Math.cos(pitchRef.current),
Math.sin(pitchRef.current),
Math.cos(yawRef.current) * Math.cos(pitchRef.current),
);
const right = new Vector3(Math.cos(yawRef.current), 0, -Math.sin(yawRef.current));
const speed = boostRef.current ? 28 : 10;
freePositionRef.current.addScaledVector(forward, input.moveForward * speed * delta);
freePositionRef.current.addScaledVector(right, input.moveX * speed * delta);
const keyboard = spectatorKeyboardAxes(pressedKeysRef.current);
const movement = spectatorCameraTranslation(
yawRef.current,
pitchRef.current,
keyboard,
delta,
spectatorCameraBoosted(pressedKeysRef.current),
);
freePositionRef.current.x += movement[0];
freePositionRef.current.y += movement[1];
freePositionRef.current.z += movement[2];
camera.position.copy(freePositionRef.current);
camera.lookAt(new Vector3().copy(camera.position).add(forward));
return;
@@ -65,10 +91,14 @@ export function DungeonSpectatorCamera() {
initializedFreeRef.current = false;
const party = usePartyStore.getState();
const leader = party.members.find((member) => member.health > 0) ?? party.members[0];
const leaderPosition = leader ? getPartyRuntimePosition(leader.id) : null;
if (!leaderPosition) return;
const anchor = new Vector3(leaderPosition[0], leaderPosition[1] + FOLLOW_HEIGHT, leaderPosition[2]);
const requested = party.members.find((member) => member.id === simulation.spectatedMemberId);
const candidates = requested
? [requested, ...party.members.filter((member) => member.id !== requested.id)]
: [...party.members.filter((member) => member.health > 0), ...party.members.filter((member) => member.health <= 0)];
const followed = candidates.map((member) => ({ member, position: getPartyRuntimePosition(member.id) }))
.find((candidate) => candidate.position !== null);
if (!followed?.position) return;
const anchor = new Vector3(followed.position[0], followed.position[1] + FOLLOW_HEIGHT, followed.position[2]);
const backward = new Vector3(
-Math.sin(yawRef.current) * Math.cos(pitchRef.current),
-Math.sin(pitchRef.current),
@@ -78,7 +108,9 @@ export function DungeonSpectatorCamera() {
const hit = world.castRay(ray, FOLLOW_DISTANCE, true, rapier.QueryFilterFlags.EXCLUDE_SENSORS);
const distance = hit ? Math.max(0.8, hit.timeOfImpact - 0.25) : FOLLOW_DISTANCE;
const desired = anchor.clone().addScaledVector(backward, distance);
camera.position.lerp(desired, 1 - Math.exp(-12 * Math.min(delta, 0.1)));
if (followedMemberRef.current !== followed.member.id) camera.position.copy(desired);
else camera.position.lerp(desired, 1 - Math.exp(-12 * Math.min(delta, 0.1)));
followedMemberRef.current = followed.member.id;
camera.lookAt(anchor);
});
+19
View File
@@ -3221,6 +3221,11 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
.balance-lab-config, .balance-lab-jobs { border-right: 1px solid var(--front-line); }
.balance-lab-config h2, .balance-lab-jobs h2, .balance-lab-detail h2 { margin: 4px 0 10px; font-family: Georgia, serif; font-size: 20px; font-weight: 400; }
.balance-lab-help, .balance-lab-local-note, .balance-lab-detail > p:not(.front-kicker), .balance-lab-empty { color: var(--front-dim); font-size: 8px; line-height: 1.45; }
.balance-lab-runtime { display: grid; gap: 3px; margin-top: 10px; padding: 8px 9px; border: 1px solid rgba(121, 185, 104, .25); border-radius: 6px; color: #cbe6bb; background: rgba(53, 106, 48, .13); }
.balance-lab-runtime strong { font-size: 8px; }
.balance-lab-runtime span { color: var(--front-dim); font-size: 7px; line-height: 1.45; }
.balance-lab-runtime.is-unavailable { border-color: rgba(205, 104, 85, .4); color: #f0b6aa; background: rgba(133, 48, 39, .15); }
.balance-lab-runtime.is-unavailable span { color: #d7a89e; }
.balance-lab-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 14px; }
.balance-lab-form-grid label { display: grid; gap: 4px; color: var(--front-dim); font-size: 8px; }
.balance-lab-form-grid__wide { grid-column: 1 / -1; }
@@ -4039,6 +4044,20 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
.dungeon-sim-hud button:hover:not(:disabled), .dungeon-sim-hud button.is-active { background: #1d526b; border-color: #70d7ff; }
.dungeon-sim-hud button.is-danger { color: #ffd8d8; border-color: #a94848; background: #502121; }
.dungeon-sim-hud button:disabled { opacity: 0.45; }
.dungeon-sim-hud__spectate { display: grid; gap: 7px; margin: 0 0 12px; padding: 10px; border: 1px solid rgba(112, 215, 255, .2); border-radius: 8px; background: rgba(7, 21, 31, .72); }
.dungeon-sim-hud__spectate > header { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.dungeon-sim-hud__spectate > header span { color: #82aaba; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; }
.dungeon-sim-hud__spectate > header strong { overflow: hidden; color: #e9f8ff; text-overflow: ellipsis; white-space: nowrap; }
.dungeon-sim-hud__spectate > div { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 5px; }
.dungeon-sim-hud__spectate > div > button { display: grid; min-width: 0; grid-template-columns: 5px minmax(0, 1fr); gap: 5px; padding: 6px; text-align: left; }
.dungeon-sim-hud__spectate button > i { width: 5px; height: 100%; min-height: 28px; border-radius: 99px; }
.dungeon-sim-hud__spectate button > span { min-width: 0; }
.dungeon-sim-hud__spectate button strong,
.dungeon-sim-hud__spectate button small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dungeon-sim-hud__spectate button strong { font-size: .68rem; }
.dungeon-sim-hud__spectate button small { margin-top: 2px; color: #8fb5c4; font-size: .52rem; text-transform: uppercase; }
.dungeon-sim-hud__spectate button em { display: none; }
.dungeon-sim-hud__spectate > p { margin: 0; color: #8fb5c4; font-size: .68rem; }
.dungeon-sim-hud__seek { display: grid; gap: 4px; margin-bottom: 10px; color: #9cc6d5; }
.dungeon-sim-hud__seek input { width: 100%; }
.dungeon-sim-hud__status { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 14px; margin: 0 0 12px; }
+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>