Release Healer Man 0.1.46
This commit is contained in:
1 parent
f82daf1c13
commit
9ff574ac0b
73 files changed
+33486
-174
No files matched your search
@@ -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
@@ -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 />}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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
|
||||
)));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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)]),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user