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 | null; sequence: number; configure: (patch: Partial>) => void; load: (session: PlayerSession | null, character: CharacterProfile) => Promise; saveLoadout: (session: PlayerSession, character: CharacterProfile, input: { slot: number; name: string; specializationId: string }) => Promise; deleteLoadout: (session: PlayerSession, characterId: string, slot: number) => Promise; queueMatch: (session: PlayerSession | null, character: CharacterProfile) => Promise; ready: (session: PlayerSession) => Promise; startPractice: () => void; leave: (session: PlayerSession | null) => Promise; intent: (intent: Omit) => 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((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; }