forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
9c5fb4c614
commit
bd073fb00f
113 files changed
+4902
-273
No files matched your search
@@ -0,0 +1,132 @@
|
||||
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 { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyStore";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
import "./dungeonRace.css";
|
||||
|
||||
interface BoardEntry { readonly accountId: string; readonly username: string; readonly rank: number;
|
||||
readonly wins?: number; readonly losses?: number; readonly durationMs?: number }
|
||||
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 [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 || "";
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const query = new URLSearchParams({ view: boardScope === "overall" ? "wins" : boardView,
|
||||
period, ...(boardScope === "dungeon" && boardDungeonId ? { dungeon: boardDungeonId } : {}) });
|
||||
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]);
|
||||
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.</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>Dungeon<select value={dungeonId} disabled={busy} onChange={(event) => setDungeonId(event.target.value)}>
|
||||
<option value="">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>
|
||||
<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 & losses</button>
|
||||
<button className="front-button" type="button" aria-pressed={boardView === "times"} onClick={() => setBoardView("times")}>Fastest clears</button></div>}
|
||||
<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>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>
|
||||
{entry.durationMs === undefined && <td>{entry.losses}</td>}</tr>)}</tbody></table> : null}
|
||||
</section>
|
||||
</main>
|
||||
</FrontSurface>;
|
||||
}
|
||||
Reference in new issue
Block a user