Files
healer-man/src/ui/DungeonRaceScreen.tsx
2026-09-27 21:48:00 -04:00

153 lines
12 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { useDungeonRaceStore, type RacePool } from "../app/dungeonRaceStore";
import { loadDungeonQueuePolicy, useDungeonQueuePolicyStore } from "../app/dungeonQueuePolicyStore";
import { requestOnlineJson } from "../app/onlineAccountClient";
import { type RaceBotSource, type RaceBotTier } from "../app/dungeonRaceBot";
import { dungeonsByCategory, randomizedBiteSizedDungeons } from "../game/dungeonCatalog";
import { dungeonEnabledForQueue } from "../game/dungeonQueuePolicy";
import { formatDungeonDuration } from "../game/dungeonRunTypes";
import { dungeonRaceAffix } from "../game/dungeonRaceAffixes";
import { dungeonLeaderboardCatalog as leaderboardCatalog } from "../game/dungeonLeaderboardCatalog";
import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyStore";
import { specializationById } from "../game/specializationCatalog";
import { BrandMark, FrontSurface } from "./FrontSurface";
import "./dungeonRace.css";
import { BiteSizedBoardFilters, useBiteSizedBoardSelection } from "./BiteSizedBoardFilters";
interface BoardEntry { readonly accountId: string; readonly username: string; readonly rank: number;
readonly wins?: number; readonly losses?: number; readonly durationMs?: number;
readonly affixIds?: readonly string[] }
interface BoardResponse { readonly entries: readonly BoardEntry[]; readonly total: number }
export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void }) {
const character = useShellStore((state) => state.activeCharacter) ?? useShellStore(selectedCharacter);
const session = useShellStore((state) => state.session);
const race = useDungeonRaceStore();
const policy = useDungeonQueuePolicyStore((state) => state.config);
const refreshPolicy = useDungeonQueuePolicyStore((state) => state.refresh);
const [pool, setPool] = useState<RacePool>("authored");
const [dungeonId, setDungeonId] = useState("");
const [practice, setPractice] = useState(!session?.accessToken);
const [tier, setTier] = useState<RaceBotTier>("regular");
const [source, setSource] = useState<RaceBotSource>("wow");
const [role, setRole] = useState<PartyRole | null>(null);
const [period, setPeriod] = useState<"30d" | "all">("30d");
const [boardScope, setBoardScope] = useState<"overall" | "dungeon">("overall");
const [boardView, setBoardView] = useState<"wins" | "times">("wins");
const [boardBossId, setBoardBossId] = useState("");
const [board, setBoard] = useState<BoardResponse | null>(null);
const [boardError, setBoardError] = useState("");
useEffect(() => { void refreshPolicy(session); }, [refreshPolicy, session]);
const allEntries = useMemo(() => (pool === "randomized" ? randomizedBiteSizedDungeons()
: dungeonsByCategory("bite-sized")).filter((entry) => entry.available && dungeonEnabledForQueue(policy, entry)),
[pool, policy]);
const boardDungeonId = dungeonId || allEntries[0]?.id || "";
const boardDungeon = leaderboardCatalog.dungeons.find((entry) => entry.id === boardDungeonId);
const bossOptions = boardDungeon?.difficulties.find((entry) => entry.id === boardDungeon.defaultDifficultyId)
?.finalBossIds?.map((id) => boardDungeon.bosses.find((boss) => boss.id === id)!) ?? [];
const selectedBoardBossId = bossOptions.find((boss) => boss.id === boardBossId)?.id ?? bossOptions[0]?.id;
const spotBoard = useBiteSizedBoardSelection(boardDungeonId);
useEffect(() => {
const controller = new AbortController();
const query = new URLSearchParams({ view: boardScope === "overall" ? "wins" : boardView,
period, ...(boardScope === "dungeon" && boardDungeonId ? { dungeon: boardDungeonId,
...(selectedBoardBossId ? { boss: selectedBoardBossId } : {}),
...(boardView === "times" && spotBoard.spotId ? { spot: spotBoard.spotId, spotRevision: String(spotBoard.spotRevision) } : {}) } : {}) });
if (boardScope === "dungeon" && !boardDungeonId) { setBoard(null); return; }
void requestOnlineJson<BoardResponse>(`/api/dungeon-races/leaderboard?${query}`, { signal: controller.signal })
.then((value) => { setBoard(value); setBoardError(""); })
.catch((error: unknown) => { if (!controller.signal.aborted) { setBoard(null);
setBoardError(error instanceof Error ? error.message : "Leaderboard unavailable."); } });
return () => controller.abort();
}, [boardDungeonId, boardScope, boardView, period, selectedBoardBossId, spotBoard.spotId, spotBoard.spotRevision]);
const specialization = specializationById(character?.specializationId);
const roles = character?.categoryId === "rom" ? partyRolesForClass(character.classId)
: specialization ? [specialization.role] : [];
const eligibleCharacter = character && ["wow", "rom"].includes(character.categoryId ?? "wow");
const selectedRole = role && roles.includes(role) ? role : roles[0];
const busy = ["queued", "preparing", "countdown", "active"].includes(race.status) || race.preparingContent;
const start = async () => {
if (!character || !eligibleCharacter || !selectedRole || !allEntries.length) return;
const latest = await loadDungeonQueuePolicy();
const candidates = allEntries.filter((entry) => dungeonEnabledForQueue(latest, entry));
if (!candidates.length) { useDungeonRaceStore.setState({ error: "No dungeon is enabled for this race queue." }); return; }
if (practice) await race.startPractice(character, selectedRole,
dungeonId || candidates[Math.floor(Math.random() * candidates.length)].id, tier, source);
else if (session?.accessToken) await race.startOnline(session, character, selectedRole, pool, dungeonId || undefined);
};
return <FrontSurface className="dungeon-race-screen" ariaLabel="Bite-Sized Dungeon Race">
<header className="dungeon-race-screen__header"><BrandMark compact /><div><small>Main menu / Dungeon Race</small>
<h1>Bite-Sized Dungeon Race</h1></div><button className="front-button" type="button" onClick={() => { void race.leave(); onClose(); }}>Main menu</button></header>
<main className="dungeon-race-screen__layout">
<section className="dungeon-race-screen__setup">
<p>Race another player or a full NPC party through matching short dungeons. First final boss kill wins. You can still finish and earn its card after a loss. Each race rolls one kill mechanic and one movement mechanic for both sides.</p>
<div className="dungeon-race-screen__choices" role="group" aria-label="Race type">
<button className="front-button" type="button" aria-pressed={!practice} disabled={!session?.accessToken || busy} onClick={() => setPractice(false)}>Online ranked</button>
<button className="front-button" type="button" aria-pressed={practice} disabled={busy} onClick={() => setPractice(true)}>Offline practice</button>
</div>
<div className="dungeon-race-screen__choices" role="group" aria-label="Dungeon pool">
<button className="front-button" type="button" aria-pressed={pool === "authored"} disabled={busy}
onClick={() => { setPool("authored"); setDungeonId(""); }}>Bite-Sized</button>
<button className="front-button" type="button" aria-pressed={pool === "randomized"} disabled={busy}
onClick={() => { setPool("randomized"); setDungeonId(""); }}>Randomized Bite-Sized</button>
</div>
<label>{pool === "randomized" ? "Creature pool" : "Dungeon"}<select value={dungeonId} disabled={busy} onChange={(event) => setDungeonId(event.target.value)}>
<option value="">{pool === "randomized" ? "Random available creature pool" : "Random eligible dungeon"}</option>
{allEntries.map((entry) => <option key={entry.id} value={entry.id}>{entry.title}</option>)}
</select></label>
<div className="dungeon-race-screen__choices" role="group" aria-label="Your role">
{roles.map((candidate) => <button key={candidate} className="front-button" type="button"
aria-pressed={selectedRole === candidate} disabled={busy} onClick={() => setRole(candidate)}>{PARTY_ROLE_LABELS[candidate]}</button>)}
</div>
{practice && <div className="dungeon-race-screen__practice">
<label>NPC skill<select value={tier} onChange={(event) => setTier(event.target.value as RaceBotTier)}>
<option value="novice">Novice</option><option value="regular">Regular</option>
<option value="veteran">Veteran</option><option value="elite">Elite</option>
</select></label>
<label>NPC party<select value={source} onChange={(event) => setSource(event.target.value as RaceBotSource)}>
<option value="wow">WoW</option><option value="rom">RoM</option>
</select></label>
<small>Practice awards local boss cards and never enters a leaderboard.</small>
</div>}
{!eligibleCharacter && <p role="status">Select a WoW or RoM character to race. CoA characters will be added later.</p>}
{!session?.accessToken && !practice && <p role="status">Sign in online for ranked races.</p>}
{!allEntries.length && <p role="status">No Bite-Sized dungeon is enabled for this queue.</p>}
{race.status === "queued" && <p role="status">Searching for a player. An NPC team joins after five seconds.</p>}
{race.status === "preparing" && <p role="status">{race.preparingContent ? "Installing race content…" : "Preparing both parties…"}</p>}
{race.error && <p role="alert">{race.error}</p>}
<div className="dungeon-race-screen__choices"><button className="front-button front-button--primary" type="button"
disabled={busy || !eligibleCharacter || !selectedRole || !allEntries.length} onClick={() => void start()}>
{practice ? "Start practice" : "Enter race queue"}</button>
{busy && <button className="front-button" type="button" onClick={() => void race.leave()}>Cancel race</button>}
</div>
</section>
<section className="dungeon-race-screen__board" aria-label="Dungeon Race leaderboards">
<h2>Leaderboards</h2>
{boardScope === "dungeon" && boardView === "times" && <BiteSizedBoardFilters selection={spotBoard} />}
<div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={boardScope === "overall"} onClick={() => setBoardScope("overall")}>Overall wins</button>
<button className="front-button" type="button" aria-pressed={boardScope === "dungeon"} onClick={() => setBoardScope("dungeon")}>This dungeon</button></div>
{boardScope === "dungeon" && <div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={boardView === "wins"} onClick={() => setBoardView("wins")}>Wins &amp; losses</button>
<button className="front-button" type="button" aria-pressed={boardView === "times"} onClick={() => setBoardView("times")}>Fastest clears</button></div>}
{boardScope === "dungeon" && bossOptions.length > 1 && <label>Boss<select value={selectedBoardBossId}
onChange={(event) => setBoardBossId(event.target.value)}>
{bossOptions.map((boss) => <option key={boss.id} value={boss.id}>{boss.name}</option>)}
</select></label>}
<div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={period === "30d"} onClick={() => setPeriod("30d")}>Last 30 days</button>
<button className="front-button" type="button" aria-pressed={period === "all"} onClick={() => setPeriod("all")}>All time</button></div>
{boardError && <p role="status">{boardError}</p>}
{!boardError && board?.entries.length === 0 && <p>No results yet.</p>}
{board?.entries.length ? <table><thead><tr><th>Rank</th><th>Player</th>
<th>{boardScope === "dungeon" && boardView === "times" ? "Best time" : "Wins"}</th>
{boardScope === "dungeon" && boardView === "times" && <th>Mechanics</th>}
{!(boardScope === "dungeon" && boardView === "times") && <th>Losses</th>}</tr></thead><tbody>
{board.entries.map((entry) => <tr key={entry.accountId}><td>#{entry.rank}</td><td>{entry.username}</td>
<td>{entry.durationMs !== undefined ? formatDungeonDuration(entry.durationMs) : entry.wins}</td>
{boardScope === "dungeon" && boardView === "times" && <td>{entry.affixIds?.length
? entry.affixIds.map((id) => dungeonRaceAffix(id)?.name ?? id).join(" + ") : "Legacy"}</td>}
{entry.durationMs === undefined && <td>{entry.losses}</td>}</tr>)}</tbody></table> : null}
</section>
</main>
</FrontSurface>;
}