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

+4
View File
@@ -14,6 +14,8 @@ const ClassHallScreen = lazy(() => import("./ui/ClassHallScreen").then((module)
const DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen })));
const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
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 ShellTransition() {
@@ -72,6 +74,8 @@ export default function App() {
else if (phase === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
else if (phase === "manastorms") screen = <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
else if (phase === "arenas") screen = <Suspense fallback={<ShellTransition />}><ArenaSelectScreen /></Suspense>;
else if (phase === "arena-game") screen = <Suspense fallback={<ShellTransition />}><ArenaRuntime /></Suspense>;
else if (phase === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><GmAdminScreen /></Suspense>;
else if (phase === "game") screen = <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>;
const requiresRuntimeCatalog = !["login", "characters", "create-character"].includes(phase);
+281
View File
@@ -0,0 +1,281 @@
import { Clone } from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber";
import { Suspense, useEffect, useMemo, useRef, type RefObject } from "react";
import { FrontSide, Group, Mesh, Vector3 } from "three";
import { selectedCharacter, useShellStore } from "./app/shellStore";
import { CharacterModel } from "./avatar/CharacterModel";
import { ProceduralAvatar } from "./avatar/ProceduralAvatar";
import { classById } from "./app/characterCatalog";
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./game/equipment";
import { useArenaStore } from "./game/arenaStore";
import { arenaStageById } from "./game/arenaStages";
import type { ArenaActorSnapshot, ArenaDynamicObjectSnapshot, ArenaStageManifest } from "./game/arenaTypes";
import { GameGltfLoaderLifecycle, useGameGLTF } from "./scene/useGameGLTF";
function StageEnvironment({ stage }: { stage: ArenaStageManifest }) {
const visual = stage.assets.find((asset) => asset.id === "visual")?.url;
return visual ? <LoadedStage url={visual} /> : <ProceduralStage stage={stage} />;
}
function LoadedStage({ url }: { url: string }) {
const gltf = useGameGLTF(`${url}?arena-runtime=1`);
useEffect(() => {
gltf.scene.traverse((object) => {
if (!(object instanceof Mesh)) return;
const materials = Array.isArray(object.material) ? object.material : [object.material];
for (const material of materials) {
material.side = FrontSide;
material.needsUpdate = true;
}
});
}, [gltf.scene]);
return <Clone object={gltf.scene} />;
}
function ProceduralStage({ stage }: { stage: ArenaStageManifest }) {
const width = stage.bounds.max[0] - stage.bounds.min[0];
const depth = stage.bounds.max[2] - stage.bounds.min[2];
const centerX = (stage.bounds.max[0] + stage.bounds.min[0]) / 2;
const centerZ = (stage.bounds.max[2] + stage.bounds.min[2]) / 2;
return (
<group>
<mesh position={[centerX, stage.bounds.min[1], centerZ]} receiveShadow>
<boxGeometry args={[width, 0.5, depth]} />
<meshStandardMaterial color="#252b27" roughness={0.9} />
</mesh>
{stage.lineOfSight.map((object) => (
<mesh key={object.id} position={object.position as [number, number, number]} castShadow receiveShadow>
{object.shape === "cylinder"
? <cylinderGeometry args={[object.size[0] / 2, object.size[0] / 2, object.size[1], 16]} />
: <boxGeometry args={object.size as [number, number, number]} />}
<meshStandardMaterial color="#4e5146" roughness={0.78} />
</mesh>
))}
</group>
);
}
function DynamicStageObjects({ stage, objects }: { stage: ArenaStageManifest; objects: readonly ArenaDynamicObjectSnapshot[] }) {
const states = useMemo(() => new Map(objects.map((object) => [object.id, object])), [objects]);
const definitions = useMemo(() => new Map(stage.dynamicObjects.map((object) => [object.id, object])), [stage]);
const fire = states.get("fire");
const pipes = states.get("pipes");
const elevators = states.get("elevators");
return (
<group>
{stage.lineOfSight.filter((object) => object.dynamicObjectId).map((object) => {
const state = states.get(object.dynamicObjectId!);
const definition = definitions.get(object.dynamicObjectId!);
if (!state || !definition) return null;
const waterfall = definition.kind === "waterfall";
if (waterfall && state.state === "open") return null;
const raised = state.state === "raised" || state.state === "active";
const movingOffset = definition.kind === "moving-pillars" && !raised ? object.size[1] * 0.8 : 0;
const color = waterfall ? (state.state === "warning" ? "#9fcaf0" : "#4aa9ef") : "#9a7447";
return (
<mesh key={object.id} position={[object.position[0], object.position[1] - movingOffset, object.position[2]]} castShadow receiveShadow>
{object.shape === "cylinder"
? <cylinderGeometry args={[object.size[0] / 2, object.size[0] / 2, object.size[1], 16]} />
: <boxGeometry args={object.size as [number, number, number]} />}
<meshStandardMaterial color={color} emissive={waterfall ? color : "#000000"} emissiveIntensity={waterfall ? 0.35 : 0} transparent={waterfall} opacity={waterfall ? 0.62 : 0.9} />
</mesh>
);
})}
{fire?.state === "active" && [-31, 31].map((z) => (
<mesh key={z} position={[0, stage.bounds.min[1] + 0.6, z]}>
<boxGeometry args={[70, 1.2, 4]} />
<meshStandardMaterial color="#ff7a21" emissive="#ff3b0b" emissiveIntensity={1.8} transparent opacity={0.72} />
</mesh>
))}
{pipes && (pipes.state === "warning" || pipes.state === "active") && stage.teamSpawns.map((spawn, index) => (
<mesh key={index} position={[spawn[0], spawn[1] + 1.3, spawn[2] + (index === 0 ? 8 : -8)]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[pipes.state === "active" ? 2.2 : 1.1, 0.4, pipes.state === "active" ? 10 : 4, 12]} />
<meshStandardMaterial color={pipes.state === "active" ? "#73c8ff" : "#d9e9f6"} emissive="#4b9dcc" emissiveIntensity={0.8} transparent opacity={0.7} />
</mesh>
))}
{elevators && stage.teamSpawns.map((spawn, index) => {
const descent = elevators.state === "open" ? 1 : elevators.state === "moving" ? elevators.progress : 0;
return (
<mesh key={index} position={[spawn[0], spawn[1] - 0.5 - descent * 7, spawn[2]]} receiveShadow>
<cylinderGeometry args={[4.5, 4.5, 1, 24]} />
<meshStandardMaterial color="#6b5944" metalness={0.45} roughness={0.5} />
</mesh>
);
})}
</group>
);
}
function ArenaActor({ actor, local, character, movement, stage }: { actor: ArenaActorSnapshot; local: boolean; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]>; stage: ArenaStageManifest }) {
const group = useRef<Group>(null);
useFrame((_, delta) => {
const node = group.current;
if (!node) return;
if (local) {
node.position.x = Math.max(stage.bounds.min[0], Math.min(stage.bounds.max[0], node.position.x + movement.current[0] * 7 * delta));
node.position.z = Math.max(stage.bounds.min[2], Math.min(stage.bounds.max[2], node.position.z + movement.current[1] * 7 * delta));
}
node.position.lerp(new Vector3(...actor.position), Math.min(1, delta * (local ? 4 : 14)));
node.rotation.y = actor.facing;
});
const color = actor.team === 0 ? "#58b9ff" : "#e45b55";
const cosmeticEquipment = useMemo(
() => character ? equipmentItemsForOwner(character.equipment, character.inventory, PLAYER_EQUIPMENT_OWNER_ID) : [],
[character],
);
return (
<group ref={group} position={actor.position as [number, number, number]} rotation={[0, actor.facing, 0]} visible={actor.alive}>
{local && character ? (
<CharacterModel identity={character} equipment={cosmeticEquipment} fallback={<ProceduralAvatar accent={classById(character.classId).color} />} />
) : (
<group>
<mesh position={[0, 0.9, 0]} castShadow><capsuleGeometry args={[0.45, 1, 6, 12]} /><meshStandardMaterial color={color} roughness={0.55} /></mesh>
<mesh position={[0, 1.85, 0]} castShadow><sphereGeometry args={[0.34, 14, 10]} /><meshStandardMaterial color={actor.role === "healer" ? "#e8df8b" : color} /></mesh>
</group>
)}
<pointLight position={[0, 1.2, 0]} color={color} intensity={0.75} distance={3} />
</group>
);
}
function ArenaCamera({ actor, stage }: { actor: ArenaActorSnapshot | null; stage: ArenaStageManifest }) {
const target = useMemo(() => new Vector3(), []);
const desired = useMemo(() => new Vector3(), []);
useFrame(({ camera }, delta) => {
if (!actor) return;
const spawn = stage.teamSpawns[actor.team];
const centerX = (stage.bounds.min[0] + stage.bounds.max[0]) / 2;
const centerZ = (stage.bounds.min[2] + stage.bounds.max[2]) / 2;
const directionX = centerX - spawn[0];
const directionZ = centerZ - spawn[2];
const length = Math.max(0.001, Math.hypot(directionX, directionZ));
target.set(actor.position[0], actor.position[1] + 1.2, actor.position[2]);
desired.set(actor.position[0] - directionX / length * 5, actor.position[1] + 7.5, actor.position[2] - directionZ / length * 5);
camera.position.lerp(desired, Math.min(1, delta * 5));
camera.lookAt(target);
});
return null;
}
function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]> }) {
return (
<Canvas shadows dpr={[1, 1.35]} camera={{ fov: 52, near: 0.1, far: 1200, position: [0, 16, 24] }}>
<color attach="background" args={["#111815"]} />
<fog attach="fog" args={["#111815", 55, 240]} />
<hemisphereLight args={["#c9d8cd", "#101713", 1.55]} />
<directionalLight castShadow position={[24, 42, 18]} intensity={2.2} color="#f1d6a2" />
<GameGltfLoaderLifecycle>
<Suspense fallback={null}><StageEnvironment stage={stage} /></Suspense>
<DynamicStageObjects stage={stage} objects={dynamicObjects} />
{actors.map((actor) => <ArenaActor key={actor.id} actor={actor} local={actor.id === localActor?.id} character={character} movement={movement} stage={stage} />)}
<ArenaCamera actor={localActor} stage={stage} />
</GameGltfLoaderLifecycle>
</Canvas>
);
}
function TeamFrames({ actors, team }: { actors: readonly ArenaActorSnapshot[]; team: 0 | 1 }) {
return (
<div className={`arena-team-frames arena-team-frames--${team}`}>
{actors.filter((actor) => actor.team === team).map((actor) => (
<button key={actor.id} type="button" className={!actor.alive ? "is-dead" : ""} onClick={() => useArenaStore.getState().intent({ type: "target", targetActorId: actor.id })}>
<span><strong>{actor.name}</strong><small>{actor.isBot ? "AI" : "Player"} / {actor.role}</small></span>
<i><b style={{ width: `${actor.health / actor.maxHealth * 100}%` }} /></i>
<em>{actor.health}</em>
</button>
))}
</div>
);
}
export default function ArenaRuntime() {
const snapshot = useArenaStore((state) => state.snapshot);
const result = useArenaStore((state) => state.result);
const intent = useArenaStore((state) => state.intent);
const leave = useArenaStore((state) => state.leave);
const load = useArenaStore((state) => state.load);
const session = useShellStore((state) => state.session);
const character = useShellStore(selectedCharacter);
const stage = snapshot ? arenaStageById(snapshot.stageId) : null;
const localActor = snapshot?.actors.find((actor) => actor.id === (snapshot.controlledActorId ?? "practice-player")) ?? null;
const movementRef = useRef<[number, number]>([0, 0]);
useEffect(() => {
if (snapshot || !character) return;
if (!session?.accessToken) {
useArenaStore.setState({ error: "Offline Practice ended when the game reloaded. Start a new practice match.", queue: { status: "idle" } });
useShellStore.setState({ phase: "arenas" });
return;
}
void load(session, character).then(() => {
if (useArenaStore.getState().queue.status !== "matched") useShellStore.setState({ phase: "arenas" });
});
}, [character, load, session, snapshot]);
useEffect(() => {
const pressed = new Set<string>();
const movement = () => {
const next: [number, number] = [
Number(pressed.has("KeyD") || pressed.has("ArrowRight")) - Number(pressed.has("KeyA") || pressed.has("ArrowLeft")),
Number(pressed.has("KeyS") || pressed.has("ArrowDown")) - Number(pressed.has("KeyW") || pressed.has("ArrowUp")),
];
movementRef.current = next;
intent({ type: "movement", movement: { x: next[0], z: next[1] } });
};
const down = (event: KeyboardEvent) => {
if (["KeyW", "KeyA", "KeyS", "KeyD", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.code)) {
pressed.add(event.code);
movement();
}
if (event.code === "Digit1") intent({ type: "ability", abilityId: "attack" });
if (event.code === "Digit2") intent({ type: "ability", abilityId: "strike" });
if (event.code === "Digit3") intent({ type: "ability", abilityId: "interrupt" });
if (event.code === "Digit4") intent({ type: "ability", abilityId: "control" });
if (event.code === "Digit5") intent({ type: "ability", abilityId: "defensive" });
if (event.code === "Digit6") intent({ type: "ability", abilityId: "heal" });
};
const up = (event: KeyboardEvent) => {
if (pressed.delete(event.code)) movement();
};
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
};
}, [intent]);
const exit = async () => {
await leave(session);
useShellStore.setState({ phase: "arenas" });
};
if (!snapshot || !stage) return <div className="world-transition"><span>HM</span><strong>Entering arena</strong><small>Waiting for the authoritative match snapshot...</small></div>;
const seconds = snapshot.phaseEndsAt ? Math.max(0, Math.ceil((snapshot.phaseEndsAt - snapshot.serverTime) / 1000)) : null;
return (
<main className="arena-runtime">
<div className="arena-world"><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} /></div>
<header className="arena-match-header">
<span>{stage.name}</span><strong>{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}</strong><em>Dampening {Math.round(snapshot.dampening * 100)}%</em>
</header>
<TeamFrames actors={snapshot.actors} team={0} />
<TeamFrames actors={snapshot.actors} team={1} />
{snapshot.phase === "preparation" && <div className="arena-countdown"><small>Gates sealed</small><strong>{seconds}</strong><span>Prepare your opening</span></div>}
<nav className="arena-action-bar" aria-label="Arena abilities">
{["attack", "strike", "interrupt", "control", "defensive", "heal"].map((ability, index) => (
<button key={ability} type="button" onClick={() => intent({ type: "ability", abilityId: ability })}><b>{index + 1}</b><span>{ability}</span></button>
))}
</nav>
<button className="arena-exit" type="button" onClick={() => void exit()}>{result ? "Leave arena" : "Exit / bot takeover"}</button>
{result && (
<section className="arena-result-panel" role="dialog" aria-modal="true">
<p className="front-kicker">Match complete</p>
<h1>{result.draw ? "Draw" : result.winnerTeam === localActor?.team ? "Victory" : "Defeat"}</h1>
{result.ratingUpdates.map((update) => <p key={`${update.characterId}-${update.bracket}`}>{update.bracket}: {update.before} {update.delta >= 0 ? "+" : ""}{update.delta} → {update.after} / {update.title}</p>)}
{!result.ratingUpdates.length && <p>Practice result — rating unchanged.</p>}
<button type="button" className="front-button front-button--primary" onClick={() => void exit()}>Return to Arena Command</button>
</section>
)}
</main>
);
}
+76
View File
@@ -0,0 +1,76 @@
import type { PlayerSession } from "./types";
import { onlineApiOrigin, requestOnlineJson } from "./onlineAccountClient";
import type { ArenaLoadout, ArenaQueueRequest, ArenaQueueState, ArenaStageManifest } from "../game/arenaTypes";
export interface ArenaStateResponse {
characterId: string | null;
loadouts: ArenaLoadout[];
ratings: Record<"2v2" | "3v3", { bracket: "2v2" | "3v3"; rating: number; title: string; updatedAt: number | null } | null>;
queue: ArenaQueueState;
stages: ArenaStageManifest[];
}
function token(session: PlayerSession): string {
if (session.kind !== "account" || !session.accessToken) throw new Error("Rated arenas require an online account.");
return session.accessToken;
}
export function fetchArenaState(session: PlayerSession, characterId: string): Promise<ArenaStateResponse> {
return requestOnlineJson(`/api/arena/state?characterId=${encodeURIComponent(characterId)}`, {}, token(session));
}
export function putArenaLoadout(
session: PlayerSession,
characterId: string,
slot: number,
loadout: Omit<ArenaLoadout, "slot" | "characterId" | "level" | "version" | "selected" | "updatedAt"> & { selected?: boolean },
): Promise<ArenaLoadout> {
return requestOnlineJson(`/api/arena/loadouts/${slot}`, {
method: "PUT",
body: JSON.stringify({ ...loadout, characterId }),
}, token(session));
}
export function deleteArenaLoadout(session: PlayerSession, characterId: string, slot: number) {
return requestOnlineJson<{ ok: true }>(`/api/arena/loadouts/${slot}`, {
method: "DELETE",
body: JSON.stringify({ characterId }),
}, token(session));
}
export function enterArenaQueue(session: PlayerSession, request: ArenaQueueRequest): Promise<ArenaQueueState> {
return requestOnlineJson("/api/arena/queue", { method: "POST", body: JSON.stringify(request) }, token(session));
}
export function leaveArenaQueue(session: PlayerSession): Promise<ArenaQueueState> {
return requestOnlineJson("/api/arena/queue", { method: "DELETE" }, token(session));
}
export function acceptArenaReadyCheck(session: PlayerSession): Promise<ArenaQueueState> {
return requestOnlineJson("/api/arena/ready", { method: "POST" }, token(session));
}
interface ArenaSocketTicket { ticket: string; expiresAt: number; path: string }
export async function openArenaSocket(
session: PlayerSession,
onMessage: (message: unknown) => void,
onClose: () => void,
): Promise<WebSocket> {
const response = await requestOnlineJson<ArenaSocketTicket>("/api/arena/socket-ticket", { method: "POST" }, token(session));
const origin = onlineApiOrigin();
const base = origin || window.location.origin;
const url = new URL(response.path, base);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.searchParams.set("ticket", response.ticket);
const socket = new WebSocket(url);
socket.addEventListener("message", (event) => {
try { onMessage(JSON.parse(String(event.data))); } catch { /* Ignore malformed server messages. */ }
});
socket.addEventListener("close", onClose);
await new Promise<void>((resolve, reject) => {
socket.addEventListener("open", () => resolve(), { once: true });
socket.addEventListener("error", () => reject(new Error("Could not connect to the arena server.")), { once: true });
});
return socket;
}
+2 -2
View File
@@ -40,7 +40,7 @@ let pendingCharacters: CharacterProfile[] | null = null;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let saveQueue = Promise.resolve();
function apiOrigin(): string {
export function onlineApiOrigin(): string {
const configured = String(import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, "");
if (configured) return configured;
return Capacitor.isNativePlatform() ? NATIVE_API_ORIGIN : "";
@@ -53,7 +53,7 @@ export async function requestOnlineJson<T>(pathname: string, init: RequestInit =
if (token) headers.set("Authorization", `Bearer ${token}`);
let response: Response;
try {
response = await fetch(`${apiOrigin()}${pathname}`, { ...init, headers });
response = await fetch(`${onlineApiOrigin()}${pathname}`, { ...init, headers });
} catch {
throw new OnlineApiError("The online server is unavailable. Check your connection or play offline.", 0);
}
+10
View File
@@ -86,6 +86,7 @@ export interface ShellState {
openMainMenu: () => boolean;
openDungeons: () => boolean;
openManastorms: () => boolean;
openArenas: () => boolean;
openGmAdmin: () => boolean;
openClassHall: () => boolean;
changeSelectedSpecialization: (specializationId: string, resetTalents: boolean) => boolean;
@@ -333,6 +334,15 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ phase: "manastorms", activeCharacter: null, notice: "" });
return true;
},
openArenas: () => {
const { session, selectedCharacterId, characters } = get();
if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
return false;
}
set({ phase: "arenas", activeCharacter: null, notice: "" });
return true;
},
openGmAdmin: () => {
const { session, selectedCharacterId, characters } = get();
if (!isGameMasterSession(session)) {
+1 -1
View File
@@ -5,7 +5,7 @@ import type { EquipmentAssignments } from "../game/equipment";
import type { GameplaySettings } from "../game/combatStore";
import type { ManastormProgress } from "../game/manastormProgress";
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "gm-admin" | "game";
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "arenas" | "arena-game" | "gm-admin" | "game";
export type SessionKind = "account" | "offline";
export type AccountRole = "gm";
+68
View File
@@ -0,0 +1,68 @@
import type { CharacterProfile } from "../app/types";
import { ArenaSimulation } from "../../server/arena-simulation.mjs";
import type { ArenaAuthoritativeSnapshot, ArenaCombatIntent, ArenaResult, ArenaRole, ArenaStageManifest } from "./arenaTypes";
interface PracticeOptions {
character: CharacterProfile;
role: ArenaRole;
bracket: "2v2" | "3v3";
ruleset: "wow" | "coa" | "rom";
stage: ArenaStageManifest;
difficulty: number;
onSnapshot: (snapshot: ArenaAuthoritativeSnapshot) => void;
onResult: (result: ArenaResult) => void;
}
function teamRoles(teamSize: number, playerRole?: ArenaRole): ArenaRole[] {
const roles: ArenaRole[] = playerRole ? [playerRole] : [];
if (!roles.includes("healer")) roles.push("healer");
while (roles.length < teamSize) roles.push("damage");
return roles;
}
export function createArenaPractice(options: PracticeOptions) {
const id = `practice-${Date.now().toString(36)}`;
const teamSize = options.bracket === "2v2" ? 2 : 3;
const participants = [
...teamRoles(teamSize, options.role).map((role, index) => ({
actorId: index === 0 ? "practice-player" : `practice-ally-${index}`,
accountId: null,
characterId: index === 0 ? options.character.id : null,
name: index === 0 ? options.character.name : role === "healer" ? "Training Medic" : `Training Ally ${index}`,
team: 0 as const,
role,
isBot: index !== 0,
rating: options.difficulty,
})),
...teamRoles(teamSize).map((role, index) => ({
actorId: `practice-enemy-${index}`,
accountId: null,
characterId: null,
name: role === "healer" ? "Opponent Medic" : `Opponent ${index + 1}`,
team: 1 as const,
role,
isBot: true,
rating: options.difficulty,
})),
];
const simulation = new ArenaSimulation({
id,
stage: options.stage,
bracket: options.bracket,
mode: "practice",
ruleset: options.ruleset,
participants,
onSnapshot: (snapshot) => options.onSnapshot({ ...snapshot, controlledActorId: "practice-player" }),
onResult: (result) => options.onResult({ ...result, ratingUpdates: [] }),
});
simulation.start();
options.onSnapshot({ ...simulation.snapshot(), controlledActorId: "practice-player" });
return {
intent(intent: ArenaCombatIntent) {
return simulation.receiveIntent("practice-player", intent);
},
stop() {
simulation.stop();
},
};
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
arenaAiProfile,
arenaDampening,
arenaDrDuration,
arenaRatingBand,
calculateArenaRating,
clampArenaRating,
} from "./arenaRules";
describe("arena rating and difficulty", () => {
it("clamps ratings and applies the requested Elo bands", () => {
expect(clampArenaRating(-5)).toBe(0);
expect(clampArenaRating(2600)).toBe(2500);
expect(arenaRatingBand(0)).toMatchObject({ title: "Recruit", k: 64 });
expect(arenaRatingBand(2400)).toMatchObject({ title: "Gladiator", k: 20 });
expect(calculateArenaRating(0, 0, 1)).toMatchObject({ delta: 32, after: 32 });
expect(calculateArenaRating(0, 0, 0)).toMatchObject({ delta: 0, after: 0 });
expect(calculateArenaRating(1250, 1250, 0.5)).toMatchObject({ delta: 0, after: 1250 });
});
it("continuously improves bots without removing the error floor", () => {
const recruit = arenaAiProfile(0);
const rival = arenaAiProfile(1600);
const gladiator = arenaAiProfile(2500);
expect(rival.reactionMinSeconds).toBeLessThan(recruit.reactionMinSeconds);
expect(gladiator.healerInterruptChance).toBe(0.85);
expect(gladiator.minimumErrorChance).toBe(0.08);
});
it("starts dampening at ten percent and reaches zero healing at nine minutes", () => {
expect(arenaDampening(0)).toBeCloseTo(0.1);
expect(arenaDampening(30_000)).toBeCloseTo(0.15);
expect(arenaDampening(540_000)).toBe(1);
});
it("uses full, half, quarter, immune DR and resets after fifteen seconds", () => {
expect(arenaDrDuration(8_000, 0, 0).durationMs).toBe(8_000);
expect(arenaDrDuration(8_000, 1, 0).durationMs).toBe(4_000);
expect(arenaDrDuration(8_000, 2, 0).durationMs).toBe(2_000);
expect(arenaDrDuration(8_000, 3, 0).durationMs).toBe(0);
expect(arenaDrDuration(8_000, 3, 15_000).durationMs).toBe(8_000);
});
});
+80
View File
@@ -0,0 +1,80 @@
export type ArenaBracket = "2v2" | "3v3";
export type ArenaMode = "practice" | "rated-ai" | "ranked-pvp";
export type ArenaRuleset = "wow" | "coa" | "rom";
export type ArenaRole = "healer" | "damage";
export interface ArenaAiProfile {
readonly rating: number;
readonly title: string;
readonly reactionMinSeconds: number;
readonly reactionMaxSeconds: number;
readonly healerInterruptChance: number;
readonly coordinatedCcChance: number;
readonly correctDefensiveChance: number;
readonly minimumErrorChance: number;
}
export const ARENA_BRACKETS: Readonly<Record<ArenaBracket, { readonly size: 2 | 3; readonly healers: 1; readonly damage: 1 | 2 }>> = Object.freeze({
"2v2": Object.freeze({ size: 2, healers: 1, damage: 1 }),
"3v3": Object.freeze({ size: 3, healers: 1, damage: 2 }),
});
export const ARENA_RATING_BANDS = Object.freeze([
{ min: 0, max: 499, title: "Recruit", k: 64, reaction: [1.4, 2.4], healerInterrupt: 0, coordinatedCc: 0.05, defensive: 0.35, error: 0.35 },
{ min: 500, max: 999, title: "Combatant", k: 48, reaction: [1, 1.8], healerInterrupt: 0.2, coordinatedCc: 0.2, defensive: 0.5, error: 0.28 },
{ min: 1000, max: 1399, title: "Challenger", k: 40, reaction: [0.75, 1.3], healerInterrupt: 0.4, coordinatedCc: 0.4, defensive: 0.65, error: 0.2 },
{ min: 1400, max: 1799, title: "Rival", k: 32, reaction: [0.55, 1], healerInterrupt: 0.6, coordinatedCc: 0.6, defensive: 0.75, error: 0.15 },
{ min: 1800, max: 2099, title: "Duelist", k: 28, reaction: [0.4, 0.8], healerInterrupt: 0.72, coordinatedCc: 0.72, defensive: 0.82, error: 0.12 },
{ min: 2100, max: 2399, title: "Elite", k: 24, reaction: [0.3, 0.65], healerInterrupt: 0.8, coordinatedCc: 0.8, defensive: 0.88, error: 0.1 },
{ min: 2400, max: 2500, title: "Gladiator", k: 20, reaction: [0.25, 0.55], healerInterrupt: 0.85, coordinatedCc: 0.85, defensive: 0.9, error: 0.08 },
] as const);
export function clampArenaRating(value: number): number {
return Math.max(0, Math.min(2500, Math.round(Number(value) || 0)));
}
export function arenaRatingBand(rating: number): typeof ARENA_RATING_BANDS[number] {
const normalized = clampArenaRating(rating);
return ARENA_RATING_BANDS.find((band) => normalized >= band.min && normalized <= band.max) ?? ARENA_RATING_BANDS[0];
}
function lerp(left: number, right: number, amount: number): number {
return left + (right - left) * Math.max(0, Math.min(1, amount));
}
export function arenaAiProfile(rating: number): ArenaAiProfile {
const normalized = clampArenaRating(rating);
const index = ARENA_RATING_BANDS.findIndex((band) => normalized >= band.min && normalized <= band.max);
const current = ARENA_RATING_BANDS[Math.max(0, index)];
const next = ARENA_RATING_BANDS[Math.min(ARENA_RATING_BANDS.length - 1, Math.max(0, index) + 1)];
const progress = current === next ? 0 : (normalized - current.min) / Math.max(1, current.max - current.min + 1);
return {
rating: normalized,
title: current.title,
reactionMinSeconds: lerp(current.reaction[0], next.reaction[0], progress),
reactionMaxSeconds: lerp(current.reaction[1], next.reaction[1], progress),
healerInterruptChance: lerp(current.healerInterrupt, next.healerInterrupt, progress),
coordinatedCcChance: lerp(current.coordinatedCc, next.coordinatedCc, progress),
correctDefensiveChance: lerp(current.defensive, next.defensive, progress),
minimumErrorChance: lerp(current.error, next.error, progress),
};
}
export function calculateArenaRating(rating: number, opponentRating: number, score: 0 | 0.5 | 1) {
const before = clampArenaRating(rating);
if (score === 0.5) return { before, expected: 0.5, delta: 0, after: before };
const expected = 1 / (1 + 10 ** ((clampArenaRating(opponentRating) - before) / 400));
const rawDelta = Math.round(arenaRatingBand(before).k * (score - expected));
const after = clampArenaRating(before + rawDelta);
return { before, expected, delta: after - before, after };
}
export function arenaDampening(elapsedMs: number): number {
return Math.min(1, 0.1 + Math.floor(Math.max(0, elapsedMs) / 30_000) * 0.05);
}
export function arenaDrDuration(baseDurationMs: number, priorApplications: number, elapsedSinceEndMs = Infinity) {
const applications = elapsedSinceEndMs >= 15_000 ? 0 : Math.max(0, Math.trunc(priorApplications));
const multiplier = [1, 0.5, 0.25, 0][Math.min(3, applications)];
return { durationMs: Math.round(Math.max(0, baseDurationMs) * multiplier), applications: Math.min(3, applications + 1), multiplier };
}
+25
View File
@@ -0,0 +1,25 @@
import catalog from "../../shared/arena-stages.json";
import type { ArenaStageManifest } from "./arenaTypes";
function normalizeStage(stage: Omit<ArenaStageManifest, "assets">): ArenaStageManifest {
return {
...stage,
assets: [
{ id: "visual", url: `${stage.assetBaseUrl}/visual.glb` },
{ id: "collision", url: `${stage.assetBaseUrl}/collision.glb` },
{ id: "navigation", url: `${stage.assetBaseUrl}/navigation.glb` },
],
};
}
export const ARENA_STAGES = Object.freeze(
catalog.stages.map((stage) => normalizeStage(stage as unknown as Omit<ArenaStageManifest, "assets">)),
);
export const VALIDATED_ARENA_STAGES = Object.freeze(
ARENA_STAGES.filter((stage) => Object.values(stage.validation).slice(1, 6).every(Boolean)),
);
export function arenaStageById(stageId: string) {
return ARENA_STAGES.find((stage) => stage.id === stageId) ?? null;
}
+245
View File
@@ -0,0 +1,245 @@
import { create } from "zustand";
import {
acceptArenaReadyCheck,
deleteArenaLoadout as deleteRemoteLoadout,
enterArenaQueue,
fetchArenaState,
leaveArenaQueue,
openArenaSocket,
putArenaLoadout as putRemoteLoadout,
type ArenaStateResponse,
} from "../app/arenaClient";
import type { CharacterProfile, PlayerSession } from "../app/types";
import { useShellStore } from "../app/shellStore";
import { arenaAiProfile } from "./arenaRules";
import { ARENA_STAGES } from "./arenaStages";
import { createArenaPractice } from "./arenaPractice";
import type { ArenaAuthoritativeSnapshot, ArenaCombatIntent, ArenaLoadout, ArenaQueueState, ArenaResult } from "./arenaTypes";
import { romGearProfile, specializationById, specializationsForClass } from "./specializationCatalog";
type ArenaModeSelection = "offline-practice" | "rated-ai" | "ranked-pvp";
interface ArenaStoreState {
mode: ArenaModeSelection;
bracket: "2v2" | "3v3";
ruleset: "wow" | "coa" | "rom";
stageId: string;
botDifficulty: number;
selectedLoadoutSlot: number;
remote: ArenaStateResponse | null;
queue: ArenaQueueState;
snapshot: ArenaAuthoritativeSnapshot | null;
result: ArenaResult | null;
loading: boolean;
error: string;
socket: WebSocket | null;
practice: ReturnType<typeof createArenaPractice> | null;
sequence: number;
configure: (patch: Partial<Pick<ArenaStoreState, "mode" | "bracket" | "ruleset" | "stageId" | "botDifficulty" | "selectedLoadoutSlot">>) => void;
load: (session: PlayerSession | null, character: CharacterProfile) => Promise<void>;
saveLoadout: (session: PlayerSession, character: CharacterProfile, input: { slot: number; name: string; specializationId: string }) => Promise<boolean>;
deleteLoadout: (session: PlayerSession, characterId: string, slot: number) => Promise<boolean>;
queueMatch: (session: PlayerSession | null, character: CharacterProfile) => Promise<boolean>;
ready: (session: PlayerSession) => Promise<void>;
leave: (session: PlayerSession | null) => Promise<void>;
intent: (intent: Omit<ArenaCombatIntent, "sequence">) => void;
resetResult: () => void;
}
function messageFor(error: unknown) {
return error instanceof Error ? error.message : "The arena request could not be completed.";
}
function rulesetFor(character: CharacterProfile): "wow" | "coa" | "rom" {
return character.categoryId === "coa" || character.categoryId === "rom" ? character.categoryId : "wow";
}
function defaultSpec(character: CharacterProfile, preferredRole: "healer" | "damage" = "healer") {
if (character.categoryId === "rom") return romGearProfile(character.classId, preferredRole);
const selected = specializationById(character.specializationId);
if (selected && selected.role !== "tank") return selected;
return specializationsForClass(character.classId).find((spec) => spec.role === preferredRole)
?? specializationsForClass(character.classId).find((spec) => spec.role !== "tank")
?? null;
}
function handleSocketMessage(raw: unknown) {
const message = raw as { type?: string; snapshot?: ArenaAuthoritativeSnapshot; result?: ArenaResult; deadline?: number; matchId?: string; message?: string };
if (message.type === "snapshot" && message.snapshot) useArenaStore.setState({ snapshot: message.snapshot });
if (message.type === "ready-check") useArenaStore.setState((current) => ({ queue: { ...current.queue, status: "ready-check", readyDeadline: message.deadline, matchId: message.matchId } }));
if (message.type === "match-start" && message.snapshot) {
useArenaStore.setState({ snapshot: message.snapshot, queue: { status: "matched", matchId: message.matchId }, loading: false });
useShellStore.setState({ phase: "arena-game" });
}
if (message.type === "result" && message.result) useArenaStore.setState({ result: message.result, loading: false });
if (message.type === "queue-cancelled") useArenaStore.setState({ queue: { status: "queued" }, error: message.message ?? "Ready check cancelled." });
}
async function connectArenaSocket(session: PlayerSession) {
const existing = useArenaStore.getState().socket;
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) return existing;
const socket = await openArenaSocket(session, handleSocketMessage, () => {
const current = useArenaStore.getState();
const error = current.queue.status === "matched"
? "Arena connection closed; your character is now controlled by a bot."
: current.queue.status !== "idle" ? "Live arena updates disconnected. Reopen Arenas to reconnect." : current.error;
useArenaStore.setState({ socket: null, error });
});
useArenaStore.setState({ socket });
return socket;
}
export const useArenaStore = create<ArenaStoreState>((set, get) => ({
mode: "offline-practice",
bracket: "2v2",
ruleset: "wow",
stageId: "warnorken-arena",
botDifficulty: 1000,
selectedLoadoutSlot: 1,
remote: null,
queue: { status: "idle" },
snapshot: null,
result: null,
loading: false,
error: "",
socket: null,
practice: null,
sequence: 0,
configure: (patch) => set(patch),
load: async (session, character) => {
get().practice?.stop();
get().socket?.close();
const ruleset = rulesetFor(character);
set({ ruleset, loading: Boolean(session?.accessToken), error: "", snapshot: null, result: null, practice: null, socket: null });
if (!session?.accessToken) {
set({ remote: null, queue: { status: "idle" }, loading: false });
return;
}
try {
const remote = await fetchArenaState(session, character.id);
set({ remote, queue: remote.queue, loading: false });
await connectArenaSocket(session);
} catch (error) {
set({ loading: false, error: messageFor(error) });
}
},
saveLoadout: async (session, character, input) => {
const spec = character.categoryId === "rom"
? (["healer", "damage"] as const).map((role) => romGearProfile(character.classId, role)).find((entry) => entry.id === input.specializationId)
: specializationById(input.specializationId);
if (!spec || spec.classId !== character.classId || spec.role === "tank") {
set({ error: "Rated loadouts require a healer or damage specialization from this character's class." });
return false;
}
set({ loading: true, error: "" });
try {
await putRemoteLoadout(session, character.id, input.slot, {
name: input.name,
classId: character.classId,
specializationId: spec.id,
role: spec.role,
ruleset: rulesetFor(character),
talents: character.talentRanks,
actions: Object.fromEntries(Object.entries(character.actionBindings).filter((entry): entry is [string, string] => typeof entry[1] === "string")),
});
const remote = await fetchArenaState(session, character.id);
set({ remote, selectedLoadoutSlot: input.slot, loading: false });
return true;
} catch (error) {
set({ loading: false, error: messageFor(error) });
return false;
}
},
deleteLoadout: async (session, characterId, slot) => {
set({ loading: true, error: "" });
try {
await deleteRemoteLoadout(session, characterId, slot);
const remote = await fetchArenaState(session, characterId);
set({ remote, selectedLoadoutSlot: remote.loadouts[0]?.slot ?? 1, loading: false });
return true;
} catch (error) {
set({ loading: false, error: messageFor(error) });
return false;
}
},
queueMatch: async (session, character) => {
const state = get();
const validatedStages = ARENA_STAGES.filter((entry) => entry.validation.status === "validated");
const stage = state.stageId === "random"
? validatedStages[Math.floor(Math.random() * validatedStages.length)]
: ARENA_STAGES.find((entry) => entry.id === state.stageId) ?? ARENA_STAGES[0];
const fallback = defaultSpec(character);
if (!stage || !fallback) {
set({ error: "This character has no arena-compatible specialization." });
return false;
}
if (state.mode === "offline-practice") {
const practice = createArenaPractice({
character, role: fallback.role as "healer" | "damage", bracket: state.bracket,
ruleset: state.ruleset, stage, difficulty: state.botDifficulty,
onSnapshot: (snapshot) => set({ snapshot }),
onResult: (result) => set({ result }),
});
set({ practice, error: "", result: null, queue: { status: "matched", matchId: practice ? `practice-${Date.now()}` : undefined } });
useShellStore.setState({ phase: "arena-game" });
return true;
}
if (!session?.accessToken) {
set({ error: "Rated arenas require an online account. Offline Practice remains available." });
return false;
}
let loadout = state.remote?.loadouts.find((entry) => entry.slot === state.selectedLoadoutSlot);
if (!loadout) {
const saved = await get().saveLoadout(session, character, { slot: state.selectedLoadoutSlot, name: `${fallback.displayName} PvP`, specializationId: fallback.id });
if (!saved) return false;
loadout = get().remote?.loadouts.find((entry) => entry.slot === state.selectedLoadoutSlot);
}
if (!loadout) return false;
set({ loading: true, error: "", result: null });
try {
const socket = await connectArenaSocket(session);
const queue = await enterArenaQueue(session, {
characterId: character.id, loadoutSlot: loadout.slot, bracket: state.bracket,
mode: state.mode, ruleset: state.ruleset,
botDifficulty: state.botDifficulty,
});
set({ socket, queue, loading: false });
return true;
} catch (error) {
set({ loading: false, error: messageFor(error) });
return false;
}
},
ready: async (session) => {
try { set({ queue: await acceptArenaReadyCheck(session), error: "" }); }
catch (error) { set({ error: messageFor(error) }); }
},
leave: async (session) => {
get().practice?.stop();
get().socket?.close();
if (session?.accessToken && get().queue.status !== "idle") await leaveArenaQueue(session).catch(() => undefined);
set({ queue: { status: "idle" }, snapshot: null, result: null, practice: null, socket: null, error: "" });
},
intent: (intent) => {
const sequence = get().sequence + 1;
const message = { ...intent, sequence } as ArenaCombatIntent;
set({ sequence });
get().practice?.intent(message);
const socket = get().socket;
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
},
resetResult: () => set({ result: null }),
}));
export function currentArenaAiTitle() {
return arenaAiProfile(useArenaStore.getState().botDifficulty).title;
}
+164
View File
@@ -0,0 +1,164 @@
import type { ArenaBracket, ArenaMode, ArenaRuleset } from "./arenaRules";
export type Vec3Tuple = readonly [number, number, number];
export type ArenaRole = "healer" | "damage";
export type ArenaPhase = "queue" | "ready-check" | "preparation" | "active" | "finished" | "void";
export interface ArenaStageAsset {
id: "visual" | "collision" | "navigation";
url: string;
checksum?: string;
byteLength?: number;
}
export interface ArenaStageValidation {
status: "source-ready" | "repair-required" | "validated";
traversal: boolean;
collision: boolean;
spawnSafety: boolean;
lineOfSight: boolean;
performance: boolean;
notes: string[];
}
export interface ArenaLineOfSightObject {
id: string;
shape: "box" | "cylinder";
position: Vec3Tuple;
size: Vec3Tuple;
dynamicObjectId?: string;
}
export interface ArenaDynamicObjectDefinition {
id: string;
kind: "pipe-knockback" | "waterfall" | "starting-elevators" | "fire-cycle" | "moving-pillars";
initialDelayMs?: number;
initialDelayRangeMs?: readonly [number, number];
runtimeStartMs?: number;
warningMs: number;
activeMs: number;
cycleMs: number;
}
export interface ArenaStageManifest {
id: string;
name: string;
sourceMap: string;
mapId: number;
assetBaseUrl: string;
assets: readonly ArenaStageAsset[];
bounds: { min: Vec3Tuple; max: Vec3Tuple };
teamSpawns: readonly [Vec3Tuple, Vec3Tuple];
preparationGates: readonly [Vec3Tuple, Vec3Tuple];
spectatorPositions: readonly [Vec3Tuple, Vec3Tuple];
shadowSight: readonly [Vec3Tuple, Vec3Tuple];
lineOfSight: readonly ArenaLineOfSightObject[];
dynamicObjects: readonly ArenaDynamicObjectDefinition[];
validation: ArenaStageValidation;
}
export interface ArenaLoadout {
slot: number;
name: string;
characterId: string;
classId: string;
specializationId: string;
role: ArenaRole;
ruleset: ArenaRuleset;
level: 80;
talents: Record<string, number>;
actions: Record<string, string>;
version: number;
selected: boolean;
updatedAt: number;
}
export interface ArenaQueueRequest {
characterId: string;
loadoutSlot: number;
bracket: ArenaBracket;
mode: ArenaMode;
ruleset: ArenaRuleset;
botDifficulty?: number;
groupMembers?: readonly { accountId: string; characterId: string; loadoutSlot: number }[];
}
export interface ArenaQueueState {
status: "idle" | "queued" | "ready-check" | "matched";
ticketId?: string;
joinedAt?: number;
searchRange?: number;
readyDeadline?: number;
matchId?: string;
lockedLoadout?: ArenaLoadout;
}
export interface ArenaCombatIntent {
sequence: number;
type: "movement" | "facing" | "target" | "ability";
movement?: { x: number; z: number };
facing?: number;
targetActorId?: string;
abilityId?: string;
clientTick?: number;
}
export interface ArenaActorSnapshot {
id: string;
name: string;
team: 0 | 1;
role: ArenaRole;
isBot: boolean;
disconnected: boolean;
alive: boolean;
position: Vec3Tuple;
facing: number;
health: number;
maxHealth: number;
resource: number;
maxResource: number;
targetActorId: string | null;
cooldowns: Record<string, number>;
effects: readonly { id: string; remainingMs: number; stacks?: number }[];
}
export interface ArenaDynamicObjectSnapshot {
id: string;
state: "closed" | "warning" | "active" | "open" | "raised" | "moving" | "lowered";
progress: number;
collisionEnabled: boolean;
}
export interface ArenaAuthoritativeSnapshot {
matchId: string;
tick: number;
serverTime: number;
phase: ArenaPhase;
phaseEndsAt: number | null;
stageId: string;
bracket: ArenaBracket;
mode: ArenaMode;
ruleset: ArenaRuleset;
dampening: number;
actors: readonly ArenaActorSnapshot[];
dynamicObjects: readonly ArenaDynamicObjectSnapshot[];
acknowledgedSequence?: number;
controlledActorId?: string;
}
export interface ArenaRatingUpdate {
characterId: string;
bracket: ArenaBracket;
before: number;
delta: number;
after: number;
title: string;
}
export interface ArenaResult {
matchId: string;
winnerTeam: 0 | 1 | null;
draw: boolean;
reason: "elimination" | "simultaneous-elimination" | "void";
ratingUpdates: readonly ArenaRatingUpdate[];
}
+352
View File
@@ -3094,3 +3094,355 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
@media (max-height: 560px) and (min-width: 601px) {
.gm-review-card { top: auto; bottom: max(62px, calc(env(safe-area-inset-bottom) + 50px)); }
}
/* Rated arenas */
.arena-select-screen {
display: grid;
grid-template: 4.4rem 1fr 2.6rem / 17.5rem 1fr;
min-height: 100%;
color: #eef5e8;
}
.arena-select-header {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: .65rem 1rem;
border-bottom: 1px solid rgba(205, 223, 186, .18);
background: rgba(6, 15, 12, .86);
}
.arena-select-header > div,
.arena-select-header > p {
display: flex;
align-items: center;
gap: .7rem;
margin: 0;
}
.arena-select-header p {
flex-direction: column;
align-items: flex-end;
gap: .1rem;
}
.arena-select-sidebar {
padding: .9rem;
overflow: auto;
border-right: 1px solid rgba(205, 223, 186, .16);
background: rgba(8, 20, 16, .88);
}
.arena-segmented {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: .25rem;
margin: .35rem 0 1rem;
}
.arena-segmented:first-of-type {
grid-template-columns: repeat(2, 1fr);
}
.arena-segmented button,
.arena-mode-list button,
.arena-stage-grid button,
.arena-loadout-editor button,
.arena-loadout-editor input,
.arena-loadout-editor select {
border: 1px solid rgba(198, 219, 177, .2);
color: inherit;
background: rgba(20, 38, 30, .9);
}
.arena-segmented button {
padding: .55rem .3rem;
font-weight: 800;
text-transform: uppercase;
}
.arena-segmented button.is-active,
.arena-mode-list button.is-active,
.arena-stage-grid button.is-active {
border-color: #d8b962;
background: linear-gradient(135deg, rgba(127, 102, 38, .65), rgba(29, 57, 42, .9));
box-shadow: inset 0 0 0 1px rgba(255, 232, 154, .16);
}
.arena-mode-list {
display: grid;
gap: .35rem;
margin: .35rem 0 1rem;
}
.arena-mode-list button {
display: grid;
gap: .18rem;
padding: .6rem;
text-align: left;
}
.arena-mode-list small,
.arena-stage-grid small {
color: #aeb9ab;
line-height: 1.25;
}
.arena-select-main {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(15rem, .65fr);
gap: 1rem;
padding: 1rem;
overflow: auto;
background: radial-gradient(circle at 25% 20%, rgba(54, 88, 59, .28), transparent 45%);
}
.arena-stage-browser,
.arena-configuration {
min-width: 0;
padding: .9rem;
border: 1px solid rgba(205, 223, 186, .16);
background: rgba(8, 18, 14, .76);
}
.arena-stage-browser > header,
.arena-configuration > header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: .6rem;
}
.arena-stage-browser h1,
.arena-configuration h2 {
margin: .12rem 0 .65rem;
font-family: Georgia, serif;
}
.arena-status {
padding: .28rem .45rem;
border: 1px solid currentColor;
color: #d8b962;
font-size: .65rem;
font-weight: 800;
letter-spacing: .08em;
text-transform: uppercase;
}
.arena-status--validated { color: #88d49a; }
.arena-status--repair-required { color: #dc8d73; }
.arena-stage-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .35rem;
}
.arena-stage-grid button {
display: grid;
grid-template-columns: 2rem 1fr;
align-items: center;
gap: .4rem;
padding: .55rem;
text-align: left;
}
.arena-stage-grid button > b {
color: #d8b962;
font: 700 1rem Georgia, serif;
}
.arena-stage-grid button > span {
display: grid;
min-width: 0;
}
.arena-stage-note {
margin: .7rem 0 0;
color: #c7cec2;
font-size: .74rem;
}
.arena-configuration {
display: flex;
flex-direction: column;
gap: .7rem;
}
.arena-configuration input[type="range"] { width: 100%; accent-color: #d8b962; }
.arena-configuration dl {
display: grid;
gap: .3rem;
margin: 0;
}
.arena-configuration dl div {
display: flex;
justify-content: space-between;
border-bottom: 1px solid rgba(205, 223, 186, .12);
}
.arena-loadout-editor,
.arena-loadout-editor label {
display: grid;
gap: .3rem;
}
.arena-loadout-editor {
gap: .55rem;
}
.arena-loadout-editor label {
color: #aeb9ab;
font-size: .7rem;
font-weight: 700;
text-transform: uppercase;
}
.arena-loadout-editor input,
.arena-loadout-editor select,
.arena-loadout-editor button {
padding: .48rem;
}
.arena-queue-button {
display: grid;
gap: .15rem;
margin-top: auto;
padding: .8rem;
}
.arena-runtime {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
color: #f2f5ee;
background: #07100d;
}
.arena-world,
.arena-world > div { position: absolute; inset: 0; }
.arena-match-header {
position: absolute;
top: .7rem;
left: 50%;
display: flex;
gap: .7rem;
align-items: center;
padding: .45rem .7rem;
border: 1px solid rgba(225, 236, 214, .22);
background: rgba(5, 12, 9, .82);
transform: translateX(-50%);
backdrop-filter: blur(8px);
}
.arena-match-header span,
.arena-match-header em { color: #bac5b7; font-size: .72rem; font-style: normal; }
.arena-team-frames {
position: absolute;
top: 4.2rem;
display: grid;
width: min(16rem, 27vw);
gap: .3rem;
}
.arena-team-frames--0 { left: .7rem; }
.arena-team-frames--1 { right: .7rem; }
.arena-team-frames button {
display: grid;
grid-template: auto .28rem / 1fr auto;
gap: .22rem .6rem;
padding: .42rem;
border: 1px solid rgba(215, 230, 204, .2);
color: inherit;
text-align: left;
background: rgba(5, 12, 9, .82);
}
.arena-team-frames--1 button { text-align: right; }
.arena-team-frames button.is-dead { opacity: .38; filter: grayscale(1); }
.arena-team-frames button > span { display: grid; }
.arena-team-frames button small { color: #acb8aa; }
.arena-team-frames button i { grid-column: 1 / -1; overflow: hidden; background: #241b1b; }
.arena-team-frames button i b { display: block; height: 100%; background: #54a55d; transition: width .1s linear; }
.arena-team-frames button em { font-style: normal; }
.arena-countdown {
position: absolute;
inset: 50% auto auto 50%;
display: grid;
place-items: center;
min-width: 13rem;
padding: 1rem;
border: 1px solid rgba(220, 190, 99, .5);
background: rgba(6, 13, 10, .88);
transform: translate(-50%, -50%);
}
.arena-countdown strong { color: #e1c46f; font: 700 3rem Georgia, serif; }
.arena-action-bar {
position: absolute;
bottom: .8rem;
left: 50%;
display: flex;
gap: .3rem;
transform: translateX(-50%);
}
.arena-action-bar button {
display: grid;
place-items: center;
width: 4.2rem;
min-height: 3.6rem;
border: 1px solid rgba(221, 232, 210, .25);
color: inherit;
background: rgba(5, 12, 9, .88);
}
.arena-action-bar button b { color: #dfc16b; }
.arena-action-bar button span { font-size: .65rem; text-transform: capitalize; }
.arena-exit {
position: absolute;
right: .7rem;
bottom: .8rem;
padding: .5rem;
border: 1px solid rgba(221, 232, 210, .2);
color: #d2d9cd;
background: rgba(5, 12, 9, .8);
}
.arena-result-panel {
position: absolute;
inset: 50% auto auto 50%;
width: min(27rem, 85vw);
padding: 1.25rem;
border: 1px solid #c4a95d;
text-align: center;
background: rgba(7, 15, 11, .96);
transform: translate(-50%, -50%);
box-shadow: 0 1rem 5rem rgba(0, 0, 0, .55);
}
.arena-result-panel h1 { margin: .2rem 0 .7rem; color: #e0c26c; font: 700 2.5rem Georgia, serif; }
@media (max-width: 760px) {
.arena-select-screen { grid-template: auto auto 1fr auto / 1fr; }
.arena-select-header { grid-column: 1; }
.arena-select-sidebar { border-right: 0; border-bottom: 1px solid rgba(205, 223, 186, .16); }
.arena-select-main { grid-template-columns: 1fr; }
.arena-team-frames { width: 42vw; }
.arena-match-header { left: .55rem; gap: .35rem; transform: none; }
.arena-match-header span { display: none; }
.arena-action-bar { bottom: .55rem; gap: .2rem; }
.arena-action-bar button { width: 3rem; min-height: 3.25rem; }
.arena-exit { inset: .55rem .55rem auto auto; max-width: 5.4rem; font-size: .65rem; }
}
+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}