Release Healer Man 0.1.16

This commit is contained in:
phenom committed 2026-09-05 20:42:38 -04:00
1 parent f49e54f35f
commit b57d2b2839
89 files changed
+6055 -17

No files matched your search

+158
View File
@@ -0,0 +1,158 @@
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 />}
/>
);
}
+13 -5
View File
@@ -27,11 +27,11 @@ const GAME_MODES = [
available: true,
},
{
id: "roguelike-rpg",
id: "arenas",
number: "03",
name: "Roguelike RPG",
description: "A new mode is taking shape.",
available: false,
name: "Arenas",
description: "Rated 2v2 and 3v3 combat against players or rating-matched opponents.",
available: true,
},
{
id: "trials",
@@ -69,6 +69,7 @@ export function MainMenuScreen() {
const rosterCharacter = useShellStore(selectedCharacter);
const openDungeons = useShellStore((state) => state.openDungeons);
const openManastorms = useShellStore((state) => state.openManastorms);
const openArenas = useShellStore((state) => state.openArenas);
const openClassHall = useShellStore((state) => state.openClassHall);
const returnToCharacters = useShellStore((state) => state.returnToCharacters);
const notice = useShellStore((state) => state.notice);
@@ -89,6 +90,11 @@ export function MainMenuScreen() {
run: openManastorms,
neighbors: { left: "mode-dungeons", down: "back-to-roster" },
},
{
id: "mode-arenas",
run: openArenas,
neighbors: { left: "mode-manastorms", down: "back-to-roster" },
},
{
id: "mode-downloads",
run: () => setContentManagerOpen(true),
@@ -101,7 +107,7 @@ export function MainMenuScreen() {
},
...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []),
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
], [character, isGm, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
], [character, isGm, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
const controller = useMenuController(actions, {
initialId: "mode-dungeons",
onBack: returnToCharacters,
@@ -197,6 +203,8 @@ export function MainMenuScreen() {
type="button"
onClick={mode.id === "manastorms"
? openManastorms
: mode.id === "arenas"
? openArenas
: mode.id === "downloads"
? () => setContentManagerOpen(true)
: openDungeons}