import { useMemo, useState } from "react"; import { dungeonContentIsUsable, installContentPack } from "../content/contentManager"; import { useContentManagerSnapshot } from "../content/useContentManager"; import { classById, raceById } from "../app/characterCatalog"; import { installDungeonShellRuntime } from "../app/dungeonShellRuntime"; import { selectedCharacter, useShellStore } from "../app/shellStore"; import { DualDisplayFrame } from "../components/DualDisplayFrame"; import { DUNGEON_CATALOG, DUNGEON_CATEGORIES, dungeonsByCategory, type DungeonCategoryId, type DungeonId, } from "../game/dungeonCatalog"; import { dungeonDefinitionById } from "../game/dungeonRegistry"; import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole, } from "../game/partyStore"; import { useMenuController, type MenuAction } from "../input/useMenuController"; import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel"; import { ControllerButton } from "./ControllerButton"; import { DungeonMapGraphic } from "./DungeonMapGraphic"; import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface"; import { OnlineGroupPanel } from "./OnlineGroupPanel"; import { useOnlineGroupStore } from "../app/onlineGroupStore"; import { specializationById } from "../game/specializationCatalog"; installDungeonShellRuntime(); const ROLE_DETAILS: Readonly> = { tank: { sigil: "T", summary: "Lead from the front with a healer and three damage dealers at your side.", }, healer: { sigil: "+", summary: "Keep the group alive while one tank and three damage dealers handle the fight.", }, damage: { sigil: "D", summary: "Focus down enemies with a tank, a dedicated healer, and two other damage dealers.", }, }; interface PendingDungeonDownload { readonly dungeonId: DungeonId; readonly role: PartyRole | null; } export function DungeonSelectScreen() { const contentState = useContentManagerSnapshot(); const activeCharacter = useShellStore((state) => state.activeCharacter); const rosterCharacter = useShellStore(selectedCharacter); const enterDungeon = useShellStore((state) => state.enterDungeon); const returnToMainMenu = useShellStore((state) => state.returnToMainMenu); const notice = useShellStore((state) => state.notice); 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.available) ?? DUNGEON_CATALOG[0]; const [selectedCategory, setSelectedCategory] = useState(firstAvailable?.category ?? "wow"); const [selectedDungeonId, setSelectedDungeonId] = useState(firstAvailable?.id ?? ""); const [rolePickerOpen, setRolePickerOpen] = useState(false); const [pendingDownload, setPendingDownload] = useState(null); const [downloadError, setDownloadError] = useState(""); const [downloading, setDownloading] = useState(false); const categoryDungeons = dungeonsByCategory(selectedCategory); const firstCategoryDungeon = categoryDungeons.find((dungeon) => dungeon.available) ?? categoryDungeons[0]; const selectedDungeon = categoryDungeons.find((dungeon) => dungeon.id === selectedDungeonId) ?? firstCategoryDungeon ?? null; const selectedDefinition = selectedDungeon ? dungeonDefinitionById(selectedDungeon.id) : null; const selectedPackInstalled = selectedDungeon ? dungeonContentIsUsable(selectedDungeon.id) : false; const canStart = Boolean(selectedDungeon?.available && selectedPackInstalled); const savedSpecialization = specializationById(character?.specializationId); const availableRoles = character ? character.categoryId === "rom" ? partyRolesForClass(character.classId) : savedSpecialization ? [savedSpecialization.role] : [] : []; const selectDungeon = (dungeonId: DungeonId) => { setSelectedDungeonId(dungeonId); setRolePickerOpen(false); setDownloadError(""); const dungeon = DUNGEON_CATALOG.find((candidate) => candidate.id === dungeonId); if (dungeon?.available && !dungeonContentIsUsable(dungeonId)) { setPendingDownload({ dungeonId, role: null }); } }; const selectCategory = (category: DungeonCategoryId) => { const dungeons = dungeonsByCategory(category); const nextDungeon = dungeons.find((dungeon) => dungeon.available) ?? dungeons[0]; setSelectedCategory(category); setSelectedDungeonId(nextDungeon?.id ?? ""); setRolePickerOpen(false); }; const continueAsRole = async (role: PartyRole) => { if (!selectedDungeon?.available || !character) return; if (!onlineGroup || !session || session.kind !== "account") { enterDungeon(selectedDungeon.id, role); return; } if (!await updateOnlineMember(session, character, role)) return; if (onlineGroup.leaderAccountId !== session.ownerId) { useShellStore.getState().setNotice(`Ready as ${PARTY_ROLE_LABELS[role]}. Waiting for the group leader to start.`); return; } await startOnlineActivity(session, { type: "dungeon", contentId: selectedDungeon.id, partySize: 5, }); }; const requestRolePicker = () => { if (!selectedDungeon?.available) return; if (!dungeonContentIsUsable(selectedDungeon.id)) { setDownloadError(""); setPendingDownload({ dungeonId: selectedDungeon.id, role: null }); return; } setRolePickerOpen(true); }; const enterAsRole = async (role: PartyRole) => { if (!selectedDungeon?.available || !character) return; if (!dungeonContentIsUsable(selectedDungeon.id)) { setDownloadError(""); setPendingDownload({ dungeonId: selectedDungeon.id, role }); return; } await continueAsRole(role); }; const confirmDownload = async () => { if (!pendingDownload || downloading) return; setDownloading(true); setDownloadError(""); try { await installContentPack(`dungeon:${pendingDownload.dungeonId}`); if (!dungeonContentIsUsable(pendingDownload.dungeonId)) { throw new Error("The downloaded expedition could not be verified on this device."); } const role = pendingDownload.role; setPendingDownload(null); if (role) await continueAsRole(role); else setRolePickerOpen(true); } catch (error) { setDownloadError(error instanceof Error ? error.message : "The expedition download failed."); } finally { setDownloading(false); } }; const actions = useMemo(() => pendingDownload ? [ { id: "dungeon-download-cancel", run: () => setPendingDownload(null), enabled: !downloading }, { id: "dungeon-download-confirm", run: () => void confirmDownload(), enabled: !downloading }, ] : [ ...DUNGEON_CATEGORIES.map((category, index) => ({ id: `dungeon-category-${category.id}`, run: () => selectCategory(category.id), neighbors: { left: `dungeon-category-${DUNGEON_CATEGORIES[(index + DUNGEON_CATEGORIES.length - 1) % DUNGEON_CATEGORIES.length].id}`, right: `dungeon-category-${DUNGEON_CATEGORIES[(index + 1) % DUNGEON_CATEGORIES.length].id}`, down: firstCategoryDungeon ? `dungeon-${firstCategoryDungeon.id}` : "dungeon-back", }, })), ...categoryDungeons.map((dungeon, index) => ({ id: `dungeon-${dungeon.id}`, run: () => selectDungeon(dungeon.id), enabled: true, neighbors: index === 0 ? { up: `dungeon-category-${selectedCategory}` } : undefined, })), ...(rolePickerOpen ? availableRoles.map((role) => ({ id: `dungeon-role-${role}`, run: () => enterAsRole(role), })) : [{ id: "choose-dungeon-role", run: requestRolePicker, enabled: Boolean(selectedDungeon?.available), }]), ...(rolePickerOpen ? [{ id: "dungeon-role-back", run: () => setRolePickerOpen(false), }] : []), { id: "dungeon-back", run: returnToMainMenu }, ], [availableRoles, categoryDungeons, downloading, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon]); const controller = useMenuController(actions, { initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back", onBack: pendingDownload ? () => { if (!downloading) setPendingDownload(null); } : rolePickerOpen ? () => setRolePickerOpen(false) : returnToMainMenu, }); const race = character ? raceById(character.raceId) : null; const characterClass = character ? classById(character.classId) : null; return (
Main menu / Dungeons Choose an expedition
{character && race && characterClass && (

{character.name} Level {character.level} {race.name} {characterClass.name}

)}
{selectedDungeon ? ( <>
{selectedDefinition ? ( ) : ( )}

{canStart ? "Playable expedition" : selectedDungeon.available ? "Content required" : "In development"}

{selectedDungeon.title}

{selectedDungeon.summary}

{!canStart && selectedDungeon.availabilityReason && (

{selectedDungeon.availabilityReason}

)} {selectedDungeon.available && !selectedPackInstalled && (

Select this expedition to download its required files.

)} {rolePickerOpen && characterClass ? (

Party role

How do you want to play?

{characterClass.name} can enter as {availableRoles.map((role) => PARTY_ROLE_LABELS[role]).join(" or ")}.
{availableRoles.map((role) => ( enterAsRole(role)} > {onlineGroup && session?.ownerId !== onlineGroup.leaderAccountId ? `Ready as ${PARTY_ROLE_LABELS[role]}` : PARTY_ROLE_LABELS[role]} {ROLE_DETAILS[role].summary} ))}
setRolePickerOpen(false)} > Back to dungeon details
) : ( <>
Status
{canStart ? "Available offline" : selectedDungeon.available ? "Download required" : "Coming soon"}
Category
{DUNGEON_CATEGORIES.find((category) => category.id === selectedCategory)?.fullName}
Location
{selectedDungeon.location}
Theme
{selectedDungeon.theme}
Map
{selectedDungeon.mapId}
Starting point
{selectedDungeon.entranceName}
{canStart ? "Choose role" : selectedDungeon.available ? "Download and choose role" : "Unavailable"} {canStart ? "Select your role before entering" : selectedDungeon.available ? "Required files will be verified before play" : "This expedition is not ready yet"} )} {notice &&

{notice}

}
) : (

No expeditions

No dungeons are available yet.

)}
{pendingDownload && (

Download required

Download {DUNGEON_CATALOG.find((dungeon) => dungeon.id === pendingDownload.dungeonId)?.title ?? "this expedition"}?

The map, creatures, and shared dependencies are not available on this device. Download and verify them now, then continue?

{downloading && (

Downloading and verifying files {contentState.totalBytes > 0 ? ` — ${Math.round((contentState.downloadedBytes / contentState.totalBytes) * 100)}%` : "..."}

)} {downloadError &&

{downloadError}

}
setPendingDownload(null)} > Not now void confirmDownload()} > {downloading ? "Downloading..." : downloadError ? "Try again" : "Download and continue"}
)} } bottom={} /> ); }