249 lines
11 KiB
TypeScript
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;
|
|
}
|