Update Healer Man server
This commit is contained in:
1 parent
bd073fb00f
commit
83eac6d476
15 files changed
+438
-272
No files matched your search
@@ -0,0 +1,83 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDungeonRaceStore, type RaceServerState } from "./dungeonRaceStore";
|
||||
import type { CharacterProfile, PlayerSession } from "./types";
|
||||
|
||||
const request = vi.hoisted(() => vi.fn());
|
||||
vi.mock("./onlineAccountClient", () => ({ requestOnlineJson: request }));
|
||||
|
||||
const session: PlayerSession = {
|
||||
kind: "account", ownerId: "player-1", displayName: "Racer", accessToken: "race-token",
|
||||
};
|
||||
const character = { id: "character-1" } as CharacterProfile;
|
||||
const queued: RaceServerState = { status: "queued", serverTime: Date.now(), joinedAt: Date.now() };
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("dungeon race exit", () => {
|
||||
beforeEach(() => {
|
||||
request.mockReset();
|
||||
useDungeonRaceStore.getState().reset();
|
||||
});
|
||||
afterEach(() => useDungeonRaceStore.getState().reset());
|
||||
|
||||
it("clears an active race immediately and waits for the server leave before requeuing", async () => {
|
||||
const deleteRequest = deferred<RaceServerState>();
|
||||
request.mockImplementation((path: string, init: RequestInit) =>
|
||||
path === "/api/dungeon-races/queue" && init.method === "DELETE"
|
||||
? deleteRequest.promise : Promise.resolve(queued));
|
||||
await useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
|
||||
useDungeonRaceStore.setState({ status: "active", inGame: true, matchId: "old-match",
|
||||
dungeonId: "bite-sized-stormwind-stockade", startAt: Date.now() - 10_000 });
|
||||
|
||||
const leaving = useDungeonRaceStore.getState().leave();
|
||||
expect(useDungeonRaceStore.getState()).toMatchObject({ mode: null, status: "idle", inGame: false,
|
||||
matchId: null, startAt: null });
|
||||
const requeue = useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
|
||||
await Promise.resolve();
|
||||
expect(request.mock.calls.filter(([, init]) => init.method === "POST")).toHaveLength(1);
|
||||
|
||||
deleteRequest.resolve(queued);
|
||||
await Promise.all([leaving, requeue]);
|
||||
expect(request.mock.calls.map(([, init]) => init.method)).toEqual(["POST", "DELETE", "POST"]);
|
||||
expect(useDungeonRaceStore.getState().status).toBe("queued");
|
||||
});
|
||||
|
||||
it("cancels a queue request in flight without restoring its late response", async () => {
|
||||
const firstQueue = deferred<RaceServerState>();
|
||||
request.mockImplementation((path: string, init: RequestInit) =>
|
||||
path === "/api/dungeon-races/queue" && init.method === "POST"
|
||||
? firstQueue.promise : Promise.resolve(queued));
|
||||
const starting = useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
|
||||
const leaving = useDungeonRaceStore.getState().leave();
|
||||
expect(useDungeonRaceStore.getState().mode).toBeNull();
|
||||
firstQueue.resolve(queued);
|
||||
await Promise.all([starting, leaving]);
|
||||
expect(useDungeonRaceStore.getState()).toMatchObject({ mode: null, status: "idle", matchId: null });
|
||||
expect(request.mock.calls.map(([, init]) => init.method)).toEqual(["POST", "DELETE"]);
|
||||
});
|
||||
|
||||
it("ignores an older preparation response after the match has started", async () => {
|
||||
const stale = deferred<RaceServerState>();
|
||||
request.mockImplementation((path: string) => path === "/api/dungeon-races/state"
|
||||
? stale.promise : Promise.resolve(queued));
|
||||
await useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
|
||||
useDungeonRaceStore.setState({ status: "active", matchId: "race-1", inGame: true });
|
||||
const syncing = useDungeonRaceStore.getState().sync();
|
||||
stale.resolve({ status: "preparing", matchId: "race-1", serverTime: Date.now() });
|
||||
await syncing;
|
||||
expect(useDungeonRaceStore.getState().status).toBe("active");
|
||||
});
|
||||
|
||||
it("ends offline practice locally without a server request", async () => {
|
||||
useDungeonRaceStore.setState({ mode: "practice", status: "settled", inGame: true,
|
||||
matchId: "practice-1", startAt: Date.now() - 30_000, result: "loss" });
|
||||
await useDungeonRaceStore.getState().leave();
|
||||
expect(useDungeonRaceStore.getState()).toMatchObject({ mode: null, status: "idle", inGame: false,
|
||||
matchId: null, startAt: null, result: null });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+76
-29
@@ -11,6 +11,9 @@ import type { PartyRole } from "../game/partyRoles";
|
||||
|
||||
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;
|
||||
@@ -70,6 +73,10 @@ 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;
|
||||
|
||||
function stopPolling() { if (pollTimer) clearInterval(pollTimer); pollTimer = null; }
|
||||
function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "The race could not continue."; }
|
||||
@@ -86,12 +93,14 @@ async function prepareContent(dungeonId: string, seed: string): Promise<void> {
|
||||
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 (get().seed !== seed || get().status === "void") return;
|
||||
if (raceRevision !== revision || get().seed !== seed || get().status === "void") return;
|
||||
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,
|
||||
@@ -99,9 +108,11 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
if (!entered) throw new Error(useShellStore.getState().notice || "Could not enter the race dungeon.");
|
||||
set({ inGame: true, preparingContent: false });
|
||||
} catch (error) {
|
||||
set({ error: errorMessage(error), preparingContent: false });
|
||||
if (get().mode === "online") await get().leave();
|
||||
} finally { preparingSeed = null; }
|
||||
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();
|
||||
@@ -110,6 +121,8 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
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();
|
||||
set({ status: response.status, matchId: response.matchId ?? null, seed: response.seed ?? null,
|
||||
@@ -132,16 +145,35 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
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 = session.accessToken;
|
||||
sessionToken = token;
|
||||
set({ mode: "online", status: "queued" });
|
||||
let request: Promise<RaceServerState> | null = null;
|
||||
try {
|
||||
const response = await requestOnlineJson<RaceServerState>("/api/dungeon-races/queue", {
|
||||
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 } : {}) }),
|
||||
}, sessionToken);
|
||||
}, token);
|
||||
queueRequest = request;
|
||||
const response = await request;
|
||||
if (queueRequest === request) queueRequest = null;
|
||||
if (raceRevision !== revision) return;
|
||||
applyServerState(response);
|
||||
pollTimer = setInterval(() => { void get().sync(); }, 750);
|
||||
} catch (error) { set({ status: "idle", error: errorMessage(error) }); }
|
||||
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();
|
||||
@@ -162,8 +194,12 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
},
|
||||
sync: async () => {
|
||||
if (get().mode !== "online" || !sessionToken) return;
|
||||
try { applyServerState(await requestOnlineJson<RaceServerState>("/api/dungeon-races/state", {}, sessionToken)); }
|
||||
catch (error) { set({ error: errorMessage(error) }); }
|
||||
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();
|
||||
@@ -172,11 +208,15 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
set({ ready: true, status: "countdown", startAt: Date.now() + 3_000 }); return;
|
||||
}
|
||||
if (!sessionToken) return;
|
||||
const revision = raceRevision;
|
||||
const token = sessionToken;
|
||||
readyPending = true;
|
||||
try { applyServerState(await requestOnlineJson<RaceServerState>(
|
||||
`/api/dungeon-races/${encodeURIComponent(state.matchId)}/ready`, { method: "POST" }, sessionToken)); }
|
||||
catch (error) { set({ error: errorMessage(error) }); }
|
||||
finally { readyPending = false; }
|
||||
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();
|
||||
@@ -192,14 +232,15 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
}
|
||||
if (!sessionToken) return Promise.resolve();
|
||||
const matchId = state.matchId;
|
||||
finishPromise = requestOnlineJson<RaceServerState>(
|
||||
const report = requestOnlineJson<RaceServerState>(
|
||||
`/api/dungeon-races/${encodeURIComponent(state.matchId)}/finish`, {
|
||||
method: "POST", body: JSON.stringify({ runId: state.seed, bossIds }),
|
||||
}, sessionToken)
|
||||
.then((response) => { if (get().matchId === matchId) applyServerState(response); })
|
||||
.catch((error: unknown) => { if (get().matchId === matchId) set({ error: errorMessage(error) }); })
|
||||
.finally(() => { finishPromise = null; });
|
||||
return finishPromise;
|
||||
.finally(() => { if (finishPromise === report) finishPromise = null; });
|
||||
finishPromise = report;
|
||||
return report;
|
||||
},
|
||||
tick: () => {
|
||||
const state = get();
|
||||
@@ -211,19 +252,25 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
opponentProgress: raceBotProgress(now - state.startAt, state.opponent) });
|
||||
},
|
||||
leave: async () => {
|
||||
stopPolling();
|
||||
if (get().mode === "online" && sessionToken) {
|
||||
try { await requestOnlineJson("/api/dungeon-races/queue", { method: "DELETE" }, sessionToken); }
|
||||
catch (error) { set({ error: errorMessage(error) }); }
|
||||
}
|
||||
if (!get().inGame) {
|
||||
const message = get().error;
|
||||
get().reset();
|
||||
set({ error: message });
|
||||
}
|
||||
const token = get().mode === "online" ? sessionToken : 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. */ }
|
||||
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: () => {
|
||||
stopPolling(); sessionToken = null; preparingSeed = null; readyPending = false;
|
||||
raceRevision += 1;
|
||||
stopPolling(); sessionToken = null; preparingSeed = null; readyPending = false; finishPromise = null;
|
||||
set({ mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: 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 });
|
||||
|
||||
Reference in new issue
Block a user