Update Healer Man server

This commit is contained in:
phenom committed 2026-09-26 17:51:56 -04:00
1 parent 9c5fb4c614
commit bd073fb00f
113 files changed
+4902 -273

No files matched your search

+30 -2
View File
@@ -4,6 +4,7 @@ import {
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
} from "../game/dungeonContentSource";
import type { DungeonRunProgress } from "../game/dungeonRunTypes";
import { DEFAULT_DUNGEON_QUEUE_POLICY } from "../game/dungeonQueuePolicy";
import { canStartAnotherBiteSizedDungeon, startAnotherBiteSizedDungeon } from "./biteSizedDungeonRuntime";
import { useOnlineGroupStore } from "./onlineGroupStore";
import { useOnlineSessionStore } from "./onlineSessionStore";
@@ -38,6 +39,29 @@ afterEach(() => {
});
describe("bite-sized replay", () => {
it("does not replay a disabled randomized location", async () => {
const enterDungeon = vi.fn(() => true);
useShellStore.setState({
session: { kind: "offline", ownerId: "local-owner", displayName: "Tester" },
activeDungeonRole: "healer",
enterDungeon,
setNotice: vi.fn(),
});
await expect(startAnotherBiteSizedDungeon(
{ ...progress, dungeonId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID },
{
random: () => 0,
loadPolicy: async () => ({ schemaVersion: 1, disabled: {
regular: [], biteSized: [], randomizedBiteSized: [RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID],
} }),
createRunId: () => "unused",
contentIsUsable: () => true,
prepareRandomizedContent: vi.fn(),
},
)).resolves.toBe(false);
expect(enterDungeon).not.toHaveBeenCalled();
});
it("starts a fresh local run and treats the local player as party leader", async () => {
const enterDungeon = vi.fn(() => true);
const saveCheckpoint = vi.fn();
@@ -51,12 +75,14 @@ describe("bite-sized replay", () => {
expect(canStartAnotherBiteSizedDungeon()).toBe(true);
await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true);
expect(saveCheckpoint).toHaveBeenCalledWith(null);
expect(enterDungeon).toHaveBeenCalledWith(BITE_SIZED_STOCKADES_DUNGEON_ID, "healer", {
expect(enterDungeon).toHaveBeenCalledWith(expect.stringMatching(/^bite-sized-/), "healer", {
difficultyId: "normal",
bossDifficulty: 1,
freshRun: true,
runId: expect.any(String),
});
expect(enterDungeon).not.toHaveBeenCalledWith(BITE_SIZED_STOCKADES_DUNGEON_ID,
expect.anything(), expect.anything());
});
it("replays randomized mode with a new seed and prepares that seed's creature assets", async () => {
@@ -73,6 +99,7 @@ describe("bite-sized replay", () => {
await expect(startAnotherBiteSizedDungeon(randomizedProgress, {
random: () => 0,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
createRunId: () => "next-randomized-seed",
contentIsUsable: () => true,
prepareRandomizedContent,
@@ -114,7 +141,7 @@ describe("bite-sized replay", () => {
await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true);
expect(startActivity).toHaveBeenCalledWith(expect.objectContaining({ ownerId: "leader" }), {
type: "dungeon",
contentId: BITE_SIZED_STOCKADES_DUNGEON_ID,
contentId: expect.stringMatching(/^bite-sized-/),
difficultyId: "normal",
bossDifficulty: 1,
partySize: 4,
@@ -140,6 +167,7 @@ describe("bite-sized replay", () => {
await expect(startAnotherBiteSizedDungeon(
{ ...progress, dungeonId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID },
{ random: () => 0, createRunId: () => "unused-local-seed", contentIsUsable: () => true,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
prepareRandomizedContent: vi.fn() },
)).resolves.toBe(true);
+7 -2
View File
@@ -1,11 +1,13 @@
import { dungeonContentIsUsable } from "../content/contentManager";
import { randomDungeonByCategory, randomRandomizedBiteSizedDungeon } from "../game/dungeonCatalog";
import { dungeonEnabledForQueue, type DungeonQueuePolicy } from "../game/dungeonQueuePolicy";
import { isRandomizedBiteSizedDungeonId } from "../game/dungeonContentSource";
import { dungeonDefinitionById } from "../game/dungeonRegistry";
import type { DungeonRunProgress } from "../game/dungeonRunTypes";
import { useOnlineGroupStore } from "./onlineGroupStore";
import { useOnlineSessionStore } from "./onlineSessionStore";
import { ensureRandomizedBiteSizedRunContent } from "./randomizedBiteSizedContent";
import { loadDungeonQueuePolicy } from "./dungeonQueuePolicyStore";
import { useShellStore } from "./shellStore";
export function canStartAnotherBiteSizedDungeon(): boolean {
@@ -18,6 +20,7 @@ export function canStartAnotherBiteSizedDungeon(): boolean {
interface BiteSizedReplayDependencies {
readonly random: () => number;
readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
readonly createRunId: () => string;
readonly contentIsUsable: (dungeonId: string) => boolean;
readonly prepareRandomizedContent: (seed: string) => Promise<void>;
@@ -25,6 +28,7 @@ interface BiteSizedReplayDependencies {
const DEFAULT_REPLAY_DEPENDENCIES: BiteSizedReplayDependencies = {
random: Math.random,
loadPolicy: loadDungeonQueuePolicy,
createRunId: () => globalThis.crypto.randomUUID(),
contentIsUsable: dungeonContentIsUsable,
prepareRandomizedContent: ensureRandomizedBiteSizedRunContent,
@@ -45,11 +49,12 @@ export async function startAnotherBiteSizedDungeon(
}
const randomized = isRandomizedBiteSizedDungeonId(progress.dungeonId);
const policy = await dependencies.loadPolicy();
const next = randomized
? randomRandomizedBiteSizedDungeon(progress.dungeonId, dependencies.random,
(dungeon) => dependencies.contentIsUsable(dungeon.id))
(dungeon) => dependencies.contentIsUsable(dungeon.id) && dungeonEnabledForQueue(policy, dungeon))
: randomDungeonByCategory("bite-sized", progress.dungeonId, dependencies.random,
(dungeon) => dependencies.contentIsUsable(dungeon.id));
(dungeon) => dependencies.contentIsUsable(dungeon.id) && dungeonEnabledForQueue(policy, dungeon));
if (!next) {
shell.setNotice(randomized
? "No Randomized Bite-Sized location is ready to enter."
+113
View File
@@ -0,0 +1,113 @@
import { create } from "zustand";
import { requestOnlineJson } from "./onlineAccountClient";
import type { PlayerSession } from "./types";
import {
DEFAULT_DUNGEON_QUEUE_POLICY,
normalizeDungeonQueuePolicy,
type DungeonQueueMode,
type DungeonQueuePolicy,
} from "../game/dungeonQueuePolicy";
interface PublishedPolicy {
readonly revision: number;
readonly updatedAt: number | null;
readonly updatedBy?: string | null;
readonly config: DungeonQueuePolicy;
}
interface PolicyState extends PublishedPolicy {
readonly draft: DungeonQueuePolicy;
readonly loading: boolean;
readonly saving: boolean;
readonly dirty: boolean;
readonly error: string | null;
refresh: (session: PlayerSession | null) => Promise<void>;
setEnabled: (mode: DungeonQueueMode, dungeonId: string, enabled: boolean) => void;
resetDraft: () => void;
publish: (session: PlayerSession | null) => Promise<boolean>;
}
const CACHE_KEY = "healer-man.dungeon-queue-policy.v1";
function cached(): PublishedPolicy {
try {
const value = JSON.parse(globalThis.localStorage?.getItem(CACHE_KEY) ?? "null") as PublishedPolicy;
if (!Number.isInteger(value.revision)) throw new Error("missing");
return { ...value, config: normalizeDungeonQueuePolicy(value.config) };
} catch {
return { revision: 0, updatedAt: null, config: DEFAULT_DUNGEON_QUEUE_POLICY };
}
}
function cache(value: PublishedPolicy): void {
try { globalThis.localStorage?.setItem(CACHE_KEY, JSON.stringify(value)); } catch { /* offline cache is optional */ }
}
function normalized(value: PublishedPolicy): PublishedPolicy {
return { ...value, config: normalizeDungeonQueuePolicy(value.config) };
}
/** Fetch for each random roll so a newly published GM setting is used without a reload. */
export async function loadDungeonQueuePolicy(): Promise<DungeonQueuePolicy> {
try {
const value = normalized(await requestOnlineJson<PublishedPolicy>("/api/dungeon-queue-policy"));
cache(value);
useDungeonQueuePolicyStore.setState((state) => state.dirty ? state : { ...value, draft: value.config });
return value.config;
} catch {
return cached().config;
}
}
const initial = cached();
export const useDungeonQueuePolicyStore = create<PolicyState>((set, get) => ({
...initial,
draft: initial.config,
loading: false,
saving: false,
dirty: false,
error: null,
refresh: async (session) => {
set({ loading: true, error: null });
try {
const gm = Boolean(session?.accessToken && session.roles?.includes("gm"));
const value = normalized(await requestOnlineJson<PublishedPolicy>(
gm ? "/api/gm/dungeon-queue-policy" : "/api/dungeon-queue-policy",
{}, gm ? session?.accessToken : undefined,
));
cache(value);
set({ ...value, draft: value.config, loading: false, dirty: false });
} catch (error) {
set({ loading: false, error: error instanceof Error ? error.message : "Could not load dungeon queue settings." });
}
},
setEnabled: (mode, dungeonId, enabled) => set((state) => {
const ids = new Set(state.draft.disabled[mode]);
if (enabled) ids.delete(dungeonId);
else ids.add(dungeonId);
return {
draft: { schemaVersion: 1, disabled: { ...state.draft.disabled, [mode]: [...ids].sort() } },
dirty: true,
error: null,
};
}),
resetDraft: () => set((state) => ({ draft: state.config, dirty: false, error: null })),
publish: async (session) => {
if (!session?.accessToken || !session.roles?.includes("gm")) {
set({ error: "Game Master access is required." });
return false;
}
set({ saving: true, error: null });
try {
const value = normalized(await requestOnlineJson<PublishedPolicy>("/api/gm/dungeon-queue-policy", {
method: "PUT", body: JSON.stringify({ expectedRevision: get().revision, config: get().draft }),
}, session.accessToken));
cache(value);
set({ ...value, draft: value.config, saving: false, dirty: false });
return true;
} catch (error) {
set({ saving: false, error: error instanceof Error ? error.message : "Could not publish dungeon queue settings." });
return false;
}
},
}));
+39
View File
@@ -0,0 +1,39 @@
import catalog from "../game/dungeonRaceBotCatalog.json";
export type RaceBotTier = "novice" | "regular" | "veteran" | "elite";
export type RaceBotSource = "wow" | "rom";
export interface RaceBot {
readonly type: "bot";
readonly name: string;
readonly tier: RaceBotTier;
readonly source: RaceBotSource;
readonly roster: readonly { readonly name: string; readonly classId: string; readonly role: string }[];
readonly finishMs: number;
readonly milestones: readonly { readonly atMs: number; readonly progress: number; readonly label: string }[];
}
export function hashRaceSeed(value: string): number {
let hash = 2166136261;
for (const char of value) { hash ^= char.charCodeAt(0); hash = Math.imul(hash, 16777619); }
return hash >>> 0;
}
export function createRaceBot(seed: string, tierId: RaceBotTier, source: RaceBotSource): RaceBot {
const tier = catalog.tiers.find((entry) => entry.id === tierId) ?? catalog.tiers[1];
const variance = 0.92 + (hashRaceSeed(`${seed}:pace`) % 161) / 1000;
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),
milestones: catalog.milestones.map((entry) => ({ ...entry,
atMs: Math.round(catalog.baselineMs * tier.multiplier * variance * entry.at) })) };
}
export function raceBotProgress(elapsedMs: number, bot: RaceBot): number {
if (elapsedMs <= 0) return 0;
const previous = [{ atMs: 0, progress: 0 }, ...bot.milestones]
.filter((entry) => entry.atMs <= elapsedMs).at(-1)!;
const next = bot.milestones.find((entry) => entry.atMs > elapsedMs);
if (!next) return 100;
return Math.floor(previous.progress + (next.progress - previous.progress)
* (elapsedMs - previous.atMs) / (next.atMs - previous.atMs));
}
+232
View File
@@ -0,0 +1,232 @@
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";
export type RacePool = "authored" | "randomized";
export type RaceStatus = "idle" | "queued" | "preparing" | "countdown" | "active" | "settled" | "void";
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 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;
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;
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);
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;
preparingSeed = seed;
set({ preparingContent: true, error: "" });
try {
await prepareContent(dungeonId, seed);
if (get().seed !== seed || get().status === "void") return;
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
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.");
set({ inGame: true, preparingContent: false });
} catch (error) {
set({ error: errorMessage(error), preparingContent: false });
if (get().mode === "online") await get().leave();
} finally { 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;
}
const result = response.result ?? null;
const offset = response.serverTime - Date.now();
set({ status: response.status, matchId: response.matchId ?? null, seed: response.seed ?? null,
dungeonId: response.dungeonId ?? null, difficultyId: response.difficultyId ?? 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, 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();
selectedRole = role;
sessionToken = session.accessToken;
set({ mode: "online", status: "queued" });
try {
const response = await requestOnlineJson<RaceServerState>("/api/dungeon-races/queue", {
method: "POST", body: JSON.stringify({ characterId: character.id, role, pool, ...(dungeonId ? { dungeonId } : {}) }),
}, sessionToken);
applyServerState(response);
pollTimer = setInterval(() => { void get().sync(); }, 750);
} catch (error) { 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()}`;
set({ mode: "practice", status: "preparing", matchId: seed, seed, dungeonId: resolvedDungeon,
difficultyId: null,
opponent: createRaceBot(seed, tier, source) });
await enterPreparedRun(resolvedDungeon, seed);
},
sync: async () => {
if (get().mode !== "online" || !sessionToken) return;
try { applyServerState(await requestOnlineJson<RaceServerState>("/api/dungeon-races/state", {}, sessionToken)); }
catch (error) { 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;
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; }
},
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();
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;
finishPromise = 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;
},
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 () => {
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 });
}
},
reset: () => {
stopPolling(); sessionToken = null; preparingSeed = null; readyPending = false;
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 });
},
};
});
+1
View File
@@ -75,6 +75,7 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: D
});
return false;
}
if (settings?.raceStartBlocked) useGameStore.setState({ raceStartBlocked: true });
useGameStore.getState().setCompanionOpen(false);
useShellStore.setState({
phase: "game",
+1
View File
@@ -9,6 +9,7 @@ export interface DungeonEntrySettings {
readonly bossDifficulty?: number;
readonly freshRun?: boolean;
readonly runId?: string;
readonly raceStartBlocked?: boolean;
}
let installedRuntime: DungeonShellRuntime | null = null;
+31 -1
View File
@@ -2,7 +2,8 @@ import { describe, expect, it, vi } from "vitest";
import { createDefaultAppearance } from "./characterCatalog";
import type { CharacterProfile, PlayerSession } from "./types";
import type { OnlineGroup } from "./onlineGroupTypes";
import { dungeonById } from "../game/dungeonCatalog";
import { dungeonById, dungeonsByCategory, randomRegularDungeon } from "../game/dungeonCatalog";
import { DEFAULT_DUNGEON_QUEUE_POLICY } from "../game/dungeonQueuePolicy";
import { dungeonContentPackId } from "../game/dungeonContentSource";
import { queueRandomDungeon, type RandomDungeonQueueOptions } from "./randomDungeonQueue";
@@ -37,6 +38,7 @@ describe("random dungeon queue", () => {
const options = queueOptions();
const result = await queueRandomDungeon(options, {
random: () => 0,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
contentIsUsable: () => installed,
installPack,
installDungeonRuntime,
@@ -58,6 +60,7 @@ describe("random dungeon queue", () => {
const options = queueOptions({ mode: "bite-sized" });
await expect(queueRandomDungeon(options, {
random: () => 0,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
contentIsUsable: () => true,
installPack,
installDungeonRuntime: vi.fn(async () => undefined),
@@ -66,10 +69,35 @@ describe("random dungeon queue", () => {
expect(installPack).not.toHaveBeenCalled();
});
it("skips a disabled full dungeon and rejects a queue with no enabled Bite-Sized locations", async () => {
const first = randomRegularDungeon(undefined, () => 0)!;
const options = queueOptions();
await expect(queueRandomDungeon(options, {
random: () => 0,
loadPolicy: async () => ({ schemaVersion: 1, disabled: { regular: [first.id], biteSized: [], randomizedBiteSized: [] } }),
contentIsUsable: () => true,
installPack: vi.fn(),
installDungeonRuntime: vi.fn(),
})).resolves.toMatchObject({ status: "entered" });
expect(options.enterDungeon).not.toHaveBeenCalledWith(first.id, expect.anything(), expect.anything());
const biteOptions = queueOptions({ mode: "bite-sized" });
await expect(queueRandomDungeon(biteOptions, {
random: () => 0,
loadPolicy: async () => ({ schemaVersion: 1, disabled: { regular: [],
biteSized: dungeonsByCategory("bite-sized").map((dungeon) => dungeon.id), randomizedBiteSized: [] } }),
contentIsUsable: () => true,
installPack: vi.fn(),
installDungeonRuntime: vi.fn(),
})).resolves.toMatchObject({ status: "error", message: expect.stringMatching(/no dungeon/i) });
expect(biteOptions.enterDungeon).not.toHaveBeenCalled();
});
it("does not enter when the downloaded pack cannot be verified", async () => {
const options = queueOptions();
const result = await queueRandomDungeon(options, {
random: () => 0,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
contentIsUsable: () => false,
installPack: vi.fn(async () => undefined),
installDungeonRuntime: vi.fn(async () => undefined),
@@ -83,6 +111,7 @@ describe("random dungeon queue", () => {
const options = queueOptions({ session: accountSession, group });
const result = await queueRandomDungeon(options, {
random: () => 0,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
contentIsUsable: () => installed,
installPack: vi.fn(async () => { installed = true; }),
installDungeonRuntime: vi.fn(async () => undefined),
@@ -100,6 +129,7 @@ describe("random dungeon queue", () => {
const installPack = vi.fn(async () => undefined);
await expect(queueRandomDungeon(options, {
random: () => 0,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
contentIsUsable: () => false,
installPack,
installDungeonRuntime: vi.fn(async () => undefined),
+7 -2
View File
@@ -3,6 +3,8 @@ import type { OnlineGroup, StartOnlineGroupActivityInput } from "./onlineGroupTy
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
import { dungeonContentPackId } from "../game/dungeonContentSource";
import { randomDungeonByCategory, randomRegularDungeon, type DungeonCatalogEntry } from "../game/dungeonCatalog";
import { dungeonEnabledForQueue, type DungeonQueuePolicy } from "../game/dungeonQueuePolicy";
import { loadDungeonQueuePolicy } from "./dungeonQueuePolicyStore";
import { dungeonDefinitionById } from "../game/dungeonRegistry";
import { classCanFillPartyRole, type PartyRole } from "../game/partyRoles";
import { specializationById } from "../game/specializationCatalog";
@@ -28,6 +30,7 @@ export interface RandomDungeonQueueOptions {
interface RandomDungeonQueueDependencies {
readonly random: () => number;
readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
readonly contentIsUsable: (dungeonId: string) => boolean;
readonly installPack: (packId: string) => Promise<void>;
readonly installDungeonRuntime: () => Promise<void>;
@@ -35,6 +38,7 @@ interface RandomDungeonQueueDependencies {
const DEFAULT_DEPENDENCIES: RandomDungeonQueueDependencies = {
random: Math.random,
loadPolicy: loadDungeonQueuePolicy,
contentIsUsable: dungeonContentIsUsable,
installPack: installContentPack,
installDungeonRuntime: async () => {
@@ -72,9 +76,10 @@ export async function queueRandomDungeon(
}
options.onProgress("rolling", null);
const policy = await dependencies.loadPolicy();
const dungeon = options.mode === "regular"
? randomRegularDungeon(undefined, dependencies.random)
: randomDungeonByCategory("bite-sized", undefined, dependencies.random);
? randomRegularDungeon(undefined, dependencies.random, (entry) => dungeonEnabledForQueue(policy, entry))
: randomDungeonByCategory("bite-sized", undefined, dependencies.random, (entry) => dungeonEnabledForQueue(policy, entry));
if (!dungeon) return { status: "error", message: "No dungeon is currently ready for this queue." };
if (!dependencies.contentIsUsable(dungeon.id)) {
options.onProgress("downloading", dungeon);