Files
healer-man/src/ui/ManastormSelectScreen.tsx
T
2026-09-19 21:01:51 -04:00

548 lines
25 KiB
TypeScript

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<Record<PartyRole, { readonly sigil: string; readonly summary: string }>> = {
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<ManastormPartySize>(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<readonly string[]>(persistedSpellIds);
const [pendingDownloadRole, setPendingDownloadRole] = useState<PartyRole | null>(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<MenuAction[]>(() => 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 <CompanionReadinessScreen onClose={() => setReadinessOpen(false)} />;
return (
<DualDisplayFrame
contextLabel="Manastorm details"
top={
<FrontSurface className="manastorm-select-screen" ariaLabel="Manastorm selection">
<header className="manastorm-select-header">
<BrandMark compact />
<div>
<span>Main menu / Manastorms</span>
<strong>Enter the storm</strong>
</div>
{character && race && characterClass ? (
<p><b>{character.name}</b><small>Level {character.level} {race.name} {characterClass.name}</small></p>
) : null}
</header>
<main className="manastorm-select-content">
<section className="manastorm-select-intro">
<p className="front-kicker">Ascension expedition</p>
<h1>Manastorms</h1>
<p><strong>{modeLabel} progression</strong></p>
<p>
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.
</p>
{!canEnter ? (
<p className="front-notice" role="status">
{!modeEnabled
? `${modeLabel} Manastorms are currently disabled by a Game Master.`
: "No bosses are currently marked Ready for this Manastorm mode."}
</p>
) : null}
{canEnter && missingContentPackIds.length > 0 ? (
<p className="front-notice" role="status">
{missingContentPackIds.length} Manastorm map {missingContentPackIds.length === 1 ? "pack is" : "packs are"} not on this device. Choose a role to download them and continue.
</p>
) : null}
<dl>
<div><dt>Catalog maps</dt><dd>{MANASTORM_CATALOG_SUMMARY.maps}</dd></div>
<div><dt>Streamed maps</dt><dd>{GENERATED_MANASTORM_STAGE_ASSET_PACKAGE_COUNT}</dd></div>
<div><dt>Encounter records</dt><dd>{MANASTORM_CATALOG_SUMMARY.encounters}</dd></div>
<div><dt>Bedlam Bullions</dt><dd>{progress.currencies.bedlamBullions}</dd></div>
<div><dt>Bonzo Bolts</dt><dd>{progress.currencies.bonzoBolts}</dd></div>
<div><dt>Cache chance</dt><dd>{cacheChance}%</dd></div>
<div><dt>Bad-luck protection</dt><dd>{modeProgress.cacheMeter} / 5 misses</dd></div>
<div>
<dt>Next reward preview</dt>
<dd>
{rewardPreview
? `${rewardPreview.currencyAmount} ${rewardPreview.currencyId.replace(/([A-Z])/g, " $1").toLowerCase()}`
: "Unavailable"}
</dd>
</div>
<div><dt>Active spell picks</dt><dd>{selectedSpellIds.length || "None selected"}</dd></div>
</dl>
</section>
<section className="manastorm-role-select" aria-labelledby="manastorm-role-title">
<header>
<p className="front-kicker">Run setup</p>
<h2 id="manastorm-role-title">Build your expedition</h2>
<small>Progress and first clears are tracked separately for every party size.</small>
</header>
<OnlineGroupPanel />
<CompanionPoolControls onOpenReadiness={() => setReadinessOpen(true)} compact />
<div className="manastorm-setup-block">
<strong>Party size</strong>
<div className="manastorm-party-size-grid">
{MANASTORM_PARTY_SIZES.map((size) => (
<ControllerButton
key={size}
controlId={`manastorm-party-${size}`}
selectedId={controller.selectedId}
select={controller.select}
className={`manastorm-size-option ${effectivePartySize === size ? "is-active" : ""}`}
type="button"
disabled={Boolean(onlineGroup && (size < groupHumanCount || (!fillWithAi && size !== groupHumanCount)))}
onClick={() => setPartySize(size)}
>
{size}
</ControllerButton>
))}
</div>
<small>
{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}
</small>
</div>
<div className="manastorm-setup-block">
<strong>Entry level</strong>
<div className="manastorm-entry-grid">
<ControllerButton
controlId="manastorm-start-fresh"
selectedId={controller.selectedId}
select={controller.select}
className={`manastorm-entry-option ${!resume || checkpoint === 1 ? "is-active" : ""}`}
type="button"
disabled={!canEnter}
onClick={() => setResume(false)}
>
Start at 1
</ControllerButton>
{checkpoint > 1 ? (
<ControllerButton
controlId="manastorm-start-resume"
selectedId={controller.selectedId}
select={controller.select}
className={`manastorm-entry-option ${resume ? "is-active" : ""}`}
type="button"
onClick={() => setResume(true)}
>
Resume at {checkpoint}
</ControllerButton>
) : null}
</div>
</div>
<div className="manastorm-setup-block">
<strong>
Local active-spell loadout
{" · "}
{selectedSpellIds.length} / {spellLoadoutModel?.maxSelections ?? 0}
</strong>
<small>
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.
</small>
<div className="manastorm-spell-grid">
{spellLoadoutModel?.options.map((spell) => {
const selected = selectedSpellIds.includes(spell.id);
const capped = !selected
&& selectedSpellIds.length >= spellLoadoutModel.maxSelections;
return (
<AbilityTooltipTarget key={spell.id} ability={spell}>
<ControllerButton
key={spell.id}
controlId={`manastorm-spell-${spell.id}`}
selectedId={controller.selectedId}
select={controller.select}
className={`manastorm-spell-option ${selected ? "is-active" : ""}`}
type="button"
aria-disabled={capped}
onClick={() => { if (!capped) toggleSpell(spell.id); }}
title={`${spell.description} · Client Spell.dbc ID ${spell.dbcSpellId}`}
>
<img src={spell.icon} alt="" aria-hidden="true" />
<span>
<b>{spell.name}</b>
<small>Spell {spell.dbcSpellId}</small>
</span>
</ControllerButton>
</AbilityTooltipTarget>
);
})}
{!spellLoadoutModel?.options.length ? (
<p className="front-notice">
No imported active spells are available at this character level.
</p>
) : null}
</div>
</div>
<strong className="manastorm-role-label">Party role</strong>
<div className="manastorm-role-grid">
{availableRoles.map((role) => (
<ControllerButton
key={role}
controlId={`manastorm-role-${role}`}
selectedId={controller.selectedId}
select={controller.select}
className={`dungeon-role-option dungeon-role-option--${role}`}
type="button"
disabled={!canEnter}
onFocus={() => preloadRole(role)}
onPointerEnter={() => preloadRole(role)}
onClick={() => { if (canEnter) void enterAsRole(role); }}
>
<b aria-hidden="true">{ROLE_DETAILS[role].sigil}</b>
<span>
<strong>{onlineGroup && session?.ownerId !== onlineGroup.leaderAccountId ? "Ready" : "Enter"} as {PARTY_ROLE_LABELS[role]}</strong>
<small>{ROLE_DETAILS[role].summary}</small>
</span>
</ControllerButton>
))}
</div>
{notice ? <p className="front-notice" role="status">{notice}</p> : null}
<ControllerButton
controlId="manastorm-back"
selectedId={controller.selectedId}
select={controller.select}
className="front-button manastorm-select-back"
type="button"
onClick={returnToMainMenu}
>
Back to main menu
</ControllerButton>
</section>
</main>
<ControllerLegend back />
{pendingDownloadRole && (
<div className="front-modal-backdrop">
<section className="front-confirm" role="alertdialog" aria-modal="true" aria-labelledby="manastorm-download-title">
<p className="front-kicker">Download required</p>
<h2 id="manastorm-download-title">Download {modeLabel} Manastorm maps?</h2>
<p>
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?
</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="manastorm-download-cancel"
selectedId={controller.selectedId}
select={controller.select}
className="front-button"
type="button"
disabled={downloading}
onClick={() => setPendingDownloadRole(null)}
>
Not now
</ControllerButton>
<ControllerButton
controlId="manastorm-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 />}
/>
);
}