Files
i-want-to-heal/src/modes/iwt1/IWantToHeal1App.tsx
T
2026-07-06 11:46:53 -04:00

1623 lines
66 KiB
TypeScript

import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
import {
loadCpuPvpLeaderboard,
type CpuPvpLeaderboardEntry,
type PvpContentType,
} from '../../pvpRoguelike'
import {
loadAuthSession,
logoutAccount,
type Account,
type AuthSession,
type CharacterProfile,
} from '../../profile'
import {
applyCloudSaveSync,
getCloudSyncStatus,
getGameMode,
previewCloudSaveSync,
type CloudSyncChoice,
type CloudSyncComparison,
type GameMode,
} from '../../gameRepository'
import { focusFirstControl, useGameAction } from '../../input.tsx'
import {
useDualScreen,
useDualScreenSetupPublisher,
type DualScreenSetupState,
} from '../../dualScreen'
import { barFillStyle } from '../../components/barStyles'
const CombatScreen = lazy(() => import('../../components/CombatScreen').then((module) => ({ default: module.CombatScreen })))
const CustomizeScreen = lazy(() => import('../../components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen })))
const EquipmentScreen = lazy(() => import('../../components/EquipmentScreen').then((module) => ({ default: module.EquipmentScreen })))
const HunterProfileScreen = lazy(() => import('../../components/HunterProfileScreen').then((module) => ({ default: module.HunterProfileScreen })))
const PvPRoguelikeScreen = lazy(() => import('../../components/PvpRoguelikeScreen').then((module) => ({ default: module.PvPRoguelikeScreen })))
const PvpStadiumScreen = lazy(() => import('../../components/PvpStadiumScreen').then((module) => ({ default: module.PvpStadiumScreen })))
const TalentScreen = lazy(() => import('../../components/TalentScreen').then((module) => ({ default: module.TalentScreen })))
const SettingsScreen = lazy(() => import('../../components/SettingsScreen').then((module) => ({ default: module.SettingsScreen })))
type Screen =
| 'menu'
| 'dungeons'
| 'combat'
| 'raids'
| 'roguelike'
| 'pvp'
| 'customize'
| 'equipment'
| 'hunter-profile'
| 'talents'
| 'settings'
const MENU_ITEMS: Array<{
screen: Screen
label: string
glyph: string
description: string
}> = [
{ screen: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Guide a six-player party through dangerous encounters.' },
{ screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide an eighteen-player party through three-phase challenges.' },
{ screen: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through escalating random encounters.' },
{ screen: 'pvp', label: 'PvP', glyph: 'P', description: 'Race another healer through roguelike encounters with buffs and sabotage.' },
{ screen: 'hunter-profile', label: 'Hunter Profile', glyph: 'H', description: 'Review boss kills, PvP record, drops, and pets.' },
{ screen: 'customize', label: 'Customize Character', glyph: 'C', description: 'Choose your class and prepare a six-ability loadout.' },
{ screen: 'settings', label: 'Settings', glyph: 'S', description: 'Remap PC and controller inputs.' },
]
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
const SHOW_LEADERBOARDS = false
const ACTIVITY_PAGE_SIZE = 4
const HOME_MENU_COLUMNS = 2
const DUNGEON_NAV_COLUMNS = 2
type DungeonNavEntry =
| { kind: 'spacer'; disabled: true }
| { kind: 'back'; disabled?: boolean }
| { kind: 'pagePrev'; disabled?: boolean }
| { kind: 'pageNext'; disabled?: boolean }
| { kind: 'activity'; index: number; disabled?: boolean }
| { kind: 'tier'; index: number; disabled?: boolean }
| { kind: 'start'; disabled?: boolean }
| { kind: 'marathon'; disabled?: boolean }
| { kind: 'loot'; disabled?: boolean }
| { kind: 'lootSort'; disabled?: boolean }
type RoguelikeNavEntry =
| { kind: 'back' }
| { kind: 'variantPve' }
| { kind: 'variantPvp' }
| { kind: 'pveDungeon' }
| { kind: 'pveRaid' }
| { kind: 'pveStart' }
| { kind: 'pvpDungeon' }
| { kind: 'pvpRaid' }
| { kind: 'pvpStadium' }
| { kind: 'pvpStart' }
type RoguelikeNavPosition = {
row: number
column: number
}
function formatSaveTimestamp(updatedAt: number | null) {
if (!updatedAt) return 'Unknown legacy timestamp'
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(updatedAt))
}
function cloudSyncRelationText(comparison: CloudSyncComparison) {
if (comparison.relation === 'server-newer') return 'Server save is newer than this device.'
if (comparison.relation === 'local-newer') return 'This device save is newer than the server.'
if (comparison.relation === 'same') return 'Server and this device have the same save time.'
return 'One save has no timestamp yet. Choose the copy you want to keep.'
}
function activityInitials(name: string) {
return name
.split(/\s+/)
.filter((word) => /^[A-Za-z0-9]/.test(word))
.slice(0, 2)
.map((word) => word[0].toUpperCase())
.join('')
}
function ScreenLoading() {
return (
<main className="game-shell">
<section className="message-panel">
<p className="eyebrow">Opening Chronicle</p>
<h1>Loading...</h1>
</section>
</main>
)
}
type RoguelikeVariant = 'pve' | 'pvp'
type IWantToHeal1AppProps = {
initialSession: AuthSession
onAuthenticationCleared: () => void
onBackToGameSelect: () => void
}
function IWantToHeal1App({
initialSession,
onAuthenticationCleared,
onBackToGameSelect,
}: IWantToHeal1AppProps) {
const { enabled: dualScreenEnabled } = useDualScreen()
const [screen, setScreen] = useState<Screen>('menu')
const [account, setAccount] = useState<Account | null>(initialSession.account)
const [profile, setProfile] = useState<CharacterProfile | null>(initialSession.profile)
const [authChecked, setAuthChecked] = useState(false)
const [gameMode, setGameMode] = useState<GameMode>(getGameMode())
const [serverMessage, setServerMessage] = useState('')
const [selectedDifficultyId, setSelectedDifficultyId] = useState(() => {
const saved = Number(window.localStorage.getItem(LAST_DIFFICULTY_KEY))
return Number.isFinite(saved) && saved > 0 ? saved : 1
})
const [selectedDungeonId, setSelectedDungeonId] = useState(1)
const [selectedRaidId, setSelectedRaidId] = useState(20)
const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon')
const [roguelikeVariant, setRoguelikeVariant] = useState<RoguelikeVariant>('pve')
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
const [selectedMarathonMode, setSelectedMarathonMode] = useState(false)
const [activityPage, setActivityPage] = useState(0)
const [combatContentId, setCombatContentId] = useState(1)
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
const [showLoot, setShowLoot] = useState(false)
const [lootSort, setLootSort] = useState<'sequence' | 'boss'>('sequence')
const [showLeaderboard, setShowLeaderboard] = useState(false)
const [error, setError] = useState('')
const [syncingCloud, setSyncingCloud] = useState(false)
const [syncMessage, setSyncMessage] = useState('')
const [syncComparison, setSyncComparison] = useState<CloudSyncComparison | null>(null)
const [homeSelectedIndex, setHomeSelectedIndex] = useState(0)
const [dungeonSelectedIndex, setDungeonSelectedIndex] = useState(0)
const [roguelikeSelectedIndex, setRoguelikeSelectedIndex] = useState(1)
useEffect(() => {
loadAuthSession()
.then((session) => {
setAccount(session.account)
setProfile(session.profile)
})
.catch((reason: unknown) => {
setServerMessage(
reason instanceof Error
? `${reason.message} Offline play is still available.`
: 'Unable to reach the server. Offline play is still available.',
)
})
.finally(() => setAuthChecked(true))
}, [])
useEffect(() => {
if (authChecked && (!account || !profile)) onAuthenticationCleared()
}, [account, authChecked, onAuthenticationCleared, profile])
useEffect(() => {
const handleModeChange = (event: Event) => {
const nextMode = (event as CustomEvent<GameMode>).detail
setGameMode(nextMode)
}
window.addEventListener('chronicle:mode-changed', handleModeChange as EventListener)
return () => {
window.removeEventListener('chronicle:mode-changed', handleModeChange as EventListener)
}
}, [])
useEffect(() => {
if (screen === 'combat') return
window.requestAnimationFrame(() => {
focusFirstControl()
})
}, [screen])
useEffect(() => {
if (!authChecked || !account || !profile || screen === 'combat') return
window.requestAnimationFrame(() => {
focusFirstControl()
})
}, [account, authChecked, profile, screen])
useEffect(() => {
window.localStorage.setItem(LAST_DIFFICULTY_KEY, String(selectedDifficultyId))
}, [selectedDifficultyId])
const [cpuLeaderboard, setCpuLeaderboard] = useState<CpuPvpLeaderboardEntry[]>([])
useEffect(() => {
const frame = window.requestAnimationFrame(() => {
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
})
return () => window.cancelAnimationFrame(frame)
}, [pvpContentType, screen, roguelikeVariant])
const profileDungeons = profile?.dungeons
const dungeonOptions = useMemo(
() => profileDungeons?.filter((candidate) => candidate.contentType === 'dungeon') ?? [],
[profileDungeons],
)
const raidOptions = useMemo(
() => profileDungeons?.filter((candidate) => candidate.contentType === 'raid') ?? [],
[profileDungeons],
)
const selectedDungeonOption = useMemo(
() => dungeonOptions.find((candidate) => candidate.id === selectedDungeonId) ?? dungeonOptions[0],
[dungeonOptions, selectedDungeonId],
)
const selectedRaidOption = useMemo(
() => raidOptions.find((candidate) => candidate.id === selectedRaidId) ?? raidOptions[0],
[raidOptions, selectedRaidId],
)
const activityOptions = useMemo(
() => screen === 'raids' ? raidOptions : dungeonOptions,
[dungeonOptions, raidOptions, screen],
)
const combatDungeonOption = useMemo(
() => {
if (!profileDungeons) return undefined
return combatContentId < 0
? profileDungeons.find((candidate) => candidate.contentType === roguelikeKind) ?? profileDungeons[0]
: profileDungeons.find((candidate) => candidate.id === combatContentId) ?? profileDungeons[0]
},
[combatContentId, profileDungeons, roguelikeKind],
)
const combatDifficultyOption = useMemo(
() => combatDungeonOption?.difficulties.find(
(candidate) => candidate.id === selectedDifficultyId,
) ?? combatDungeonOption?.difficulties[0],
[combatDungeonOption, selectedDifficultyId],
)
const roguelikePool = useMemo(
() => profileDungeons
?.filter((candidate) => candidate.contentType === roguelikeKind)
.flatMap((candidate) => candidate.encounters) ?? [],
[profileDungeons, roguelikeKind],
)
const pvpPool = useMemo(
() => profileDungeons
?.filter((candidate) => candidate.contentType === pvpContentType)
.flatMap((candidate) => candidate.encounters) ?? [],
[profileDungeons, pvpContentType],
)
const tierOptions = useMemo(
() => activityOptions
.flatMap((option) => option.difficulties)
.filter((difficulty, index, all) => (
all.findIndex((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel) === index
))
.sort((a, b) => a.droppedItemLevel - b.droppedItemLevel),
[activityOptions],
)
const savedDifficulty = useMemo(
() => profileDungeons
?.flatMap((option) => option.difficulties)
.find((candidate) => candidate.id === selectedDifficultyId),
[profileDungeons, selectedDifficultyId],
)
const selectedTier = useMemo(
() => {
const characterLevel = profile?.character.level ?? 0
const savedTier = tierOptions.find((candidate) => (
candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel
))
if (savedTier) return savedTier
for (let index = tierOptions.length - 1; index >= 0; index -= 1) {
if (characterLevel >= tierOptions[index].unlockLevel) return tierOptions[index]
}
return tierOptions[0]
},
[profile?.character.level, savedDifficulty?.droppedItemLevel, tierOptions],
)
const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0
const activityPageCount = Math.max(1, Math.ceil(activityOptions.length / ACTIVITY_PAGE_SIZE))
const currentActivityPage = Math.min(activityPage, activityPageCount - 1)
const pagedActivityOptions = useMemo(
() => activityOptions.slice(
currentActivityPage * ACTIVITY_PAGE_SIZE,
currentActivityPage * ACTIVITY_PAGE_SIZE + ACTIVITY_PAGE_SIZE,
),
[activityOptions, currentActivityPage],
)
const selectedActivityOption = useMemo(
() => {
const fallback = screen === 'raids' && selectedRaidOption ? selectedRaidOption : selectedDungeonOption
const selectedActivityId = screen === 'raids' && selectedRaidOption ? selectedRaidOption.id : selectedDungeonOption?.id
return activityOptions.find((candidate) => candidate.id === selectedActivityId)
?? activityOptions[0]
?? fallback
},
[activityOptions, screen, selectedDungeonOption, selectedRaidOption],
)
const selectedDifficultyOption = useMemo(
() => selectedActivityOption?.difficulties.find(
(candidate) => candidate.droppedItemLevel === selectedTierItemLevel,
) ?? selectedActivityOption?.difficulties[0],
[selectedActivityOption, selectedTierItemLevel],
)
const lootPreviewEncounters = useMemo(
() => [...(selectedActivityOption?.encounters ?? [])]
.filter((encounter) => encounter.isBoss)
.sort((a, b) => lootSort === 'boss'
? a.enemyName.localeCompare(b.enemyName) || a.sequence - b.sequence
: a.sequence - b.sequence),
[lootSort, selectedActivityOption?.encounters],
)
const lootPreviewByEncounterId = useMemo(
() => new Map(lootPreviewEncounters.map((encounter) => [
encounter.id,
encounter.lootTables.filter((entry) => entry.difficultyId === selectedDifficultyOption?.id),
])),
[lootPreviewEncounters, selectedDifficultyOption?.id],
)
const leaderboardEntries = useMemo(
() => selectedActivityOption?.leaderboards[leaderboardCategory].filter(
(entry) => entry.difficultyId === selectedDifficultyOption?.id,
) ?? [],
[leaderboardCategory, selectedActivityOption?.leaderboards, selectedDifficultyOption?.id],
)
async function signOut() {
try {
await logoutAccount()
setAccount(null)
setProfile(null)
setGameMode(getGameMode())
setScreen('menu')
setSyncMessage('')
setSyncComparison(null)
onAuthenticationCleared()
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
}
}
async function syncSaveNow() {
setSyncingCloud(true)
setSyncMessage('')
setSyncComparison(null)
try {
const comparison = await previewCloudSaveSync()
setSyncComparison(comparison)
setSyncMessage('')
} catch (reason) {
setSyncMessage(reason instanceof Error ? reason.message : 'Unable to sync cloud save.')
} finally {
setSyncingCloud(false)
}
}
async function keepCloudSave(choice: CloudSyncChoice) {
setSyncingCloud(true)
setSyncMessage(choice === 'local' ? 'Uploading this device save...' : 'Downloading server save...')
try {
const updated = await applyCloudSaveSync(choice)
setProfile(updated)
setGameMode(getGameMode())
setSyncComparison(null)
setSyncMessage(choice === 'local'
? 'Server now uses this device save.'
: 'This device now uses the server save.')
} catch (reason) {
setSyncMessage(reason instanceof Error ? reason.message : 'Unable to sync cloud save.')
} finally {
setSyncingCloud(false)
}
}
const cloudSync = getCloudSyncStatus()
const canShowCloudSync = Boolean(account && account.id !== -1 && cloudSync.available)
const cloudSyncEntryCount = canShowCloudSync ? (syncComparison ? 3 : 1) : 0
const homeMenuOffset = cloudSyncEntryCount
const homeMenuEntryCount = MENU_ITEMS.length + homeMenuOffset
const homeActiveIndex = Math.min(homeSelectedIndex, homeMenuEntryCount - 1)
function openHomeMenuIndex(index: number) {
if (canShowCloudSync && index === 0) {
if (!syncingCloud) void syncSaveNow()
return
}
if (canShowCloudSync && syncComparison && index === 1) {
if (!syncingCloud) void keepCloudSave('local')
return
}
if (canShowCloudSync && syncComparison && index === 2) {
if (!syncingCloud) void keepCloudSave('server')
return
}
const item = MENU_ITEMS[index - homeMenuOffset]
if (!item) return
if (item.screen === 'pvp') {
setRoguelikeVariant('pvp')
setRoguelikeSelectedIndex(2)
setScreen('roguelike')
return
}
if (item.screen === 'roguelike') {
setRoguelikeSelectedIndex(1)
}
if (item.screen === 'dungeons' || item.screen === 'raids') {
const nextOptions = item.screen === 'raids' ? raidOptions : dungeonOptions
setActivityPage(0)
setDungeonSelectedIndex(firstActivityDungeonEntryIndexForPageCount(
Math.max(1, Math.ceil(nextOptions.length / ACTIVITY_PAGE_SIZE)),
))
}
setScreen(item.screen)
}
function moveHomeSelection(action: string) {
setHomeSelectedIndex((current) => {
const bounded = Math.min(current, homeMenuEntryCount - 1)
if (canShowCloudSync && syncComparison && bounded <= 2) {
if (bounded === 0) {
return action === 'navigateDown' || action === 'navigateRight' ? 1 : 0
}
if (bounded === 1) {
if (action === 'navigateRight') return 2
if (action === 'navigateDown') return Math.min(homeMenuOffset, homeMenuEntryCount - 1)
return 0
}
if (action === 'navigateLeft') return 1
if (action === 'navigateDown') return Math.min(homeMenuOffset, homeMenuEntryCount - 1)
return 0
}
if (canShowCloudSync && !syncComparison && bounded === 0) {
return action === 'navigateDown' || action === 'navigateRight'
? Math.min(homeMenuOffset, homeMenuEntryCount - 1)
: 0
}
const menuIndex = bounded - homeMenuOffset
if (menuIndex < 0) return bounded
const column = menuIndex % HOME_MENU_COLUMNS
if (action === 'navigateLeft') {
if (column > 0) return bounded - 1
if (canShowCloudSync && syncComparison && menuIndex < HOME_MENU_COLUMNS) return 2
return bounded
}
if (action === 'navigateRight') {
const nextMenuIndex = menuIndex + 1
return column < HOME_MENU_COLUMNS - 1 && nextMenuIndex < MENU_ITEMS.length ? bounded + 1 : bounded
}
if (action === 'navigateUp') {
const previousMenuIndex = menuIndex - HOME_MENU_COLUMNS
if (previousMenuIndex >= 0) return homeMenuOffset + previousMenuIndex
if (canShowCloudSync && syncComparison) return column === 0 ? 1 : 2
if (canShowCloudSync) return 0
return bounded
}
const nextMenuIndex = menuIndex + HOME_MENU_COLUMNS
return nextMenuIndex < MENU_ITEMS.length ? homeMenuOffset + nextMenuIndex : bounded
})
}
function dungeonEntries() {
const difficulty = selectedDifficultyOption ?? selectedActivityOption?.difficulties[0]
const locked = !profile || !difficulty
const entries: DungeonNavEntry[] = [
{ kind: 'back' },
]
if (activityPageCount > 1) {
entries.push(
{ kind: 'pagePrev', disabled: currentActivityPage === 0 },
{ kind: 'pageNext', disabled: currentActivityPage >= activityPageCount - 1 },
)
}
if (entries.length % DUNGEON_NAV_COLUMNS !== 0) {
entries.push({ kind: 'spacer', disabled: true })
}
pagedActivityOptions.forEach((candidate, index) => {
const candidateDifficulty = difficulty
? candidate.difficulties.find(
(option) => option.droppedItemLevel === difficulty.droppedItemLevel,
) ?? candidate.difficulties[0]
: candidate.difficulties[0]
entries.push({
kind: 'activity',
index,
disabled: !profile || !candidateDifficulty,
})
})
entries.push(
{ kind: 'start', disabled: locked },
{ kind: 'marathon', disabled: locked },
)
tierOptions.forEach((difficultyOption, index) => {
entries.push({
kind: 'tier',
index,
disabled: !profile || !difficultyOption,
})
})
entries.push({ kind: 'loot' })
if (showLoot) entries.push({ kind: 'lootSort' })
return entries
}
function firstEnabledDungeonEntry(entries: DungeonNavEntry[]) {
return Math.max(0, entries.findIndex((entry) => !entry.disabled))
}
function firstActivityDungeonEntryIndexForPageCount(pageCount: number) {
const entryCountBeforeActivities = pageCount > 1 ? 3 : 1
return entryCountBeforeActivities % DUNGEON_NAV_COLUMNS === 0
? entryCountBeforeActivities
: entryCountBeforeActivities + 1
}
function activeDungeonEntry(entries = dungeonEntries()) {
if (entries[dungeonSelectedIndex] && !entries[dungeonSelectedIndex].disabled) {
return entries[dungeonSelectedIndex]
}
const firstEnabled = firstEnabledDungeonEntry(entries)
return entries[firstEnabled] ?? entries[0]
}
function dungeonEntrySelected(entry: DungeonNavEntry['kind'], index?: number) {
const active = activeDungeonEntry()
return active?.kind === entry && ('index' in active ? active.index === index : index === undefined)
}
function moveDungeonSelection(action: string) {
const entries = dungeonEntries()
if (entries.length === 0) return
setDungeonSelectedIndex((current) => {
const bounded = entries[current] && !entries[current].disabled
? current
: firstEnabledDungeonEntry(entries)
const column = bounded % DUNGEON_NAV_COLUMNS
const direction = action === 'navigateLeft' || action === 'navigateUp' ? -1 : 1
const step = action === 'navigateUp' || action === 'navigateDown' ? DUNGEON_NAV_COLUMNS : 1
if (action === 'navigateLeft' && column === 0) return bounded
if (action === 'navigateRight' && column === DUNGEON_NAV_COLUMNS - 1) return bounded
for (let next = bounded + direction * step; next >= 0 && next < entries.length; next += direction * step) {
if (!entries[next].disabled) return next
}
return bounded
})
}
function selectActivityByPageIndex(index: number) {
const candidate = pagedActivityOptions[index]
if (!candidate) return
const difficulty = selectedDifficultyOption
? candidate.difficulties.find(
(option) => option.droppedItemLevel === selectedDifficultyOption.droppedItemLevel,
) ?? candidate.difficulties[0]
: candidate.difficulties[0]
if (screen === 'raids') setSelectedRaidId(candidate.id)
else setSelectedDungeonId(candidate.id)
setSelectedDifficultyId(difficulty.id)
}
function selectTierByIndex(index: number) {
const difficulty = tierOptions[index]
const activity = selectedActivityOption ?? activityOptions[0]
if (!difficulty || !activity) return
setActivityPage(0)
const nextActivity = activity.difficulties.some(
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
)
? activity
: activityOptions.find((option) =>
option.difficulties.some((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel),
)
if (!nextActivity) return
if (screen === 'raids') setSelectedRaidId(nextActivity.id)
else setSelectedDungeonId(nextActivity.id)
const nextDifficulty = nextActivity.difficulties.find(
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
)
if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id)
}
function startSelectedRun(marathon: boolean) {
const activity = selectedActivityOption ?? activityOptions[0]
const difficulty = selectedDifficultyOption ?? activity?.difficulties[0]
if (!activity || !difficulty) return
setSelectedMarathonMode(marathon)
setCombatContentId(activity.id)
setSelectedDifficultyId(difficulty.id)
setScreen('combat')
}
function openDungeonEntry(entry: DungeonNavEntry | undefined) {
if (!entry || entry.disabled) return
if (entry.kind === 'back') setScreen('menu')
else if (entry.kind === 'pagePrev') setActivityPage((page) => Math.max(0, page - 1))
else if (entry.kind === 'pageNext') setActivityPage((page) => Math.min(activityPageCount - 1, page + 1))
else if (entry.kind === 'activity') selectActivityByPageIndex(entry.index)
else if (entry.kind === 'tier') selectTierByIndex(entry.index)
else if (entry.kind === 'start') startSelectedRun(false)
else if (entry.kind === 'marathon') startSelectedRun(true)
else if (entry.kind === 'loot') setShowLoot((current) => !current)
else if (entry.kind === 'lootSort') setLootSort((current) => current === 'sequence' ? 'boss' : 'sequence')
}
function startPveRoguelike() {
const baseDungeon = dungeonOptions[0]
const baseRaid = raidOptions[0]
if (roguelikeKind === 'raid') {
setCombatContentId(-2)
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
} else {
setCombatContentId(-1)
setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1)
}
setSelectedMarathonMode(false)
setScreen('combat')
}
function roguelikeEntries() {
const entries: RoguelikeNavEntry[] = [
{ kind: 'back' },
{ kind: 'variantPve' },
{ kind: 'variantPvp' },
]
if (roguelikeVariant === 'pve') {
entries.push(
{ kind: 'pveDungeon' },
{ kind: 'pveRaid' },
{ kind: 'pveStart' },
)
} else {
entries.push(
{ kind: 'pvpDungeon' },
{ kind: 'pvpRaid' },
{ kind: 'pvpStadium' },
{ kind: 'pvpStart' },
)
}
return entries
}
function activeRoguelikeEntry(entries = roguelikeEntries()) {
return entries[Math.min(roguelikeSelectedIndex, entries.length - 1)] ?? entries[0]
}
function roguelikeEntrySelected(kind: RoguelikeNavEntry['kind']) {
return activeRoguelikeEntry()?.kind === kind
}
function roguelikeEntryPosition(entry: RoguelikeNavEntry): RoguelikeNavPosition {
if (entry.kind === 'back') return { row: 0, column: 0 }
if (entry.kind === 'variantPve') return { row: 1, column: 0 }
if (entry.kind === 'variantPvp') return { row: 1, column: 1 }
if (entry.kind === 'pveDungeon' || entry.kind === 'pvpDungeon') return { row: 2, column: 0 }
if (entry.kind === 'pveRaid' || entry.kind === 'pvpRaid') return { row: 2, column: 1 }
if (entry.kind === 'pvpStart') return { row: 3, column: 1 }
return { row: 3, column: 0 }
}
function moveRoguelikeSelection(action: string) {
const entries = roguelikeEntries()
setRoguelikeSelectedIndex((current) => {
const bounded = Math.min(current, entries.length - 1)
const active = entries[bounded]
if (!active) return bounded
const activePosition = roguelikeEntryPosition(active)
const candidates = entries
.map((entry, index) => ({ entry, index, position: roguelikeEntryPosition(entry) }))
.filter(({ index, position }) => {
if (index === bounded) return false
if (action === 'navigateLeft') return position.row === activePosition.row && position.column < activePosition.column
if (action === 'navigateRight') return position.row === activePosition.row && position.column > activePosition.column
if (action === 'navigateUp') return position.row < activePosition.row
return position.row > activePosition.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.position.row - activePosition.row)
+ Math.abs(a.position.column - activePosition.column)
const bPrimary = Math.abs(b.position.row - activePosition.row)
+ Math.abs(b.position.column - activePosition.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.position.column - activePosition.column)
: Math.abs(a.position.row - activePosition.row)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.position.column - activePosition.column)
: Math.abs(b.position.row - activePosition.row)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
})
}
function openRoguelikeEntry(entry: RoguelikeNavEntry | undefined) {
if (!entry) return
if (entry.kind === 'back') setScreen('menu')
else if (entry.kind === 'variantPve') setRoguelikeVariant('pve')
else if (entry.kind === 'variantPvp') setRoguelikeVariant('pvp')
else if (entry.kind === 'pveDungeon') setRoguelikeKind('dungeon')
else if (entry.kind === 'pveRaid') setRoguelikeKind('raid')
else if (entry.kind === 'pveStart') startPveRoguelike()
else if (entry.kind === 'pvpDungeon') setPvpContentType('dungeon')
else if (entry.kind === 'pvpRaid') setPvpContentType('raid')
else if (entry.kind === 'pvpStadium') setPvpContentType('stadium')
else if (entry.kind === 'pvpStart') setScreen('pvp')
}
useGameAction((action, device) => {
if (device !== 'controller') return
if (screen === 'menu') {
if (action === 'confirm') {
openHomeMenuIndex(homeActiveIndex)
return
}
if (!action.startsWith('navigate')) return
moveHomeSelection(action)
return
}
if (screen === 'dungeons' || screen === 'raids') {
if (action === 'back') {
setScreen('menu')
return
}
if (action === 'confirm') {
openDungeonEntry(activeDungeonEntry())
return
}
if (action.startsWith('navigate')) moveDungeonSelection(action)
}
if (screen === 'roguelike') {
if (action === 'back') {
setScreen('menu')
return
}
if (action === 'confirm') {
openRoguelikeEntry(activeRoguelikeEntry())
return
}
if (action.startsWith('navigate')) moveRoguelikeSelection(action)
}
})
const setupDualScreenState = useMemo<DualScreenSetupState | null>(() => {
if (
!(screen === 'dungeons' || screen === 'raids')
|| !profile
|| !selectedActivityOption
|| !selectedDifficultyOption
) return null
return {
contentType: selectedActivityOption.contentType,
title: selectedActivityOption.name,
subtitle: `${selectedActivityOption.locationName} | Level ${selectedActivityOption.recommendedLevel} | ${selectedActivityOption.partySize} Players`,
description: selectedActivityOption.description,
initials: activityInitials(selectedActivityOption.name),
difficultyName: selectedDifficultyOption.name,
itemLevel: selectedDifficultyOption.droppedItemLevel,
experience: Math.round(selectedActivityOption.experienceReward * selectedDifficultyOption.experienceMultiplier),
lockedReason: undefined,
stats: {
health: `${selectedDifficultyOption.healthMultiplier.toFixed(2)}x`,
damage: `${selectedDifficultyOption.damageMultiplier.toFixed(2)}x`,
xp: `${selectedDifficultyOption.experienceMultiplier.toFixed(1)}x`,
loot: `iLvl ${selectedDifficultyOption.droppedItemLevel}`,
},
}
}, [profile, screen, selectedActivityOption, selectedDifficultyOption])
useDualScreenSetupPublisher(setupDualScreenState, dualScreenEnabled)
if (error) {
return (
<main className="game-shell">
<section className="message-panel">
<p className="eyebrow">Database Error</p>
<h1>Character Unavailable</h1>
<p>{error}</p>
</section>
</main>
)
}
if (!authChecked) {
return (
<main className="game-shell">
<section className="message-panel">
<p className="eyebrow">Opening Chronicle</p>
<h1>Loading...</h1>
</section>
</main>
)
}
if (!account || !profile) {
return (
<main className="game-shell">
<section className="message-panel">
<p className="eyebrow">Opening Chronicle</p>
<h1>Returning to Sign In...</h1>
{serverMessage && <p>{serverMessage}</p>}
</section>
</main>
)
}
if (screen === 'combat') {
const dungeon = combatDungeonOption ?? profile.dungeons[0]
const difficulty = combatDifficultyOption ?? dungeon.difficulties[0]
return (
<Suspense fallback={<ScreenLoading />}>
<CombatScreen
difficulty={difficulty}
dungeon={dungeon}
hardMode={false}
marathonMode={selectedMarathonMode && combatContentId > 0}
profile={profile}
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
roguelikeUpgradeTiming={combatContentId < 0 ? 'encounter' : undefined}
roguelikeAbilityLabelMode={combatContentId < 0 ? 'ability' : undefined}
roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined}
startPart={1}
onExit={() => {
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
}}
onMainMenu={() => setScreen('menu')}
onProfileUpdated={setProfile}
/>
</Suspense>
)
}
if (screen === 'pvp') {
if (pvpContentType === 'stadium') {
return (
<Suspense fallback={<ScreenLoading />}>
<PvpStadiumScreen
gameMode={gameMode}
onExit={() => {
setRoguelikeVariant('pvp')
setScreen('roguelike')
}}
onProfileUpdated={setProfile}
profile={profile}
/>
</Suspense>
)
}
return (
<Suspense fallback={<ScreenLoading />}>
<PvPRoguelikeScreen
contentType={pvpContentType}
encounterPool={pvpPool}
gameMode={gameMode}
onExit={() => {
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
setRoguelikeVariant('pvp')
setScreen('roguelike')
}}
onMainMenu={() => {
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
setScreen('menu')
}}
onProfileUpdated={setProfile}
profile={profile}
/>
</Suspense>
)
}
const levelStart = profile.character.currentLevelExperience
const levelEnd = profile.character.nextLevelExperience
const experienceIntoLevel = profile.character.experience - levelStart
const experienceForLevel = Math.max(1, levelEnd - levelStart)
const experiencePercent = profile.character.level >= profile.maxLevel
? 100
: Math.min(100, (experienceIntoLevel / experienceForLevel) * 100)
const dungeon = selectedDungeonOption ?? profile.dungeons[0]!
const activityPageStart = activityOptions.length === 0
? 0
: currentActivityPage * ACTIVITY_PAGE_SIZE + 1
const activityPageEnd = Math.min(activityOptions.length, (currentActivityPage + 1) * ACTIVITY_PAGE_SIZE)
const activity = selectedActivityOption ?? dungeon
const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0]
return (
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''} ${screen === 'settings' ? 'settings-shell' : ''}`}>
{screen !== 'hunter-profile' && (
<header className="topbar app-header">
<button
className="brand-button"
onClick={onBackToGameSelect}
type="button"
>
<strong>Games</strong>
</button>
<button
className="brand-button"
onClick={() => setScreen('menu')}
type="button"
>
<strong>Menu</strong>
</button>
<div className="character-summary">
<strong>{profile.character.name}</strong>
<small>Level {profile.character.level}</small>
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
<div className="header-xp" title={`${profile.character.experience} total experience`}>
<span style={barFillStyle(experiencePercent)} />
</div>
<button className="logout-button" onClick={signOut} type="button">
{gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`}
</button>
</div>
</header>
)}
{screen === 'menu' && (
<section className="menu-screen" data-game-nav-active="true">
<div className={`main-menu-grid ${canShowCloudSync ? 'has-cloud-sync' : ''}`}>
{canShowCloudSync && (
<div
className={`menu-card cloud-sync-card ${homeActiveIndex === 0 ? 'game-selected' : ''}`}
data-game-selected={homeActiveIndex === 0 ? 'true' : undefined}
onPointerDown={() => setHomeSelectedIndex(0)}
>
<span>{syncComparison ? '!' : cloudSync.dirty ? 'S' : 'C'}</span>
<div>
<strong>Sync With Server</strong>
<small>
{syncComparison
? cloudSyncRelationText(syncComparison)
: cloudSync.dirty
? 'Local progress waiting. Upload when you want to refresh the server copy.'
: 'Server copy matches this device.'}
</small>
{syncComparison && (
<div className="cloud-sync-times">
<small>Device: {formatSaveTimestamp(syncComparison.local.updatedAt)}</small>
<small>Server: {formatSaveTimestamp(syncComparison.server.updatedAt)}</small>
</div>
)}
{syncMessage && <small className="cloud-sync-message">{syncMessage}</small>}
</div>
<div className="cloud-sync-control-stack">
<button
className="text-button"
data-controller-nav="skip"
disabled={syncingCloud}
onClick={syncSaveNow}
type="button"
>
{syncingCloud ? 'Checking...' : syncComparison ? 'Check Again' : 'Sync With Server'}
</button>
{syncComparison && (
<div className="cloud-sync-actions">
<button
className={`text-button ${homeActiveIndex === 1 ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={homeActiveIndex === 1 ? 'true' : undefined}
disabled={syncingCloud}
onClick={() => keepCloudSave('local')}
onPointerDown={() => setHomeSelectedIndex(1)}
type="button"
>
Keep Device Save
</button>
<button
className={`text-button ${homeActiveIndex === 2 ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={homeActiveIndex === 2 ? 'true' : undefined}
disabled={syncingCloud}
onClick={() => keepCloudSave('server')}
onPointerDown={() => setHomeSelectedIndex(2)}
type="button"
>
Keep Server Save
</button>
</div>
)}
</div>
</div>
)}
{MENU_ITEMS.map((item, index) => {
const homeIndex = index + homeMenuOffset
return (
<button
className={`menu-card ${homeActiveIndex === homeIndex ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={homeActiveIndex === homeIndex ? 'true' : undefined}
key={item.screen}
onClick={() => openHomeMenuIndex(homeIndex)}
onPointerDown={() => setHomeSelectedIndex(homeIndex)}
type="button"
>
<span>{item.glyph}</span>
<div>
<strong>{item.label}</strong>
<small>{item.description}</small>
</div>
</button>
)
})}
</div>
</section>
)}
{screen === 'roguelike' && (
<section className="content-screen roguelike-screen" data-game-nav-active="true">
<ScreenHeading
backClassName={roguelikeEntrySelected('back') ? 'game-selected' : ''}
backControllerSkip
eyebrow="Endless Draft"
title="Roguelike"
onBack={() => setScreen('menu')}
onBackPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'back'))}
/>
<div className="roguelike-variant-row">
<button
className={`text-button ${roguelikeVariant === 'pve' ? 'active' : ''} ${roguelikeEntrySelected('variantPve') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => setRoguelikeVariant('pve')}
onPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'variantPve'))}
type="button"
>
PvE
</button>
<button
className={`text-button ${roguelikeVariant === 'pvp' ? 'active' : ''} ${roguelikeEntrySelected('variantPvp') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => setRoguelikeVariant('pvp')}
onPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'variantPvp'))}
type="button"
>
PvP
</button>
</div>
{roguelikeVariant === 'pve' && (
<>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Run Type</p>
<h2>PvE Roguelike</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeKind === 'dungeon' ? 'active' : ''} ${roguelikeEntrySelected('pveDungeon') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => setRoguelikeKind('dungeon')}
onPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'pveDungeon'))}
type="button"
>
Dungeon
</button>
<button
className={`text-button ${roguelikeKind === 'raid' ? 'active' : ''} ${roguelikeEntrySelected('pveRaid') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => setRoguelikeKind('raid')}
onPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'pveRaid'))}
type="button"
>
Raid
</button>
</div>
</div>
<div className="menu-card pvp-queue-panel">
<span>{roguelikeKind === 'raid' ? 'R' : 'D'}</span>
<div>
<strong>{roguelikeKind === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'}</strong>
<small>
{roguelikeKind === 'raid'
? 'Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.'
: 'Five-player party. Two random trash enemies and a boss with a lighter early ramp.'}
</small>
</div>
<button
className={`text-button ${roguelikeEntrySelected('pveStart') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={startPveRoguelike}
onPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'pveStart'))}
type="button"
>
Start Run
</button>
</div>
</>
)}
{roguelikeVariant === 'pvp' && (
<>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Match Type</p>
<h2>PvP Roguelike</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${pvpContentType === 'dungeon' ? 'active' : ''} ${roguelikeEntrySelected('pvpDungeon') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => setPvpContentType('dungeon')}
onPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'pvpDungeon'))}
type="button"
>
Dungeon
</button>
<button
className={`text-button ${pvpContentType === 'raid' ? 'active' : ''} ${roguelikeEntrySelected('pvpRaid') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => setPvpContentType('raid')}
onPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'pvpRaid'))}
type="button"
>
Raid
</button>
<button
className={`text-button ${pvpContentType === 'stadium' ? 'active' : ''} ${roguelikeEntrySelected('pvpStadium') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => setPvpContentType('stadium')}
onPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'pvpStadium'))}
type="button"
>
Stadium
</button>
</div>
</div>
<div className="menu-card pvp-queue-panel">
<span>{gameMode === 'offline' ? 'C' : 'Q'}</span>
<div>
<strong>{gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'}</strong>
<small>
{pvpContentType === 'stadium'
? 'Best-of-5 survival with dampening, equalized gear, and after-round buff buying.'
: gameMode === 'offline'
? 'Offline mode always places you against a random CPU 1-5.'
: 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'}
</small>
</div>
<button
className={`text-button ${roguelikeEntrySelected('pvpStart') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => setScreen('pvp')}
onPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'pvpStart'))}
type="button"
>
Start Match
</button>
</div>
{SHOW_LEADERBOARDS && (
<div className="leaderboard-section">
<div className="equipment-heading toggle-heading">
<div>
<p className="eyebrow">CPU Leaderboard</p>
<h2>{pvpContentType === 'stadium' ? 'Stadium' : pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
</div>
</div>
<div className="leaderboard-table">
<div className="leaderboard-header pvp-leaderboard-row">
<strong>Rank</strong>
<strong>Player</strong>
<strong>CPU</strong>
<strong>Clears</strong>
<strong>Result</strong>
</div>
{cpuLeaderboard.map((entry, index) => (
<div className="leaderboard-row pvp-leaderboard-row" key={`${entry.characterName}-${entry.completedAt}`}>
<strong>#{index + 1}</strong>
<span>{entry.characterName}</span>
<span>CPU {entry.cpuDifficulty}</span>
<span>{entry.encountersCleared}</span>
<span>{entry.result}</span>
</div>
))}
{cpuLeaderboard.length === 0 && (
<div className="leaderboard-empty">
No CPU runs recorded yet for this mode.
</div>
)}
</div>
</div>
)}
</>
)}
</section>
)}
{(screen === 'dungeons' || screen === 'raids') && (
<section className="content-screen dungeon-run-screen" data-game-nav-active="true">
<div className="dungeon-run-board">
<div className="dungeon-run-main">
<section className="run-setup-panel dungeon-choice-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Pick Run</p>
<h2>{screen === 'raids' ? 'Raid' : 'Dungeon'}</h2>
</div>
<div className="dungeon-heading-actions">
<button
className={`back-button inline-back-button ${dungeonEntrySelected('back') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => setScreen('menu')}
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'back'))}
type="button"
>
Back
</button>
{activityPageCount > 1 ? (
<div className="activity-pager" aria-label={`${screen === 'raids' ? 'Raid' : 'Dungeon'} pages`}>
<button
className={dungeonEntrySelected('pagePrev') ? 'game-selected' : ''}
data-controller-nav="skip"
disabled={currentActivityPage === 0}
onClick={() => setActivityPage((page) => Math.max(0, page - 1))}
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'pagePrev'))}
type="button"
>
Prev
</button>
<span>{activityPageStart}-{activityPageEnd} of {activityOptions.length}</span>
<button
className={dungeonEntrySelected('pageNext') ? 'game-selected' : ''}
data-controller-nav="skip"
disabled={currentActivityPage >= activityPageCount - 1}
onClick={() => setActivityPage((page) => Math.min(activityPageCount - 1, page + 1))}
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'pageNext'))}
type="button"
>
Next
</button>
</div>
) : (
<small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
)}
</div>
</div>
<div className="activity-card-grid dungeon-choice-grid">
{pagedActivityOptions.map((candidate, index) => {
const difficulty = candidate.difficulties.find(
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
) ?? candidate.difficulties[0]
const selected = candidate.id === activity.id
return (
<button
className={`activity-card ${selected ? 'selected' : ''} ${dungeonEntrySelected('activity', index) ? 'game-selected' : ''}`}
data-controller-nav="skip"
key={candidate.id}
onClick={() => {
if (screen === 'raids') setSelectedRaidId(candidate.id)
else setSelectedDungeonId(candidate.id)
setSelectedDifficultyId(difficulty.id)
}}
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'activity' && 'index' in entry && entry.index === index))}
type="button"
>
<span className={`dungeon-art ${candidate.contentType === 'raid' ? 'raid-art' : ''}`}>
{activityInitials(candidate.name)}
</span>
<strong>{candidate.name}</strong>
<small>{candidate.locationName}</small>
<i>
Level {candidate.recommendedLevel} | {candidate.partySize} Players
</i>
</button>
)
})}
</div>
</section>
</div>
<aside className="dungeon-setup-rail">
<section className="run-setup-panel part-setup-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Start</p>
<h2>Run</h2>
</div>
<small>
Pick a hunt or marathon.
</small>
</div>
<div className="part-picker">
<button
className={`primary-button selected-part ${dungeonEntrySelected('start') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => {
setSelectedMarathonMode(false)
setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat')
}}
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'start'))}
type="button"
>
Start Hunt
</button>
<button
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''} ${dungeonEntrySelected('marathon') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => {
setSelectedMarathonMode(true)
setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat')
}}
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'marathon'))}
type="button"
>
Marathon
</button>
</div>
</section>
<section className="run-setup-panel tier-setup-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Item Level</p>
<h2>Tier</h2>
</div>
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers are available at every level.</small>
</div>
<div className="tier-grid">
{tierOptions.map((difficulty, index) => {
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
return (
<button
className={`${selected ? 'selected' : ''} ${dungeonEntrySelected('tier', index) ? 'game-selected' : ''}`}
data-controller-nav="skip"
key={difficulty.id}
onClick={() => {
setActivityPage(0)
const nextActivity = activity.difficulties.some(
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
)
? activity
: activityOptions.find((option) =>
option.difficulties.some((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel),
)
if (nextActivity) {
if (screen === 'raids') setSelectedRaidId(nextActivity.id)
else setSelectedDungeonId(nextActivity.id)
const nextDifficulty = nextActivity.difficulties.find(
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
)
if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id)
}
}}
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'tier' && 'index' in entry && entry.index === index))}
type="button"
>
<strong>iLvl {difficulty.droppedItemLevel}</strong>
<span>{difficulty.name}</span>
</button>
)
})}
</div>
</section>
<div className="loot-preview-section">
<div className="equipment-heading toggle-heading">
<div>
<p className="eyebrow">Encounter Rewards</p>
<h2>{selectedDifficulty.name} Loot Tables</h2>
</div>
<button
className={`text-button ${dungeonEntrySelected('loot') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => setShowLoot((current) => !current)}
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'loot'))}
type="button"
>
{showLoot ? 'Hide Loot' : 'View Loot'}
</button>
</div>
{showLoot && (
<>
<div className="loot-toolbar">
<label className={dungeonEntrySelected('lootSort') ? 'game-selected' : ''}>
<span>Sort</span>
<select
data-controller-nav="skip"
onChange={(event) => setLootSort(event.target.value as 'sequence' | 'boss')}
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'lootSort'))}
value={lootSort}
>
<option value="sequence">Encounter order</option>
<option value="boss">Boss name</option>
</select>
</label>
</div>
<p className="section-note">
Bosses drop 1-3 boss coins from one loot roll
{activity.completionItemLevel
? ` - full clear guarantees iLvl ${activity.completionItemLevel}`
: ''}
</p>
<div className="loot-preview-grid">
{lootPreviewEncounters.map((encounter) => {
const loot = lootPreviewByEncounterId.get(encounter.id) ?? []
return (
<article key={encounter.id}>
<div className="loot-encounter-title">
{encounter.isBoss ? (
<img className="loot-boss-icon" src={encounter.imageUrl} alt={`${encounter.enemyName} icon`} />
) : (
<span>{encounter.sequence}</span>
)}
<div>
<strong>{encounter.enemyName}</strong>
<small>{loot.length > 0 ? '1 loot roll, 1-3 coins' : 'No coin table'}</small>
</div>
</div>
<div className="loot-items">
{loot.map((item) => (
<div className={`rarity-${item.rarity}`} key={item.id}>
<span>{item.glyph}</span>
<div>
<strong>{item.name}</strong>
<small>{item.slot} - iLvl {item.itemLevel}</small>
</div>
<i>{item.dropWeight}% weight</i>
</div>
))}
</div>
</article>
)
})}
</div>
</>
)}
</div>
{SHOW_LEADERBOARDS && (
<div className="leaderboard-section">
<div className="equipment-heading toggle-heading">
<div>
<p className="eyebrow">Efficiency Rankings</p>
<h2>{selectedDifficulty.name} Leaderboard</h2>
</div>
<button
className="text-button"
onClick={() => setShowLeaderboard((current) => !current)}
type="button"
>
{showLeaderboard ? 'Hide Leaderboard' : 'View Leaderboard'}
</button>
</div>
{showLeaderboard && (
<>
<p className="section-note">
{gameMode === 'offline'
? 'Offline runs are not submitted'
: canShowCloudSync
? 'Manual save sync updates your cloud profile.'
: 'Lowest resource spent ranks first'}
</p>
<div className="leaderboard-tabs">
{([
{ key: 'part_1', label: 'Run' },
{ key: 'part_2', label: 'Legacy 2' },
{ key: 'part_3', label: 'Legacy 3' },
{ key: 'full_run', label: 'Legacy Full' },
] as const).map((tab) => (
<button
key={tab.key}
className={`leaderboard-tab ${leaderboardCategory === tab.key ? 'active' : ''}`}
onClick={() => setLeaderboardCategory(tab.key)}
type="button"
>
{tab.label}
</button>
))}
</div>
<div className="leaderboard-table">
<div className="leaderboard-header">
<span>Rank</span>
<span>Healer</span>
<span>Class</span>
<span>Level</span>
<span>Item Level</span>
<span>Resource</span>
<span>Time</span>
</div>
{leaderboardEntries
.map((entry) => (
<div className="leaderboard-row" key={`${entry.rank}-${entry.completedAt}`}>
<strong>#{entry.rank}</strong>
<span>{entry.characterName}</span>
<span>{entry.className}</span>
<span>{entry.characterLevel}</span>
<span>{entry.averageItemLevel.toFixed(1)}</span>
<strong>{entry.resourceSpent}</strong>
<span>{entry.durationSeconds}s</span>
</div>
))}
{leaderboardEntries.length === 0 && (
<div className="leaderboard-empty">
{gameMode === 'offline'
? 'Connect with an online character to compete in rankings.'
: canShowCloudSync
? 'No leaderboard entries yet.'
: 'Complete this difficulty to claim the first ranking.'}
</div>
)}
</div>
</>
)}
</div>
)}
</aside>
</div>
</section>
)}
<Suspense fallback={<ScreenLoading />}>
{screen === 'customize' && (
<CustomizeScreen
profile={profile}
onBack={() => setScreen('menu')}
onSaved={setProfile}
/>
)}
{screen === 'talents' && (
<TalentScreen
profile={profile}
onBack={() => setScreen('menu')}
onUpdated={setProfile}
/>
)}
{screen === 'equipment' && (
<EquipmentScreen
profile={profile}
onBack={() => setScreen('menu')}
onUpdated={setProfile}
/>
)}
{screen === 'hunter-profile' && (
<HunterProfileScreen
profile={profile}
onBack={() => setScreen('menu')}
/>
)}
{screen === 'settings' && (
<SettingsScreen onBack={() => setScreen('menu')} />
)}
</Suspense>
</main>
)
}
function ScreenHeading({
backClassName = '',
backControllerSkip = false,
eyebrow,
title,
onBack,
onBackPointerDown,
}: {
backClassName?: string
backControllerSkip?: boolean
eyebrow: string
title: string
onBack: () => void
onBackPointerDown?: () => void
}) {
return (
<div className="screen-heading">
<div>
<p className="eyebrow">{eyebrow}</p>
<h1>{title}</h1>
</div>
<button
className={`back-button ${backClassName}`}
data-controller-nav={backControllerSkip ? 'skip' : undefined}
onClick={onBack}
onPointerDown={onBackPointerDown}
type="button"
>
Back
</button>
</div>
)
}
export default IWantToHeal1App