Files
i-want-to-heal-mmo/src/components/FrontEnd.tsx
T
2026-07-13 17:21:10 -04:00

1134 lines
80 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, 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, SaveSlotId, SaveSlotState } from "../frontend/types";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
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 { AbilityId, 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 { DualDisplayFrame } from "./DualDisplayFrame";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
const BossTrophyPortrait = lazy(() => import("./BossTrophyPortrait").then((module) => ({ default: module.BossTrophyPortrait })));
function FocusButton({
id,
focusedId,
focus,
className = "",
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { id: string; focusedId: string; focus: (id: string) => void }) {
return (
<button
{...props}
className={`${className} ${focusedId === id ? "is-controller-focused" : ""}`}
onFocus={(event) => { focus(id); props.onFocus?.(event); }}
onPointerEnter={(event) => { focus(id); props.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.focusedId === "username" ? "is-controller-focused" : ""}
value={username}
onChange={(event) => setUsername(event.target.value)}
onFocus={() => controller.focus("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.focusedId === "password" ? "is-controller-focused" : ""}
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
onFocus={() => controller.focus("password")}
autoComplete="current-password"
maxLength={128}
minLength={10}
required
/>
<FocusButton id="sign-in" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" type="submit">
<span>Sign in & sync</span><small>Online saves enabled</small>
</FocusButton>
<FocusButton id="create-account" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={() => { void createAccount(username, password); }}>
<span>Create account</span><small>Required for first sync</small>
</FocusButton>
<FocusButton id="continue" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}>
<span>Continue</span><small>Choose saved hunter</small>
</FocusButton>
</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, focused, onSelect, onFocus }: { slot: SaveSlotState; selected: boolean; focused: boolean; onSelect: () => void; onFocus: () => 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" : ""} ${focused ? "is-controller-focused" : ""}`} onClick={onSelect} onFocus={onFocus} onPointerEnter={onFocus}>
<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 [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: "confirm-create", run: finishCreation },
{ 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}
focused={controller.isFocused(`slot-${slot.id}`)}
onSelect={() => selectSlot(slot.id)}
onFocus={() => controller.focus(`slot-${slot.id}`)}
/>
))}
</div>
<div className="save-top-actions">
<FocusButton id={primaryActionId} focusedId={controller.focusedId} focus={controller.focus} 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>
</FocusButton>
<FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}><strong>Upload</strong><small>Device server</small></FocusButton>
<FocusButton id="download" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasOnline || !accountId} onClick={() => downloadSlot(selectedSlotId)}><strong>Download</strong><small>Server device</small></FocusButton>
<FocusButton id="copy" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} onClick={() => openSaveDialog("copy")}><strong>Copy</strong><small>Duplicate save</small></FocusButton>
<FocusButton id="delete" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} className="danger-link" onClick={() => openSaveDialog("delete")}><strong>Delete</strong><small>Erase device copy</small></FocusButton>
<FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("login")}><strong>Back</strong><small>Login screen</small></FocusButton>
</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">
<FocusButton id="version-online" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" disabled={resolvingOnline} onClick={() => { void continueWithOnline(); }}><span>{resolvingOnline ? "Loading…" : "Continue online copy"}</span><small>{formatSaveTimestamp(selected.online.updatedAt)}</small></FocusButton>
<FocusButton id="version-local" focusedId={controller.focusedId} focus={controller.focus} disabled={resolvingOnline} onClick={() => { setDialog(null); playSlot(selectedSlotId); }}><span>Continue device copy</span><small>{formatSaveTimestamp(selected.local.updatedAt)}</small></FocusButton>
<FocusButton id="cancel-version" focusedId={controller.focusedId} focus={controller.focus} disabled={resolvingOnline} onClick={() => setDialog(null)}>Cancel</FocusButton>
</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
id="new-hunter-name"
value={hunterName}
maxLength={MAX_HUNTER_NAME_LENGTH}
autoComplete="off"
autoFocus
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">
<FocusButton id="confirm-create" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" type="submit" disabled={!normalizeHunterName(hunterName)}>Create hunter</FocusButton>
<FocusButton id="cancel-create" focusedId={controller.focusedId} focus={controller.focus} type="button" onClick={() => setDialog(null)}>Cancel</FocusButton>
</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) => (
<FocusButton key={slot.id} id={`copy-${slot.id}`} focusedId={controller.focusedId} focus={controller.focus} onClick={() => { copySlot(selectedSlotId, slot.id); setDialog(null); }}>
Slot {slot.id}<small>{slot.local ? "Overwrite" : "Empty"}</small>
</FocusButton>
))}
</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">
<FocusButton id="confirm-delete" focusedId={controller.focusedId} focus={controller.focus} className="is-danger" onClick={() => { deleteSlot(selectedSlotId); setDialog(null); }}>Delete local</FocusButton>
<FocusButton id="cancel-delete" focusedId={controller.focusedId} focus={controller.focus} onClick={() => setDialog(null)}>Cancel</FocusButton>
</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; icon: string; label: string; copy: string }[] = [
{ id: "roguelike-pve", icon: "✦", label: "PVE", copy: "Randomized roguelike runs" },
{ id: "rogue-trials", icon: "Ⅲ", label: "Rogue Trials", copy: "Four rounds, then a boss trio" },
{ id: "dungeons", icon: "♜", label: "Dungeons", copy: "Choose your boss encounter" },
{ id: "roguelike-pvp", icon: "⚔", label: "Roguelike PvP", copy: "Draft, race, sabotage" },
{ id: "stadium-pvp", icon: "◉", label: "Stadium PvP", copy: "Prepared 5v5 rounds" },
];
function HomeScreen() {
const hunter = useActiveHunter();
const accountId = useFrontendStore((state) => state.accountId);
const selectMode = useFrontendStore((state) => state.selectMode);
const selectHealerClass = useFrontendStore((state) => state.selectHealerClass);
const navigate = useFrontendStore((state) => state.navigate);
const actions = useMemo<MenuAction[]>(() => [
{ id: "roguelike-pve", run: () => selectMode("roguelike-pve"), neighbors: { right: "rogue-trials", down: "roguelike-pvp" } },
{ id: "rogue-trials", run: () => selectMode("rogue-trials"), neighbors: { left: "roguelike-pve", right: "dungeons", down: "stadium-pvp" } },
{ id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "rogue-trials", down: "stadium-pvp" } },
{ id: "roguelike-pvp", run: () => selectMode("roguelike-pvp"), neighbors: { left: "roguelike-pve", right: "stadium-pvp", up: "roguelike-pve", down: "profile" } },
{ id: "stadium-pvp", run: () => selectMode("stadium-pvp"), neighbors: { left: "roguelike-pvp", up: "rogue-trials", down: "settings" } },
{ id: "profile", run: () => navigate("profile"), neighbors: { up: "roguelike-pvp", right: "gear", down: "class-priest" } },
{ id: "gear", run: () => navigate("gear"), neighbors: { up: "roguelike-pvp", left: "profile", right: "settings", down: "class-druid" } },
{ id: "settings", run: () => navigate("settings"), neighbors: { up: "stadium-pvp", left: "gear", down: "class-shaman" } },
...HEALER_CLASS_ORDER.map((classId, index) => ({
id: `class-${classId}`,
run: () => selectHealerClass(classId),
neighbors: {
left: `class-${HEALER_CLASS_ORDER[(index + HEALER_CLASS_ORDER.length - 1) % HEALER_CLASS_ORDER.length]}`,
right: `class-${HEALER_CLASS_ORDER[(index + 1) % HEALER_CLASS_ORDER.length]}`,
up: index === 0 ? "profile" : index === 1 ? "gear" : "settings",
down: "change-save",
},
})),
{ id: "change-save", run: () => navigate("saves"), neighbors: { up: "class-druid" } },
], [navigate, 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="mode-grid">
{HOME_MODES.map((mode) => (
<FocusButton key={mode.id} id={mode.id} focusedId={controller.focusedId} focus={controller.focus} className="mode-card" onClick={() => selectMode(mode.id)}>
<i>{mode.icon}</i><span><small>{mode.copy}</small><strong>{mode.label}</strong></span><b></b>
</FocusButton>
))}
</div>
<div className="home-secondary-actions">
<FocusButton id="profile" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("profile")}><i></i><span><strong>Hunter Profile</strong><small>Stats & collection log</small></span><b></b></FocusButton>
<FocusButton id="gear" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("gear")}><i></i><span><strong>Gear Upgrade</strong><small>Spend group drops</small></span><b></b></FocusButton>
<FocusButton id="settings" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("settings")}><i></i><span><strong>Settings</strong><small>Audio, display, controls</small></span><b></b></FocusButton>
</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 <FocusButton key={classId} id={`class-${classId}`} focusedId={controller.focusedId} focus={controller.focus} 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>
</FocusButton>;
})}</div></div>
<FocusButton id="change-save" focusedId={controller.focusedId} focus={controller.focus} className="change-save" onClick={() => navigate("saves")}><span>Change save slot</span><small>Last saved {formatSaveTimestamp(hunter.updatedAt)}</small></FocusButton>
</FrontSurface>
}
/>
);
}
type ProfileStatId = BossId | "roguelike" | "rogue-trials-endless";
function ProfileScreen() {
const hunter = useActiveHunter();
const accountId = useFrontendStore((state) => state.accountId);
const navigate = useFrontendStore((state) => state.navigate);
const collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]);
const [groupId, setGroupId] = useState(collections[0]?.groupId ?? "");
const [collectionView, setCollectionView] = useState<"loot" | "trophies" | "stats">("trophies");
const collection = collections.find((group) => group.groupId === groupId) ?? collections[0];
const [selectedStat, setSelectedStat] = useState<ProfileStatId>("roguelike");
const [leaderboard, setLeaderboard] = useState<LeaderboardResult | null>(null);
const [leaderboardStatus, setLeaderboardStatus] = useState("");
useEffect(() => {
if (selectedStat === "roguelike" || selectedStat === "rogue-trials-endless" || collection?.bosses.some((boss) => boss.bossId === selectedStat)) return;
setSelectedStat(collection?.bosses[0]?.bossId ?? "roguelike");
}, [collection, selectedStat]);
useEffect(() => {
if (!hunter || collectionView !== "stats") return;
if (!accountId) {
setLeaderboard(null);
setLeaderboardStatus("Sign in to view overall rankings.");
return;
}
let cancelled = false;
setLeaderboardStatus("Loading overall rankings…");
const request = selectedStat === "roguelike"
? onlineRepository.roguelikeLeaderboard(hunter.slotId)
: selectedStat === "rogue-trials-endless"
? onlineRepository.rogueTrialsEndlessLeaderboard(hunter.slotId)
: onlineRepository.bossLeaderboard(selectedStat, hunter.slotId);
void request.then((result) => {
if (cancelled) return;
setLeaderboard(result);
setLeaderboardStatus("");
}).catch((error) => {
if (cancelled) return;
setLeaderboard(null);
setLeaderboardStatus(error instanceof Error ? error.message : "Leaderboard unavailable.");
});
return () => { cancelled = true; };
}, [accountId, collectionView, hunter, selectedStat]);
const actions = useMemo<MenuAction[]>(() => [
{ id: "view-trophies", run: () => setCollectionView("trophies"), neighbors: { right: "view-stats" } },
{ id: "view-stats", run: () => setCollectionView("stats"), neighbors: { left: "view-trophies", right: "view-loot", down: collectionView === "stats" ? "stat-roguelike" : undefined } },
{ id: "view-loot", run: () => setCollectionView("loot"), neighbors: { left: "view-stats" } },
...(collectionView === "stats" ? [
{ id: "stat-roguelike", run: () => setSelectedStat("roguelike"), neighbors: { up: "view-stats", down: "stat-rogue-trials-endless" } },
{ id: "stat-rogue-trials-endless", run: () => setSelectedStat("rogue-trials-endless"), neighbors: { up: "stat-roguelike", down: `stat-${collection.bosses[0].bossId}` } },
...collection.bosses.map((boss, index) => ({
id: `stat-${boss.bossId}`,
run: () => setSelectedStat(boss.bossId),
neighbors: {
up: index === 0 ? "stat-rogue-trials-endless" : `stat-${collection.bosses[index - 1].bossId}`,
down: index === collection.bosses.length - 1 ? `group-${collection.groupId}` : `stat-${collection.bosses[index + 1].bossId}`,
},
})),
] : []),
...collections.map((group) => ({ id: `group-${group.groupId}`, run: () => setGroupId(group.groupId) })),
{ id: "back", run: () => navigate("home") },
], [collection, collectionView, collections, navigate]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
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 trophiesEarned = collection.bosses.filter((boss) => boss.pet.count > 0).length;
const selectedStatValue = selectedStat === "roguelike"
? hunter.stats.highestRoguelikeRound
: selectedStat === "rogue-trials-endless"
? hunter.stats.highestRogueTrialsEndlessKills
: hunter.stats.bossKills[selectedStat] ?? 0;
const selectedStatLabel = selectedStat === "roguelike"
? "Roguelike rounds"
: selectedStat === "rogue-trials-endless"
? "Rogue Trials endless kills"
: BOSS_DEFINITIONS[selectedStat].name;
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>Collection log</h1></div><div className="profile-view-tabs" role="tablist" aria-label="Collection view">
<FocusButton id="view-trophies" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "trophies" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "trophies"} onClick={() => setCollectionView("trophies")}>Trophy Case</FocusButton>
<FocusButton id="view-stats" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "stats" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "stats"} onClick={() => setCollectionView("stats")}>Boss Stats</FocusButton>
<FocusButton id="view-loot" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "loot" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "loot"} onClick={() => setCollectionView("loot")}>Group Loot</FocusButton>
</div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
{collectionView === "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 stay individual.</strong><small>Open Trophy Case to inspect every guardian pet.</small></span></div>
</> : collectionView === "trophies" ? <>
<div className="collection-heading trophy-heading"><span><small>Boss pets · 1 in 500 per victory</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{trophiesEarned} / {collection.bosses.length} trophies lit</b></div>
<div className={`trophy-case trophy-count-${collection.bosses.length}`}>
{collection.bosses.map((boss) => {
const owned = boss.pet.count > 0;
return <article key={boss.bossId} className={`boss-trophy ${owned ? "is-owned" : "is-locked"}`} style={{ "--boss-accent": BOSS_DEFINITIONS[boss.bossId].accent } as React.CSSProperties}>
<Suspense fallback={<div className="trophy-portrait trophy-portrait-fallback" role="img" aria-label={`${boss.bossName} portrait`}><span>{BOSS_DEFINITIONS[boss.bossId].icon}</span></div>}><BossTrophyPortrait bossId={boss.bossId} /></Suspense>
<div className="trophy-plaque"><small>{owned ? "Pet secured" : "Pet undiscovered"}</small><strong>{boss.bossName}</strong><span>{boss.kills} kills · {boss.pet.chance}</span></div>
<b className="trophy-state">{owned ? `Owned${boss.pet.count > 1 ? ` ×${boss.pet.count}` : ""}` : "Locked"}</b>
</article>;
})}
</div>
<div className="collection-note trophy-note"><i></i><span><strong>Each guardian keeps its own trophy.</strong><small>Defeat that boss for a 1 in 500 pet roll.</small></span></div>
</> : <>
<div className="collection-heading boss-stats-heading"><span><small>Lifetime records · Overall leaderboards</small><h2>Boss Stats</h2></span><b>Endless best {hunter.stats.highestRogueTrialsEndlessKills} kills</b></div>
<div className="boss-stats-layout">
<section className="boss-stat-selector" aria-label="Boss statistic selection">
<FocusButton id="stat-roguelike" focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === "roguelike" ? "is-selected" : ""} onClick={() => setSelectedStat("roguelike")}><i></i><span><strong>Roguelike</strong><small>Highest round before defeat</small></span><b>{hunter.stats.highestRoguelikeRound}</b></FocusButton>
<FocusButton id="stat-rogue-trials-endless" focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === "rogue-trials-endless" ? "is-selected" : ""} onClick={() => setSelectedStat("rogue-trials-endless")}><i></i><span><strong>Trials Endless</strong><small>Most bosses in one run</small></span><b>{hunter.stats.highestRogueTrialsEndlessKills}</b></FocusButton>
{collection.bosses.map((boss) => <FocusButton key={boss.bossId} id={`stat-${boss.bossId}`} focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === boss.bossId ? "is-selected" : ""} onClick={() => setSelectedStat(boss.bossId)}><i>{BOSS_DEFINITIONS[boss.bossId].icon}</i><span><strong>{boss.bossName}</strong><small>Lifetime boss kills</small></span><b>{boss.kills}</b></FocusButton>)}
</section>
<section className="leaderboard-panel" aria-label="Overall leaderboard">
<header><span><small>Overall Top 5</small><strong>{selectedStatLabel}</strong></span><b>{selectedStatValue} {selectedStat === "roguelike" ? "round" : "kills"}</b></header>
{leaderboardStatus ? <div className="leaderboard-status">{leaderboardStatus}</div> : <div className="leaderboard-rows">
{leaderboard?.top.length ? leaderboard.top.map((entry) => <div key={`${entry.username}-${entry.slotId}`} className={entry.username === accountId && entry.slotId === hunter.slotId ? "is-you" : ""}><b>#{entry.rank}</b><span><strong>{entry.hunterName}</strong><small>{entry.username}</small></span><em>{entry.value}</em></div>) : <div className="leaderboard-empty">No ranked hunters yet.</div>}
</div>}
<div className="leaderboard-self"><b>{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}</b><span><strong>Your rank · {hunter.hunterName}</strong><small>{accountId ?? "Offline hunter"}</small></span><em>{selectedStatValue}</em></div>
</section>
</div>
<div className="collection-note trophy-note"><i></i><span><strong>Rankings update with server saves.</strong><small>Top five always shown; your row stays visible at any rank.</small></span></div>
</>}
</FrontSurface>
}
bottom={
<FrontSurface className="profile-context" bottom ariaLabel="Hunter statistics and mechanic group list">
<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>
</div>
<div className="boss-log"><span>Mechanic groups</span>{collections.map((group) => (
<FocusButton key={group.groupId} id={`group-${group.groupId}`} focusedId={controller.focusedId} focus={controller.focus} 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>
</FocusButton>
))}</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 = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman";
const passiveHealerClassId = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman" ? selectedOwnerId : "priest";
const passiveChoices = PASSIVE_INFUSIONS.filter((passive) => passive.abilityId === 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.focusedId.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"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></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 <FocusButton key={ownerId} id={`owner-${ownerId}`} focusedId={controller.focusedId} focus={controller.focus} 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></FocusButton>;
})}
</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 <FocusButton key={slotId} id={`slot-${slotId}`} focusedId={controller.focusedId} focus={controller.focus} 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></FocusButton>;
})}
</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) => <FocusButton key={infusion.id} id={`infusion-${infusion.id}`} focusedId={controller.focusedId} focus={controller.focus} 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></FocusButton>)}
</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) => <FocusButton key={abilityId} id={`passive-ability-${abilityId}`} focusedId={controller.focusedId} focus={controller.focus} className={selectedPassiveAbilityId === abilityId ? "is-selected" : ""} onClick={() => selectPassiveAbility(abilityId)}>{healerAbilities[abilityId].shortName}</FocusButton>)}
</div>
<div className="gear-passive-choice-list">
{passiveChoices.map((passive) => <FocusButton
key={passive.id}
id={`passive-${passive.id}`}
focusedId={controller.focusedId}
focus={controller.focus}
disabled={!passiveUnlocked}
className={`${selectedPassiveInfusionId === passive.id ? "is-selected" : ""} ${hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "is-equipped" : ""}`}
onFocus={() => selectPassiveInfusion(passive.id)}
onPointerEnter={() => selectPassiveInfusion(passive.id)}
onClick={() => { selectPassiveInfusion(passive.id); installPassive(passive.id); }}
><i>{passive.icon}</i><span><strong>{healerAbilities[passive.abilityId].shortName}: {passive.name}</strong><small>{formatRunBuffEffect(passive.id, 1)}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""}</b></FocusButton>)}
</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.abilityId].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)}</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" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} 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></FocusButton> : 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> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} 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></FocusButton>}
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
</FrontSurface>
}
/>
);
}
function SettingToggle({ id, label, copy, value, focusedId, focus, onClick }: { id: string; label: string; copy: string; value: boolean; focusedId: string; focus: (id: string) => void; onClick: () => void }) {
return <FocusButton id={id} focusedId={focusedId} focus={focus} className="setting-row" onClick={onClick}><span><strong>{label}</strong><small>{copy}</small></span><b className={value ? "is-on" : ""}>{value ? "ON" : "OFF"}</b></FocusButton>;
}
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><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></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><FocusButton id="volume-down" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}></FocusButton><b>{settings.masterVolume}%</b><FocusButton id="volume-up" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}></FocusButton></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} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("reducedMotion", !settings.reducedMotion)} /><SettingToggle id="numbers" label="Damage numbers" copy="Show combat values over units" value={settings.damageNumbers} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("damageNumbers", !settings.damageNumbers)} /><SettingToggle id="text" label="Large interface text" copy="Increase menu and tactical labels" value={settings.largeText} focusedId={controller.focusedId} focus={controller.focus} 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 click-to-focus required</strong><small>Controller input routes through app-level actions.</small></span></div>
</FrontSurface>
}
/>
);
}
function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"]) => void }) {
const hunter = useActiveHunter();
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 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 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 launch = () => {
if (isPveRun) return onLaunch(selectRandomBossPair(), "initiate");
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: () => navigate("home"), neighbors: isDungeon ? { down: `boss-group-${selectedBossGroup.id}` } : { down: "launch" } },
], [bossGridColumns, isDungeon, isPveRun, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossGroup, selectedBossId, selectedDifficultySlug, visibleBossIds]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
const launchLabel = isRogueTrials ? "Begin Rogue Trials" : isPve ? "Begin randomized run" : 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."],
]
: 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
? [
["Randomized pair", "Two distinct bosses are selected only when the run begins."],
["Dual-boss pressure", "Both guardians fight simultaneously and must be defeated."],
["Buff intermission", "Choose one of three stacking buffs after every cleared round."],
]
: [
["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><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></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) => (
<FocusButton
key={group.id}
id={`boss-group-${group.id}`}
focusedId={controller.focusedId}
focus={controller.focus}
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>
</FocusButton>
))}
</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 (
<FocusButton
key={bossId}
id={`boss-${bossId}`}
focusedId={controller.focusedId}
focus={controller.focus}
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>
</FocusButton>
);
})}
</div>
</div>
)}
{isDungeon && (
<div className="difficulty-picker" aria-label="Choose encounter difficulty">
<span>Difficulty</span>
{DIFFICULTIES.map((difficulty) => <FocusButton key={difficulty.slug} id={`difficulty-${difficulty.slug}`} focusedId={controller.focusedId} focus={controller.focus} className={difficulty.slug === selectedDifficultySlug ? "is-selected" : ""} onClick={() => selectDifficulty(difficulty.slug)}><strong>{difficulty.name}</strong><small>iLvl {difficulty.itemLevel}</small></FocusButton>)}
</div>
)}
<FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · {DEFAULT_CONTROLLER_GLYPHS.confirm}</small></FocusButton>
{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>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>}
</FrontSurface>
}
/>
);
}
export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"]) => void }) {
const screen = useFrontendStore((state) => state.screen);
if (screen === "login") return <LoginScreen />;
if (screen === "saves") return <SaveScreen />;
if (screen === "home") return <HomeScreen />;
if (screen === "profile") return <ProfileScreen />;
if (screen === "gear") return <GearScreen />;
if (screen === "settings") return <SettingsScreen />;
if (screen === "mode") return <ModeScreen onLaunch={onLaunch} />;
return null;
}