Release Healer Man 0.1.22

This commit is contained in:
phenom committed 2026-09-09 11:21:33 -04:00
1 parent f6f2375145
commit 7f6001723a
144 files changed
+54107 -690

No files matched your search

+10
View File
@@ -5,6 +5,7 @@ import {
auraDisplayName,
auraDefinitionDisplaysInStrip,
auraDefinitionIcon,
auraDefinitionIsDispellableDebuff,
auraDefinitionIsLongTerm,
auraDescription,
groupAurasForDisplay,
@@ -64,6 +65,15 @@ describe("aura strip visibility", () => {
expect(auraDefinitionIsLongTerm(aura({ durationMs: LONG_TERM_AURA_DURATION_MS - 1 }))).toBe(false);
});
it("identifies only categorized debuffs as dispellable player effects", () => {
expect(auraDefinitionIsDispellableDebuff(aura({ dispelCategory: "magic" }))).toBe(true);
expect(auraDefinitionIsDispellableDebuff(aura())).toBe(false);
expect(auraDefinitionIsDispellableDebuff(aura({
disposition: "buff",
dispelCategory: "magic",
}))).toBe(false);
});
it("prefers authored tooltip copy and explains absorb-only fallback auras", () => {
expect(auraDescription(activeAura(aura({ description: "Raises armor by 10%." })))).toBe(
"Raises armor by 10%.",
+9 -3
View File
@@ -5,6 +5,7 @@ import { formatTimedEffectTimer } from "../game/timedEffectDisplay";
import {
auraDefinitionDisplaysInStrip,
auraDefinitionIcon,
auraDefinitionIsDispellableDebuff,
auraDefinitionIsLongTerm,
auraDescription,
auraDisplayName,
@@ -19,6 +20,7 @@ interface AuraStripProps {
readonly className?: string;
readonly hideLongTerm?: boolean;
readonly showOnlyLongTerm?: boolean;
readonly highlightDispellableDebuffs?: boolean;
readonly targetLabels?: Readonly<Record<string, string>>;
readonly onInspect?: StatusEffectInspectHandler;
readonly tooltipId?: string;
@@ -31,6 +33,7 @@ export function AuraStrip({
className = "",
hideLongTerm = false,
showOnlyLongTerm = false,
highlightDispellableDebuffs = false,
targetLabels,
onInspect,
tooltipId,
@@ -64,6 +67,9 @@ export function AuraStrip({
const targetLabel = targetLabels?.[aura.targetId];
const initial = displayName.charAt(0).toUpperCase() || "*";
const icon = auraDefinitionIcon(aura.definition);
const dispellable = highlightDispellableDebuffs
&& auraDefinitionIsDispellableDebuff(aura.definition);
const dispelLabel = dispellable ? `, dispellable ${aura.definition.dispelCategory}` : "";
const inspection: StatusEffectInspection = {
id: aura.instanceId,
name: displayName,
@@ -79,11 +85,11 @@ export function AuraStrip({
return (
<span
key={aura.instanceId}
className={`aura aura--${aura.definition.disposition} ${onInspect ? "aura--inspectable" : ""}`.trim()}
className={`aura aura--${aura.definition.disposition} ${dispellable ? "aura--dispellable" : ""} ${onInspect ? "aura--inspectable" : ""}`.trim()}
tabIndex={onInspect ? 0 : undefined}
aria-label={`${displayName}${targetLabel ? ` on ${targetLabel}` : ""}, ${aura.definition.disposition}${timer ? `, ${timer} remaining` : ""}`}
aria-label={`${displayName}${targetLabel ? ` on ${targetLabel}` : ""}, ${aura.definition.disposition}${dispelLabel}${timer ? `, ${timer} remaining` : ""}`}
aria-describedby={onInspect ? tooltipId : undefined}
title={onInspect ? undefined : `${displayName}${timer ? ` · ${timer} remaining` : ""}`}
title={onInspect ? undefined : `${displayName}${dispellable ? ` · Dispellable ${aura.definition.dispelCategory}` : ""}${timer ? ` · ${timer} remaining` : ""}`}
onPointerEnter={onInspect ? () => onInspect(inspection) : undefined}
onPointerLeave={onInspect ? (event) => {
if (event.currentTarget !== document.activeElement) onInspect(null);
+35
View File
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
BOSS_LOOT_AUTO_DISMISS_MS,
scheduleBossLootAutoDismiss,
} from "./BossLootWindow";
describe("boss loot auto-dismiss", () => {
afterEach(() => {
vi.useRealTimers();
});
it("dismisses the visible reward after ten seconds", () => {
vi.useFakeTimers();
const dismiss = vi.fn();
scheduleBossLootAutoDismiss(42, dismiss);
vi.advanceTimersByTime(BOSS_LOOT_AUTO_DISMISS_MS - 1);
expect(dismiss).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(dismiss).toHaveBeenCalledOnce();
expect(dismiss).toHaveBeenCalledWith(42);
});
it("cancels a pending auto-dismiss when the popup changes or closes", () => {
vi.useFakeTimers();
const dismiss = vi.fn();
const cancel = scheduleBossLootAutoDismiss(42, dismiss);
cancel();
vi.advanceTimersByTime(BOSS_LOOT_AUTO_DISMISS_MS);
expect(dismiss).not.toHaveBeenCalled();
});
});
+21
View File
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { useCombatStore } from "../game/combatStore";
import { useGameStore } from "../game/store";
import { classCanEquipItem, PLAYER_EQUIPMENT_OWNER_ID } from "../game/equipment";
@@ -7,6 +8,16 @@ import {
} from "../game/heirloomEquipment";
import { gearProfileFor } from "../game/specializationCatalog";
export const BOSS_LOOT_AUTO_DISMISS_MS = 10_000;
export function scheduleBossLootAutoDismiss(
rewardId: number,
dismiss: (id: number) => void,
): () => void {
const timeoutId = setTimeout(() => dismiss(rewardId), BOSS_LOOT_AUTO_DISMISS_MS);
return () => clearTimeout(timeoutId);
}
function itemType(inventorySlot: string): string {
return inventorySlot
.split(/[-_]/)
@@ -23,6 +34,16 @@ export function BossLootWindow() {
const level = useCombatStore((state) => state.level);
const equipment = useCombatStore((state) => state.equipment);
const inventory = useCombatStore((state) => state.inventory);
const visibleRewardId = reward?.id;
useEffect(() => {
if (visibleRewardId === undefined) return;
return scheduleBossLootAutoDismiss(
visibleRewardId,
(rewardId) => useCombatStore.getState().dismissBossLootReward(rewardId),
);
}, [visibleRewardId]);
if (!reward) return null;
const profile = gearProfileFor(classId, specializationId, role);
+9 -2
View File
@@ -28,8 +28,12 @@ import { ActionBar, type ActionBarItem, type ActionBarItems } from "./ActionBar"
import { DungeonMapGraphic } from "./DungeonMapGraphic";
import { ExperienceBar } from "./ExperienceBar";
import { BrandMark, FrontSurface } from "./FrontSurface";
import {
getLoadedCompanionManastormPanel,
loadCompanionManastormPanel,
} from "./gameplayResources";
const CompanionManastormPanel = lazy(() => import("./CompanionManastormPanel").then((module) => ({
const CompanionManastormPanel = lazy(() => loadCompanionManastormPanel().then((module) => ({
default: module.CompanionManastormPanel,
})));
@@ -154,6 +158,7 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
}) {
const selected = snapshot.characters.find((character) => character.id === snapshot.selectedCharacterId) ?? null;
const active = snapshot.activeCharacter ?? selected;
const PreloadedCompanionManastormPanel = getLoadedCompanionManastormPanel();
if (snapshot.phase === "login") {
return (
@@ -295,7 +300,9 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
}
if (snapshot.phase === "manastorms") {
return (
return PreloadedCompanionManastormPanel ? (
<PreloadedCompanionManastormPanel selected={selected} send={send} />
) : (
<Suspense fallback={<FrontSurface bottom className="companion-panel" ariaLabel="Loading Manastorm companion screen"><span>Loading Manastorm data…</span></FrontSurface>}>
<CompanionManastormPanel selected={selected} send={send} />
</Suspense>
+116 -16
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { contentPackIsUsable } from "../content/contentManager";
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
import { useContentManagerSnapshot } from "../content/useContentManager";
import { classById, raceById } from "../app/characterCatalog";
import { installDungeonShellRuntime } from "../app/dungeonShellRuntime";
@@ -44,8 +44,13 @@ const ROLE_DETAILS: Readonly<Record<PartyRole, { readonly sigil: string; readonl
},
};
interface PendingDungeonDownload {
readonly dungeonId: DungeonId;
readonly role: PartyRole | null;
}
export function DungeonSelectScreen() {
useContentManagerSnapshot();
const contentState = useContentManagerSnapshot();
const activeCharacter = useShellStore((state) => state.activeCharacter);
const rosterCharacter = useShellStore(selectedCharacter);
const enterDungeon = useShellStore((state) => state.enterDungeon);
@@ -60,12 +65,17 @@ export function DungeonSelectScreen() {
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 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
@@ -74,6 +84,11 @@ export function DungeonSelectScreen() {
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);
@@ -83,7 +98,7 @@ export function DungeonSelectScreen() {
setRolePickerOpen(false);
};
const enterAsRole = async (role: PartyRole) => {
const continueAsRole = async (role: PartyRole) => {
if (!selectedDungeon?.available || !character) return;
if (!onlineGroup || !session || session.kind !== "account") {
enterDungeon(selectedDungeon.id, role);
@@ -100,7 +115,51 @@ export function DungeonSelectScreen() {
partySize: 5,
});
};
const actions = useMemo<MenuAction[]>(() => [
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<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),
@@ -123,7 +182,7 @@ export function DungeonSelectScreen() {
}))
: [{
id: "choose-dungeon-role",
run: () => setRolePickerOpen(true),
run: requestRolePicker,
enabled: Boolean(selectedDungeon?.available),
}]),
...(rolePickerOpen ? [{
@@ -131,17 +190,16 @@ export function DungeonSelectScreen() {
run: () => setRolePickerOpen(false),
}] : []),
{ id: "dungeon-back", run: returnToMainMenu },
], [availableRoles, categoryDungeons, enterDungeon, firstCategoryDungeon, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon]);
], [availableRoles, categoryDungeons, downloading, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon]);
const controller = useMenuController(actions, {
initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back",
onBack: rolePickerOpen ? () => setRolePickerOpen(false) : returnToMainMenu,
onBack: pendingDownload
? () => { if (!downloading) setPendingDownload(null); }
: rolePickerOpen ? () => setRolePickerOpen(false) : returnToMainMenu,
});
const race = character ? raceById(character.raceId) : null;
const characterClass = character ? classById(character.classId) : null;
const selectedPackInstalled = selectedDungeon ? contentPackIsUsable(`dungeon:${selectedDungeon.id}`) : false;
const canStart = Boolean(selectedDungeon?.available && selectedPackInstalled);
return (
<DualDisplayFrame
contextLabel="Dungeon details"
@@ -203,7 +261,7 @@ export function DungeonSelectScreen() {
<b aria-hidden="true">{String(index + 1).padStart(2, "0")}</b>
<span><strong>{dungeon.title}</strong><small>{dungeon.entranceName}</small></span>
<em>{dungeon.available
? contentPackIsUsable(`dungeon:${dungeon.id}`) ? "Ready" : "Download required"
? dungeonContentIsUsable(dungeon.id) ? "Ready" : "Download required"
: "Coming soon"}</em>
</ControllerButton>
))}
@@ -242,7 +300,7 @@ export function DungeonSelectScreen() {
<p className="front-notice" role="status">{selectedDungeon.availabilityReason}</p>
)}
{selectedDungeon.available && !selectedPackInstalled && (
<p className="front-notice" role="status">Install this expedition from Main menu / Downloads before playing offline.</p>
<p className="front-notice" role="status">Select this expedition to download its required files.</p>
)}
<OnlineGroupPanel />
{rolePickerOpen && characterClass ? (
@@ -298,11 +356,11 @@ export function DungeonSelectScreen() {
select={controller.select}
className="front-button front-button--primary dungeon-select-enter"
type="button"
disabled={!canStart}
onClick={() => setRolePickerOpen(true)}
disabled={!selectedDungeon.available}
onClick={requestRolePicker}
>
<span>Choose role</span>
<small>{canStart ? "Select your role before entering" : selectedDungeon.available ? "Install this expedition from Downloads" : "This expedition is not ready yet"}</small>
<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>
</>
)}
@@ -318,6 +376,48 @@ export function DungeonSelectScreen() {
</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 />}
+26 -25
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect, useMemo } from "react";
import { lazy, Suspense, useMemo } from "react";
import { classById } from "../app/characterCatalog";
import { useShellStore } from "../app/shellStore";
import { useCombatStore } from "../game/combatStore";
@@ -12,24 +12,16 @@ import { AbilityBindingsPanel } from "./AbilityBindingsPanel";
import { SpellbookPanel } from "./SpellbookPanel";
import { EquipmentPanel } from "./EquipmentPanel";
import { RomSkillsPanel } from "./RomSkillsPanel";
import { getLoadedTalentGameplayPanel, loadTalentGameplayPanel } from "./talentGameplayResources";
import {
getLoadedBossGuidePanel,
getLoadedPartyManagementPanel,
loadBossGuidePanel,
loadPartyManagementPanel,
} from "./gameplayResources";
const PartyManagementPanel = lazy(() => import("./PartyManagementPanel").then((module) => ({ default: module.PartyManagementPanel })));
const BossGuidePanel = lazy(() => import("./BossGuidePanel").then((module) => ({ default: module.BossGuidePanel })));
let talentGameplayPanelModule: Promise<typeof import("./TalentGameplayPanel")> | null = null;
function loadTalentGameplayPanel() {
if (!talentGameplayPanelModule) {
talentGameplayPanelModule = import("./TalentGameplayPanel").catch((error: unknown) => {
talentGameplayPanelModule = null;
throw error;
});
}
return talentGameplayPanelModule;
}
function preloadTalentGameplayPanel() {
void loadTalentGameplayPanel().catch(() => undefined);
}
const PartyManagementPanel = lazy(() => loadPartyManagementPanel().then((module) => ({ default: module.PartyManagementPanel })));
const BossGuidePanel = lazy(() => loadBossGuidePanel().then((module) => ({ default: module.BossGuidePanel })));
const TalentGameplayPanel = lazy(loadTalentGameplayPanel);
@@ -61,10 +53,9 @@ export function GameplayPanels() {
const presentationSettings = useShellStore((state) => state.combatPresentationSettings);
const overlay = useGameStore((state) => state.overlay);
const classDefinition = classById(classId);
useEffect(() => {
if (overlay === "pause" && !classId.startsWith("rom-")) preloadTalentGameplayPanel();
}, [classId, overlay]);
const PreloadedTalentGameplayPanel = getLoadedTalentGameplayPanel();
const PreloadedPartyManagementPanel = getLoadedPartyManagementPanel();
const PreloadedBossGuidePanel = getLoadedBossGuidePanel();
const spellbookAbilities = useMemo(() => [...abilities]
.sort((left, right) => left.unlockLevel - right.unlockLevel)
@@ -110,15 +101,25 @@ export function GameplayPanels() {
return (
<>
{overlay === "party" ? <Suspense fallback={null}><PartyManagementPanel /></Suspense> : null}
{overlay === "boss-guide" ? <Suspense fallback={null}><BossGuidePanel /></Suspense> : null}
{overlay === "party"
? PreloadedPartyManagementPanel
? <PreloadedPartyManagementPanel />
: <Suspense fallback={null}><PartyManagementPanel /></Suspense>
: null}
{overlay === "boss-guide"
? PreloadedBossGuidePanel
? <PreloadedBossGuidePanel />
: <Suspense fallback={null}><BossGuidePanel /></Suspense>
: null}
<EquipmentPanel />
<AbilityBindingsPanel />
<SpellbookPanel abilities={spellbookAbilities} playerLevel={level} onActivate={castAbility} />
{overlay === "talents"
? classId.startsWith("rom-")
? <RomSkillsPanel />
: <Suspense fallback={<TalentsPanelLoading />}><TalentGameplayPanel /></Suspense>
: PreloadedTalentGameplayPanel
? <PreloadedTalentGameplayPanel />
: <Suspense fallback={<TalentsPanelLoading />}><TalentGameplayPanel /></Suspense>
: null}
<OptionsPanel
settings={settings}
+48 -13
View File
@@ -1,4 +1,11 @@
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react";
import {
useCallback,
useEffect,
useMemo,
useState,
useSyncExternalStore,
type CSSProperties,
} from "react";
import { useProgress } from "@react-three/drei";
import { classById, raceById } from "../app/characterCatalog";
import { useShellStore } from "../app/shellStore";
@@ -19,7 +26,6 @@ import { ExperienceBar } from "./ExperienceBar";
import { Minimap } from "./Minimap";
import { PlayerStatusPanel } from "./PlayerStatusPanel";
import { EncounterTimeline } from "./EncounterTimeline";
import { PlayerFrame } from "./PlayerFrame";
import { PartyFrames } from "./PartyFrames";
import { TimedEffectStrip } from "./TimedEffectStrip";
import { AuraStrip } from "./AuraStrip";
@@ -29,6 +35,11 @@ import { BossLootWindow } from "./BossLootWindow";
import { SummonFrames } from "./SummonFrames";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { onlineAccountIdFromActor, onlinePlayerActorId } from "../game/onlineSessionRuntime";
import {
activeDungeonEncounterRevision,
activeDungeonRunIsComplete,
subscribeDungeonEncounterSnapshots,
} from "../game/dungeonEncounterRuntime";
function LoadingOverlay() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -106,6 +117,27 @@ export function DefeatedPrompt({
);
}
export function DungeonCompletePrompt({
dungeonTitle,
onFinish,
}: {
readonly dungeonTitle: string;
readonly onFinish: () => void;
}) {
return (
<section className="dungeon-complete-banner" role="status" aria-live="polite">
<span aria-hidden="true">◆</span>
<p>
<strong>{dungeonTitle} complete</strong>
<small>Every required encounter is defeated. Loot the room or finish the run.</small>
</p>
<button type="button" className="button button--primary" onClick={onFinish}>
Finish dungeon
</button>
</section>
);
}
export function Hud() {
const character = useShellStore((state) => state.activeCharacter);
const gameMode = useGameStore((state) => state.gameMode);
@@ -130,10 +162,6 @@ export function Hud() {
const xpToNext = useCombatStore((state) => state.xpToNext);
const health = useCombatStore((state) => state.health);
const maxHealth = useCombatStore((state) => state.maxHealth);
const shield = useCombatStore((state) => state.shield);
const namedAbsorb = useCombatStore((state) => state.auras
.filter((aura) => aura.targetId === PLAYER_AGGRO_ID)
.reduce((total, aura) => total + aura.absorbs.reduce((sum, pool) => sum + pool.remaining, 0), 0));
const resource = useCombatStore((state) => state.resource);
const maxResource = useCombatStore((state) => state.maxResource);
const resourcePools = useCombatStore((state) => state.resourcePools);
@@ -160,6 +188,13 @@ export function Hud() {
const settings = useCombatStore((state) => state.settings);
const onlineSession = useOnlineSessionStore((state) => state.snapshot);
const [clock, setClock] = useState(() => Date.now());
useSyncExternalStore(
subscribeDungeonEncounterSnapshots,
activeDungeonEncounterRevision,
activeDungeonEncounterRevision,
);
const dungeonComplete = gameMode === "dungeon"
&& activeDungeonRunIsComplete(activeDungeonId);
useEffect(() => {
const timer = window.setInterval(() => setClock(Date.now()), 100);
@@ -318,24 +353,24 @@ export function Hud() {
}
respawnDefeatedPlayer();
};
const finishDungeon = () => {
useShellStore.getState().saveActiveDungeonRunCheckpoint(null);
useShellStore.getState().returnToMainMenu();
};
return (
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
<ManastormStatus />
<BossLootWindow />
{dungeonComplete && health > 0 ? (
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} />
) : null}
{feedback?.kind === "loot" ? (
<section className="loot-reward" role="status" aria-live="polite">
<span aria-hidden="true">◆</span>
<p><strong>Loot acquired</strong><small>{feedback.message}</small><em>Press I to inspect and equip</em></p>
</section>
) : null}
<PlayerFrame
character={character ? { ...character, level } : null}
health={{ current: health, maximum: maxHealth, label: "Health", tone: "health" }}
resource={{ current: resource, maximum: maxResource, label: resourceName, tone: resourceTone }}
shield={shield + namedAbsorb}
dead={health <= 0}
/>
<PartyFrames />
<SummonFrames now={clock} />
+113 -6
View File
@@ -4,8 +4,13 @@ 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 } from "../game/manastormAssetRegistry";
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";
@@ -46,6 +51,7 @@ const ROLE_DETAILS: Readonly<Record<PartyRole, { readonly sigil: string; readonl
};
export function ManastormSelectScreen() {
const contentState = useContentManagerSnapshot();
const activeCharacter = useShellStore((state) => state.activeCharacter);
const rosterCharacter = useShellStore(selectedCharacter);
const enterManastorm = useShellStore((state) => state.enterManastorm);
@@ -80,9 +86,20 @@ export function ManastormSelectScreen() {
);
const modeEnabled = adminConfig.global.enabled
&& (modeId === 2 ? adminConfig.global.endgameEnabled : adminConfig.global.levelingEnabled);
const hasReadyBosses = Boolean(effectiveCatalog.stages?.some((stage) => (
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";
@@ -124,6 +141,9 @@ export function ManastormSelectScreen() {
);
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("");
useEffect(() => {
setSelectedSpellIds(persistedSpellIds);
@@ -155,7 +175,7 @@ export function ManastormSelectScreen() {
});
};
const enterAsRole = async (role: PartyRole) => {
const continueAsRole = async (role: PartyRole) => {
if (!canEnter || !character) return;
if (!onlineGroup || !session || session.kind !== "account") {
enterManastorm(role, partySize, startingLevel);
@@ -174,7 +194,42 @@ export function ManastormSelectScreen() {
});
};
const actions = useMemo<MenuAction[]>(() => [
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),
@@ -196,10 +251,13 @@ export function ManastormSelectScreen() {
availableRoles,
canEnter,
checkpoint,
contentState.installedRevision,
groupHumanCount,
fillWithAi,
onlineGroup,
effectivePartySize,
downloading,
pendingDownloadRole,
returnToMainMenu,
selectedSpellIds,
spellLoadoutModel,
@@ -207,7 +265,9 @@ export function ManastormSelectScreen() {
]);
const controller = useMenuController(actions, {
initialId: "manastorm-party-5",
onBack: returnToMainMenu,
onBack: pendingDownloadRole
? () => { if (!downloading) setPendingDownloadRole(null); }
: returnToMainMenu,
});
const race = character ? raceById(character.raceId) : null;
@@ -246,6 +306,11 @@ export function ManastormSelectScreen() {
: "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>
@@ -412,6 +477,48 @@ export function ManastormSelectScreen() {
</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 />}
+27 -7
View File
@@ -16,6 +16,7 @@ import type { OnlineGroupMember } from "../app/onlineGroupTypes";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
import { onlinePlayerActorId } from "../game/onlineSessionRuntime";
import { auraDefinitionIsDispellableDebuff } from "./auraDisplay";
const PARTY_COMMANDS: readonly {
command: PartyCommand;
@@ -119,19 +120,19 @@ function MemberFrame({ member, selected, onSelect }: {
), 0));
const absorbPercent = Math.min(100 - percent, healthPercent(absorb, member.maxHealth));
const dispellableCount = useCombatStore((state) => state.auras.reduce((total, aura) => (
aura.targetId === member.id && aura.definition.disposition === "debuff" && aura.definition.dispelCategory
aura.targetId === member.id && auraDefinitionIsDispellableDebuff(aura.definition)
? total + 1
: total
), 0));
const dispellableNames = useCombatStore((state) => state.auras
.filter((aura) => aura.targetId === member.id && aura.definition.disposition === "debuff" && aura.definition.dispelCategory)
.filter((aura) => aura.targetId === member.id && auraDefinitionIsDispellableDebuff(aura.definition))
.map((aura) => aura.definition.name)
.join(", "));
const resourcePercent = healthPercent(member.resource, member.maxResource);
return (
<button
type="button"
className={`party-frame ${selected ? "is-selected" : ""} ${member.health <= 0 ? "is-down" : ""}`}
className={`party-frame ${selected ? "is-selected" : ""} ${dispellableCount ? "is-dispellable" : ""} ${member.health <= 0 ? "is-down" : ""}`}
onClick={onSelect}
aria-pressed={selected}
aria-label={`Target ${member.name}, ${member.gender} ${race.name}, ${member.specialization} ${characterClass.name}`}
@@ -149,7 +150,12 @@ function MemberFrame({ member, selected, onSelect }: {
<i style={{ width: `${resourcePercent}%` }} />
</span>
<TimedEffectStrip targetId={member.id} className="timed-effect-strip--party" />
<AuraStrip targetId={member.id} className="aura-strip--party" hideLongTerm />
<AuraStrip
targetId={member.id}
className="aura-strip--party"
hideLongTerm
highlightDispellableDebuffs
/>
</span>
</button>
);
@@ -214,6 +220,15 @@ export function PartyFrames() {
: total
), 0));
const playerShield = useCombatStore((state) => state.shield);
const playerDispellableCount = useCombatStore((state) => state.auras.reduce((total, aura) => (
aura.targetId === PLAYER_AGGRO_ID && auraDefinitionIsDispellableDebuff(aura.definition)
? total + 1
: total
), 0));
const playerDispellableNames = useCombatStore((state) => state.auras
.filter((aura) => aura.targetId === PLAYER_AGGRO_ID && auraDefinitionIsDispellableDebuff(aura.definition))
.map((aura) => aura.definition.name)
.join(", "));
const members = usePartyStore((state) => state.members);
const selectedMemberId = usePartyStore((state) => state.selectedMemberId);
const selfSelected = usePartyStore((state) => state.selfSelected);
@@ -232,7 +247,6 @@ export function PartyFrames() {
const onlinePlayersByAccount = new Map(
(onlineSession?.players ?? []).map((player) => [player.accountId, player]),
);
if (!members.length && !onlineMembers.length) return null;
const groupSize = members.length + onlineMembers.length + 1;
const selectMember = (id: string) => {
@@ -257,7 +271,7 @@ export function PartyFrames() {
{members.length ? <PartyCommandBar /> : null}
<button
type="button"
className={`party-frame party-frame--player ${selfSelected ? "is-selected" : ""} ${playerHealth <= 0 ? "is-down" : ""}`}
className={`party-frame party-frame--player ${selfSelected ? "is-selected" : ""} ${playerDispellableCount ? "is-dispellable" : ""} ${playerHealth <= 0 ? "is-down" : ""}`}
onClick={selectSelf}
aria-pressed={selfSelected}
aria-label={`Target ${character?.name ?? "Player"}, you`}
@@ -265,6 +279,7 @@ export function PartyFrames() {
<span className="party-frame__sigil">YOU</span>
<span className="party-frame__body">
<span className="party-frame__identity"><strong>{character?.name ?? "Adventurer"}</strong><small>You · {PARTY_ROLE_LABELS[playerRole]}</small></span>
{playerDispellableCount ? <span className="party-frame__dispel" title={playerDispellableNames}>DISPEL {playerDispellableCount}</span> : null}
<span className="party-frame__health" role="progressbar" aria-label="Player health" aria-valuemin={0} aria-valuemax={playerMaxHealth} aria-valuenow={Math.max(0, playerHealth)}>
<i style={{ width: `${playerPercent}%` }} />
{playerAbsorb > 0 ? <em style={{ left: `${playerPercent}%`, width: `${playerAbsorbPercent}%` }} /> : null}
@@ -278,7 +293,12 @@ export function PartyFrames() {
targetIds={[null, PLAYER_AGGRO_ID]}
className="timed-effect-strip--party"
/>
<AuraStrip targetId={PLAYER_AGGRO_ID} className="aura-strip--party" hideLongTerm />
<AuraStrip
targetId={PLAYER_AGGRO_ID}
className="aura-strip--party"
hideLongTerm
highlightDispellableDebuffs
/>
</span>
</button>
{onlineMembers.map((member) => {
+2 -2
View File
@@ -6,7 +6,7 @@ import {
runtimeUnspentCoaEssence,
runtimeUnspentTalentPoints,
} from "../game/talentRuntimeCatalog";
import { loadTalentUiCatalog, talentUiNodesForTree } from "../game/talentUiCatalog";
import { loadedTalentUiCatalog, loadTalentUiCatalog, talentUiNodesForTree } from "../game/talentUiCatalog";
import { TalentsPanel } from "./TalentsPanel";
function treeSigil(name: string): string {
@@ -18,7 +18,7 @@ export default function TalentGameplayPanel() {
const level = useCombatStore((state) => state.level);
const talentRanks = useCombatStore((state) => state.talentRanks);
const classDefinition = classById(classId);
const catalog = use(loadTalentUiCatalog(classId));
const catalog = loadedTalentUiCatalog(classId) ?? use(loadTalentUiCatalog(classId));
const talentTrees = useMemo(() => catalog.trees.map((tree) => ({
id: tree.id,
name: tree.name,
+4
View File
@@ -55,6 +55,10 @@ export function auraDefinitionIsLongTerm(definition: AuraDefinition): boolean {
return definition.durationMs === null || definition.durationMs >= LONG_TERM_AURA_DURATION_MS;
}
export function auraDefinitionIsDispellableDebuff(definition: AuraDefinition): boolean {
return definition.disposition === "debuff" && definition.dispelCategory !== undefined;
}
export function auraDescription(aura: ActiveAura): string {
const authored = aura.definition.description?.trim();
if (authored) return authored;
+14 -1
View File
@@ -1,7 +1,7 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { DefeatedPrompt } from "./Hud";
import { DefeatedPrompt, DungeonCompletePrompt } from "./Hud";
describe("defeated prompt", () => {
it("offers an explicit dungeon respawn while explaining that waiting is allowed", () => {
@@ -26,3 +26,16 @@ describe("defeated prompt", () => {
expect(markup).not.toContain("Resurrect now");
});
});
describe("dungeon completion prompt", () => {
it("keeps final-room looting available while exposing an explicit run exit", () => {
const markup = renderToStaticMarkup(createElement(DungeonCompletePrompt, {
dungeonTitle: "The Deadmines",
onFinish: () => undefined,
}));
expect(markup).toContain("The Deadmines complete");
expect(markup).toContain("Loot the room or finish the run");
expect(markup).toContain("Finish dungeon");
});
});
+37 -3
View File
@@ -5,12 +5,46 @@ const gameplayPanelsSource = readFileSync(
new URL("./GameplayPanels.tsx", import.meta.url),
"utf8",
);
const talentResourcesSource = readFileSync(
new URL("./talentGameplayResources.ts", import.meta.url),
"utf8",
);
const appSource = readFileSync(
new URL("../App.tsx", import.meta.url),
"utf8",
);
const gameplayResourcesSource = readFileSync(
new URL("./gameplayResources.ts", import.meta.url),
"utf8",
);
const companionPanelSource = readFileSync(
new URL("./CompanionPanel.tsx", import.meta.url),
"utf8",
);
describe("gameplay panel loading integration", () => {
it("starts loading talents from the pause menu and keeps a visible first-open surface", () => {
expect(gameplayPanelsSource).toContain('overlay === "pause"');
expect(gameplayPanelsSource).toContain("preloadTalentGameplayPanel()");
it("loads talent resources behind the main-menu catalog gate, not during gameplay", () => {
expect(appSource).toContain("preloadTalentGameplayResources(talentClassId)");
expect(talentResourcesSource).toContain("loadTalentUiCatalog(classId)");
expect(talentResourcesSource).toContain("loadTalentGameplayPanel()");
expect(talentResourcesSource).toContain("image.decode()");
expect(gameplayPanelsSource).toContain("getLoadedTalentGameplayPanel()");
expect(gameplayPanelsSource).toContain("<PreloadedTalentGameplayPanel />");
expect(gameplayPanelsSource).not.toContain("requestIdleCallback");
expect(gameplayPanelsSource).not.toContain("preloadTalentGameplayResources");
expect(gameplayPanelsSource).toContain("fallback={<TalentsPanelLoading />}");
expect(gameplayPanelsSource).not.toContain("<Suspense fallback={null}><TalentGameplayPanel />");
});
it("warms the shared runtime and first-open panels before showing the main menu", () => {
expect(appSource).toContain("preloadSharedGameplayResources(originalMediaEnabled)");
expect(appSource).toContain("getLoadedGameRuntime()");
expect(gameplayResourcesSource).toContain('import("../GameRuntime")');
expect(gameplayResourcesSource).toContain('import("./PartyManagementPanel")');
expect(gameplayResourcesSource).toContain('import("./BossGuidePanel")');
expect(gameplayResourcesSource).toContain('import("./CompanionManastormPanel")');
expect(gameplayPanelsSource).toContain("<PreloadedPartyManagementPanel />");
expect(gameplayPanelsSource).toContain("<PreloadedBossGuidePanel />");
expect(companionPanelSource).toContain("<PreloadedCompanionManastormPanel");
});
});
+96
View File
@@ -0,0 +1,96 @@
let gameRuntimeModule: Promise<typeof import("../GameRuntime")> | null = null;
let loadedGameRuntime: typeof import("../GameRuntime")["default"] | null = null;
let partyManagementPanelModule: Promise<typeof import("./PartyManagementPanel")> | null = null;
let loadedPartyManagementPanel: typeof import("./PartyManagementPanel")["PartyManagementPanel"] | null = null;
let bossGuidePanelModule: Promise<typeof import("./BossGuidePanel")> | null = null;
let loadedBossGuidePanel: typeof import("./BossGuidePanel")["BossGuidePanel"] | null = null;
let companionManastormPanelModule: Promise<typeof import("./CompanionManastormPanel")> | null = null;
let loadedCompanionManastormPanel: typeof import("./CompanionManastormPanel")["CompanionManastormPanel"] | null = null;
export function loadGameRuntime() {
if (!gameRuntimeModule) {
gameRuntimeModule = import("../GameRuntime")
.then((module) => {
loadedGameRuntime = module.default;
return module;
})
.catch((error: unknown) => {
gameRuntimeModule = null;
throw error;
});
}
return gameRuntimeModule;
}
export function getLoadedGameRuntime() {
return loadedGameRuntime;
}
export function loadPartyManagementPanel() {
if (!partyManagementPanelModule) {
partyManagementPanelModule = import("./PartyManagementPanel")
.then((module) => {
loadedPartyManagementPanel = module.PartyManagementPanel;
return module;
})
.catch((error: unknown) => {
partyManagementPanelModule = null;
throw error;
});
}
return partyManagementPanelModule;
}
export function getLoadedPartyManagementPanel() {
return loadedPartyManagementPanel;
}
export function loadBossGuidePanel() {
if (!bossGuidePanelModule) {
bossGuidePanelModule = import("./BossGuidePanel")
.then((module) => {
loadedBossGuidePanel = module.BossGuidePanel;
return module;
})
.catch((error: unknown) => {
bossGuidePanelModule = null;
throw error;
});
}
return bossGuidePanelModule;
}
export function getLoadedBossGuidePanel() {
return loadedBossGuidePanel;
}
export function loadCompanionManastormPanel() {
if (!companionManastormPanelModule) {
companionManastormPanelModule = import("./CompanionManastormPanel")
.then((module) => {
loadedCompanionManastormPanel = module.CompanionManastormPanel;
return module;
})
.catch((error: unknown) => {
companionManastormPanelModule = null;
throw error;
});
}
return companionManastormPanelModule;
}
export function getLoadedCompanionManastormPanel() {
return loadedCompanionManastormPanel;
}
export async function preloadSharedGameplayResources(originalMediaEnabled: boolean): Promise<void> {
await Promise.all([
loadGameRuntime(),
loadPartyManagementPanel(),
loadBossGuidePanel(),
loadCompanionManastormPanel(),
]);
if (!originalMediaEnabled) return;
const { preloadCombatEffectManifest } = await import("../game/combatPresentation");
await preloadCombatEffectManifest();
}
+20
View File
@@ -0,0 +1,20 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const styles = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
function declarationBlock(selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return styles.match(new RegExp(`(?:^|\\n)\\s*${escapedSelector}\\s*\\{([^}]*)\\}`))?.[1] ?? "";
}
describe("gameplay HUD layering", () => {
it("contains world-space labels below the DOM HUD", () => {
const sceneShell = declarationBlock(".scene-shell");
const hud = declarationBlock(".hud");
expect(sceneShell).toMatch(/z-index:\s*0\s*;/);
expect(sceneShell).toMatch(/isolation:\s*isolate\s*;/);
expect(hud).toMatch(/z-index:\s*(?:[1-9]|[1-9]\d+)\s*;/);
});
});
+71
View File
@@ -0,0 +1,71 @@
import type { ClassId } from "../app/characterCatalog";
import { loadTalentUiCatalog, talentUiArtUrls } from "../game/talentUiCatalog";
let talentGameplayPanelModule: Promise<typeof import("./TalentGameplayPanel")> | null = null;
let loadedTalentGameplayPanel: typeof import("./TalentGameplayPanel")["default"] | null = null;
const talentArtLoads = new Map<string, Promise<void>>();
const retainedTalentArt = new Map<string, HTMLImageElement>();
const talentGameplayResourceLoads = new Map<ClassId, Promise<void>>();
export function loadTalentGameplayPanel() {
if (!talentGameplayPanelModule) {
talentGameplayPanelModule = import("./TalentGameplayPanel")
.then((module) => {
loadedTalentGameplayPanel = module.default;
return module;
})
.catch((error: unknown) => {
talentGameplayPanelModule = null;
throw error;
});
}
return talentGameplayPanelModule;
}
export function getLoadedTalentGameplayPanel() {
return loadedTalentGameplayPanel;
}
function preloadTalentArt(url: string): Promise<void> {
const cached = talentArtLoads.get(url);
if (cached) return cached;
if (typeof Image === "undefined") return Promise.resolve();
const image = new Image();
retainedTalentArt.set(url, image);
const pending = new Promise<void>((resolve) => {
image.onload = () => {
image.onload = null;
image.onerror = null;
if (typeof image.decode === "function") {
void image.decode().catch(() => undefined).then(() => resolve());
} else {
resolve();
}
};
image.onerror = () => {
image.onload = null;
image.onerror = null;
resolve();
};
image.src = url;
});
talentArtLoads.set(url, pending);
return pending;
}
export function preloadTalentGameplayResources(classId: ClassId): Promise<void> {
const cached = talentGameplayResourceLoads.get(classId);
if (cached) return cached;
const pending = Promise.all([
loadTalentUiCatalog(classId),
loadTalentGameplayPanel(),
]).then(async ([catalog]) => {
await Promise.all(talentUiArtUrls(catalog).map(preloadTalentArt));
}).catch((error: unknown) => {
talentGameplayResourceLoads.delete(classId);
throw error;
});
talentGameplayResourceLoads.set(classId, pending);
return pending;
}