import { useEffect, useState } from 'react' import './App.css' import { CombatScreen } from './components/CombatScreen' import { AuthScreen } from './components/AuthScreen' import { CustomizeScreen } from './components/CustomizeScreen' import { EquipmentScreen } from './components/EquipmentScreen' import { PvPRoguelikeScreen } from './components/PvpRoguelikeScreen' import { TalentScreen } from './components/TalentScreen' import { SettingsScreen } from './components/SettingsScreen' import { loadCpuPvpLeaderboard, type CpuPvpLeaderboardEntry, type PvpContentType, } from './pvpRoguelike' import { loadAuthSession, logoutAccount, type Account, type AuthSession, type CharacterProfile, } from './profile' import { getCloudSyncStatus, getGameMode, syncCloudSave, type GameMode, } from './gameRepository' import { focusFirstControl } from './input.tsx' type Screen = | 'menu' | 'dungeons' | 'combat' | 'raids' | 'roguelike' | 'pvp' | 'customize' | 'equipment' | '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: '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 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('') } type RoguelikeUpgradeTiming = 'boss' | 'encounter' type RoguelikeVariant = 'pve' | 'pvp' type RoguelikeAbilityLabelMode = 'ability' | 'slot' function App() { const [screen, setScreen] = useState('menu') const [account, setAccount] = useState(null) const [profile, setProfile] = useState(null) 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 [roguelikeUpgradeTiming, setRoguelikeUpgradeTiming] = useState('encounter') const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState('ability') 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('') 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(() => { 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(() => { setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType)) }, [pvpContentType, screen, roguelikeVariant]) function acceptSession(session: AuthSession) { setAccount(session.account) setProfile(session.profile) setGameMode(getGameMode()) setScreen('menu') setError('') setServerMessage('') window.requestAnimationFrame(() => { focusFirstControl() }) } async function signOut() { try { await logoutAccount() setAccount(null) setProfile(null) setGameMode(getGameMode()) setScreen('menu') setSyncMessage('') } catch (reason) { setError(reason instanceof Error ? reason.message : 'Unable to sign out.') } } async function syncSaveNow() { setSyncingCloud(true) setSyncMessage('') try { const updated = await syncCloudSave() setProfile(updated) setGameMode(getGameMode()) setSyncMessage('Cloud save updated.') } catch (reason) { setSyncMessage(reason instanceof Error ? reason.message : 'Unable to sync cloud save.') } finally { setSyncingCloud(false) } } if (error) { return (

Database Error

Character Unavailable

{error}

) } if (!authChecked) { return (

Opening Chronicle

Loading...

) } if (!account || !profile) { return ( ) } if (screen === 'combat') { const dungeon = combatContentId < 0 ? profile.dungeons.find((candidate) => candidate.contentType === roguelikeKind) ?? profile.dungeons[0] : profile.dungeons.find((candidate) => candidate.id === combatContentId) ?? profile.dungeons[0] const difficulty = dungeon.difficulties.find( (candidate) => candidate.id === selectedDifficultyId, ) ?? dungeon.difficulties[0] const roguelikePool = profile.dungeons .filter((candidate) => candidate.contentType === roguelikeKind) .flatMap((candidate) => candidate.encounters) return ( 0} profile={profile} roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined} roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined} roguelikeAbilityLabelMode={combatContentId < 0 ? roguelikeAbilityLabelMode : undefined} roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined} startPart={1} onExit={() => { setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons') }} onProfileUpdated={setProfile} /> ) } if (screen === 'pvp') { const pvpPool = profile.dungeons .filter((candidate) => candidate.contentType === pvpContentType) .flatMap((candidate) => candidate.encounters) return ( { setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType)) setRoguelikeVariant('pvp') setScreen('roguelike') }} 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 dungeonOptions = profile.dungeons.filter((candidate) => candidate.contentType === 'dungeon') const raidOptions = profile.dungeons.filter((candidate) => candidate.contentType === 'raid') const dungeon = dungeonOptions.find((candidate) => candidate.id === selectedDungeonId) ?? dungeonOptions[0]! const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId) ?? raidOptions[0] const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions const 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') } const tierOptions = activityOptions .flatMap((option) => option.difficulties) .filter((difficulty, index, all) => ( all.findIndex((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel) === index )) .sort((a, b) => a.droppedItemLevel - b.droppedItemLevel) const savedDifficulty = profile.dungeons .flatMap((option) => option.difficulties) .find((candidate) => candidate.id === selectedDifficultyId) const selectedTier = tierOptions.find((candidate) => ( candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel && profile.character.level >= candidate.unlockLevel )) ?? tierOptions.slice().reverse().find((candidate) => profile.character.level >= candidate.unlockLevel) ?? tierOptions[0] 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 = activityOptions.slice( currentActivityPage * ACTIVITY_PAGE_SIZE, currentActivityPage * ACTIVITY_PAGE_SIZE + ACTIVITY_PAGE_SIZE, ) const activityPageStart = activityOptions.length === 0 ? 0 : currentActivityPage * ACTIVITY_PAGE_SIZE + 1 const activityPageEnd = Math.min(activityOptions.length, (currentActivityPage + 1) * ACTIVITY_PAGE_SIZE) const selectedActivityId = screen === 'raids' && raid ? raid.id : dungeon.id const activity = activityOptions.find((candidate) => candidate.id === selectedActivityId) ?? activityOptions[0] ?? (screen === 'raids' && raid ? raid : dungeon) const selectedDifficulty = activity.difficulties.find( (candidate) => candidate.droppedItemLevel === selectedTierItemLevel, ) ?? activity.difficulties[0] const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel const cloudSync = getCloudSyncStatus() const canShowCloudSync = account.id !== -1 && cloudSync.available const lootPreviewEncounters = [...activity.encounters] .filter((encounter) => encounter.isBoss) .sort((a, b) => lootSort === 'boss' ? a.enemyName.localeCompare(b.enemyName) || a.sequence - b.sequence : a.sequence - b.sequence) return (
{profile.character.name} Level {profile.character.level} Item Level {profile.gearStats.averageItemLevel.toFixed(1)}
{screen === 'menu' && (
{canShowCloudSync && (
{cloudSync.dirty ? 'S' : 'C'}
Cloud Save {cloudSync.dirty ? 'Local progress waiting. Upload when you want to refresh the server copy.' : 'Server copy matches this device.'} {syncMessage && {syncMessage}}
)} {MENU_ITEMS.map((item) => ( ))}
)} {screen === 'roguelike' && (
setScreen('menu')} />
{roguelikeVariant === 'pve' && ( <>

Run Type

PvE Roguelike

Upgrade Timing

Buff Drafts

Upgrade Labels

Display Mode

{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'} {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 === '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') && (
{activityInitials(activity.name)}

Selected Run

{activity.name}

{activity.description}

Level {activity.recommendedLevel} {activity.partySize} Players {selectedDifficulty.name} iLvl {selectedDifficulty.droppedItemLevel} {Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP

Pick Run

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

{activityPageCount > 1 ? (
{activityPageStart}-{activityPageEnd} of {activityOptions.length}
) : ( {selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components. )}
{pagedActivityOptions.map((candidate) => { const difficulty = candidate.difficulties.find( (option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel, ) ?? candidate.difficulties[0] const locked = profile.character.level < difficulty.unlockLevel 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 === 'settings' && ( setScreen('menu')} /> )}
) } function ScreenHeading({ eyebrow, title, onBack, }: { eyebrow: string title: string onBack: () => void }) { return (

{eyebrow}

{title}

) } export default App