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 & { id: string; focusedId: string; focus: (id: string) => void }) { return ( ); } 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(() => 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 (
Hunter records

Choose a save

{accountId ? `● ${accountId}` : "○ OFFLINE"}
{slots.map((slot) => ( selectSlot(slot.id)} onFocus={() => controller.focus(`slot-${slot.id}`)} /> ))}
{continuation === "create" ? "Create hunter" : resolvingOnline ? "Loading online save…" : "Continue"} {continuation === "create" ? `Use slot ${selectedSlotId}` : continuation === "online" ? "Download online copy" : continuation === "choose" ? "Choose online or device copy" : `Slot ${selectedSlotId} · ${selected.local?.hunterName}`} uploadSlot(selectedSlotId)}>UploadDevice → server downloadSlot(selectedSlotId)}>DownloadServer → device openSaveDialog("copy")}>CopyDuplicate save openSaveDialog("delete")}>DeleteErase device copy navigate("login")}>BackLogin screen
Autosave OFFLINE FIRST
{notice || "Lower display shows selected save details."}
{dialog && (
{dialog === "version" && selected.local && selected.online ? (
Newer online save found

Which save do you want?

Online choice replaces this device copy. Device choice keeps online copy unchanged.

Online copyNEWER
{selected.online.hunterName} {formatPlayTime(selected.online.playSeconds)} · Level {selected.online.healers[selected.online.activeClassId].level}
Device copyOFFLINE
{selected.local.hunterName} {formatPlayTime(selected.local.playSeconds)} · Level {selected.local.healers[selected.local.activeClassId].level}
{ void continueWithOnline(); }}>{resolvingOnline ? "Loading…" : "Continue online copy"}{formatSaveTimestamp(selected.online.updatedAt)} { setDialog(null); playSlot(selectedSlotId); }}>Continue device copy{formatSaveTimestamp(selected.local.updatedAt)} setDialog(null)}>Cancel
{versionError &&
{versionError}
}
) : dialog === "create" ? (
{ event.preventDefault(); finishCreation(); }}> New offline save

Name your hunter

This name identifies the character in local and online save lists.

