Files
healer-man/src/game/arenaStore.ts
T
2026-09-05 21:27:30 -04:00

249 lines
11 KiB
TypeScript

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>;
startPractice: () => 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) }); }
},
startPractice: () => get().practice?.start(),
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;
}