Update Healer Man server
This commit is contained in:
1 parent
9c5fb4c614
commit
bd073fb00f
113 files changed
+4902
-273
No files matched your search
@@ -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);
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface DungeonEntrySettings {
|
||||
readonly bossDifficulty?: number;
|
||||
readonly freshRun?: boolean;
|
||||
readonly runId?: string;
|
||||
readonly raceStartBlocked?: boolean;
|
||||
}
|
||||
|
||||
let installedRuntime: DungeonShellRuntime | null = null;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user