setHunterName(event.target.value)} onKeyDown={(event) => { if (event.key === "Escape") setDialog(null); }} placeholder="Enter name" /> {normalizeHunterName(hunterName).length}/{MAX_HUNTER_NAME_LENGTH}
Create hunter setDialog(null)}>Cancel
) : dialog === "copy" ? ( <> Copy local save

Choose destination

Destination local save will be overwritten. Online copies stay unchanged.

{slots.filter((slot) => slot.id !== selectedSlotId).map((slot) => ( { copySlot(selectedSlotId, slot.id); setDialog(null); }}> Slot {slot.id}{slot.local ? "Overwrite" : "Empty"} ))}
) : ( <> Delete local save

Erase slot {selectedSlotId}?

Device copy will be removed. Existing online version remains available for download.

{ deleteSlot(selectedSlotId); setDialog(null); }}>Delete local setDialog(null)}>Cancel
)}
)} } bottom={
Slot {selectedSlotId}{cloudStatus}
{selected.local ?? selected.online ? ( <>
{(selected.local ?? selected.online)!.hunterName[0]}
{selected.local ? "Device save" : "Online copy only"}

{(selected.local ?? selected.online)!.hunterName}

{(selected.local ?? selected.online)!.location}

) : ( <>
Local record

Empty slot

Create a hunter or download an online version.

)}
{(selected.local ?? selected.online) && (() => { const save = (selected.local ?? selected.online)!; const healer = HEALER_CLASSES[save.activeClassId]; return ( <>
Active healerLv {save.healers[save.activeClassId].level}{healer.name} Play time{formatPlayTime(save.playSeconds)}Local activity Boss kills{save.stats.totalBossKills}{save.stats.flawlessClears} flawless
Roguelike bestRound {save.stats.highestRoguelikeRound} Endless best{save.stats.highestRogueTrialsEndlessKills} kills
); })()}
Device copy{selected.local ? formatSaveTimestamp(selected.local.updatedAt) : "Not present"} Online copy{selected.online ? formatSaveTimestamp(selected.online.updatedAt) : accountId ? "Not uploaded" : "Sign-in required"}
{notice || "Use upper display for every save action. Details here follow selected slot."}
} /> ); } 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(() => [ { 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 (
Welcome back, {hunter.hunterName}{accountId ? "● SYNC READY" : "○ OFFLINE"}
{HOME_MODES.map((mode) => ( selectMode(mode.id)}> {mode.icon}{mode.copy}{mode.label} ))}
navigate("profile")}>Hunter ProfileStats & collection log navigate("gear")}>Gear UpgradeSpend group drops navigate("settings")}>SettingsAudio, display, controls
} bottom={
Active hunterLOCAL AUTOSAVE
{hunter.hunterName[0]}{activeHealer.icon}
Level {activeProgress.level} · {activeHealer.specialization}

{hunter.hunterName}

{hunter.location}

Boss kills{hunter.stats.totalBossKills} Flawless{hunter.stats.flawlessClears} Allies saved{hunter.stats.alliesSaved}
Choose healer
{HEALER_CLASS_ORDER.map((classId) => { const healer = HEALER_CLASSES[classId]; const progress = hunter.healers[classId]; return selectHealerClass(classId)}> {healer.icon}{healer.name}Level {progress.level} · {progress.inventory.length} items{classId === hunter.activeClassId ? "✓" : ""} ; })}
navigate("saves")}>Change save slotLast saved {formatSaveTimestamp(hunter.updatedAt)}
} /> ); } 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("roguelike"); const [leaderboard, setLeaderboard] = useState(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(() => [ { 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 (
Hunter profile

Collection log

setCollectionView("trophies")}>Trophy Case setCollectionView("stats")}>Boss Stats setCollectionView("loot")}>Group Loot
navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back
{collectionView === "loot" ? <>
Shared group drops · Core: {collection.coreMechanic}

Group {collection.groupLetter} · {collection.groupName}

{earned} / {collection.drops.length} discovered
{collection.drops.map((drop) => (
{drop.icon}{drop.count} {drop.rarity}{drop.name}

{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : `Defeat a Group ${collection.groupLetter} boss`}

{drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""}
))}
Boss pets stay individual.Open Trophy Case to inspect every guardian pet.
: collectionView === "trophies" ? <>
Boss pets · 1 in 500 per victory

Group {collection.groupLetter} · {collection.groupName}

{trophiesEarned} / {collection.bosses.length} trophies lit
{collection.bosses.map((boss) => { const owned = boss.pet.count > 0; return
{BOSS_DEFINITIONS[boss.bossId].icon}
}>
{owned ? "Pet secured" : "Pet undiscovered"}{boss.bossName}{boss.kills} kills · {boss.pet.chance}
{owned ? `Owned${boss.pet.count > 1 ? ` ×${boss.pet.count}` : ""}` : "Locked"} ; })}
Each guardian keeps its own trophy.Defeat that boss for a 1 in 500 pet roll.
: <>
Lifetime records · Overall leaderboards

Boss Stats

Endless best {hunter.stats.highestRogueTrialsEndlessKills} kills
setSelectedStat("roguelike")}>RoguelikeHighest round before defeat{hunter.stats.highestRoguelikeRound} setSelectedStat("rogue-trials-endless")}>Trials EndlessMost bosses in one run{hunter.stats.highestRogueTrialsEndlessKills} {collection.bosses.map((boss) => setSelectedStat(boss.bossId)}>{BOSS_DEFINITIONS[boss.bossId].icon}{boss.bossName}Lifetime boss kills{boss.kills})}
Overall Top 5{selectedStatLabel}{selectedStatValue} {selectedStat === "roguelike" ? "round" : "kills"}
{leaderboardStatus ?
{leaderboardStatus}
:
{leaderboard?.top.length ? leaderboard.top.map((entry) =>
#{entry.rank}{entry.hunterName}{entry.username}{entry.value}
) :
No ranked hunters yet.
}
}
{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}Your rank · {hunter.hunterName}{accountId ?? "Offline hunter"}{selectedStatValue}
Rankings update with server saves.Top five always shown; your row stays visible at any rank.
} } bottom={
{hunter.hunterName} · {activeHealer.name} statsLEVEL {activeProgress.level}
Total boss kills{hunter.stats.totalBossKills} Flawless clears{hunter.stats.flawlessClears} Allies saved{hunter.stats.alliesSaved} Healing done{hunter.stats.healingDone.toLocaleString()} Highest roguelike round{hunter.stats.highestRoguelikeRound} Endless best{hunter.stats.highestRogueTrialsEndlessKills}
Mechanic groups{collections.map((group) => ( setGroupId(group.groupId)}> {group.defeated ? group.groupLetter : "?"}Group {group.groupLetter} · {group.groupName}{group.bosses.reduce((sum, boss) => sum + boss.kills, 0)} kills · {group.coreMechanic}{group.drops.filter((drop) => drop.count > 0).length}/{group.drops.length} ))}
} /> ); } 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(); const slots = new Set(); 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(() => [ ...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 (
Group drop workshop

Gear & Infusions

selectWorkshopMode("upgrade")}>Upgrade selectWorkshopMode("infusion")}>Infusion
navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back
{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 selectOwner(ownerId)}>{GEAR_OWNER_LABELS[ownerId]}Highest slot +{highest}{ownerId === selectedOwnerId ? "✓" : ""}; })}
{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 selectSlot(slotId)}>{slotId === "weapon" ? "⚔" : slotId === "helmet" ? "♙" : slotId === "chest" ? "▧" : slotId === "legs" ? "Ⅱ" : "⌁"}{GEAR_SLOT_LABELS[slotId]}{GEAR_STAT_LABELS[slotRecipe.statId]}+{progress.level}; })}
{workshopMode === "upgrade" ?
Selected upgrade

{GEAR_OWNER_LABELS[selectedOwnerId]} · {GEAR_SLOT_LABELS[selectedSlotId]} +{slot.level}

{GEAR_STAT_LABELS[recipe.statId]} from Group {BOSS_GROUP_BY_ID[recipe.primaryGroupId].letter} and Group {BOSS_GROUP_BY_ID[recipe.secondaryGroupId].letter} drops.

Current{currentBonus}{slot.level >= MAX_GEAR_LEVEL ? "Maximum" : `Rank +${slot.level + 1}`}{nextBonus}
:
Active infusion · unlock +{ACTIVE_INFUSION_MIN_GEAR_LEVEL}

{selectedInfusion.icon} {selectedInfusion.name}

{selectedInfusion.description} Anchor purchase to a +{ACTIVE_INFUSION_MIN_GEAR_LEVEL} slot.

{infusionChoices.map((infusion) => selectInfusion(infusion.id)}>{infusion.icon}{infusion.name}{infusion.description}{hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "✓" : ""})}
{healerOwner &&
Passive blessing · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL}
{ABILITY_ORDER.map((abilityId) => selectPassiveAbility(abilityId)}>{healerAbilities[abilityId].shortName})}
{passiveChoices.map((passive) => selectPassiveInfusion(passive.id)} onPointerEnter={() => selectPassiveInfusion(passive.id)} onClick={() => { selectPassiveInfusion(passive.id); installPassive(passive.id); }} >{passive.icon}{healerAbilities[passive.abilityId].shortName}: {passive.name}{formatRunBuffEffect(passive.id, 1)}{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""})}
}
}
} bottom={
{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : passiveContext ? `${healerAbilities[selectedPassive.abilityId].name}: ${selectedPassive.name}` : `${selectedInfusion.name} infusion`}{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS
{workshopMode === "upgrade" ? "Upgrade requirements" : passiveContext ? "Passive blessing · Rank 1" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`} {passiveContext ?
{passiveUnlocked ? "✓" : "×"}{formatRunBuffEffect(selectedPassive.id, 1)}{selectedPassive.detail}{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "EQUIPPED" : "RANK 1"}
: (workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => { const owned = hunter.materials.find((item) => item.id === cost.itemId)?.quantity ?? 0; return
= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}>{owned >= cost.quantity ? "✓" : "×"}{cost.itemName}{owned} owned · {cost.quantity} needed{owned}/{cost.quantity}
; }) :
Maximum rank reachedNo more materials required.+{MAX_GEAR_LEVEL}
}
{workshopMode === "upgrade" ? {slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"} : passiveContext ?
{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : `${DEFAULT_CONTROLLER_GLYPHS.confirm} · Equip selected passive`}Applies at rank 1 next encounter.
: {infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}{!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"}}
{notice || "Gear changes save locally and apply when next encounter starts."}
} /> ); } 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 {label}{copy}{value ? "ON" : "OFF"}; } 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(() => [ { 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 (
Field configuration

Settings

navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back
Audio
Master volumeAll music, effects, and voice
updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}>−{settings.masterVolume}% updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}>+
Display & accessibility updateSetting("reducedMotion", !settings.reducedMotion)} /> updateSetting("damageNumbers", !settings.damageNumbers)} /> updateSetting("largeText", !settings.largeText)} />
{notice || "Settings write to offline storage immediately."}
} bottom={
ControllerBUILT-IN THOR PAD
{DEFAULT_CONTROLLER_GLYPHS.faceTop}{DEFAULT_CONTROLLER_GLYPHS.faceLeft}{DEFAULT_CONTROLLER_GLYPHS.faceRight}{DEFAULT_CONTROLLER_GLYPHS.faceBottom}
{DEFAULT_CONTROLLER_GLYPHS.confirm} Confirm / cast Purify{DEFAULT_CONTROLLER_GLYPHS.back} Back / cast ShieldD-Pad Navigate / target partyRight stick Rotate camera{DEFAULT_CONTROLLER_GLYPHS.start} Pause / menu
No click-to-focus requiredController input routes through app-level actions.
} /> ); } 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(() => [ ...(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 (
Game mode

{mode.title}

navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back
{!isDungeon &&
{mode.eyebrow}

{mode.title}

{mode.description}

{mode.detail}
} {isDungeon && (
Choose a mechanic group
{BOSS_GROUPS.map((group) => ( selectBossGroup(group.id)} > {group.letter}Group {group.letter}{group.name} ))}
Group {selectedBossGroup.letter} · {selectedBossGroup.name}{selectedBossGroup.coreMechanic} mechanics · {visibleBossIds.length} guardians
{visibleBossIds.map((bossId) => { const boss = BOSS_DEFINITIONS[bossId]; return ( selectBoss(bossId)} > {boss.icon}{boss.name}{boss.mechanicIds.filter((id) => !bossMechanicIsPassive(id)).map(bossMechanicName).join(" · ")}{selectedBossId === bossId ? "✓" : ""} ); })}
)} {isDungeon && (
Difficulty {DIFFICULTIES.map((difficulty) => selectDifficulty(difficulty.slug)}>{difficulty.name}iLvl {difficulty.itemLevel})}
)} {launchLabel}{mode.status} · {DEFAULT_CONTROLLER_GLYPHS.confirm} {message &&
{message}
} } bottom={
Run preparation{isDungeon ? selectedDifficulty.name.toUpperCase() : mode.status.toUpperCase()}
{contextRules.map(([title, copy], index) =>
0{index + 1}{title}{copy}
)}
Equipped role{healer.specialization} · Level {progress?.level ?? 1}6 abilities · {progress?.inventory.length ?? 0} class items · Controller ready
{isDungeon &&
Guaranteed reward{bossGroupDrop(selectedBossId, selectedDifficultySlug).name}1–3 group drops · {selectedDifficulty.rarity} · Pet chance 1 in 500
}
} /> ); } export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"]) => void }) { const screen = useFrontendStore((state) => state.screen); if (screen === "login") return ; if (screen === "saves") return ; if (screen === "home") return ; if (screen === "profile") return ; if (screen === "gear") return ; if (screen === "settings") return ; if (screen === "mode") return ; return null; }