Update Healer Man server
This commit is contained in:
1 parent
ab1434a3b8
commit
e284f4fc76
60 files changed
+2704
-118
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import catalog from "../game/dungeonRaceBotCatalog.json";
|
||||
import { dungeonRaceAffixPace, type DungeonRaceAffixPair } from "../game/dungeonRaceAffixes";
|
||||
|
||||
export type RaceBotTier = "novice" | "regular" | "veteran" | "elite";
|
||||
export type RaceBotSource = "wow" | "rom";
|
||||
@@ -18,14 +19,17 @@ export function hashRaceSeed(value: string): number {
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function createRaceBot(seed: string, tierId: RaceBotTier, source: RaceBotSource): RaceBot {
|
||||
export function createRaceBot(seed: string, tierId: RaceBotTier, source: RaceBotSource,
|
||||
affixIds?: DungeonRaceAffixPair): RaceBot {
|
||||
const tier = catalog.tiers.find((entry) => entry.id === tierId) ?? catalog.tiers[1];
|
||||
const variance = 0.92 + (hashRaceSeed(`${seed}:pace`) % 161) / 1000;
|
||||
const pace = affixIds ? dungeonRaceAffixPace(affixIds) : 1;
|
||||
const finishMs = Math.round(catalog.baselineMs * tier.multiplier * variance * pace);
|
||||
return { type: "bot", name: `${tier.label} ${source === "rom" ? "RuneWaker" : "Azeroth"} Team`,
|
||||
tier: tier.id as RaceBotTier, source, roster: catalog.teams[source],
|
||||
finishMs: Math.round(catalog.baselineMs * tier.multiplier * variance),
|
||||
finishMs,
|
||||
milestones: catalog.milestones.map((entry) => ({ ...entry,
|
||||
atMs: Math.round(catalog.baselineMs * tier.multiplier * variance * entry.at) })) };
|
||||
atMs: Math.round(finishMs * entry.at) })) };
|
||||
}
|
||||
|
||||
export function raceBotProgress(elapsedMs: number, bot: RaceBot): number {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDungeonRaceStore, type RaceServerState } from "./dungeonRaceStore";
|
||||
import type { CharacterProfile, PlayerSession } from "./types";
|
||||
import { appendCombatEvent } from "../game/combatEvents";
|
||||
import { startRaceCombatLog } from "../game/dungeonRaceCombatLog";
|
||||
|
||||
const request = vi.hoisted(() => vi.fn());
|
||||
vi.mock("./onlineAccountClient", () => ({ requestOnlineJson: request }));
|
||||
@@ -80,4 +82,53 @@ describe("dungeon race exit", () => {
|
||||
matchId: null, startAt: null, result: null });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends the full recorded stream with a completed online clear", async () => {
|
||||
request.mockResolvedValue(queued);
|
||||
await useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
|
||||
const startedAt = Date.now() - 1_000;
|
||||
useDungeonRaceStore.setState({ mode: "online", status: "active", inGame: true,
|
||||
matchId: "race-combat-clear", seed: "race-combat-clear",
|
||||
dungeonId: "bite-sized-stormwind-stockade", startAt: startedAt, serverOffset: 0 });
|
||||
startRaceCombatLog("race-combat-clear", "bite-sized-stormwind-stockade");
|
||||
appendCombatEvent([], { occurredAt: startedAt + 100, kind: "damage",
|
||||
sourceActorId: "player", targetActorId: "mob-1", rawAmount: 20,
|
||||
effectiveAmount: 20, overheal: 0, absorbed: 0, critical: false }, 1);
|
||||
await useDungeonRaceStore.getState().reportClear(["boss-1"]);
|
||||
const finish = request.mock.calls.find(([path]) => path === "/api/dungeon-races/race-combat-clear/finish");
|
||||
expect(finish).toBeDefined();
|
||||
const body = JSON.parse(finish![1].body);
|
||||
expect(body.combatLog).toMatchObject({ completed: true, runId: "race-combat-clear",
|
||||
events: [{ kind: "damage", effective: 20 }] });
|
||||
});
|
||||
|
||||
it("uploads a partial log before leaving a started online race", async () => {
|
||||
request.mockResolvedValue(queued);
|
||||
await useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
|
||||
useDungeonRaceStore.setState({ mode: "online", status: "active", inGame: true,
|
||||
matchId: "race-combat-leave", seed: "race-combat-leave",
|
||||
dungeonId: "bite-sized-stormwind-stockade", startAt: Date.now() - 1_000, serverOffset: 0 });
|
||||
startRaceCombatLog("race-combat-leave", "bite-sized-stormwind-stockade");
|
||||
await useDungeonRaceStore.getState().leave();
|
||||
const partial = request.mock.calls.find(([path]) => path === "/api/dungeon-races/race-combat-leave/combat-log");
|
||||
expect(partial).toBeDefined();
|
||||
expect(JSON.parse(partial![1].body).combatLog.completed).toBe(false);
|
||||
expect(request.mock.calls.map(([path, init]) => `${init.method}:${path}`)).toEqual([
|
||||
"POST:/api/dungeon-races/queue",
|
||||
"POST:/api/dungeon-races/race-combat-leave/combat-log",
|
||||
"DELETE:/api/dungeon-races/queue",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects an unknown server mechanic pair before entering or readying", async () => {
|
||||
request.mockImplementation((path: string) => Promise.resolve(path === "/api/dungeon-races/queue"
|
||||
? { status: "preparing", serverTime: Date.now(), matchId: "future-race",
|
||||
seed: "future-race", dungeonId: "bite-sized-stormwind-stockade",
|
||||
affixIds: ["unknown-kill", "falling-stars"] } : queued));
|
||||
await useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
|
||||
await vi.waitFor(() => expect(useDungeonRaceStore.getState().error).toContain("does not support"));
|
||||
expect(useDungeonRaceStore.getState()).toMatchObject({ mode: null, status: "idle", inGame: false, ready: false });
|
||||
expect(request.mock.calls.map(([path]) => path)).toContain("/api/dungeon-races/queue");
|
||||
expect(request.mock.calls.some(([, init]) => init.method === "DELETE")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,10 @@ import { dungeonContentIsUsable, installContentPack } from "../content/contentMa
|
||||
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";
|
||||
@@ -26,6 +30,7 @@ export interface RaceServerState {
|
||||
readonly seed?: string;
|
||||
readonly dungeonId?: string | null;
|
||||
readonly difficultyId?: string;
|
||||
readonly affixIds?: readonly string[];
|
||||
readonly startAt?: number | null;
|
||||
readonly prepareDeadline?: number;
|
||||
readonly ready?: boolean;
|
||||
@@ -42,6 +47,7 @@ interface RaceState {
|
||||
seed: string | null;
|
||||
dungeonId: string | null;
|
||||
difficultyId: string | null;
|
||||
affixIds: DungeonRaceAffixPair | null;
|
||||
startAt: number | null;
|
||||
serverOffset: number;
|
||||
opponent: RaceOpponent | null;
|
||||
@@ -77,6 +83,7 @@ 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."; }
|
||||
@@ -99,6 +106,9 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
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();
|
||||
@@ -106,8 +116,10 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
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();
|
||||
@@ -125,8 +137,16 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
&& 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,
|
||||
@@ -138,7 +158,7 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
if (["settled", "void", "idle"].includes(response.status)) stopPolling();
|
||||
}
|
||||
return {
|
||||
mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, startAt: null,
|
||||
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,
|
||||
@@ -187,9 +207,10 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
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,
|
||||
opponent: createRaceBot(seed, tier, source) });
|
||||
difficultyId: null, affixIds,
|
||||
opponent: createRaceBot(seed, tier, source, affixIds) });
|
||||
await enterPreparedRun(resolvedDungeon, seed);
|
||||
},
|
||||
sync: async () => {
|
||||
@@ -223,6 +244,8 @@ export const useDungeonRaceStore = create<RaceState>((set, 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");
|
||||
@@ -234,9 +257,9 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
const matchId = state.matchId;
|
||||
const report = requestOnlineJson<RaceServerState>(
|
||||
`/api/dungeon-races/${encodeURIComponent(state.matchId)}/finish`, {
|
||||
method: "POST", body: JSON.stringify({ runId: state.seed, bossIds }),
|
||||
method: "POST", body: JSON.stringify({ runId: state.seed, bossIds, combatLog: pendingCombatLog }),
|
||||
}, sessionToken)
|
||||
.then((response) => { if (get().matchId === matchId) applyServerState(response); })
|
||||
.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;
|
||||
@@ -252,7 +275,11 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
opponentProgress: raceBotProgress(now - state.startAt, state.opponent) });
|
||||
},
|
||||
leave: async () => {
|
||||
const token = get().mode === "online" ? sessionToken : null;
|
||||
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;
|
||||
@@ -260,6 +287,12 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
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;
|
||||
})();
|
||||
@@ -270,8 +303,10 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
},
|
||||
reset: () => {
|
||||
raceRevision += 1;
|
||||
cleanupDungeonRaceAffixes();
|
||||
stopPolling(); sessionToken = null; preparingSeed = null; readyPending = false; finishPromise = null;
|
||||
set({ mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, startAt: 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 });
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user