Files
healer-man/src/ui/ArenaSelectScreen.tsx
T
2026-09-05 20:42:38 -04:00

159 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { useArenaStore } from "../game/arenaStore";
import { arenaAiProfile } from "../game/arenaRules";
import { ARENA_STAGES } from "../game/arenaStages";
import { romGearProfile, specializationById, specializationsForClass } from "../game/specializationCatalog";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { ControllerLegend, FrontSurface } from "./FrontSurface";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
const MODES = [
{ id: "offline-practice", name: "Offline Practice", detail: "Choose any arena and bot rating. Results never affect rating." },
{ id: "rated-ai", name: "Rated AI", detail: "Fill both teams immediately with rating-matched opponents." },
{ id: "ranked-pvp", name: "Ranked PvP", detail: "Match humans first; all open slots lock to bots after 30 seconds." },
] as const;
export function ArenaSelectScreen() {
const character = useShellStore(selectedCharacter);
const session = useShellStore((state) => state.session);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
const arena = useArenaStore();
const [loadoutName, setLoadoutName] = useState("Arena Loadout");
const specs = useMemo(() => {
if (!character) return [];
if (character.categoryId === "rom") return [romGearProfile(character.classId, "healer"), romGearProfile(character.classId, "damage")];
return specializationsForClass(character.classId).filter((spec) => spec.role !== "tank");
}, [character]);
const fallbackSpec = specs.find((spec) => spec.id === character?.specializationId) ?? specs[0] ?? null;
const [specializationId, setSpecializationId] = useState(fallbackSpec?.id ?? "");
const profile = arenaAiProfile(arena.botDifficulty);
const randomStage = arena.stageId === "random";
const selectedStage = ARENA_STAGES.find((stage) => stage.id === arena.stageId) ?? ARENA_STAGES[0];
const selectedLoadout = arena.remote?.loadouts.find((loadout) => loadout.slot === arena.selectedLoadoutSlot) ?? null;
const rating = arena.remote?.ratings[arena.bracket];
useEffect(() => {
if (character) void arena.load(session, character);
}, [character?.id, session?.accessToken]);
useEffect(() => {
if (!fallbackSpec) return;
setSpecializationId(fallbackSpec.id);
setLoadoutName(`${fallbackSpec.displayName} PvP`);
}, [fallbackSpec?.id]);
if (!character || !selectedStage) return null;
const online = Boolean(session?.accessToken);
const rated = arena.mode !== "offline-practice";
const nativeRuleset = character.categoryId === "coa" || character.categoryId === "rom" ? character.categoryId : "wow";
const stageHeading = rated ? "Random Rated Arena" : randomStage ? "Random Arena" : selectedStage.name;
const queueLabel = arena.queue.status === "queued"
? "Leave queue"
: arena.queue.status === "ready-check"
? "Ready"
: arena.loading ? "Working..." : arena.mode === "offline-practice" ? "Start practice" : "Enter rated queue";
const primaryAction = async () => {
if (arena.queue.status === "queued") {
await arena.leave(session);
return;
}
if (arena.queue.status === "ready-check" && session) {
await arena.ready(session);
return;
}
await arena.queueMatch(session, character);
};
return (
<DualDisplayFrame
contextLabel="Arena queue details"
top={(
<FrontSurface className="arena-select-screen" ariaLabel="Arena selection">
<header className="arena-select-header">
<div><span className="brand-sigil">+</span><strong>Arena Command</strong></div>
<p><small>{character.name}</small><b>{arena.bracket} / {rating ? `${rating.rating} ${rating.title}` : "Practice"}</b></p>
<button className="front-button" type="button" onClick={returnToMainMenu}>Back</button>
</header>
<aside className="arena-select-sidebar">
<p className="front-kicker">Format</p>
<div className="arena-segmented">
{(["2v2", "3v3"] as const).map((bracket) => (
<button key={bracket} type="button" className={arena.bracket === bracket ? "is-active" : ""} onClick={() => arena.configure({ bracket })}>{bracket}</button>
))}
</div>
<p className="front-kicker">Queue</p>
<div className="arena-mode-list">
{MODES.map((mode) => (
<button key={mode.id} type="button" className={arena.mode === mode.id ? "is-active" : ""} onClick={() => arena.configure({ mode: mode.id, ...(mode.id === "offline-practice" ? {} : { ruleset: nativeRuleset }) })}>
<strong>{mode.name}</strong><small>{mode.detail}</small>
</button>
))}
</div>
<p className="front-kicker">Ruleset pool</p>
<div className="arena-segmented">
{(["wow", "coa", "rom"] as const).map((ruleset) => (
<button key={ruleset} type="button" disabled={rated && ruleset !== nativeRuleset} className={arena.ruleset === ruleset ? "is-active" : ""} onClick={() => arena.configure({ ruleset })}>{ruleset.toUpperCase()}</button>
))}
</div>
</aside>
<main className="arena-select-main">
<section className="arena-stage-browser">
<header><div><p className="front-kicker">Eight battlegrounds</p><h1>{stageHeading}</h1></div><span className="arena-status arena-status--validated">validated</span></header>
<div className="arena-stage-grid">
<button type="button" disabled={rated} className={rated || randomStage ? "is-active" : ""} onClick={() => arena.configure({ stageId: "random" })}>
<b>00</b><span><strong>Random Arena</strong><small>Validated eight-map pool</small></span>
</button>
{ARENA_STAGES.map((stage, index) => (
<button key={stage.id} type="button" disabled={rated} className={!rated && !randomStage && stage.id === selectedStage.id ? "is-active" : ""} onClick={() => arena.configure({ stageId: stage.id })}>
<b>{String(index + 1).padStart(2, "0")}</b><span><strong>{stage.name}</strong><small>{stage.sourceMap}</small></span>
</button>
))}
</div>
<p className="arena-stage-note">{rated ? "Rated matches choose uniformly from the validated pool and avoid your immediately previous arena when possible." : randomStage ? "Practice will select one of the eight validated arenas when the match starts." : selectedStage.validation.notes[0]}</p>
</section>
<section className="arena-configuration">
{arena.mode === "offline-practice" ? (
<>
<header><p className="front-kicker">Opponent calibration</p><h2>{arena.botDifficulty} / {profile.title}</h2></header>
<input aria-label="Bot rating" type="range" min="0" max="2500" step="25" value={arena.botDifficulty} onChange={(event) => arena.configure({ botDifficulty: Number(event.target.value) })} />
<dl>
<div><dt>Reaction</dt><dd>{profile.reactionMinSeconds.toFixed(2)}–{profile.reactionMaxSeconds.toFixed(2)}s</dd></div>
<div><dt>Healer kicks</dt><dd>{Math.round(profile.healerInterruptChance * 100)}%</dd></div>
<div><dt>Minimum error</dt><dd>{Math.round(profile.minimumErrorChance * 100)}%</dd></div>
</dl>
</>
) : (
<>
<header><p className="front-kicker">Level 80 PvP profile</p><h2>{selectedLoadout?.name ?? "Automatic first loadout"}</h2></header>
{!online && <p className="front-notice">Sign in to use rated queues. Practice remains fully offline.</p>}
{online && (
<div className="arena-loadout-editor">
<label>Name<input value={loadoutName} maxLength={32} onChange={(event) => setLoadoutName(event.target.value)} /></label>
<label>Specialization<select value={specializationId} onChange={(event) => setSpecializationId(event.target.value)}>{specs.map((spec) => <option key={spec.id} value={spec.id}>{spec.displayName} / {spec.role}</option>)}</select></label>
<label>Slot<select value={arena.selectedLoadoutSlot} onChange={(event) => arena.configure({ selectedLoadoutSlot: Number(event.target.value) })}>{Array.from({ length: 10 }, (_, index) => <option key={index + 1} value={index + 1}>{index + 1}</option>)}</select></label>
<button type="button" onClick={() => void arena.saveLoadout(session!, character, { slot: arena.selectedLoadoutSlot, name: loadoutName, specializationId })}>Save loadout</button>
</div>
)}
<p>Gear and prestige are replaced by versioned arena templates. Cosmetics remain visible.</p>
</>
)}
<button className="front-button front-button--primary arena-queue-button" disabled={arena.loading || (rated && !online)} type="button" onClick={() => void primaryAction()}>
<strong>{queueLabel}</strong>
<small>{arena.queue.status === "queued" ? `Searching within ±${arena.queue.searchRange ?? 100}` : rated ? "15s ready check / 10s sealed preparation" : `${randomStage ? "Random arena" : selectedStage.name} / no rating changes`}</small>
</button>
{arena.error && <p className="front-notice" role="alert">{arena.error}</p>}
</section>
</main>
<ControllerLegend back />
</FrontSurface>
)}
bottom={<LazyLocalCompanionPanel />}
/>
);
}