Update Healer Man server

This commit is contained in:
phenom committed 2026-09-23 21:14:46 -04:00
1 parent 7449abdad2
commit a641a6e948
9 files changed
+363 -47

No files matched your search

+15
View File
@@ -26,6 +26,7 @@ const MANASTORM_ACTIVITY: OnlineGroupActivity = {
function entryDependencies(enterDungeon: () => boolean) {
return {
prepareDungeonContent: vi.fn(async () => undefined),
prepareRandomizedDungeonContent: vi.fn(async () => undefined),
installDungeonRuntime: vi.fn(async () => undefined),
enterDungeon: vi.fn(enterDungeon),
@@ -45,6 +46,9 @@ describe("online group activity entry", () => {
const dependencies = entryDependencies(() => true);
await enterOnlineGroupActivity({ ...DUNGEON_ACTIVITY, difficultyId: "heroic", bossDifficulty: 9 },
"damage", vi.fn(), dependencies);
expect(dependencies.prepareDungeonContent).toHaveBeenCalledWith("deadmines");
expect(dependencies.prepareDungeonContent.mock.invocationCallOrder[0])
.toBeLessThan(dependencies.enterDungeon.mock.invocationCallOrder[0]);
expect(dependencies.enterDungeon).toHaveBeenCalledWith("deadmines", "damage", {
difficultyId: "heroic", bossDifficulty: 9, freshRun: true, runId: DUNGEON_ACTIVITY.id,
});
@@ -59,10 +63,21 @@ describe("online group activity entry", () => {
await enterOnlineGroupActivity(activity, "healer", vi.fn(), dependencies);
expect(dependencies.prepareDungeonContent).toHaveBeenCalledWith(activity.contentId);
expect(dependencies.prepareRandomizedDungeonContent).toHaveBeenCalledWith(activity.id);
expect(dependencies.prepareRandomizedDungeonContent.mock.invocationCallOrder[0])
.toBeLessThan(dependencies.enterDungeon.mock.invocationCallOrder[0]);
});
it("does not enter a shared dungeon when its content download fails", async () => {
const dependencies = entryDependencies(() => true);
dependencies.prepareDungeonContent.mockRejectedValueOnce(new Error("Download failed"));
const consumeActivity = vi.fn();
await expect(enterOnlineGroupActivity(DUNGEON_ACTIVITY, "healer", consumeActivity, dependencies))
.rejects.toThrow("Download failed");
expect(dependencies.enterDungeon).not.toHaveBeenCalled();
expect(consumeActivity).not.toHaveBeenCalled();
});
it("leaves a dungeon activity unconsumed when dungeon entry fails", async () => {
const consumeActivity = vi.fn();
const dependencies = entryDependencies(() => false);
+9 -1
View File
@@ -12,12 +12,14 @@ import { useContentManagerSnapshot } from "../content/useContentManager";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
import { installShelvedOnlineGroupRuntime } from "@shelvedOnlineGroupRuntime";
import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../game/dungeonContentSource";
import { dungeonContentPackId, RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../game/dungeonContentSource";
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
const GROUP_POLL_MS = 2_500;
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
interface OnlineGroupActivityEntryDependencies {
readonly prepareDungeonContent: (dungeonId: string) => Promise<void>;
readonly prepareRandomizedDungeonContent: (seed: string) => Promise<void>;
readonly installDungeonRuntime: () => Promise<void>;
readonly enterDungeon: (dungeonId: string, role?: PartyRole, settings?: DungeonEntrySettings) => boolean;
@@ -26,6 +28,11 @@ interface OnlineGroupActivityEntryDependencies {
}
const DEFAULT_ACTIVITY_ENTRY_DEPENDENCIES: OnlineGroupActivityEntryDependencies = {
prepareDungeonContent: async (dungeonId) => {
if (dungeonContentIsUsable(dungeonId)) return;
await installContentPack(dungeonContentPackId(dungeonId));
if (!dungeonContentIsUsable(dungeonId)) throw new Error("The group dungeon download could not be verified.");
},
prepareRandomizedDungeonContent: async (seed) => {
const { ensureRandomizedBiteSizedRunContent } = await import("./randomizedBiteSizedContent");
await ensureRandomizedBiteSizedRunContent(seed);
@@ -53,6 +60,7 @@ export async function enterOnlineGroupActivity(
): Promise<boolean> {
let entered: boolean;
if (activity.type === "dungeon") {
await dependencies.prepareDungeonContent(activity.contentId);
if (activity.contentId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) {
await dependencies.prepareRandomizedDungeonContent(activity.id);
}
+110
View File
@@ -0,0 +1,110 @@
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 { dungeonContentPackId } from "../game/dungeonContentSource";
import { queueRandomDungeon, type RandomDungeonQueueOptions } from "./randomDungeonQueue";
const character: CharacterProfile = {
id: "character-1", ownerId: "player-1", name: "Remedy", raceId: "human", classId: "priest",
specializationId: "priest:holy", categoryId: "wow", gender: "female",
appearance: createDefaultAppearance(), level: 1, experience: 0, talentRanks: {},
actionBindings: {}, inventory: [], equipment: {}, location: "", createdAt: 1, lastPlayedAt: 1,
};
const offlineSession: PlayerSession = { kind: "offline", ownerId: "player-1", displayName: "Offline" };
const accountSession: PlayerSession = { kind: "account", ownerId: "player-1", displayName: "Remedy", accessToken: "test-token" };
const group: OnlineGroup = {
id: "group-1", leaderAccountId: "player-1", members: [], activity: null,
activityRevision: 0, createdAt: 1, updatedAt: 1,
};
function queueOptions(overrides: Partial<RandomDungeonQueueOptions> = {}): RandomDungeonQueueOptions {
return {
mode: "regular", role: "healer", character, session: offlineSession, group: null,
includeBetaSpecs: false, fillWithAi: true, onProgress: vi.fn(),
enterDungeon: vi.fn(() => true), updateOnlineMember: vi.fn(async () => true),
startOnlineActivity: vi.fn(async () => true), failureNotice: () => "",
...overrides,
};
}
describe("random dungeon queue", () => {
it("downloads a rolled full dungeon before entering a fresh local run", async () => {
let installed = false;
const installPack = vi.fn(async () => { installed = true; });
const installDungeonRuntime = vi.fn(async () => undefined);
const options = queueOptions();
const result = await queueRandomDungeon(options, {
random: () => 0,
contentIsUsable: () => installed,
installPack,
installDungeonRuntime,
});
expect(result.status).toBe("entered");
const dungeonId = vi.mocked(options.enterDungeon).mock.calls[0][0];
expect(["wow", "rom"]).toContain(dungeonById(dungeonId)?.category);
expect(installPack).toHaveBeenCalledWith(dungeonContentPackId(dungeonId));
expect(installPack.mock.invocationCallOrder[0]).toBeLessThan(installDungeonRuntime.mock.invocationCallOrder[0]);
expect(installDungeonRuntime.mock.invocationCallOrder[0])
.toBeLessThan(vi.mocked(options.enterDungeon).mock.invocationCallOrder[0]);
expect(options.enterDungeon).toHaveBeenCalledWith(dungeonId, "healer", expect.objectContaining({ freshRun: true }));
expect(options.onProgress).toHaveBeenCalledWith("downloading", expect.objectContaining({ id: dungeonId }));
});
it("keeps regular Bite-Sized separate from the creature-randomized mode", async () => {
const installPack = vi.fn(async () => undefined);
const options = queueOptions({ mode: "bite-sized" });
await expect(queueRandomDungeon(options, {
random: () => 0,
contentIsUsable: () => true,
installPack,
installDungeonRuntime: vi.fn(async () => undefined),
})).resolves.toMatchObject({ status: "entered" });
expect(options.enterDungeon).toHaveBeenCalledWith("bite-sized-stormwind-stockade", "healer", expect.any(Object));
expect(installPack).not.toHaveBeenCalled();
});
it("does not enter when the downloaded pack cannot be verified", async () => {
const options = queueOptions();
const result = await queueRandomDungeon(options, {
random: () => 0,
contentIsUsable: () => false,
installPack: vi.fn(async () => undefined),
installDungeonRuntime: vi.fn(async () => undefined),
});
expect(result).toMatchObject({ status: "error", message: expect.stringMatching(/verified/i) });
expect(options.enterDungeon).not.toHaveBeenCalled();
});
it("publishes the rolled dungeon for the online leader after installation", async () => {
let installed = false;
const options = queueOptions({ session: accountSession, group });
const result = await queueRandomDungeon(options, {
random: () => 0,
contentIsUsable: () => installed,
installPack: vi.fn(async () => { installed = true; }),
installDungeonRuntime: vi.fn(async () => undefined),
});
expect(result.status).toBe("started");
expect(options.updateOnlineMember).toHaveBeenCalledWith(accountSession, character, "healer");
expect(options.startOnlineActivity).toHaveBeenCalledWith(accountSession, expect.objectContaining({
type: "dungeon", partySize: 5, fillWithAi: true,
}));
expect(options.enterDungeon).not.toHaveBeenCalled();
});
it("marks a group member ready and waits for the leader's roll", async () => {
const options = queueOptions({ session: accountSession, group: { ...group, leaderAccountId: "player-2" } });
const installPack = vi.fn(async () => undefined);
await expect(queueRandomDungeon(options, {
random: () => 0,
contentIsUsable: () => false,
installPack,
installDungeonRuntime: vi.fn(async () => undefined),
})).resolves.toMatchObject({ status: "waiting" });
expect(installPack).not.toHaveBeenCalled();
expect(options.startOnlineActivity).not.toHaveBeenCalled();
});
});
+116
View File
@@ -0,0 +1,116 @@
import type { CharacterProfile, PlayerSession } from "./types";
import type { OnlineGroup, StartOnlineGroupActivityInput } from "./onlineGroupTypes";
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
import { dungeonContentPackId } from "../game/dungeonContentSource";
import { randomDungeonByCategory, randomRegularDungeon, type DungeonCatalogEntry } from "../game/dungeonCatalog";
import { dungeonDefinitionById } from "../game/dungeonRegistry";
import { classCanFillPartyRole, type PartyRole } from "../game/partyRoles";
import { specializationById } from "../game/specializationCatalog";
import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
export type RandomDungeonQueueMode = "regular" | "bite-sized";
export type RandomDungeonQueueStage = "rolling" | "downloading" | "entering";
export interface RandomDungeonQueueOptions {
readonly mode: RandomDungeonQueueMode;
readonly role: PartyRole;
readonly character: CharacterProfile | null;
readonly session: PlayerSession | null;
readonly group: OnlineGroup | null;
readonly includeBetaSpecs: boolean;
readonly fillWithAi: boolean;
readonly onProgress: (stage: RandomDungeonQueueStage, dungeon: DungeonCatalogEntry | null) => void;
readonly enterDungeon: (dungeonId: string, role: PartyRole, settings: DungeonEntrySettings) => boolean;
readonly updateOnlineMember: (session: PlayerSession, character: CharacterProfile, role: PartyRole) => Promise<boolean>;
readonly startOnlineActivity: (session: PlayerSession, input: StartOnlineGroupActivityInput) => Promise<boolean>;
readonly failureNotice: () => string;
}
interface RandomDungeonQueueDependencies {
readonly random: () => number;
readonly contentIsUsable: (dungeonId: string) => boolean;
readonly installPack: (packId: string) => Promise<void>;
readonly installDungeonRuntime: () => Promise<void>;
}
const DEFAULT_DEPENDENCIES: RandomDungeonQueueDependencies = {
random: Math.random,
contentIsUsable: dungeonContentIsUsable,
installPack: installContentPack,
installDungeonRuntime: async () => {
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
installDungeonShellRuntime();
},
};
export type RandomDungeonQueueResult =
| { readonly status: "entered" | "waiting" | "started"; readonly dungeonTitle?: string }
| { readonly status: "error"; readonly message: string };
/** Rolls only after Queue is pressed; downloads the selected map before local or shared entry. */
export async function queueRandomDungeon(
options: RandomDungeonQueueOptions,
dependencies: RandomDungeonQueueDependencies = DEFAULT_DEPENDENCIES,
): Promise<RandomDungeonQueueResult> {
const { character, session, group, role } = options;
if (!character || !session) return { status: "error", message: "Select a character before queuing." };
const specialization = specializationById(character.specializationId);
if ((character.categoryId !== "rom" && specialization?.role !== role)
|| !classCanFillPartyRole(character.classId, role)) {
return { status: "error", message: "That role is unavailable for this character." };
}
if (group && (session.kind !== "account" || !session.accessToken)) {
return { status: "error", message: "Reconnect before queuing with your group." };
}
try {
if (group) {
if (!await options.updateOnlineMember(session, character, role)) {
return { status: "error", message: options.failureNotice() || "The group role could not be updated." };
}
if (group.leaderAccountId !== session.ownerId) return { status: "waiting" };
}
options.onProgress("rolling", null);
const dungeon = options.mode === "regular"
? randomRegularDungeon(undefined, dependencies.random)
: randomDungeonByCategory("bite-sized", undefined, dependencies.random);
if (!dungeon) return { status: "error", message: "No dungeon is currently ready for this queue." };
if (!dependencies.contentIsUsable(dungeon.id)) {
options.onProgress("downloading", dungeon);
await dependencies.installPack(dungeonContentPackId(dungeon.id));
if (!dependencies.contentIsUsable(dungeon.id)) {
return { status: "error", message: `${dungeon.title} could not be verified after downloading.` };
}
}
const difficultyId = dungeonDefinitionById(dungeon.id)?.defaultDifficultyId;
options.onProgress("entering", dungeon);
if (group) {
const started = await options.startOnlineActivity(session, {
type: "dungeon",
contentId: dungeon.id,
difficultyId,
bossDifficulty: 1,
partySize: 5,
fillWithAi: options.fillWithAi,
includeBetaSpecs: options.includeBetaSpecs,
});
return started
? { status: "started", dungeonTitle: dungeon.title }
: { status: "error", message: options.failureNotice() || "The group dungeon could not be started." };
}
await dependencies.installDungeonRuntime();
const entered = options.enterDungeon(dungeon.id, role, {
difficultyId,
bossDifficulty: 1,
freshRun: true,
});
return entered
? { status: "entered", dungeonTitle: dungeon.title }
: { status: "error", message: options.failureNotice() || `Could not enter ${dungeon.title}.` };
} catch (error) {
return { status: "error", message: error instanceof Error ? error.message : "The random dungeon queue failed." };
}
}
-20
View File
@@ -5,7 +5,6 @@ import { installDungeonShellRuntime } from "./dungeonShellRuntime";
import { installManastormShellRuntime } from "./manastormShellRuntime";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
import { DUNGEON_MANIFEST } from "../game/dungeonManifest";
import { dungeonById } from "../game/dungeonCatalog";
import { useGameStore } from "../game/store";
import { useManastormStore } from "../game/manastormStore";
import { createEmptyManastormProgress, recordManastormCheckpoint } from "../game/manastormProgress";
@@ -95,7 +94,6 @@ describe("shell store", () => {
selectedCharacterId: null,
activeCharacter: null,
activeDungeonRole: "healer",
dungeonSelectionId: null,
notice: "",
});
useGameStore.getState().resetAtEntrance();
@@ -137,24 +135,6 @@ describe("shell store", () => {
expect(useGameStore.getState().distanceTravelled).toBe(42);
});
it("opens random full and authored Bite-Sized dungeons in the existing selector", () => {
prepareSelectedCharacter();
useShellStore.getState().openMainMenu();
expect(useShellStore.getState().openDungeons("random")).toBe(true);
const fullDungeon = dungeonById(useShellStore.getState().dungeonSelectionId ?? "");
expect(fullDungeon?.available).toBe(true);
expect(["wow", "rom"]).toContain(fullDungeon?.category);
useShellStore.getState().returnToMainMenu();
expect(useShellStore.getState().openDungeons("random-bite-sized")).toBe(true);
expect(useShellStore.getState().dungeonSelectionId).toBe("bite-sized-stormwind-stockade");
useShellStore.getState().returnToMainMenu();
expect(useShellStore.getState().openDungeons()).toBe(true);
expect(useShellStore.getState().dungeonSelectionId).toBeNull();
});
it("mounts the game and resets it only after choosing the available dungeon", () => {
prepareSelectedCharacter();
useShellStore.getState().openMainMenu();
+3 -16
View File
@@ -38,7 +38,6 @@ import {
import type { ContentCategoryId } from "./contentCategories";
import type { CharacterDraft, CharacterProfile, PlayerSession, ShellPhase } from "./types";
import type { DungeonId } from "../game/dungeonRegistry";
import { randomDungeonByCategory, randomRegularDungeon } from "../game/dungeonCatalog";
import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults";
import type { InventoryItem } from "../game/lootTypes";
import { emptyBossCardCollection, type BossCardCollection } from "../game/bossCards";
@@ -78,7 +77,6 @@ export interface ShellState {
draft: CharacterDraft;
notice: string;
activeDungeonId: DungeonId;
dungeonSelectionId: DungeonId | null;
activeDungeonRole: PartyRole;
combatPresentationSettings: CombatPresentationSettings;
beginSession: (session: PlayerSession) => void;
@@ -92,7 +90,7 @@ export interface ShellState {
createDraftCharacter: () => boolean;
deleteSelectedCharacter: () => void;
openMainMenu: () => boolean;
openDungeons: (selection?: "random" | "random-bite-sized") => boolean;
openDungeons: () => boolean;
openManastorms: () => boolean;
openArenas: () => boolean;
openGmAdmin: () => boolean;
@@ -168,7 +166,6 @@ const restored = restoredState();
export const useShellStore = create<ShellState>((set, get) => ({
...restored,
activeDungeonId: DEFAULT_DUNGEON_ID,
dungeonSelectionId: null,
activeDungeonRole: "healer",
activeCharacter: null,
draft: defaultDraft(),
@@ -322,7 +319,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ phase: "main-menu", activeCharacter: null, notice: "" });
return true;
},
openDungeons: (selection) => {
openDungeons: () => {
const { session, selectedCharacterId, characters } = get();
if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
@@ -333,17 +330,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." });
return false;
}
const dungeon = selection === "random" ? randomRegularDungeon()
: selection === "random-bite-sized" ? randomDungeonByCategory("bite-sized")
: null;
if (selection && !dungeon) {
set({ notice: selection === "random"
? "No regular dungeon is ready to enter."
: "No regular Bite-Sized dungeon is ready to enter." });
return false;
}
set({ phase: "dungeons", activeCharacter: null, dungeonSelectionId: dungeon?.id ?? null,
notice: dungeon ? `Randomly selected ${dungeon.title}.` : "" });
set({ phase: "dungeons", activeCharacter: null, notice: "" });
return true;
},
openManastorms: () => {
+9
View File
@@ -2402,6 +2402,15 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
.main-menu-gm-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.main-menu-gm-grid .main-menu-mode { min-height: 78px; border-color: rgba(210,177,109,.34); background: linear-gradient(135deg, rgba(103,76,39,.28), rgba(29,39,40,.58)); }
.main-menu-gm-grid .main-menu-mode:disabled { cursor: not-allowed; opacity: .48; filter: saturate(.35); }
.main-menu-random-modal { width: min(450px, 100%); max-height: 100%; overflow-y: auto; padding: clamp(18px, 3vw, 28px); border: 1px solid rgba(159,217,223,.48); border-radius: 8px; background: linear-gradient(150deg, rgba(21,45,48,.98), rgba(5,15,20,.99)); box-shadow: 0 30px 80px #000, inset 3px 0 0 var(--main-menu-ice); }
.main-menu-random-modal h2 { margin: 5px 0 10px; color: #f4eddd; font: 28px Georgia, serif; font-weight: 400; }
.main-menu-random-modal > p:not(.front-kicker) { color: #b9c9c8; font-size: 12px; line-height: 1.5; }
.main-menu-random-modal__roles { display: grid; grid-template-columns: repeat(auto-fit, minmax(105px, 1fr)); gap: 8px; margin: 18px 0; }
.main-menu-random-modal__role { min-height: 48px; justify-content: center; border-color: rgba(159,217,223,.35); }
.main-menu-random-modal__role[aria-pressed="true"] { border-color: var(--main-menu-ice); background: rgba(64,116,121,.5); color: #fff; }
.main-menu-random-modal__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 18px; }
.main-menu-random-modal__actions .front-button { justify-content: center; min-height: 44px; }
.main-menu-random-modal__progress { min-height: 18px; }
/* Dungeon browser */
.dungeon-select-screen {
+1 -4
View File
@@ -68,15 +68,12 @@ export function DungeonSelectScreen() {
const enterDungeon = useShellStore((state) => state.enterDungeon);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
const notice = useShellStore((state) => state.notice);
const dungeonSelectionId = useShellStore((state) => state.dungeonSelectionId);
const session = useShellStore((state) => state.session);
const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group);
const updateOnlineMember = useOnlineGroupStore((state) => state.updateMember);
const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity);
const character = activeCharacter ?? rosterCharacter;
const firstAvailable = DUNGEON_CATALOG.find((dungeon) => dungeon.id === dungeonSelectionId)
?? DUNGEON_CATALOG.find((dungeon) => dungeon.available)
?? DUNGEON_CATALOG[0];
const firstAvailable = DUNGEON_CATALOG.find((dungeon) => dungeon.available) ?? DUNGEON_CATALOG[0];
const [selectedCategory, setSelectedCategory] = useState<DungeonCategoryId>(firstAvailable?.category ?? "wow");
const [selectedDungeonId, setSelectedDungeonId] = useState<DungeonId | "">(firstAvailable?.id ?? "");
const [rolePickerOpen, setRolePickerOpen] = useState(false);
+100 -6
View File
@@ -1,7 +1,12 @@
import { lazy, Suspense, useCallback, useMemo, useState } from "react";
import { classById } from "../app/characterCatalog";
import { queueRandomDungeon, type RandomDungeonQueueMode, type RandomDungeonQueueStage } from "../app/randomDungeonQueue";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useContentManagerSnapshot } from "../content/useContentManager";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { useCompanionPartyPreferences } from "../game/companionPartyPreferences";
import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyStore";
import { specializationById, specializationIconUrl } from "../game/specializationCatalog";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
@@ -125,23 +130,78 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
readonly onOpenEditor: (editor: MainMenuEditor) => void;
}) {
const [gmMenuOpen, setGmMenuOpen] = useState(false);
const [randomQueueMode, setRandomQueueMode] = useState<RandomDungeonQueueMode | null>(null);
const [selectedRole, setSelectedRole] = useState<PartyRole | null>(null);
const [queueing, setQueueing] = useState(false);
const [queueStage, setQueueStage] = useState<RandomDungeonQueueStage>("rolling");
const [queuedDungeonTitle, setQueuedDungeonTitle] = useState("");
const [queueError, setQueueError] = useState("");
const activeCharacter = useShellStore((state) => state.activeCharacter);
const rosterCharacter = useShellStore(selectedCharacter);
const openDungeons = useShellStore((state) => state.openDungeons);
const openRandomDungeon = useCallback(() => openDungeons("random"), [openDungeons]);
const openRandomBiteSizedDungeon = useCallback(() => openDungeons("random-bite-sized"), [openDungeons]);
const openRandomDungeon = useCallback(() => { setRandomQueueMode("regular"); setSelectedRole(null); setQueueError(""); }, []);
const openRandomBiteSizedDungeon = useCallback(() => { setRandomQueueMode("bite-sized"); setSelectedRole(null); setQueueError(""); }, []);
const openManastorms = useShellStore((state) => state.openManastorms);
const openArenas = useShellStore((state) => state.openArenas);
const openClassHall = useShellStore((state) => state.openClassHall);
const returnToCharacters = useShellStore((state) => state.returnToCharacters);
const notice = useShellStore((state) => state.notice);
const session = useShellStore((state) => state.session);
const enterDungeon = useShellStore((state) => state.enterDungeon);
const openGmAdmin = useShellStore((state) => state.openGmAdmin);
const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group);
const fillWithAi = useOnlineGroupStore((state) => state.fillWithAi);
const updateOnlineMember = useOnlineGroupStore((state) => state.updateMember);
const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity);
const includeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs);
const contentState = useContentManagerSnapshot();
const character = activeCharacter ?? rosterCharacter;
const isRom = character?.categoryId === "rom";
const isGm = sessionCanUseGmTools(session);
const savedSpecialization = specializationById(character?.specializationId);
const availableRoles = character
? isRom ? partyRolesForClass(character.classId) : savedSpecialization ? [savedSpecialization.role] : []
: [];
const actions = useMemo<MenuAction[]>(() => gmMenuOpen ? [
const closeRandomQueue = useCallback(() => {
if (!queueing) setRandomQueueMode(null);
}, [queueing]);
const startRandomQueue = useCallback(async () => {
if (!randomQueueMode || !selectedRole || queueing) return;
setQueueing(true);
setQueueStage("rolling");
setQueueError("");
setQueuedDungeonTitle("");
const result = await queueRandomDungeon({
mode: randomQueueMode,
role: selectedRole,
character,
session,
group: onlineGroup,
includeBetaSpecs,
fillWithAi,
onProgress: (stage, dungeon) => { setQueueStage(stage); setQueuedDungeonTitle(dungeon?.title ?? ""); },
enterDungeon: (dungeonId, role, settings) => enterDungeon(dungeonId, role, settings),
updateOnlineMember,
startOnlineActivity: (groupSession, input) => startOnlineActivity(groupSession, input),
failureNotice: () => useOnlineGroupStore.getState().error || useShellStore.getState().notice,
});
if (result.status === "error") setQueueError(result.message);
else if (result.status === "waiting") {
useShellStore.getState().setNotice(`Ready as ${PARTY_ROLE_LABELS[selectedRole]}. Waiting for the group leader to start.`);
setRandomQueueMode(null);
} else if (result.status === "started") {
useShellStore.getState().setNotice(`Starting ${result.dungeonTitle} with your group.`);
setRandomQueueMode(null);
}
setQueueing(false);
}, [character, enterDungeon, fillWithAi, includeBetaSpecs, onlineGroup, queueing, randomQueueMode, selectedRole, session, startOnlineActivity, updateOnlineMember]);
const actions = useMemo<MenuAction[]>(() => randomQueueMode ? [
...availableRoles.map((role) => ({ id: `random-queue-role-${role}`, run: () => setSelectedRole(role), enabled: !queueing })),
{ id: "random-queue-confirm", run: () => void startRandomQueue(), enabled: Boolean(selectedRole) && !queueing },
{ id: "random-queue-cancel", run: closeRandomQueue, enabled: !queueing },
] : gmMenuOpen ? [
...(isGm ? [{ id: "gm-menu-gm-tools", run: openGmAdmin, neighbors: { up: "gm-menu-close" } }] : []),
{ id: "gm-menu-boss-testing", run: onOpenBossTesting, ...(!isGm ? { neighbors: { up: "gm-menu-close" } } : {}) },
{ id: "gm-menu-companion-readiness", run: onOpenReadiness },
@@ -184,11 +244,11 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
] : []),
{ id: "mode-downloads", run: onOpenDownloads },
{ id: "back-to-roster", run: returnToCharacters, neighbors: { down: "mode-dungeons" } },
], [character, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBalanceTuning, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, openRandomBiteSizedDungeon, openRandomDungeon, returnToCharacters]);
], [availableRoles, character, closeRandomQueue, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBalanceTuning, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, openRandomBiteSizedDungeon, openRandomDungeon, queueing, randomQueueMode, returnToCharacters, selectedRole, startRandomQueue]);
const controller = useMenuController(actions, {
columns: 2,
initialId: gmMenuOpen ? (isGm ? "gm-menu-gm-tools" : "gm-menu-boss-testing") : "mode-dungeons",
onBack: gmMenuOpen ? () => setGmMenuOpen(false) : returnToCharacters,
initialId: randomQueueMode ? `random-queue-role-${availableRoles[0]}` : gmMenuOpen ? (isGm ? "gm-menu-gm-tools" : "gm-menu-boss-testing") : "mode-dungeons",
onBack: randomQueueMode ? closeRandomQueue : gmMenuOpen ? () => setGmMenuOpen(false) : returnToCharacters,
});
const characterClass = character ? classById(character.classId) : null;
@@ -315,6 +375,40 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
</main>
<ControllerLegend back />
{randomQueueMode && (
<div className="front-modal-backdrop">
<section className="main-menu-random-modal" role="dialog" aria-modal="true" aria-labelledby="random-queue-title">
<p className="front-kicker">{randomQueueMode === "regular" ? "Random Dungeon" : "Random Regular Bite-Sized Dungeon"}</p>
<h2 id="random-queue-title">Choose your role</h2>
<p>Pick how you want to play, then queue. A dungeon will be rolled and downloaded before you enter.</p>
<div className="main-menu-random-modal__roles" aria-label="Party role">
{availableRoles.map((role, index) => (
<ControllerButton key={role} controlId={`random-queue-role-${role}`}
selectedId={controller.selectedId} select={controller.select} autoFocus={index === 0}
className="front-button main-menu-random-modal__role" type="button"
aria-pressed={selectedRole === role} disabled={queueing} onClick={() => setSelectedRole(role)}>
{PARTY_ROLE_LABELS[role]}
</ControllerButton>
))}
</div>
{!availableRoles.length && <p className="front-notice">Choose a specialization in your Class Hall before queuing.</p>}
{queueing && <p className="main-menu-random-modal__progress" role="status" aria-live="polite">
{queueStage === "rolling" ? "Rolling a dungeon…" : queueStage === "downloading"
? `Downloading ${queuedDungeonTitle}${contentState.installingPackId && contentState.totalBytes > 0
? ` — ${Math.round((contentState.downloadedBytes / contentState.totalBytes) * 100)}%` : "…"}`
: `Entering ${queuedDungeonTitle}…`}
</p>}
{queueError && <p className="front-notice" role="alert">{queueError}</p>}
<div className="main-menu-random-modal__actions">
<ControllerButton controlId="random-queue-cancel" selectedId={controller.selectedId} select={controller.select}
className="front-button" type="button" disabled={queueing} onClick={closeRandomQueue}>Cancel</ControllerButton>
<ControllerButton controlId="random-queue-confirm" selectedId={controller.selectedId} select={controller.select}
className="front-button front-button--primary" type="button" disabled={!selectedRole || queueing}
onClick={() => void startRandomQueue()}>{queueing ? "Queuing…" : "Queue"}</ControllerButton>
</div>
</section>
</div>
)}
{gmMenuOpen && (
<div className="front-modal-backdrop">
<section className="main-menu-gm-modal" role="dialog" aria-modal="true" aria-labelledby="gm-menu-title">