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 (

Opening Chronicle

Loading...

) } 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('menu') const [account, setAccount] = useState(initialSession.account) const [profile, setProfile] = useState(initialSession.profile) const [authChecked, setAuthChecked] = useState(false) const [gameMode, setGameMode] = useState(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('pve') const [pvpContentType, setPvpContentType] = useState('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(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).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([]) 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(() => { 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 (

Database Error

Character Unavailable

{error}

) } if (!authChecked) { return (

Opening Chronicle

Loading...

) } if (!account || !profile) { return (

Opening Chronicle

Returning to Sign In...

{serverMessage &&

{serverMessage}

}
) } if (screen === 'combat') { const dungeon = combatDungeonOption ?? profile.dungeons[0] const difficulty = combatDifficultyOption ?? dungeon.difficulties[0] return ( }> 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} /> ) } if (screen === 'pvp') { if (pvpContentType === 'stadium') { return ( }> { setRoguelikeVariant('pvp') setScreen('roguelike') }} onProfileUpdated={setProfile} profile={profile} /> ) } return ( }> { setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType)) setRoguelikeVariant('pvp') setScreen('roguelike') }} onMainMenu={() => { setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType)) setScreen('menu') }} onProfileUpdated={setProfile} profile={profile} /> ) } 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 (
{screen !== 'hunter-profile' && (
{profile.character.name} Level {profile.character.level} Item Level {profile.gearStats.averageItemLevel.toFixed(1)}
)} {screen === 'menu' && (
{canShowCloudSync && (
setHomeSelectedIndex(0)} > {syncComparison ? '!' : cloudSync.dirty ? 'S' : 'C'}
Sync With Server {syncComparison ? cloudSyncRelationText(syncComparison) : cloudSync.dirty ? 'Local progress waiting. Upload when you want to refresh the server copy.' : 'Server copy matches this device.'} {syncComparison && (
Device: {formatSaveTimestamp(syncComparison.local.updatedAt)} Server: {formatSaveTimestamp(syncComparison.server.updatedAt)}
)} {syncMessage && {syncMessage}}
{syncComparison && (
)}
)} {MENU_ITEMS.map((item, index) => { const homeIndex = index + homeMenuOffset return ( ) })}
)} {screen === 'roguelike' && (
setScreen('menu')} onBackPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'back'))} />
{roguelikeVariant === 'pve' && ( <>

Run Type

PvE Roguelike

{roguelikeKind === 'raid' ? 'R' : 'D'}
{roguelikeKind === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'} {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.'}
)} {roguelikeVariant === 'pvp' && ( <>

Match Type

PvP Roguelike

{gameMode === 'offline' ? 'C' : 'Q'}
{gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'} {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.'}
{SHOW_LEADERBOARDS && (

CPU Leaderboard

{pvpContentType === 'stadium' ? 'Stadium' : pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}

Rank Player CPU Clears Result
{cpuLeaderboard.map((entry, index) => (
#{index + 1} {entry.characterName} CPU {entry.cpuDifficulty} {entry.encountersCleared} {entry.result}
))} {cpuLeaderboard.length === 0 && (
No CPU runs recorded yet for this mode.
)}
)} )}
)} {(screen === 'dungeons' || screen === 'raids') && (

Pick Run

{screen === 'raids' ? 'Raid' : 'Dungeon'}

{activityPageCount > 1 ? (
{activityPageStart}-{activityPageEnd} of {activityOptions.length}
) : ( {selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components. )}
{pagedActivityOptions.map((candidate, index) => { const difficulty = candidate.difficulties.find( (option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel, ) ?? candidate.difficulties[0] const selected = candidate.id === activity.id return ( ) })}
)} }> {screen === 'customize' && ( setScreen('menu')} onSaved={setProfile} /> )} {screen === 'talents' && ( setScreen('menu')} onUpdated={setProfile} /> )} {screen === 'equipment' && ( setScreen('menu')} onUpdated={setProfile} /> )} {screen === 'hunter-profile' && ( setScreen('menu')} /> )} {screen === 'settings' && ( setScreen('menu')} /> )}
) } function ScreenHeading({ backClassName = '', backControllerSkip = false, eyebrow, title, onBack, onBackPointerDown, }: { backClassName?: string backControllerSkip?: boolean eyebrow: string title: string onBack: () => void onBackPointerDown?: () => void }) { return (

{eyebrow}

{title}

) } export default IWantToHeal1App