diff --git a/src/app/OnlineGroupCoordinator.test.ts b/src/app/OnlineGroupCoordinator.test.ts index 988b3aa7..f867a947 100644 --- a/src/app/OnlineGroupCoordinator.test.ts +++ b/src/app/OnlineGroupCoordinator.test.ts @@ -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); diff --git a/src/app/OnlineGroupCoordinator.tsx b/src/app/OnlineGroupCoordinator.tsx index fe0d1ba6..2229f47d 100644 --- a/src/app/OnlineGroupCoordinator.tsx +++ b/src/app/OnlineGroupCoordinator.tsx @@ -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; readonly prepareRandomizedDungeonContent: (seed: string) => Promise; readonly installDungeonRuntime: () => Promise; 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 { 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); } diff --git a/src/app/randomDungeonQueue.test.ts b/src/app/randomDungeonQueue.test.ts new file mode 100644 index 00000000..f90d29f9 --- /dev/null +++ b/src/app/randomDungeonQueue.test.ts @@ -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 { + 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(); + }); +}); diff --git a/src/app/randomDungeonQueue.ts b/src/app/randomDungeonQueue.ts new file mode 100644 index 00000000..e6a58cc4 --- /dev/null +++ b/src/app/randomDungeonQueue.ts @@ -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; + readonly startOnlineActivity: (session: PlayerSession, input: StartOnlineGroupActivityInput) => Promise; + readonly failureNotice: () => string; +} + +interface RandomDungeonQueueDependencies { + readonly random: () => number; + readonly contentIsUsable: (dungeonId: string) => boolean; + readonly installPack: (packId: string) => Promise; + readonly installDungeonRuntime: () => Promise; +} + +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 { + 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." }; + } +} diff --git a/src/app/shellStore.test.ts b/src/app/shellStore.test.ts index 2104860c..f29520d8 100644 --- a/src/app/shellStore.test.ts +++ b/src/app/shellStore.test.ts @@ -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(); diff --git a/src/app/shellStore.ts b/src/app/shellStore.ts index 849cd268..b6444a06 100644 --- a/src/app/shellStore.ts +++ b/src/app/shellStore.ts @@ -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((set, get) => ({ ...restored, activeDungeonId: DEFAULT_DUNGEON_ID, - dungeonSelectionId: null, activeDungeonRole: "healer", activeCharacter: null, draft: defaultDraft(), @@ -322,7 +319,7 @@ export const useShellStore = create((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((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: () => { diff --git a/src/styles.css b/src/styles.css index 610e48b3..1ecedff2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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 { diff --git a/src/ui/DungeonSelectScreen.tsx b/src/ui/DungeonSelectScreen.tsx index 5afa7eb3..675b0de4 100644 --- a/src/ui/DungeonSelectScreen.tsx +++ b/src/ui/DungeonSelectScreen.tsx @@ -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(firstAvailable?.category ?? "wow"); const [selectedDungeonId, setSelectedDungeonId] = useState(firstAvailable?.id ?? ""); const [rolePickerOpen, setRolePickerOpen] = useState(false); diff --git a/src/ui/MainMenuScreen.tsx b/src/ui/MainMenuScreen.tsx index 16f2ce80..54cb49a2 100644 --- a/src/ui/MainMenuScreen.tsx +++ b/src/ui/MainMenuScreen.tsx @@ -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(null); + const [selectedRole, setSelectedRole] = useState(null); + const [queueing, setQueueing] = useState(false); + const [queueStage, setQueueStage] = useState("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(() => 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(() => 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 + {randomQueueMode && ( +
+
+

{randomQueueMode === "regular" ? "Random Dungeon" : "Random Regular Bite-Sized Dungeon"}

+

Choose your role

+

Pick how you want to play, then queue. A dungeon will be rolled and downloaded before you enter.

+
+ {availableRoles.map((role, index) => ( + setSelectedRole(role)}> + {PARTY_ROLE_LABELS[role]} + + ))} +
+ {!availableRoles.length &&

Choose a specialization in your Class Hall before queuing.

} + {queueing &&

+ {queueStage === "rolling" ? "Rolling a dungeon…" : queueStage === "downloading" + ? `Downloading ${queuedDungeonTitle}${contentState.installingPackId && contentState.totalBytes > 0 + ? ` — ${Math.round((contentState.downloadedBytes / contentState.totalBytes) * 100)}%` : "…"}` + : `Entering ${queuedDungeonTitle}…`} +

} + {queueError &&

{queueError}

} +
+ Cancel + void startRandomQueue()}>{queueing ? "Queuing…" : "Queue"} +
+
+
+ )} {gmMenuOpen && (