Release Healer Man 0.1.16
This commit is contained in:
1 parent
f49e54f35f
commit
b57d2b2839
89 files changed
+6055
-17
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user