import { useEffect, useMemo, useState } from "react"; import { classById, raceById } from "../app/characterCatalog"; import { installManastormShellRuntime } from "../app/manastormShellRuntime"; import { selectedCharacter, useShellStore } from "../app/shellStore"; import { preloadAvatarModels } from "../avatar/avatarPreloader"; import { DualDisplayFrame } from "../components/DualDisplayFrame"; import { contentPackIsUsable, installContentPack } from "../content/contentManager"; import { useContentManagerSnapshot } from "../content/useContentManager"; import { MANASTORM_CATALOG_SUMMARY } from "../game/manastormCatalogSummary"; import { GENERATED_MANASTORM_STAGE_ASSET_PACKAGE_COUNT, generatedManastormContentPackId, } from "../game/manastormAssetRegistry"; import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "../game/manastormInstalledCatalog"; import { effectiveManastormCatalog } from "../game/manastormAdminConfig"; import { useManastormAdminStore } from "../game/manastormAdminStore"; import { manastormActiveSpellLoadoutModel } from "../game/manastormSpellLoadout"; import { drawManastormStage, manastormCacheChance, manastormModeForCharacterLevel, manastormReward, } from "../game/manastorm"; import { MANASTORM_PARTY_SIZES, createEmptyManastormProgress, manastormModeProgress, manastormPartyProgress, type ManastormPartySize, } from "../game/manastormProgress"; import { createDungeonParty, 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 { AbilityTooltipTarget } from "./AbilityTooltip"; import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface"; import { OnlineGroupPanel } from "./OnlineGroupPanel"; import { useOnlineGroupStore } from "../app/onlineGroupStore"; import { specializationById } from "../game/specializationCatalog"; import { useCompanionPartyPreferences } from "../game/companionPartyPreferences"; import { useCompanionSpecReadinessStore } from "../game/companionSpecReadinessStore"; import { CompanionPoolControls } from "./CompanionPoolControls"; import { CompanionReadinessScreen } from "./CompanionReadinessScreen"; installManastormShellRuntime(); const ROLE_DETAILS: Readonly> = { tank: { sigil: "T", summary: "Hold each empowered boss while your healer and damage party work." }, healer: { sigil: "+", summary: "Keep the expedition alive as every new storm hits harder." }, damage: { sigil: "D", summary: "Burn through guardians and bosses with a balanced party." }, }; export function ManastormSelectScreen() { const contentState = useContentManagerSnapshot(); const activeCharacter = useShellStore((state) => state.activeCharacter); const rosterCharacter = useShellStore(selectedCharacter); const enterManastorm = useShellStore((state) => state.enterManastorm); const saveSelectedManastormLoadout = useShellStore((state) => state.saveSelectedManastormLoadout); 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 fillWithAi = useOnlineGroupStore((state) => state.fillWithAi); const updateOnlineMember = useOnlineGroupStore((state) => state.updateMember); const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity); const adminConfig = useManastormAdminStore((state) => state.config); const character = activeCharacter ?? rosterCharacter; const savedSpecialization = specializationById(character?.specializationId); const availableRoles = character ? character.categoryId === "rom" ? partyRolesForClass(character.classId) : savedSpecialization ? [savedSpecialization.role] : [] : []; const [partySize, setPartySize] = useState(5); const includeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs); const readinessPolicy = useCompanionSpecReadinessStore((state) => state.config); const readinessPolicyRevision = useCompanionSpecReadinessStore((state) => state.revision); const groupHumanCount = onlineGroup?.members.length ?? 1; const effectivePartySize = onlineGroup ? (fillWithAi ? Math.max(partySize, groupHumanCount) : groupHumanCount) as ManastormPartySize : partySize; const [resume, setResume] = useState(true); const progress = character?.manastormProgress ?? createEmptyManastormProgress(); const effectiveCatalog = useMemo( () => effectiveManastormCatalog(adminConfig, INSTALLED_MANASTORM_RUNTIME_CATALOG), [adminConfig], ); const modeId = manastormModeForCharacterLevel( character?.level ?? 1, effectiveCatalog, ); const modeEnabled = adminConfig.global.enabled && (modeId === 2 ? adminConfig.global.endgameEnabled : adminConfig.global.levelingEnabled); const eligibleStages = useMemo(() => effectiveCatalog.stages?.filter((stage) => ( !stage.modeIds?.length || stage.modeIds.includes(modeId) )) ?? [], [effectiveCatalog, modeId]); const hasReadyBosses = eligibleStages.length > 0; const requiredContentPackIds = useMemo(() => Object.freeze([...new Set(eligibleStages.flatMap((stage) => { if (stage.assetPackageId) { const packId = generatedManastormContentPackId(stage.assetPackageId); return packId ? [packId] : []; } return stage.dungeonId ? [`dungeon:${stage.dungeonId}`] : []; }))].sort()), [eligibleStages]); const missingContentPackIds = import.meta.env.DEV ? [] : requiredContentPackIds.filter((packId) => !contentPackIsUsable(packId)); const canEnter = modeEnabled && hasReadyBosses; const modeProgress = manastormModeProgress(progress, modeId); const modeLabel = modeId === 2 ? "End-game" : "Leveling"; const partyProgress = manastormPartyProgress(progress, effectivePartySize, modeId); const checkpoint = Math.max(1, partyProgress.checkpointLevel); const startingLevel = resume && checkpoint > 1 ? checkpoint : 1; const cacheChance = modeId === 2 ? Math.round(manastormCacheChance(startingLevel, modeProgress.cacheMeter) * 100) : 100; const rewardPreview = useMemo(() => { try { const preview = drawManastormStage( startingLevel, effectivePartySize, [], null, () => 0.5, effectiveCatalog, modeId, Math.max(startingLevel, partyProgress.highestLevel + 1), ); return manastormReward( preview.encounter, startingLevel, effectivePartySize, effectiveCatalog, modeId, ); } catch { return null; } }, [effectiveCatalog, effectivePartySize, modeId, partyProgress.highestLevel, startingLevel]); const spellLoadoutModel = useMemo(() => character ? manastormActiveSpellLoadoutModel(character.classId, character.level, character.talentRanks) : null, [character?.classId, character?.level, character?.talentRanks]); const availableSpellIds = useMemo( () => new Set(spellLoadoutModel?.options.map((option) => option.id) ?? []), [spellLoadoutModel], ); const persistedSpellIds = progress.loadout.filter((id) => availableSpellIds.has(id)); const [selectedSpellIds, setSelectedSpellIds] = useState(persistedSpellIds); const [pendingDownloadRole, setPendingDownloadRole] = useState(null); const [downloading, setDownloading] = useState(false); const [downloadError, setDownloadError] = useState(""); const [readinessOpen, setReadinessOpen] = useState(false); useEffect(() => { setSelectedSpellIds(persistedSpellIds); }, [progress.loadout.join("|"), spellLoadoutModel?.classId, spellLoadoutModel?.characterLevel]); const toggleSpell = (spellId: string) => { if (!spellLoadoutModel) return; const selected = selectedSpellIds.includes(spellId); const next = selected ? selectedSpellIds.filter((id) => id !== spellId) : selectedSpellIds.length < spellLoadoutModel.maxSelections ? [...selectedSpellIds, spellId] : selectedSpellIds; if (next === selectedSpellIds) return; if (saveSelectedManastormLoadout(next)) setSelectedSpellIds(next); }; const preloadRole = (role: PartyRole) => { if (!character) return; const party = createDungeonParty( character.id, character.level, role, effectivePartySize, character.categoryId ?? "wow", { readinessPolicy, readinessPolicyRevision, includeBetaSpecs, excludedPlayerSpecializationId: character.specializationId, }, ); void preloadAvatarModels([character, ...party]).catch((error) => { console.warn("Selected party avatar preloading could not be completed", error); }); }; const continueAsRole = async (role: PartyRole) => { if (!canEnter || !character) return; if (!onlineGroup || !session || session.kind !== "account") { enterManastorm(role, partySize, startingLevel); 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: "manastorm", contentId: "manastorm", partySize: effectivePartySize, startingLevel, includeBetaSpecs, }); }; const enterAsRole = async (role: PartyRole) => { if (!canEnter || !character) return; if (missingContentPackIds.length > 0) { setDownloadError(""); setPendingDownloadRole(role); return; } await continueAsRole(role); }; const confirmDownload = async () => { if (!pendingDownloadRole || downloading) return; setDownloading(true); setDownloadError(""); try { for (const packId of requiredContentPackIds) { if (!contentPackIsUsable(packId)) await installContentPack(packId); } const stillMissing = requiredContentPackIds.filter((packId) => !contentPackIsUsable(packId)); if (stillMissing.length > 0) { throw new Error("One or more downloaded Manastorm maps could not be verified on this device."); } const role = pendingDownloadRole; setPendingDownloadRole(null); await continueAsRole(role); } catch (error) { setDownloadError(error instanceof Error ? error.message : "The Manastorm download failed."); } finally { setDownloading(false); } }; const actions = useMemo(() => pendingDownloadRole ? [ { id: "manastorm-download-cancel", run: () => setPendingDownloadRole(null), enabled: !downloading }, { id: "manastorm-download-confirm", run: () => void confirmDownload(), enabled: !downloading }, ] : [ ...MANASTORM_PARTY_SIZES.map((size) => ({ id: `manastorm-party-${size}`, run: () => setPartySize(size), enabled: !onlineGroup || (size >= groupHumanCount && (fillWithAi || size === groupHumanCount)), })), { id: "manastorm-start-fresh", run: () => setResume(false) }, ...(checkpoint > 1 ? [{ id: "manastorm-start-resume", run: () => setResume(true) }] : []), ...(spellLoadoutModel?.options.map((spell) => ({ id: `manastorm-spell-${spell.id}`, run: () => toggleSpell(spell.id), })) ?? []), ...availableRoles.map((role) => ({ id: `manastorm-role-${role}`, run: () => { if (canEnter) void enterAsRole(role); }, enabled: canEnter, })), { id: "manastorm-back", run: returnToMainMenu }, ], [ availableRoles, canEnter, checkpoint, contentState.installedRevision, groupHumanCount, fillWithAi, onlineGroup, effectivePartySize, downloading, pendingDownloadRole, returnToMainMenu, selectedSpellIds, spellLoadoutModel, startingLevel, ]); const controller = useMenuController(actions, { initialId: "manastorm-party-5", onBack: pendingDownloadRole ? () => { if (!downloading) setPendingDownloadRole(null); } : returnToMainMenu, }); const race = character ? raceById(character.raceId) : null; const characterClass = character ? classById(character.classId) : null; if (readinessOpen) return setReadinessOpen(false)} />; return (
Main menu / Manastorms Enter the storm
{character && race && characterClass ? (

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

) : null}

Ascension expedition

Manastorms

{modeLabel} progression

Break Chaotic Link by clearing guardians or challenge the empowered boss early, claim the reward, and cross the portal. Special encounters every five levels unlock the next checkpoint.

{!canEnter ? (

{!modeEnabled ? `${modeLabel} Manastorms are currently disabled by a Game Master.` : "No bosses are currently marked Ready for this Manastorm mode."}

) : null} {canEnter && missingContentPackIds.length > 0 ? (

{missingContentPackIds.length} Manastorm map {missingContentPackIds.length === 1 ? "pack is" : "packs are"} not on this device. Choose a role to download them and continue.

) : null}
Catalog maps
{MANASTORM_CATALOG_SUMMARY.maps}
Streamed maps
{GENERATED_MANASTORM_STAGE_ASSET_PACKAGE_COUNT}
Encounter records
{MANASTORM_CATALOG_SUMMARY.encounters}
Bedlam Bullions
{progress.currencies.bedlamBullions}
Bonzo Bolts
{progress.currencies.bonzoBolts}
Cache chance
{cacheChance}%
Bad-luck protection
{modeProgress.cacheMeter} / 5 misses
Next reward preview
{rewardPreview ? `${rewardPreview.currencyAmount} ${rewardPreview.currencyId.replace(/([A-Z])/g, " $1").toLowerCase()}` : "Unavailable"}
Active spell picks
{selectedSpellIds.length || "None selected"}

Run setup

Build your expedition

Progress and first clears are tracked separately for every party size.
setReadinessOpen(true)} compact />
Party size
{MANASTORM_PARTY_SIZES.map((size) => ( setPartySize(size)} > {size} ))}
{onlineGroup ? fillWithAi ? `${groupHumanCount} player${groupHumanCount === 1 ? "" : "s"} + ${Math.max(0, effectivePartySize - groupHumanCount)} AI teammate${effectivePartySize - groupHumanCount === 1 ? "" : "s"}` : `${groupHumanCount} grouped player${groupHumanCount === 1 ? "" : "s"} · AI fill off` : partySize === 1 ? "Solo" : `You + ${partySize - 1} companion${partySize === 2 ? "" : "s"}`} {" · "}Highest clear {partyProgress.highestLevel}
Entry level
setResume(false)} > Start at 1 {checkpoint > 1 ? ( setResume(true)} > Resume at {checkpoint} ) : null}
Local active-spell loadout {" · "} {selectedSpellIds.length} / {spellLoadoutModel?.maxSelections ?? 0} Client-grounded spells only. The installed Manastorm tables contain no authoritative spell-choice list, so these picks locally replace the first four base controls for this run.
{spellLoadoutModel?.options.map((spell) => { const selected = selectedSpellIds.includes(spell.id); const capped = !selected && selectedSpellIds.length >= spellLoadoutModel.maxSelections; return ( { if (!capped) toggleSpell(spell.id); }} title={`${spell.description} · Client Spell.dbc ID ${spell.dbcSpellId}`} > {spell.name} Spell {spell.dbcSpellId} ); })} {!spellLoadoutModel?.options.length ? (

No imported active spells are available at this character level.

) : null}
Party role
{availableRoles.map((role) => ( preloadRole(role)} onPointerEnter={() => preloadRole(role)} onClick={() => { if (canEnter) void enterAsRole(role); }} > {onlineGroup && session?.ownerId !== onlineGroup.leaderAccountId ? "Ready" : "Enter"} as {PARTY_ROLE_LABELS[role]} {ROLE_DETAILS[role].summary} ))}
{notice ?

{notice}

: null} Back to main menu
{pendingDownloadRole && (

Download required

Download {modeLabel} Manastorm maps?

This mode can draw {missingContentPackIds.length} map {missingContentPackIds.length === 1 ? "pack" : "packs"} that {missingContentPackIds.length === 1 ? "is" : "are"} not on this device. Download and verify the required files now, then enter the storm?

{downloading && (

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

)} {downloadError &&

{downloadError}

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