315 lines
16 KiB
TypeScript
315 lines
16 KiB
TypeScript
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<RaceStatus, number> = {
|
|
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<void>;
|
|
startPractice: (character: CharacterProfile, role: PartyRole, dungeonId: string,
|
|
tier: RaceBotTier, source: RaceBotSource) => Promise<void>;
|
|
sync: () => Promise<void>;
|
|
readyUp: () => Promise<void>;
|
|
reportClear: (bossIds: readonly string[]) => Promise<void>;
|
|
tick: () => void;
|
|
leave: () => Promise<void>;
|
|
reset: () => void;
|
|
}
|
|
|
|
let sessionToken: string | null = null;
|
|
let selectedRole: PartyRole = "healer";
|
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
let preparingSeed: string | null = null;
|
|
let readyPending = false;
|
|
let finishPromise: Promise<void> | null = null;
|
|
let queueRequest: Promise<RaceServerState> | null = null;
|
|
let pendingLeave: Promise<void> | 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<void> {
|
|
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<RaceState>((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<RaceServerState> | 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<RaceServerState>("/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<RaceServerState>("/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<RaceServerState>(
|
|
`/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<RaceServerState>(
|
|
`/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 });
|
|
},
|
|
};
|
|
});
|