521 lines
28 KiB
TypeScript
521 lines
28 KiB
TypeScript
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<Record<PartyRole, { readonly sigil: string; readonly summary: string }>> = {
|
|
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<DungeonCategoryId>(firstAvailable?.category ?? "wow");
|
|
const [selectedDungeonId, setSelectedDungeonId] = useState<DungeonId | "">(firstAvailable?.id ?? "");
|
|
const [rolePickerOpen, setRolePickerOpen] = useState(false);
|
|
const [pendingDownload, setPendingDownload] = useState<PendingDungeonDownload | null>(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<DungeonClearFilter>("final");
|
|
const [difficultyId, setDifficultyId] = useState("");
|
|
const [bossDifficulty, setBossDifficulty] = useState(1);
|
|
const [leaderboardBossId, setLeaderboardBossId] = useState("");
|
|
const includeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs);
|
|
const [expandedRuns, setExpandedRuns] = useState<ReadonlySet<string>>(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<MenuAction[]>(() => 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 <CompanionReadinessScreen onClose={() => setReadinessOpen(false)} />;
|
|
return (
|
|
<DualDisplayFrame
|
|
contextLabel="Dungeon details"
|
|
top={
|
|
<FrontSurface className="dungeon-select-screen" ariaLabel="Dungeon selection">
|
|
<header className="dungeon-select-header">
|
|
<div className="dungeon-select-header__breadcrumb">
|
|
<span>Main menu / Dungeons</span>
|
|
</div>
|
|
{character && race && characterClass && (
|
|
<p>
|
|
<b>{character.name}</b>
|
|
<small>Level {character.level} {race.name} {characterClass.name}</small>
|
|
</p>
|
|
)}
|
|
</header>
|
|
|
|
<aside className="dungeon-select-list" aria-label="Available dungeons">
|
|
<header>
|
|
<p className="front-kicker">Dungeon browser</p>
|
|
<h1>Dungeons</h1>
|
|
<small>{categoryDungeons.filter((dungeon) => dungeon.available).length} available</small>
|
|
</header>
|
|
<nav className="dungeon-category-tabs" aria-label="Dungeon categories">
|
|
{DUNGEON_CATEGORIES.map((category) => {
|
|
const count = dungeonsByCategory(category.id).length;
|
|
return (
|
|
<ControllerButton
|
|
key={category.id}
|
|
controlId={`dungeon-category-${category.id}`}
|
|
selectedId={controller.selectedId}
|
|
select={controller.select}
|
|
className={`dungeon-category-tab ${category.id === selectedCategory ? "is-active" : ""}`}
|
|
type="button"
|
|
aria-pressed={category.id === selectedCategory}
|
|
title={category.fullName}
|
|
onClick={() => selectCategory(category.id)}
|
|
>
|
|
<strong>{category.label}</strong>
|
|
<small>{count} {count === 1 ? "dungeon" : "dungeons"}</small>
|
|
</ControllerButton>
|
|
);
|
|
})}
|
|
</nav>
|
|
<div className="dungeon-select-list__items">
|
|
{categoryDungeons.map((dungeon, index) => (
|
|
<ControllerButton
|
|
key={dungeon.id}
|
|
controlId={`dungeon-${dungeon.id}`}
|
|
selectedId={controller.selectedId}
|
|
select={controller.select}
|
|
className={`dungeon-select-row ${dungeon.id === selectedDungeon?.id ? "is-active" : ""} ${(reports.snapshot?.openCounts[dungeon.id] ?? 0) > 0 ? "has-open-reports" : ""}`}
|
|
type="button"
|
|
aria-pressed={dungeon.id === selectedDungeon?.id}
|
|
onClick={() => selectDungeon(dungeon.id)}
|
|
>
|
|
<b aria-hidden="true">{String(index + 1).padStart(2, "0")}</b>
|
|
<span><strong>{dungeon.title}</strong><small>{DUNGEON_RELEASE_STAGE_LABELS[dungeon.releaseStage]} · {dungeon.entranceName}</small></span>
|
|
{(reports.snapshot?.openCounts[dungeon.id] ?? 0) > 0 && (
|
|
<sup className="dungeon-open-report-count" aria-label={`${reports.snapshot!.openCounts[dungeon.id]} open reports`}
|
|
title={`${reports.snapshot!.openCounts[dungeon.id]} open reports`}>{reports.snapshot!.openCounts[dungeon.id]}</sup>
|
|
)}
|
|
<em>{dungeon.available
|
|
? dungeonContentIsUsable(dungeon.id) ? "Ready" : "Download required"
|
|
: "Coming soon"}</em>
|
|
</ControllerButton>
|
|
))}
|
|
</div>
|
|
{reports.error && <small role="status" className="dungeon-report-load-error">Report counts unavailable. {reports.snapshot ? "Showing the last loaded counts." : ""}</small>}
|
|
<ControllerButton
|
|
controlId="dungeon-back"
|
|
selectedId={controller.selectedId}
|
|
select={controller.select}
|
|
className="front-button dungeon-select-back"
|
|
type="button"
|
|
onClick={returnToMainMenu}
|
|
>
|
|
Back to main menu
|
|
</ControllerButton>
|
|
</aside>
|
|
|
|
<main className={`dungeon-select-detail ${leaderboardOpen ? "dungeon-select-detail--leaderboard-open" : ""}`} aria-live="polite">
|
|
{selectedDungeon ? (
|
|
<>
|
|
<section className="dungeon-select-copy">
|
|
<p className="front-kicker">{canStart ? "Playable expedition" : selectedDungeon.available ? "Content required" : "In development"}</p>
|
|
<h2>{selectedDungeon.title}</h2>
|
|
<span className="dungeon-release-stage">{DUNGEON_RELEASE_STAGE_LABELS[selectedDungeon.releaseStage]}</span>
|
|
<p>{selectedDungeon.summary}</p>
|
|
<ControllerButton
|
|
controlId="leaderboard-toggle"
|
|
selectedId={controller.selectedId}
|
|
select={controller.select}
|
|
className="front-button dungeon-select-leaderboard-toggle"
|
|
type="button"
|
|
aria-expanded={leaderboardOpen}
|
|
aria-controls={leaderboardOpen ? "dungeon-select-leaderboard" : undefined}
|
|
onClick={() => setLeaderboardOpen((open) => !open)}
|
|
>
|
|
{leaderboardOpen ? "Hide leaderboard" : "View leaderboard"}
|
|
</ControllerButton>
|
|
<DungeonDifficultySlider value={bossDifficulty} onChange={setBossDifficulty}
|
|
selectedId={controller.selectedId} select={controller.select} />
|
|
{!canStart && selectedDungeon.availabilityReason && (
|
|
<p className="front-notice" role="status">{selectedDungeon.availabilityReason}</p>
|
|
)}
|
|
{selectedDungeon.available && !selectedPackInstalled && (
|
|
<p className="front-notice" role="status">Select this expedition to download its required files.</p>
|
|
)}
|
|
<OnlineGroupPanel />
|
|
<CompanionPoolControls onOpenReadiness={() => setReadinessOpen(true)} compact />
|
|
{rolePickerOpen && characterClass ? (
|
|
<section className="dungeon-role-picker" aria-labelledby="dungeon-role-title">
|
|
<header>
|
|
<p className="front-kicker">Party role</p>
|
|
<h3 id="dungeon-role-title">How do you want to play?</h3>
|
|
<small>{characterClass.name} can enter as {availableRoles.map((role) => PARTY_ROLE_LABELS[role]).join(" or ")}.</small>
|
|
</header>
|
|
<div className="dungeon-role-picker__options">
|
|
{availableRoles.map((role) => (
|
|
<ControllerButton
|
|
key={role}
|
|
controlId={`dungeon-role-${role}`}
|
|
selectedId={controller.selectedId}
|
|
select={controller.select}
|
|
className={`dungeon-role-option dungeon-role-option--${role}`}
|
|
type="button"
|
|
disabled={preparingSpot}
|
|
onClick={() => enterAsRole(role)}
|
|
>
|
|
<b aria-hidden="true">{ROLE_DETAILS[role].sigil}</b>
|
|
<span>
|
|
<strong>{onlineGroup && session?.ownerId !== onlineGroup.leaderAccountId ? `Ready as ${PARTY_ROLE_LABELS[role]}` : PARTY_ROLE_LABELS[role]}</strong>
|
|
<small>{ROLE_DETAILS[role].summary}</small>
|
|
</span>
|
|
</ControllerButton>
|
|
))}
|
|
</div>
|
|
<ControllerButton
|
|
controlId="dungeon-role-back"
|
|
selectedId={controller.selectedId}
|
|
select={controller.select}
|
|
className="front-button dungeon-role-picker__back"
|
|
type="button"
|
|
onClick={() => setRolePickerOpen(false)}
|
|
>
|
|
Back to dungeon details
|
|
</ControllerButton>
|
|
</section>
|
|
) : (
|
|
<>
|
|
<dl>
|
|
<div><dt>Starting point</dt><dd>{selectedDungeon.entranceName}</dd></div>
|
|
</dl>
|
|
<ControllerButton
|
|
controlId="choose-dungeon-role"
|
|
selectedId={controller.selectedId}
|
|
select={controller.select}
|
|
className="front-button front-button--primary dungeon-select-enter"
|
|
type="button"
|
|
disabled={!selectedDungeon.available}
|
|
onClick={requestRolePicker}
|
|
>
|
|
<span>{canStart ? "Choose role" : selectedDungeon.available ? "Download and choose role" : "Unavailable"}</span>
|
|
<small>{canStart ? "Select your role before entering" : selectedDungeon.available ? "Required files will be verified before play" : "This expedition is not ready yet"}</small>
|
|
</ControllerButton>
|
|
</>
|
|
)}
|
|
{notice && <p className="front-notice" role="status">{notice}</p>}
|
|
</section>
|
|
{leaderboardOpen && (
|
|
<div id="dungeon-select-leaderboard" className="dungeon-select-leaderboard-column">
|
|
<BiteSizedBoardFilters selection={spotBoard} />
|
|
<DungeonLeaderboard dungeonTitle={selectedDungeon.title} data={leaderboard.data} scope={leaderboard.scope}
|
|
loading={leaderboard.loading} filter={clearFilter} onFilter={setClearFilter}
|
|
expanded={expandedRuns} onExpand={toggleRun} offset={leaderboard.offset} onPage={leaderboard.setOffset}
|
|
onRefresh={leaderboard.refresh} difficultyLabel={`${bossDifficultyLabel(bossDifficulty)} · ${leaderboardDifficulty?.difficulty === "heroic" ? "Heroic" : "Normal"}`}
|
|
onDifficulty={(selectedDefinition?.difficultyVariants.length ?? 0) > 1 ? cycleDifficulty : undefined}
|
|
bossLabel={selectedBoss?.name} onBoss={hasBossBoards ? cycleBoss : undefined}
|
|
selectedId={controller.selectedId} select={controller.select} />
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<section className="dungeon-select-empty">
|
|
<p className="front-kicker">No expeditions</p>
|
|
<h2>No dungeons are available yet.</h2>
|
|
</section>
|
|
)}
|
|
</main>
|
|
|
|
<ControllerLegend back />
|
|
{pendingDownload && (
|
|
<div className="front-modal-backdrop">
|
|
<section className="front-confirm" role="alertdialog" aria-modal="true" aria-labelledby="dungeon-download-title">
|
|
<p className="front-kicker">Download required</p>
|
|
<h2 id="dungeon-download-title">
|
|
Download {DUNGEON_CATALOG.find((dungeon) => dungeon.id === pendingDownload.dungeonId)?.title ?? "this expedition"}?
|
|
</h2>
|
|
<p>The map, creatures, and shared dependencies are not available on this device. Download and verify them now, then continue?</p>
|
|
{downloading && (
|
|
<p role="status">
|
|
Downloading and verifying files
|
|
{contentState.totalBytes > 0 ? ` — ${Math.round((contentState.downloadedBytes / contentState.totalBytes) * 100)}%` : "..."}
|
|
</p>
|
|
)}
|
|
{downloadError && <p className="front-notice" role="alert">{downloadError}</p>}
|
|
<div>
|
|
<ControllerButton
|
|
controlId="dungeon-download-cancel"
|
|
selectedId={controller.selectedId}
|
|
select={controller.select}
|
|
className="front-button"
|
|
type="button"
|
|
disabled={downloading}
|
|
onClick={() => setPendingDownload(null)}
|
|
>
|
|
Not now
|
|
</ControllerButton>
|
|
<ControllerButton
|
|
controlId="dungeon-download-confirm"
|
|
selectedId={controller.selectedId}
|
|
select={controller.select}
|
|
className="front-button front-button--primary"
|
|
type="button"
|
|
disabled={downloading}
|
|
onClick={() => void confirmDownload()}
|
|
>
|
|
{downloading ? "Downloading..." : downloadError ? "Try again" : "Download and continue"}
|
|
</ControllerButton>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)}
|
|
</FrontSurface>
|
|
}
|
|
bottom={<LazyLocalCompanionPanel />}
|
|
/>
|
|
);
|
|
}
|