Release Healer Man 0.1.46

This commit is contained in:
phenom committed 2026-09-16 14:30:58 -04:00
1 parent f82daf1c13
commit 9ff574ac0b
73 files changed
+33486 -174

No files matched your search

+2
View File
@@ -20,6 +20,7 @@ const CharacterSelectScreen = lazy(() => import("./ui/CharacterSelectScreen").th
const ClassHallScreen = lazy(() => import("./ui/ClassHallScreen").then((module) => ({ default: module.ClassHallScreen })));
const DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen })));
const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
const DungeonSimulationAdminScreen = lazy(() => import("./ui/DungeonSimulationAdminScreen").then((module) => ({ default: module.DungeonSimulationAdminScreen })));
function ShellTransition() {
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
}
@@ -86,6 +87,7 @@ export default function App() {
else if (phase === "main-menu") screen = <Suspense fallback={<ShellTransition />}><MainMenuScreen /></Suspense>;
else if (phase === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
else if (phase === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><DungeonSimulationAdminScreen /></Suspense>;
else if (isShelvedShellPhase(phase)) {
screen = <Suspense fallback={<ShellTransition />}><ShelvedModeRoute phase={phase} session={session} /></Suspense>;
}
+15 -7
View File
@@ -32,6 +32,11 @@ import { useUiLayoutStore } from "./game/uiLayoutStore";
import { profileInputBindings } from "./game/keybindPreferences";
import { KeybindsPanel } from "./ui/KeybindsPanel";
import { lootShelvedBoss, releaseShelvedGameRuntime, ShelvedGameBridges } from "@shelvedGameRuntime";
import { useDungeonSimulationStore } from "./game/dungeonSimulationStore";
import { DungeonSimulationHud } from "./ui/DungeonSimulationHud";
import { useDungeonRouteStore } from "./game/dungeonRouteStore";
import { installCombatClock } from "./game/combatClock";
import { dungeonSimulationNow } from "./game/dungeonSimulationStore";
function InputBridge() {
const overlay = useGameStore((state) => state.overlay);
@@ -97,7 +102,10 @@ function InputBridge() {
export default function GameRuntime() {
useEffect(connectDpsMeter, []);
useEffect(() => installCombatClock(dungeonSimulationNow), []);
useEffect(() => { void useDungeonRouteStore.getState().refresh(); }, []);
const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout");
const dungeonSimulation = useDungeonSimulationStore((state) => state.sessionMode !== "play");
useEffect(() => () => {
const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId);
clearDungeonRuntimeCaches();
@@ -127,13 +135,13 @@ export default function GameRuntime() {
<BossTestingBridge />
<ShelvedGameBridges />
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
<Hud />
<MapPanel />
<GameplayPanels />
<PauseMenu />
<DungeonRunResults />
<UiLayoutEditor />
<KeybindsPanel />
{dungeonSimulation ? <DungeonSimulationHud /> : <Hud />}
{!dungeonSimulation && <MapPanel />}
{!dungeonSimulation && <GameplayPanels />}
{!dungeonSimulation && <PauseMenu />}
{!dungeonSimulation && <DungeonRunResults />}
{!dungeonSimulation && <UiLayoutEditor />}
{!dungeonSimulation && <KeybindsPanel />}
</main>
}
bottom={<LocalCompanionPanel />}
-2
View File
@@ -5,7 +5,6 @@ import { useManastormAdminStore } from "../game/manastormAdminStore";
const ManastormSelectScreen = lazy(() => import("../ui/ManastormSelectScreen").then((module) => ({ default: module.ManastormSelectScreen })));
const ArenaSelectScreen = lazy(() => import("../ui/ArenaSelectScreen").then((module) => ({ default: module.ArenaSelectScreen })));
const ArenaRuntime = lazy(() => import("../ArenaRuntime"));
const GmAdminScreen = lazy(() => import("../ui/GmAdminScreen").then((module) => ({ default: module.GmAdminScreen })));
function ShelvedTransition() {
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
@@ -28,6 +27,5 @@ export function ShelvedModeRoute({
if (phase === "manastorms") screen = <ManastormSelectScreen />;
else if (phase === "arenas") screen = <ArenaSelectScreen />;
else if (phase === "arena-game") screen = <ArenaRuntime />;
else if (phase === "gm-admin") screen = <GmAdminScreen />;
return <Suspense fallback={<ShelvedTransition />}>{screen}</Suspense>;
}
+192
View File
@@ -0,0 +1,192 @@
import { onlineApiOrigin, requestOnlineJson } from "./onlineAccountClient";
export type BalanceLabPartySource = "wow" | "coa" | "rom" | "all";
export type BalanceLabBossCategory = "all" | "wow" | "rom";
export type BalanceLabJobStatus = "queued" | "running" | "complete" | "failed";
export interface BalanceLabJobSpec {
readonly source: BalanceLabPartySource;
readonly level: number;
readonly partyConfigurations: number;
readonly simulationsPerParty: number;
readonly durationSeconds: number;
readonly partySeed: number;
readonly bossCategory: BalanceLabBossCategory;
readonly bossKeys: readonly string[];
}
export interface BalanceLabJob {
readonly id: string;
readonly status: BalanceLabJobStatus;
readonly createdAt: string;
readonly startedAt: string | null;
readonly completedAt: string | null;
readonly requestedBy: string;
readonly spec: BalanceLabJobSpec;
readonly progress: {
readonly party: number;
readonly partiesCompleted: number;
readonly partiesTotal: number;
readonly simulationsCompleted: number;
readonly simulationsTotal: number;
};
readonly artifacts: readonly string[];
readonly error: string | null;
readonly logTail: readonly string[];
readonly summary: BalanceLabReportSummary | null;
}
export interface BalanceLabReportSummary {
readonly partyConfigurations: number;
readonly bosses: number;
readonly runs: number;
readonly classSpecs: number;
readonly classAbilities: number;
readonly bossPressureMedianDtps: number;
}
export interface BalanceLabBossResult {
readonly bossKey: string;
readonly bossName: string;
readonly category: string;
readonly dungeonId: string;
readonly dungeonTitle: string;
readonly runs: number;
readonly incomingDtps: number;
readonly averageIncomingDamage: number;
readonly partyDps: number;
readonly bossDpsTaken: number;
readonly defeatRate: number;
readonly wipeRate: number;
readonly averageDeaths: number;
readonly averageTimeToDefeat: number | null;
readonly bossMaxHealth: number;
readonly pressureVsMedian: number | null;
readonly pressureFlag: string;
}
export interface BalanceLabClassSpecResult {
readonly source: string;
readonly buildId: string;
readonly classId: string;
readonly className: string;
readonly specialization: string;
readonly role: string;
readonly partyConfigurations: number;
readonly runs: number;
readonly dps: number;
readonly bossDps: number;
readonly hps: number;
readonly dtps: number;
readonly totalDamage: number;
readonly totalDamageTaken: number;
readonly totalHealing: number;
readonly totalAbsorbs: number;
readonly deathEvents: number;
readonly damageVsSourceRoleMedian: number | null;
readonly damageFlag: string;
}
export interface BalanceLabPartyResult {
readonly partyConfigurationId: string;
readonly source: string;
readonly members: string;
readonly runs: number;
readonly partyDps: number;
readonly incomingDtps: number;
readonly defeatRate: number;
readonly wipeRate: number;
readonly averageDeaths: number;
readonly reportPath: string;
}
export interface BalanceLabClassAbilityResult {
readonly source: string;
readonly buildId: string;
readonly classId: string;
readonly className: string;
readonly specialization: string;
readonly role: string;
readonly abilityId: string;
readonly abilityName: string;
readonly icon: string;
readonly categories: string;
readonly cooldownSeconds: number | null;
readonly partyConfigurations: number;
readonly bosses: number;
readonly runs: number;
readonly availableRuns: number;
readonly activeRuns: number;
readonly usageRate: number;
readonly casts: number;
readonly castsPerMinute: number;
readonly hits: number;
readonly hitsPerRun: number;
readonly rawDamage: number;
readonly effectiveDamage: number;
readonly dps: number;
readonly damageShare: number;
readonly averageDamagePerHit: number;
readonly rawHealing: number;
readonly effectiveHealing: number;
readonly overheal: number;
readonly absorbs: number;
readonly status: "unused" | "damage" | "support" | "used";
readonly unusedAvailable: boolean;
}
export interface BalanceLabSuiteReport {
readonly schemaVersion: number;
readonly generatedAt: string;
readonly engine: string;
readonly level: number;
readonly summary: BalanceLabReportSummary;
readonly bosses: readonly BalanceLabBossResult[];
readonly classSpecs: readonly BalanceLabClassSpecResult[];
readonly classAbilities?: readonly BalanceLabClassAbilityResult[];
readonly partyConfigurations: readonly BalanceLabPartyResult[];
}
export async function listBalanceLabJobs(token: string): Promise<readonly BalanceLabJob[]> {
return (await requestOnlineJson<{ jobs: BalanceLabJob[] }>(
"/api/gm/balance-lab/jobs",
{},
token,
)).jobs;
}
export async function startBalanceLabJob(spec: BalanceLabJobSpec, token: string): Promise<BalanceLabJob> {
return (await requestOnlineJson<{ job: BalanceLabJob }>(
"/api/gm/balance-lab/jobs",
{ method: "POST", body: JSON.stringify(spec) },
token,
)).job;
}
export async function getBalanceLabJob(id: string, token: string): Promise<BalanceLabJob> {
return (await requestOnlineJson<{ job: BalanceLabJob }>(
`/api/gm/balance-lab/jobs/${encodeURIComponent(id)}`,
{},
token,
)).job;
}
export async function getBalanceLabReport(id: string, token: string): Promise<BalanceLabSuiteReport> {
return requestOnlineJson<BalanceLabSuiteReport>(
`/api/gm/balance-lab/jobs/${encodeURIComponent(id)}/artifacts/suite-report.json`,
{},
token,
);
}
export async function fetchBalanceLabArtifact(id: string, name: string, token: string): Promise<Blob> {
const response = await fetch(
`${onlineApiOrigin()}/api/gm/balance-lab/jobs/${encodeURIComponent(id)}/artifacts/${encodeURIComponent(name)}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!response.ok) {
const body = await response.json().catch(() => ({})) as { error?: string };
throw new Error(body.error ?? `Could not download ${name} (${response.status}).`);
}
return response.blob();
}
+87
View File
@@ -0,0 +1,87 @@
import type { PlayerSession } from "./types";
import { requestOnlineJson } from "./onlineAccountClient";
import type {
DungeonReplaySummary,
DungeonReplayV1,
DungeonRouteApproval,
DungeonRouteReview,
DungeonRouteReviewStatus,
DungeonSimulationReportV1,
} from "../game/dungeonSimulationTypes";
function gmToken(session: PlayerSession | null): string {
if (session?.kind !== "account" || !session.accessToken || !session.roles?.includes("gm")) {
throw new Error("Game Master access is required.");
}
return session.accessToken;
}
export async function fetchDungeonRouteApprovals(): Promise<readonly DungeonRouteApproval[]> {
return (await requestOnlineJson<{ approvals: DungeonRouteApproval[] }>("/api/dungeon-route-approvals")).approvals;
}
export async function fetchGmDungeonRoutes(session: PlayerSession | null): Promise<{
readonly reviews: readonly DungeonRouteReview[];
readonly latestReplays: readonly DungeonReplaySummary[];
}> {
return requestOnlineJson("/api/gm/dungeon-routes", {}, gmToken(session));
}
export async function putDungeonRouteReview(
session: PlayerSession | null,
route: { readonly dungeonId: string; readonly routeId: string; readonly routeHash: string },
update: {
readonly status: DungeonRouteReviewStatus;
readonly expectedRevision: number;
readonly note?: string;
readonly criticalOverrideNote?: string;
},
): Promise<DungeonRouteReview> {
const response = await requestOnlineJson<{ review: DungeonRouteReview }>(
`/api/gm/dungeon-routes/${encodeURIComponent(route.dungeonId)}/${encodeURIComponent(route.routeId)}/review`,
{ method: "PUT", body: JSON.stringify({ ...update, routeHash: route.routeHash }) },
gmToken(session),
);
return response.review;
}
export async function fetchDungeonSimulationReplays(
session: PlayerSession | null,
dungeonId?: string,
): Promise<readonly DungeonReplaySummary[]> {
const query = dungeonId ? `?dungeon=${encodeURIComponent(dungeonId)}` : "";
return (await requestOnlineJson<{ replays: DungeonReplaySummary[] }>(
`/api/gm/dungeon-sim/replays${query}`,
{},
gmToken(session),
)).replays;
}
export async function saveDungeonSimulationReplay(
session: PlayerSession | null,
replay: DungeonReplayV1,
): Promise<DungeonReplaySummary> {
return (await requestOnlineJson<{ replay: DungeonReplaySummary }>(
"/api/gm/dungeon-sim/replays",
{ method: "POST", body: JSON.stringify(replay) },
gmToken(session),
)).replay;
}
export function fetchDungeonSimulationReplay(
session: PlayerSession | null,
replayId: string,
): Promise<DungeonReplayV1> {
return requestOnlineJson(`/api/gm/dungeon-sim/replays/${encodeURIComponent(replayId)}`, {}, gmToken(session));
}
export function fetchDungeonSimulationReport(
session: PlayerSession | null,
replayId: string,
): Promise<DungeonSimulationReportV1> {
return requestOnlineJson(`/api/gm/dungeon-sim/replays/${encodeURIComponent(replayId)}/report`, {}, gmToken(session));
}
export function deleteDungeonSimulationReplay(session: PlayerSession | null, replayId: string): Promise<{ readonly ok: true }> {
return requestOnlineJson(`/api/gm/dungeon-sim/replays/${encodeURIComponent(replayId)}`, { method: "DELETE" }, gmToken(session));
}
+71
View File
@@ -0,0 +1,71 @@
import { touchCharacter } from "./accountRepository";
import { useShellStore } from "./shellStore";
import { activateDungeonSession } from "../game/dungeonSession";
import { dungeonRouteDefinition } from "../game/dungeonRoutes";
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import type { DungeonReplayV1, DungeonSimulationMode } from "../game/dungeonSimulationTypes";
import { defaultPartyRoleForClass } from "../game/partyRoles";
import { useGameStore } from "../game/store";
function gmCharacter() {
const shell = useShellStore.getState();
if (shell.session?.kind !== "account" || !shell.session.accessToken || !shell.session.roles?.includes("gm")) {
shell.setNotice("Game Master access is required.");
return null;
}
const character = shell.selectedCharacterId
? touchCharacter(shell.session.ownerId, shell.selectedCharacterId)
?? shell.characters.find((candidate) => candidate.id === shell.selectedCharacterId)
: null;
if (!character) shell.setNotice("Select a character before launching a simulation.");
return character;
}
export function enterDungeonSimulation(
dungeonId: string,
mode: DungeonSimulationMode,
seed: string,
): boolean {
const character = gmCharacter();
const route = dungeonRouteDefinition(dungeonId);
if (!character || !route || !seed.trim()) return false;
if (!activateDungeonSession(dungeonId, undefined, {
gmTestMode: true,
dungeonSimulation: true,
})) {
useShellStore.getState().setNotice("The dungeon package is unavailable on this client.");
return false;
}
useDungeonSimulationStore.getState().startLive({ dungeonId, route, mode, seed: seed.trim() });
useGameStore.getState().setCompanionOpen(false);
useShellStore.setState({
phase: "game",
activeCharacter: character,
activeDungeonId: dungeonId,
activeDungeonRole: defaultPartyRoleForClass(character.classId),
notice: "",
});
return true;
}
export function enterDungeonSimulationReplay(replay: DungeonReplayV1): boolean {
const character = gmCharacter();
if (!character || !dungeonRouteDefinition(replay.dungeonId, replay.routeId)) return false;
if (!activateDungeonSession(replay.dungeonId, undefined, {
gmTestMode: true,
dungeonSimulation: true,
})) {
useShellStore.getState().setNotice("The replay dungeon package is unavailable on this client.");
return false;
}
useDungeonSimulationStore.getState().startReplay(replay);
useGameStore.getState().setCompanionOpen(false);
useShellStore.setState({
phase: "game",
activeCharacter: character,
activeDungeonId: replay.dungeonId,
activeDungeonRole: defaultPartyRoleForClass(character.classId),
notice: "",
});
return true;
}
+15 -1
View File
@@ -1,13 +1,15 @@
import { describe, expect, it } from "vitest";
import {
isShelvedShellPhase,
sessionCanUseGmTools,
sessionCanUseShelvedGmTools,
SHELVED_MODES_ENABLED,
} from "./releaseFeatures";
describe("release feature boundary", () => {
it("recognizes every legacy shelved phase", () => {
expect(["manastorms", "arenas", "arena-game", "gm-admin"].every(isShelvedShellPhase)).toBe(true);
expect(["manastorms", "arenas", "arena-game"].every(isShelvedShellPhase)).toBe(true);
expect(isShelvedShellPhase("gm-admin")).toBe(false);
expect(isShelvedShellPhase("main-menu")).toBe(false);
expect(isShelvedShellPhase("game")).toBe(false);
});
@@ -24,4 +26,16 @@ describe("release feature boundary", () => {
expect(sessionCanUseShelvedGmTools({ ...session, roles: [] })).toBe(false);
expect(sessionCanUseShelvedGmTools({ ...session, accessToken: "" })).toBe(false);
});
it("exposes dungeon GM tools in normal releases to authenticated GMs", () => {
const session = {
kind: "account",
ownerId: "gm-account",
displayName: "Phenom",
accessToken: "token",
roles: ["gm"],
} as const;
expect(sessionCanUseGmTools(session)).toBe(true);
expect(sessionCanUseGmTools({ ...session, roles: [] })).toBe(false);
});
});
+8 -1
View File
@@ -4,7 +4,6 @@ export const SHELVED_SHELL_PHASES = Object.freeze([
"manastorms",
"arenas",
"arena-game",
"gm-admin",
] as const);
export type ShelvedShellPhase = (typeof SHELVED_SHELL_PHASES)[number];
@@ -21,3 +20,11 @@ export function sessionCanUseShelvedGmTools(session: {
return SHELVED_MODES_ENABLED
&& Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
}
export function sessionCanUseGmTools(session: {
readonly kind: string;
readonly accessToken?: string;
readonly roles?: readonly string[];
} | null): boolean {
return Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
}
+2 -2
View File
@@ -59,7 +59,7 @@ import {
logoutOnlineSession,
refreshOnlineSession,
} from "./onlineAccountClient";
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "./releaseFeatures";
import { sessionCanUseGmTools, SHELVED_MODES_ENABLED } from "./releaseFeatures";
import { useGameStore } from "../game/store";
import {
normalizeSpecializationId,
@@ -366,7 +366,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
},
openGmAdmin: () => {
const { session, selectedCharacterId, characters } = get();
if (!sessionCanUseShelvedGmTools(session)) {
if (!sessionCanUseGmTools(session)) {
set({ notice: "Game Master access is required." });
return false;
}
+70 -36
View File
@@ -23,6 +23,10 @@ import {
onlineSessionOwnsWorldSimulation,
} from "./onlineSessionRuntime";
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
import {
DUNGEON_SIMULATION_FIXED_STEP_MS,
useDungeonSimulationStore,
} from "./dungeonSimulationStore";
/**
* Connects the pure combat runtime to the active character and persistent
@@ -75,7 +79,7 @@ export function CombatBridge() {
// after Character Select has already replaced the active character.
const shell = useShellStore.getState();
if (shell.activeCharacter?.id !== characterId || shell.session?.ownerId !== ownerId) return;
if (useGameStore.getState().bossTest) return;
if (useGameStore.getState().gmTestMode) return;
// Persisted collections are replaced immutably by combat actions. A
// reference/primitive comparison therefore detects the same changes as
@@ -121,54 +125,80 @@ export function CombatBridge() {
let previous = performance.now();
let timer: number | null = null;
const advanceOneTick = (delta: number, epochNow: number, spectator: boolean) => {
const game = useGameStore.getState();
const simulation = useDungeonSimulationStore.getState();
const combat = useCombatStore.getState();
combat.setPlayerPosition(game.playerPosition);
combat.tick(delta, epochNow);
if (
onlineSessionOwnsWorldSimulation()
&& game.gameMode === "dungeon"
&& (!spectator || simulation.mode === "full-combat")
) {
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
}
if (onlineSessionOwnsWorldSimulation()) {
if (!spectator || simulation.mode === "full-combat") {
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
}
advanceDungeonPartyCombat(epochNow);
}
useCombatStore.getState().synchronizeActors();
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
const mobs = useCombatStore.getState().mobs;
const combatEvents = deriveDungeonEncounterCombatEvents(
encounterMobCursor.current,
mobs,
epochNow,
);
encounterMobCursor.current = combatEvents.cursor;
for (const event of combatEvents.events) dispatchActiveDungeonEntityEvent(event);
const registeredEntityIds = Object.keys(mobs);
advanceDungeonEncounters(game.activeDungeonId, {
kind: "tick",
now: epochNow,
facts: {
registeredEntityIds,
deadEntityIds: registeredEntityIds.filter((id) => mobs[id].dead),
},
});
}
};
const advance = () => {
const now = performance.now();
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
const wallDeltaMs = Math.min(1_000, Math.max(0, now - previous));
const delta = Math.min(0.25, wallDeltaMs / 1_000);
previous = now;
const simulation = useDungeonSimulationStore.getState();
if (simulation.sessionMode === "spectate-replay") {
simulation.consumeTicks(wallDeltaMs);
return;
}
if (simulation.sessionMode === "spectate-live") {
const assetStatus = useGameStore.getState().assetStatus;
if (assetStatus !== "ready" && assetStatus !== "proxy") return;
for (const tickNow of simulation.consumeTicks(wallDeltaMs)) {
advanceOneTick(DUNGEON_SIMULATION_FIXED_STEP_MS / 1_000, tickNow, true);
}
return;
}
const game = useGameStore.getState();
const sharedOnline = onlineSessionIsActive();
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
const epochNow = Date.now();
const combat = useCombatStore.getState();
combat.setPlayerPosition(game.playerPosition);
combat.tick(delta, epochNow);
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
}
if (onlineSessionOwnsWorldSimulation()) {
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
advanceDungeonPartyCombat(epochNow);
}
useCombatStore.getState().synchronizeActors();
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
const mobs = useCombatStore.getState().mobs;
const combatEvents = deriveDungeonEncounterCombatEvents(
encounterMobCursor.current,
mobs,
epochNow,
);
encounterMobCursor.current = combatEvents.cursor;
for (const event of combatEvents.events) dispatchActiveDungeonEntityEvent(event);
const registeredEntityIds = Object.keys(mobs);
advanceDungeonEncounters(game.activeDungeonId, {
kind: "tick",
now: epochNow,
facts: {
registeredEntityIds,
deadEntityIds: registeredEntityIds.filter((id) => mobs[id].dead),
},
});
}
advanceOneTick(delta, Date.now(), false);
}
};
const reconcileTimer = () => {
const game = useGameStore.getState();
const simulation = useDungeonSimulationStore.getState();
const shouldRun = combatSimulationTimerShouldRun({
sharedOnline: onlineSessionIsActive(),
paused: game.paused,
companionOpen: game.companionOpen,
documentHidden: document.hidden,
paused: simulation.sessionMode === "play" ? game.paused : false,
companionOpen: simulation.sessionMode === "play" ? game.companionOpen : false,
documentHidden: simulation.sessionMode === "play" ? document.hidden : false,
});
if (shouldRun && timer === null) {
// Reset the clock on resume so a long pause/background period cannot
@@ -193,6 +223,9 @@ export function CombatBridge() {
// store notification gives the bridge a reactive point at which to
// reconcile while preserving continuous shared-online simulation.
const unsubscribeOnlineSession = useOnlineSessionStore.subscribe(reconcileTimer);
const unsubscribeSimulation = useDungeonSimulationStore.subscribe((state, previousState) => {
if (state.sessionMode !== previousState.sessionMode || state.runState !== previousState.runState) reconcileTimer();
});
document.addEventListener("visibilitychange", reconcileTimer);
reconcileTimer();
@@ -200,6 +233,7 @@ export function CombatBridge() {
if (timer !== null) window.clearInterval(timer);
unsubscribeGame();
unsubscribeOnlineSession();
unsubscribeSimulation();
document.removeEventListener("visibilitychange", reconcileTimer);
};
}, []);
+3 -2
View File
@@ -13,6 +13,7 @@ import type { DungeonEncounterEffectEnvelope } from "./dungeonEncounterTypes";
import { publishOnlinePartyTeleport } from "./onlineSessionRuntime";
import { dungeonDefinitionById } from "./dungeonRegistry";
import { removeMobPosition } from "./mobRuntimeRegistry";
import { dungeonSimulationNow } from "./dungeonSimulationStore";
function grantEncounterLoot(lootTableId: string, now: number): boolean {
const table = LOOT_CATALOG.bosses.find((candidate) => candidate.lootTableId === lootTableId);
@@ -29,14 +30,14 @@ function grantEncounterLoot(lootTableId: string, now: number): boolean {
/** Applies platform effects emitted by the renderer-neutral encounter reducer. */
export function DungeonEncounterEffectBridge() {
useEffect(() => registerDungeonEncounterEffectHandler((envelope) => {
applyDungeonEncounterEffect(envelope, Date.now());
applyDungeonEncounterEffect(envelope, dungeonSimulationNow());
}), []);
return null;
}
export function applyDungeonEncounterEffect(
{ dungeonId, effect }: DungeonEncounterEffectEnvelope,
now = Date.now(),
now = dungeonSimulationNow(),
): void {
if (effect.kind === "signal") {
dispatchDungeonEncounterSignal(effect.signalId, now);
+16 -9
View File
@@ -6,6 +6,7 @@ import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import { useManastormStore } from "./manastormStore";
import { onlineGroupAiPartySize, useOnlineGroupStore } from "../app/onlineGroupStore";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
export function PartyBridge() {
@@ -15,6 +16,8 @@ export function PartyBridge() {
const manastormPartySize = useManastormStore((state) => state.partySize);
const onlineActivity = useOnlineGroupStore((state) => state.activeActivity);
const level = useCombatStore((state) => state.level);
const simulationMode = useDungeonSimulationStore((state) => state.sessionMode);
const simulationSeed = useDungeonSimulationStore((state) => state.seed);
const resetRevision = useGameStore((state) => state.resetRevision);
const mountedResetRevision = useRef(resetRevision);
@@ -27,19 +30,23 @@ export function PartyBridge() {
&& onlineActivity.type === (gameMode === "manastorm" ? "manastorm" : "dungeon")
? onlineGroupAiPartySize(onlineActivity.partySize)
: requestedPartySize;
party.initializeParty(
character.id,
combat.level,
combat.health,
playerRole,
aiPartySize,
character.categoryId ?? "wow",
);
if (simulationMode === "play") {
party.initializeParty(
character.id,
combat.level,
combat.health,
playerRole,
aiPartySize,
character.categoryId ?? "wow",
);
} else {
party.initializeSpectatorParty(simulationSeed, combat.level, character.categoryId ?? "wow");
}
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
combat.synchronizeActors();
combat.applyDefaultPartyBuffs();
resetPartyRuntimeClock();
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole, simulationMode, simulationSeed]);
useEffect(() => {
const party = usePartyStore.getState();
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { createConfiguredPartyMember } from "../partyStore";
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
import {
loadSelectableBenchmarkBuilds,
selectableBenchmarkBuildCatalog,
} from "./benchmarkBuilds";
describe("balance-lab selectable builds", () => {
it("lists every authored WoW specialization instead of the representative cohort", () => {
const catalog = selectableBenchmarkBuildCatalog(60, [
"warrior", "paladin", "hunter", "rogue", "priest", "death-knight",
"shaman", "mage", "warlock", "druid",
]);
expect(catalog).toHaveLength(31);
expect(catalog).toEqual(expect.arrayContaining([
expect.objectContaining({ classId: "hunter", orientation: "Beast Mastery", role: "damage" }),
expect.objectContaining({ classId: "priest", orientation: "Discipline", role: "healer" }),
expect.objectContaining({ classId: "warlock", orientation: "Destruction", role: "damage" }),
expect.objectContaining({ classId: "druid", orientation: "Feral (Bear)", role: "tank" }),
]));
expect(new Set(catalog.map((build) => build.id)).size).toBe(catalog.length);
});
it("legally allocates a previously omitted specialization", async () => {
const builds = await loadSelectableBenchmarkBuilds(20, ["hunter"]);
const beastMastery = builds.find((build) => build.orientation === "Beast Mastery");
expect(beastMastery?.role).toBe("damage");
expect(Object.keys(beastMastery?.talentRanks ?? {})).not.toHaveLength(0);
});
it("keeps each CoA specialization tree selectable", async () => {
const builds = await loadSelectableBenchmarkBuilds(20, ["witch-doctor"]);
expect(builds.map((build) => `${build.orientation}:${build.role}`)).toEqual([
"Shadowhunting:damage",
"Voodoo:damage",
"Brewing:healer",
]);
expect(builds.every((build) => Object.keys(build.talentRanks).length > 0)).toBe(true);
});
it("constructs a legal build for every selectable WoW, CoA, and RoM entry", async () => {
const catalog = selectableBenchmarkBuildCatalog(20);
const builds = await loadSelectableBenchmarkBuilds(20);
expect(builds.map((build) => build.id)).toEqual(catalog.map((build) => build.id));
for (const build of builds) {
expect(runtimeNormalizeTalentRanks(build.classId, build.level, build.talentRanks))
.toEqual(build.talentRanks);
}
}, 60_000);
it("constructs an exact companion without replacing selected talents", () => {
const talents = Object.freeze({ "hunter-beast-mastery-0": 3 });
const member = createConfiguredPartyMember({
name: "Balance Hunter",
classId: "hunter",
specialization: "Beast Mastery",
specializationId: "hunter:beast-mastery",
talentTreeId: "hunter-beast-mastery",
talentRanks: talents,
role: "damage",
}, 20, 2, "balance-test");
expect(member.name).toBe("Balance Hunter");
expect(member.specialization).toBe("Beast Mastery");
expect(member.specializationId).toBe("hunter:beast-mastery");
expect(member.talentRanks).toBe(talents);
expect(member.id).toContain("party-damage-2-hunter");
});
});
+206
View File
@@ -3,6 +3,7 @@ import {
CLASSES,
COA_CLASS_IDS,
ROM_CLASS_IDS,
classById,
contentCategoryForClass,
type BaseClassId,
type ClassId,
@@ -20,6 +21,10 @@ import type { EquipmentAssignments } from "../equipment";
import { partyRolesForClass, type PartyRole } from "../partyRoles";
import { runtimePartyTalentRanksForLevel } from "../partyTalentRuntime";
import { clampPlayerLevel } from "../progression";
import {
specializationsForClass,
type SpecializationDefinition,
} from "../specializationCatalog";
import {
runtimeAllocateTalentPoint,
runtimeNormalizeTalentRanks,
@@ -153,6 +158,16 @@ function benchmarkBuildId(classId: ClassId, role: PartyRole, level: number): str
return `canonical-${contentCategoryForClass(classId)}-${classId}-${role}-level-${level}`;
}
function benchmarkVariantBuildId(
classId: ClassId,
role: PartyRole,
orientation: string,
level: number,
): string {
const variant = orientation.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
return `balance-${contentCategoryForClass(classId)}-${classId}-${role}-${variant}-level-${level}`;
}
function commonBuildFields(classId: ClassId, role: PartyRole, level: number) {
return {
id: benchmarkBuildId(classId, role, level),
@@ -365,6 +380,197 @@ function romBuild(
});
}
function specializationTree(
catalog: TalentUiCatalog,
specialization: SpecializationDefinition,
): TalentUiTree {
const normalized = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, "");
const names = new Set([
normalized(specialization.talentTree),
normalized(specialization.displayName),
]);
const tree = catalog.trees.find((candidate) => names.has(normalized(candidate.name)));
if (!tree) {
throw new Error(
`No talent tree matches ${classById(specialization.classId).name} ${specialization.displayName}.`,
);
}
return tree;
}
function allocateSingleTreeTalentRanks(
classId: BaseClassId,
catalog: TalentUiCatalog,
tree: TalentUiTree,
level: number,
): RuntimeTalentRanks {
const candidates = catalog.nodes
.filter((node) => node.treeId === tree.id)
.sort((left, right) => (
left.row - right.row
|| left.column - right.column
|| left.index - right.index
|| left.id.localeCompare(right.id)
));
const successfulRequests: Record<string, number> = {};
let ranks: RuntimeTalentRanks = EMPTY_TALENT_RANKS;
while (true) {
let allocated = false;
for (const node of candidates) {
if ((successfulRequests[node.id] ?? 0) >= node.maxRank) continue;
const result = runtimeAllocateTalentPoint(ranks, classId, node.id, level);
if (!result.ok) continue;
successfulRequests[node.id] = (successfulRequests[node.id] ?? 0) + 1;
ranks = result.ranks;
allocated = true;
break;
}
if (!allocated) break;
}
return Object.freeze({ ...runtimeNormalizeTalentRanks(classId, level, ranks) });
}
function selectableClassicBuild(
specialization: SpecializationDefinition,
level: number,
catalog: TalentUiCatalog | null,
): BenchmarkBuildDefinition {
const classId = specialization.classId as BaseClassId;
let talentRanks: RuntimeTalentRanks;
try {
talentRanks = runtimePartyTalentRanksForLevel(classId, specialization.displayName, level);
} catch {
if (!catalog) throw new Error(`No talent catalog was loaded for ${classId}.`);
talentRanks = allocateSingleTreeTalentRanks(
classId,
catalog,
specializationTree(catalog, specialization),
level,
);
}
return Object.freeze({
...commonBuildFields(classId, specialization.role, level),
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, level),
orientation: specialization.displayName,
talentRanks,
});
}
function selectableCoaBuild(
specialization: SpecializationDefinition,
level: number,
catalog: TalentUiCatalog,
): BenchmarkBuildDefinition {
const classId = specialization.classId as CoaClassId;
const tree = specializationTree(catalog, specialization);
const abilitiesByNode = coaNodeAbilities(classId);
return Object.freeze({
...commonBuildFields(classId, specialization.role, level),
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, level),
orientation: specialization.displayName,
talentRanks: allocateCoaTalentRanks(
classId,
catalog,
tree,
specialization.role,
level,
abilitiesByNode,
),
});
}
export interface SelectableBenchmarkBuildCatalogEntry {
readonly id: string;
readonly source: ReturnType<typeof contentCategoryForClass>;
readonly classId: ClassId;
readonly role: PartyRole;
readonly orientation: string;
readonly level: number;
}
/** Cheap metadata-only roster used by interactive tools before a build is selected. */
export function selectableBenchmarkBuildCatalog(
level = CANONICAL_BENCHMARK_LEVEL,
classIds?: readonly ClassId[],
): readonly SelectableBenchmarkBuildCatalogEntry[] {
const safeLevel = clampPlayerLevel(level);
const requested = classIds ? new Set(classIds) : null;
return CLASSES
.map((definition) => definition.id)
.filter((classId) => !requested || requested.has(classId))
.flatMap((classId): readonly SelectableBenchmarkBuildCatalogEntry[] => {
if (isRomClassId(classId)) return partyRolesForClass(classId).map((role) => ({
id: benchmarkBuildId(classId, role, safeLevel),
source: contentCategoryForClass(classId),
classId,
role,
orientation: "Primary",
level: safeLevel,
}));
return specializationsForClass(classId).map((specialization) => ({
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, safeLevel),
source: contentCategoryForClass(classId),
classId,
role: specialization.role,
orientation: specialization.displayName,
level: safeLevel,
}));
});
}
/**
* Builds every user-selectable class/spec/role variant for the balance lab.
* This is intentionally broader than the canonical benchmark cohort above,
* which keeps one representative orientation per class-role pair.
*/
export async function loadSelectableBenchmarkBuilds(
level = CANONICAL_BENCHMARK_LEVEL,
classIds?: readonly ClassId[],
): Promise<BenchmarkBuildDefinition[]> {
const safeLevel = clampPlayerLevel(level);
const requested = classIds ? new Set(classIds) : null;
const selectedClassIds = CLASSES
.map((definition) => definition.id)
.filter((classId) => !requested || requested.has(classId));
const catalogClassIds = selectedClassIds.filter((classId) => (
isCoaClassId(classId)
|| (isBaseClassId(classId) && specializationsForClass(classId).some((specialization) => {
try {
runtimePartyTalentRanksForLevel(classId, specialization.displayName, safeLevel);
return false;
} catch {
return true;
}
}))
));
await preloadAbilityCatalogsForClasses(selectedClassIds);
const catalogs = new Map<ClassId, TalentUiCatalog>(await Promise.all(
catalogClassIds.map(async (classId) => [
classId,
await loadTalentUiCatalog(classId),
] as const),
));
const builds: BenchmarkBuildDefinition[] = [];
for (const classId of selectedClassIds) {
if (isRomClassId(classId)) {
for (const role of partyRolesForClass(classId)) builds.push(romBuild(classId, role, safeLevel));
continue;
}
for (const specialization of specializationsForClass(classId)) {
if (isBaseClassId(classId)) {
builds.push(selectableClassicBuild(specialization, safeLevel, catalogs.get(classId) ?? null));
} else if (isCoaClassId(classId)) {
const catalog = catalogs.get(classId);
if (!catalog) throw new Error(`No loaded CoA talent catalog exists for ${classId}.`);
builds.push(selectableCoaBuild(specialization, safeLevel, catalog));
}
}
}
return builds;
}
/**
* Creates one stable, legal benchmark build for every declared class-role pair.
* A filter changes only membership; output always follows the canonical CLASSES
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { estimateBossPartyPressure, rankBossPartyPressure } from "./bossPressure";
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
describe("boss party-pressure audit", () => {
it("produces a finite deterministic ranking for every WoW boss", () => {
const definitions = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
const first = rankBossPartyPressure(definitions);
const second = rankBossPartyPressure(definitions);
expect(first.length).toBeGreaterThan(200);
expect(second).toEqual(first);
expect(first.every((entry) => Number.isFinite(entry.partyDamagePerSecond)
&& entry.partyDamagePerSecond >= 0)).toBe(true);
expect(first.map((entry) => entry.partyDamage)).toEqual(
[...first.map((entry) => entry.partyDamage)].sort((left, right) => right - left),
);
});
it("counts grouped effects across the party and excludes self-only utility", () => {
const gundrak = DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === "gundrak")!;
const eckId = Object.entries(gundrak.entities)
.find(([, entity]) => entity.combat?.serverEntry === 29_932)![0];
const estimate = estimateBossPartyPressure(gundrak, eckId);
expect(estimate.boss).toBe("Eck the Ferocious");
expect(estimate.attacks.find((attack) => attack.spellId === 55_837)?.partyDamage).toBe(0);
expect(estimate.attacks.find((attack) => attack.spellId === 55_816)?.partyDamage).toBe(0);
expect(estimate.attacks.find((attack) => attack.spellId === 55_815)).toMatchObject({
target: "random-party",
targetsPerCast: 1,
});
});
});
+151
View File
@@ -0,0 +1,151 @@
import { creatureRuntimeBaseStats } from "./azerothCoreReference";
import { scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
import type { DungeonDefinition } from "./dungeonTypes";
import type {
EnemyAbilityEffect,
EnemyAttackDefinition,
EnemyAtomicAbilityEffect,
} from "./enemyAttacks";
import type { PopulationEntityDefinition } from "./mobPopulation";
export const BOSS_PRESSURE_WINDOW_MS = 60_000;
export const BOSS_PRESSURE_PARTY_SIZE = 5;
export interface BossAttackPressureEstimate {
readonly id: string;
readonly name: string;
readonly spellId?: number;
readonly target: EnemyAttackDefinition["target"];
readonly school: EnemyAttackDefinition["school"];
readonly expectedCasts: number;
readonly targetsPerCast: number;
readonly damagePerTarget: number;
readonly partyDamage: number;
}
export interface BossPartyPressureEstimate {
readonly key: string;
readonly dungeonId: string;
readonly dungeonTitle: string;
readonly boss: string;
readonly serverEntry: number | null;
readonly playerLevel: number;
readonly enemyLevel: number;
readonly windowSeconds: number;
readonly attackDamage: number;
readonly partyDamage: number;
readonly partyDamagePerSecond: number;
readonly tankDamage: number;
readonly randomTargetDamage: number;
readonly groupwideDamage: number;
readonly attacks: readonly BossAttackPressureEstimate[];
}
function midpoint(range: readonly [number, number] | undefined, fallback: number): number {
if (!range) return fallback;
return (Math.max(0, range[0]) + Math.max(0, range[1])) / 2;
}
function expectedCasts(attack: EnemyAttackDefinition, windowMs: number): number {
const initial = midpoint(attack.initialCooldownMs, 450);
if (windowMs < initial) return 0;
const repeat = Math.max(1, midpoint(attack.repeatCooldownMs, attack.cooldownMs));
const casts = 1 + (windowMs - initial) / repeat;
return Math.min(attack.maximumCasts ?? Number.POSITIVE_INFINITY, casts);
}
function atomicDamageMultiplier(effect: EnemyAtomicAbilityEffect): number {
if (effect.kind === "damage") return Math.max(0, effect.multiplier);
if (effect.kind === "leech") return Math.max(0, effect.damageMultiplier);
if (effect.kind === "control") return Math.max(0, effect.damageMultiplier ?? 0);
return 0;
}
function damageMultiplier(attack: EnemyAttackDefinition, effect: EnemyAbilityEffect | undefined): number {
if (!effect) return Math.max(0, attack.damageMultiplier);
if (effect.kind === "composite") {
return effect.effects.reduce((total, entry) => total + atomicDamageMultiplier(entry), 0);
}
return atomicDamageMultiplier(effect);
}
function targetsPerCast(attack: EnemyAttackDefinition, partySize: number): number {
if (attack.target === "nearby-party") return partySize;
if (attack.target === "primary" || attack.target === "random-party") return 1;
return 0;
}
/**
* Comparable direct-damage ceiling for one boss with a grouped party. It uses
* live attack definitions and source creature stats, but deliberately excludes
* armor/resistance, avoidable movement, summons, healing, and mutually exclusive
* phase uptime. Those omissions keep the audit deterministic and make inflated
* schedules or accidental party-wide targeting obvious.
*/
export function estimateBossPartyPressure(
dungeon: DungeonDefinition,
entityId: string,
playerLevel = 80,
windowMs = BOSS_PRESSURE_WINDOW_MS,
partySize = BOSS_PRESSURE_PARTY_SIZE,
): BossPartyPressureEstimate {
const entity: PopulationEntityDefinition | undefined = dungeon.entities[entityId];
if (!entity || entity.kind !== "boss") throw new Error(`${dungeon.id}:${entityId} is not a boss`);
const enemyLevel = scaledDungeonEnemyLevel(playerLevel, true);
const serverEntry = entity.combat?.serverEntry ?? null;
const baseDamage = serverEntry
? creatureRuntimeBaseStats(serverEntry, enemyLevel)?.averageAttackDamage
: undefined;
const attackDamage = (baseDamage ?? (12 + enemyLevel * 2)) * (entity.combat?.damageMultiplier ?? 1);
const attacks = (entity.combat?.attacks ?? []).map((attack): BossAttackPressureEstimate => {
const casts = expectedCasts(attack, windowMs);
const targets = targetsPerCast(attack, partySize);
const perTarget = attackDamage * damageMultiplier(attack, attack.effect);
return {
id: attack.id,
name: attack.name,
...(attack.spellId === undefined ? {} : { spellId: attack.spellId }),
target: attack.target,
school: attack.school,
expectedCasts: casts,
targetsPerCast: targets,
damagePerTarget: perTarget,
partyDamage: casts * targets * perTarget,
};
});
const sum = (target: EnemyAttackDefinition["target"]): number => attacks
.filter((attack) => attack.target === target)
.reduce((total, attack) => total + attack.partyDamage, 0);
const partyDamage = attacks.reduce((total, attack) => total + attack.partyDamage, 0);
return {
key: `${dungeon.id}/${entityId}`,
dungeonId: dungeon.id,
dungeonTitle: dungeon.title,
boss: entity.name,
serverEntry,
playerLevel,
enemyLevel,
windowSeconds: windowMs / 1_000,
attackDamage,
partyDamage,
partyDamagePerSecond: partyDamage / Math.max(1, windowMs / 1_000),
tankDamage: sum("primary"),
randomTargetDamage: sum("random-party"),
groupwideDamage: sum("nearby-party"),
attacks,
};
}
export function rankBossPartyPressure(
definitions: readonly DungeonDefinition[],
playerLevel = 80,
windowMs = BOSS_PRESSURE_WINDOW_MS,
partySize = BOSS_PRESSURE_PARTY_SIZE,
): readonly BossPartyPressureEstimate[] {
return definitions.flatMap((dungeon) => Object.entries(dungeon.entities)
.filter(([, entity]) => entity.kind === "boss")
.map(([entityId]) => estimateBossPartyPressure(dungeon, entityId, playerLevel, windowMs, partySize)))
.sort((left, right) => right.partyDamage - left.partyDamage
|| left.boss.localeCompare(right.boss)
|| left.dungeonId.localeCompare(right.dungeonId));
}
+3 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
import { buildBossTestingCatalog, BOSS_TESTING_CATALOG, createBossTestingRoom, randomTestingBoss } from "./bossTesting";
import { anotherRandomTestingBoss, buildBossTestingCatalog, BOSS_TESTING_CATALOG, createBossTestingRoom, randomTestingBoss } from "./bossTesting";
import { clearBossTestingDungeons, DUNGEON_DEFINITIONS, registerBossTestingDungeon, requireDungeonDefinition } from "./dungeonRegistry";
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
import { useGameStore } from "./store";
@@ -138,5 +138,7 @@ describe("Boss Testing", () => {
expect(randomTestingBoss(candidates, () => 0)).toBe(candidates[0]);
expect(randomTestingBoss(candidates, () => 1)).toBe(candidates[1]);
expect(randomTestingBoss(candidates, () => Number.NaN)).toBe(candidates[0]);
expect(anotherRandomTestingBoss(candidates, candidates[0].key, () => 0)).toBe(candidates[1]);
expect(anotherRandomTestingBoss([candidates[0]], candidates[0].key)).toBeNull();
});
});
+8
View File
@@ -42,6 +42,14 @@ export function randomTestingBoss(bosses: readonly TestableBoss[], random: () =>
return bosses[index];
}
export function anotherRandomTestingBoss(
bosses: readonly TestableBoss[],
currentKey: string,
random: () => number = Math.random,
): TestableBoss | null {
return randomTestingBoss(bosses.filter((boss) => boss.key !== currentKey), random);
}
function references(value: unknown, ids: ReadonlySet<string>): boolean {
if (typeof value === "string") return ids.has(value);
if (Array.isArray(value)) return value.some((entry) => references(entry, ids));
+10 -5
View File
@@ -1,5 +1,6 @@
import { create } from "zustand";
import { combatNow } from "./combatClock";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { useGameStore } from "./store";
import {
baseClassFor,
@@ -2633,7 +2634,7 @@ function damageMobInWork(
const health = mob.health - applied;
const killed = health <= 0;
const canPlayWoundAnimation = !killed && now >= mob.nextWoundAnimationAt;
const testing = Boolean(useGameStore.getState().bossTest);
const testing = useGameStore.getState().gmTestMode;
const canAward = killed && !mob.experienceGranted && !testing;
const beforeLevel = work.progression.level;
const pendingLoot = testing ? [] : killed && mob.lootSourceId
@@ -3365,7 +3366,7 @@ function combatActorSnapshots(state: CombatState): Readonly<Record<string, Comba
state.lastResourceSpentAt[id as ResourceType] ?? 0,
)]),
);
actors[PLAYER_AURA_ENTITY_ID] = {
if (useDungeonSimulationStore.getState().sessionMode === "play") actors[PLAYER_AURA_ENTITY_ID] = {
id: PLAYER_AURA_ENTITY_ID,
threatActorId: PLAYER_AURA_ENTITY_ID,
kind: "player",
@@ -4168,12 +4169,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
get().synchronizeActors();
const state = get();
const partyMembers = usePartyStore.getState().members;
const includePlayer = useDungeonSimulationStore.getState().sessionMode === "play";
const sources = [
{
...(includePlayer ? [{
id: PLAYER_AURA_ENTITY_ID,
level: state.level,
abilities: state.abilities,
},
}] : []),
...partyMembers.filter((member) => member.health > 0).map((member) => ({
id: member.id,
level: member.level,
@@ -4200,7 +4202,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
selected.set(family, { sourceId: source.id, ability, effects, potency });
}
}
const recipients = [PLAYER_AURA_ENTITY_ID, ...partyMembers.filter((member) => member.health > 0).map((member) => member.id)];
const recipients = [
...(includePlayer ? [PLAYER_AURA_ENTITY_ID] : []),
...partyMembers.filter((member) => member.health > 0).map((member) => member.id),
];
for (const [family, buff] of selected) {
for (const recipientId of recipients) {
for (const effect of buff.effects) {
+143
View File
@@ -0,0 +1,143 @@
import { dispatchDungeonWorldObjectInteraction } from "./dungeonWorldInteraction";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { useDungeonRouteStore } from "./dungeonRouteStore";
import {
dungeonRouteDefinition,
dungeonRouteLeg,
} from "./dungeonRoutes";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import type { DungeonRouteDefinitionV1, DungeonRouteLeg } from "./dungeonSimulationTypes";
import type { Vector3Tuple } from "./dungeonTypes";
export interface ActiveDungeonRouteTarget {
readonly route: DungeonRouteDefinitionV1;
readonly leg: DungeonRouteLeg;
readonly checkpointIndex: number;
readonly target: Vector3Tuple;
readonly remaining: readonly Vector3Tuple[];
}
let activeDungeonId: string | null = null;
let activeObjectiveId: string | null = null;
let checkpointIndex = 0;
const completedActions = new Set<string>();
function distance(left: Vector3Tuple, right: Vector3Tuple): number {
return Math.hypot(right[0] - left[0], right[1] - left[1], right[2] - left[2]);
}
export function resetDungeonRouteRuntime(): void {
activeDungeonId = null;
activeObjectiveId = null;
checkpointIndex = 0;
completedActions.clear();
}
export function routeForActiveDungeon(dungeonId: string): DungeonRouteDefinitionV1 | null {
const route = dungeonRouteDefinition(dungeonId);
if (!route) return null;
const simulation = useDungeonSimulationStore.getState();
if (simulation.sessionMode === "spectate-live") return route;
return useDungeonRouteStore.getState().isApproved(route) ? route : null;
}
function executeReachedActions(
dungeonId: string,
leg: DungeonRouteLeg,
reachedIndex: number,
actorId: string | undefined,
now: number,
): void {
const definition = requireDungeonDefinition(dungeonId);
for (const action of leg.actions) {
if (action.checkpointIndex > reachedIndex || completedActions.has(action.id)) continue;
const object = definition.serverGameObjects.find((candidate) => candidate.id === action.objectId);
if (!object?.interaction) {
useDungeonSimulationStore.getState().addFinding({
id: `invalid-action:${leg.id}:${action.id}`,
atMs: useDungeonSimulationStore.getState().elapsedMs,
severity: "critical",
code: "invalid-required-interaction",
message: `Route action ${action.id} references unavailable object ${action.objectId}.`,
objectiveId: leg.objectiveId,
checkpointIndex: action.checkpointIndex,
});
completedActions.add(action.id);
continue;
}
dispatchDungeonWorldObjectInteraction(object, actorId, now);
if (useDungeonSimulationStore.getState().sessionMode === "spectate-live") {
useDungeonSimulationStore.getState().addFinding({
id: `route-action:${leg.id}:${action.id}`,
atMs: useDungeonSimulationStore.getState().elapsedMs,
severity: "info",
code: "route-action",
message: `Performed required route action ${action.id}.`,
objectiveId: leg.objectiveId,
checkpointIndex: action.checkpointIndex,
actionId: action.id,
});
}
completedActions.add(action.id);
}
}
function advanceNavigationOnlyObjective(route: DungeonRouteDefinitionV1, leg: DungeonRouteLeg): void {
const simulation = useDungeonSimulationStore.getState();
if (simulation.sessionMode !== "spectate-live" || simulation.mode !== "navigation-only") return;
const index = route.legs.findIndex((candidate) => candidate.id === leg.id);
const next = route.legs[index + 1] ?? null;
simulation.setRouteProgress(next?.objectiveId ?? null, 0);
activeObjectiveId = next?.objectiveId ?? null;
checkpointIndex = 0;
}
export function activeDungeonRouteTarget(
dungeonId: string,
objectiveId: string | null,
position: Vector3Tuple,
actorId: string | undefined,
now: number,
): ActiveDungeonRouteTarget | null {
const route = routeForActiveDungeon(dungeonId);
const simulation = useDungeonSimulationStore.getState();
const routedObjectiveId = simulation.sessionMode === "spectate-live" && simulation.mode === "navigation-only"
? simulation.objectiveId
: objectiveId;
const leg = dungeonRouteLeg(route, routedObjectiveId);
if (!route || !leg || !leg.checkpoints.length) return null;
if (activeDungeonId !== dungeonId || activeObjectiveId !== leg.objectiveId) {
activeDungeonId = dungeonId;
activeObjectiveId = leg.objectiveId;
checkpointIndex = 0;
}
while (
checkpointIndex < leg.checkpoints.length
&& distance(position, leg.checkpoints[checkpointIndex]) <= 1.25
) {
executeReachedActions(dungeonId, leg, checkpointIndex, actorId, now);
checkpointIndex += 1;
}
if (checkpointIndex >= leg.checkpoints.length) {
advanceNavigationOnlyObjective(route, leg);
return null;
}
if (simulation.sessionMode === "spectate-live") {
simulation.setRouteProgress(leg.objectiveId, checkpointIndex);
}
return {
route,
leg,
checkpointIndex,
target: leg.checkpoints[checkpointIndex],
remaining: leg.checkpoints.slice(checkpointIndex),
};
}
export function activeDungeonRouteProgress(): {
readonly dungeonId: string | null;
readonly objectiveId: string | null;
readonly checkpointIndex: number;
} {
return { dungeonId: activeDungeonId, objectiveId: activeObjectiveId, checkpointIndex };
}
+35
View File
@@ -0,0 +1,35 @@
import { create } from "zustand";
import { fetchDungeonRouteApprovals } from "../app/dungeonSimulationClient";
import type { DungeonRouteApproval, DungeonRouteDefinitionV1 } from "./dungeonSimulationTypes";
import {
cacheDungeonRouteApprovals,
readCachedDungeonRouteApprovals,
routeHasExactApproval,
} from "./dungeonRoutes";
export interface DungeonRouteState {
approvals: readonly DungeonRouteApproval[];
loading: boolean;
refreshedAt: number | null;
refresh: () => Promise<void>;
isApproved: (route: DungeonRouteDefinitionV1 | null) => boolean;
}
export const useDungeonRouteStore = create<DungeonRouteState>((set, get) => ({
approvals: readCachedDungeonRouteApprovals(),
loading: false,
refreshedAt: null,
refresh: async () => {
if (get().loading) return;
set({ loading: true });
try {
const approvals = await fetchDungeonRouteApprovals();
cacheDungeonRouteApprovals(approvals);
set({ approvals, refreshedAt: Date.now(), loading: false });
} catch {
// An exact-hash cached approval is intentionally usable during outages.
set({ loading: false });
}
},
isApproved: (route) => routeHasExactApproval(route, get().approvals),
}));
+108
View File
@@ -0,0 +1,108 @@
import rawCatalog from "./generated/dungeonRouteCatalog.generated.json";
import type {
DungeonRouteApproval,
DungeonRouteCatalogV1,
DungeonRouteDefinitionV1,
DungeonRouteLeg,
} from "./dungeonSimulationTypes";
import type { Vector3Tuple } from "./dungeonTypes";
export const DUNGEON_ROUTE_CATALOG = rawCatalog as unknown as DungeonRouteCatalogV1;
export const DUNGEON_ROUTE_APPROVAL_CACHE_KEY = "healer-man.dungeon-route-approvals.v1";
const routesByDungeon = new Map(
DUNGEON_ROUTE_CATALOG.routes.map((route) => [route.dungeonId, route] as const),
);
export function dungeonRouteDefinition(dungeonId: string, routeId = "primary"): DungeonRouteDefinitionV1 | null {
const route = routesByDungeon.get(dungeonId) ?? null;
return route?.id === routeId ? route : null;
}
export function dungeonRouteLeg(
route: DungeonRouteDefinitionV1 | null,
objectiveId: string | null,
): DungeonRouteLeg | null {
if (!route || !objectiveId) return null;
return route.legs.find((leg) => leg.objectiveId === objectiveId) ?? null;
}
export function routeDistance(points: readonly Vector3Tuple[]): number {
let total = 0;
for (let index = 1; index < points.length; index += 1) {
total += Math.hypot(
points[index][0] - points[index - 1][0],
points[index][1] - points[index - 1][1],
points[index][2] - points[index - 1][2],
);
}
return total;
}
export function routePointDistanceSquared(point: Vector3Tuple, start: Vector3Tuple, end: Vector3Tuple): {
readonly planarSquared: number;
readonly vertical: number;
readonly progress: number;
} {
const dx = end[0] - start[0];
const dz = end[2] - start[2];
const lengthSquared = dx * dx + dz * dz;
const progress = lengthSquared > 1e-9
? Math.max(0, Math.min(1, ((point[0] - start[0]) * dx + (point[2] - start[2]) * dz) / lengthSquared))
: 0;
const projectedX = start[0] + dx * progress;
const projectedY = start[1] + (end[1] - start[1]) * progress;
const projectedZ = start[2] + dz * progress;
return {
planarSquared: (point[0] - projectedX) ** 2 + (point[2] - projectedZ) ** 2,
vertical: Math.abs(point[1] - projectedY),
progress,
};
}
export function positionAlongRouteCorridor(
point: Vector3Tuple,
checkpoints: readonly Vector3Tuple[],
planarLimit = 10,
verticalLimit = 4,
): boolean {
for (let index = 1; index < checkpoints.length; index += 1) {
const distance = routePointDistanceSquared(point, checkpoints[index - 1], checkpoints[index]);
if (distance.planarSquared <= planarLimit ** 2 && distance.vertical <= verticalLimit) return true;
}
return checkpoints.length === 1
&& Math.hypot(point[0] - checkpoints[0][0], point[2] - checkpoints[0][2]) <= planarLimit
&& Math.abs(point[1] - checkpoints[0][1]) <= verticalLimit;
}
export function readCachedDungeonRouteApprovals(): readonly DungeonRouteApproval[] {
if (typeof localStorage === "undefined") return [];
try {
const parsed = JSON.parse(localStorage.getItem(DUNGEON_ROUTE_APPROVAL_CACHE_KEY) ?? "null");
if (!Array.isArray(parsed?.approvals)) return [];
return parsed.approvals.filter((approval: unknown): approval is DungeonRouteApproval => {
if (!approval || typeof approval !== "object") return false;
const value = approval as Partial<DungeonRouteApproval>;
return typeof value.dungeonId === "string" && typeof value.routeId === "string"
&& /^[a-f0-9]{64}$/.test(value.routeHash ?? "") && Number.isFinite(value.reviewedAt);
});
} catch {
return [];
}
}
export function cacheDungeonRouteApprovals(approvals: readonly DungeonRouteApproval[]): void {
if (typeof localStorage === "undefined") return;
localStorage.setItem(DUNGEON_ROUTE_APPROVAL_CACHE_KEY, JSON.stringify({ approvals }));
}
export function routeHasExactApproval(
route: DungeonRouteDefinitionV1 | null,
approvals: readonly DungeonRouteApproval[],
): boolean {
return Boolean(route?.generationStatus === "Candidate" && approvals.some((approval) => (
approval.dungeonId === route.dungeonId
&& approval.routeId === route.id
&& approval.routeHash === route.hash
)));
}
+9 -1
View File
@@ -21,12 +21,16 @@ import { dungeonContentIsUsable } from "../content/contentManager";
import { useDungeonRunStore } from "./dungeonRunStore";
import type { DungeonRunProgress } from "./dungeonRunTypes";
import { validBossDifficulty } from "./dungeonDifficulty";
import { resetDungeonRouteRuntime } from "./dungeonRouteRuntime";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { dungeonRouteDefinition } from "./dungeonRoutes";
export interface DungeonSessionOptions {
readonly bossDifficulty?: number;
readonly gameMode?: GameMode;
readonly spawn?: WorldSpawn;
readonly gmTestMode?: boolean;
readonly dungeonSimulation?: boolean;
readonly bossTest?: BossTestSession | null;
readonly gmReviewTarget?: GmReviewTarget | null;
/** Used by in-game encounter transitions so character progression and inventory survive. */
@@ -50,7 +54,8 @@ export function activateDungeonSession(
const definition = dungeonDefinitionById(dungeonId);
const testingSource = bossTestingSourceDungeonId(dungeonId);
const testing = Boolean(testingSource && options.gmTestMode && options.bossTest?.sourceDungeonId === testingSource);
if (!definition || (!testing && !dungeonCanEnter(dungeonId))
const dungeonSimulation = Boolean(options.dungeonSimulation && options.gmTestMode && dungeonRouteDefinition(dungeonId));
if (!definition || (!testing && !dungeonSimulation && !dungeonCanEnter(dungeonId))
|| !dungeonContentIsUsable(testing ? testingSource! : dungeonId)) {
return false;
}
@@ -72,6 +77,8 @@ export function activateDungeonSession(
resetDungeonEncounters("dungeon", dungeonId);
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetDungeonRouteRuntime();
useDungeonSimulationStore.getState().leave();
clearDungeonRuntimeCaches();
resetPartyRuntimeClock();
if (options.preserveCharacter) {
@@ -97,6 +104,7 @@ export function activateDungeonSession(
});
usePartyStore.setState({
seed: null,
spectatorParty: false,
members: [],
command: "defend",
selectedMemberId: null,
+70
View File
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
DUNGEON_SIMULATION_FIXED_STEP_MS,
fixedSimulationTickCount,
useDungeonSimulationStore,
} from "./dungeonSimulationStore";
import { createSpectatorDungeonParty } from "./partyStore";
import { routeHasExactApproval } from "./dungeonRoutes";
import type { DungeonRouteDefinitionV1, DungeonSimulationSpeed } from "./dungeonSimulationTypes";
const route: DungeonRouteDefinitionV1 = {
schemaVersion: 1,
id: "primary",
dungeonId: "test-dungeon",
mapId: 1,
hash: "a".repeat(64),
generationStatus: "Candidate",
generationIssues: [],
legs: [{
id: "leg-1",
objectiveId: "boss-1",
objectiveName: "Boss",
checkpoints: [[0, 0, 0], [4, 0, 0]],
expectedDistance: 4,
corridorPlanarDistance: 10,
corridorVerticalDistance: 4,
actions: [],
}],
};
describe("dungeon simulation fixed clock", () => {
beforeEach(() => useDungeonSimulationStore.getState().leave());
it("produces the same ordered ticks at every playback speed", () => {
const speeds: readonly DungeonSimulationSpeed[] = [0.5, 1, 2, 4];
for (const speed of speeds) {
const wallDuration = 500 / speed;
const result = fixedSimulationTickCount(0, wallDuration, speed);
expect(result.ticks).toBe(500 / DUNGEON_SIMULATION_FIXED_STEP_MS);
expect(result.accumulatorMs).toBe(0);
}
});
it("freezes while paused and advances exactly one tick per step", () => {
const store = useDungeonSimulationStore.getState();
store.startLive({ dungeonId: route.dungeonId, route, mode: "navigation-only", seed: "clock", startedAt: 10_000 });
store.setRunning(false);
expect(useDungeonSimulationStore.getState().consumeTicks(5_000)).toEqual([]);
expect(useDungeonSimulationStore.getState().elapsedMs).toBe(0);
useDungeonSimulationStore.getState().step();
expect(useDungeonSimulationStore.getState().consumeTicks(0)).toEqual([10_050]);
expect(useDungeonSimulationStore.getState().elapsedMs).toBe(50);
});
});
describe("spectator roster and exact-hash approvals", () => {
it("creates a stable tank, healer, and three damage dealers", () => {
const first = createSpectatorDungeonParty("same-seed", 80, "wow");
const second = createSpectatorDungeonParty("same-seed", 80, "wow");
expect(second).toEqual(first);
expect(first.map((member) => member.role)).toEqual(["tank", "healer", "damage", "damage", "damage"]);
});
it("rejects stale hashes and generator-rejected routes", () => {
const approval = { dungeonId: route.dungeonId, routeId: route.id, routeHash: route.hash, reviewedAt: 1 };
expect(routeHasExactApproval(route, [approval])).toBe(true);
expect(routeHasExactApproval({ ...route, hash: "b".repeat(64) }, [approval])).toBe(false);
expect(routeHasExactApproval({ ...route, generationStatus: "Rejected" }, [approval])).toBe(false);
});
});
+274
View File
@@ -0,0 +1,274 @@
import { create } from "zustand";
import type {
DungeonReplayFrame,
DungeonReplayV1,
DungeonRouteDefinitionV1,
DungeonSessionMode,
DungeonSimulationFinding,
DungeonSimulationMode,
DungeonSimulationPull,
DungeonSimulationReportV1,
DungeonSimulationResult,
DungeonSimulationSpeed,
DungeonSpectatorCameraMode,
} from "./dungeonSimulationTypes";
export const DUNGEON_SIMULATION_FIXED_STEP_MS = 50;
export const DUNGEON_REPLAY_SAMPLE_MS = 100;
export const DUNGEON_REPLAY_KEYFRAME_MS = 5_000;
export const DUNGEON_SIMULATION_MAX_DURATION_MS = 2 * 60 * 60 * 1_000;
const MAX_TICKS_PER_ADVANCE = 80;
export interface StartDungeonSimulationOptions {
readonly dungeonId: string;
readonly route: DungeonRouteDefinitionV1;
readonly mode: DungeonSimulationMode;
readonly seed: string;
readonly startedAt?: number;
}
export interface DungeonSimulationState {
sessionMode: DungeonSessionMode;
mode: DungeonSimulationMode;
runState: "idle" | "running" | "paused" | "finished";
speed: DungeonSimulationSpeed;
cameraMode: DungeonSpectatorCameraMode;
dungeonId: string | null;
routeId: string | null;
routeHash: string | null;
seed: string;
startedAt: number;
elapsedMs: number;
accumulatorMs: number;
pendingSteps: number;
objectiveId: string | null;
checkpointIndex: number;
result: DungeonSimulationResult | null;
findings: readonly DungeonSimulationFinding[];
pulls: readonly DungeonSimulationPull[];
recoveries: number;
frames: readonly DungeonReplayFrame[];
replay: DungeonReplayV1 | null;
startLive: (options: StartDungeonSimulationOptions) => void;
startReplay: (replay: DungeonReplayV1) => void;
leave: () => void;
setRunning: (running: boolean) => void;
toggleRunning: () => void;
setSpeed: (speed: DungeonSimulationSpeed) => void;
setCameraMode: (mode: DungeonSpectatorCameraMode) => void;
step: () => void;
seekReplay: (elapsedMs: number) => void;
consumeTicks: (wallDeltaMs: number) => readonly number[];
setRouteProgress: (objectiveId: string | null, checkpointIndex: number) => void;
addFinding: (finding: DungeonSimulationFinding) => void;
addPull: (pull: DungeonSimulationPull) => void;
addRecovery: () => void;
addFrame: (frame: DungeonReplayFrame) => void;
finish: (result: DungeonSimulationResult, roster: DungeonSimulationReportV1["roster"]) => DungeonReplayV1 | null;
}
const initialState = {
sessionMode: "play" as DungeonSessionMode,
mode: "full-combat" as DungeonSimulationMode,
runState: "idle" as const,
speed: 1 as DungeonSimulationSpeed,
cameraMode: "follow-leader" as DungeonSpectatorCameraMode,
dungeonId: null as string | null,
routeId: null as string | null,
routeHash: null as string | null,
seed: "",
startedAt: 0,
elapsedMs: 0,
accumulatorMs: 0,
pendingSteps: 0,
objectiveId: null as string | null,
checkpointIndex: 0,
result: null as DungeonSimulationResult | null,
findings: [] as readonly DungeonSimulationFinding[],
pulls: [] as readonly DungeonSimulationPull[],
recoveries: 0,
frames: [] as readonly DungeonReplayFrame[],
replay: null as DungeonReplayV1 | null,
};
function clampWallDelta(value: number): number {
return Math.max(0, Math.min(1_000, Number.isFinite(value) ? value : 0));
}
export function fixedSimulationTickCount(
accumulatorMs: number,
wallDeltaMs: number,
speed: DungeonSimulationSpeed,
): { readonly ticks: number; readonly accumulatorMs: number } {
const total = Math.max(0, accumulatorMs) + clampWallDelta(wallDeltaMs) * speed;
const ticks = Math.min(MAX_TICKS_PER_ADVANCE, Math.floor(total / DUNGEON_SIMULATION_FIXED_STEP_MS));
return { ticks, accumulatorMs: total - ticks * DUNGEON_SIMULATION_FIXED_STEP_MS };
}
export const useDungeonSimulationStore = create<DungeonSimulationState>((set, get) => ({
...initialState,
startLive: ({ dungeonId, route, mode, seed, startedAt = Date.now() }) => set({
...initialState,
sessionMode: "spectate-live",
mode,
runState: "running",
dungeonId,
routeId: route.id,
routeHash: route.hash,
seed,
startedAt,
objectiveId: route.legs[0]?.objectiveId ?? null,
}),
startReplay: (replay) => set({
...initialState,
sessionMode: "spectate-replay",
mode: replay.mode,
runState: "paused",
dungeonId: replay.dungeonId,
routeId: replay.routeId,
routeHash: replay.routeHash,
seed: replay.seed,
startedAt: replay.startedAt,
result: replay.result,
findings: replay.report.findings,
pulls: replay.report.pulls,
recoveries: replay.report.recoveries,
frames: replay.frames,
replay,
}),
leave: () => set(initialState),
setRunning: (running) => set((state) => state.sessionMode === "play" || state.runState === "finished"
? state
: { runState: running ? "running" : "paused", accumulatorMs: 0 }),
toggleRunning: () => {
const state = get();
state.setRunning(state.runState !== "running");
},
setSpeed: (speed) => set({ speed }),
setCameraMode: (cameraMode) => set({ cameraMode }),
step: () => set((state) => {
if (state.runState !== "paused") return state;
if (state.sessionMode === "spectate-replay" && state.replay) {
const next = state.replay.frames.find((frame) => frame.atMs > state.elapsedMs);
return { elapsedMs: next?.atMs ?? state.elapsedMs };
}
return { pendingSteps: state.pendingSteps + 1 };
}),
seekReplay: (elapsedMs) => set((state) => state.sessionMode !== "spectate-replay" || !state.replay
? state
: { elapsedMs: Math.max(0, Math.min(state.replay.durationMs, elapsedMs)), accumulatorMs: 0 }),
consumeTicks: (wallDeltaMs) => {
const state = get();
if (state.sessionMode === "play" || state.runState === "finished") return [];
if (state.sessionMode === "spectate-replay") {
if (state.runState !== "running" || !state.replay) return [];
const advanced = Math.min(state.replay.durationMs, state.elapsedMs + clampWallDelta(wallDeltaMs) * state.speed);
set({ elapsedMs: advanced, runState: advanced >= state.replay.durationMs ? "finished" : "running" });
return [];
}
let ticks = 0;
let accumulatorMs = state.accumulatorMs;
let pendingSteps = state.pendingSteps;
if (state.runState === "running") {
const advance = fixedSimulationTickCount(accumulatorMs, wallDeltaMs, state.speed);
ticks = advance.ticks;
accumulatorMs = advance.accumulatorMs;
} else if (state.runState === "paused" && pendingSteps > 0) {
ticks = 1;
pendingSteps -= 1;
accumulatorMs = 0;
}
const availableTicks = Math.max(0, Math.floor((DUNGEON_SIMULATION_MAX_DURATION_MS - state.elapsedMs) / DUNGEON_SIMULATION_FIXED_STEP_MS));
ticks = Math.min(ticks, availableTicks);
const elapsedMs = state.elapsedMs + ticks * DUNGEON_SIMULATION_FIXED_STEP_MS;
set({ elapsedMs, accumulatorMs, pendingSteps });
return Array.from({ length: ticks }, (_, index) => state.startedAt + state.elapsedMs + (index + 1) * DUNGEON_SIMULATION_FIXED_STEP_MS);
},
setRouteProgress: (objectiveId, checkpointIndex) => set((state) => {
const normalized = Math.max(0, Math.trunc(checkpointIndex));
return state.objectiveId === objectiveId && state.checkpointIndex === normalized
? state
: { objectiveId, checkpointIndex: normalized };
}),
addFinding: (finding) => set((state) => state.findings.some((candidate) => candidate.id === finding.id)
? state
: { findings: [...state.findings, finding] }),
addPull: (pull) => set((state) => ({ pulls: [...state.pulls, pull] })),
addRecovery: () => set((state) => ({ recoveries: state.recoveries + 1 })),
addFrame: (frame) => set((state) => {
if (state.frames[state.frames.length - 1]?.atMs === frame.atMs) return state;
return { frames: [...state.frames, frame] };
}),
finish: (result, roster) => {
const state = get();
if (state.sessionMode !== "spectate-live" || state.runState === "finished" || !state.dungeonId || !state.routeId || !state.routeHash) return null;
const objectiveIds = [...new Set(state.frames.map((frame) => frame.objectiveId).filter((id): id is string => Boolean(id)))];
const objectiveTimings = objectiveIds.map((objectiveId) => {
const frames = state.frames.filter((frame) => frame.objectiveId === objectiveId);
const laterObjective = state.frames.find((frame) => frame.atMs > (frames.at(-1)?.atMs ?? 0) && frame.objectiveId !== objectiveId);
return {
objectiveId,
startedAtMs: frames[0]?.atMs ?? 0,
completedAtMs: laterObjective?.atMs ?? (state.objectiveId === objectiveId ? null : frames.at(-1)?.atMs ?? null),
};
});
const pathDistances = objectiveIds.map((objectiveId) => {
const frames = state.frames.filter((frame) => frame.objectiveId === objectiveId);
let actualDistance = 0;
for (let index = 1; index < frames.length; index += 1) {
const previous = frames[index - 1].actors[0]?.position;
const current = frames[index].actors[0]?.position;
if (previous && current) actualDistance += Math.hypot(
current[0] - previous[0], current[1] - previous[1], current[2] - previous[2],
);
}
return { objectiveId, actualDistance };
});
const report: DungeonSimulationReportV1 = {
schemaVersion: 1,
dungeonId: state.dungeonId,
routeId: state.routeId,
routeHash: state.routeHash,
seed: state.seed,
mode: state.mode,
startedAt: state.startedAt,
durationMs: state.elapsedMs,
result,
roster,
findings: state.findings,
pulls: state.pulls,
recoveries: state.recoveries,
objectiveTimings,
actionTimings: state.findings.filter((finding) => finding.code === "route-action" && finding.actionId).map((finding) => ({
actionId: finding.actionId!,
objectiveId: finding.objectiveId ?? null,
atMs: finding.atMs,
})),
pathDistances,
finalObjectiveId: state.objectiveId,
finalCheckpointIndex: state.checkpointIndex,
finalState: state.frames.at(-1)?.actors ?? [],
};
const replay: DungeonReplayV1 = {
schemaVersion: 1,
dungeonId: state.dungeonId,
routeId: state.routeId,
routeHash: state.routeHash,
mode: state.mode,
seed: state.seed,
result,
startedAt: state.startedAt,
durationMs: state.elapsedMs,
report,
frames: state.frames,
events: state.findings,
};
set({ runState: "finished", result, replay });
return replay;
},
}));
export function dungeonSimulationNow(): number {
const state = useDungeonSimulationStore.getState();
return state.sessionMode === "spectate-live" ? state.startedAt + state.elapsedMs : Date.now();
}
+172
View File
@@ -0,0 +1,172 @@
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 DungeonRouteReviewStatus = "Candidate" | "Approved" | "NeedsWork";
export type DungeonSimulationResult = "completed" | "wiped" | "stopped" | "recording-limit";
export type DungeonSimulationFindingSeverity = "info" | "warning" | "critical";
export interface DungeonRouteAction {
readonly id: string;
readonly checkpointIndex: number;
readonly kind: "interact-world-object";
readonly objectId: string;
}
export interface DungeonRouteLeg {
readonly id: string;
readonly objectiveId: string;
readonly objectiveName: string;
readonly checkpoints: readonly Vector3Tuple[];
readonly expectedDistance: number;
readonly corridorPlanarDistance: number;
readonly corridorVerticalDistance: number;
readonly actions: readonly DungeonRouteAction[];
}
export interface DungeonRouteDefinitionV1 {
readonly schemaVersion: 1;
readonly id: string;
readonly dungeonId: string;
readonly mapId: number;
readonly hash: string;
readonly generationStatus: "Candidate" | "Rejected";
readonly generationIssues: readonly string[];
readonly legs: readonly DungeonRouteLeg[];
}
export interface DungeonRouteCatalogV1 {
readonly schemaVersion: 1;
readonly generatedAt: string;
readonly source: {
readonly kind: "playerbots-landmark-reference";
readonly auditReport: string;
readonly note: string;
};
readonly routes: readonly DungeonRouteDefinitionV1[];
}
export interface DungeonRouteApproval {
readonly dungeonId: string;
readonly routeId: string;
readonly routeHash: string;
readonly reviewedAt: number;
}
export interface DungeonRouteReview {
readonly dungeonId: string;
readonly routeId: string;
readonly routeHash: string;
readonly status: DungeonRouteReviewStatus;
readonly note: string | null;
readonly criticalOverrideNote: string | null;
readonly reviewedBy: string;
readonly reviewedAt: number;
readonly revision: number;
}
export interface DungeonSimulationFinding {
readonly id: string;
readonly atMs: number;
readonly severity: DungeonSimulationFindingSeverity;
readonly code: string;
readonly message: string;
readonly position?: Vector3Tuple;
readonly actorId?: string;
readonly objectiveId?: string;
readonly checkpointIndex?: number;
readonly actionId?: string;
}
export interface DungeonSimulationPull {
readonly atMs: number;
readonly mobIds: readonly string[];
readonly routeLegId: string | null;
readonly position: Vector3Tuple;
readonly classification: "expected" | "off-route" | "theoretical";
}
export interface DungeonSimulationActorSnapshot {
readonly id: string;
readonly position: Vector3Tuple;
readonly yaw: number;
readonly health: number;
readonly maxHealth: number;
readonly targetId: string | null;
readonly status: string;
}
export interface DungeonReplayFrame {
readonly atMs: number;
readonly keyframe: boolean;
readonly actors: readonly DungeonSimulationActorSnapshot[];
readonly objectiveId: string | null;
readonly checkpointIndex: number;
}
export interface DungeonSimulationReportV1 {
readonly schemaVersion: 1;
readonly dungeonId: string;
readonly routeId: string;
readonly routeHash: string;
readonly seed: string;
readonly mode: DungeonSimulationMode;
readonly startedAt: number;
readonly durationMs: number;
readonly result: DungeonSimulationResult;
readonly roster: readonly { readonly id: string; readonly name: string; readonly role: string; readonly classId: string }[];
readonly findings: readonly DungeonSimulationFinding[];
readonly pulls: readonly DungeonSimulationPull[];
readonly recoveries: number;
readonly objectiveTimings: readonly {
readonly objectiveId: string;
readonly startedAtMs: number;
readonly completedAtMs: number | null;
}[];
readonly actionTimings: readonly {
readonly actionId: string;
readonly objectiveId: string | null;
readonly atMs: number;
}[];
readonly pathDistances: readonly {
readonly objectiveId: string;
readonly actualDistance: number;
}[];
readonly finalObjectiveId: string | null;
readonly finalCheckpointIndex: number;
readonly finalState: readonly DungeonSimulationActorSnapshot[];
}
export interface DungeonReplayV1 {
readonly schemaVersion: 1;
readonly dungeonId: string;
readonly routeId: string;
readonly routeHash: string;
readonly mode: DungeonSimulationMode;
readonly seed: string;
readonly result: DungeonSimulationResult;
readonly startedAt: number;
readonly durationMs: number;
readonly report: DungeonSimulationReportV1;
readonly frames: readonly DungeonReplayFrame[];
readonly events: readonly DungeonSimulationFinding[];
}
export interface DungeonReplaySummary {
readonly id: string;
readonly dungeonId: string;
readonly routeId: string;
readonly routeHash: string;
readonly mode: DungeonSimulationMode;
readonly seed: string;
readonly result: DungeonSimulationResult;
readonly startedAt: number;
readonly durationMs: number;
readonly criticalFindings: number;
readonly submittedBy: string;
readonly createdAt: number;
readonly expiresAt: number;
readonly compressedBytes: number;
}
@@ -0,0 +1 @@
{"schemaVersion":1,"generatedAt":"2026-05-08T10:36:36-07:00","source":{"kind":"playerbots-landmark-reference","auditReport":"dungeon-pipeline/DUNGEON_ROUTE_GENERATION_AUDIT.json","note":"PlayerBots was used only as offline topology evidence. Shipped checkpoint geometry is computed from HealerMan navigation assets."},"routes":[{"schemaVersion":1,"id":"primary","dungeonId":"shadowfang-keep","mapId":33,"legs":[{"id":"leg-01-creature-16242","objectiveId":"creature-16242","objectiveName":"Rethilgore","checkpoints":[[2108.6162,76.9903,-228.5817],[2112.0329,76.9903,-228.419],[2111.7075,76.9903,-227.6055],[2109.4839,76.9903,-226.6293],[2111.816,76.9903,-225.4362],[2111.2736,76.9903,-223.9719],[2109.3755,76.9903,-223.1584],[2110.189,76.9903,-221.1518],[2108.7247,76.9903,-220.501],[2109.267,76.9903,-219.579],[2107.8569,76.9903,-218.4401],[2107.7485,76.9903,-216.4334],[2106.5553,76.9903,-215.1861],[2106.8265,76.9903,-213.3964],[2104.6572,76.9903,-213.5591],[2105.308,76.9903,-211.6067],[2103.2471,76.9903,-208.9492],[2100.5897,76.9903,-207.7019],[2100.21,76.9903,-204.1225],[2098.9627,76.9903,-203.4174],[2099.7762,76.9903,-202.2785],[2100.9693,76.9903,-202.4955],[2101.132,77.0445,-201.1939],[2101.7286,77.153,-201.7904],[2104.4945,78.7257,-201.682],[2105.308,78.6715,-200.2719],[2106.0672,78.6715,-200.6515],[2106.013,78.9969,-202.6582],[2108.0739,80.407,-205.0987],[2108.7789,80.5154,-205.0987],[2108.9958,81.0578,-206.4003],[2110.0805,81.112,-207.7561],[2111.0567,81.0578,-207.4307],[2112.5752,81.0578,-208.6781],[2115.3954,81.0578,-207.3222],[2117.2936,81.0578,-205.5868],[2114.365,81.0578,-205.4241],[2120.2764,81.2205,-204.2852],[2120.4391,81.3289,-203.4174],[2121.1441,81.4374,-203.2005],[2121.0899,81.4916,-202.1701],[2123.1508,81.5459,-201.9531],[2122.5542,81.5459,-199.9465],[2127.4352,81.5459,-198.8618],[2125.5913,81.5459,-196.5298],[2131.8823,81.5459,-196.0417],[2132.9128,81.5459,-194.957],[2127.7606,81.5459,-194.1977],[2132.5874,81.5459,-193.5469],[2127.7606,81.5459,-193.2215],[2127.8148,81.5459,-192.7334],[2134.2144,81.5459,-191.703],[2132.8043,81.5459,-191.1064],[2135.9499,81.6543,-190.9437],[2136.9803,82.034,-190.5641],[2137.0345,82.1967,-189.7506],[2137.6853,82.6848,-189.2083],[2138.7158,82.9559,-190.0218],[2139.4208,83.2271,-189.2083],[2141.2105,83.1729,-189.2625],[2140.8851,83.2271,-189.9133],[2141.5359,83.4983,-190.0218],[2141.102,83.6067,-190.7268],[2141.4274,84.6372,-192.625],[2143.3798,85.8303,-194.1435],[2143.0544,86.1557,-195.0112],[2143.651,86.5353,-196.1501],[2144.4645,86.5896,-196.0417],[2144.4645,86.6438,-198.8076],[2145.7661,86.8065,-199.8922],[2144.7899,87.0777,-200.3803],[2145.6034,87.4573,-201.1396],[2145.6034,87.8912,-202.0074],[2144.8441,88.3793,-202.6582],[2145.1153,89.0301,-203.8513],[2144.1933,89.7351,-204.7733],[2144.2476,90.0063,-205.8037],[2143.9764,90.2774,-206.346],[2143.3798,90.4401,-206.0749],[2143.1629,90.7655,-206.6714],[2142.6748,90.7113,-206.6172],[2143.4883,90.6571,-209.7627],[2142.6748,90.6571,-211.9863],[2144.356,90.7113,-215.5386],[2146.0373,90.7655,-219.0909],[2148.2066,90.9282,-219.0909],[2149.6167,91.0367,-221.6941],[2151.4064,90.9282,-226.087],[2149.1828,90.7113,-230.4257],[2151.4606,90.7113,-230.8053],[2150.5386,90.7113,-233.1373],[2148.3151,90.6028,-233.1373],[2149.0201,90.3859,-236.0659],[2148.0981,90.169,-234.8728],[2148.4235,90.0063,-236.554],[2147.2304,89.7893,-235.0897],[2145.9288,89.1928,-235.6863],[2144.9526,88.3793,-237.476],[2145.0611,88.1081,-239.2657],[2142.8375,87.2404,-239.7538],[2142.6206,87.2404,-240.5131],[2142.1325,87.2404,-240.3504],[2142.1325,87.0777,-243.7671],[2143.2714,87.1319,-245.8822],[2143.3798,87.1319,-247.5092],[2144.4645,87.1861,-248.9193],[2143.7052,87.1319,-248.4854],[2144.4645,87.1861,-249.4616],[2143.7052,87.2404,-250.0582],[2142.8917,87.1861,-250.2751],[2142.5663,87.0777,-249.4074],[2142.1325,86.915,-250.2751],[2141.4817,86.6438,-249.787],[2139.3123,85.3422,-251.1971],[2136.7634,84.1491,-251.5225],[2133.8348,82.5763,-252.8241],[2134.1059,82.5221,-253.3664],[2133.4009,82.0882,-253.6918],[2130.6892,81.2205,-253.6918],[2129.6588,81.2205,-254.5595],[2124.2897,81.2205,-254.1257],[2124.8862,81.2205,-253.041],[2121.3611,81.2205,-252.4444],[2126.3505,81.2205,-251.8479],[2124.3439,81.2205,-250.9259]],"expectedDistance":261.42,"corridorPlanarDistance":10,"corridorVerticalDistance":4,"actions":[]},{"id":"leg-02-creature-16254","objectiveId":"creature-16254","objectiveName":"Baron Silverlaine","checkpoints":[[2124.3439,81.2205,-250.9259],[2126.3505,81.2205,-251.8479],[2121.3611,81.2205,-252.4444],[2124.8862,81.2205,-253.041],[2124.2897,81.2205,-254.1257],[2129.6588,81.2205,-254.5595],[2130.6892,81.2205,-253.6918],[2133.4009,82.0882,-253.6918],[2134.1059,82.5221,-253.3664],[2133.8348,82.5763,-252.8241],[2136.7634,84.1491,-251.5225],[2139.3123,85.3422,-251.1971],[2141.4817,86.6438,-249.787],[2142.1325,86.915,-250.2751],[2142.5663,87.0777,-249.4074],[2142.8917,87.1861,-250.2751],[2143.7052,87.2404,-250.0582],[2144.4645,87.1861,-249.4616],[2143.7052,87.1319Line truncated
+36 -3
View File
@@ -12,6 +12,7 @@ import {
MAX_MOUSE_DELTA_PER_FRAME,
POINTER_LOCK_MOUSE_GRACE_MS,
beginMouseLookDrag,
clearBrowserSelection,
clearJumpRequest,
consumeJumpRequest,
consumeMouseLook,
@@ -31,9 +32,10 @@ import {
import { normalizeInputPreferences, replaceInputBinding } from "../input/keybinds";
import { setCapturingInput } from "../input/keybindRuntime";
function stubInputEventTargets(): void {
function stubInputEventTargets(selection: Pick<Selection, "removeAllRanges"> | null = null): void {
const windowTarget = new EventTarget();
const documentTarget = new EventTarget();
Object.defineProperty(windowTarget, "getSelection", { configurable: true, value: () => selection });
Object.defineProperty(documentTarget, "pointerLockElement", { configurable: true, value: null });
vi.stubGlobal("window", windowTarget);
vi.stubGlobal("document", documentTarget);
@@ -362,15 +364,19 @@ describe("gameplay input bindings", () => {
});
it("turns the camera from a right-mouse drag without pointer lock", () => {
stubInputEventTargets();
const selection = { removeAllRanges: vi.fn() };
stubInputEventTargets(selection);
const nextActions = inputActionSpies();
const dispose = installInput(nextActions);
beginMouseLookDrag(100, 80);
const move = new Event("mousemove") as MouseEvent;
expect(selection.removeAllRanges).toHaveBeenCalledOnce();
const move = new Event("pointermove") as PointerEvent;
Object.defineProperties(move, {
clientX: { value: 114 },
clientY: { value: 73 },
pointerType: { value: "mouse" },
movementX: { value: 9_999 },
movementY: { value: 9_999 },
});
@@ -387,6 +393,33 @@ describe("gameplay input bindings", () => {
dispose();
});
it("prevents page selection only while right-mouse camera drag is active", () => {
stubInputEventTargets();
const dispose = installInput(inputActionSpies());
const idleSelection = new Event("selectstart", { cancelable: true });
document.dispatchEvent(idleSelection);
expect(idleSelection.defaultPrevented).toBe(false);
beginMouseLookDrag(20, 30);
const cameraSelection = new Event("selectstart", { cancelable: true });
document.dispatchEvent(cameraSelection);
expect(cameraSelection.defaultPrevented).toBe(true);
endMouseLookDrag();
const releasedSelection = new Event("selectstart", { cancelable: true });
document.dispatchEvent(releasedSelection);
expect(releasedSelection.defaultPrevented).toBe(false);
dispose();
});
it("can clear an existing browser selection without a camera drag", () => {
const selection = { removeAllRanges: vi.fn() };
stubInputEventTargets(selection);
clearBrowserSelection();
expect(selection.removeAllRanges).toHaveBeenCalledOnce();
});
it("contains malformed and stale pointer-lock movement", () => {
expect(sanitizeMouseDelta(Number.NaN)).toBe(0);
expect(sanitizeMouseDelta(Number.POSITIVE_INFINITY)).toBe(0);
+27 -13
View File
@@ -131,11 +131,18 @@ const mouseLookBuffer = createMouseLookBuffer();
let mouseLookDragActive = false;
let previousMousePosition: { x: number; y: number } | null = null;
/** Clears browser text selection before gameplay takes ownership of a mouse drag. */
export function clearBrowserSelection(): void {
if (typeof window === "undefined") return;
window.getSelection?.()?.removeAllRanges();
}
/**
* Starts popup-free desktop camera look. Unlike Pointer Lock, this retains the
* normal browser cursor and only consumes movement while right mouse is held.
*/
export function beginMouseLookDrag(clientX: number, clientY: number): void {
clearBrowserSelection();
mouseLookDragActive = true;
previousMousePosition = Number.isFinite(clientX) && Number.isFinite(clientY)
? { x: clientX, y: clientY }
@@ -317,20 +324,20 @@ export function installInput(nextActions: InputActions): () => void {
}
publishActionLayer();
};
const onPointerMove = (event: PointerEvent) => {
if (!mouseLookDragActive || event.pointerType !== "mouse") return;
const current = Number.isFinite(event.clientX) && Number.isFinite(event.clientY)
? { x: event.clientX, y: event.clientY }
: null;
if (!current) return;
const previous = previousMousePosition;
previousMousePosition = current;
if (
previous
&& mouseLookBuffer.push(current.x - previous.x, current.y - previous.y)
) actions?.setInputMode("keyboard");
};
const onMouseMove = (event: MouseEvent) => {
if (mouseLookDragActive) {
const current = Number.isFinite(event.clientX) && Number.isFinite(event.clientY)
? { x: event.clientX, y: event.clientY }
: null;
if (!current) return;
const previous = previousMousePosition;
previousMousePosition = current;
if (
previous
&& mouseLookBuffer.push(current.x - previous.x, current.y - previous.y)
) actions?.setInputMode("keyboard");
return;
}
if (
document.pointerLockElement
&& mouseLookBuffer.push(event.movementX, event.movementY)
@@ -341,6 +348,9 @@ export function installInput(nextActions: InputActions): () => void {
mouseLookBuffer.reset(locked ? POINTER_LOCK_MOUSE_GRACE_MS : 0);
nextActions.setCameraLookActive(locked || mouseLookDragActive);
};
const onSelectStart = (event: Event) => {
if (mouseLookDragActive) event.preventDefault();
};
const clear = () => {
keys.clear();
keyboardModifiers.clear();
@@ -355,15 +365,19 @@ export function installInput(nextActions: InputActions): () => void {
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", clear);
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("pointerlockchange", onPointerLockChange);
document.addEventListener("selectstart", onSelectStart);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", clear);
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("pointerlockchange", onPointerLockChange);
document.removeEventListener("selectstart", onSelectStart);
unsubscribeController();
clear();
actions = null;
+45 -6
View File
@@ -64,6 +64,7 @@ import {
isPositionInFacingArc,
isSourceBehindTarget,
} from "./combatSpatial";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
const OUT_OF_COMBAT_REGEN_FRACTION = 0.06;
const ATTACK_ACQUISITION_RANGE = 30;
@@ -128,6 +129,16 @@ export function activePartyBossObjectives(): readonly PartyBossObjective[] {
}] : [];
}
const game = useGameStore.getState();
const simulation = useDungeonSimulationStore.getState();
if (
simulation.sessionMode === "spectate-live"
&& simulation.mode === "navigation-only"
&& simulation.objectiveId
) {
const objective = requireDungeonDefinition(game.activeDungeonId).objectiveOrder
.find((candidate) => candidate.id === simulation.objectiveId);
return objective ? [objective] : [];
}
const completed = new Set(activeDungeonCompletedObjectiveIds(game.activeDungeonId));
const mobs = useCombatStore.getState().mobs;
const objectives = requireDungeonDefinition(game.activeDungeonId).objectiveOrder
@@ -143,6 +154,10 @@ export function activePartyBossObjectives(): readonly PartyBossObjective[] {
));
}
function simulatedPartyExcludesPlayer(): boolean {
return useDungeonSimulationStore.getState().sessionMode !== "play";
}
export function resetPartyRuntimeClock(): void {
lastAdvanceAt = 0;
nextRegenAt = 0;
@@ -799,7 +814,10 @@ function partyHealingTargets(
): readonly string[] {
const combat = useCombatStore.getState();
const targeting = targetingRulesForAbility(ability);
const living = [PLAYER_AGGRO_ID, ...members.map((member) => member.id)].filter((id) => combat.actors[id]?.alive);
const living = [
...(simulatedPartyExcludesPlayer() ? [] : [PLAYER_AGGRO_ID]),
...members.map((member) => member.id),
].filter((id) => combat.actors[id]?.alive);
const healthFraction = (id: string) => {
const actor = combat.actors[id];
return actor ? actor.health / Math.max(1, actor.maxHealth) : 1;
@@ -850,7 +868,7 @@ function choosePartySupportIntent(
): PartySupportIntent | null {
const combat = useCombatStore.getState();
const fallenTargetId = [
...(combat.health <= 0 ? [PLAYER_AGGRO_ID] : []),
...(!simulatedPartyExcludesPlayer() && combat.health <= 0 ? [PLAYER_AGGRO_ID] : []),
...members
.filter((candidate) => candidate.health <= 0)
.sort((left, right) => (
@@ -866,7 +884,7 @@ function choosePartySupportIntent(
}
if (member.role !== "healer") return null;
const dispelTargetId = [
...(combat.health > 0 ? [PLAYER_AGGRO_ID] : []),
...(!simulatedPartyExcludesPlayer() && combat.health > 0 ? [PLAYER_AGGRO_ID] : []),
...members.filter((candidate) => candidate.health > 0).map((candidate) => candidate.id),
].find((id) => combat.auras.some((aura) => (
aura.targetId === id
@@ -880,7 +898,7 @@ function choosePartySupportIntent(
}
}
const injured = [
...(combat.health > 0 && combat.health < combat.maxHealth
...(!simulatedPartyExcludesPlayer() && combat.health > 0 && combat.health < combat.maxHealth
? [{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, role: "healer" as const }]
: []),
...members.filter((candidate) => candidate.health > 0 && candidate.health < candidate.maxHealth),
@@ -1024,7 +1042,10 @@ function executePartyAbility(
}
} else if (effect.kind === "apply-aura") {
const recipients = effect.scope === "party"
? [PLAYER_AGGRO_ID, ...members.filter((candidate) => candidate.health > 0).map((candidate) => candidate.id)]
? [
...(simulatedPartyExcludesPlayer() ? [] : [PLAYER_AGGRO_ID]),
...members.filter((candidate) => candidate.health > 0).map((candidate) => candidate.id),
]
: effect.recipient === "caster"
? [member.id]
: combat.mobs[targetId] ? hostileTargets : friendlyTargets;
@@ -1036,7 +1057,7 @@ function executePartyAbility(
}
} else if (effect.kind === "dispel") {
const recipients = effect.scope === "party"
? [PLAYER_AGGRO_ID, ...members.map((candidate) => candidate.id)]
? [...(simulatedPartyExcludesPlayer() ? [] : [PLAYER_AGGRO_ID]), ...members.map((candidate) => candidate.id)]
: [targetId];
for (const recipientId of recipients) {
effectiveAmount += useCombatStore.getState().dispelActor(recipientId, effect.categories, effect.maxCount);
@@ -1134,6 +1155,24 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
const authoritativeMembers: readonly PartyMember[] = party.members.map((member) => (
memberFromCompanionActor(member, combat.actors[member.id])
));
const simulation = useDungeonSimulationStore.getState();
if (simulation.sessionMode === "spectate-live" && simulation.mode === "navigation-only") {
const members = authoritativeMembers.map((member) => ({
...member,
status: member.health > 0 ? "advancing" as const : "down" as const,
activeCast: null,
activeCastTargetId: null,
nextActionAt: 0,
}));
party.commitRuntime({
members,
activeMobId: null,
objectiveBossId: simulation.objectiveId,
memberTargetIds: {},
});
useCombatStore.getState().commitCompanionActors(members);
return;
}
let members: readonly PartyMember[] = authoritativeMembers;
const partyOrigin = partyRuntimeCenter(
members.filter((member) => !partyRuntimeIsRejoining(member.id)).map((member) => member.id),
+82 -4
View File
@@ -89,6 +89,17 @@ interface PartyTemplate {
readonly talentTreeId: string;
}
export interface ConfiguredPartyMemberSeed {
readonly name: string;
readonly classId: ClassId;
readonly secondaryClassId?: ClassId | null;
readonly specialization: string;
readonly specializationId?: string | null;
readonly talentTreeId?: string;
readonly talentRanks?: TalentRanks;
readonly role: PartyRole;
}
const TANK_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
{ name: "Garrick", classId: "warrior", specialization: "Protection", talentTreeId: "warrior-protection" },
{ name: "Morvane", classId: "death-knight", specialization: "Blood", talentTreeId: "death-knight-blood" },
@@ -241,6 +252,7 @@ function createMember(
level: number,
index: number,
seed: string,
configuredTalentRanks?: TalentRanks,
): PartyMember {
// Appearance has its own keyed stream so adding customization fields later
// cannot reshuffle which class/spec templates make up this character's party.
@@ -282,9 +294,9 @@ function createMember(
specialization: template.specialization,
specializationId,
talentTreeId: template.talentTreeId,
talentRanks: isRomClassId(template.classId)
talentRanks: configuredTalentRanks ?? (isRomClassId(template.classId)
? Object.freeze({})
: runtimePartyTalentRanksForLevel(template.classId, template.specialization, level),
: runtimePartyTalentRanksForLevel(template.classId, template.specialization, level)),
role,
level: Math.max(1, Math.trunc(level)),
health: maxHealth,
@@ -310,6 +322,27 @@ function createMember(
};
}
/** Creates one exact companion while retaining the live party stat/equipment path. */
export function createConfiguredPartyMember(
configuration: ConfiguredPartyMemberSeed,
level: number,
index: number,
seed: string,
): PartyMember {
const member = createMember({
name: configuration.name,
classId: configuration.classId,
specialization: configuration.specialization,
talentTreeId: configuration.talentTreeId ?? configuration.specializationId ?? "",
}, configuration.role, level, index, seed, configuration.talentRanks);
return Object.freeze({
...member,
secondaryClassId: configuration.secondaryClassId ?? null,
specializationId: configuration.specializationId ?? member.specializationId,
talentRanks: configuration.talentRanks ?? member.talentRanks,
});
}
/**
* Builds the four AI slots around the player's selected role. The resulting
* group is always a classic five-player composition including the player.
@@ -352,6 +385,29 @@ export function createDungeonParty(
].slice(0, Math.max(0, partySize - 1));
}
/** Builds a complete deterministic NPC group for GM spectator simulations. */
export function createSpectatorDungeonParty(
seed: string,
level: number,
categoryId: ContentCategoryId = "wow",
): readonly PartyMember[] {
const random = { value: hashSeed(seed || "healer-man-spectator") };
const tankTemplates = categoryId === "rom" ? ROM_TANK_TEMPLATES : TANK_TEMPLATES;
const healerTemplates = categoryId === "rom" ? ROM_HEALER_TEMPLATES : HEALER_TEMPLATES;
const damagePool = [...(categoryId === "rom" ? ROM_DAMAGE_TEMPLATES : DAMAGE_TEMPLATES)];
for (let index = damagePool.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(nextRandom(random) * (index + 1));
[damagePool[index], damagePool[swapIndex]] = [damagePool[swapIndex], damagePool[index]];
}
const tank = tankTemplates[Math.floor(nextRandom(random) * tankTemplates.length)] ?? tankTemplates[0];
const healer = healerTemplates[Math.floor(nextRandom(random) * healerTemplates.length)] ?? healerTemplates[0];
return [
createMember(tank, "tank", level, 0, seed),
createMember(healer, "healer", level, 1, seed),
...damagePool.slice(0, 3).map((template, index) => createMember(template, "damage", level, index + 2, seed)),
];
}
export interface PartyRuntimeCommit {
readonly members: readonly PartyMember[];
readonly activeMobId: string | null;
@@ -361,6 +417,7 @@ export interface PartyRuntimeCommit {
export interface PartyState {
seed: string | null;
spectatorParty: boolean;
playerRole: PartyRole;
partySize: PartySize;
categoryId: ContentCategoryId;
@@ -381,6 +438,7 @@ export interface PartyState {
partySize?: PartySize,
categoryId?: ContentCategoryId,
) => void;
initializeSpectatorParty: (seed: string, level: number, categoryId?: ContentCategoryId) => void;
resetCombat: (level: number, playerHealth: number) => void;
syncLevel: (level: number) => void;
syncEquipment: (equipment: EquipmentAssignments, inventory: readonly InventoryItem[]) => void;
@@ -424,6 +482,7 @@ export interface PartyState {
export const usePartyStore = create<PartyState>((set, get) => ({
seed: null,
spectatorParty: false,
playerRole: "healer",
partySize: 5,
categoryId: "wow",
@@ -439,6 +498,7 @@ export const usePartyStore = create<PartyState>((set, get) => ({
initializeParty: (seed, level, playerHealth, playerRole = "healer", partySize = 5, categoryId = "wow") => set({
seed,
spectatorParty: false,
playerRole,
partySize,
categoryId,
@@ -452,10 +512,28 @@ export const usePartyStore = create<PartyState>((set, get) => ({
memberTargetIds: {},
}),
initializeSpectatorParty: (seed, level, categoryId = "wow") => set({
seed,
spectatorParty: true,
playerRole: "healer",
partySize: 5,
categoryId,
members: createSpectatorDungeonParty(seed, level, categoryId),
command: "attack",
selectedMemberId: null,
selfSelected: false,
routeBlocked: false,
activeMobId: null,
objectiveBossId: null,
memberTargetIds: {},
}),
resetCombat: (level, playerHealth) => set((state) => {
return {
members: createDungeonParty(state.seed ?? "healer-man", level, state.playerRole, state.partySize, state.categoryId),
command: "defend",
members: state.spectatorParty
? createSpectatorDungeonParty(state.seed ?? "healer-man-spectator", level, state.categoryId)
: createDungeonParty(state.seed ?? "healer-man", level, state.playerRole, state.partySize, state.categoryId),
command: state.spectatorParty ? "attack" : "defend",
selectedMemberId: null,
selfSelected: false,
routeBlocked: false,
@@ -525,6 +525,32 @@ describe("source-reviewed Wrath encounter batch two", () => {
]));
});
it("keeps Eck's source cadence without turning setup and Berserk spells into repeat damage", () => {
const gundrak = adaptedDefinition("gundrak");
const eck = Object.values(gundrak.entities).find((entity) => entity.combat?.serverEntry === 29_932);
expect(eck?.name).toBe("Eck the Ferocious");
expect(eck?.visual.model).toMatchObject({ groundOffset: 0, labelHeight: 7 });
const attacks = new Map(eck?.combat?.attacks.map((attack) => [attack.spellId ?? 0, attack]));
expect(attacks.get(55_813)).toMatchObject({
name: "Eck Bite", target: "primary", initialCooldownMs: [5_000, 5_000],
repeatCooldownMs: [8_000, 12_000], damageMultiplier: 1.35,
});
expect(attacks.get(55_814)).toMatchObject({
name: "Eck Spit", target: "primary", initialCooldownMs: [10_000, 37_000],
repeatCooldownMs: [8_000, 12_000], damageMultiplier: 1.35,
});
expect(attacks.get(55_815)).toMatchObject({
name: "Eck Spring", target: "random-party", initialCooldownMs: [10_000, 24_000],
repeatCooldownMs: [10_000, 24_000], damageMultiplier: 1.35,
telegraph: { shape: "circle", anchor: "target", warningMs: 750 },
});
expect(attacks.get(55_837)).toMatchObject({ target: "self", maximumCasts: 1, damageMultiplier: 0 });
expect(attacks.get(55_816)).toMatchObject({
target: "self", initialCooldownMs: [90_000, 90_000], maximumCasts: 1,
damageMultiplier: 0, effect: { kind: "aura", aura: "enrage" },
});
});
it("runs every required lifecycle through its reviewed gates and completes without renderer state", () => {
const halls = adaptedDefinition("halls-of-stone");
const hallsRun = finishEncounter(halls, adaptedEncounter(halls));
+73 -2
View File
@@ -20,6 +20,7 @@ import type {
PopulationEntityDefinition,
StaticMobSpawnDefinition,
} from "./mobPopulation";
import type { EnemyAttackDefinition } from "./enemyAttacks";
export interface WowWrathEncounterBatchTwoSourceFile {
readonly path: string;
@@ -71,6 +72,43 @@ const UTGARDE_PINNACLE_FILES = Object.freeze([
const GUNDRAK_FILES = Object.freeze([
`${SOURCE_ROOT}/Northrend/Gundrak/instance_gundrak.cpp`,
`${SOURCE_ROOT}/Northrend/Gundrak/gundrak.h`,
`${SOURCE_ROOT}/Northrend/Gundrak/boss_eck.cpp`,
]);
const ECK_ATTACKS: readonly EnemyAttackDefinition[] = Object.freeze([
Object.freeze({
id: "basic-attack", name: "Basic Attack", delivery: "melee", target: "primary",
school: "physical", animation: "attack", range: 4.5, cooldownMs: 2_000,
initialCooldownMs: [450, 450] as const, repeatCooldownMs: [2_000, 2_000] as const, damageMultiplier: 1,
}),
Object.freeze({
id: "spell-55837", name: "Eck Spring Init", spellId: 55_837, delivery: "area", target: "self",
school: "physical", animation: "cast", castTimeMs: 0, range: 0, cooldownMs: 1,
initialCooldownMs: [450, 450] as const, repeatCooldownMs: [1, 1] as const, maximumCasts: 1, damageMultiplier: 0,
effect: { kind: "aura", aura: "eck-spring-init", durationMs: 1_000 } as const,
}),
Object.freeze({
id: "spell-55813", name: "Eck Bite", spellId: 55_813, delivery: "melee", target: "primary",
school: "physical", animation: "attack", range: 5.5, cooldownMs: 10_000,
initialCooldownMs: [5_000, 5_000] as const, repeatCooldownMs: [8_000, 12_000] as const, damageMultiplier: 1.35,
}),
Object.freeze({
id: "spell-55814", name: "Eck Spit", spellId: 55_814, delivery: "projectile", target: "primary",
school: "nature", animation: "cast", castTimeMs: 0, range: 28, cooldownMs: 10_000,
initialCooldownMs: [10_000, 37_000] as const, repeatCooldownMs: [8_000, 12_000] as const, damageMultiplier: 1.35,
}),
Object.freeze({
id: "spell-55815", name: "Eck Spring", spellId: 55_815, delivery: "projectile", target: "random-party",
school: "physical", animation: "cast", castTimeMs: 750, range: 30, cooldownMs: 17_000,
initialCooldownMs: [10_000, 24_000] as const, repeatCooldownMs: [10_000, 24_000] as const, damageMultiplier: 1.35,
telegraph: { shape: "circle", anchor: "target", warningMs: 750, radius: 3 } as const,
}),
Object.freeze({
id: "spell-55816", name: "Eck Berserk", spellId: 55_816, delivery: "area", target: "self",
school: "physical", animation: "cast", castTimeMs: 0, range: 0, cooldownMs: 90_000,
initialCooldownMs: [90_000, 90_000] as const, repeatCooldownMs: [90_000, 90_000] as const, maximumCasts: 1,
damageMultiplier: 0, effect: { kind: "aura", aura: "enrage", durationMs: 0, magnitude: 1 } as const,
}),
]);
export const WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS: readonly WowWrathEncounterBatchTwoReview[] = Object.freeze([
@@ -239,11 +277,20 @@ export const WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS: readonly WowWrathEncounterBa
sha256: "ec0477724b5343879ecfd713060c408796373d3f6fbeaf16dcef84f1393c6356",
anchors: ["boss, altar, statue, bridge, collision, Eck, and Gal'darah door ids (lines 27-73)"],
},
{
path: GUNDRAK_FILES[2],
sha256: "ef6f4736b921c5f654260cb0c61f2590e77270c8e5a4c4f0935db4c5f0478e07",
anchors: [
"Eck Spring initialization and threat handoff (lines 51-72)",
"Bite, Spit, Spring, and 90-second Berserk schedules (lines 75-90)",
],
},
],
reviewedBehavior: "Slad'ran, Moorabi, and the Drakkari Colossus independently enable their exact altars; using all three rotates the exact statues and bridge collision after five seconds, exposing Gal'darah; Moorabi releases the heroic Eck route.",
adaptations: [
"The bridge and statues change discrete procedural states without native rotation animation; the source five-second delay is retained.",
"The optional Eck actor is activated after Moorabi for deterministic recovery rather than after the source Ruins Dweller formation; its actor spawn remains heroic-mask-only, so normal rendering filters it out.",
"Eck keeps the source's independent Bite, Spit, Spring, and 90-second Berserk timings; Spring uses a short target warning because the runtime does not yet execute creature jump motion.",
"Gal'darah's room door closes on engagement and all three source Gal'darah doors open on death; no native door animation is claimed.",
"Exact imported altar, statue, bridge, collision, Eck-door, underwater-gate, and Gal'darah-door rows are replaced by procedural stateful objects at their source transforms.",
],
@@ -1529,9 +1576,33 @@ function buildGundrak(definition: DungeonDefinition): DungeonDefinition {
const galDarah = objectiveForEntry(definition, 29_306);
const eckBase = objectiveForEntry(definition, 29_932);
const eck = eventObjective(definition, eckBase.id);
const entities = ensureEntities(definition, []);
const eckEntityId = entityIdForEntry(entities, 29_932);
const baseEntities = ensureEntities(definition, []);
const eckEntityId = entityIdForEntry(baseEntities, 29_932);
if (!eckEntityId) throw new Error("gundrak is missing Eck the Ferocious entry 29932");
const sourceEck = baseEntities[eckEntityId];
if (!sourceEck.combat) throw new Error("gundrak Eck the Ferocious is missing combat data");
if (!sourceEck.visual.model) throw new Error("gundrak Eck the Ferocious is missing its source model");
const entities = Object.freeze({
...baseEntities,
[eckEntityId]: Object.freeze({
...sourceEck,
visual: Object.freeze({
...sourceEck.visual,
model: Object.freeze({
...sourceEck.visual.model,
// The skinned Oracle bind pose extends far below its animated root.
// Treating that minimum as the floor lifted Eck out of the camera.
groundOffset: 0,
labelHeight: 7,
}),
}),
combat: Object.freeze({
...sourceEck.combat,
mechanicIds: Object.freeze(ECK_ATTACKS.slice(1).map((attack) => attack.id)),
attacks: ECK_ATTACKS,
}),
}),
});
const eckGroup: DungeonEncounterSpawnGroupDefinition = Object.freeze({
id: "gundrak:eck",
spawns: Object.freeze([spawnAt("gundrak:eck-actor", eckEntityId, eck.position, [0, 0], 2)]),
+458
View File
@@ -0,0 +1,458 @@
import { Line } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import { useShellStore } from "../app/shellStore";
import { saveDungeonSimulationReplay } from "../app/dungeonSimulationClient";
import { useCombatStore } from "../game/combatStore";
import { combatSpatialActor } from "../game/combatSpatial";
import { activeDungeonCompletedObjectiveIds } from "../game/dungeonEncounterRuntime";
import { getMobPosition, updateMobRuntimePosition } from "../game/mobRuntimeRegistry";
import { getPartyRuntimePosition, updatePartyRuntimePosition } from "../game/partyRuntimeRegistry";
import { usePartyStore, type PartyMemberStatus } from "../game/partyStore";
import { dungeonRouteDefinition, positionAlongRouteCorridor, routePointDistanceSquared } from "../game/dungeonRoutes";
import {
DUNGEON_REPLAY_KEYFRAME_MS,
DUNGEON_REPLAY_SAMPLE_MS,
DUNGEON_SIMULATION_MAX_DURATION_MS,
useDungeonSimulationStore,
} from "../game/dungeonSimulationStore";
import type {
DungeonReplayFrame,
DungeonSimulationResult,
DungeonRouteLeg,
DungeonSimulationActorSnapshot,
} from "../game/dungeonSimulationTypes";
import type { Vector3Tuple } from "../game/dungeonTypes";
let savingRunKey: string | null = null;
function simulationRoster() {
return usePartyStore.getState().members.map((member) => ({
id: member.id,
name: member.name,
role: member.role,
classId: member.classId,
}));
}
export async function finalizeDungeonSimulation(result: DungeonSimulationResult): Promise<void> {
const simulation = useDungeonSimulationStore.getState();
if (simulation.sessionMode !== "spectate-live" || simulation.runState === "finished") return;
const replay = simulation.finish(result, simulationRoster());
if (!replay) return;
const key = `${replay.dungeonId}:${replay.routeHash}:${replay.startedAt}`;
if (savingRunKey === key) return;
savingRunKey = key;
try {
await saveDungeonSimulationReplay(useShellStore.getState().session, replay);
} catch (error) {
console.error("Unable to upload dungeon simulation replay", error);
}
}
function actorSnapshots(): readonly DungeonSimulationActorSnapshot[] {
const party = usePartyStore.getState();
const companions = party.members.map((member) => ({
id: member.id,
position: getPartyRuntimePosition(member.id) ?? [0, 0, 0],
yaw: combatSpatialActor(member.id)?.yaw ?? 0,
health: member.health,
maxHealth: member.maxHealth,
targetId: party.memberTargetIds[member.id] ?? party.activeMobId,
status: member.status,
}));
const mobs = Object.values(useCombatStore.getState().mobs).map((mob) => ({
id: mob.id,
position: getMobPosition(mob.id) ?? [0, 0, 0] as Vector3Tuple,
yaw: combatSpatialActor(mob.id)?.yaw ?? 0,
health: mob.health,
maxHealth: mob.maxHealth,
targetId: mob.targetActorId,
status: mob.dead ? "dead" : mob.engaged ? "engaged" : "idle",
}));
return [...companions, ...mobs];
}
function routeProgress(position: Vector3Tuple, leg: DungeonRouteLeg): number {
let cumulative = 0;
let bestDistance = Number.POSITIVE_INFINITY;
let bestProgress = 0;
for (let index = 1; index < leg.checkpoints.length; index += 1) {
const start = leg.checkpoints[index - 1];
const end = leg.checkpoints[index];
const segmentLength = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2]);
const projection = routePointDistanceSquared(position, start, end);
const score = projection.planarSquared + projection.vertical ** 2;
if (score < bestDistance) {
bestDistance = score;
bestProgress = cumulative + segmentLength * projection.progress;
}
cumulative += segmentLength;
}
return bestProgress;
}
interface DiagnosticCursor {
runKey: string;
nextSampleAt: number;
lastLeaderPosition: Vector3Tuple | null;
lastProgressAt: number;
offCorridorAt: number | null;
routeBlockedAt: number | null;
splitAt: Map<string, number>;
observedPulls: Set<string>;
objectiveId: string | null;
objectiveDistance: number;
maximumRouteProgress: number;
finalCheckpointAt: number | null;
replayFrameAt: number;
}
function newCursor(runKey: string): DiagnosticCursor {
return {
runKey,
nextSampleAt: 0,
lastLeaderPosition: null,
lastProgressAt: 0,
offCorridorAt: null,
routeBlockedAt: null,
splitAt: new Map(),
observedPulls: new Set(),
objectiveId: null,
objectiveDistance: 0,
maximumRouteProgress: 0,
finalCheckpointAt: null,
replayFrameAt: -1,
};
}
function addFinding(
id: string,
severity: "info" | "warning" | "critical",
code: string,
message: string,
position?: Vector3Tuple,
actorId?: string,
): void {
const simulation = useDungeonSimulationStore.getState();
simulation.addFinding({
id,
atMs: simulation.elapsedMs,
severity,
code,
message,
position,
actorId,
objectiveId: simulation.objectiveId ?? undefined,
checkpointIndex: simulation.checkpointIndex,
});
}
function applyReplayFrame(frame: DungeonReplayFrame): void {
const party = usePartyStore.getState();
const combat = useCombatStore.getState();
const snapshots = new Map(frame.actors.map((actor) => [actor.id, actor] as const));
const members = party.members.map((member) => {
const snapshot = snapshots.get(member.id);
if (!snapshot) return member;
updatePartyRuntimePosition(member.id, ...snapshot.position);
return {
...member,
health: snapshot.health,
maxHealth: snapshot.maxHealth,
status: snapshot.status as PartyMemberStatus,
};
});
const mobs = { ...combat.mobs };
for (const snapshot of frame.actors) {
const mob = mobs[snapshot.id];
if (!mob) continue;
updateMobRuntimePosition(snapshot.id, ...snapshot.position, snapshot.yaw);
mobs[snapshot.id] = {
...mob,
health: snapshot.health,
maxHealth: snapshot.maxHealth,
dead: snapshot.status === "dead" || snapshot.health <= 0,
engaged: snapshot.status === "engaged",
targetActorId: snapshot.targetId,
};
}
party.commitRuntime({
members,
activeMobId: null,
objectiveBossId: frame.objectiveId,
memberTargetIds: Object.fromEntries(frame.actors.flatMap((actor) => actor.targetId ? [[actor.id, actor.targetId]] : [])),
});
useCombatStore.setState({ mobs });
combat.commitCompanionActors(members);
useDungeonSimulationStore.getState().setRouteProgress(frame.objectiveId, frame.checkpointIndex);
}
function recordPulls(cursor: DiagnosticCursor, leaderPosition: Vector3Tuple, leg: DungeonRouteLeg | null): void {
const simulation = useDungeonSimulationStore.getState();
const partyPositions = usePartyStore.getState().members
.map((member) => getPartyRuntimePosition(member.id))
.filter((position): position is Vector3Tuple => Boolean(position));
for (const mob of Object.values(useCombatStore.getState().mobs).sort((left, right) => left.id.localeCompare(right.id))) {
if (mob.dead || mob.despawned || cursor.observedPulls.has(mob.id)) continue;
const position = getMobPosition(mob.id);
if (!position) continue;
const theoretical = simulation.mode === "navigation-only" && partyPositions.some((partyPosition) => (
Math.hypot(position[0] - partyPosition[0], position[1] - partyPosition[1], position[2] - partyPosition[2])
<= Math.max(0, mob.aggroRange)
));
if (!mob.engaged && !theoretical) continue;
cursor.observedPulls.add(mob.id);
const classification = theoretical
? "theoretical" as const
: leg && positionAlongRouteCorridor(position, leg.checkpoints, leg.corridorPlanarDistance, leg.corridorVerticalDistance)
? "expected" as const
: "off-route" as const;
simulation.addPull({
atMs: simulation.elapsedMs,
mobIds: [mob.id],
routeLegId: leg?.id ?? null,
position: [leaderPosition[0], leaderPosition[1], leaderPosition[2]],
classification,
});
}
}
function inspectLiveSimulation(cursor: DiagnosticCursor): void {
const simulation = useDungeonSimulationStore.getState();
const party = usePartyStore.getState();
const leader = party.members.find((member) => member.health > 0);
const leaderPosition = leader ? getPartyRuntimePosition(leader.id) : null;
if (!leader || !leaderPosition) return;
const route = simulation.dungeonId ? dungeonRouteDefinition(simulation.dungeonId, simulation.routeId ?? "primary") : null;
const leg = route?.legs.find((candidate) => candidate.objectiveId === simulation.objectiveId) ?? null;
if (cursor.objectiveId !== simulation.objectiveId) {
if (cursor.objectiveId && route) {
const previousLeg = route.legs.find((candidate) => candidate.objectiveId === cursor.objectiveId);
if (previousLeg && cursor.objectiveDistance > previousLeg.expectedDistance * 1.5
&& cursor.objectiveDistance > previousLeg.expectedDistance + 10) {
addFinding(
`detour:${cursor.objectiveId}`,
"warning",
"excessive-detour",
`Leg ${previousLeg.id} travelled ${cursor.objectiveDistance.toFixed(1)} m for an expected ${previousLeg.expectedDistance.toFixed(1)} m.`,
leaderPosition,
leader.id,
);
}
}
cursor.objectiveId = simulation.objectiveId;
cursor.objectiveDistance = 0;
cursor.maximumRouteProgress = 0;
cursor.finalCheckpointAt = null;
}
if (cursor.lastLeaderPosition) {
const moved = Math.hypot(
leaderPosition[0] - cursor.lastLeaderPosition[0],
leaderPosition[1] - cursor.lastLeaderPosition[1],
leaderPosition[2] - cursor.lastLeaderPosition[2],
);
cursor.objectiveDistance += moved;
if (moved >= 0.1) cursor.lastProgressAt = simulation.elapsedMs;
} else cursor.lastProgressAt = simulation.elapsedMs;
cursor.lastLeaderPosition = [leaderPosition[0], leaderPosition[1], leaderPosition[2]];
if (party.command === "attack" && simulation.objectiveId && simulation.elapsedMs - cursor.lastProgressAt >= 2_000) {
addFinding(
`stall:${simulation.objectiveId}:${simulation.checkpointIndex}`,
"warning",
"movement-stall",
`${leader.name} made no movement progress for at least two seconds.`,
leaderPosition,
leader.id,
);
}
if (party.routeBlocked) {
cursor.routeBlockedAt ??= simulation.elapsedMs;
if (simulation.elapsedMs - cursor.routeBlockedAt >= 1_000) {
addFinding(
`no-path:${simulation.objectiveId}:${simulation.checkpointIndex}`,
"critical",
"no-path",
`No collision-valid path to checkpoint ${simulation.checkpointIndex}.`,
leaderPosition,
leader.id,
);
}
} else cursor.routeBlockedAt = null;
if (leg) {
const inside = positionAlongRouteCorridor(
leaderPosition,
leg.checkpoints,
leg.corridorPlanarDistance,
leg.corridorVerticalDistance,
);
if (!inside) {
cursor.offCorridorAt ??= simulation.elapsedMs;
if (simulation.elapsedMs - cursor.offCorridorAt >= 1_000) {
addFinding(
`off-corridor:${leg.id}:${simulation.checkpointIndex}`,
"warning",
"off-corridor",
`${leader.name} remained outside the ${leg.corridorPlanarDistance} m / ${leg.corridorVerticalDistance} m route corridor for one second.`,
leaderPosition,
leader.id,
);
}
} else cursor.offCorridorAt = null;
const progress = routeProgress(leaderPosition, leg);
cursor.maximumRouteProgress = Math.max(cursor.maximumRouteProgress, progress);
if (cursor.maximumRouteProgress - progress >= 8) {
addFinding(
`backtrack:${leg.id}:${Math.floor(cursor.maximumRouteProgress / 8)}`,
"warning",
"backtracking",
`${leader.name} backtracked at least 8 m on ${leg.id}.`,
leaderPosition,
leader.id,
);
}
if (simulation.checkpointIndex >= leg.checkpoints.length - 1) {
cursor.finalCheckpointAt ??= simulation.elapsedMs;
if (
simulation.mode === "navigation-only"
&& simulation.elapsedMs - cursor.finalCheckpointAt >= 5_000
) {
addFinding(
`scripted-progression:${leg.id}`,
"critical",
"failed-scripted-progression",
`Navigation-only progression did not advance after reaching ${leg.objectiveName}.`,
leaderPosition,
leader.id,
);
}
} else cursor.finalCheckpointAt = null;
}
for (const follower of party.members.filter((member) => member.health > 0 && member.id !== leader.id)) {
const position = getPartyRuntimePosition(follower.id);
if (!position) continue;
const distance = Math.hypot(
position[0] - leaderPosition[0],
position[1] - leaderPosition[1],
position[2] - leaderPosition[2],
);
if (distance > 12) {
const separatedAt = cursor.splitAt.get(follower.id) ?? simulation.elapsedMs;
cursor.splitAt.set(follower.id, separatedAt);
if (simulation.elapsedMs - separatedAt >= 2_000) {
addFinding(
`separation:${follower.id}:${simulation.objectiveId}`,
"warning",
"party-separation",
`${follower.name} remained more than 12 m from the leader for two seconds.`,
position,
follower.id,
);
}
} else cursor.splitAt.delete(follower.id);
}
if (simulation.recoveries >= 2) {
addFinding(
`repeated-recovery:${simulation.objectiveId}`,
"critical",
"repeated-recovery",
"The simulator required repeated position recovery teleports.",
leaderPosition,
leader.id,
);
}
recordPulls(cursor, leaderPosition, leg);
while (simulation.elapsedMs >= cursor.nextSampleAt) {
const atMs = cursor.nextSampleAt;
simulation.addFrame({
atMs,
keyframe: atMs % DUNGEON_REPLAY_KEYFRAME_MS === 0,
actors: actorSnapshots(),
objectiveId: simulation.objectiveId,
checkpointIndex: simulation.checkpointIndex,
});
cursor.nextSampleAt += DUNGEON_REPLAY_SAMPLE_MS;
}
if (simulation.elapsedMs >= DUNGEON_SIMULATION_MAX_DURATION_MS) {
void finalizeDungeonSimulation("recording-limit");
} else if (!party.members.some((member) => member.health > 0)) {
void finalizeDungeonSimulation("wiped");
} else if (simulation.mode === "navigation-only" && simulation.objectiveId === null) {
void finalizeDungeonSimulation("completed");
} else if (
simulation.mode === "full-combat"
&& simulation.elapsedMs >= 1_000
&& route
&& route.legs.every((candidate) => activeDungeonCompletedObjectiveIds(route.dungeonId).includes(candidate.objectiveId))
) {
void finalizeDungeonSimulation("completed");
}
}
export function DungeonSimulationSceneBridge() {
const sessionMode = useDungeonSimulationStore((state) => state.sessionMode);
const dungeonId = useDungeonSimulationStore((state) => state.dungeonId);
const routeId = useDungeonSimulationStore((state) => state.routeId);
const objectiveId = useDungeonSimulationStore((state) => state.objectiveId);
const checkpointIndex = useDungeonSimulationStore((state) => state.checkpointIndex);
const frames = useDungeonSimulationStore((state) => state.frames);
const cursorRef = useRef<DiagnosticCursor>(newCursor(""));
const route = useMemo(
() => dungeonId ? dungeonRouteDefinition(dungeonId, routeId ?? "primary") : null,
[dungeonId, routeId],
);
const leg = route?.legs.find((candidate) => candidate.objectiveId === objectiveId) ?? null;
const trace = useMemo(() => frames
.map((frame) => frame.actors[0]?.position)
.filter((position): position is Vector3Tuple => Boolean(position))
.slice(-2_000), [frames]);
useFrame(() => {
const simulation = useDungeonSimulationStore.getState();
if (simulation.sessionMode === "play") return;
const runKey = `${simulation.sessionMode}:${simulation.dungeonId}:${simulation.startedAt}`;
if (cursorRef.current.runKey !== runKey) cursorRef.current = newCursor(runKey);
if (simulation.sessionMode === "spectate-replay") {
const replay = simulation.replay;
if (!replay?.frames.length) return;
const frame = [...replay.frames].reverse().find((candidate) => candidate.atMs <= simulation.elapsedMs)
?? replay.frames[0];
if (frame.atMs !== cursorRef.current.replayFrameAt) {
cursorRef.current.replayFrameAt = frame.atMs;
applyReplayFrame(frame);
}
return;
}
if (simulation.runState === "finished") return;
inspectLiveSimulation(cursorRef.current);
}, 1);
if (sessionMode === "play") return null;
return (
<group name="dungeon-simulation-route-overlay">
{leg && leg.checkpoints.length > 1 && (
<Line points={leg.checkpoints} color="#60d9ff" lineWidth={2} transparent opacity={0.72} />
)}
{leg?.checkpoints.map((checkpoint, index) => (
<mesh key={`${leg.id}:${index}`} position={checkpoint}>
<sphereGeometry args={[index === checkpointIndex ? 0.28 : 0.14, 8, 8]} />
<meshBasicMaterial color={index === checkpointIndex ? "#ffe56c" : "#59bde8"} transparent opacity={0.85} />
</mesh>
))}
{trace.length > 1 && (
<Line points={trace} color="#ff9f43" lineWidth={1.5} transparent opacity={0.8} />
)}
</group>
);
}
+86
View File
@@ -0,0 +1,86 @@
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 { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
import { usePartyStore } from "../game/partyStore";
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import { useGameStore } from "../game/store";
const FOLLOW_DISTANCE = 7;
const FOLLOW_HEIGHT = 2.4;
const LOOK_SENSITIVITY = 0.0021;
export function DungeonSpectatorCamera() {
const camera = useThree((state) => state.camera);
const { rapier, world } = useRapier();
const yawRef = useRef(useGameStore.getState().cameraYaw);
const pitchRef = useRef(-0.22);
const freePositionRef = useRef(new Vector3().copy(camera.position));
const boostRef = useRef(false);
const initializedFreeRef = useRef(false);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
boostRef.current = event.shiftKey;
};
window.addEventListener("keydown", onKey);
window.addEventListener("keyup", onKey);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("keyup", onKey);
};
}, []);
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") {
if (!initializedFreeRef.current) {
freePositionRef.current.copy(camera.position);
initializedFreeRef.current = true;
}
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);
camera.position.copy(freePositionRef.current);
camera.lookAt(new Vector3().copy(camera.position).add(forward));
return;
}
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 backward = new Vector3(
-Math.sin(yawRef.current) * Math.cos(pitchRef.current),
-Math.sin(pitchRef.current),
-Math.cos(yawRef.current) * Math.cos(pitchRef.current),
).normalize();
const ray = new rapier.Ray(anchor, backward);
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)));
camera.lookAt(anchor);
});
return null;
}
+13 -4
View File
@@ -69,6 +69,9 @@ import {
activeEncounterSpawnGroupSpawns,
suppressCompletedDungeonPopulation,
} from "../game/dungeonEncounterSpawns";
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import { DungeonSpectatorCamera } from "./DungeonSpectatorCamera";
import { DungeonSimulationSceneBridge } from "./DungeonSimulationSceneBridge";
function DungeonCameraRange({ far }: { readonly far: number }) {
const camera = useThree((state) => state.camera);
@@ -170,6 +173,8 @@ export function GameScene() {
const paused = useGameStore((state) => state.paused);
const mapOpen = useGameStore((state) => state.mapOpen);
const companionOpen = useGameStore((state) => state.companionOpen);
const simulationSessionMode = useDungeonSimulationStore((state) => state.sessionMode);
const simulationRunState = useDungeonSimulationStore((state) => state.runState);
const gameplayFrameRate = useCombatStore((state) => state.settings.gameplayFrameRate);
const sharedOnline = useOnlineGroupStore((state) => state.activeActivity !== null);
const onlineAuthority = useOnlineSessionStore((state) => state.snapshot?.authority ?? true);
@@ -383,15 +388,19 @@ export function GameScene() {
<Physics
key={encounterWorldKey(activeDungeonId, sessionRevision)}
gravity={[0, PLAYER_GRAVITY, 0]}
paused={simulationBlocked}
paused={simulationBlocked || (simulationSessionMode === "spectate-live" && simulationRunState !== "running")}
timeStep={1 / 60}
>
<DungeonEnvironment onReady={markEnvironmentReady} />
{worldReady && (
<>
<PlayerRig />
<OnlinePlayerPopulation active={!simulationBlocked} />
<PartyPopulation active={!simulationBlocked} simulate={!sharedOnline || onlineAuthority} />
{simulationSessionMode === "play" ? <PlayerRig /> : <DungeonSpectatorCamera />}
{simulationSessionMode === "play" && <OnlinePlayerPopulation active={!simulationBlocked} />}
<PartyPopulation
active={!simulationBlocked}
simulate={simulationSessionMode !== "spectate-replay" && (!sharedOnline || onlineAuthority)}
/>
<DungeonSimulationSceneBridge />
<GroundEffectTelegraphs />
{gameMode === "dungeon" && (
<DungeonWorldObjects definitions={dungeon.serverGameObjects} />
+66 -9
View File
@@ -57,6 +57,7 @@ import {
samplePackFormation,
} from "../game/mobPopulation";
import { useGameStore } from "../game/store";
import { dungeonSimulationNow, useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import {
manastormExperienceReward,
manastormRuntimeScaling,
@@ -913,7 +914,7 @@ function ProxyMob({
const attackRevision = mob?.attackRevision ?? 0;
const attackAnimation = mob?.attackAnimation ?? "attack";
const woundRevision = mob?.woundRevision ?? 0;
const statusNow = Date.now();
const statusNow = dungeonSimulationNow();
const movementLocked = mob?.statuses.some((status) => status.endsAt > statusNow
&& MOVEMENT_LOCKING_MOB_STATUSES.has(status.kind)) ?? false;
const feared = mob?.statuses.some((status) => status.endsAt > statusNow
@@ -1106,7 +1107,7 @@ function MobHealthPlate({ instanceId, height, boss }: {
if (plateRef.current) plateRef.current.lookAt(camera.position);
if (castFillRef.current && mob?.activeCast) {
const duration = Math.max(1, mob.activeCast.completesAt - mob.activeCast.startedAt);
const now = Date.now();
const now = dungeonSimulationNow();
const progress = Math.max(0.001, Math.min(1, (now - mob.activeCast.startedAt) / duration));
castFillRef.current.scale.x = progress;
castFillRef.current.position.x = -width * (1 - progress) / 2;
@@ -1212,6 +1213,7 @@ function RoamingPack({
const nearby = usePopulationProximity(scratchPositions);
const memberRefs = useRef<Array<Group | null>>([]);
const chasingRefs = useRef<boolean[]>(definition.members.map(() => false));
const simulationElapsedRef = useRef(0);
useEffect(() => {
const cleanups = definition.members.map((member, index) => registerMobRuntime(
@@ -1226,8 +1228,13 @@ function RoamingPack({
if (!nearby) return;
const combat = useCombatStore.getState();
const simulation = useDungeonSimulationStore.getState();
const movementDelta = simulation.sessionMode === "spectate-live"
? Math.max(0, (simulation.elapsedMs - simulationElapsedRef.current) / 1_000)
: simulation.sessionMode === "spectate-replay" ? 0 : delta;
simulationElapsedRef.current = simulation.elapsedMs;
const playerPosition = useGameStore.getState().playerPosition;
const now = Date.now();
const now = dungeonSimulationNow();
const anyEngaged = definition.members.some((member) => combat.mobs[`${definition.id}:${member.id}`]?.engaged);
if (active && !anyEngaged) {
let movementMultiplier = 1;
@@ -1242,7 +1249,7 @@ function RoamingPack({
const slow = statuses.find((status) => status.endsAt > now && status.kind === "slow");
if (slow) movementMultiplier = Math.min(movementMultiplier, 1 - (slow.magnitude ?? 0.5));
}
distanceRef.current = (distanceRef.current + Math.min(delta, 0.05) * definition.speed * movementMultiplier)
distanceRef.current = (distanceRef.current + Math.min(movementDelta, 0.5) * definition.speed * movementMultiplier)
% path.totalLength;
}
@@ -1259,6 +1266,20 @@ function RoamingPack({
const forward = scratchForwards[index];
const member = definition.members[index];
const instanceId = `${definition.id}:${member.id}`;
if (simulation.sessionMode === "spectate-replay") {
const replayTransform = getMobRuntimeTransform(instanceId);
if (replayTransform) {
const object = memberRefs.current[index];
if (object) {
object.position.set(...replayTransform.position);
object.rotation.y = replayTransform.yaw;
}
chasePositions[index][0] = replayTransform.position[0];
chasePositions[index][1] = replayTransform.position[1];
chasePositions[index][2] = replayTransform.position[2];
}
continue;
}
const synchronizedTransform = onlineSynchronizedMobTransform(instanceId);
if (synchronizedTransform) {
const object = memberRefs.current[index];
@@ -1389,8 +1410,27 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
const entity = entities[spawn.entityId];
const groupRef = useRef<Group>(null);
const positionRef = useRef<MutableMobVector3>([...spawn.position]);
const simulationElapsedRef = useRef(0);
useEffect(() => registerMobRuntime(spawn.id, spawn.position, spawn.yaw ?? 0), [spawn.id, spawn.position, spawn.yaw]);
useFrame((_, delta) => {
const simulation = useDungeonSimulationStore.getState();
const movementDelta = simulation.sessionMode === "spectate-live"
? Math.max(0, (simulation.elapsedMs - simulationElapsedRef.current) / 1_000)
: simulation.sessionMode === "spectate-replay" ? 0 : delta;
simulationElapsedRef.current = simulation.elapsedMs;
if (simulation.sessionMode === "spectate-replay") {
const replayTransform = getMobRuntimeTransform(spawn.id);
if (replayTransform) {
positionRef.current[0] = replayTransform.position[0];
positionRef.current[1] = replayTransform.position[1];
positionRef.current[2] = replayTransform.position[2];
if (groupRef.current) {
groupRef.current.position.set(...replayTransform.position);
groupRef.current.rotation.y = replayTransform.yaw;
}
}
return;
}
const synchronizedTransform = onlineSynchronizedMobTransform(spawn.id);
if (synchronizedTransform) {
positionRef.current[0] = synchronizedTransform.position[0];
@@ -1413,7 +1453,7 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
const position = positionRef.current;
const player = useGameStore.getState().playerPosition;
const targetPosition = mobTargetPosition(mob?.targetActorId, player);
const now = Date.now();
const now = dungeonSimulationNow();
let feared = false;
let movementLocked = false;
if (mob?.dead) {
@@ -1423,7 +1463,7 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
&& MOVEMENT_LOCKING_MOB_STATUSES.has(status.kind));
feared = mob.statuses.some((status) => status.endsAt > now && status.kind === "fear");
const slow = mob.statuses.find((status) => status.endsAt > now && status.kind === "slow")?.magnitude ?? 0;
const maximumStep = Math.min(delta, 0.05) * mob.moveSpeed * Math.max(0.1, 1 - slow);
const maximumStep = Math.min(movementDelta, 0.5) * mob.moveSpeed * Math.max(0.1, 1 - slow);
if (active && !movementLocked && feared) {
stepFearedMobAway(
position,
@@ -1438,7 +1478,7 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
maximumStep, active, now, movementLocked || feared);
} else if (mob?.combatPhase === "returning") {
navigateMob(navigator, mob, position, targetPosition, mob.homePosition ?? spawn.position,
Math.min(delta, 0.05) * mob.moveSpeed * 1.25, active, now);
Math.min(movementDelta, 0.5) * mob.moveSpeed * 1.25, active, now);
} else {
position[0] = spawn.position[0];
position[1] = spawn.position[1];
@@ -1531,18 +1571,35 @@ function SummonedPopulationMember({
const groupRef = useRef<Group>(null);
const initial = getMobRuntimeTransform(instanceId)?.position ?? [0, 0, 0];
const positionRef = useRef<MutableMobVector3>([...initial]);
const simulationElapsedRef = useRef(0);
useFrame((_, delta) => {
const simulation = useDungeonSimulationStore.getState();
const movementDelta = simulation.sessionMode === "spectate-live"
? Math.max(0, (simulation.elapsedMs - simulationElapsedRef.current) / 1_000)
: simulation.sessionMode === "spectate-replay" ? 0 : delta;
simulationElapsedRef.current = simulation.elapsedMs;
if (simulation.sessionMode === "spectate-replay") {
const replayTransform = getMobRuntimeTransform(instanceId);
if (replayTransform && groupRef.current) {
positionRef.current[0] = replayTransform.position[0];
positionRef.current[1] = replayTransform.position[1];
positionRef.current[2] = replayTransform.position[2];
groupRef.current.position.set(...replayTransform.position);
groupRef.current.rotation.y = replayTransform.yaw;
}
return;
}
const mob = useCombatStore.getState().mobs[instanceId];
if (!mob || mob.dead || mob.despawned) return;
const player = useGameStore.getState().playerPosition;
const targetPosition = mobTargetPosition(mob.targetActorId, player);
const position = positionRef.current;
const now = Date.now();
const now = dungeonSimulationNow();
const movementLocked = mob.statuses.some((status) => status.endsAt > now
&& MOVEMENT_LOCKING_MOB_STATUSES.has(status.kind));
const feared = mob.statuses.some((status) => status.endsAt > now && status.kind === "fear");
const slow = mob.statuses.find((status) => status.endsAt > now && status.kind === "slow")?.magnitude ?? 0;
const maximumStep = Math.min(delta, 0.05) * mob.moveSpeed * Math.max(0.1, 1 - slow);
const maximumStep = Math.min(movementDelta, 0.5) * mob.moveSpeed * Math.max(0.1, 1 - slow);
if (active && !movementLocked && feared) {
stepFearedMobAway(
position,
+105 -33
View File
@@ -106,6 +106,13 @@ import {
PARTY_FLOOR_RAY_DEPTH,
PARTY_FLOOR_RAY_HEIGHT,
} from "./partyTraversal";
import {
activeDungeonRouteTarget,
routeForActiveDungeon,
type ActiveDungeonRouteTarget,
} from "../game/dungeonRouteRuntime";
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import { useDungeonRouteStore } from "../game/dungeonRouteStore";
const PARTY_MOVE_SPEED = 5.25;
const PARTY_CATCH_UP_SPEED = 7.5;
@@ -363,6 +370,7 @@ function PartyActors({
const movementProfilesRef = useRef(new Map<string, PartyMovementProfile>());
const directFollowMovingRef = useRef(new Map<string, boolean>());
const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>());
const lastSimulationElapsedRef = useRef(0);
const memberIdKey = members.map((member) => member.id).join("|");
useEffect(() => registerCombatSpatialProvider({
actor: (actorId) => {
@@ -611,10 +619,15 @@ function PartyActors({
return;
}
const game = useGameStore.getState();
const simulation = useDungeonSimulationStore.getState();
const playerNavigation = game.playerNavigationPosition;
appendPartyBreadcrumb(playerTrailRef.current, playerNavigation);
const routeNow = clock.elapsedTime * 1_000;
const combatNow = Date.now();
const routeNow = simulation.sessionMode === "spectate-live"
? simulation.elapsedMs
: clock.elapsedTime * 1_000;
const combatNow = simulation.sessionMode === "spectate-live"
? simulation.startedAt + simulation.elapsedMs
: Date.now();
for (const member of party.members) {
if (member.health <= 0) {
finishPartyRuntimeRejoin(member.id);
@@ -673,7 +686,13 @@ function PartyActors({
}
const objective = objectives.find((candidate) => candidate.id === party.objectiveBossId) ?? null;
const leaderIndex = party.members.findIndex((member) => member.health > 0 && !partyRuntimeIsRejoining(member.id));
const safeDelta = Math.min(delta, 0.05);
const simulationDelta = simulation.sessionMode === "spectate-live"
? Math.max(0, (simulation.elapsedMs - lastSimulationElapsedRef.current) / 1_000)
: delta;
lastSimulationElapsedRef.current = simulation.elapsedMs;
const safeDelta = simulation.sessionMode === "spectate-live"
? Math.min(simulationDelta, 0.5)
: Math.min(delta, 0.05);
const desired: MutablePartyWorldPosition = [0, 0, 0];
const nextPosition: MutablePartyWorldPosition = [0, 0, 0];
const leaderDestination: MutablePartyWorldPosition = [0, 0, 0];
@@ -696,6 +715,7 @@ function PartyActors({
let leaderFollowingPlayer = false;
let leaderPlayerTrailBlocked = false;
let forceLeaderBreadcrumb = false;
let authoredRouteTarget: ActiveDungeonRouteTarget | null = null;
const leaderMember = leaderIndex >= 0 ? party.members[leaderIndex] : null;
const leaderPosition = leaderMember ? positionsRef.current.get(leaderMember.id) : null;
@@ -763,18 +783,28 @@ function PartyActors({
// Imported source spawns can sit on decorative or discarded Recast
// islands. Route to the nearest retained walkable surface and let the
// normal aggro/combat path take over when the encounter engages.
const objectiveApproach = navigationGraph
authoredRouteTarget = activeDungeonRouteTarget(
game.activeDungeonId,
objective.id,
leaderPosition,
leader.id,
combatNow,
);
const objectiveApproach = authoredRouteTarget?.target ?? (navigationGraph
? findNearestPartyNavigationSurface(
navigationGraph,
objective.position,
PARTY_OBJECTIVE_APPROACH_SNAP_DISTANCE,
)?.position
: null;
: null);
const objectiveDestination = objectiveApproach ?? objective.position;
leaderDestination[0] = objectiveDestination[0];
leaderDestination[1] = objectiveDestination[1];
leaderDestination[2] = objectiveDestination[2];
goalKey = `leader:${leader.id}|objective:${objective.id}`;
goalKey = authoredRouteTarget
? `leader:${leader.id}|route:${authoredRouteTarget.route.hash}|${authoredRouteTarget.leg.id}|${authoredRouteTarget.checkpointIndex}`
: `leader:${leader.id}|objective:${objective.id}`;
if (authoredRouteTarget) leaderLocalFinishDistance = 0.3;
} else {
leaderFollowingPlayer = true;
leaderDestination[0] = playerNavigation[0];
@@ -918,27 +948,38 @@ function PartyActors({
leaderDestination,
routeNow,
)) {
routeRef.current = planPartyRoute(
navigationGraph,
goalKey,
leaderPosition,
leaderDestination,
routeNow,
{
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
maxDestinationSnapDistance: 5,
localFinishDistance: leaderLocalFinishDistance,
directSegmentAllowed,
routeSegmentAllowed: navigationRouteSegmentAllowed,
destinationReached: targetPosition ? (surface) => {
const band = personalizedPartyCombatRangeBand(partyCombatRangeBand(leaderMember),
movementProfileFor(leaderMember.id, leaderIndex));
const distance = fullDistance(surface, targetPosition);
return distance >= band.minimum && distance <= band.maximum
&& hasStaticLineOfSight(surface, targetPosition);
} : undefined,
},
);
routeRef.current = authoredRouteTarget
&& !navigationGraph
&& directSegmentAllowed(leaderPosition, leaderDestination)
? {
goalKey,
destination: [leaderDestination[0], leaderDestination[1], leaderDestination[2]],
waypoints: [[leaderDestination[0], leaderDestination[1], leaderDestination[2]]],
cursor: 0,
plannedAt: routeNow,
blocked: false,
}
: planPartyRoute(
navigationGraph,
goalKey,
leaderPosition,
leaderDestination,
routeNow,
{
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
maxDestinationSnapDistance: 5,
localFinishDistance: leaderLocalFinishDistance,
directSegmentAllowed,
routeSegmentAllowed: navigationRouteSegmentAllowed,
destinationReached: targetPosition ? (surface) => {
const band = personalizedPartyCombatRangeBand(partyCombatRangeBand(leaderMember),
movementProfileFor(leaderMember.id, leaderIndex));
const distance = fullDistance(surface, targetPosition);
return distance >= band.minimum && distance <= band.maximum
&& hasStaticLineOfSight(surface, targetPosition);
} : undefined,
},
);
}
leaderHasWaypoint = nextPartyRouteWaypoint(routeRef.current, leaderPosition, desired);
if (!leaderHasWaypoint && routeRef.current.blocked) {
@@ -960,6 +1001,19 @@ function PartyActors({
desired[2] = leaderDestination[2];
leaderHasWaypoint = true;
}
if (
!leaderHasWaypoint
&& authoredRouteTarget
&& routeRef.current.blocked
&& simulation.sessionMode === "play"
&& directSegmentAllowed(leaderPosition, playerNavigation)
) {
desired[0] = playerNavigation[0];
desired[1] = playerNavigation[1];
desired[2] = playerNavigation[2];
leaderHasWaypoint = true;
leaderFollowingPlayer = true;
}
}
}
if (leaderHasWaypoint) {
@@ -980,12 +1034,12 @@ function PartyActors({
&& !targetPosition
&& leaderPosition
&& !routeRef.current.blocked
&& routeRef.current.waypoints.length > routeRef.current.cursor
&& (Boolean(authoredRouteTarget) || routeRef.current.waypoints.length > routeRef.current.cursor)
) {
updatePartyRuntimeAttackCorridor(
leaderPosition,
routeRef.current.waypoints,
routeRef.current.cursor,
authoredRouteTarget?.remaining ?? routeRef.current.waypoints,
authoredRouteTarget ? 0 : routeRef.current.cursor,
);
} else {
clearPartyRuntimeAttackCorridor();
@@ -998,7 +1052,7 @@ function PartyActors({
&& (
leaderFollowingPlayer
? leaderPlayerTrailBlocked
: navigationFailed || Boolean(navigationGraph && routeRef.current.blocked)
: navigationFailed || Boolean((navigationGraph || authoredRouteTarget) && routeRef.current.blocked)
),
);
@@ -1376,6 +1430,20 @@ function PartyActors({
trailPoints: [...leaderTrailRef.current.points, ...playerTrailRef.current.points].map((point) => point.position),
validate: recoveryPointAllowed,
});
if (landing && simulation.sessionMode === "spectate-live") {
simulation.addFinding({
id: `recovery:${member.id}:${simulation.elapsedMs}`,
atMs: simulation.elapsedMs,
severity: "critical",
code: "position-recovery",
message: `${member.name} required a position-only recovery teleport.`,
position: [current[0], current[1], current[2]],
actorId: member.id,
objectiveId: simulation.objectiveId ?? undefined,
checkpointIndex: simulation.checkpointIndex,
});
simulation.addRecovery();
}
if (landing && applyPartyStuckRecoveryLanding(positionsRef.current, member.id, landing.position, stuckProgress, routeNow)) {
updatePartyRuntimePosition(member.id, current[0], current[1], current[2]);
actor.position.set(current[0], presentationY(current), current[2]);
@@ -1504,6 +1572,8 @@ export function PartyPopulation({
const sessionRevision = useGameStore((state) => state.sessionRevision);
const wailingPhase = useWailingEncounterStore((state) => state.phase);
const manastormEncounter = useManastormStore((state) => state.currentEncounter);
const simulationSessionMode = useDungeonSimulationStore((state) => state.sessionMode);
useDungeonRouteStore((state) => state.approvals);
const dungeon = requireDungeonDefinition(activeDungeonId);
const navigationReviewed = dungeon.validation.status === "green"
|| dungeon.validation.navigation === "green";
@@ -1520,13 +1590,15 @@ export function PartyPopulation({
// fragmented Recast islands do not claim autonomous route fidelity. Until
// reviewed links make navigation green, the player leads and the party
// follows/engages locally instead of displaying a false blocked route.
: navigationReviewed
: simulationSessionMode === "spectate-live" || Boolean(routeForActiveDungeon(activeDungeonId))
? dungeon.objectiveOrder.filter((objective) => !objective.optional)
: navigationReviewed
? activeDungeonId === "wailing-caverns"
&& wailingPhase !== "mutanus"
&& wailingPhase !== "complete"
? dungeon.objectiveOrder.filter((objective) => !objective.eventOnly)
: dungeon.objectiveOrder.filter((objective) => !objective.optional)
: [];
: [];
const assets = useMemo(
() => manastormPackage
? resolveManastormStageAssets(manastormPackage)
+2 -1
View File
@@ -7,6 +7,7 @@ import {
wailingDisciplePosition,
} from "../game/wailingCavernsEncounter";
import { WorldNpcPrompt } from "./WorldNpcPrompt";
import { dungeonSimulationNow } from "../game/dungeonSimulationStore";
export function WailingNaralexEvent() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -17,7 +18,7 @@ export function WailingNaralexEvent() {
useFrame(() => {
if (activeDungeonId !== "wailing-caverns" || gameMode !== "dungeon") return;
const now = Date.now();
const now = dungeonSimulationNow();
const position = wailingDisciplePosition(
useWailingEncounterStore.getState().phase,
useWailingEncounterStore.getState().phaseStartedAt,
+252
View File
@@ -58,6 +58,18 @@ button { font: inherit; }
height: 100%;
}
.game-shell,
.arena-runtime {
-webkit-user-select: none;
user-select: none;
}
.game-shell :is(input, textarea, [contenteditable]:not([contenteditable="false"])),
.arena-runtime :is(input, textarea, [contenteditable]:not([contenteditable="false"])) {
-webkit-user-select: text;
user-select: text;
}
.scene-shell {
z-index: 0;
isolation: isolate;
@@ -2076,6 +2088,7 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
.main-menu-modes > header h2 { margin: 3px 0 4px; font: 28px Georgia, serif; font-weight: 400; }
.main-menu-modes > header > p:last-child { max-width: 570px; margin: 0; color: var(--front-dim); font-size: 9px; line-height: 1.45; }
.main-menu-mode-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 14px; }
.main-menu-gm-stack { display: grid; gap: 8px; }
.main-menu-mode {
position: relative;
display: grid;
@@ -3200,6 +3213,166 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
.gm-boss-actions { display: grid; gap: 7px; }
.gm-admin-denied { display: grid; width: min(470px, calc(100% - 30px)); margin: auto; gap: 12px; padding: 26px; border: 1px solid var(--front-line); background: rgba(3, 10, 7, .75); }
/* GM server-side balance simulations */
.balance-lab-screen { display: grid; grid-template-rows: auto minmax(0, 1fr); }
.balance-lab-screen .gm-admin-header__actions .front-button:not(.front-button--primary) { display: block; }
.balance-lab-layout { display: grid; grid-template-columns: minmax(290px, .9fr) minmax(245px, .72fr) minmax(270px, .82fr); grid-template-rows: minmax(420px, 56vh) auto; min-height: 0; overflow: auto; align-content: start; }
.balance-lab-config, .balance-lab-jobs, .balance-lab-detail { min-width: 0; min-height: 0; padding: 16px; overflow: auto; }
.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-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; }
.balance-lab-form-grid input, .balance-lab-form-grid select, .balance-lab-boss-picker input {
width: 100%; min-width: 0; padding: 8px; border: 1px solid var(--front-line); border-radius: 6px; outline: 0; color: var(--front-cream); background: rgba(2, 9, 6, .88); font: inherit;
}
.balance-lab-form-grid input:focus, .balance-lab-form-grid select:focus, .balance-lab-boss-picker input:focus { border-color: rgba(169, 211, 132, .65); }
.balance-lab-boss-picker { margin-top: 10px; border: 1px solid var(--front-line); border-radius: 8px; overflow: hidden; }
.balance-lab-boss-picker > header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid var(--front-line); }
.balance-lab-boss-picker > header strong { color: #d7c68c; font-size: 8px; white-space: nowrap; }
.balance-lab-boss-picker > div { display: grid; max-height: 220px; overflow: auto; }
.balance-lab-boss-picker label { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8px; padding: 7px 8px; border-bottom: 1px solid rgba(190, 213, 172, .08); cursor: pointer; }
.balance-lab-boss-picker label:hover, .balance-lab-boss-picker label.is-selected { background: rgba(149, 190, 118, .1); }
.balance-lab-boss-picker label input { width: 15px; height: 15px; accent-color: #91bc75; }
.balance-lab-boss-picker label span, .balance-lab-boss-picker label strong, .balance-lab-boss-picker label small { display: block; min-width: 0; }
.balance-lab-boss-picker label strong { overflow: hidden; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.balance-lab-boss-picker label small { margin-top: 2px; color: var(--front-dim); font-size: 7px; }
.balance-lab-estimate { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px; margin-top: 10px; }
.balance-lab-estimate span { display: grid; gap: 3px; padding: 8px; border: 1px solid rgba(190, 213, 172, .11); border-radius: 6px; color: var(--front-dim); font-size: 7px; }
.balance-lab-estimate b { color: var(--front-cream); font-size: 11px; }
.balance-lab-run { width: 100%; margin-top: 10px; }
.balance-lab-local-note { margin: 8px 0 0; }
.balance-lab-local-note code { color: #d7c68c; }
.balance-lab-jobs { display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
.balance-lab-jobs > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.balance-lab-jobs > header button { padding: 6px 9px; border: 1px solid var(--front-line); border-radius: 6px; color: var(--front-cream); background: transparent; cursor: pointer; font-size: 8px; }
.balance-lab-history-filters { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; margin-bottom: 8px; }
.balance-lab-history-filters input, .balance-lab-history-filters select { min-width: 0; padding: 7px 8px; border: 1px solid var(--front-line); border-radius: 6px; outline: 0; color: var(--front-cream); background: rgba(2, 9, 6, .88); font: inherit; font-size: 8px; }
.balance-lab-history-filters input:focus, .balance-lab-history-filters select:focus { border-color: rgba(169, 211, 132, .65); }
.balance-lab-job-list { min-height: 0; overflow: auto; border: 1px solid var(--front-line); border-radius: 8px; }
.balance-lab-job-list > button { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 10px; border: 0; border-bottom: 1px solid rgba(190, 213, 172, .08); color: var(--front-cream); background: transparent; cursor: pointer; text-align: left; }
.balance-lab-job-list > button:hover, .balance-lab-job-list > button.is-selected { background: rgba(149, 190, 118, .1); }
.balance-lab-job-list > button.is-selected { box-shadow: inset 3px 0 #9dcc7c; }
.balance-lab-job-list span, .balance-lab-job-list strong, .balance-lab-job-list small { display: block; min-width: 0; }
.balance-lab-job-list strong { font-size: 9px; }
.balance-lab-job-list small { margin-top: 3px; color: var(--front-dim); font-size: 7px; }
.balance-lab-job-list em { padding: 4px 6px; border-radius: 4px; color: #e8e3c8; background: rgba(173, 144, 76, .18); font-size: 7px; font-style: normal; text-transform: uppercase; }
.balance-lab-job-list em.is-complete { color: #c9f0b6; background: rgba(85, 146, 78, .24); }
.balance-lab-job-list em.is-failed { color: #f0b6ac; background: rgba(151, 62, 53, .24); }
.balance-lab-job-list em.is-running { color: #cce8ff; background: rgba(68, 125, 158, .24); }
.balance-lab-empty { padding: 12px; }
.balance-lab-detail { background: rgba(3, 10, 7, .48); }
.balance-lab-progress { height: 8px; margin: 12px 0; overflow: hidden; border: 1px solid var(--front-line); border-radius: 999px; background: rgba(0,0,0,.32); }
.balance-lab-progress span { display: block; height: 100%; background: linear-gradient(90deg, #4e8f64, #92c889); transition: width 180ms linear; }
.balance-lab-detail dl div { display: flex; justify-content: space-between; gap: 8px; padding: 7px 0; border-top: 1px solid rgba(190, 213, 172, .09); font-size: 8px; }
.balance-lab-detail dd { margin: 0; color: #d7c68c; text-align: right; }
.balance-lab-artifacts { display: grid; gap: 6px; margin-top: 14px; }
.balance-lab-artifacts button { text-align: left; }
.balance-lab-log { margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--front-line); }
.balance-lab-log summary { color: var(--front-dim); cursor: pointer; font-size: 8px; }
.balance-lab-log pre { max-height: 210px; overflow: auto; padding: 8px; border-radius: 6px; color: #bad3ae; background: rgba(0,0,0,.28); font: 7px/1.45 monospace; white-space: pre-wrap; }
/* Balance Lab historical run analysis */
.balance-lab-analysis { grid-column: 1 / -1; min-width: 0; border-top: 1px solid var(--front-line); background: linear-gradient(180deg, rgba(4, 14, 10, .94), rgba(2, 9, 7, .98)); }
.balance-results { min-width: 0; padding: 20px; }
.balance-results-loading { min-height: 180px; margin: 0; padding: 30px 20px; color: var(--front-dim); font: 10px Georgia, serif; }
.balance-results__header { display: grid; grid-template-columns: minmax(240px, .72fr) minmax(460px, 1.28fr); gap: 20px; align-items: end; }
.balance-results__header h2 { margin: 4px 0 5px; font: 25px/1.1 Georgia, serif; }
.balance-results__header > div > p:not(.front-kicker) { margin: 0; color: var(--front-dim); font-size: 8px; line-height: 1.5; }
.balance-results__filters { display: grid; grid-template-columns: minmax(165px, 1.4fr) repeat(3, minmax(110px, .75fr)); gap: 7px; }
.balance-results__filters label { display: grid; min-width: 0; gap: 4px; color: var(--front-dim); font-size: 7px; }
.balance-results__filters input, .balance-results__filters select, .balance-results-sort select { width: 100%; min-width: 0; padding: 8px; border: 1px solid var(--front-line); border-radius: 6px; outline: 0; color: var(--front-cream); background: rgba(1, 7, 5, .94); font: inherit; font-size: 8px; }
.balance-results__filters input:focus, .balance-results__filters select:focus, .balance-results-sort select:focus { border-color: rgba(169, 211, 132, .65); }
.balance-results__tabs { display: flex; gap: 4px; margin: 18px 0 14px; padding-bottom: 8px; border-bottom: 1px solid var(--front-line); }
.balance-results__tabs button { padding: 7px 12px; border: 1px solid transparent; border-radius: 6px; color: var(--front-dim); background: transparent; cursor: pointer; font: inherit; font-size: 8px; text-transform: capitalize; }
.balance-results__tabs button:hover { color: var(--front-cream); background: rgba(149, 190, 118, .06); }
.balance-results__tabs button.is-active { border-color: rgba(157, 204, 124, .34); color: #dcefcf; background: rgba(110, 159, 84, .16); box-shadow: inset 0 -2px #83b76c; }
.balance-results-overview { display: grid; gap: 12px; }
.balance-results-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 7px; }
.balance-results-summary article { display: grid; gap: 5px; padding: 11px 12px; border: 1px solid rgba(190, 213, 172, .12); border-radius: 7px; background: rgba(9, 24, 17, .46); }
.balance-results-summary span { color: var(--front-dim); font-size: 7px; text-transform: uppercase; letter-spacing: .05em; }
.balance-results-summary strong { color: #e6dfbf; font: 17px/1 Georgia, serif; }
.balance-results-summary small { color: var(--front-dim); font: 7px system-ui, sans-serif; }
.balance-results-charts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; align-items: start; }
.balance-results-chart { min-width: 0; overflow: hidden; border: 1px solid rgba(190, 213, 172, .13); border-radius: 8px; background: rgba(2, 10, 7, .72); }
.balance-results-chart > header { display: flex; align-items: start; justify-content: space-between; gap: 10px; padding: 12px; border-bottom: 1px solid rgba(190, 213, 172, .1); }
.balance-results-chart h4 { margin: 0; font: 15px/1.2 Georgia, serif; }
.balance-results-chart header p { margin: 4px 0 0; color: var(--front-dim); font-size: 7px; line-height: 1.4; }
.balance-results-chart header > strong { min-width: 24px; padding: 4px 6px; border-radius: 999px; color: #d6e8c9; background: rgba(126, 176, 96, .14); font-size: 7px; text-align: center; }
.balance-results-chart__plot { display: grid; max-height: 328px; gap: 2px; overflow-y: auto; padding: 9px 11px; }
.balance-results-bar { display: grid; grid-template-columns: minmax(105px, 34%) minmax(70px, 1fr) minmax(44px, auto); align-items: center; gap: 8px; min-height: 18px; }
.balance-results-bar__label { display: block; min-width: 0; }
.balance-results-bar__label b, .balance-results-bar__label small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.balance-results-bar__label b { color: #dce5d5; font-size: 7px; font-weight: 600; }
.balance-results-bar__label small { margin-top: 1px; color: #718173; font-size: 6px; }
.balance-results-bar__track { height: 7px; overflow: hidden; border-radius: 999px; background: rgba(255,255,255,.045); }
.balance-results-bar__track i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #8a3f37, #d47c65); box-shadow: 0 0 8px rgba(213, 113, 88, .2); }
.balance-results-chart.is-party .balance-results-bar__track i { background: linear-gradient(90deg, #366e52, #79bd7a); }
.balance-results-chart.is-time .balance-results-bar__track i { background: linear-gradient(90deg, #766130, #d1aa51); }
.balance-results-chart.is-class .balance-results-bar__track i { background: linear-gradient(90deg, #395e76, #6faac4); }
.balance-results-chart.is-taken .balance-results-bar__track i { background: linear-gradient(90deg, #6b3d70, #b376b5); }
.balance-results-bar > strong { color: #d8d2b5; font-size: 7px; text-align: right; white-space: nowrap; }
.balance-results-chart > footer { padding: 7px 11px; border-top: 1px solid rgba(190, 213, 172, .08); color: var(--front-dim); font-size: 6px; }
.balance-results-empty { padding: 18px 8px; color: var(--front-dim); font-size: 8px; text-align: center; }
.balance-results-table-panel { min-width: 0; }
.balance-results-table-panel > header { display: flex; align-items: end; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
.balance-results-table-panel h3 { margin: 0; font: 17px/1.2 Georgia, serif; }
.balance-results-table-panel header p { margin: 4px 0 0; color: var(--front-dim); font-size: 7px; }
.balance-results-sort { display: grid; grid-template-columns: minmax(145px, 1fr) auto; gap: 6px; min-width: min(330px, 46vw); }
.balance-results-sort button { padding: 7px 9px; border: 1px solid var(--front-line); border-radius: 6px; color: var(--front-cream); background: rgba(1, 7, 5, .8); cursor: pointer; font: inherit; font-size: 7px; white-space: nowrap; }
.balance-results-table-wrap { max-height: min(620px, 66vh); overflow: auto; border: 1px solid var(--front-line); border-radius: 8px; background: rgba(1, 7, 5, .56); }
.balance-results-table { width: 100%; }
.balance-results table { width: 100%; border-collapse: collapse; font-size: 7px; }
.balance-results th, .balance-results td { padding: 8px 9px; border-bottom: 1px solid rgba(190, 213, 172, .075); text-align: right; white-space: nowrap; }
.balance-results th { position: sticky; z-index: 1; top: 0; color: #aebca8; background: #09120e; font-size: 6px; letter-spacing: .035em; text-transform: uppercase; }
.balance-results th:first-child, .balance-results td:first-child, .balance-results td.balance-results-members { text-align: left; }
.balance-results tbody tr:hover { background: rgba(146, 195, 112, .055); }
.balance-results td strong, .balance-results td small { display: block; }
.balance-results td strong { color: #dfe7d7; font-size: 8px; }
.balance-results td small { max-width: 180px; margin-top: 2px; overflow: hidden; color: var(--front-dim); font-size: 6px; text-overflow: ellipsis; }
.balance-results td.balance-results-members { min-width: 300px; max-width: 540px; white-space: normal; line-height: 1.5; }
.balance-results-flag { display: inline-block; padding: 3px 5px; border-radius: 4px; color: #cbd7c5; background: rgba(116, 138, 107, .14); }
.balance-results-flag.is-high { color: #efb8aa; background: rgba(155, 65, 50, .18); }
.balance-results-flag.is-low { color: #b8d6ee; background: rgba(60, 111, 147, .18); }
.balance-results-abilities { display: grid; min-width: 0; gap: 10px; }
.balance-results-abilities > .balance-results-chart { width: 100%; }
.balance-results-summary--abilities article:nth-child(3) strong { color: #efb8aa; }
.balance-results-ability-controls { display: grid; grid-template-columns: minmax(170px, 1.15fr) minmax(145px, .9fr) minmax(145px, .9fr) auto; gap: 6px; min-width: min(680px, 60vw); }
.balance-results-ability-controls select { width: 100%; min-width: 0; padding: 7px 8px; border: 1px solid var(--front-line); border-radius: 6px; outline: 0; color: var(--front-cream); background: rgba(1, 7, 5, .8); font: inherit; font-size: 7px; }
.balance-results-ability-controls select:focus { border-color: rgba(169, 211, 132, .65); }
.balance-results-ability-controls button { padding: 7px 9px; border: 1px solid var(--front-line); border-radius: 6px; color: var(--front-cream); background: rgba(1, 7, 5, .8); cursor: pointer; font: inherit; font-size: 7px; white-space: nowrap; }
.balance-results-abilities th:nth-child(2), .balance-results-abilities td:nth-child(2) { text-align: left; }
.balance-results-ability-name { display: flex; align-items: center; gap: 7px; min-width: 150px; }
.balance-results-ability-name > img { width: 24px; height: 24px; flex: 0 0 auto; border: 1px solid rgba(210, 226, 194, .2); border-radius: 4px; object-fit: cover; background: #07100b; }
.balance-results-ability-name > span { display: block; min-width: 0; }
.balance-results-legacy-note { padding: 18px; border: 1px solid rgba(210, 184, 106, .24); border-radius: 8px; color: #d6c991; background: rgba(89, 69, 24, .12); font-size: 8px; line-height: 1.5; }
@media (max-width: 900px) {
.balance-lab-layout { grid-template-columns: minmax(290px, 1fr) minmax(250px, .8fr); }
.balance-lab-detail { grid-column: 1 / -1; border-top: 1px solid var(--front-line); }
.balance-results__header { grid-template-columns: 1fr; }
.balance-results__filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 600px) {
.balance-lab-layout { display: block; overflow: auto; }
.balance-lab-screen .gm-admin-header { grid-template-columns: auto minmax(0, 1fr) auto; gap: 9px; padding-inline: 10px; }
.balance-lab-screen .gm-admin-header__actions .front-button { padding-inline: 7px; font-size: 8px; }
.balance-lab-config, .balance-lab-jobs { border-right: 0; border-bottom: 1px solid var(--front-line); }
.balance-lab-form-grid { grid-template-columns: 1fr; }
.balance-lab-form-grid__wide { grid-column: auto; }
.balance-lab-analysis { border-top: 1px solid var(--front-line); }
.balance-results { padding: 14px 10px 20px; }
.balance-results__filters, .balance-results-summary, .balance-results-charts { grid-template-columns: 1fr; }
.balance-results__tabs { overflow-x: auto; }
.balance-results__tabs button { white-space: nowrap; }
.balance-results-table-panel > header { display: grid; }
.balance-results-sort { width: 100%; min-width: 0; }
.balance-results-ability-controls { width: 100%; min-width: 0; grid-template-columns: 1fr; }
.balance-results-bar { grid-template-columns: minmax(90px, 40%) minmax(55px, 1fr) minmax(40px, auto); gap: 5px; }
}
.gm-review-card {
position: absolute;
top: max(276px, calc(env(safe-area-inset-top) + 264px));
@@ -3830,3 +4003,82 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
.arena-combat-log { position: absolute; left: .7rem; top: 47%; color: #e4e3cc; font-size: .75rem; pointer-events: none; max-width: 18rem; }
.arena-team-frames button.is-selected { outline: 2px solid #ffda8b; }
@media (max-width: 760px) { .arena-toolbar { max-width: 100%; left: .5rem; justify-content: center; } .arena-toolbar button { font-size: .6rem; padding: .25rem; } }
.dungeon-sim-hud {
position: fixed;
inset: 16px 16px auto auto;
z-index: 70;
width: min(520px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
overflow: auto;
color: #e8f7ff;
background: rgba(7, 14, 22, 0.92);
border: 1px solid rgba(99, 211, 255, 0.42);
border-radius: 12px;
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.55);
padding: 14px;
backdrop-filter: blur(12px);
}
.dungeon-sim-hud__header,
.dungeon-sim-hud__controls,
.dungeon-sim-hud__status div,
.dungeon-sim-hud__findings header,
.dungeon-sim-hud__findings li {
display: flex;
align-items: center;
gap: 8px;
}
.dungeon-sim-hud__header { justify-content: space-between; }
.dungeon-sim-hud__header div { display: grid; }
.dungeon-sim-hud__header small { color: #86b7ca; text-transform: uppercase; letter-spacing: 0.08em; }
.dungeon-sim-hud__header strong { font-size: 1.08rem; }
.dungeon-sim-hud__header > time { color: #ffe578; font: 700 1.15rem/1 monospace; }
.dungeon-sim-hud__controls { flex-wrap: wrap; margin: 12px 0; }
.dungeon-sim-hud button { color: #dff7ff; background: #122a39; border: 1px solid #315b6e; border-radius: 6px; padding: 6px 9px; }
.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__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; }
.dungeon-sim-hud__status div { justify-content: space-between; min-width: 0; }
.dungeon-sim-hud__status dt { color: #82aaba; }
.dungeon-sim-hud__status dd { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dungeon-sim-hud__findings header { justify-content: space-between; border-top: 1px solid rgba(139, 210, 236, 0.2); padding-top: 10px; }
.dungeon-sim-hud__findings ol { display: grid; gap: 6px; list-style: none; margin: 8px 0 0; padding: 0; }
.dungeon-sim-hud__findings li { align-items: flex-start; color: #bdd5df; font-size: 0.82rem; }
.dungeon-sim-hud__findings li time { flex: 0 0 auto; color: #759aaa; font-family: monospace; }
.dungeon-sim-hud__findings li[data-severity="critical"] { color: #ffaaaa; }
.dungeon-sim-hud__findings li[data-severity="warning"] { color: #ffe09b; }
.dungeon-sim-admin { min-height: 100vh; padding: clamp(18px, 3vw, 42px); }
.dungeon-sim-admin__header { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 18px; margin-bottom: 24px; }
.dungeon-sim-admin__header h1, .dungeon-sim-admin__details h2 { margin: 0; }
.dungeon-sim-admin__header-actions { display: flex; gap: 8px; }
.dungeon-sim-admin__layout { display: grid; grid-template-columns: minmax(300px, 430px) minmax(420px, 1fr); gap: 22px; }
.dungeon-sim-admin__catalog, .dungeon-sim-admin__details { background: rgba(7, 14, 22, 0.8); border: 1px solid rgba(108, 189, 220, 0.28); border-radius: 14px; padding: 18px; }
.dungeon-sim-admin label { display: grid; gap: 5px; margin-bottom: 11px; color: #a7c6d2; }
.dungeon-sim-admin input, .dungeon-sim-admin select, .dungeon-sim-admin textarea { width: 100%; color: #e8f7ff; background: #0c1c27; border: 1px solid #315164; border-radius: 6px; padding: 8px; }
.dungeon-sim-admin textarea { min-height: 70px; resize: vertical; }
.dungeon-sim-admin fieldset { display: grid; gap: 8px; border: 1px solid rgba(111, 190, 220, 0.25); border-radius: 10px; margin: 16px 0 0; padding: 14px; }
.dungeon-sim-admin legend { color: #d6f2ff; padding: 0 6px; }
.dungeon-sim-admin__route-meta { display: grid; gap: 5px; margin-top: 14px; }
.dungeon-sim-admin__route-meta code { color: #7fb7c9; font-size: 0.68rem; overflow-wrap: anywhere; }
.route-review-status { width: fit-content; border: 1px solid #647b85; border-radius: 999px; padding: 3px 8px; font-size: 0.72rem; text-transform: uppercase; }
.route-review-status--approved { color: #9cf3b8; border-color: #3c9c61; }
.route-review-status--needswork { color: #ffb1a4; border-color: #b8564a; }
.dungeon-sim-admin__replays { display: grid; gap: 9px; margin: 14px 0 24px; }
.dungeon-sim-admin__replays article { display: flex; justify-content: space-between; gap: 12px; padding: 11px; background: rgba(18, 42, 57, 0.66); border-radius: 8px; }
.dungeon-sim-admin__replays article > div { display: grid; gap: 4px; }
.dungeon-sim-admin__replays article > div:last-child { display: flex; align-items: center; }
.dungeon-sim-admin__replays small { color: #8db0bd; }
.dungeon-sim-admin__legs { display: grid; gap: 7px; padding-left: 22px; }
.dungeon-sim-admin__legs li { padding: 7px 9px; background: rgba(18, 42, 57, 0.45); }
.dungeon-sim-admin__legs li > * { display: block; }
.dungeon-sim-admin__legs span, .dungeon-sim-admin__legs small { color: #91b4c1; }
@media (max-width: 900px) {
.dungeon-sim-admin__layout { grid-template-columns: 1fr; }
.dungeon-sim-admin__header { grid-template-columns: 1fr; }
.dungeon-sim-admin__replays article { flex-direction: column; }
}
+263
View File
@@ -0,0 +1,263 @@
import { useMemo, useState } from "react";
import type {
BalanceLabBossResult,
BalanceLabClassAbilityResult,
BalanceLabClassSpecResult,
BalanceLabPartyResult,
BalanceLabSuiteReport,
} from "../app/balanceLabClient";
type ResultsTab = "overview" | "bosses" | "classes" | "abilities" | "parties";
type PartySourceFilter = "all" | "wow" | "coa" | "rom";
type BossSourceFilter = "all" | "wow" | "rom";
interface ChartDatum {
readonly key: string;
readonly label: string;
readonly detail: string;
readonly value: number;
}
const number = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 });
const wholeNumber = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
function formatNumber(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : number.format(value);
}
function formatWhole(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : wholeNumber.format(value);
}
function formatPercent(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : `${number.format(value * 100)}%`;
}
function formatSeconds(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : `${number.format(value)}s`;
}
function formatRatio(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : `${number.format(value)}×`;
}
function flagClass(flag: string): string {
if (flag === "high" || flag === "very high") return "is-high";
if (flag === "low" || flag === "very low") return "is-low";
return "is-baseline";
}
function BarChart({
title,
description,
rows,
format = formatNumber,
tone = "damage",
}: {
readonly title: string;
readonly description: string;
readonly rows: readonly ChartDatum[];
readonly format?: (value: number) => string;
readonly tone?: "damage" | "party" | "time" | "class" | "taken";
}) {
const sorted = [...rows].filter((row) => Number.isFinite(row.value)).sort((left, right) => right.value - left.value);
const visible = sorted.slice(0, 15);
const maximum = Math.max(1, ...visible.map((row) => row.value));
return (
<article className={`balance-results-chart is-${tone}`} aria-label={title}>
<header><div><h4>{title}</h4><p>{description}</p></div><strong>{rows.length}</strong></header>
<div className="balance-results-chart__plot">
{visible.map((row) => (
<div className="balance-results-bar" key={row.key} title={`${row.label}: ${format(row.value)}`}>
<span className="balance-results-bar__label"><b>{row.label}</b><small>{row.detail}</small></span>
<span className="balance-results-bar__track"><i style={{ width: `${Math.max(1.5, (row.value / maximum) * 100)}%` }} /></span>
<strong>{format(row.value)}</strong>
</div>
))}
{visible.length === 0 && <p className="balance-results-empty">No matching data for this graph.</p>}
</div>
{sorted.length > visible.length && <footer>Showing the highest 15 of {sorted.length}. The complete filtered set is available in the tables.</footer>}
</article>
);
}
function sortNumeric<T>(rows: readonly T[], read: (row: T) => number | null, ascending: boolean): T[] {
return [...rows].sort((left, right) => {
const leftValue = read(left);
const rightValue = read(right);
const leftComparable = leftValue === null || !Number.isFinite(leftValue) ? Number.NEGATIVE_INFINITY : leftValue;
const rightComparable = rightValue === null || !Number.isFinite(rightValue) ? Number.NEGATIVE_INFINITY : rightValue;
return (leftComparable - rightComparable) * (ascending ? 1 : -1);
});
}
export function BalanceLabResults({ report }: { readonly report: BalanceLabSuiteReport }) {
const [tab, setTab] = useState<ResultsTab>("overview");
const [search, setSearch] = useState("");
const [partySource, setPartySource] = useState<PartySourceFilter>("all");
const [bossSource, setBossSource] = useState<BossSourceFilter>("all");
const [role, setRole] = useState("all");
const [bossSort, setBossSort] = useState<keyof BalanceLabBossResult>("incomingDtps");
const [classSort, setClassSort] = useState<keyof BalanceLabClassSpecResult>("bossDps");
const [abilityBuild, setAbilityBuild] = useState("all");
const [abilityStatus, setAbilityStatus] = useState("all");
const [abilitySort, setAbilitySort] = useState<keyof BalanceLabClassAbilityResult>("effectiveDamage");
const [partySort, setPartySort] = useState<keyof BalanceLabPartyResult>("partyDps");
const [ascending, setAscending] = useState(false);
const query = search.trim().toLowerCase();
const bosses = useMemo(() => report.bosses.filter((boss) => (
(bossSource === "all" || boss.category === bossSource)
&& (!query || `${boss.bossName} ${boss.dungeonTitle} ${boss.bossKey} ${boss.pressureFlag}`.toLowerCase().includes(query))
)), [bossSource, query, report.bosses]);
const classes = useMemo(() => report.classSpecs.filter((row) => (
(partySource === "all" || row.source === partySource)
&& (role === "all" || row.role === role)
&& (!query || `${row.className} ${row.specialization} ${row.role} ${row.source} ${row.damageFlag}`.toLowerCase().includes(query))
)), [partySource, query, report.classSpecs, role]);
const parties = useMemo(() => report.partyConfigurations.filter((row) => (
(partySource === "all" || row.source === partySource)
&& (!query || `${row.partyConfigurationId} ${row.source} ${row.members}`.toLowerCase().includes(query))
)), [partySource, query, report.partyConfigurations]);
const allAbilities = report.classAbilities ?? [];
const abilityBuildOptions = useMemo(() => {
const unique = new Map<string, BalanceLabClassAbilityResult>();
for (const row of allAbilities) unique.set(`${row.source}/${row.buildId}`, row);
return [...unique.entries()].sort((left, right) => (
left[1].source.localeCompare(right[1].source)
|| left[1].className.localeCompare(right[1].className)
|| left[1].specialization.localeCompare(right[1].specialization)
));
}, [allAbilities]);
const abilities = useMemo(() => allAbilities.filter((row) => {
const buildKey = `${row.source}/${row.buildId}`;
const matchesStatus = abilityStatus === "all"
|| (abilityStatus === "used" && row.activeRuns > 0)
|| (abilityStatus === "unused" && row.unusedAvailable)
|| (abilityStatus === "unused-damage" && row.unusedAvailable && /damage|execute|debuff/i.test(row.categories))
|| (abilityStatus === "damage" && row.effectiveDamage > 0)
|| (abilityStatus === "support" && (row.effectiveHealing > 0 || row.absorbs > 0));
return (partySource === "all" || row.source === partySource)
&& (role === "all" || row.role === role)
&& (abilityBuild === "all" || buildKey === abilityBuild)
&& matchesStatus
&& (!query || `${row.className} ${row.specialization} ${row.role} ${row.source} ${row.abilityName} ${row.abilityId} ${row.categories} ${row.status}`.toLowerCase().includes(query));
}), [abilityBuild, abilityStatus, allAbilities, partySource, query, role]);
const sortedBosses = useMemo(() => sortNumeric(bosses, (row) => {
const value = row[bossSort];
return typeof value === "number" ? value : null;
}, ascending), [ascending, bossSort, bosses]);
const sortedClasses = useMemo(() => sortNumeric(classes, (row) => {
const value = row[classSort];
return typeof value === "number" ? value : null;
}, ascending), [ascending, classSort, classes]);
const sortedAbilities = useMemo(() => sortNumeric(abilities, (row) => {
const value = row[abilitySort];
return typeof value === "number" ? value : null;
}, ascending), [abilities, abilitySort, ascending]);
const sortedParties = useMemo(() => sortNumeric(parties, (row) => {
const value = row[partySort];
return typeof value === "number" ? value : null;
}, ascending), [ascending, parties, partySort]);
const bossChart = (metric: keyof BalanceLabBossResult): ChartDatum[] => bosses.map((boss) => ({
key: boss.bossKey,
label: boss.bossName,
detail: boss.dungeonTitle,
value: typeof boss[metric] === "number" ? boss[metric] as number : Number.NaN,
}));
const classChart = (metric: keyof BalanceLabClassSpecResult): ChartDatum[] => classes.map((row) => ({
key: `${row.source}/${row.buildId}/${metric}`,
label: `${row.className} · ${row.specialization}`,
detail: `${row.source.toUpperCase()} · ${row.role}`,
value: typeof row[metric] === "number" ? row[metric] as number : Number.NaN,
}));
const abilityChart = (metric: keyof BalanceLabClassAbilityResult): ChartDatum[] => abilities.map((row) => ({
key: `${row.source}/${row.buildId}/${row.abilityId}/${metric}`,
label: row.abilityName,
detail: `${row.className} · ${row.specialization}`,
value: typeof row[metric] === "number" ? row[metric] as number : Number.NaN,
}));
const availableAbilityCount = abilities.filter((row) => row.availableRuns > 0).length;
const unusedAbilityCount = abilities.filter((row) => row.unusedAvailable).length;
const usedAbilityCount = abilities.filter((row) => row.activeRuns > 0).length;
const filteredAbilityDamage = abilities.reduce((total, row) => total + row.effectiveDamage, 0);
return (
<section className="balance-results" aria-label="Balance Lab report analysis">
<header className="balance-results__header">
<div>
<p className="front-kicker">Run analysis</p>
<h2>Level {report.level} balance report</h2>
<p>{new Date(report.generatedAt).toLocaleString()} · {report.summary.runs.toLocaleString()} simulations · every filtered row remains available below.</p>
</div>
<div className="balance-results__filters">
<label><span>Find anything</span><input type="search" value={search} onChange={(event) => setSearch(event.currentTarget.value)} placeholder="Boss, dungeon, class, spec, party…" /></label>
<label><span>Party source</span><select value={partySource} onChange={(event) => setPartySource(event.currentTarget.value as PartySourceFilter)}><option value="all">All party sources</option><option value="wow">WoW</option><option value="coa">CoA</option><option value="rom">RoM</option></select></label>
<label><span>Boss source</span><select value={bossSource} onChange={(event) => setBossSource(event.currentTarget.value as BossSourceFilter)}><option value="all">All boss sources</option><option value="wow">WoW</option><option value="rom">RoM</option></select></label>
<label><span>Class role</span><select value={role} onChange={(event) => setRole(event.currentTarget.value)}><option value="all">All roles</option><option value="tank">Tank</option><option value="healer">Healer</option><option value="damage">Damage</option></select></label>
</div>
</header>
<nav className="balance-results__tabs" aria-label="Balance report sections">
{(["overview", "bosses", "classes", "abilities", "parties"] as const).map((value) => <button key={value} type="button" className={tab === value ? "is-active" : ""} onClick={() => setTab(value)}>{value === "classes" ? "Class / spec" : value}</button>)}
</nav>
{tab === "overview" && <div className="balance-results-overview">
<div className="balance-results-summary">
<article><span>Party configurations</span><strong>{report.summary.partyConfigurations.toLocaleString()}</strong></article>
<article><span>Bosses tested</span><strong>{report.summary.bosses.toLocaleString()}</strong></article>
<article><span>Class/spec builds</span><strong>{report.summary.classSpecs.toLocaleString()}</strong></article>
<article><span>Median boss pressure</span><strong>{formatNumber(report.summary.bossPressureMedianDtps)} <small>DTPS</small></strong></article>
</div>
<div className="balance-results-charts">
<BarChart title="Boss damage to party" description="Incoming party damage per second; fastest way to spot healer-pressure outliers." rows={bossChart("incomingDtps")} tone="damage" />
<BarChart title="Party damage to boss" description="Damage per second applied directly to each boss, excluding unrelated targets." rows={bossChart("bossDpsTaken")} tone="party" />
<BarChart title="Kill times" description="Average seconds to defeat; bosses never defeated are listed in the boss table as —." rows={bossChart("averageTimeToDefeat")} format={formatSeconds} tone="time" />
<BarChart title="Class/spec boss damage" description="Every tested build ranked by DPS applied directly to bosses." rows={classChart("bossDps")} tone="class" />
<BarChart title="Damage taken by class/spec" description="Incoming damage per second by build; useful for survivability and healer-load checks." rows={classChart("dtps")} tone="taken" />
</div>
</div>}
{tab === "bosses" && <div className="balance-results-table-panel">
<header><div><h3>Every boss</h3><p>{sortedBosses.length} matching bosses · pressure, outgoing damage, party output, kill time, survival, and outcomes.</p></div><div className="balance-results-sort"><select aria-label="Sort bosses" value={bossSort} onChange={(event) => setBossSort(event.currentTarget.value as keyof BalanceLabBossResult)}><option value="incomingDtps">Boss → party DTPS</option><option value="averageIncomingDamage">Average party damage</option><option value="bossDpsTaken">Party → boss DPS</option><option value="partyDps">Total party DPS</option><option value="averageTimeToDefeat">Kill time</option><option value="wipeRate">Wipe rate</option><option value="defeatRate">Defeat rate</option><option value="averageDeaths">Average deaths</option></select><button type="button" onClick={() => setAscending((value) => !value)}>{ascending ? "Low → high" : "High → low"}</button></div></header>
<div className="balance-results-table-wrap"><table><thead><tr><th>Boss</th><th>Source</th><th>Runs</th><th>Boss → party DTPS</th><th>Avg party damage</th><th>Pressure</th><th>Party → boss DPS</th><th>Total party DPS</th><th>Kill time</th><th>Defeat</th><th>Wipe</th><th>Avg deaths</th><th>Boss health</th></tr></thead><tbody>{sortedBosses.map((boss) => <tr key={boss.bossKey}><td><strong>{boss.bossName}</strong><small>{boss.dungeonTitle}</small></td><td>{boss.category.toUpperCase()}</td><td>{boss.runs.toLocaleString()}</td><td>{formatNumber(boss.incomingDtps)}</td><td>{formatWhole(boss.averageIncomingDamage)}</td><td><span className={`balance-results-flag ${flagClass(boss.pressureFlag)}`}>{formatRatio(boss.pressureVsMedian)} · {boss.pressureFlag}</span></td><td>{formatNumber(boss.bossDpsTaken)}</td><td>{formatNumber(boss.partyDps)}</td><td>{formatSeconds(boss.averageTimeToDefeat)}</td><td>{formatPercent(boss.defeatRate)}</td><td>{formatPercent(boss.wipeRate)}</td><td>{formatNumber(boss.averageDeaths)}</td><td>{formatWhole(boss.bossMaxHealth)}</td></tr>)}</tbody></table></div>
</div>}
{tab === "classes" && <div className="balance-results-table-panel">
<header><div><h3>Every class and specialization</h3><p>{sortedClasses.length} matching builds · output, healing, mitigation, survivability, and role-relative tuning.</p></div><div className="balance-results-sort"><select aria-label="Sort class specs" value={classSort} onChange={(event) => setClassSort(event.currentTarget.value as keyof BalanceLabClassSpecResult)}><option value="bossDps">Boss DPS</option><option value="dps">Total DPS</option><option value="hps">HPS</option><option value="dtps">Damage taken / sec</option><option value="totalDamageTaken">Total damage taken</option><option value="totalDamage">Total damage dealt</option><option value="totalHealing">Total healing</option><option value="totalAbsorbs">Total absorbs</option><option value="deathEvents">Deaths</option></select><button type="button" onClick={() => setAscending((value) => !value)}>{ascending ? "Low → high" : "High → low"}</button></div></header>
<div className="balance-results-table-wrap"><table><thead><tr><th>Class / specialization</th><th>Source</th><th>Role</th><th>Parties</th><th>Runs</th><th>Boss DPS</th><th>Total DPS</th><th>HPS</th><th>Damage taken / sec</th><th>Total damage taken</th><th>Role median</th><th>Total damage dealt</th><th>Total healing</th><th>Absorbs</th><th>Deaths</th></tr></thead><tbody>{sortedClasses.map((row) => <tr key={`${row.source}/${row.buildId}`}><td><strong>{row.className}</strong><small>{row.specialization}</small></td><td>{row.source.toUpperCase()}</td><td>{row.role}</td><td>{row.partyConfigurations.toLocaleString()}</td><td>{row.runs.toLocaleString()}</td><td>{formatNumber(row.bossDps)}</td><td>{formatNumber(row.dps)}</td><td>{formatNumber(row.hps)}</td><td>{formatNumber(row.dtps)}</td><td>{formatWhole(row.totalDamageTaken)}</td><td><span className={`balance-results-flag ${flagClass(row.damageFlag)}`}>{formatRatio(row.damageVsSourceRoleMedian)} · {row.damageFlag}</span></td><td>{formatWhole(row.totalDamage)}</td><td>{formatWhole(row.totalHealing)}</td><td>{formatWhole(row.totalAbsorbs)}</td><td>{row.deathEvents.toLocaleString()}</td></tr>)}</tbody></table></div>
</div>}
{tab === "abilities" && <div className="balance-results-abilities">
{allAbilities.length === 0 ? <p className="balance-results-legacy-note">This saved run predates ability-level reporting. Run the same configuration again to populate ability use, unused abilities, damage share, healing, and absorbs.</p> : <>
<div className="balance-results-summary balance-results-summary--abilities">
<article><span>Matching abilities</span><strong>{abilities.length.toLocaleString()}</strong></article>
<article><span>Used in at least one run</span><strong>{usedAbilityCount.toLocaleString()}</strong></article>
<article><span>Available but unused</span><strong>{unusedAbilityCount.toLocaleString()}</strong></article>
<article><span>Filtered effective damage</span><strong>{formatWhole(filteredAbilityDamage)}</strong></article>
</div>
<BarChart title="Ability damage contribution" description="Effective damage by ability for the selected source, role, class/spec, status, and text filters." rows={abilityChart("effectiveDamage")} tone="class" />
<div className="balance-results-table-panel">
<header><div><h3>Every class/spec ability</h3><p>{sortedAbilities.length} matching abilities · {availableAbilityCount} are available to the tested builds. Zero-use rows are deliberately retained.</p></div><div className="balance-results-ability-controls">
<select aria-label="Filter ability class spec" value={abilityBuild} onChange={(event) => setAbilityBuild(event.currentTarget.value)}><option value="all">All class/specs</option>{abilityBuildOptions.map(([key, row]) => <option key={key} value={key}>{row.source.toUpperCase()} · {row.className} — {row.specialization}</option>)}</select>
<select aria-label="Filter ability usage" value={abilityStatus} onChange={(event) => setAbilityStatus(event.currentTarget.value)}><option value="all">All ability states</option><option value="used">Used in any run</option><option value="unused">Available but unused</option><option value="unused-damage">Unused damage abilities</option><option value="damage">Dealt damage</option><option value="support">Healed or absorbed</option></select>
<select aria-label="Sort abilities" value={abilitySort} onChange={(event) => setAbilitySort(event.currentTarget.value as keyof BalanceLabClassAbilityResult)}><option value="effectiveDamage">Effective damage</option><option value="damageShare">Damage share</option><option value="dps">DPS</option><option value="averageDamagePerHit">Damage per hit</option><option value="activeRuns">Active runs</option><option value="usageRate">Usage rate</option><option value="casts">Casts</option><option value="castsPerMinute">Casts per minute</option><option value="hits">Hits</option><option value="effectiveHealing">Effective healing</option><option value="overheal">Overheal</option><option value="absorbs">Absorbs</option></select>
<button type="button" onClick={() => setAscending((value) => !value)}>{ascending ? "Low → high" : "High → low"}</button>
</div></header>
<div className="balance-results-table-wrap"><table><thead><tr><th>Ability</th><th>Class / specialization</th><th>Source</th><th>Role</th><th>AI category</th><th>Usage state</th><th>Active runs</th><th>Usage rate</th><th>Casts</th><th>Casts / min</th><th>Hits</th><th>Effective damage</th><th>Damage share</th><th>DPS</th><th>Damage / hit</th><th>Raw damage</th><th>Effective healing</th><th>Overheal</th><th>Absorbs</th><th>Cooldown</th></tr></thead><tbody>{sortedAbilities.map((row) => <tr key={`${row.source}/${row.buildId}/${row.abilityId}`}>
<td><span className="balance-results-ability-name">{row.icon && <img src={row.icon} alt="" />}<span><strong>{row.abilityName}</strong><small>{row.abilityId}</small></span></span></td>
<td><strong>{row.className}</strong><small>{row.specialization}</small></td><td>{row.source.toUpperCase()}</td><td>{row.role}</td><td>{row.categories || "—"}</td><td><span className={`balance-results-flag ${row.unusedAvailable ? "is-high" : row.status === "support" ? "is-low" : "is-baseline"}`}>{row.unusedAvailable ? "available · unused" : row.status}</span></td><td>{row.activeRuns}/{row.runs}</td><td>{formatPercent(row.usageRate)}</td><td>{row.casts.toLocaleString()}</td><td>{formatNumber(row.castsPerMinute)}</td><td>{row.hits.toLocaleString()}</td><td>{formatWhole(row.effectiveDamage)}</td><td>{formatPercent(row.damageShare)}</td><td>{formatNumber(row.dps)}</td><td>{formatNumber(row.averageDamagePerHit)}</td><td>{formatWhole(row.rawDamage)}</td><td>{formatWhole(row.effectiveHealing)}</td><td>{formatWhole(row.overheal)}</td><td>{formatWhole(row.absorbs)}</td><td>{row.cooldownSeconds === null ? "—" : formatSeconds(row.cooldownSeconds)}</td>
</tr>)}</tbody></table></div>
</div>
</>}
</div>}
{tab === "parties" && <div className="balance-results-table-panel">
<header><div><h3>Every party configuration</h3><p>{sortedParties.length} matching parties · compare composition output, healer pressure, outcomes, and deaths.</p></div><div className="balance-results-sort"><select aria-label="Sort parties" value={partySort} onChange={(event) => setPartySort(event.currentTarget.value as keyof BalanceLabPartyResult)}><option value="partyDps">Party DPS</option><option value="incomingDtps">Incoming DTPS</option><option value="defeatRate">Defeat rate</option><option value="wipeRate">Wipe rate</option><option value="averageDeaths">Average deaths</option><option value="runs">Runs</option></select><button type="button" onClick={() => setAscending((value) => !value)}>{ascending ? "Low → high" : "High → low"}</button></div></header>
<div className="balance-results-table-wrap"><table><thead><tr><th>Party</th><th>Source</th><th>Members</th><th>Runs</th><th>Party DPS</th><th>Incoming DTPS</th><th>Defeat</th><th>Wipe</th><th>Avg deaths</th></tr></thead><tbody>{sortedParties.map((row) => <tr key={row.partyConfigurationId}><td><strong>{row.partyConfigurationId}</strong></td><td>{row.source.toUpperCase()}</td><td className="balance-results-members">{row.members}</td><td>{row.runs.toLocaleString()}</td><td>{formatNumber(row.partyDps)}</td><td>{formatNumber(row.incomingDtps)}</td><td>{formatPercent(row.defeatRate)}</td><td>{formatPercent(row.wipeRate)}</td><td>{formatNumber(row.averageDeaths)}</td></tr>)}</tbody></table></div>
</div>}
</section>
);
}
+296
View File
@@ -0,0 +1,296 @@
import { useEffect, useMemo, useState } from "react";
import { sessionCanUseGmTools } from "../app/releaseFeatures";
import { useShellStore } from "../app/shellStore";
import {
fetchBalanceLabArtifact,
getBalanceLabJob,
getBalanceLabReport,
listBalanceLabJobs,
startBalanceLabJob,
type BalanceLabBossCategory,
type BalanceLabJob,
type BalanceLabPartySource,
type BalanceLabSuiteReport,
} from "../app/balanceLabClient";
import { BOSS_TESTING_CATALOG } from "../game/bossTesting";
import { BalanceLabResults } from "./BalanceLabResults";
import { BrandMark, FrontSurface } from "./FrontSurface";
type BossScope = BalanceLabBossCategory | "specific";
const ARTIFACT_LABELS: Readonly<Record<string, string>> = {
"suite-report.html": "Open full report",
"suite-report.json": "Download report JSON",
"suite-report.md": "Download report Markdown",
"party_configurations.csv": "Party configurations CSV",
"class_specs_across_parties.csv": "Class/spec totals CSV",
"class_abilities_across_parties.csv": "Class/spec ability breakdown CSV",
"class_spec_by_boss.csv": "Class/spec by boss CSV",
"bosses_across_parties.csv": "Boss totals CSV",
"boss_by_party.csv": "Boss by party CSV",
"suite_runs.csv": "Every simulation CSV",
"suite-config.json": "Exact rerun configuration",
};
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "The Balance Lab request failed.";
}
function progressPercent(job: BalanceLabJob): number {
if (job.status === "complete") return 100;
if (job.status === "queued" || job.progress.partiesTotal < 1) return 0;
const currentFraction = job.progress.simulationsTotal > 0
? job.progress.simulationsCompleted / job.progress.simulationsTotal
: 0;
return Math.max(0, Math.min(99, ((job.progress.partiesCompleted + currentFraction) / job.progress.partiesTotal) * 100));
}
function describeJob(job: BalanceLabJob): string {
if (job.status === "queued") return "Waiting for the current server job to finish.";
if (job.status === "failed") return job.error ?? "The server job failed.";
if (job.status === "complete") return job.summary
? `Completed ${job.summary.runs.toLocaleString()} simulations across ${job.summary.bosses.toLocaleString()} bosses and ${job.summary.partyConfigurations.toLocaleString()} party configurations.`
: `Completed ${job.progress.partiesTotal} party configurations.`;
const party = job.progress.party || job.progress.partiesCompleted + 1;
const simulation = job.progress.simulationsTotal > 0
? ` · current party ${job.progress.simulationsCompleted}/${job.progress.simulationsTotal} boss simulations`
: "";
return `Party ${party}/${job.progress.partiesTotal}${simulation}`;
}
export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) {
const session = useShellStore((state) => state.session);
const token = session?.accessToken ?? "";
const [source, setSource] = useState<BalanceLabPartySource>("all");
const [level, setLevel] = useState(60);
const [partyConfigurations, setPartyConfigurations] = useState(10);
const [simulationsPerParty, setSimulationsPerParty] = useState(3);
const [durationSeconds, setDurationSeconds] = useState(60);
const [partySeed, setPartySeed] = useState(1);
const [bossScope, setBossScope] = useState<BossScope>("all");
const [bossSearch, setBossSearch] = useState("");
const [selectedBossKeys, setSelectedBossKeys] = useState<readonly string[]>([]);
const [jobs, setJobs] = useState<readonly BalanceLabJob[]>([]);
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [jobSearch, setJobSearch] = useState("");
const [jobStatus, setJobStatus] = useState<"all" | BalanceLabJob["status"]>("all");
const [reports, setReports] = useState<Readonly<Record<string, BalanceLabSuiteReport>>>({});
const [reportLoadingId, setReportLoadingId] = useState<string | null>(null);
const [reportError, setReportError] = useState("");
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const selectedJob = jobs.find((job) => job.id === selectedJobId) ?? jobs[0] ?? null;
const selectedReport = selectedJob ? reports[selectedJob.id] ?? null : null;
const visibleJobs = useMemo(() => {
const query = jobSearch.trim().toLowerCase();
return jobs.filter((job) => (
(jobStatus === "all" || job.status === jobStatus)
&& (!query || `${job.requestedBy} ${job.spec.source} ${job.spec.level} ${job.id} ${job.status}`.toLowerCase().includes(query))
));
}, [jobSearch, jobStatus, jobs]);
const filteredBosses = useMemo(() => {
const query = bossSearch.trim().toLowerCase();
return BOSS_TESTING_CATALOG.filter((boss) => !query
|| `${boss.name} ${boss.dungeonTitle} ${boss.key} ${boss.category}`.toLowerCase().includes(query));
}, [bossSearch]);
const selectedBossSet = useMemo(() => new Set(selectedBossKeys), [selectedBossKeys]);
const bossCount = bossScope === "specific"
? selectedBossKeys.length
: BOSS_TESTING_CATALOG.filter((boss) => bossScope === "all" || boss.category === bossScope).length;
const partyCount = partyConfigurations * (source === "all" ? 3 : 1);
const estimatedRuns = partyCount * bossCount * simulationsPerParty;
useEffect(() => {
if (!token) return;
let cancelled = false;
setLoading(true);
void listBalanceLabJobs(token).then((nextJobs) => {
if (cancelled) return;
setJobs(nextJobs);
setSelectedJobId((current) => current ?? nextJobs[0]?.id ?? null);
setError("");
}).catch((reason) => {
if (!cancelled) setError(errorMessage(reason));
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [token]);
useEffect(() => {
if (!token || !selectedJob || (selectedJob.status !== "queued" && selectedJob.status !== "running")) return;
let cancelled = false;
const timer = window.setInterval(() => {
void getBalanceLabJob(selectedJob.id, token).then((updated) => {
if (cancelled) return;
setJobs((current) => [updated, ...current.filter((job) => job.id !== updated.id)]);
}).catch((reason) => {
if (!cancelled) setError(errorMessage(reason));
});
}, 1_500);
return () => { cancelled = true; window.clearInterval(timer); };
}, [selectedJob, token]);
useEffect(() => {
if (!token || !selectedJob || selectedJob.status !== "complete" || selectedReport
|| !selectedJob.artifacts.includes("suite-report.json")) return;
let cancelled = false;
setReportLoadingId(selectedJob.id);
setReportError("");
void getBalanceLabReport(selectedJob.id, token).then((report) => {
if (!cancelled) setReports((current) => ({ ...current, [selectedJob.id]: report }));
}).catch((reason) => {
if (!cancelled) setReportError(errorMessage(reason));
}).finally(() => {
if (!cancelled) setReportLoadingId((current) => current === selectedJob.id ? null : current);
});
return () => { cancelled = true; };
}, [selectedJob, selectedReport, token]);
if (!sessionCanUseGmTools(session)) {
return (
<FrontSurface className="gm-admin-screen" ariaLabel="Balance Lab access denied">
<section className="gm-admin-denied">
<BrandMark />
<h1>Game Master access required</h1>
<p>Server simulations are available only to server-authorized GM accounts.</p>
<button className="front-button" type="button" onClick={onClose}>Back to main menu</button>
</section>
</FrontSurface>
);
}
const toggleBoss = (key: string) => {
setSelectedBossKeys((current) => current.includes(key)
? current.filter((candidate) => candidate !== key)
: [...current, key]);
};
const run = async () => {
if (bossScope === "specific" && selectedBossKeys.length === 0) {
setError("Select at least one boss for a specific-boss run.");
return;
}
setSubmitting(true);
setError("");
try {
const job = await startBalanceLabJob({
source,
level,
partyConfigurations,
simulationsPerParty,
durationSeconds,
partySeed,
bossCategory: bossScope === "specific" ? "all" : bossScope,
bossKeys: bossScope === "specific" ? selectedBossKeys : [],
}, token);
setJobs((current) => [job, ...current.filter((candidate) => candidate.id !== job.id)]);
setSelectedJobId(job.id);
} catch (reason) {
setError(errorMessage(reason));
} finally {
setSubmitting(false);
}
};
const openArtifact = async (job: BalanceLabJob, name: string) => {
const preview = name.endsWith(".html") ? window.open("", "_blank") : null;
setError("");
try {
const blob = await fetchBalanceLabArtifact(job.id, name, token);
const url = URL.createObjectURL(blob);
if (preview) preview.location.href = url;
else {
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
anchor.click();
}
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (reason) {
preview?.close();
setError(errorMessage(reason));
}
};
return (
<FrontSurface className="balance-lab-screen" ariaLabel="GM Balance Lab">
<header className="gm-admin-header">
<BrandMark compact />
<div><span>Server-authorized GM tool</span><strong>Balance Lab</strong></div>
<div className="gm-admin-header__actions">
<button className="front-button" type="button" onClick={onClose}>Main menu</button>
</div>
</header>
<main className="balance-lab-layout">
<section className="balance-lab-config">
<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>
<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>
</select></label>
<label><span>Level</span><input type="number" min={1} max={80} value={level} onChange={(event) => setLevel(event.currentTarget.valueAsNumber)} /></label>
<label><span>Parties per source</span><input type="number" min={1} max={100} value={partyConfigurations} onChange={(event) => setPartyConfigurations(event.currentTarget.valueAsNumber)} /></label>
<label><span>Runs per party/boss</span><input type="number" min={1} max={100} value={simulationsPerParty} onChange={(event) => setSimulationsPerParty(event.currentTarget.valueAsNumber)} /></label>
<label><span>Fight limit (seconds)</span><input type="number" min={5} max={600} value={durationSeconds} onChange={(event) => setDurationSeconds(event.currentTarget.valueAsNumber)} /></label>
<label><span>Party seed</span><input type="number" value={partySeed} onChange={(event) => setPartySeed(event.currentTarget.valueAsNumber)} /></label>
<label className="balance-lab-form-grid__wide"><span>Bosses</span><select value={bossScope} onChange={(event) => setBossScope(event.currentTarget.value as BossScope)}>
<option value="all">All bosses</option><option value="wow">WoW bosses</option><option value="rom">RoM bosses</option><option value="specific">Specific selection</option>
</select></label>
</div>
{bossScope === "specific" && (
<section className="balance-lab-boss-picker">
<header><input aria-label="Search Balance Lab bosses" value={bossSearch} onChange={(event) => setBossSearch(event.currentTarget.value)} placeholder="Search boss, dungeon, or key" /><strong>{selectedBossKeys.length} selected</strong></header>
<div role="listbox" aria-label="Balance Lab bosses" aria-multiselectable="true">
{filteredBosses.map((boss) => <label key={boss.key} role="option" aria-selected={selectedBossSet.has(boss.key)} className={selectedBossSet.has(boss.key) ? "is-selected" : ""}>
<input type="checkbox" checked={selectedBossSet.has(boss.key)} onChange={() => toggleBoss(boss.key)} />
<span><strong>{boss.name}</strong><small>{boss.dungeonTitle} · {boss.category.toUpperCase()}</small></span>
</label>)}
</div>
</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>
<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>
<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));
}}>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">
{visibleJobs.map((job) => <button key={job.id} type="button" className={job.id === selectedJob?.id ? "is-selected" : ""} onClick={() => setSelectedJobId(job.id)}>
<span><strong>{job.spec.source.toUpperCase()} level {job.spec.level}</strong><small>{new Date(job.createdAt).toLocaleString()} · {job.requestedBy}</small>{job.summary && <small>{job.summary.bosses.toLocaleString()} bosses · {job.summary.runs.toLocaleString()} simulations</small>}</span><em className={`is-${job.status}`}>{job.status}</em>
</button>)}
{!loading && visibleJobs.length === 0 && <p className="balance-lab-empty">No saved runs match these filters.</p>}
</div>
</section>
<aside className="balance-lab-detail">
{selectedJob ? <>
<p className="front-kicker">Selected job</p><h2>{selectedJob.spec.source.toUpperCase()} level {selectedJob.spec.level}</h2>
<p>{describeJob(selectedJob)}</p>
<div className="balance-lab-progress" aria-label={`${progressPercent(selectedJob).toFixed(0)} percent complete`}><span style={{ width: `${progressPercent(selectedJob)}%` }} /></div>
<dl>
<div><dt>Status</dt><dd>{selectedJob.status}</dd></div><div><dt>Parties</dt><dd>{selectedJob.progress.partiesTotal}</dd></div><div><dt>Runs per pairing</dt><dd>{selectedJob.spec.simulationsPerParty}</dd></div><div><dt>Boss filter</dt><dd>{selectedJob.spec.bossKeys.length ? `${selectedJob.spec.bossKeys.length} selected` : selectedJob.spec.bossCategory}</dd></div><div><dt>Fight limit</dt><dd>{selectedJob.spec.durationSeconds}s</dd></div>
</dl>
{selectedJob.status === "complete" && <div className="balance-lab-artifacts"><button type="button" className="front-button front-button--primary" onClick={() => document.getElementById("balance-lab-analysis")?.scrollIntoView({ behavior: "smooth", block: "start" })}>View graphs and filterable data</button>{selectedJob.artifacts.map((name) => <button key={name} type="button" className="front-button" onClick={() => void openArtifact(selectedJob, name)}>{ARTIFACT_LABELS[name] ?? name}</button>)}</div>}
{selectedJob.logTail.length > 0 && <details className="balance-lab-log"><summary>Server progress log</summary><pre>{selectedJob.logTail.slice(-20).join("\n")}</pre></details>}
</> : <p className="balance-lab-empty">Select a job to inspect its progress and reports.</p>}
</aside>
{selectedJob?.status === "complete" && <div className="balance-lab-analysis" id="balance-lab-analysis">
{reportLoadingId === selectedJob.id && <p className="balance-results-loading">Loading this run's complete analysis…</p>}
{reportError && !selectedReport && <p className="front-notice" role="alert">{reportError}</p>}
{selectedReport && <BalanceLabResults report={selectedReport} />}
</div>}
</main>
</FrontSurface>
);
}
+26 -6
View File
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from "react";
import { useShellStore } from "../app/shellStore";
import { BOSS_TESTING_STATUSES, BOSS_TESTING_STATUS_LABELS, useBossTestingReviews, type BossTestingStatus } from "../app/bossTestingReviews";
import { bossTestIsDefeated } from "../game/BossTestingBridge";
import { enterBossTesting, prepareBossTesting } from "../app/bossTestingRuntime";
import { anotherRandomTestingBoss, BOSS_TESTING_CATALOG } from "../game/bossTesting";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
@@ -14,6 +16,7 @@ export function BossTestingReviewPanel() {
const save = useBossTestingReviews((state) => state.save);
const refresh = useBossTestingReviews((state) => state.refresh);
const [saving, setSaving] = useState(false);
const [startingRandom, setStartingRandom] = useState(false);
const [message, setMessage] = useState("");
const busy = useRef(false);
const mark = async (status: BossTestingStatus) => {
@@ -30,21 +33,37 @@ export function BossTestingReviewPanel() {
return () => window.removeEventListener("online", reconnect);
}, [refresh, session]);
const defeated = bossTestIsDefeated();
const disabled = saving || startingRandom;
const back = () => { if (!busy.current) useGameStore.getState().openOverlay("pause"); };
const retry = () => useGameStore.getState().resetAtEntrance();
const leave = () => useShellStore.getState().returnToMainMenu();
const fightRandom = async () => {
if (busy.current || !boss) return;
const next = anotherRandomTestingBoss(BOSS_TESTING_CATALOG, boss.key);
if (!next) { setMessage("No other bosses are available."); return; }
busy.current = true; setStartingRandom(true); setMessage(`Preparing ${next.name}…`);
try {
await prepareBossTesting(next);
enterBossTesting(next, useShellStore.getState().activeDungeonRole ?? undefined);
} catch (failure) {
setMessage(failure instanceof Error ? failure.message : "The next boss could not be started. Please retry.");
} finally {
busy.current = false; setStartingRandom(false);
}
};
const actions: MenuAction[] = [
...BOSS_TESTING_STATUSES.map((status) => ({ id: `boss-review-${status}`, run: () => void mark(status), enabled: !saving })),
{ id: "boss-review-report", run: () => useGameStore.getState().openOverlay("report-issue"), enabled: !saving },
{ id: "boss-review-retry", run: retry, enabled: !saving },
{ id: "boss-review-leave", run: leave, enabled: !saving },
{ id: "boss-review-back", run: back, enabled: !saving },
...BOSS_TESTING_STATUSES.map((status) => ({ id: `boss-review-${status}`, run: () => void mark(status), enabled: !disabled })),
{ id: "boss-review-report", run: () => useGameStore.getState().openOverlay("report-issue"), enabled: !disabled },
{ id: "boss-review-retry", run: retry, enabled: !disabled },
{ id: "boss-review-random", run: () => void fightRandom(), enabled: !disabled },
{ id: "boss-review-leave", run: leave, enabled: !disabled },
{ id: "boss-review-back", run: back, enabled: !disabled },
];
const controller = useMenuController(actions, { initialId: "boss-review-needs-testing", onBack: back });
if (!boss) return null;
const button = (controlId: string, label: string, run: () => void, pressed?: boolean) =>
<ControllerButton controlId={controlId} selectedId={controller.selectedId} select={controller.select}
type="button" className="button" onClick={run} disabled={saving} aria-pressed={pressed}>{label}</ControllerButton>;
type="button" className="button" onClick={run} disabled={disabled} aria-pressed={pressed}>{label}</ControllerButton>;
return <div className="modal-backdrop" role="presentation">
<section className="pause-menu boss-testing-review" role="dialog" aria-modal="true" aria-labelledby="boss-review-title">
<p className="eyebrow">{defeated ? "Boss defeated" : "Boss Testing paused"}</p><h2 id="boss-review-title">{boss.name}</h2>
@@ -56,6 +75,7 @@ export function BossTestingReviewPanel() {
<div className="pause-menu__actions">
{button("boss-review-report", "Submit a boss report", () => useGameStore.getState().openOverlay("report-issue"))}
{button("boss-review-retry", "Fight this boss again", retry)}
{button("boss-review-random", startingRandom ? "Preparing random boss…" : "Fight another random boss", () => void fightRandom())}
{button("boss-review-leave", "Return to main menu", leave)}
{button("boss-review-back", "Back to pause menu", back)}
</div>
+249
View File
@@ -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>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import { dungeonRouteDefinition } from "../game/dungeonRoutes";
import type { DungeonSimulationSpeed } from "../game/dungeonSimulationTypes";
import { finalizeDungeonSimulation } from "../scene/DungeonSimulationSceneBridge";
import { useShellStore } from "../app/shellStore";
const SPEEDS: readonly DungeonSimulationSpeed[] = [0.5, 1, 2, 4];
function formatTime(elapsedMs: number): string {
const seconds = Math.floor(elapsedMs / 1_000);
return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}.${Math.floor((elapsedMs % 1_000) / 100)}`;
}
function downloadReport(): void {
const simulation = useDungeonSimulationStore.getState();
const report = simulation.replay?.report;
if (!report) return;
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 DungeonSimulationHud() {
const simulation = useDungeonSimulationStore();
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 returnToGmTools = () => {
useDungeonSimulationStore.getState().leave();
useShellStore.getState().openGmAdmin();
};
return (
<aside className="dungeon-sim-hud" aria-label="Dungeon simulation controls">
<div className="dungeon-sim-hud__header">
<div>
<small>{simulation.sessionMode === "spectate-replay" ? "Recorded replay" : simulation.mode.replace("-", " ")}</small>
<strong>{simulation.dungeonId ?? "Dungeon simulation"}</strong>
</div>
<time>{formatTime(simulation.elapsedMs)}</time>
</div>
<div className="dungeon-sim-hud__controls">
<button type="button" onClick={simulation.toggleRunning} disabled={simulation.runState === "finished"}>
{simulation.runState === "running" ? "Pause" : "Play"}
</button>
<button type="button" onClick={simulation.step} disabled={simulation.runState !== "paused"}>Step</button>
{SPEEDS.map((speed) => (
<button
type="button"
key={speed}
className={simulation.speed === speed ? "is-active" : undefined}
onClick={() => simulation.setSpeed(speed)}
>
{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>
{simulation.sessionMode === "spectate-live" && simulation.runState !== "finished" ? (
<button type="button" className="is-danger" onClick={() => void finalizeDungeonSimulation("stopped")}>
Stop & save
</button>
) : null}
{simulation.replay && <button type="button" onClick={downloadReport}>Download JSON</button>}
{simulation.runState === "finished" && <button type="button" onClick={returnToGmTools}>Return to GM Tools</button>}
</div>
{simulation.sessionMode === "spectate-replay" && (
<label className="dungeon-sim-hud__seek">
<span>Replay position</span>
<input
type="range"
min={0}
max={replayDuration}
step={100}
value={Math.min(simulation.elapsedMs, replayDuration)}
onChange={(event) => simulation.seekReplay(Number(event.target.value))}
/>
</label>
)}
<dl className="dungeon-sim-hud__status">
<div><dt>State</dt><dd>{simulation.runState}{simulation.result ? ` · ${simulation.result}` : ""}</dd></div>
<div><dt>Objective</dt><dd>{leg?.objectiveName ?? simulation.objectiveId ?? "Complete"}</dd></div>
<div><dt>Checkpoint</dt><dd>{leg ? `${Math.min(simulation.checkpointIndex + 1, leg.checkpoints.length)} / ${leg.checkpoints.length}` : "—"}</dd></div>
<div><dt>Route</dt><dd>{simulation.routeHash?.slice(0, 12) ?? "—"}</dd></div>
</dl>
<section className="dungeon-sim-hud__findings">
<header><strong>Findings</strong><span>{simulation.findings.length}</span></header>
<ol>
{[...simulation.findings].reverse().slice(0, 12).map((finding) => (
<li key={finding.id} data-severity={finding.severity}>
<time>{formatTime(finding.atMs)}</time>
<span>{finding.message}</span>
</li>
))}
</ol>
</section>
</aside>
);
}
+39 -18
View File
@@ -9,11 +9,12 @@ import { ControllerButton } from "./ControllerButton";
import { ContentManagerScreen } from "./ContentManagerScreen";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
import { sessionCanUseGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
import { DungeonReportsScreen } from "./DungeonReportsScreen";
import { MainMenuCharacterEditor, type MainMenuEditor } from "./MainMenuCharacterEditor";
const BossCardCollectionScreen = lazy(() => import("./BossCardCollection"));
const BossTestingScreen = lazy(() => import("./BossTestingScreen").then((module) => ({ default: module.BossTestingScreen })));
const BalanceLabScreen = lazy(() => import("./BalanceLabScreen").then((module) => ({ default: module.BalanceLabScreen })));
const GAME_MODES = [
{
@@ -71,20 +72,23 @@ export function MainMenuScreen() {
const [reportsOpen, setReportsOpen] = useState(false);
const [cardsOpen, setCardsOpen] = useState(false);
const [bossTestingOpen, setBossTestingOpen] = useState(false);
const [balanceLabOpen, setBalanceLabOpen] = useState(false);
const [editor, setEditor] = useState<MainMenuEditor | null>(null);
if (editor) return <MainMenuCharacterEditor editor={editor} onClose={() => setEditor(null)} />;
if (bossTestingOpen) return <Suspense fallback={<p role="status">Opening Boss Testing…</p>}><BossTestingScreen onClose={() => setBossTestingOpen(false)} /></Suspense>;
if (balanceLabOpen) return <Suspense fallback={<p role="status">Opening Balance Lab…</p>}><BalanceLabScreen onClose={() => setBalanceLabOpen(false)} /></Suspense>;
if (cardsOpen) return <Suspense fallback={<p role="status">Opening collection log…</p>}><BossCardCollectionScreen onClose={() => setCardsOpen(false)} /></Suspense>;
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenEditor={setEditor} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenBalanceLab={() => setBalanceLabOpen(true)} onOpenEditor={setEditor} />;
}
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenEditor }: {
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenBalanceLab, onOpenEditor }: {
readonly onOpenDownloads: () => void;
readonly onOpenReports: () => void;
readonly onOpenCards: () => void;
readonly onOpenBossTesting: () => void;
readonly onOpenBalanceLab: () => void;
readonly onOpenEditor: (editor: MainMenuEditor) => void;
}) {
const activeCharacter = useShellStore((state) => state.activeCharacter);
@@ -99,7 +103,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
const openGmAdmin = useShellStore((state) => state.openGmAdmin);
const character = activeCharacter ?? rosterCharacter;
const isRom = character?.categoryId === "rom";
const isGm = sessionCanUseShelvedGmTools(session);
const isGm = sessionCanUseGmTools(session);
const actions = useMemo<MenuAction[]>(() => [
{
@@ -132,11 +136,14 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
{ id: "mode-keybinds", run: () => onOpenEditor("keybinds") },
{ id: "mode-bindings", run: () => onOpenEditor("bindings") },
] : []),
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
...(isGm ? [
{ id: "mode-gm-tools", run: openGmAdmin },
{ id: "mode-balance-lab", run: onOpenBalanceLab },
] : []),
{ id: "mode-reports", run: onOpenReports },
{ id: "mode-cards", run: onOpenCards },
{ id: "mode-boss-testing", run: onOpenBossTesting },
], [character, isGm, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
], [character, isGm, onOpenBalanceLab, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
const controller = useMenuController(actions, {
initialId: "mode-dungeons",
onBack: returnToCharacters,
@@ -221,18 +228,32 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
<em>Open</em>
</ControllerButton>
{isGm && (
<ControllerButton
controlId="mode-gm-tools"
selectedId={controller.selectedId}
select={controller.select}
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--gm"
type="button"
onClick={openGmAdmin}
>
<b aria-hidden="true">GM</b>
<span><strong>GM Tools</strong><small>Manage Manastorm rules, boss readiness, and encounter placement.</small></span>
<em>Authorized</em>
</ControllerButton>
<div className="main-menu-gm-stack">
<ControllerButton
controlId="mode-gm-tools"
selectedId={controller.selectedId}
select={controller.select}
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--gm"
type="button"
onClick={openGmAdmin}
>
<b aria-hidden="true">GM</b>
<span><strong>GM Tools</strong><small>Run dungeon route simulations, inspect findings, and review recordings.</small></span>
<em>Authorized</em>
</ControllerButton>
<ControllerButton
controlId="mode-balance-lab"
selectedId={controller.selectedId}
select={controller.select}
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--gm"
type="button"
onClick={onOpenBalanceLab}
>
<b aria-hidden="true">BL</b>
<span><strong>Balance Lab</strong><small>Queue automatic party and boss simulations on the game server.</small></span>
<em>Server</em>
</ControllerButton>
</div>
)}
{character && (
<ControllerButton