import { create } from "zustand"; import { requestOnlineJson } from "./onlineAccountClient"; import { useShellStore } from "./shellStore"; import type { CharacterProfile, PlayerSession } from "./types"; import { dungeonById, dungeonsByCategory, randomizedBiteSizedDungeons } from "../game/dungeonCatalog"; import { dungeonContentPackId } from "../game/dungeonContentSource"; import { dungeonContentIsUsable, installContentPack } from "../content/contentManager"; import { ensureRandomizedBiteSizedRunContent } from "./randomizedBiteSizedContent"; import { createRaceBot, raceBotProgress, type RaceBot, type RaceBotSource, type RaceBotTier } from "./dungeonRaceBot"; import type { PartyRole } from "../game/partyRoles"; import { finishRaceCombatLog, startRaceCombatLog, stopRaceCombatLog, type RaceCombatLog } from "../game/dungeonRaceCombatLog"; import { rollDungeonRaceAffixes, validDungeonRaceAffixes, type DungeonRaceAffixPair } from "../game/dungeonRaceAffixes"; import { cleanupDungeonRaceAffixes } from "../game/dungeonRaceAffixLifecycle"; export type RacePool = "authored" | "randomized"; export type RaceStatus = "idle" | "queued" | "preparing" | "countdown" | "active" | "settled" | "void"; const RACE_STATUS_ORDER: Record = { idle: -1, queued: 0, preparing: 1, countdown: 2, active: 3, settled: 4, void: 5, }; export interface RaceHumanOpponent { readonly type: "human"; readonly name: string; readonly source: "wow" | "rom"; readonly ready: boolean; readonly finished: boolean } export type RaceOpponent = RaceHumanOpponent | RaceBot; export interface RaceServerState { readonly status: RaceStatus; readonly serverTime: number; readonly joinedAt?: number; readonly botAt?: number; readonly matchId?: string; readonly seed?: string; readonly dungeonId?: string | null; readonly difficultyId?: string; readonly affixIds?: readonly string[]; readonly startAt?: number | null; readonly prepareDeadline?: number; readonly ready?: boolean; readonly opponent?: RaceOpponent | null; readonly opponentProgress?: number | null; readonly result?: "win" | "loss" | null; readonly clearDurationMs?: number | null; } interface RaceState { mode: "online" | "practice" | null; status: RaceStatus; matchId: string | null; seed: string | null; dungeonId: string | null; difficultyId: string | null; affixIds: DungeonRaceAffixPair | null; startAt: number | null; serverOffset: number; opponent: RaceOpponent | null; opponentProgress: number | null; result: "win" | "loss" | null; clearDurationMs: number | null; ready: boolean; inGame: boolean; preparingContent: boolean; toastAt: number | null; error: string; queueAt: number | null; botAt: number | null; startOnline: (session: PlayerSession, character: CharacterProfile, role: PartyRole, pool: RacePool, dungeonId?: string) => Promise; startPractice: (character: CharacterProfile, role: PartyRole, dungeonId: string, tier: RaceBotTier, source: RaceBotSource) => Promise; sync: () => Promise; readyUp: () => Promise; reportClear: (bossIds: readonly string[]) => Promise; tick: () => void; leave: () => Promise; reset: () => void; } let sessionToken: string | null = null; let selectedRole: PartyRole = "healer"; let pollTimer: ReturnType | null = null; let preparingSeed: string | null = null; let readyPending = false; let finishPromise: Promise | null = null; let queueRequest: Promise | null = null; let pendingLeave: Promise | null = null; let pendingCleanupToken: string | null = null; let raceRevision = 0; let pendingCombatLog: RaceCombatLog | null = null; function stopPolling() { if (pollTimer) clearInterval(pollTimer); pollTimer = null; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "The race could not continue."; } function choosePracticeDungeon(pool: RacePool): string | null { const entries = pool === "randomized" ? randomizedBiteSizedDungeons() : dungeonsByCategory("bite-sized"); return entries.filter((entry) => entry.available)[0]?.id ?? null; } async function prepareContent(dungeonId: string, seed: string): Promise { if (dungeonId.startsWith("randomized-bite-sized-")) await ensureRandomizedBiteSizedRunContent(seed, undefined, dungeonId); else if (!dungeonContentIsUsable(dungeonId)) await installContentPack(dungeonContentPackId(dungeonId)); if (!dungeonContentIsUsable(dungeonId)) throw new Error("The dungeon content could not be verified."); } export const useDungeonRaceStore = create((set, get) => { async function enterPreparedRun(dungeonId: string, seed: string, difficultyId?: string) { if (get().inGame || preparingSeed === seed) return; const revision = raceRevision; preparingSeed = seed; set({ preparingContent: true, error: "" }); try { await prepareContent(dungeonId, seed); if (raceRevision !== revision || get().seed !== seed || get().status === "void") return; if (!validDungeonRaceAffixes(get().affixIds)) { throw new Error("This race uses mechanics your client does not support. Update the game to race."); } const { installDungeonShellRuntime } = await import("./dungeonShellRuntime"); if (raceRevision !== revision || get().seed !== seed) return; installDungeonShellRuntime(); const entered = useShellStore.getState().enterDungeon(dungeonId, selectedRole, { freshRun: true, runId: seed, difficultyId, bossDifficulty: 1, raceStartBlocked: true, }); if (!entered) throw new Error(useShellStore.getState().notice || "Could not enter the race dungeon."); startRaceCombatLog(seed, dungeonId); set({ inGame: true, preparingContent: false }); } catch (error) { stopRaceCombatLog(); if (raceRevision === revision) { set({ error: errorMessage(error), preparingContent: false }); if (get().mode === "online") await get().leave(); } } finally { if (preparingSeed === seed) preparingSeed = null; } } function applyServerState(response: RaceServerState) { const previous = get(); if (response.status === "idle" && previous.inGame) { set({ status: "void", error: "The race server restarted. You can finish this dungeon for its rewards, but the race is unranked." }); stopPolling(); return; } if (response.matchId && response.matchId === previous.matchId && RACE_STATUS_ORDER[response.status] < RACE_STATUS_ORDER[previous.status]) return; const result = response.result ?? null; const offset = response.serverTime - Date.now(); if (response.matchId && !validDungeonRaceAffixes(response.affixIds)) { const message = "This race uses mechanics your client does not support. Update the game to race."; void get().leave().finally(() => { if (get().mode === null) set({ error: message }); }); return; } set({ status: response.status, matchId: response.matchId ?? null, seed: response.seed ?? null, dungeonId: response.dungeonId ?? null, difficultyId: response.difficultyId ?? null, affixIds: validDungeonRaceAffixes(response.affixIds) ? response.affixIds : null, startAt: response.startAt ?? null, serverOffset: offset, opponent: response.opponent ?? null, opponentProgress: response.opponentProgress ?? null, result, clearDurationMs: response.clearDurationMs ?? null, ready: response.ready ?? false, queueAt: response.joinedAt ?? null, botAt: response.botAt ?? null, toastAt: result && result !== previous.result ? Date.now() : previous.toastAt }); if (response.matchId && response.seed && response.dungeonId && response.status !== "void") void enterPreparedRun(response.dungeonId, response.seed, response.difficultyId); if (["settled", "void", "idle"].includes(response.status)) stopPolling(); } return { mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, affixIds: null, startAt: null, serverOffset: 0, opponent: null, opponentProgress: null, result: null, clearDurationMs: null, ready: false, inGame: false, preparingContent: false, toastAt: null, error: "", queueAt: null, botAt: null, startOnline: async (session, character, role, pool, dungeonId) => { if (!session.accessToken) throw new Error("Sign in online to enter a ranked race."); get().reset(); const revision = raceRevision; const token = session.accessToken; selectedRole = role; sessionToken = token; set({ mode: "online", status: "queued" }); let request: Promise | null = null; try { if (pendingLeave) try { await pendingLeave; } catch { /* Retry cleanup before queuing. */ } if (raceRevision !== revision) return; if (pendingCleanupToken) { const cleanupToken = pendingCleanupToken; await requestOnlineJson("/api/dungeon-races/queue", { method: "DELETE" }, cleanupToken); if (pendingCleanupToken === cleanupToken) pendingCleanupToken = null; } if (raceRevision !== revision) return; request = requestOnlineJson("/api/dungeon-races/queue", { method: "POST", body: JSON.stringify({ characterId: character.id, role, pool, ...(dungeonId ? { dungeonId } : {}) }), }, token); queueRequest = request; const response = await request; if (queueRequest === request) queueRequest = null; if (raceRevision !== revision) return; applyServerState(response); if (!["settled", "void", "idle"].includes(response.status)) pollTimer = setInterval(() => { void get().sync(); }, 750); } catch (error) { if (queueRequest === request) queueRequest = null; if (raceRevision === revision) set({ status: "idle", error: errorMessage(error) }); } }, startPractice: async (character, role, dungeonId, tier, source) => { get().reset(); if (!["wow", "rom"].includes(character.categoryId ?? "wow")) { set({ error: "Choose a WoW or RoM character for practice." }); return; } selectedRole = role; const resolvedDungeon = dungeonId || choosePracticeDungeon("authored"); if (!resolvedDungeon || !(resolvedDungeon.startsWith("bite-sized-") || resolvedDungeon.startsWith("randomized-bite-sized-")) || !dungeonById(resolvedDungeon)?.available) { set({ error: "No Bite-Sized dungeon is available for practice." }); return; } const seed = `practice-${globalThis.crypto.randomUUID()}`; const affixIds = rollDungeonRaceAffixes(seed); set({ mode: "practice", status: "preparing", matchId: seed, seed, dungeonId: resolvedDungeon, difficultyId: null, affixIds, opponent: createRaceBot(seed, tier, source, affixIds) }); await enterPreparedRun(resolvedDungeon, seed); }, sync: async () => { if (get().mode !== "online" || !sessionToken) return; const revision = raceRevision; const token = sessionToken; try { const response = await requestOnlineJson("/api/dungeon-races/state", {}, token); if (raceRevision === revision) applyServerState(response); } catch (error) { if (raceRevision === revision) set({ error: errorMessage(error) }); } }, readyUp: async () => { const state = get(); if (!state.inGame || state.ready || readyPending || !state.matchId) return; if (state.mode === "practice") { set({ ready: true, status: "countdown", startAt: Date.now() + 3_000 }); return; } if (!sessionToken) return; const revision = raceRevision; const token = sessionToken; readyPending = true; try { const response = await requestOnlineJson( `/api/dungeon-races/${encodeURIComponent(state.matchId)}/ready`, { method: "POST" }, token); if (raceRevision === revision) applyServerState(response); } catch (error) { if (raceRevision === revision) set({ error: errorMessage(error) }); } finally { if (raceRevision === revision) readyPending = false; } }, reportClear: (bossIds) => { const state = get(); if (finishPromise) return finishPromise; if (!state.matchId || !state.startAt || ["void", "idle"].includes(state.status) || state.clearDurationMs != null) return Promise.resolve(); pendingCombatLog ??= finishRaceCombatLog(state.seed ?? state.matchId, state.startAt - state.serverOffset, state.mode === "practice"); if (state.mode === "practice") { const now = Date.now(); const result = state.result ?? (now < state.startAt + (state.opponent as RaceBot).finishMs ? "win" : "loss"); set({ status: "settled", result, toastAt: state.result ? state.toastAt : now, clearDurationMs: Math.max(1, now - state.startAt) }); return Promise.resolve(); } if (!sessionToken) return Promise.resolve(); const matchId = state.matchId; const report = requestOnlineJson( `/api/dungeon-races/${encodeURIComponent(state.matchId)}/finish`, { method: "POST", body: JSON.stringify({ runId: state.seed, bossIds, combatLog: pendingCombatLog }), }, sessionToken) .then((response) => { if (get().matchId === matchId) { pendingCombatLog = null; applyServerState(response); } }) .catch((error: unknown) => { if (get().matchId === matchId) set({ error: errorMessage(error) }); }) .finally(() => { if (finishPromise === report) finishPromise = null; }); finishPromise = report; return report; }, tick: () => { const state = get(); if (state.mode !== "practice" || !state.startAt || state.result || state.opponent?.type !== "bot") return; const now = Date.now(); if (now >= state.startAt + state.opponent.finishMs) set({ status: "settled", result: "loss", toastAt: now, opponentProgress: 100 }); else if (now >= state.startAt) set({ status: "active", opponentProgress: raceBotProgress(now - state.startAt, state.opponent) }); }, leave: async () => { const departing = get(); const token = departing.mode === "online" ? sessionToken : null; const partialLog = departing.seed && departing.startAt && departing.clearDurationMs === null ? finishRaceCombatLog(departing.seed, departing.startAt - departing.serverOffset, departing.mode === "practice", false) : null; const request = queueRequest; get().reset(); const revision = raceRevision; if (!token) return; pendingCleanupToken = token; const closing = (async () => { try { await request; } catch { /* A failed queue request needs no server cleanup. */ } if (partialLog && departing.matchId) { try { await requestOnlineJson(`/api/dungeon-races/${encodeURIComponent(departing.matchId)}/combat-log`, { method: "POST", body: JSON.stringify({ combatLog: partialLog }) }, token); } catch (error) { if (raceRevision === revision) set({ error: errorMessage(error) }); } } await requestOnlineJson("/api/dungeon-races/queue", { method: "DELETE" }, token); if (pendingCleanupToken === token) pendingCleanupToken = null; })(); pendingLeave = closing; try { await closing; } catch (error) { if (raceRevision === revision) set({ error: errorMessage(error) }); } finally { if (pendingLeave === closing) pendingLeave = null; } }, reset: () => { raceRevision += 1; cleanupDungeonRaceAffixes(); stopPolling(); sessionToken = null; preparingSeed = null; readyPending = false; finishPromise = null; stopRaceCombatLog(); pendingCombatLog = null; set({ mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, affixIds: null, startAt: null, serverOffset: 0, opponent: null, opponentProgress: null, result: null, clearDurationMs: null, ready: false, inGame: false, preparingContent: false, toastAt: null, error: "", queueAt: null, botAt: null }); }, }; });