Release Healer Man 0.1.22
This commit is contained in:
1 parent
f6f2375145
commit
7f6001723a
144 files changed
+54107
-690
No files matched your search
@@ -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%.",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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} />
|
||||
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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*;/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user