import { useMemo, useState } from "react"; import { BiteSizedBoardFilters, useBiteSizedBoardSelection } from "./BiteSizedBoardFilters"; 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, legacyBiteSizedRunSelection } 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 { DungeonLeaderboard } from "./DungeonLeaderboard"; import { dungeonLeaderboardCatalog as leaderboardCatalog } from "../game/dungeonLeaderboardCatalog"; import { useDungeonLeaderboard } from "./useDungeonLeaderboard"; import type { DungeonClearFilter } from "../game/dungeonRunTypes"; import { DUNGEON_LEADERBOARD_PAGE_SIZE } from "../app/dungeonLeaderboardClient"; import { ControllerLegend, FrontSurface } from "./FrontSurface"; import { OnlineGroupPanel } from "./OnlineGroupPanel"; import { useOnlineGroupStore } from "../app/onlineGroupStore"; import { specializationById } from "../game/specializationCatalog"; import { DUNGEON_RELEASE_STAGE_LABELS } from "../game/dungeonReleaseStage"; import { useDungeonReports } from "./useDungeonReports"; import { DungeonDifficultySlider } from "./DungeonDifficultySlider"; import { bossDifficultyLabel } from "../game/dungeonDifficulty"; import { useCompanionPartyPreferences } from "../game/companionPartyPreferences"; import { CompanionPoolControls } from "./CompanionPoolControls"; import { CompanionReadinessScreen } from "./CompanionReadinessScreen"; import { dungeonContentPackId } from "../game/dungeonContentSource"; import { prepareBiteSizedLayoutContent } from "../app/biteSizedLayoutContent"; 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 reports = useDungeonReports(); 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 [preparingSpot, setPreparingSpot] = 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 [clearFilter, setClearFilter] = useState("final"); const [difficultyId, setDifficultyId] = useState(""); const [bossDifficulty, setBossDifficulty] = useState(1); const [leaderboardBossId, setLeaderboardBossId] = useState(""); const includeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs); const [expandedRuns, setExpandedRuns] = useState>(new Set()); const [leaderboardOpen, setLeaderboardOpen] = useState(false); const [readinessOpen, setReadinessOpen] = useState(false); const leaderboardDifficulty = selectedDefinition?.difficultyVariants.find((variant) => variant.id === difficultyId) ?? selectedDefinition?.difficultyVariants.find((variant) => variant.id === selectedDefinition.defaultDifficultyId); const bossOptions = leaderboardCatalog.dungeons.find((entry) => entry.id === selectedDungeon?.id)?.bosses ?? []; const hasBossBoards = (leaderboardCatalog.dungeons.find((entry) => entry.id === selectedDungeon?.id)?.difficulties .find((entry) => entry.id === leaderboardDifficulty?.id)?.finalBossIds?.length ?? 0) > 1; const selectedBoss = hasBossBoards ? bossOptions.find((boss) => boss.id === leaderboardBossId) ?? bossOptions[0] : undefined; const cycleBoss = () => { const index = bossOptions.findIndex((boss) => boss.id === selectedBoss?.id); if (hasBossBoards && bossOptions.length) setLeaderboardBossId(bossOptions[(index + 1) % bossOptions.length].id); }; const spotBoard = useBiteSizedBoardSelection(selectedDungeon?.id ?? ""); const leaderboard = useDungeonLeaderboard(selectedDungeon?.id ?? "", leaderboardDifficulty?.id ?? "normal", clearFilter, bossDifficulty, selectedBoss?.id, spotBoard.spotId, spotBoard.spotRevision); const toggleRun = (id: string) => setExpandedRuns((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const cycleDifficulty = () => { const variants = selectedDefinition?.difficultyVariants ?? []; const index = variants.findIndex((variant) => variant.id === leaderboardDifficulty?.id); if (variants.length) setDifficultyId(variants[(index + 1) % variants.length].id); }; 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 || preparingSpot) return; if (!onlineGroup || !session || session.kind !== "account") { setPreparingSpot(true); useShellStore.getState().setNotice("Preparing dungeon and creature downloads..."); try { const checkpoint = character.dungeonRunCheckpoint; const resume = checkpoint?.dungeonId === selectedDungeon.id && checkpoint.run?.finishedAt == null && (leaderboardDifficulty?.id === undefined || checkpoint.difficultyId === leaderboardDifficulty.id) && checkpoint.run?.bossDifficulty === bossDifficulty ? checkpoint.run : undefined; const runId = resume?.id ?? crypto.randomUUID(); const biteSizedSelection = await prepareBiteSizedLayoutContent(selectedDungeon.id, runId, resume ? resume.biteSizedSelection ?? legacyBiteSizedRunSelection(selectedDungeon.id, runId) : undefined); enterDungeon(selectedDungeon.id, role, { difficultyId: leaderboardDifficulty?.id, bossDifficulty, runId, ...(biteSizedSelection ? { biteSizedSelection } : {}) }); } catch (error) { useShellStore.getState().setNotice(error instanceof Error ? error.message : "Could not prepare the spot."); } finally { setPreparingSpot(false); } 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, difficultyId: leaderboardDifficulty?.id, bossDifficulty, partySize: 5, includeBetaSpecs, }); }; 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(dungeonContentPackId(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}` } : {}), right: "leaderboard-toggle" }, })), { id: "leaderboard-toggle", run: () => setLeaderboardOpen((open) => !open) }, ...(leaderboardOpen ? [ { id: "leaderboard-final", run: () => setClearFilter("final") }, { id: "leaderboard-full", run: () => setClearFilter("full") }, { id: "leaderboard-difficulty", run: cycleDifficulty, enabled: (selectedDefinition?.difficultyVariants.length ?? 0) > 1 }, { id: "leaderboard-boss", run: cycleBoss, enabled: hasBossBoards }, { id: "leaderboard-refresh", run: leaderboard.refresh }, ] : []), { id: "boss-difficulty-down", run: () => setBossDifficulty((value) => Math.max(1, value - 1)), enabled: bossDifficulty > 1 }, { id: "boss-difficulty-up", run: () => setBossDifficulty((value) => Math.min(10, value + 1)), enabled: bossDifficulty < 10 }, ...(leaderboardOpen ? [ ...leaderboard.data.entries.map((entry) => ({ id: `leaderboard-run-${entry.id}`, run: () => toggleRun(entry.id) })), { id: "leaderboard-prev", run: () => leaderboard.setOffset(Math.max(0, leaderboard.offset - DUNGEON_LEADERBOARD_PAGE_SIZE)), enabled: !leaderboard.loading && leaderboard.offset > 0 }, { id: "leaderboard-next", run: () => leaderboard.setOffset(leaderboard.offset + DUNGEON_LEADERBOARD_PAGE_SIZE), enabled: !leaderboard.loading && leaderboard.offset + DUNGEON_LEADERBOARD_PAGE_SIZE < leaderboard.data.total }, ] : []), ...(rolePickerOpen ? availableRoles.map((role) => ({ id: `dungeon-role-${role}`, run: () => enterAsRole(role), enabled: !preparingSpot, })) : [{ 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, preparingSpot, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon, selectedDefinition, leaderboard, leaderboardOpen, expandedRuns, leaderboardDifficulty, bossDifficulty, hasBossBoards, selectedBoss]); 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; if (readinessOpen) return setReadinessOpen(false)} />; return (
Main menu / Dungeons
{character && race && characterClass && (

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

)}
{selectedDungeon ? ( <>

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

{selectedDungeon.title}

{DUNGEON_RELEASE_STAGE_LABELS[selectedDungeon.releaseStage]}

{selectedDungeon.summary}

setLeaderboardOpen((open) => !open)} > {leaderboardOpen ? "Hide leaderboard" : "View leaderboard"} {!canStart && selectedDungeon.availabilityReason && (

{selectedDungeon.availabilityReason}

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

Select this expedition to download its required files.

)} setReadinessOpen(true)} compact /> {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
) : ( <>
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}

}
{leaderboardOpen && (
1 ? cycleDifficulty : undefined} bossLabel={selectedBoss?.name} onBoss={hasBossBoards ? cycleBoss : undefined} selectedId={controller.selectedId} select={controller.select} />
)} ) : (

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={} /> ); }