Files
i-want-to-heal-mmo/src/components/FrontEnd.tsx
T

1872 lines
128 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { buildCollections, MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName } from "../frontend/data";
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
import { resolveSaveContinuation, saveVersionsMatch } from "../frontend/saveContinuation";
import { useActiveHunter, useFrontendStore } from "../frontend/store";
import type { GameModeId, ProfileStatId, SaveSlotId, SaveSlotState } from "../frontend/types";
import {
PROFILE_SECTIONS,
alphabeticalBosses,
defaultStatForSection,
isBossProfileStat,
profileSectionForStat,
} from "../frontend/profileSections";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { HEALER_CLASSES, HEALER_CLASS_ORDER, isHealerClassId } from "../game/healers";
import { HEALER_GUIDES } from "../game/healerGuides";
import { ABILITY_ORDER } from "../game/data";
import { BOSS_DEFINITIONS, BOSS_GROUP_BY_ID, BOSS_GROUPS } from "../game/bossCatalog";
import { bossMechanicIsPassive, bossMechanicName } from "../game/bosses/mechanicPool";
import { RUN_BUFFS, formatRunBuffEffect, selectRandomBossPair } from "../game/roguelike";
import type { BossId } from "../game/types";
import {
GEAR_OWNER_LABELS,
GEAR_OWNER_ORDER,
GEAR_RECIPES,
GEAR_SLOT_LABELS,
GEAR_SLOT_ORDER,
GEAR_STAT_LABELS,
MAX_GEAR_LEVEL,
canAffordGearUpgrade,
canUpgradeGearSlot,
gearBonusText,
gearUpgradeCosts,
type GearOwnerId,
} from "../game/progression/gear";
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossGroupDrop } from "../game/progression/loot";
import {
ACTIVE_INFUSION_MIN_GEAR_LEVEL,
PASSIVE_INFUSIONS,
PASSIVE_INFUSION_MIN_GEAR_LEVEL,
activeInfusionUnlocked,
infusionCosts,
infusionsForOwner,
passiveInfusionUnlocked,
} from "../game/progression/infusions";
import { requestDisplaySurface } from "../platform/displayRouting";
import { onlineRepository, type LeaderboardResult } from "../frontend/onlineRepository";
import { leaderboardCache } from "../frontend/leaderboardCache";
import { hasPendingSaveSync, networkAppearsOnline } from "../frontend/saveSync";
import { DualDisplayFrame } from "./DualDisplayFrame";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
import {
HOCKEY_PVP_GOAL_DAMAGE,
HOCKEY_PVP_QUEUE_TIMEOUT_MS,
hockeyPvpBossAt,
randomHockeyPvpCpuName,
type HockeyPvpMatchConfig,
} from "../game/hockeyHealingPvp";
import { BLOCKBREAKER_BREACH_DAMAGE } from "../game/blockbreaker";
import {
APPEARANCE_SLOT_DEFINITIONS,
appearanceSlotEnabled,
appearanceSlotLabel,
appearancesMatch,
cycleAppearanceSlot,
type AppearanceSlotId,
} from "../game/appearanceLab";
import { CHARACTER_MODEL_MODE, type CharacterAppearanceV1 } from "../game/characterAppearance";
import { createDefaultHealerAppearance } from "../game/healerVisuals";
const HealerAppearancePreview = lazy(() => import("./GameScene").then((module) => ({ default: module.HealerAppearancePreview })));
function ControllerButton({
id,
selectedId,
select,
className = "",
onClick,
onPointerEnter,
onFocus: _onFocus,
trackPointer = true,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { id: string; selectedId: string; select: (id: string) => void; trackPointer?: boolean }) {
return (
<button
{...props}
className={`${className} ${selectedId === id ? "is-controller-selected" : ""}`}
onClick={(event) => { select(id); onClick?.(event); }}
onPointerEnter={(event) => { if (trackPointer) select(id); onPointerEnter?.(event); }}
/>
);
}
function FrontSurface({ className = "", children, bottom = false, ariaLabel }: { className?: string; children: React.ReactNode; bottom?: boolean; ariaLabel: string }) {
return (
<section className={`display front-surface ${bottom ? "bottom-display front-bottom" : "top-display front-top"} ${className}`} aria-label={ariaLabel}>
<div className="front-grain" aria-hidden="true" />
{children}
</section>
);
}
function BrandMark({ compact = false }: { compact?: boolean }) {
return (
<div className={`front-brand ${compact ? "is-compact" : ""}`}>
<span className="brand-sigil"></span>
<span><small>Healers answer the call</small><strong>I Want To Heal</strong></span>
</div>
);
}
function ControllerLegend({ back = false }: { back?: boolean }) {
return <div className="controller-legend"><span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>{back && <span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back</span>}<span><b></b> Navigate</span></div>;
}
function SaveLibraryContext({ slots, accountId }: { slots: readonly SaveSlotState[]; accountId: string | null }) {
return (
<FrontSurface className="login-save-context" bottom ariaLabel="Save slot information">
<header className="context-header"><span>Device saves</span><b>{accountId ? "SERVER LINKED" : "OFFLINE READY"}</b></header>
<div className="login-save-list">
{slots.map((slot) => {
const save = slot.local ?? slot.online;
const healer = save ? HEALER_CLASSES[save.activeClassId] : null;
return (
<article key={slot.id} className={save ? "has-save" : "is-empty"}>
<b>{String(slot.id).padStart(2, "0")}</b>
{save ? (
<>
<div className="login-save-avatar">{save.hunterName[0]}</div>
<span>
<small>{slot.local ? "On this Thor" : "Online copy"}</small>
<strong>{save.hunterName}</strong>
<em>Level {save.healers[save.activeClassId].level} {healer?.name} · {save.location}</em>
</span>
<time><strong>{formatPlayTime(save.playSeconds)}</strong><small>{formatSaveTimestamp(save.updatedAt)}</small></time>
</>
) : (
<span className="login-empty-copy"><small>Available slot</small><strong>New hunter</strong><em>Continue offline to create</em></span>
)}
</article>
);
})}
</div>
<footer className="login-save-footer"><span>Save details update from upper-screen selection</span><b>LOWER DISPLAY · INFORMATION ONLY</b></footer>
</FrontSurface>
);
}
function LoginScreen() {
const restoreSession = useFrontendStore((state) => state.restoreSession);
const signIn = useFrontendStore((state) => state.signIn);
const createAccount = useFrontendStore((state) => state.createAccount);
const continueOffline = useFrontendStore((state) => state.continueOffline);
const slots = useFrontendStore((state) => state.slots);
const accountId = useFrontendStore((state) => state.accountId);
const notice = useFrontendStore((state) => state.notice);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const restoreStarted = useRef(false);
useEffect(() => {
if (restoreStarted.current) return;
restoreStarted.current = true;
void restoreSession();
}, [restoreSession]);
const actions = useMemo<MenuAction[]>(() => [
{ id: "username", run: () => usernameRef.current?.focus() },
{ id: "password", run: () => passwordRef.current?.focus() },
{ id: "sign-in", run: () => { void signIn(username, password); } },
{ id: "create-account", run: () => { void createAccount(username, password); } },
{ id: "continue", run: continueOffline },
], [continueOffline, createAccount, password, signIn, username]);
const controller = useMenuController(actions);
const submitSignIn = () => { void signIn(username, password); };
return (
<DualDisplayFrame
top={
<FrontSurface className="login-surface" ariaLabel="I Want To Heal login">
<div className="login-aura" aria-hidden="true"><i /><b>+</b><i /></div>
<BrandMark />
<div className="login-copy">
<span>Offline-first hunter records</span>
<h1>Keep everyone standing.</h1>
<p>Continue to your saved hunters. Sign in when you want online copies for PC AYN Thor handoff.</p>
</div>
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); submitSignIn(); }}>
<label htmlFor="account-username">Username</label>
<input
ref={usernameRef}
id="account-username"
className={controller.selectedId === "username" ? "is-controller-selected" : ""}
value={username}
onChange={(event) => setUsername(event.target.value)}
onFocus={() => controller.select("username")}
autoComplete="username"
maxLength={20}
minLength={3}
pattern="[A-Za-z0-9_]+"
required
/>
<label htmlFor="account-password">Password</label>
<input
ref={passwordRef}
id="account-password"
className={controller.selectedId === "password" ? "is-controller-selected" : ""}
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
onFocus={() => controller.select("password")}
autoComplete="current-password"
maxLength={128}
minLength={10}
required
/>
<ControllerButton id="sign-in" selectedId={controller.selectedId} select={controller.select} className="front-primary" type="submit">
<span>Sign in & sync</span><small>Online saves enabled</small>
</ControllerButton>
<ControllerButton id="create-account" selectedId={controller.selectedId} select={controller.select} className="front-secondary" type="button" onClick={() => { void createAccount(username, password); }}>
<span>Create account</span><small>Required for first sync</small>
</ControllerButton>
<ControllerButton id="continue" selectedId={controller.selectedId} select={controller.select} className="front-secondary" type="button" onClick={continueOffline}>
<span>Continue</span><small>Choose saved hunter</small>
</ControllerButton>
</form>
{notice && <div className="front-notice" role="status" aria-live="polite">{notice}</div>}
<ControllerLegend />
</FrontSurface>
}
bottom={<SaveLibraryContext slots={slots} accountId={accountId} />}
/>
);
}
function SlotCard({ slot, selected, controllerSelected, onSelect, onHover }: { slot: SaveSlotState; selected: boolean; controllerSelected: boolean; onSelect: () => void; onHover: () => void }) {
const continuation = resolveSaveContinuation(slot);
const save = slot.local ?? slot.online;
const healer = save ? HEALER_CLASSES[save.activeClassId] : null;
const copyStatus = continuation === "choose" ? "Newer online" : continuation === "online" ? "Online only" : null;
return (
<button className={`save-slot ${selected ? "is-selected" : ""} ${controllerSelected ? "is-controller-selected" : ""}`} onClick={onSelect} onPointerEnter={onHover}>
<span className="slot-number">Slot {String(slot.id).padStart(2, "0")}{copyStatus && <b>{copyStatus}</b>}</span>
{save ? (
<>
<div className="slot-portrait">{save.hunterName[0]}<i></i></div>
<span className="slot-name"><strong>{save.hunterName}</strong><small>Level {save.healers[save.activeClassId].level} · {healer?.name}</small></span>
<span className="slot-location">{save.location}</span>
<span className="slot-meta"><b>{formatPlayTime(save.playSeconds)}</b><small>{formatSaveTimestamp(save.updatedAt)}</small></span>
</>
) : (
<div className="empty-slot"><b></b><strong>New hunter</strong><small>Empty offline slot</small></div>
)}
<i className="selection-chevron"></i>
</button>
);
}
function SaveScreen() {
const slots = useFrontendStore((state) => state.slots);
const accountId = useFrontendStore((state) => state.accountId);
const selectedSlotId = useFrontendStore((state) => state.selectedSlotId);
const notice = useFrontendStore((state) => state.notice);
const selectSlot = useFrontendStore((state) => state.selectSlot);
const createSlot = useFrontendStore((state) => state.createSlot);
const playSlot = useFrontendStore((state) => state.playSlot);
const uploadSlot = useFrontendStore((state) => state.uploadSlot);
const downloadSlot = useFrontendStore((state) => state.downloadSlot);
const copySlot = useFrontendStore((state) => state.copySlot);
const deleteSlot = useFrontendStore((state) => state.deleteSlot);
const navigate = useFrontendStore((state) => state.navigate);
const [dialog, setDialog] = useState<"create" | "copy" | "delete" | "version" | null>(null);
const [hunterName, setHunterName] = useState("");
const hunterNameRef = useRef<HTMLInputElement>(null);
const [resolvingOnline, setResolvingOnline] = useState(false);
const [versionError, setVersionError] = useState("");
const selected = slots.find((slot) => slot.id === selectedSlotId)!;
const hasLocal = Boolean(selected.local);
const hasOnline = Boolean(selected.online);
const continuation = resolveSaveContinuation(selected);
const primaryActionId = continuation === "create" ? "create" : "play";
const finishCreation = () => {
if (createSlot(selectedSlotId, hunterName)) {
setHunterName("");
setDialog(null);
}
};
const openCreation = () => {
setHunterName("");
setDialog("create");
requestDisplaySurface("top");
};
const openSaveDialog = (nextDialog: "copy" | "delete") => {
setDialog(nextDialog);
requestDisplaySurface("top");
};
const continueWithOnline = async () => {
if (resolvingOnline) return;
setResolvingOnline(true);
setVersionError("");
await downloadSlot(selectedSlotId);
const refreshed = useFrontendStore.getState().slots.find((slot) => slot.id === selectedSlotId);
if (refreshed && saveVersionsMatch(refreshed.local, refreshed.online)) {
setDialog(null);
setResolvingOnline(false);
playSlot(selectedSlotId);
return;
}
setVersionError(useFrontendStore.getState().notice || "Online save could not be loaded.");
setResolvingOnline(false);
};
const continueSelected = () => {
if (continuation === "create") return openCreation();
if (continuation === "local") return playSlot(selectedSlotId);
if (continuation === "online") {
void continueWithOnline();
return;
}
setVersionError("");
setDialog("version");
requestDisplaySurface("top");
};
const actions = useMemo<MenuAction[]>(() => dialog === "version"
? [
{ id: "version-online", run: () => { void continueWithOnline(); }, enabled: !resolvingOnline },
{ id: "version-local", run: () => { setDialog(null); playSlot(selectedSlotId); }, enabled: !resolvingOnline },
{ id: "cancel-version", run: () => setDialog(null), enabled: !resolvingOnline },
]
: dialog === "create"
? [
{ id: "hunter-name", run: () => hunterNameRef.current?.focus() },
{ id: "confirm-create", run: finishCreation, enabled: Boolean(normalizeHunterName(hunterName)) },
{ id: "cancel-create", run: () => setDialog(null) },
]
: dialog === "copy"
? slots.filter((slot) => slot.id !== selectedSlotId).map((slot) => ({ id: `copy-${slot.id}`, run: () => { copySlot(selectedSlotId, slot.id); setDialog(null); } }))
: dialog === "delete"
? [
{ id: "confirm-delete", run: () => { deleteSlot(selectedSlotId); setDialog(null); } },
{ id: "cancel-delete", run: () => setDialog(null) },
]
: [
...slots.map((slot, index) => ({
id: `slot-${slot.id}`,
run: () => selectSlot(slot.id),
neighbors: {
left: `slot-${slots[Math.max(0, index - 1)].id}`,
right: `slot-${slots[Math.min(slots.length - 1, index + 1)].id}`,
down: primaryActionId,
},
})),
{ id: primaryActionId, run: continueSelected, enabled: !resolvingOnline, neighbors: { up: `slot-${selectedSlotId}`, right: "upload" } },
{ id: "upload", run: () => uploadSlot(selectedSlotId), enabled: hasLocal && Boolean(accountId), neighbors: { left: primaryActionId, right: "download", up: "slot-1" } },
{ id: "download", run: () => downloadSlot(selectedSlotId), enabled: hasOnline && Boolean(accountId), neighbors: { left: "upload", right: "copy", up: "slot-2" } },
{ id: "copy", run: () => openSaveDialog("copy"), enabled: hasLocal, neighbors: { left: "download", right: "delete", up: "slot-2" } },
{ id: "delete", run: () => openSaveDialog("delete"), enabled: hasLocal, neighbors: { left: "copy", right: "back", up: "slot-3" } },
{ id: "back", run: () => navigate("login"), neighbors: { left: "delete", up: "slot-3" } },
], [accountId, continuation, copySlot, createSlot, deleteSlot, dialog, downloadSlot, hasLocal, hasOnline, hunterName, navigate, playSlot, primaryActionId, resolvingOnline, selectSlot, selectedSlotId, slots, uploadSlot]);
const controller = useMenuController(actions, { onBack: () => dialog ? resolvingOnline ? undefined : setDialog(null) : navigate("login") });
const cloudStatus = continuation === "choose" ? "Newer online save" : !accountId ? "Offline mode" : selected.online ? "Online version available" : "No online version";
return (
<DualDisplayFrame
top={
<FrontSurface className={`save-surface ${dialog ? "has-dialog" : ""}`} ariaLabel="Save slots">
<header className="front-screen-header"><BrandMark compact /><div><span>Hunter records</span><h1>Choose a save</h1></div><b className={accountId ? "is-online" : ""}>{accountId ? `● ${accountId}` : "○ OFFLINE"}</b></header>
<div className="save-slot-grid">
{slots.map((slot) => (
<SlotCard
key={slot.id}
slot={slot}
selected={selectedSlotId === slot.id}
controllerSelected={controller.isSelected(`slot-${slot.id}`)}
onSelect={() => { controller.select(`slot-${slot.id}`); selectSlot(slot.id); }}
onHover={() => controller.select(`slot-${slot.id}`)}
/>
))}
</div>
<div className="save-top-actions">
<ControllerButton id={primaryActionId} selectedId={controller.selectedId} select={controller.select} className="front-primary" disabled={resolvingOnline} onClick={continueSelected}>
<span>{continuation === "create" ? "Create hunter" : resolvingOnline ? "Loading online save…" : "Continue"}</span>
<small>{continuation === "create" ? `Use slot ${selectedSlotId}` : continuation === "online" ? "Download online copy" : continuation === "choose" ? "Choose online or device copy" : `Slot ${selectedSlotId} · ${selected.local?.hunterName}`}</small>
</ControllerButton>
<ControllerButton id="upload" selectedId={controller.selectedId} select={controller.select} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}><strong>Upload</strong><small>Device server</small></ControllerButton>
<ControllerButton id="download" selectedId={controller.selectedId} select={controller.select} disabled={!hasOnline || !accountId} onClick={() => downloadSlot(selectedSlotId)}><strong>Download</strong><small>Server device</small></ControllerButton>
<ControllerButton id="copy" selectedId={controller.selectedId} select={controller.select} disabled={!hasLocal} onClick={() => openSaveDialog("copy")}><strong>Copy</strong><small>Duplicate save</small></ControllerButton>
<ControllerButton id="delete" selectedId={controller.selectedId} select={controller.select} disabled={!hasLocal} className="danger-link" onClick={() => openSaveDialog("delete")}><strong>Delete</strong><small>Erase device copy</small></ControllerButton>
<ControllerButton id="back" selectedId={controller.selectedId} select={controller.select} onClick={() => navigate("login")}><strong>Back</strong><small>Login screen</small></ControllerButton>
</div>
<div className="save-footer"><span>Autosave <b>OFFLINE FIRST</b></span><div className="save-top-notice" role="status" aria-live="polite">{notice || "Lower display shows selected save details."}</div><ControllerLegend back /></div>
{dialog && (
<div className={`front-dialog ${dialog === "version" ? "version-dialog" : ""}`} role="dialog" aria-modal="true" aria-label={dialog === "version" ? "Choose save version" : dialog === "create" ? "Name new hunter" : dialog === "copy" ? "Copy save" : "Delete save"}>
{dialog === "version" && selected.local && selected.online ? (
<div className="version-choice-dialog">
<span>Newer online save found</span>
<h2>Which save do you want?</h2>
<p>Online choice replaces this device copy. Device choice keeps online copy unchanged.</p>
<div className="version-comparison">
<article className="is-newer">
<header><span>Online copy</span><b>NEWER</b></header>
<strong>{selected.online.hunterName}</strong>
<time>{formatSaveTimestamp(selected.online.updatedAt)}</time>
<small>{formatPlayTime(selected.online.playSeconds)} · Level {selected.online.healers[selected.online.activeClassId].level}</small>
</article>
<article>
<header><span>Device copy</span><b>OFFLINE</b></header>
<strong>{selected.local.hunterName}</strong>
<time>{formatSaveTimestamp(selected.local.updatedAt)}</time>
<small>{formatPlayTime(selected.local.playSeconds)} · Level {selected.local.healers[selected.local.activeClassId].level}</small>
</article>
</div>
<div className="dialog-actions version-actions">
<ControllerButton id="version-online" selectedId={controller.selectedId} select={controller.select} className="front-primary" disabled={resolvingOnline} onClick={() => { void continueWithOnline(); }}><span>{resolvingOnline ? "Loading…" : "Continue online copy"}</span><small>{formatSaveTimestamp(selected.online.updatedAt)}</small></ControllerButton>
<ControllerButton id="version-local" selectedId={controller.selectedId} select={controller.select} disabled={resolvingOnline} onClick={() => { setDialog(null); playSlot(selectedSlotId); }}><span>Continue device copy</span><small>{formatSaveTimestamp(selected.local.updatedAt)}</small></ControllerButton>
<ControllerButton id="cancel-version" selectedId={controller.selectedId} select={controller.select} disabled={resolvingOnline} onClick={() => setDialog(null)}>Cancel</ControllerButton>
</div>
{versionError && <div className="version-choice-error" role="alert">{versionError}</div>}
</div>
) : dialog === "create" ? (
<form onSubmit={(event) => { event.preventDefault(); finishCreation(); }}>
<span>New offline save</span><h2>Name your hunter</h2><p>This name identifies the character in local and online save lists.</p>
<label htmlFor="new-hunter-name">Hunter name</label>
<input
ref={hunterNameRef}
id="new-hunter-name"
className={controller.selectedId === "hunter-name" ? "is-controller-selected" : ""}
value={hunterName}
maxLength={MAX_HUNTER_NAME_LENGTH}
autoComplete="off"
onFocus={() => controller.select("hunter-name")}
onChange={(event) => setHunterName(event.target.value)}
onKeyDown={(event) => { if (event.key === "Escape") setDialog(null); }}
placeholder="Enter name"
/>
<small>{normalizeHunterName(hunterName).length}/{MAX_HUNTER_NAME_LENGTH}</small>
<div className="dialog-actions">
<ControllerButton id="confirm-create" selectedId={controller.selectedId} select={controller.select} className="front-primary" type="submit" disabled={!normalizeHunterName(hunterName)}>Create hunter</ControllerButton>
<ControllerButton id="cancel-create" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => setDialog(null)}>Cancel</ControllerButton>
</div>
</form>
) : dialog === "copy" ? (
<>
<span>Copy local save</span><h2>Choose destination</h2><p>Destination local save will be overwritten. Online copies stay unchanged.</p>
<div className="dialog-actions">
{slots.filter((slot) => slot.id !== selectedSlotId).map((slot) => (
<ControllerButton key={slot.id} id={`copy-${slot.id}`} selectedId={controller.selectedId} select={controller.select} onClick={() => { copySlot(selectedSlotId, slot.id); setDialog(null); }}>
Slot {slot.id}<small>{slot.local ? "Overwrite" : "Empty"}</small>
</ControllerButton>
))}
</div>
</>
) : (
<>
<span>Delete local save</span><h2>Erase slot {selectedSlotId}?</h2><p>Device copy will be removed. Existing online version remains available for download.</p>
<div className="dialog-actions">
<ControllerButton id="confirm-delete" selectedId={controller.selectedId} select={controller.select} className="is-danger" onClick={() => { deleteSlot(selectedSlotId); setDialog(null); }}>Delete local</ControllerButton>
<ControllerButton id="cancel-delete" selectedId={controller.selectedId} select={controller.select} onClick={() => setDialog(null)}>Cancel</ControllerButton>
</div>
</>
)}
</div>
)}
</FrontSurface>
}
bottom={
<FrontSurface className="save-context" bottom ariaLabel="Selected save information">
<header className="context-header"><span>Slot {selectedSlotId}</span><b>{cloudStatus}</b></header>
<div className="selected-save-summary">
{selected.local ?? selected.online ? (
<><div className="summary-avatar">{(selected.local ?? selected.online)!.hunterName[0]}</div><span><small>{selected.local ? "Device save" : "Online copy only"}</small><h2>{(selected.local ?? selected.online)!.hunterName}</h2><p>{(selected.local ?? selected.online)!.location}</p><time>{formatSaveTimestamp((selected.local ?? selected.online)!.updatedAt)}</time></span></>
) : (
<><div className="summary-avatar is-empty"></div><span><small>Local record</small><h2>Empty slot</h2><p>Create a hunter or download an online version.</p></span></>
)}
</div>
{(selected.local ?? selected.online) && (() => {
const save = (selected.local ?? selected.online)!;
const healer = HEALER_CLASSES[save.activeClassId];
return (
<>
<div className="save-dossier-stats">
<span><small>Active healer</small><strong>Lv {save.healers[save.activeClassId].level}</strong><em>{healer.name}</em></span>
<span><small>Play time</small><strong>{formatPlayTime(save.playSeconds)}</strong><em>Local activity</em></span>
<span><small>Boss kills</small><strong>{save.stats.totalBossKills}</strong><em>{save.stats.flawlessClears} flawless</em></span>
</div>
<div className="save-dossier-records">
<span><small>Roguelike best</small><b>Round {save.stats.highestRoguelikeRound}</b></span>
<span><small>Endless best</small><b>{save.stats.highestRogueTrialsEndlessKills} kills</b></span>
</div>
</>
);
})()}
<div className="save-copy-state">
<span><i className={selected.local ? "is-present" : ""} />Device copy<b>{selected.local ? formatSaveTimestamp(selected.local.updatedAt) : "Not present"}</b></span>
<span><i className={selected.online ? "is-present" : ""} />Online copy<b>{selected.online ? formatSaveTimestamp(selected.online.updatedAt) : accountId ? "Not uploaded" : "Sign-in required"}</b></span>
</div>
<div className="front-notice is-lower">{notice || "Use upper display for every save action. Details here follow selected slot."}</div>
</FrontSurface>
}
/>
);
}
const HOME_MODES: { id: GameModeId; category: "pve" | "pvp"; icon: string; label: string; copy: string }[] = [
{ id: "roguelike-pve", category: "pve", icon: "✦", label: "RPG Roguelike", copy: "Draft party, spells, gear, and route" },
{ id: "rogue-trials", category: "pve", icon: "Ⅲ", label: "Rogue Trials", copy: "Four rounds, then a boss trio" },
{ id: "dungeons", category: "pve", icon: "♜", label: "Dungeons", copy: "Choose your boss encounter" },
{ id: "hockey-healing", category: "pve", icon: "◌", label: "Hockey Healing", copy: "Defend goal under boss pressure" },
{ id: "hockey-healing-pvp", category: "pvp", icon: "◇", label: "Healing Hockey PVP", copy: "Online mirrored healer duel" },
{ id: "blockbreaker", category: "pve", icon: "▦", label: "Blockbreaker", copy: "Break color walls while healing" },
{ id: "aether-assault", category: "pve", icon: "⌁", label: "Aether Assault", copy: "Auto-fire through arcane formations" },
{ id: "roguelike-pvp", category: "pvp", icon: "⚔", label: "Roguelike PvP", copy: "Draft, race, sabotage" },
{ id: "stadium-pvp", category: "pvp", icon: "◉", label: "Stadium PvP", copy: "Prepared 5v5 rounds" },
];
const HOME_MODE_SECTIONS = [
{ id: "pve", title: "PVE Modes", copy: "Cooperative expeditions", modes: HOME_MODES.filter((mode) => mode.category === "pve") },
{ id: "pvp", title: "PVP Modes", copy: "Competitive arenas", modes: HOME_MODES.filter((mode) => mode.category === "pvp") },
] as const;
function HomeScreen() {
const hunter = useActiveHunter();
const accountId = useFrontendStore((state) => state.accountId);
const selectMode = useFrontendStore((state) => state.selectMode);
const selectHealerClass = useFrontendStore((state) => state.selectHealerClass);
const openAppearanceLab = useFrontendStore((state) => state.openAppearanceLab);
const openClassHelp = useFrontendStore((state) => state.openClassHelp);
const navigate = useFrontendStore((state) => state.navigate);
const actions = useMemo<MenuAction[]>(() => [
{ id: "roguelike-pve", run: () => selectMode("roguelike-pve"), neighbors: { left: "roguelike-pve", right: "rogue-trials", down: "dungeons" } },
{ id: "rogue-trials", run: () => selectMode("rogue-trials"), neighbors: { left: "roguelike-pve", right: "hockey-healing-pvp", down: "hockey-healing" } },
{ id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "dungeons", right: "hockey-healing", up: "roguelike-pve", down: "blockbreaker" } },
{ id: "hockey-healing", run: () => selectMode("hockey-healing"), neighbors: { left: "dungeons", right: "roguelike-pvp", up: "rogue-trials", down: "aether-assault" } },
{ id: "blockbreaker", run: () => selectMode("blockbreaker"), neighbors: { left: "blockbreaker", right: "aether-assault", up: "dungeons", down: "profile" } },
{ id: "aether-assault", run: () => selectMode("aether-assault"), neighbors: { left: "blockbreaker", right: "stadium-pvp", up: "hockey-healing", down: "appearance" } },
{ id: "hockey-healing-pvp", run: () => selectMode("hockey-healing-pvp"), neighbors: { left: "rogue-trials", right: "hockey-healing-pvp", down: "roguelike-pvp" } },
{ id: "roguelike-pvp", run: () => selectMode("roguelike-pvp"), neighbors: { left: "hockey-healing", right: "roguelike-pvp", up: "hockey-healing-pvp", down: "stadium-pvp" } },
{ id: "stadium-pvp", run: () => selectMode("stadium-pvp"), neighbors: { left: "aether-assault", right: "stadium-pvp", up: "roguelike-pvp", down: "class-help" } },
{ id: "profile", run: () => navigate("profile"), neighbors: { up: "blockbreaker", left: "class-help", right: "gear", down: "class-priest" } },
{ id: "gear", run: () => navigate("gear"), neighbors: { up: "aether-assault", left: "profile", right: "appearance", down: "class-druid" } },
{ id: "appearance", run: openAppearanceLab, neighbors: { up: "aether-assault", left: "gear", right: "settings", down: "class-priest" } },
{ id: "settings", run: () => navigate("settings"), neighbors: { up: "stadium-pvp", left: "appearance", right: "class-help", down: "class-druid" } },
{ id: "class-help", run: openClassHelp, neighbors: { up: "stadium-pvp", left: "settings", right: "profile", down: "class-chronomancer" } },
...HEALER_CLASS_ORDER.map((classId, index) => ({
id: `class-${classId}`,
run: () => selectHealerClass(classId),
neighbors: {
left: `class-${HEALER_CLASS_ORDER[index % 3 === 0 ? Math.min(index + 2, HEALER_CLASS_ORDER.length - 1) : index - 1]}`,
right: `class-${HEALER_CLASS_ORDER[index % 3 === 2 || index === HEALER_CLASS_ORDER.length - 1 ? index - (index % 3) : index + 1]}`,
up: index < 3 ? (["appearance", "settings", "settings"] as const)[index] : `class-${HEALER_CLASS_ORDER[index - 3]}`,
down: index + 3 < HEALER_CLASS_ORDER.length ? `class-${HEALER_CLASS_ORDER[index + 3]}` : "change-save",
},
})),
{ id: "change-save", run: () => navigate("saves"), neighbors: { up: "class-chronomancer" } },
], [navigate, openAppearanceLab, openClassHelp, selectHealerClass, selectMode]);
const controller = useMenuController(actions, { columns: 2, onBack: () => navigate("saves") });
if (!hunter) return null;
const activeHealer = HEALER_CLASSES[hunter.activeClassId];
const activeProgress = hunter.healers[hunter.activeClassId];
return (
<DualDisplayFrame
top={
<FrontSurface className="home-surface" ariaLabel="Main menu">
<header className="home-header"><BrandMark compact /><span>Welcome back, <b>{hunter.hunterName}</b></span><i>{accountId ? "● SYNC READY" : "○ OFFLINE"}</i></header>
<div className="home-mode-sections">
{HOME_MODE_SECTIONS.map((section) => (
<section key={section.id} className={`home-mode-section is-${section.id}`} aria-labelledby={`home-${section.id}-heading`}>
<header><span><small>{section.copy}</small><strong id={`home-${section.id}-heading`}>{section.title}</strong></span><b>{section.modes.length} ACTIVITIES</b></header>
<div className={`mode-grid mode-grid-${section.id}`}>
{section.modes.map((mode) => (
<ControllerButton key={mode.id} id={mode.id} selectedId={controller.selectedId} select={controller.select} className={`mode-card mode-card-${mode.id}`} onClick={() => selectMode(mode.id)}>
<i>{mode.icon}</i><span><small>{mode.copy}</small><strong>{mode.label}</strong></span><b></b>
</ControllerButton>
))}
</div>
</section>
))}
</div>
<div className="home-secondary-actions">
<ControllerButton id="profile" selectedId={controller.selectedId} select={controller.select} onClick={() => navigate("profile")}><i></i><span><strong>Hunter Profile</strong><small>Stats & collection log</small></span><b></b></ControllerButton>
<ControllerButton id="gear" selectedId={controller.selectedId} select={controller.select} onClick={() => navigate("gear")}><i></i><span><strong>Gear Upgrade</strong><small>Spend group drops</small></span><b></b></ControllerButton>
<ControllerButton id="appearance" selectedId={controller.selectedId} select={controller.select} onClick={openAppearanceLab}><i></i><span><strong>Appearance Lab</strong><small>Build and preview your healer</small></span><b></b></ControllerButton>
<ControllerButton id="settings" selectedId={controller.selectedId} select={controller.select} onClick={() => navigate("settings")}><i></i><span><strong>Settings</strong><small>Audio, display, controls</small></span><b></b></ControllerButton>
<ControllerButton id="class-help" selectedId={controller.selectedId} select={controller.select} onClick={openClassHelp}><i>?</i><span><strong>Class Help</strong><small>Abilities, rotations, synergies</small></span><b></b></ControllerButton>
</div>
<ControllerLegend back />
</FrontSurface>
}
bottom={
<FrontSurface className="hunter-context" bottom ariaLabel="Active hunter summary">
<header className="context-header"><span>Active hunter</span><b>LOCAL AUTOSAVE</b></header>
<div className="hunter-card">
<div className="hunter-crest">{hunter.hunterName[0]}<i style={{ background: activeHealer.color }}>{activeHealer.icon}</i></div>
<span><small>Level {activeProgress.level} · {activeHealer.specialization}</small><h2>{hunter.hunterName}</h2><p>{hunter.location}</p></span>
</div>
<div className="hunter-stat-row">
<span><small>Boss kills</small><strong>{hunter.stats.totalBossKills}</strong></span>
<span><small>Flawless</small><strong>{hunter.stats.flawlessClears}</strong></span>
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
</div>
<div className="healer-picker"><span>Choose healer</span><div>{HEALER_CLASS_ORDER.map((classId) => {
const healer = HEALER_CLASSES[classId];
const progress = hunter.healers[classId];
return <ControllerButton key={classId} id={`class-${classId}`} selectedId={controller.selectedId} select={controller.select} className={classId === hunter.activeClassId ? "is-selected" : ""} aria-pressed={classId === hunter.activeClassId} onClick={() => selectHealerClass(classId)}>
<i style={{ color: healer.color }}>{healer.icon}</i><span><strong>{healer.name}</strong><small>Level {progress.level} · {progress.inventory.length} items</small></span><b>{classId === hunter.activeClassId ? "✓" : ""}</b>
</ControllerButton>;
})}</div></div>
<ControllerButton id="change-save" selectedId={controller.selectedId} select={controller.select} className="change-save" onClick={() => navigate("saves")}><span>Change save slot</span><small>Last saved {formatSaveTimestamp(hunter.updatedAt)}</small></ControllerButton>
</FrontSurface>
}
/>
);
}
function ClassHelpScreen() {
const navigate = useFrontendStore((state) => state.navigate);
const classId = useFrontendStore((state) => state.guideClassId);
const selectedAbilityId = useFrontendStore((state) => state.guideAbilityId);
const selectClass = useFrontendStore((state) => state.selectGuideClass);
const selectAbility = useFrontendStore((state) => state.selectGuideAbility);
const healer = HEALER_CLASSES[classId];
const guide = HEALER_GUIDES[classId];
const selectedAbility = healer.abilities[selectedAbilityId];
const selectedGuide = guide.abilityGuides[selectedAbilityId];
const classIndex = HEALER_CLASS_ORDER.indexOf(classId);
const actions = useMemo<MenuAction[]>(() => [
{ id: "guide-back", run: () => navigate("home"), neighbors: { right: "guide-class-priest", down: "guide-class-priest" } },
...HEALER_CLASS_ORDER.map((candidate, index) => ({
id: `guide-class-${candidate}`,
run: () => selectClass(candidate),
neighbors: {
left: `guide-class-${HEALER_CLASS_ORDER[index === 0 ? HEALER_CLASS_ORDER.length - 1 : index - 1]}`,
right: `guide-class-${HEALER_CLASS_ORDER[(index + 1) % HEALER_CLASS_ORDER.length]}`,
up: "guide-back",
down: `guide-${ABILITY_ORDER[Math.min(index, ABILITY_ORDER.length - 1)]}`,
},
})),
...ABILITY_ORDER.map((abilityId, index) => ({
id: `guide-${abilityId}`,
run: () => selectAbility(abilityId),
neighbors: {
left: `guide-${ABILITY_ORDER[index % 3 === 0 ? index + 2 : index - 1]}`,
right: `guide-${ABILITY_ORDER[index % 3 === 2 ? index - 2 : index + 1]}`,
up: index < 3 ? `guide-class-${classId}` : `guide-${ABILITY_ORDER[index - 3]}`,
down: index < 3 ? `guide-${ABILITY_ORDER[index + 3]}` : "guide-back",
},
})),
], [classId, navigate, selectAbility, selectClass]);
const controller = useMenuController(actions, {
initialId: `guide-class-${classId}`,
onBack: () => navigate("home"),
});
return (
<DualDisplayFrame
top={
<FrontSurface
className="class-help-surface"
ariaLabel="Healing class guide"
>
<header className="front-screen-header class-help-header">
<BrandMark compact />
<div><span>Field manual</span><h1>Class Help</h1></div>
<small>{classIndex + 1} / {HEALER_CLASS_ORDER.length}</small>
<ControllerButton id="guide-back" selectedId={controller.selectedId} select={controller.select} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</ControllerButton>
</header>
<nav className="guide-class-tabs" aria-label="Healing classes">
{HEALER_CLASS_ORDER.map((candidate) => {
const candidateHealer = HEALER_CLASSES[candidate];
return (
<ControllerButton
key={candidate}
id={`guide-class-${candidate}`}
selectedId={controller.selectedId}
select={controller.select}
className={candidate === classId ? "is-selected" : ""}
style={{ "--guide-color": candidateHealer.color } as React.CSSProperties}
aria-pressed={candidate === classId}
onClick={() => selectClass(candidate)}
>
<i>{candidateHealer.icon}</i><span><strong>{candidateHealer.name}</strong><small>{HEALER_GUIDES[candidate].role}</small></span>
</ControllerButton>
);
})}
</nav>
<section className="guide-class-intro" style={{ "--guide-color": healer.color } as React.CSSProperties}>
<i>{healer.icon}</i>
<span><small>{healer.specialization} · {guide.learningCurve}</small><h2>{guide.role}</h2><p>{healer.description}</p></span>
<b>{healer.secondaryResourceName ? `${healer.resourceName} + ${healer.secondaryResourceName}` : healer.resourceName}</b>
</section>
<div className="guide-ability-grid" aria-label={`${healer.name} abilities`}>
{ABILITY_ORDER.map((abilityId) => {
const ability = healer.abilities[abilityId];
return (
<ControllerButton
key={abilityId}
id={`guide-${abilityId}`}
selectedId={controller.selectedId}
select={controller.select}
className={`guide-ability-card ${selectedAbilityId === abilityId ? "is-selected" : ""}`}
style={{ "--ability-color": ability.color } as React.CSSProperties}
aria-pressed={selectedAbilityId === abilityId}
trackPointer={false}
onClick={() => selectAbility(abilityId)}
>
<span className="guide-ability-icon"><b>{ability.icon}</b><small>{ability.gamepad}</small></span>
<span className="guide-ability-copy"><strong>{ability.name}</strong><small>{ability.description}</small></span>
<span className="guide-ability-meta"><b>{ability.cooldown ? `${ability.cooldown}s CD` : "No cooldown"}</b><b>{ability.mana} {healer.resourceName}</b><b>{ability.targeting}</b></span>
</ControllerButton>
);
})}
</div>
<ControllerLegend back />
</FrontSurface>
}
bottom={
<FrontSurface className="class-help-context" bottom ariaLabel={`${healer.name} play guide`}>
<header className="context-header"><span>{healer.name} field guide</span><b>{guide.learningCurve.toUpperCase()}</b></header>
<section className="guide-context-class" style={{ "--guide-color": healer.color } as React.CSSProperties}>
<i>{healer.icon}</i><span><small>{healer.specialization}</small><h2>{guide.role}</h2><p>{guide.resourceGuide}</p></span>
</section>
<section className="guide-core-loop">
<header><span>Core loop</span><b>0103</b></header>
{guide.coreLoop.map((step, index) => <p key={step}><i>0{index + 1}</i><span>{step}</span></p>)}
</section>
<section className="guide-selected-detail" style={{ "--ability-color": selectedAbility.color } as React.CSSProperties}>
<header><i>{selectedAbility.icon}</i><span><small>Selected ability · {selectedAbility.gamepad}</small><h3>{selectedAbility.name}</h3></span></header>
<p>{selectedGuide.useWhen}</p>
<aside><b>Field tip</b><span>{selectedGuide.fieldTip}</span></aside>
<div className="guide-synergy-list"><span>Synergies</span>{selectedGuide.synergies.map((synergy) => {
const pairedAbility = healer.abilities[synergy.with];
return <article key={`${synergy.with}-${synergy.summary}`}><i>{pairedAbility.icon}</i><span><strong>{pairedAbility.name}</strong><small>{synergy.summary}</small></span><b>{synergy.kind === "mechanic" ? "DIRECT" : "COMBO"}</b></article>;
})}</div>
</section>
</FrontSurface>
}
/>
);
}
function ProfileScreen() {
const hunter = useActiveHunter();
const accountId = useFrontendStore((state) => state.accountId);
const navigate = useFrontendStore((state) => state.navigate);
const uploadSlot = useFrontendStore((state) => state.uploadSlot);
const collectionView = useFrontendStore((state) => state.profileCollectionView);
const groupId = useFrontendStore((state) => state.selectedProfileGroupId);
const selectedStat = useFrontendStore((state) => state.selectedProfileStatId);
const setCollectionView = useFrontendStore((state) => state.selectProfileCollectionView);
const setGroupId = useFrontendStore((state) => state.selectProfileGroup);
const setSelectedStat = useFrontendStore((state) => state.selectProfileStat);
const collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]);
const bosses = useMemo(() => alphabeticalBosses(collections), [collections]);
const collection = collections.find((group) => group.groupId === groupId) ?? collections[0];
const profileView = collectionView === "loot" ? "loot" : "stats";
const activeSection = profileSectionForStat(selectedStat);
const activeSectionDefinition = PROFILE_SECTIONS.find((section) => section.id === activeSection) ?? PROFILE_SECTIONS[0];
const selectedBoss = isBossProfileStat(selectedStat) ? bosses.find((boss) => boss.bossId === selectedStat) : undefined;
const [leaderboard, setLeaderboard] = useState<LeaderboardResult | null>(null);
const [leaderboardStatus, setLeaderboardStatus] = useState("");
const [leaderboardUpdatedAt, setLeaderboardUpdatedAt] = useState<string | null>(null);
const [leaderboardOwner, setLeaderboardOwner] = useState<string | null>(null);
const [refreshingLeaderboard, setRefreshingLeaderboard] = useState(false);
const leaderboardRequestId = useRef(0);
const leaderboardRefreshActive = useRef(false);
const displaySurface = document.documentElement.dataset.displaySurface;
const rendersTopSurface = displaySurface !== "bottom";
const rendersBottomSurface = displaySurface !== "top";
useEffect(() => {
if (!isBossProfileStat(selectedStat) || bosses.some((boss) => boss.bossId === selectedStat)) return;
setSelectedStat(bosses[0]?.bossId ?? "roguelike");
}, [bosses, selectedStat, setSelectedStat]);
useEffect(() => {
if (!rendersTopSurface || !hunter || profileView !== "stats" || activeSection === "bosses") return;
const requestId = ++leaderboardRequestId.current;
leaderboardRefreshActive.current = false;
setRefreshingLeaderboard(false);
const cached = leaderboardCache.read(hunter.slotId, selectedStat, hunter.hunterName, accountId);
setLeaderboard(cached?.result ?? null);
setLeaderboardUpdatedAt(cached?.updatedAt ?? null);
setLeaderboardOwner(cached?.accountId ?? accountId);
setLeaderboardStatus(cached
? ""
: accountId
? "No cached rankings. Refresh when online."
: "Sign in once, then refresh rankings for offline viewing.");
return () => {
if (leaderboardRequestId.current === requestId) leaderboardRequestId.current += 1;
};
}, [accountId, activeSection, hunter, profileView, rendersTopSurface, selectedStat]);
const refreshLeaderboard = useCallback(async () => {
if (!rendersTopSurface || !hunter || leaderboardRefreshActive.current) return;
if (!accountId) {
setLeaderboardStatus("Sign in to refresh online rankings.");
return;
}
if (!networkAppearsOnline()) {
setLeaderboardStatus("Offline. Cached rankings remain available.");
return;
}
const requestId = ++leaderboardRequestId.current;
leaderboardRefreshActive.current = true;
setRefreshingLeaderboard(true);
if (hasPendingSaveSync(hunter.slotId)) {
setLeaderboardStatus("Publishing local records…");
const uploaded = await uploadSlot(hunter.slotId);
if (leaderboardRequestId.current !== requestId) return;
if (!uploaded) {
setLeaderboardStatus("Local records queued. Refresh when connection returns.");
leaderboardRefreshActive.current = false;
setRefreshingLeaderboard(false);
return;
}
}
setLeaderboardStatus("Refreshing online rankings…");
const request = selectedStat === "roguelike"
? onlineRepository.roguelikeLeaderboard(hunter.slotId)
: selectedStat === "rogue-trials-endless"
? onlineRepository.rogueTrialsEndlessLeaderboard(hunter.slotId)
: selectedStat === "hockey-healing"
? onlineRepository.hockeyHealingLeaderboard(hunter.slotId)
: selectedStat === "hockey-pvp-wins"
? onlineRepository.hockeyPvpWinsLeaderboard(hunter.slotId)
: selectedStat === "hockey-pvp-boss-kills"
? onlineRepository.hockeyPvpBossKillsLeaderboard(hunter.slotId)
: selectedStat === "blockbreaker-bricks"
? onlineRepository.blockbreakerBricksLeaderboard(hunter.slotId)
: selectedStat === "blockbreaker-time"
? onlineRepository.blockbreakerTimeLeaderboard(hunter.slotId)
: selectedStat === "blockbreaker-score"
? onlineRepository.blockbreakerScoreLeaderboard(hunter.slotId)
: selectedStat === "aether-assault"
? onlineRepository.aetherAssaultLeaderboard(hunter.slotId)
: onlineRepository.bossLeaderboard(selectedStat, hunter.slotId);
try {
const result = await request;
const cached = leaderboardCache.write(accountId, hunter.hunterName, hunter.slotId, selectedStat, result);
if (leaderboardRequestId.current !== requestId) return;
setLeaderboard(result);
setLeaderboardUpdatedAt(cached.updatedAt);
setLeaderboardOwner(accountId);
setLeaderboardStatus("");
} catch (error) {
if (leaderboardRequestId.current !== requestId) return;
setLeaderboardStatus(error instanceof Error ? error.message : "Leaderboard unavailable.");
} finally {
if (leaderboardRequestId.current === requestId) {
leaderboardRefreshActive.current = false;
setRefreshingLeaderboard(false);
}
}
}, [accountId, hunter, rendersTopSurface, selectedStat, uploadSlot]);
const sectionMetricIds = activeSectionDefinition.statIds;
const bossGridColumns = 7;
const actions = useMemo<MenuAction[]>(() => [
...(rendersTopSurface ? [
{ id: "view-stats", run: () => setCollectionView("stats"), neighbors: { right: "view-loot", down: profileView === "stats" ? "section-roguelike" : undefined } },
{ id: "view-loot", run: () => setCollectionView("loot"), neighbors: { left: "view-stats" } },
...(profileView === "stats" ? [
...PROFILE_SECTIONS.map((section, index) => ({
id: `section-${section.id}`,
run: () => setSelectedStat(defaultStatForSection(section.id, bosses)),
neighbors: {
up: index === 0 ? "view-stats" : `section-${PROFILE_SECTIONS[index - 1].id}`,
down: index === PROFILE_SECTIONS.length - 1 ? "section-roguelike" : `section-${PROFILE_SECTIONS[index + 1].id}`,
right: section.id === "bosses" ? `boss-card-${bosses[0]?.bossId}` : `metric-${section.statIds[0]}`,
},
})),
...(activeSection === "bosses" ? bosses.map((boss, index) => {
const rowStart = index % bossGridColumns === 0;
const rowEnd = index % bossGridColumns === bossGridColumns - 1 || index === bosses.length - 1;
return {
id: `boss-card-${boss.bossId}`,
run: () => setSelectedStat(boss.bossId),
neighbors: {
left: rowStart ? "section-bosses" : `boss-card-${bosses[index - 1].bossId}`,
right: rowEnd ? `boss-card-${boss.bossId}` : `boss-card-${bosses[index + 1].bossId}`,
up: index < bossGridColumns ? "view-stats" : `boss-card-${bosses[index - bossGridColumns].bossId}`,
down: bosses[index + bossGridColumns] ? `boss-card-${bosses[index + bossGridColumns].bossId}` : `boss-card-${boss.bossId}`,
},
};
}) : [
...sectionMetricIds.map((statId, index) => ({
id: `metric-${statId}`,
run: () => setSelectedStat(statId),
neighbors: {
left: index === 0 ? `section-${activeSection}` : `metric-${sectionMetricIds[index - 1]}`,
right: index === sectionMetricIds.length - 1 ? "refresh-leaderboard" : `metric-${sectionMetricIds[index + 1]}`,
up: "view-stats",
down: "refresh-leaderboard",
},
})),
{ id: "refresh-leaderboard", run: () => { void refreshLeaderboard(); }, enabled: Boolean(accountId), neighbors: { left: `metric-${selectedStat}`, up: `metric-${selectedStat}` } },
]),
] : []),
{ id: "back", run: () => navigate("home") },
] : []),
...(rendersBottomSurface && profileView === "loot" ? collections.map((group) => ({ id: `group-${group.groupId}`, run: () => setGroupId(group.groupId) })) : []),
], [accountId, activeSection, bosses, collections, navigate, profileView, refreshLeaderboard, rendersBottomSurface, rendersTopSurface, sectionMetricIds, selectedStat, setCollectionView, setGroupId, setSelectedStat]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
useEffect(() => {
const hoveredSection = PROFILE_SECTIONS.find((section) => `section-${section.id}` === controller.selectedId);
if (hoveredSection) {
const nextStat = defaultStatForSection(hoveredSection.id, bosses);
if (nextStat !== selectedStat) setSelectedStat(nextStat);
return;
}
const hoveredMetric = sectionMetricIds.find((statId) => `metric-${statId}` === controller.selectedId);
if (hoveredMetric && hoveredMetric !== selectedStat) {
setSelectedStat(hoveredMetric);
return;
}
const hoveredBoss = bosses.find((boss) => `boss-card-${boss.bossId}` === controller.selectedId);
if (hoveredBoss && hoveredBoss.bossId !== selectedStat) setSelectedStat(hoveredBoss.bossId);
}, [bosses, controller.selectedId, sectionMetricIds, selectedStat, setSelectedStat]);
if (!hunter || !collection) return null;
const activeHealer = HEALER_CLASSES[hunter.activeClassId];
const activeProgress = hunter.healers[hunter.activeClassId];
const earned = collection.drops.filter((drop) => drop.count > 0).length;
const petsOwned = bosses.filter((boss) => boss.pet.count > 0).length;
const hockeyDuration = `${Math.floor(hunter.stats.longestHockeyHealingSecondsAtBest / 60)}:${String(Math.floor(hunter.stats.longestHockeyHealingSecondsAtBest % 60)).padStart(2, "0")}`;
const blockbreakerDuration = `${Math.floor(hunter.stats.longestBlockbreakerSeconds / 60)}:${String(Math.floor(hunter.stats.longestBlockbreakerSeconds % 60)).padStart(2, "0")}`;
const aetherDuration = `${Math.floor(hunter.stats.longestAetherAssaultSecondsAtBest / 60)}:${String(Math.floor(hunter.stats.longestAetherAssaultSecondsAtBest % 60)).padStart(2, "0")}`;
const metrics: { id: ProfileStatId; label: string; value: string; copy: string }[] = activeSection === "roguelike"
? [{ id: "roguelike", label: "Highest round", value: hunter.stats.highestRoguelikeRound.toLocaleString(), copy: "Best run before defeat" }]
: activeSection === "rogue-trials"
? [{ id: "rogue-trials-endless", label: "Endless best", value: hunter.stats.highestRogueTrialsEndlessKills.toLocaleString(), copy: "Bosses defeated in one run" }]
: activeSection === "hockey"
? [{ id: "hockey-healing", label: "Hockey record", value: `${hunter.stats.highestHockeyHealingReturns} returns`, copy: `${hockeyDuration} longest survival` }]
: activeSection === "hockey-pvp"
? [
{ id: "hockey-pvp-wins", label: "Match record", value: `${hunter.stats.hockeyHealingPvpWins}W · ${hunter.stats.hockeyHealingPvpLosses}L`, copy: "Lifetime PVP results" },
{ id: "hockey-pvp-boss-kills", label: "Boss race kills", value: hunter.stats.hockeyHealingPvpBossKills.toLocaleString(), copy: "Lifetime PVP bosses" },
]
: activeSection === "aether-assault"
? [{ id: "aether-assault", label: "Overall score", value: hunter.stats.highestAetherAssaultScore.toLocaleString(), copy: `Wave ${hunter.stats.highestAetherAssaultWaveAtBest} · ${aetherDuration}` }]
: [
{ id: "blockbreaker-score", label: "Overall score", value: hunter.stats.highestBlockbreakerScore.toLocaleString(), copy: "Highest single-run score" },
{ id: "blockbreaker-bricks", label: "Bricks broken", value: hunter.stats.highestBlockbreakerBricks.toLocaleString(), copy: "Most in one run" },
{ id: "blockbreaker-time", label: "Time survived", value: blockbreakerDuration, copy: "Longest run" },
];
const selectedStatValue = selectedStat === "roguelike"
? hunter.stats.highestRoguelikeRound
: selectedStat === "rogue-trials-endless"
? hunter.stats.highestRogueTrialsEndlessKills
: selectedStat === "hockey-healing"
? hunter.stats.highestHockeyHealingReturns
: selectedStat === "hockey-pvp-wins"
? hunter.stats.hockeyHealingPvpWins
: selectedStat === "hockey-pvp-boss-kills"
? hunter.stats.hockeyHealingPvpBossKills
: selectedStat === "blockbreaker-bricks"
? hunter.stats.highestBlockbreakerBricks
: selectedStat === "blockbreaker-time"
? hunter.stats.longestBlockbreakerSeconds
: selectedStat === "blockbreaker-score"
? hunter.stats.highestBlockbreakerScore
: selectedStat === "aether-assault"
? hunter.stats.highestAetherAssaultScore
: isBossProfileStat(selectedStat)
? hunter.stats.bossKills[selectedStat] ?? 0
: 0;
const selectedStatLabel = selectedStat === "roguelike"
? "Roguelike rounds"
: selectedStat === "rogue-trials-endless"
? "Rogue Trials endless kills"
: selectedStat === "hockey-healing"
? "Hockey Healing returns"
: selectedStat === "hockey-pvp-wins"
? "Healing Hockey PVP wins"
: selectedStat === "hockey-pvp-boss-kills"
? "Healing Hockey PVP boss kills"
: selectedStat === "blockbreaker-bricks"
? "Blockbreaker bricks broken"
: selectedStat === "blockbreaker-time"
? "Blockbreaker survival time"
: selectedStat === "blockbreaker-score"
? "Blockbreaker overall score"
: selectedStat === "aether-assault"
? "Aether Assault score"
: isBossProfileStat(selectedStat)
? BOSS_DEFINITIONS[selectedStat].name
: "Hunter record";
const leaderboardValue = (value: number, secondaryValue?: number) => selectedStat === "hockey-healing"
? `${value} · ${Math.floor((secondaryValue ?? 0) / 60)}:${String(Math.floor((secondaryValue ?? 0) % 60)).padStart(2, "0")}`
: selectedStat === "hockey-pvp-wins"
? `${value}W · ${secondaryValue ?? 0}L`
: selectedStat === "blockbreaker-time"
? `${Math.floor(value / 60)}:${String(Math.floor(value % 60)).padStart(2, "0")}`
: selectedStat === "aether-assault"
? `${value.toLocaleString()} · Wave ${secondaryValue ?? 0}`
: value.toLocaleString();
const selectedStatSummary = selectedStat === "hockey-healing"
? `${selectedStatValue} returns · ${hockeyDuration}`
: selectedStat === "hockey-pvp-wins"
? `${hunter.stats.hockeyHealingPvpWins}W · ${hunter.stats.hockeyHealingPvpLosses}L`
: selectedStat === "blockbreaker-time"
? `${Math.floor(selectedStatValue / 60)}:${String(Math.floor(selectedStatValue % 60)).padStart(2, "0")} survived`
: selectedStat === "blockbreaker-score"
? `${selectedStatValue.toLocaleString()} points`
: selectedStat === "aether-assault"
? `${selectedStatValue.toLocaleString()} points · Wave ${hunter.stats.highestAetherAssaultWaveAtBest}`
: selectedStat === "blockbreaker-bricks"
? `${selectedStatValue.toLocaleString()} bricks`
: `${selectedStatValue.toLocaleString()} ${selectedStat === "roguelike" ? "round" : "kills"}`;
const selectedSecondaryValue = selectedStat === "hockey-healing"
? hunter.stats.longestHockeyHealingSecondsAtBest
: selectedStat === "hockey-pvp-wins"
? hunter.stats.hockeyHealingPvpLosses
: selectedStat === "aether-assault"
? hunter.stats.highestAetherAssaultWaveAtBest
: undefined;
const leaderboardMeta = leaderboardStatus
|| (leaderboardUpdatedAt ? `Cached ${formatSaveTimestamp(leaderboardUpdatedAt)}` : "Offline cache empty");
return (
<DualDisplayFrame
top={
<FrontSurface className="profile-surface" ariaLabel="Hunter profile collection log">
<header className="front-screen-header profile-header"><BrandMark compact /><div><span>Hunter profile</span><h1>Records</h1></div><div className="profile-view-tabs" role="tablist" aria-label="Profile view">
<ControllerButton id="view-stats" selectedId={controller.selectedId} select={controller.select} className={profileView === "stats" ? "is-selected" : ""} role="tab" aria-selected={profileView === "stats"} onClick={() => setCollectionView("stats")}>Records</ControllerButton>
<ControllerButton id="view-loot" selectedId={controller.selectedId} select={controller.select} className={profileView === "loot" ? "is-selected" : ""} role="tab" aria-selected={profileView === "loot"} onClick={() => setCollectionView("loot")}>Group Loot</ControllerButton>
</div><ControllerButton id="back" selectedId={controller.selectedId} select={controller.select} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</ControllerButton></header>
{profileView === "loot" ? <>
<div className="collection-heading"><span><small>Shared group drops · Core: {collection.coreMechanic}</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
<div className="collection-grid">
{collection.drops.map((drop) => (
<article key={drop.id} className={`collection-drop rarity-${drop.rarity.toLowerCase()} ${drop.count === 0 ? "is-missing" : ""}`}>
<span className="drop-icon">{drop.icon}<b>{drop.count}</b></span>
<small>{drop.rarity}</small><strong>{drop.name}</strong>
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : `Defeat a Group ${collection.groupLetter} boss`}</p>
<small>{drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""}</small>
</article>
))}
</div>
<div className="collection-note"><i></i><span><strong>Boss pets live in the Bosses record grid.</strong><small>Every boss, kill count, and pet status stays together.</small></span></div>
</> : <>
<div className="collection-heading boss-stats-heading"><span><small>Lifetime records · Controller-first collection log</small><h2>Hunter Records</h2></span><b>{petsOwned} / {bosses.length} boss pets found</b></div>
<div className={`boss-stats-layout ${activeSection === "bosses" ? "is-boss-index" : ""}`}>
<section className="boss-stat-selector" aria-label="Hunter record section">
{PROFILE_SECTIONS.map((section) => <ControllerButton key={section.id} id={`section-${section.id}`} selectedId={controller.selectedId} select={controller.select} className={activeSection === section.id ? "is-selected" : ""} onClick={() => setSelectedStat(defaultStatForSection(section.id, bosses))}><i>{section.icon}</i><span><strong>{section.label}</strong><small>{section.copy}</small></span><b></b></ControllerButton>)}
</section>
{activeSection === "bosses" ? <section className="boss-index-panel" aria-label="All bosses alphabetically">
<header><span><small>Boss pets · AZ</small><strong>Every guardian</strong></span><b>{hunter.stats.totalBossKills.toLocaleString()} total kills</b></header>
<div className="boss-pet-grid" role="grid" aria-label="Boss pet kill records">
{bosses.map((boss) => {
const owned = boss.pet.count > 0;
return <ControllerButton key={boss.bossId} id={`boss-card-${boss.bossId}`} selectedId={controller.selectedId} select={controller.select} className={`boss-pet-card ${selectedStat === boss.bossId ? "is-selected" : ""} ${owned ? "is-owned" : "is-unowned"}`} style={{ "--boss-accent": BOSS_DEFINITIONS[boss.bossId].accent } as React.CSSProperties} role="gridcell" aria-label={`${boss.bossName}: ${boss.kills} kills; boss pet ${owned ? "owned" : "not owned"}`} aria-pressed={selectedStat === boss.bossId} onClick={() => setSelectedStat(boss.bossId)}>
<span className="boss-pet-icon" aria-hidden="true">{boss.pet.icon}</span>
<span className="boss-kill-badge"><small>Kills</small><b>{boss.kills.toLocaleString()}</b></span>
<strong>{boss.bossName}</strong>
<small>{owned ? `Pet owned${boss.pet.count > 1 ? ` ×${boss.pet.count}` : ""}` : "Pet not found"}</small>
</ControllerButton>;
})}
</div>
</section> : <section className="profile-records-panel" aria-label={`${activeSectionDefinition.label} records`}>
<header><span><small>All {activeSectionDefinition.label} stats</small><strong>{activeSectionDefinition.copy}</strong></span><b>{metrics.length} record{metrics.length === 1 ? "" : "s"}</b></header>
<div className={`profile-metric-grid metric-count-${metrics.length}`}>
{metrics.map((metric) => <ControllerButton key={metric.id} id={`metric-${metric.id}`} selectedId={controller.selectedId} select={controller.select} className={selectedStat === metric.id ? "is-selected" : ""} aria-pressed={selectedStat === metric.id} onClick={() => setSelectedStat(metric.id)}><small>{metric.label}</small><strong>{metric.value}</strong><span>{metric.copy}</span></ControllerButton>)}
</div>
<section className="leaderboard-panel profile-leaderboard" aria-label="Overall leaderboard">
<header><span><small role="status" aria-live="polite">{leaderboardMeta}</small><strong>{selectedStatLabel}</strong></span><div className="leaderboard-header-actions"><b>{selectedStatSummary}</b><ControllerButton id="refresh-leaderboard" selectedId={controller.selectedId} select={controller.select} className="leaderboard-refresh" disabled={!accountId || refreshingLeaderboard} onClick={() => { void refreshLeaderboard(); }}>{refreshingLeaderboard ? "Refreshing…" : "Refresh online"}</ControllerButton></div></header>
{leaderboard ? <div className="leaderboard-rows">
{leaderboard.top.length ? leaderboard.top.slice(0, 4).map((entry) => <div key={`${entry.username}-${entry.slotId}`} className={entry.username === leaderboardOwner && entry.slotId === hunter.slotId ? "is-you" : ""}><b>#{entry.rank}</b><span><strong>{entry.hunterName}</strong><small>{entry.username}</small></span><em>{leaderboardValue(entry.value, entry.secondaryValue)}</em></div>) : <div className="leaderboard-empty">No ranked hunters yet.</div>}
</div> : <div className="leaderboard-status">{leaderboardStatus || "No cached rankings."}</div>}
<div className="leaderboard-self"><b>{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}</b><span><strong>Your rank · {hunter.hunterName}</strong><small>{leaderboardOwner ?? "Offline hunter"}</small></span><em>{leaderboardValue(selectedStatValue, selectedSecondaryValue)}</em></div>
</section>
</section>}
</div>
<div className="collection-note trophy-note"><i>{activeSection === "bosses" ? "♛" : "◆"}</i><span><strong>{activeSection === "bosses" ? "Move through the grid to inspect a boss below." : "Every stat in this section stays visible together."}</strong><small>{activeSection === "bosses" ? "The lower screen shows kills, pet ownership, and collection details." : "Select a record card to change the online leaderboard."}</small></span></div>
</>}
</FrontSurface>
}
bottom={
<FrontSurface className={`profile-context ${activeSection === "bosses" && selectedBoss ? "is-boss-detail" : ""}`} bottom ariaLabel="Hunter statistics and selected boss details">
{profileView === "stats" && activeSection === "bosses" && selectedBoss ? <>
<header className="context-header"><span>Boss record · {selectedBoss.bossName}</span><b>{selectedBoss.pet.count > 0 ? "PET OWNED" : "PET NOT FOUND"}</b></header>
<div className="selected-boss-dossier" style={{ "--boss-accent": BOSS_DEFINITIONS[selectedBoss.bossId].accent } as React.CSSProperties}>
<div className={`selected-boss-pet ${selectedBoss.pet.count > 0 ? "is-owned" : "is-unowned"}`}><span>{selectedBoss.pet.icon}</span><small>Boss pet icon</small></div>
<div className="selected-boss-copy"><small>{BOSS_DEFINITIONS[selectedBoss.bossId].title}</small><h2>{selectedBoss.bossName}</h2><p>{BOSS_DEFINITIONS[selectedBoss.bossId].summary}</p></div>
</div>
<div className="selected-boss-stats">
<span><small>Times killed</small><strong>{selectedBoss.kills.toLocaleString()}</strong></span>
<span><small>Boss pet</small><strong>{selectedBoss.pet.count > 0 ? "Owned" : "Not found"}</strong></span>
<span><small>Pet copies</small><strong>{selectedBoss.pet.count.toLocaleString()}</strong></span>
<span><small>Drop chance</small><strong>{selectedBoss.pet.chance}</strong></span>
</div>
<div className="selected-boss-mechanics"><span>Encounter read</span><strong>{BOSS_DEFINITIONS[selectedBoss.bossId].briefing}</strong><small>Move on upper-screen grid to inspect another boss.</small></div>
</> : <>
<header className="context-header"><span>{hunter.hunterName} · {activeHealer.name} stats</span><b>LEVEL {activeProgress.level}</b></header>
<div className="profile-stats">
<span><small>Total boss kills</small><strong>{hunter.stats.totalBossKills}</strong></span>
<span><small>Flawless clears</small><strong>{hunter.stats.flawlessClears}</strong></span>
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
<span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span>
<span><small>Highest roguelike round</small><strong>{hunter.stats.highestRoguelikeRound}</strong></span>
<span><small>Endless best</small><strong>{hunter.stats.highestRogueTrialsEndlessKills}</strong></span>
<span><small>Hockey PVP record</small><strong>{hunter.stats.hockeyHealingPvpWins}W · {hunter.stats.hockeyHealingPvpLosses}L</strong></span>
<span><small>Boss pets found</small><strong>{petsOwned}/{bosses.length}</strong></span>
</div>
{profileView === "loot" && <div className="boss-log"><span>Mechanic groups</span>{collections.map((group) => (
<ControllerButton key={group.groupId} id={`group-${group.groupId}`} selectedId={controller.selectedId} select={controller.select} className={group.groupId === collection.groupId ? "is-selected" : ""} onClick={() => setGroupId(group.groupId)}>
<i>{group.defeated ? group.groupLetter : "?"}</i><span><strong>Group {group.groupLetter} · {group.groupName}</strong><small>{group.bosses.reduce((sum, boss) => sum + boss.kills, 0)} kills · {group.coreMechanic}</small></span><b>{group.drops.filter((drop) => drop.count > 0).length}/{group.drops.length}</b>
</ControllerButton>
))}</div>}
</>}
</FrontSurface>
}
/>
);
}
function GearScreen() {
const hunter = useActiveHunter();
const navigate = useFrontendStore((state) => state.navigate);
const notice = useFrontendStore((state) => state.notice);
const selectedOwnerId = useFrontendStore((state) => state.selectedGearOwnerId);
const selectedSlotId = useFrontendStore((state) => state.selectedGearSlotId);
const workshopMode = useFrontendStore((state) => state.gearWorkshopMode);
const selectedInfusionId = useFrontendStore((state) => state.selectedInfusionId);
const selectedPassiveAbilityId = useFrontendStore((state) => state.selectedPassiveAbilityId);
const selectedPassiveInfusionId = useFrontendStore((state) => state.selectedPassiveInfusionId);
const selectOwner = useFrontendStore((state) => state.selectGearOwner);
const selectSlot = useFrontendStore((state) => state.selectGearSlot);
const selectWorkshopMode = useFrontendStore((state) => state.selectGearWorkshopMode);
const selectInfusion = useFrontendStore((state) => state.selectInfusion);
const selectPassiveAbility = useFrontendStore((state) => state.selectPassiveAbility);
const selectPassiveInfusion = useFrontendStore((state) => state.selectPassiveInfusion);
const upgrade = useFrontendStore((state) => state.upgradeSelectedGear);
const installInfusion = useFrontendStore((state) => state.equipSelectedInfusion);
const installPassive = useFrontendStore((state) => state.equipPassiveInfusion);
const slot = hunter?.gearProgress[selectedOwnerId].slots[selectedSlotId];
const upgradeReadiness = useMemo(() => {
const owners = new Set<GearOwnerId>();
const slots = new Set<string>();
if (!hunter) return { owners, slots };
for (const ownerId of GEAR_OWNER_ORDER) {
for (const slotId of GEAR_SLOT_ORDER) {
if (!canUpgradeGearSlot(hunter.gearProgress, hunter.materials, ownerId, slotId)) continue;
owners.add(ownerId);
slots.add(`${ownerId}:${slotId}`);
}
}
return { owners, slots };
}, [hunter]);
const recipe = GEAR_RECIPES[selectedOwnerId][selectedSlotId];
const costs = hunter && slot ? gearUpgradeCosts(selectedOwnerId, selectedSlotId, slot.level) : [];
const canUpgrade = upgradeReadiness.slots.has(`${selectedOwnerId}:${selectedSlotId}`);
const infusionChoices = infusionsForOwner(selectedOwnerId);
const selectedInfusion = infusionChoices.find((choice) => choice.id === selectedInfusionId) ?? infusionChoices[0];
const selectedInfusionCosts = hunter ? infusionCosts(selectedOwnerId, selectedSlotId, selectedInfusion.id) : [];
const activeUnlocked = Boolean(hunter && activeInfusionUnlocked(hunter.gearProgress[selectedOwnerId]));
const anchorUnlocked = Boolean(slot && slot.level >= ACTIVE_INFUSION_MIN_GEAR_LEVEL);
const infusionEquipped = hunter?.gearProgress[selectedOwnerId].infusionAbilityId === selectedInfusion.id;
const canInstallInfusion = Boolean(hunter && activeUnlocked && anchorUnlocked && !infusionEquipped && canAffordGearUpgrade(hunter.materials, selectedInfusionCosts));
const passiveUnlocked = Boolean(hunter && passiveInfusionUnlocked(hunter.gearProgress));
const healerOwner = isHealerClassId(selectedOwnerId);
const passiveHealerClassId = isHealerClassId(selectedOwnerId) ? selectedOwnerId : "priest";
const passiveChoices = PASSIVE_INFUSIONS.filter((passive) => passive.abilitySlotId === selectedPassiveAbilityId);
const selectedPassive = RUN_BUFFS[selectedPassiveInfusionId];
const healerAbilities = HEALER_CLASSES[passiveHealerClassId].abilities;
const previewEntryId = workshopMode === "upgrade" ? "upgrade" : `infusion-${infusionChoices[0].id}`;
const actions = useMemo<MenuAction[]>(() => [
...GEAR_OWNER_ORDER.map((ownerId, index) => ({
id: `owner-${ownerId}`,
run: () => selectOwner(ownerId),
neighbors: {
up: index === 0 ? "back" : `owner-${GEAR_OWNER_ORDER[index - 1]}`,
down: index === GEAR_OWNER_ORDER.length - 1 ? previewEntryId : `owner-${GEAR_OWNER_ORDER[index + 1]}`,
right: `slot-${selectedSlotId}`,
},
})),
...GEAR_SLOT_ORDER.map((slotId, index) => ({
id: `slot-${slotId}`,
run: () => selectSlot(slotId),
neighbors: {
up: index === 0 ? "workshop-upgrade" : `slot-${GEAR_SLOT_ORDER[index - 1]}`,
down: index === GEAR_SLOT_ORDER.length - 1 ? previewEntryId : `slot-${GEAR_SLOT_ORDER[index + 1]}`,
left: `owner-${selectedOwnerId}`,
right: previewEntryId,
},
})),
{ id: "workshop-upgrade", run: () => selectWorkshopMode("upgrade"), neighbors: { right: "workshop-infusion", down: "slot-weapon" } },
{ id: "workshop-infusion", run: () => selectWorkshopMode("infusion"), neighbors: { left: "workshop-upgrade", down: `infusion-${infusionChoices[0].id}` } },
...infusionChoices.map((infusion, index) => ({
id: `infusion-${infusion.id}`,
run: () => selectInfusion(infusion.id),
neighbors: {
up: index === 0 ? "workshop-infusion" : `infusion-${infusionChoices[index - 1].id}`,
down: index === infusionChoices.length - 1 ? (healerOwner ? `passive-ability-${selectedPassiveAbilityId}` : "install-infusion") : `infusion-${infusionChoices[index + 1].id}`,
left: `slot-${selectedSlotId}`,
right: index === infusionChoices.length - 1 ? "install-infusion" : undefined,
},
})),
...(healerOwner ? ABILITY_ORDER.map((abilityId, index) => ({
id: `passive-ability-${abilityId}`,
run: () => selectPassiveAbility(abilityId),
neighbors: {
up: index < 3 ? `infusion-${infusionChoices[infusionChoices.length - 1].id}` : `passive-ability-${ABILITY_ORDER[index - 3]}`,
down: index < 3
? `passive-ability-${ABILITY_ORDER[index + 3]}`
: `passive-${passiveChoices[Math.min(index - 3, passiveChoices.length - 1)].id}`,
left: index % 3 > 0 ? `passive-ability-${ABILITY_ORDER[index - 1]}` : `slot-${selectedSlotId}`,
right: index % 3 < 2 ? `passive-ability-${ABILITY_ORDER[index + 1]}` : undefined,
},
})) : []),
...(healerOwner ? passiveChoices.map((passive, index) => ({
id: `passive-${passive.id}`,
run: () => {
selectPassiveInfusion(passive.id);
installPassive(passive.id);
},
enabled: passiveUnlocked,
neighbors: {
up: index === 0 ? `passive-ability-${selectedPassiveAbilityId}` : `passive-${passiveChoices[index - 1].id}`,
down: index === passiveChoices.length - 1 ? `passive-ability-${selectedPassiveAbilityId}` : `passive-${passiveChoices[index + 1].id}`,
left: `slot-${selectedSlotId}`,
},
})) : []),
{ id: "upgrade", run: upgrade, enabled: canUpgrade, neighbors: { left: `slot-${selectedSlotId}`, up: `slot-${selectedSlotId}` } },
{ id: "install-infusion", run: installInfusion, enabled: canInstallInfusion, neighbors: { left: `slot-${selectedSlotId}`, up: `infusion-${infusionChoices[infusionChoices.length - 1].id}`, down: healerOwner ? `passive-ability-${selectedPassiveAbilityId}` : undefined } },
{ id: "back", run: () => navigate("home"), neighbors: { left: "workshop-infusion", down: `owner-${GEAR_OWNER_ORDER[0]}` } },
], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveChoices, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectPassiveAbility, selectPassiveInfusion, selectSlot, selectWorkshopMode, selectedOwnerId, selectedPassiveAbilityId, selectedSlotId, upgrade]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
const passiveContext = workshopMode === "infusion" && healerOwner && controller.selectedId.startsWith("passive-");
if (!hunter || !slot) return null;
const currentBonus = gearBonusText(recipe.statId, slot.level);
const nextBonus = gearBonusText(recipe.statId, Math.min(MAX_GEAR_LEVEL, slot.level + 1));
return (
<DualDisplayFrame
top={
<FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop">
<header className="front-screen-header"><BrandMark compact /><div><span>Group drop workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><ControllerButton id="workshop-upgrade" selectedId={controller.selectedId} select={controller.select} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</ControllerButton><ControllerButton id="workshop-infusion" selectedId={controller.selectedId} select={controller.select} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</ControllerButton></div><ControllerButton id="back" selectedId={controller.selectedId} select={controller.select} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</ControllerButton></header>
<div className="gear-workshop-layout">
<section className="gear-owner-list" aria-label="Party gear owners">
{GEAR_OWNER_ORDER.map((ownerId) => {
const highest = Math.max(...GEAR_SLOT_ORDER.map((slotId) => hunter.gearProgress[ownerId].slots[slotId].level));
const upgradeReady = upgradeReadiness.owners.has(ownerId);
return <ControllerButton key={ownerId} id={`owner-${ownerId}`} selectedId={controller.selectedId} select={controller.select} aria-label={`${GEAR_OWNER_LABELS[ownerId]}${upgradeReady ? ", upgrade available" : ""}`} className={`${ownerId === selectedOwnerId ? "is-selected" : ""} ${upgradeReady ? "is-upgrade-ready" : ""}`} onClick={() => selectOwner(ownerId)}><span><strong>{GEAR_OWNER_LABELS[ownerId]}</strong><small>Highest slot +{highest}</small></span><b>{ownerId === selectedOwnerId ? "✓" : ""}</b></ControllerButton>;
})}
</section>
<section className="gear-slot-list" aria-label={`${GEAR_OWNER_LABELS[selectedOwnerId]} gear slots`}>
{GEAR_SLOT_ORDER.map((slotId) => {
const progress = hunter.gearProgress[selectedOwnerId].slots[slotId];
const slotRecipe = GEAR_RECIPES[selectedOwnerId][slotId];
const upgradeReady = upgradeReadiness.slots.has(`${selectedOwnerId}:${slotId}`);
return <ControllerButton key={slotId} id={`slot-${slotId}`} selectedId={controller.selectedId} select={controller.select} aria-label={`${GEAR_SLOT_LABELS[slotId]} +${progress.level}${upgradeReady ? ", upgrade available" : ""}`} className={`${slotId === selectedSlotId ? "is-selected" : ""} ${upgradeReady ? "is-upgrade-ready" : ""}`} onClick={() => selectSlot(slotId)}><i>{slotId === "weapon" ? "⚔" : slotId === "helmet" ? "♙" : slotId === "chest" ? "▧" : slotId === "legs" ? "Ⅱ" : "⌁"}</i><span><strong>{GEAR_SLOT_LABELS[slotId]}</strong><small>{GEAR_STAT_LABELS[slotRecipe.statId]}</small></span><b>+{progress.level}</b></ControllerButton>;
})}
</section>
{workshopMode === "upgrade" ? <article className="gear-preview">
<span>Selected upgrade</span>
<h2>{GEAR_OWNER_LABELS[selectedOwnerId]} · {GEAR_SLOT_LABELS[selectedSlotId]} +{slot.level}</h2>
<p>{GEAR_STAT_LABELS[recipe.statId]} from Group {BOSS_GROUP_BY_ID[recipe.primaryGroupId].letter} and Group {BOSS_GROUP_BY_ID[recipe.secondaryGroupId].letter} drops.</p>
<div className="gear-stat-comparison"><span><small>Current</small><strong>{currentBonus}</strong></span><i></i><span><small>{slot.level >= MAX_GEAR_LEVEL ? "Maximum" : `Rank +${slot.level + 1}`}</small><strong>{nextBonus}</strong></span></div>
</article> : <article className="gear-preview gear-infusion-preview">
<span>Active infusion · unlock +{ACTIVE_INFUSION_MIN_GEAR_LEVEL}</span>
<h2>{selectedInfusion.icon} {selectedInfusion.name}</h2>
<p>{selectedInfusion.description} Anchor purchase to a +{ACTIVE_INFUSION_MIN_GEAR_LEVEL} slot.</p>
<div className="gear-infusion-options">
{infusionChoices.map((infusion) => <ControllerButton key={infusion.id} id={`infusion-${infusion.id}`} selectedId={controller.selectedId} select={controller.select} className={`${infusion.id === selectedInfusion.id ? "is-selected" : ""} ${hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "is-equipped" : ""}`} onClick={() => selectInfusion(infusion.id)}><i>{infusion.icon}</i><span><strong>{infusion.name}</strong><small>{infusion.description}</small></span><b>{hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "✓" : ""}</b></ControllerButton>)}
</div>
{healerOwner && <div className="gear-passive-options">
<span>Passive blessing · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL}</span>
<div className="gear-passive-ability-filter">
{ABILITY_ORDER.map((abilityId) => <ControllerButton key={abilityId} id={`passive-ability-${abilityId}`} selectedId={controller.selectedId} select={controller.select} className={selectedPassiveAbilityId === abilityId ? "is-selected" : ""} onClick={() => selectPassiveAbility(abilityId)}>{healerAbilities[abilityId].shortName}</ControllerButton>)}
</div>
<div className="gear-passive-choice-list">
{passiveChoices.map((passive) => <ControllerButton
key={passive.id}
id={`passive-${passive.id}`}
selectedId={controller.selectedId}
select={controller.select}
disabled={!passiveUnlocked}
className={`${selectedPassiveInfusionId === passive.id ? "is-selected" : ""} ${hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "is-equipped" : ""}`}
onPointerEnter={() => selectPassiveInfusion(passive.id)}
onClick={() => { selectPassiveInfusion(passive.id); installPassive(passive.id); }}
><i>{passive.icon}</i><span><strong>{healerAbilities[passive.abilitySlotId].shortName}: {passive.name}</strong><small>{formatRunBuffEffect(passive.id, 1, healerAbilities[passive.abilitySlotId].shortName)}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""}</b></ControllerButton>)}
</div>
</div>}
</article>}
</div>
<ControllerLegend back />
</FrontSurface>
}
bottom={
<FrontSurface className="gear-context" bottom ariaLabel="Gear recipe and material inventory">
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : passiveContext ? `${healerAbilities[selectedPassive.abilitySlotId].name}: ${selectedPassive.name}` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS</b></header>
<div className="gear-costs">
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : passiveContext ? "Passive blessing · Rank 1" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
{passiveContext ? <article className={passiveUnlocked ? "is-met" : "is-missing"}><i>{passiveUnlocked ? "✓" : "×"}</i><span><strong>{formatRunBuffEffect(selectedPassive.id, 1, healerAbilities[selectedPassive.abilitySlotId].shortName)}</strong><small>{selectedPassive.detail}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "EQUIPPED" : "RANK 1"}</b></article> : (workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => {
const owned = hunter.materials.find((item) => item.id === cost.itemId)?.quantity ?? 0;
return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>;
}) : <article className="is-met"><i></i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
</div>
{workshopMode === "upgrade" ? <ControllerButton id="upgrade" selectedId={controller.selectedId} select={controller.select} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></ControllerButton> : passiveContext ? <div className="gear-passive-context-action"><span>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : `${DEFAULT_CONTROLLER_GLYPHS.confirm} · Equip selected passive`}</span><small>Applies at rank 1 next encounter.</small></div> : <ControllerButton id="install-infusion" selectedId={controller.selectedId} select={controller.select} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></ControllerButton>}
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
</FrontSurface>
}
/>
);
}
const APPEARANCE_PREVIEW_ANIMATIONS = [
{ id: "idle", label: "Idle" },
{ id: "walk", label: "Walk" },
{ id: "cast", label: "Cast" },
] as const;
function appearanceControlId(slotId: AppearanceSlotId, direction: "previous" | "next") {
return `appearance-${slotId}-${direction}`;
}
function AppearanceScreen() {
const hunter = useActiveHunter();
const classId = useFrontendStore((state) => state.appearanceClassId);
const drafts = useFrontendStore((state) => state.appearanceDrafts);
const previewMode = useFrontendStore((state) => state.previewMode);
const previewAnimation = useFrontendStore((state) => state.previewAnimation);
const notice = useFrontendStore((state) => state.notice);
const selectAppearanceClass = useFrontendStore((state) => state.selectAppearanceClass);
const updateAppearanceDraft = useFrontendStore((state) => state.updateAppearanceDraft);
const resetAppearanceDraft = useFrontendStore((state) => state.resetAppearanceDraft);
const saveAppearanceDraft = useFrontendStore((state) => state.saveAppearanceDraft);
const closeAppearanceLab = useFrontendStore((state) => state.closeAppearanceLab);
const setAppearancePreviewMode = useFrontendStore((state) => state.setAppearancePreviewMode);
const setAppearancePreviewAnimation = useFrontendStore((state) => state.setAppearancePreviewAnimation);
const draft = drafts[classId] ?? createDefaultHealerAppearance(classId);
const saved = hunter?.healers[classId].appearance ?? createDefaultHealerAppearance(classId);
const dirtyClassIds = HEALER_CLASS_ORDER.filter((candidate) => !appearancesMatch(
drafts[candidate] ?? createDefaultHealerAppearance(candidate),
hunter?.healers[candidate].appearance ?? createDefaultHealerAppearance(candidate),
));
const currentDirty = !appearancesMatch(draft, saved);
const dirtyCount = dirtyClassIds.length;
const [discardArmed, setDiscardArmed] = useState(false);
const effectivePreviewMode = CHARACTER_MODEL_MODE === "legacy" ? "legacy" : previewMode;
const changeSlot = useCallback((slotId: AppearanceSlotId, direction: -1 | 1) => {
updateAppearanceDraft(cycleAppearanceSlot(draft, slotId, direction));
}, [draft, updateAppearanceDraft]);
const requestClose = useCallback(() => {
if (dirtyCount > 0 && !discardArmed) {
setDiscardArmed(true);
return;
}
closeAppearanceLab();
}, [closeAppearanceLab, dirtyCount, discardArmed]);
useEffect(() => setDiscardArmed(false), [classId, drafts, previewAnimation, previewMode]);
const actions = useMemo<MenuAction[]>(() => {
const classActions = HEALER_CLASS_ORDER.map((candidate, index) => ({
id: `appearance-class-${candidate}`,
run: () => selectAppearanceClass(candidate),
neighbors: {
left: `appearance-class-${HEALER_CLASS_ORDER[(index - 1 + HEALER_CLASS_ORDER.length) % HEALER_CLASS_ORDER.length]}`,
right: `appearance-class-${HEALER_CLASS_ORDER[(index + 1) % HEALER_CLASS_ORDER.length]}`,
down: "appearance-animation-idle",
},
}));
const animationActions = APPEARANCE_PREVIEW_ANIMATIONS.map((animation, index) => ({
id: `appearance-animation-${animation.id}`,
run: () => setAppearancePreviewAnimation(animation.id),
neighbors: {
left: `appearance-animation-${APPEARANCE_PREVIEW_ANIMATIONS[(index - 1 + APPEARANCE_PREVIEW_ANIMATIONS.length) % APPEARANCE_PREVIEW_ANIMATIONS.length].id}`,
right: `appearance-animation-${APPEARANCE_PREVIEW_ANIMATIONS[(index + 1) % APPEARANCE_PREVIEW_ANIMATIONS.length].id}`,
up: `appearance-class-${classId}`,
down: appearanceControlId(APPEARANCE_SLOT_DEFINITIONS[0].id, index === 0 ? "previous" : "next"),
},
}));
const slotActions = APPEARANCE_SLOT_DEFINITIONS.flatMap((slot, index) => {
const previousRow = index === 0 ? "appearance-animation-idle" : appearanceControlId(APPEARANCE_SLOT_DEFINITIONS[index - 1].id, "previous");
const nextRow = index === APPEARANCE_SLOT_DEFINITIONS.length - 1 ? "appearance-compare" : appearanceControlId(APPEARANCE_SLOT_DEFINITIONS[index + 1].id, "previous");
const previous = appearanceControlId(slot.id, "previous");
const next = appearanceControlId(slot.id, "next");
const enabled = appearanceSlotEnabled(draft, slot.id);
return [
{ id: previous, run: () => changeSlot(slot.id, -1), enabled, neighbors: { left: next, right: next, up: previousRow, down: nextRow } },
{ id: next, run: () => changeSlot(slot.id, 1), enabled, neighbors: { left: previous, right: previous, up: index === 0 ? "appearance-animation-cast" : appearanceControlId(APPEARANCE_SLOT_DEFINITIONS[index - 1].id, "next"), down: index === APPEARANCE_SLOT_DEFINITIONS.length - 1 ? "appearance-save" : appearanceControlId(APPEARANCE_SLOT_DEFINITIONS[index + 1].id, "next") } },
];
});
return [
...classActions,
...animationActions,
...slotActions,
{ id: "appearance-compare", run: () => setAppearancePreviewMode(previewMode === "modular" ? "legacy" : "modular"), enabled: CHARACTER_MODEL_MODE !== "legacy", neighbors: { left: "appearance-close", right: "appearance-reset", up: appearanceControlId(APPEARANCE_SLOT_DEFINITIONS.at(-1)!.id, "previous") } },
{ id: "appearance-reset", run: resetAppearanceDraft, neighbors: { left: "appearance-compare", right: "appearance-save", up: appearanceControlId(APPEARANCE_SLOT_DEFINITIONS.at(-1)!.id, "previous") } },
{ id: "appearance-save", run: () => { saveAppearanceDraft(); }, neighbors: { left: "appearance-reset", right: "appearance-close", up: appearanceControlId(APPEARANCE_SLOT_DEFINITIONS.at(-1)!.id, "next") } },
{ id: "appearance-close", run: requestClose, neighbors: { left: "appearance-save", right: "appearance-compare", up: appearanceControlId(APPEARANCE_SLOT_DEFINITIONS.at(-1)!.id, "next") } },
];
}, [changeSlot, classId, draft, previewMode, requestClose, resetAppearanceDraft, saveAppearanceDraft, selectAppearanceClass, setAppearancePreviewAnimation, setAppearancePreviewMode]);
const controller = useMenuController(actions, { initialId: `appearance-class-${classId}`, onBack: requestClose });
if (!hunter) return null;
const healer = HEALER_CLASSES[classId];
const legacyActive = effectivePreviewMode === "legacy";
return (
<DualDisplayFrame
top={
<FrontSurface className="appearance-surface" ariaLabel="Healer appearance preview">
<header className="front-screen-header appearance-header">
<BrandMark compact />
<div><span>Character workshop</span><h1>Appearance Lab</h1></div>
<ControllerButton id="appearance-close" selectedId={controller.selectedId} select={controller.select} className="header-back" onClick={requestClose}>{DEFAULT_CONTROLLER_GLYPHS.back} · {discardArmed ? "Confirm discard" : dirtyCount > 0 ? `Cancel (${dirtyCount} unsaved)` : "Close"}</ControllerButton>
</header>
<div className="appearance-preview-stage">
<Suspense fallback={<div className="appearance-preview-loading"><span></span><strong>Assembling shared rig</strong></div>}>
<HealerAppearancePreview
classId={classId}
appearance={draft}
animation={previewAnimation}
modelMode={effectivePreviewMode}
/>
</Suspense>
<div className={`appearance-preview-mode ${legacyActive ? "is-legacy" : ""}`}>
<span>{legacyActive ? "LEGACY WHOLE MODEL" : "MODULAR LIVE PREVIEW"}</span>
<small>{legacyActive ? "Saved parts preserved but intentionally ignored" : `${previewAnimation} animation · shared Rig_Medium`}</small>
</div>
<div className="appearance-preview-name">
<small>{healer.specialization}</small>
<strong>{healer.name}</strong>
<span>{currentDirty ? "UNSAVED CHANGES" : "SAVED LOOK"}</span>
</div>
</div>
<footer className="appearance-top-hint">
<span><b>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</b> Open lower-screen controls</span>
<span>Every choice previews on same animation skeleton</span>
</footer>
</FrontSurface>
}
bottom={
<FrontSurface className="appearance-context" bottom ariaLabel="Appearance controls">
<header className="context-header"><span>Appearance controls</span><b>{dirtyCount > 0 ? `${dirtyCount} UNSAVED` : "SAVED LOCALLY"}</b></header>
<div className="appearance-class-tabs">
{HEALER_CLASS_ORDER.map((candidate) => {
const candidateHealer = HEALER_CLASSES[candidate];
return <ControllerButton
key={candidate}
id={`appearance-class-${candidate}`}
selectedId={controller.selectedId}
select={controller.select}
className={candidate === classId ? "is-selected" : ""}
aria-pressed={candidate === classId}
onClick={() => selectAppearanceClass(candidate)}
><i style={{ color: candidateHealer.color }}>{candidateHealer.icon}</i><span>{candidateHealer.name}</span></ControllerButton>;
})}
</div>
<div className="appearance-animation-tabs" aria-label="Preview animation">
{APPEARANCE_PREVIEW_ANIMATIONS.map((animation) => <ControllerButton
key={animation.id}
id={`appearance-animation-${animation.id}`}
selectedId={controller.selectedId}
select={controller.select}
className={previewAnimation === animation.id ? "is-selected" : ""}
aria-pressed={previewAnimation === animation.id}
onClick={() => setAppearancePreviewAnimation(animation.id)}
>{animation.label}</ControllerButton>)}
</div>
<div className="appearance-slot-list">
{APPEARANCE_SLOT_DEFINITIONS.map((slot) => {
const active = controller.selectedId.startsWith(`appearance-${slot.id}-`);
const enabled = appearanceSlotEnabled(draft, slot.id);
return <div className={`appearance-slot-row ${active ? "is-controller-active" : ""} ${enabled ? "" : "is-disabled"}`} key={slot.id}>
<span><strong>{slot.label}</strong><small>{slot.assetNote}</small></span>
<ControllerButton id={appearanceControlId(slot.id, "previous")} selectedId={controller.selectedId} select={controller.select} disabled={!enabled} aria-label={`Previous ${slot.label}`} onClick={() => changeSlot(slot.id, -1)}></ControllerButton>
<b>{appearanceSlotLabel(draft, slot.id)}</b>
<ControllerButton id={appearanceControlId(slot.id, "next")} selectedId={controller.selectedId} select={controller.select} disabled={!enabled} aria-label={`Next ${slot.label}`} onClick={() => changeSlot(slot.id, 1)}></ControllerButton>
</div>;
})}
</div>
<div className="appearance-actions">
<ControllerButton id="appearance-compare" selectedId={controller.selectedId} select={controller.select} disabled={CHARACTER_MODEL_MODE === "legacy"} onClick={() => setAppearancePreviewMode(previewMode === "modular" ? "legacy" : "modular")}><strong>{legacyActive ? "Show custom" : "Compare legacy"}</strong><small>{CHARACTER_MODEL_MODE === "legacy" ? "Rollout locked" : "No save change"}</small></ControllerButton>
<ControllerButton id="appearance-reset" selectedId={controller.selectedId} select={controller.select} onClick={resetAppearanceDraft}><strong>Reset</strong><small>Class default</small></ControllerButton>
<ControllerButton id="appearance-save" selectedId={controller.selectedId} select={controller.select} className="is-primary" onClick={() => { saveAppearanceDraft(); }}><strong>Save look</strong><small>Current healer</small></ControllerButton>
<ControllerButton id="appearance-close" selectedId={controller.selectedId} select={controller.select} onClick={requestClose}><strong>{discardArmed ? "Confirm" : "Cancel"}</strong><small>{discardArmed ? "Press again" : dirtyCount > 0 ? `Discard ${dirtyCount}` : "Close lab"}</small></ControllerButton>
</div>
<div className={`appearance-save-state ${dirtyCount > 0 ? "is-dirty" : ""}`}>{discardArmed ? `Discard ${dirtyCount} unsaved healer ${dirtyCount === 1 ? "look" : "looks"}? Press Cancel or Back again.` : CHARACTER_MODEL_MODE === "legacy" ? "Legacy rollout active. Modular choices remain saved for later." : currentDirty ? "Preview changed. Save look to use it in gameplay." : dirtyCount > 0 ? `${dirtyCount} other healer ${dirtyCount === 1 ? "look is" : "looks are"} still unsaved. Switch classes to save them.` : notice || "Saved look will load in the next encounter."}</div>
</FrontSurface>
}
/>
);
}
function SettingToggle({ id, label, copy, value, selectedId, select, onClick }: { id: string; label: string; copy: string; value: boolean; selectedId: string; select: (id: string) => void; onClick: () => void }) {
return <ControllerButton id={id} selectedId={selectedId} select={select} className="setting-row" onClick={onClick}><span><strong>{label}</strong><small>{copy}</small></span><b className={value ? "is-on" : ""}>{value ? "ON" : "OFF"}</b></ControllerButton>;
}
function SettingsScreen() {
const settings = useFrontendStore((state) => state.settings);
const updateSetting = useFrontendStore((state) => state.updateSetting);
const navigate = useFrontendStore((state) => state.navigate);
const notice = useFrontendStore((state) => state.notice);
const actions = useMemo<MenuAction[]>(() => [
{ id: "volume-down", run: () => updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10)) },
{ id: "volume-up", run: () => updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10)) },
{ id: "motion", run: () => updateSetting("reducedMotion", !settings.reducedMotion) },
{ id: "numbers", run: () => updateSetting("damageNumbers", !settings.damageNumbers) },
{ id: "text", run: () => updateSetting("largeText", !settings.largeText) },
{ id: "back", run: () => navigate("home") },
], [navigate, settings, updateSetting]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
return (
<DualDisplayFrame
top={
<FrontSurface className="settings-surface" ariaLabel="Settings">
<header className="front-screen-header"><BrandMark compact /><div><span>Field configuration</span><h1>Settings</h1></div><ControllerButton id="back" selectedId={controller.selectedId} select={controller.select} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</ControllerButton></header>
<div className="settings-layout">
<section><span className="settings-section-title">Audio</span><div className="volume-setting"><span><strong>Master volume</strong><small>All music, effects, and voice</small></span><div><ControllerButton id="volume-down" selectedId={controller.selectedId} select={controller.select} onClick={() => updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}></ControllerButton><b>{settings.masterVolume}%</b><ControllerButton id="volume-up" selectedId={controller.selectedId} select={controller.select} onClick={() => updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}></ControllerButton></div><i><em style={{ width: `${settings.masterVolume}%` }} /></i></div></section>
<section><span className="settings-section-title">Display & accessibility</span><SettingToggle id="motion" label="Reduced motion" copy="Limit non-essential UI movement" value={settings.reducedMotion} selectedId={controller.selectedId} select={controller.select} onClick={() => updateSetting("reducedMotion", !settings.reducedMotion)} /><SettingToggle id="numbers" label="Damage numbers" copy="Show combat values over units" value={settings.damageNumbers} selectedId={controller.selectedId} select={controller.select} onClick={() => updateSetting("damageNumbers", !settings.damageNumbers)} /><SettingToggle id="text" label="Large interface text" copy="Increase menu and tactical labels" value={settings.largeText} selectedId={controller.selectedId} select={controller.select} onClick={() => updateSetting("largeText", !settings.largeText)} /></section>
</div>
<div className="settings-save-state">{notice || "Settings write to offline storage immediately."}</div>
</FrontSurface>
}
bottom={
<FrontSurface className="controls-context" bottom ariaLabel="Controller mapping">
<header className="context-header"><span>Controller</span><b>BUILT-IN THOR PAD</b></header>
<div className="controller-map">
<div className="pad-diagram"><i></i><span><b></b></span><i></i></div>
<div className="face-diagram"><i className="triangle">{DEFAULT_CONTROLLER_GLYPHS.faceTop}</i><span><i className="square">{DEFAULT_CONTROLLER_GLYPHS.faceLeft}</i><b></b><i className="circle">{DEFAULT_CONTROLLER_GLYPHS.faceRight}</i></span><i className="cross">{DEFAULT_CONTROLLER_GLYPHS.faceBottom}</i></div>
</div>
<div className="mapping-list"><span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Confirm / cast Purify</span><span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back / cast Shield</span><span><b>D-Pad</b> Navigate / target party</span><span><b>Right stick</b> Rotate camera</span><span><b>{DEFAULT_CONTROLLER_GLYPHS.start}</b> Pause / menu</span></div>
<div className="control-assurance"><i></i><span><strong>No app focus required</strong><small>Native controller input routes through app-level actions.</small></span></div>
</FrontSurface>
}
/>
);
}
function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"], hockeyPvpMatch?: HockeyPvpMatchConfig) => void }) {
const hunter = useActiveHunter();
const accountId = useFrontendStore((state) => state.accountId);
const modeId = useFrontendStore((state) => state.selectedMode);
const selectedBossId = useFrontendStore((state) => state.selectedBossId);
const selectedDifficultySlug = useFrontendStore((state) => state.selectedDifficultySlug);
const selectBoss = useFrontendStore((state) => state.selectBoss);
const selectDifficulty = useFrontendStore((state) => state.selectDifficulty);
const navigate = useFrontendStore((state) => state.navigate);
const [message, setMessage] = useState("");
const [queueing, setQueueing] = useState(false);
const [queueElapsed, setQueueElapsed] = useState(0);
const queueActive = useRef(false);
const queueTicket = useRef<string | null>(null);
const queuePollTimer = useRef<number | null>(null);
const queueCpuTimer = useRef<number | null>(null);
const queueClockTimer = useRef<number | null>(null);
const mode = MODE_COPY[modeId];
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
const progress = hunter?.healers[hunter.activeClassId];
const selectedBoss = BOSS_DEFINITIONS[selectedBossId];
const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug];
const isPve = modeId === "roguelike-pve";
const isRogueTrials = modeId === "rogue-trials";
const isPveRun = isPve || isRogueTrials;
const isDungeon = modeId === "dungeons";
const isHockey = modeId === "hockey-healing";
const isHockeyPvp = modeId === "hockey-healing-pvp";
const isBlockbreaker = modeId === "blockbreaker";
const isAetherAssault = modeId === "aether-assault";
const selectedBossGroup = BOSS_GROUP_BY_ID[selectedBoss.groupId];
const visibleBossIds = selectedBossGroup.bossIds;
const bossGridColumns = Math.min(2, visibleBossIds.length);
const selectBossGroup = (groupId: (typeof BOSS_GROUPS)[number]["id"]) => {
selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]);
};
const clearQueueTimers = () => {
if (queuePollTimer.current !== null) window.clearTimeout(queuePollTimer.current);
if (queueCpuTimer.current !== null) window.clearTimeout(queueCpuTimer.current);
if (queueClockTimer.current !== null) window.clearInterval(queueClockTimer.current);
queuePollTimer.current = null;
queueCpuTimer.current = null;
queueClockTimer.current = null;
};
const completePvpQueue = (match: HockeyPvpMatchConfig) => {
if (!queueActive.current) return;
queueActive.current = false;
clearQueueTimers();
setQueueing(false);
setMessage(match.role === "cpu" ? `CPU rival found: ${match.opponentName}.` : `Matched with ${match.opponentName}.`);
onLaunch([hockeyPvpBossAt(match.seed, 0)], "initiate", match);
};
const fallbackToCpu = () => {
if (!queueActive.current) return;
const ticketId = queueTicket.current;
queueTicket.current = null;
if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined);
completePvpQueue({
matchId: null,
seed: Math.max(1, Math.floor(Math.random() * 0xffffffff)),
opponentName: randomHockeyPvpCpuName(),
role: "cpu",
});
};
const cancelPvpQueue = () => {
if (!queueActive.current) return;
queueActive.current = false;
clearQueueTimers();
const ticketId = queueTicket.current;
queueTicket.current = null;
if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined);
setQueueing(false);
setQueueElapsed(0);
setMessage("Matchmaking cancelled.");
};
const startPvpQueue = async () => {
if (!hunter || queueActive.current) return;
queueActive.current = true;
setQueueing(true);
setQueueElapsed(0);
setMessage(accountId ? "Searching online queue…" : "Offline queue: searching before CPU fallback…");
const startedAt = Date.now();
queueClockTimer.current = window.setInterval(() => setQueueElapsed(Date.now() - startedAt), 100);
queueCpuTimer.current = window.setTimeout(fallbackToCpu, HOCKEY_PVP_QUEUE_TIMEOUT_MS);
if (!accountId || !networkAppearsOnline()) return;
try {
const joined = await onlineRepository.joinHockeyPvpQueue(hunter.slotId, hunter.hunterName);
if (!queueActive.current) return;
queueTicket.current = joined.ticketId;
if (joined.match) {
completePvpQueue({
matchId: joined.match.id,
seed: joined.match.seed,
opponentName: joined.match.opponentName,
role: joined.match.role,
});
return;
}
const poll = async () => {
if (!queueActive.current || !queueTicket.current) return;
try {
const result = await onlineRepository.pollHockeyPvpQueue(queueTicket.current);
if (!queueActive.current) return;
if (result.match) {
completePvpQueue({
matchId: result.match.id,
seed: result.match.seed,
opponentName: result.match.opponentName,
role: result.match.role,
});
return;
}
} catch {
// Five-second CPU fallback remains authoritative during transient outages.
}
if (queueActive.current) queuePollTimer.current = window.setTimeout(poll, 350);
};
queuePollTimer.current = window.setTimeout(poll, 350);
} catch {
setMessage("Online queue unavailable. CPU fallback still searching…");
}
};
useEffect(() => () => {
queueActive.current = false;
clearQueueTimers();
const ticketId = queueTicket.current;
if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined);
}, []);
const leaveMode = () => {
cancelPvpQueue();
navigate("home");
};
const launch = () => {
if (isPveRun) return onLaunch(selectRandomBossPair(), "initiate");
if (isHockey) return onLaunch(selectRandomBossPair(), "initiate");
if (isBlockbreaker) return onLaunch(selectRandomBossPair(), "initiate");
if (isAetherAssault) return onLaunch(selectRandomBossPair(), "initiate");
if (isHockeyPvp) return queueing ? cancelPvpQueue() : void startPvpQueue();
if (isDungeon) return onLaunch([selectedBossId], selectedDifficultySlug);
setMessage("Online matchmaking is not available for this mode yet.");
};
const actions = useMemo<MenuAction[]>(() => [
...(isDungeon ? BOSS_GROUPS.map((group, index) => {
const groupColumns = 5;
const row = Math.floor(index / groupColumns);
const column = index % groupColumns;
const groupAt = (targetRow: number, targetColumn: number) => BOSS_GROUPS[targetRow * groupColumns + targetColumn];
return {
id: `boss-group-${group.id}`,
run: () => selectBossGroup(group.id),
neighbors: {
up: row > 0 ? `boss-group-${groupAt(row - 1, column)?.id}` : "back",
down: groupAt(row + 1, column)
? `boss-group-${groupAt(row + 1, column)?.id}`
: group.id === selectedBossGroup.id ? `boss-${selectedBossGroup.bossIds[0]}` : undefined,
left: column > 0 ? `boss-group-${groupAt(row, column - 1)?.id}` : undefined,
right: groupAt(row, column + 1) ? `boss-group-${groupAt(row, column + 1)?.id}` : undefined,
},
};
}) : []),
...(isDungeon ? visibleBossIds.map((bossId, index) => {
const row = Math.floor(index / bossGridColumns);
const column = index % bossGridColumns;
const bossAt = (targetRow: number, targetColumn: number) => visibleBossIds[targetRow * bossGridColumns + targetColumn];
return {
id: `boss-${bossId}`,
run: () => selectBoss(bossId),
neighbors: {
up: row > 0 ? `boss-${bossAt(row - 1, column)}` : `boss-group-${selectedBossGroup.id}`,
down: bossAt(row + 1, column) ? `boss-${bossAt(row + 1, column)}` : `difficulty-${DIFFICULTIES[0].slug}`,
left: column > 0 ? `boss-${bossAt(row, column - 1)}` : undefined,
right: bossAt(row, column + 1) ? `boss-${bossAt(row, column + 1)}` : undefined,
},
};
}) : []),
...(isDungeon ? DIFFICULTIES.map((difficulty, index) => ({
id: `difficulty-${difficulty.slug}`,
run: () => selectDifficulty(difficulty.slug),
neighbors: {
left: index > 0 ? `difficulty-${DIFFICULTIES[index - 1].slug}` : `boss-${selectedBossId}`,
right: index < DIFFICULTIES.length - 1 ? `difficulty-${DIFFICULTIES[index + 1].slug}` : "launch",
up: `boss-${selectedBossId}`,
down: "launch",
},
})) : []),
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } },
{ id: "back", run: leaveMode, neighbors: isDungeon ? { down: `boss-group-${selectedBossGroup.id}` } : { down: "launch" } },
], [bossGridColumns, isAetherAssault, isBlockbreaker, isDungeon, isHockey, isHockeyPvp, isPveRun, modeId, navigate, onLaunch, queueing, selectBoss, selectDifficulty, selectedBossGroup, selectedBossId, selectedDifficultySlug, visibleBossIds]);
const controller = useMenuController(actions, { onBack: leaveMode });
const launchLabel = isRogueTrials
? "Begin Rogue Trials"
: isPve
? "Begin RPG Roguelike"
: isHockey
? "Begin Hockey Healing"
: isBlockbreaker
? "Begin Blockbreaker"
: isAetherAssault
? "Begin Aether Assault"
: isHockeyPvp
? queueing ? "Cancel matchmaking" : "Enter online queue"
: isDungeon
? `Challenge ${selectedBoss.name}`
: "Enter matchmaking";
const contextRules = isDungeon
? [
[selectedBoss.name, selectedBoss.summary],
[bossMechanicName(selectedBoss.mechanicIds[0]), selectedBoss.briefing],
[bossMechanicName(selectedBoss.mechanicIds[1]), "Controller-ready party behavior and full lower-display support."],
]
: isAetherAssault
? [
["Movement is the only arcade input", "Spellfire launches automatically down the five runway lanes while every healing and targeting control stays unchanged."],
["Formation pressure", "Eight arcane ships enter the first wave. Later formations grow to twenty, add armor, fire faster, and peel into diving attacks."],
["Heal through every hit", "Ship bolts and dive collisions damage only the healer. Endless bosses keep attacking the full party until the formation falls."],
]
: isBlockbreaker
? [
["Break linked colors", "Aim the puck into five-column rows. A hit removes its full orthogonally connected color cluster."],
["Accelerating wall", "Rows begin every 10 seconds, accelerate 10% each minute, and push survivors toward the danger line."],
["Heal under pressure", `Two bosses attack without pause. Each brick-wall breach deals ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member. The run ends when all four allies fall.`],
]
: isHockeyPvp
? [
["Normalized rivals", "Both parties use default base gear with all upgrade and infusion bonuses disabled. Boss order remains identical."],
["Escalating pressure", `Every boss kill by either party adds 5% global healing Dampening. Every net breach still deals ${HOCKEY_PVP_GOAL_DAMAGE} partywide damage.`],
["Online or CPU", "Queue searches online for five seconds. If no rival answers, a randomly named CPU healer takes far goal."],
]
: isHockey
? [
["Wide goal defense", "Healer owns near half. Intercept every incoming puck before it reaches the wide blue goal."],
["Pong rally", "Held left-stick direction controls return angle. Moving enemy paddle tracks the puck and strikes it back."],
["Unbroken boss fight", "Party fights two bosses on enemy half. Every kill awards loot and pet chance before replacement arrives."],
]
: isRogueTrials
? [
["Four dual rounds", "Clear four randomized pairs while drafting one stacking buff after each win."],
["Unseen trio finale", "Round 5 selects three bosses that have not appeared earlier in that run."],
["Endless choice", "After the trio falls, quit with the clear or continue while every dead boss is replaced."],
]
: isPve
? [
["Draft every run", "Choose four companions from three five-card waves, then build a six-slot spellbook from every enabled healer class."],
["Challenge hallways", "Brickbreaker, Hockey, and Aether Assault objectives connect boss rooms. Repeats raise their targets; failure still advances."],
["Run-only growth", "Boss chests upgrade owned spells, companions, or +0+5 gear. Shop after each three-boss act, then face a finale."],
]
: [
["Draft a healing path", "Choose rites after every completed room."],
["Protect the formation", "Boss pressure changes around your build."],
["Bank collection drops", "Earned boss loot writes to active offline save."],
];
return (
<DualDisplayFrame
top={
<FrontSurface className={`mode-surface mode-${modeId}`} ariaLabel={`${mode.title} details`}>
<header className="front-screen-header"><BrandMark compact /><div><span>Game mode</span><h1>{mode.title}</h1></div><ControllerButton id="back" selectedId={controller.selectedId} select={controller.select} className="header-back" onClick={leaveMode}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</ControllerButton></header>
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
{isDungeon && (
<div className="boss-picker" aria-label="Choose boss encounter">
<div className="boss-picker-heading"><span>Choose a mechanic group</span></div>
<div className="boss-group-grid" aria-label="Choose boss group">
{BOSS_GROUPS.map((group) => (
<ControllerButton
key={group.id}
id={`boss-group-${group.id}`}
selectedId={controller.selectedId}
select={controller.select}
className={`boss-group-choice ${group.id === selectedBossGroup.id ? "is-selected" : ""}`}
aria-pressed={group.id === selectedBossGroup.id}
onClick={() => selectBossGroup(group.id)}
>
<b>{group.letter}</b><span><strong>Group {group.letter}</strong><small>{group.name}</small></span>
</ControllerButton>
))}
</div>
<div className="boss-group-heading"><span>Group {selectedBossGroup.letter} · {selectedBossGroup.name}</span><small>{selectedBossGroup.coreMechanic} mechanics · {visibleBossIds.length} guardians</small></div>
<div className="boss-choice-grid">
{visibleBossIds.map((bossId) => {
const boss = BOSS_DEFINITIONS[bossId];
return (
<ControllerButton
key={bossId}
id={`boss-${bossId}`}
selectedId={controller.selectedId}
select={controller.select}
className={`boss-choice ${selectedBossId === bossId ? "is-selected" : ""}`}
style={{ "--boss-accent": boss.accent } as React.CSSProperties}
aria-pressed={selectedBossId === bossId}
onClick={() => selectBoss(bossId)}
>
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>{boss.mechanicIds.filter((id) => !bossMechanicIsPassive(id)).map(bossMechanicName).join(" · ")}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
</ControllerButton>
);
})}
</div>
</div>
)}
{isDungeon && (
<div className="difficulty-picker" aria-label="Choose encounter difficulty">
<span>Difficulty</span>
{DIFFICULTIES.map((difficulty) => <ControllerButton key={difficulty.slug} id={`difficulty-${difficulty.slug}`} selectedId={controller.selectedId} select={controller.select} className={difficulty.slug === selectedDifficultySlug ? "is-selected" : ""} onClick={() => selectDifficulty(difficulty.slug)}><strong>{difficulty.name}</strong><small>iLvl {difficulty.itemLevel}</small></ControllerButton>)}
</div>
)}
<ControllerButton id="launch" selectedId={controller.selectedId} select={controller.select} className={`mode-launch ${queueing ? "is-queueing" : ""}`} onClick={launch}><span>{launchLabel}</span><small>{queueing ? `CPU fallback in ${Math.max(0, ((HOCKEY_PVP_QUEUE_TIMEOUT_MS - queueElapsed) / 1000)).toFixed(1)}s` : `${mode.status} · ${DEFAULT_CONTROLLER_GLYPHS.confirm}`}</small></ControllerButton>
{message && <div className="front-notice">{message}</div>}
</FrontSurface>
}
bottom={
<FrontSurface className="mode-context" bottom ariaLabel={`${mode.title} preparation`}>
<header className="context-header"><span>Run preparation</span><b>{isDungeon ? selectedDifficulty.name.toUpperCase() : mode.status.toUpperCase()}</b></header>
{contextRules.map(([title, copy], index) => <div className="mode-rule" key={title}><i>0{index + 1}</i><span><strong>{title}</strong><small>{copy}</small></span></div>)}
<div className="mode-loadout"><span>Equipped role</span><b>{healer.specialization} · Level {progress?.level ?? 1}</b><small>{isHockeyPvp ? "6 abilities · Base gear normalized · Controller ready" : `6 abilities · ${progress?.inventory.length ?? 0} class items · Controller ready`}</small></div>
{isDungeon && <div className="mode-loot-preview"><span>Guaranteed reward</span><b>{bossGroupDrop(selectedBossId, selectedDifficultySlug).name}</b><small>13 group drops · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>}
{isHockey && <div className="mode-loot-preview"><span>Every boss kill</span><b>Normal boss loot awarded</b><small>Guaranteed 13 group drops · Independent pet chance 1 in 500</small></div>}
{isBlockbreaker && <div className="mode-loot-preview"><span>Ranked records</span><b>Overall score · Bricks · Survival</b><small>10 points per brick ladder · +0.1× every 30 seconds</small></div>}
{isAetherAssault && <div className="mode-loot-preview"><span>Ranked record</span><b>Overall score · Wave at best</b><small>Kill streak raises multiplier · Ship damage resets it</small></div>}
{isHockeyPvp && <div className="mode-loot-preview"><span>Ranked records</span><b>Wins / losses · Lifetime PVP boss kills</b><small>Online leaderboards publish through active hunter save</small></div>}
</FrontSurface>
}
/>
);
}
export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"], hockeyPvpMatch?: HockeyPvpMatchConfig) => void }) {
const screen = useFrontendStore((state) => state.screen);
if (screen === "login") return <LoginScreen />;
if (screen === "saves") return <SaveScreen />;
if (screen === "home") return <HomeScreen />;
if (screen === "class-help") return <ClassHelpScreen />;
if (screen === "profile") return <ProfileScreen />;
if (screen === "gear") return <GearScreen />;
if (screen === "appearance") return <AppearanceScreen />;
if (screen === "settings") return <SettingsScreen />;
if (screen === "mode") return <ModeScreen onLaunch={onLaunch} />;
return null;
}