import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react' import { ControllerStylePreview } from '../../../components/ControllerIcons' import { useGameAction, useInput, type ControllerIconStyle, type InputAction, } from '../../../input' import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../../../dualScreen' import { getGameMode } from '../../../gameRepository' import { createDefaultIwt2Save, loadIwt2OnlineSave, setIwt2InfusionAbility, updateIwt2CharacterSettings, upgradeIwt2GearSlot, canAffordIwt2Costs, inventoryQuantity, writeIwt2OnlineSave, type Iwt2Save, } from '../save/iwt2Repository' import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses' import { IWT2_CLASS_METADATA, IWT2_PARTY_ORDER, type Iwt2PlayerClassId } from '../content/classes' import { findIwt2Difficulty, isIwt2DifficultyUnlocked, IWT2_DUNGEON_DIFFICULTIES, IWT2_RAID_DIFFICULTIES, type Iwt2Difficulty, } from '../content/difficulties' import { iwt2BossCoinRewardFor, iwt2BossCoinRewardsFor, iwt2BossPetRewardFor, type Iwt2BossCoinReward, } from '../content/bossRewards' import { abilitiesForHealer, IWT2_HEALER_METADATA, IWT2_HEALER_ORDER, } from '../content/healerAbilities' import { type Iwt2RoguelikeChoice, type Iwt2RoguelikeContentType, type Iwt2RoguelikeOpponentDebuffId, type Iwt2RoguelikeSelfBuffId, type Iwt2RoguelikeVariant, } from '../content/roguelike' import { IWT2_GEAR_SLOT_LABELS, IWT2_GEAR_SLOT_RECIPES, IWT2_GEAR_SLOTS, IWT2_GEAR_STAT_LABELS, iwt2GearUpgradeCosts, iwt2InfusionCosts, isIwt2InfusionUnlocked, type Iwt2GearStatId, type Iwt2GearSlotId, } from '../content/gear' import { IWT2_INFUSION_ABILITIES, iwt2InfusionAbilitiesForClass, type Iwt2InfusionAbilityId, } from '../content/infusionAbilities' type Iwt2NavAction = { key: string label: string detail?: string value?: string disabled?: boolean onConfirm: () => void } type Iwt2Mode = 'Raids' type Iwt2ScreenShellProps = { title: string eyebrow?: string onBack: () => void children: ReactNode } type Iwt2ActionListProps = { actions: Iwt2NavAction[] } type Iwt2DifficultyOption = Iwt2Difficulty & { selected: boolean locked: boolean } type Iwt2InfoRow = { key: string label: string detail: string value: string } type Iwt2NavPosition = { column: number row: number } type Iwt2UpgradeNavEntry = | { kind: 'upgradeBuff', index: number, row: number, column: number } | { kind: 'upgradeDebuff', index: number, row: number, column: number } | { kind: 'upgradeContinue', row: number, column: number, disabled?: boolean } | { kind: 'upgradeLeave', row: number, column: number } type Iwt2HunterProfileTab = 'stats' | 'collection' type Iwt2HunterProfileNavEntry = | { kind: 'back', key: string, row: number, column: number } | { kind: 'tab', key: string, row: number, column: number, tab: Iwt2HunterProfileTab } | { kind: 'stat', key: string, row: number, column: number, index: number } | { kind: 'boss', key: string, row: number, column: number, bossId: Iwt2BossId } | { kind: 'item', key: string, row: number, column: number, itemKey: string } type Iwt2HunterProfileBossEntry = { bossId: Iwt2BossId encounter: (typeof IWT2_BOSS_METADATA)[Iwt2BossId] kills: number coins: Array petId: string petName: string pets: number drops: number } type Iwt2HunterCollectionItem = { key: string glyph: string name: string chance: string quantity: number rarity: 'stat' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary' source: string } type Iwt2GearNavEntry = | { kind: 'back', key: string, row: number, column: number } | { kind: 'class', key: string, row: number, column: number, classId: Iwt2PlayerClassId } | { kind: 'slot', key: string, row: number, column: number, slotId: Iwt2GearSlotId } | { kind: 'upgrade', key: string, row: number, column: number, disabled: boolean } | { kind: 'infusion', key: string, row: number, column: number, abilityId: Iwt2InfusionAbilityId, disabled: boolean } const IWT2_HUNTER_PROFILE_DROP_COLUMNS = 6 const IWT2_NAME_MAX_LENGTH = 18 const IWT2_NAME_EDITOR_COLUMNS = 6 const IWT2_NAME_CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'.split('') function formatIwt2SaveTimestamp(updatedAt: number | null) { if (!updatedAt) return 'No timestamp' return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short', }).format(new Date(updatedAt)) } function formatIwt2SaveSummary(save: Iwt2Save | null) { if (!save) return 'No save found' return `Level ${save.character.level} - ${formatIwt2SaveTimestamp(save.updatedAt)}` } function withBackAction(actions: Iwt2NavAction[], onBack: () => void): Iwt2NavAction[] { return [ { key: 'back', label: 'Back', onConfirm: onBack, }, ...actions, ] } function useIwt2ActionList(actions: Iwt2NavAction[]) { const [selectedIndex, setSelectedIndex] = useState(0) const activeIndex = clampToEnabled(actions, selectedIndex) useGameAction((action, device) => { if (device !== 'controller' || actions.length === 0) return if (action === 'navigateUp' || action === 'navigateLeft') { setSelectedIndex((current) => nextEnabledIndex(actions, current, -1)) } else if (action === 'navigateDown' || action === 'navigateRight') { setSelectedIndex((current) => nextEnabledIndex(actions, current, 1)) } else if (action === 'confirm') { if (!actions[activeIndex]?.disabled) actions[activeIndex]?.onConfirm() } }) return { selectedIndex: activeIndex, setSelectedIndex } } const MODE_ACTIONS: Record> = { Raids: [ { bossId: 'bulldrome', label: 'Bristlemaw Raid Assignment', detail: 'Shared arena entry with raid-role notes: tank opens aggro, party spreads for charge lanes, then collapses after ground slam.', value: 'Start', }, { bossId: 'yian-kut-ku', label: 'Emberquill Raid Assignment', detail: 'Shared arena entry with raid-role notes: assign ranged pressure, clear bird waves, and keep fire puddles away from party frames.', value: 'Start', }, { bossId: 'great-jaggi', label: 'Packfang Alpha Raid Assignment', detail: 'Shared arena entry with raid-role notes: hold center, dodge pack lanes, and stabilize the paladin during howl windows.', value: 'Start', }, { bossId: 'khezu', label: 'Palevolt Maw Raid Assignment', detail: 'Shared arena entry with raid-role notes: watch lightning markers, rotate through safe bands, and recover stunned allies fast.', value: 'Start', }, { bossId: 'rathian', label: 'Verdant Wyrm Raid Assignment', detail: 'Shared arena entry with raid-role notes: dodge tail arcs, keep poison puddles out of the party path, and recover after sweeps.', value: 'Start', }, { bossId: 'barroth', label: 'Mirehorn Raid Assignment', detail: 'Shared arena entry with raid-role notes: break mud armor, sidestep mud cones, and reposition through slow zones.', value: 'Start', }, { bossId: 'tobi-kadachi', label: 'Stormtail Raid Assignment', detail: 'Shared arena entry with raid-role notes: track static charge, bait line pounces, and spread before chain shock.', value: 'Start', }, { bossId: 'rimebastion', label: 'Rimebastion Raid Assignment', detail: 'Shared arena entry with raid-role notes: read ice wall lanes, dodge shard cones, and keep the tank away from corners.', value: 'Start', }, { bossId: 'ember-mantis-duelist', label: 'Ember Mantis Duelist Raid Assignment', detail: 'Shared arena entry with raid-role notes: track sidesteps, cut through slash lanes, and spread before crossed blades.', value: 'Start', }, { bossId: 'cinderback-ricochet', label: 'Cinderback Ricochet Raid Assignment', detail: 'Shared arena entry with raid-role notes: bait ricochet lanes, avoid lava trails, and recover after armor slam.', value: 'Start', }, { bossId: 'obsidian-ram-golem', label: 'Obsidian Ram Golem Raid Assignment', detail: 'Shared arena entry with raid-role notes: bait charge lines, avoid quake fractures, and break armor safely.', value: 'Start', }, { bossId: 'stormcoil-wyrm', label: 'Stormcoil Wyrm Raid Assignment', detail: 'Shared arena entry with raid-role notes: spread for chain lightning and rotate through charged ring gaps.', value: 'Start', }, { bossId: 'venom-orchid-hydra', label: 'Venom Orchid Hydra Raid Assignment', detail: 'Shared arena entry with raid-role notes: dodge staggered poison cones and keep root lanes off the party.', value: 'Start', }, { bossId: 'sandglass-scorpion', label: 'Sandglass Scorpion Raid Assignment', detail: 'Shared arena entry with raid-role notes: track burrows, wait out stinger eruptions, and avoid sinking sand.', value: 'Start', }, { bossId: 'crystal-bat-matriarch', label: 'Crystal Bat Matriarch Raid Assignment', detail: 'Shared arena entry with raid-role notes: space for sonic rings, watch mirror lanes, and dodge swoops.', value: 'Start', }, { bossId: 'hollowcrown-revenant', label: 'Hollowcrown Revenant Raid Assignment', detail: 'Shared arena entry with raid-role notes: avoid cursed hoof zones and sidestep ghost-antler charges.', value: 'Start', }, ], } const CONTROLLER_ICON_OPTIONS: ControllerIconStyle[] = ['playstation', 'xbox', 'nintendo'] const IWT2_DUNGEON_BOSS_PAGE_SIZE = 5 const CONTROLLER_ICON_LABELS: Record = { xbox: 'Xbox', playstation: 'PlayStation', nintendo: 'Nintendo', } const IWT2_DUNGEON_ACTIONS: Array & { bossId: Iwt2BossId }> = [ { bossId: 'bulldrome', key: 'bulldrome', label: 'Bristlemaw Arena', detail: 'Level 1 field boss. Charge lanes, tank pressure, and periodic ground slam.', value: 'Ready', }, { bossId: 'yian-kut-ku', key: 'yian-kut-ku', label: 'Emberquill Arena', detail: 'Bouncing fireballs, capped fire puddles, and bird waves at 90% and 40% health.', value: 'Ready', }, { bossId: 'great-jaggi', key: 'great-jaggi', label: 'Packfang Alpha Arena', detail: 'Pack howl calls slanted add lanes that punish standing still or grouping badly.', value: 'Ready', }, { bossId: 'khezu', key: 'khezu', label: 'Palevolt Maw Arena', detail: 'Thunder rings create safe bands while lightning circles lock onto weak allies.', value: 'Ready', }, { bossId: 'rathian', key: 'rathian', label: 'Verdant Wyrm Arena', detail: 'Poison tail arcs and targeted poison puddles punish stacked positioning.', value: 'Ready', }, { bossId: 'barroth', key: 'barroth', label: 'Mirehorn Arena', detail: 'Breakable mud armor softens incoming damage while mud spray creates slow zones.', value: 'Ready', }, { bossId: 'tobi-kadachi', key: 'tobi-kadachi', label: 'Stormtail Arena', detail: 'Static builds into line pounces while chain shock punishes grouped allies.', value: 'Ready', }, { bossId: 'rimebastion', key: 'rimebastion', label: 'Rimebastion Arena', detail: 'Ice walls split the room, then shard lanes and cones punish trapped routes.', value: 'Ready', }, { bossId: 'ember-mantis-duelist', key: 'ember-mantis-duelist', label: 'Ember Mantis Duelist Arena', detail: 'Fast sidesteps lead into line slashes and crossed lane attacks.', value: 'Ready', }, { bossId: 'cinderback-ricochet', key: 'cinderback-ricochet', label: 'Cinderback Ricochet Arena', detail: 'Wall-bounce rolls leave lava trails before a heavy armor slam.', value: 'Ready', }, { bossId: 'obsidian-ram-golem', key: 'obsidian-ram-golem', label: 'Obsidian Ram Golem Arena', detail: 'Volcanic charge lines crack armor, then quake fractures split the floor.', value: 'Ready', }, { bossId: 'stormcoil-wyrm', key: 'stormcoil-wyrm', label: 'Stormcoil Wyrm Arena', detail: 'Charged rings and chain lightning punish clustered party movement.', value: 'Ready', }, { bossId: 'venom-orchid-hydra', key: 'venom-orchid-hydra', label: 'Venom Orchid Hydra Arena', detail: 'Three heads sweep poison cones while roots and pods pressure escape paths.', value: 'Ready', }, { bossId: 'sandglass-scorpion', key: 'sandglass-scorpion', label: 'Sandglass Scorpion Arena', detail: 'Burrows, delayed stinger eruptions, and shifting sand zones control space.', value: 'Ready', }, { bossId: 'crystal-bat-matriarch', key: 'crystal-bat-matriarch', label: 'Crystal Bat Matriarch Arena', detail: 'Sonic rings, mirror shard lanes, and swoop dives punish bad spacing.', value: 'Ready', }, { bossId: 'hollowcrown-revenant', key: 'hollowcrown-revenant', label: 'Hollowcrown Revenant Arena', detail: 'Cursed hoof zones and ghost-antler dash trails cut across the arena.', value: 'Ready', }, ] export function Iwt2ScreenShell({ children, eyebrow = 'I Want To Heal 2', onBack, title, }: Iwt2ScreenShellProps) { useGameAction((action, device) => { if (device === 'controller' && action === 'back') onBack() }) return (

{eyebrow}

{title}

{children}
) } export function Iwt2ActionList({ actions }: Iwt2ActionListProps) { const { selectedIndex, setSelectedIndex } = useIwt2ActionList(actions) const rowRefs = useRef>([]) useEffect(() => { rowRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest', inline: 'nearest', }) }, [selectedIndex]) return (
{actions.map((action, index) => ( ))}
) } function Iwt2InfoList({ rows }: { rows: Iwt2InfoRow[] }) { return (
{rows.map((row) => (
{row.label} {row.detail} {row.value}
))}
) } function Iwt2DifficultyStrip({ difficulties, onSelect, selected, }: { difficulties: Iwt2DifficultyOption[] onSelect: (difficulty: Iwt2Difficulty) => void selected: (key: string) => boolean }) { return (
{difficulties.map((difficulty) => ( ))}
) } export function Iwt2DungeonsScreen({ difficultySlug, onBack, onDifficultyChange, onOpenBoss, onPreviewBoss, save, }: { difficultySlug: string onBack: () => void onDifficultyChange: (difficultySlug: string) => void onOpenBoss: (bossId: Iwt2BossId, difficulty: Iwt2Difficulty) => void onPreviewBoss: (bossId: Iwt2BossId) => void save: Iwt2Save }) { const [page, setPage] = useState(0) const [selectedKey, setSelectedKey] = useState('bulldrome') const selectedDifficulty = findIwt2Difficulty(IWT2_DUNGEON_DIFFICULTIES, difficultySlug) const difficultyLocked = !isIwt2DifficultyUnlocked(selectedDifficulty, save.character.level) const pageCount = Math.max(1, Math.ceil(IWT2_DUNGEON_ACTIONS.length / IWT2_DUNGEON_BOSS_PAGE_SIZE)) const currentPage = Math.min(page, pageCount - 1) const pageStart = currentPage * IWT2_DUNGEON_BOSS_PAGE_SIZE const pageEnd = Math.min(IWT2_DUNGEON_ACTIONS.length, pageStart + IWT2_DUNGEON_BOSS_PAGE_SIZE) const visibleBosses = useMemo( () => IWT2_DUNGEON_ACTIONS.slice(pageStart, pageEnd), [pageEnd, pageStart], ) const difficultyOptions = useMemo(() => ( IWT2_DUNGEON_DIFFICULTIES.map((difficulty) => ({ ...difficulty, locked: !isIwt2DifficultyUnlocked(difficulty, save.character.level), selected: difficulty.slug === selectedDifficulty.slug, })) ), [save.character.level, selectedDifficulty.slug]) const navEntries = useMemo(() => [ { key: 'dungeons-page-prev', label: 'Previous Page', disabled: currentPage === 0, onConfirm: () => setPage((activePage) => Math.max(0, activePage - 1)), }, { key: 'dungeons-page-next', label: 'Next Page', disabled: currentPage >= pageCount - 1, onConfirm: () => setPage((activePage) => Math.min(pageCount - 1, activePage + 1)), }, { key: 'back', label: 'Back', onConfirm: onBack, }, ...difficultyOptions.map((difficulty) => ({ key: `difficulty-${difficulty.slug}`, label: difficulty.name, detail: difficulty.description, value: difficulty.locked ? `Lv ${difficulty.unlockLevel}` : `${difficulty.healthMultiplier.toFixed(2)}x HP`, disabled: difficulty.locked, onConfirm: () => onDifficultyChange(difficulty.slug), })), ...visibleBosses.map((entry) => { const reward = iwt2BossCoinRewardFor(entry.bossId, selectedDifficulty.slug) return { ...entry, detail: `${entry.detail} ${selectedDifficulty.name}: ${selectedDifficulty.healthMultiplier.toFixed(2)}x HP, ${selectedDifficulty.damageMultiplier.toFixed(2)}x damage. Reward: ${reward.name}.`, disabled: difficultyLocked, value: difficultyLocked ? `Lv ${selectedDifficulty.unlockLevel}` : selectedDifficulty.name, onConfirm: () => onOpenBoss(entry.bossId, selectedDifficulty), } }), ], [ currentPage, difficultyLocked, difficultyOptions, onBack, onDifficultyChange, onOpenBoss, pageCount, selectedDifficulty, visibleBosses, ]) const activeEntry = navEntries.find((entry) => entry.key === selectedKey && !entry.disabled) ?? navEntries.find((entry) => entry.key === visibleBosses[0]?.key) ?? navEntries.find((entry) => !entry.disabled) function selected(key: string) { return activeEntry?.key === key } function openDungeonNavEntry(entry: Iwt2NavAction | undefined) { if (!entry || entry.disabled) return entry.onConfirm() } function moveDungeonSelection(action: InputAction) { if (!action.startsWith('navigate')) return const activeIndex = Math.max(0, navEntries.findIndex((entry) => entry.key === activeEntry?.key)) const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1 const nextIndex = nextEnabledIndex(navEntries, activeIndex, direction) const nextKey = navEntries[nextIndex]?.key ?? activeEntry?.key ?? 'back' setSelectedKey(nextKey) const nextBoss = visibleBosses.find((boss) => boss.key === nextKey) if (nextBoss) onPreviewBoss(nextBoss.bossId) } useGameAction((action, device) => { if (device !== 'controller') return if (action === 'back') { onBack() return } if (action === 'confirm') { openDungeonNavEntry(activeEntry) return } moveDungeonSelection(action) }) return (

I Want To Heal 2

Dungeons

{currentPage + 1} / {pageCount}
{ setSelectedKey(`difficulty-${difficulty.slug}`) onDifficultyChange(difficulty.slug) }} selected={selected} />
{visibleBosses.map((boss) => ( ))}
) } export function Iwt2ModeScreen({ difficultySlug, mode, onBack, onDifficultyChange, onOpenBoss, onPreviewBoss, save, }: { difficultySlug: string mode: Iwt2Mode onBack: () => void onDifficultyChange: (difficultySlug: string) => void onOpenBoss: (bossId: Iwt2BossId, difficulty: Iwt2Difficulty) => void onPreviewBoss: (bossId: Iwt2BossId) => void save: Iwt2Save }) { const [selectedKey, setSelectedKey] = useState('back') const selectedDifficulty = findIwt2Difficulty(IWT2_RAID_DIFFICULTIES, difficultySlug) const difficultyLocked = !isIwt2DifficultyUnlocked(selectedDifficulty, save.character.level) const difficultyOptions = useMemo(() => ( IWT2_RAID_DIFFICULTIES.map((difficulty) => ({ ...difficulty, locked: !isIwt2DifficultyUnlocked(difficulty, save.character.level), selected: difficulty.slug === selectedDifficulty.slug, })) ), [save.character.level, selectedDifficulty.slug]) const actions = useMemo( () => withBackAction([ ...difficultyOptions.map((difficulty) => ({ key: `difficulty-${difficulty.slug}`, label: difficulty.name, detail: difficulty.description, value: difficulty.locked ? `Lv ${difficulty.unlockLevel}` : `${difficulty.healthMultiplier.toFixed(2)}x HP`, disabled: difficulty.locked, onConfirm: () => onDifficultyChange(difficulty.slug), })), ...MODE_ACTIONS[mode].map((entry) => { const reward = iwt2BossCoinRewardFor(entry.bossId, selectedDifficulty.slug) return { key: `${mode}-${entry.bossId}`, label: entry.label, detail: `${entry.detail} ${selectedDifficulty.name}: ${selectedDifficulty.healthMultiplier.toFixed(2)}x HP, ${selectedDifficulty.damageMultiplier.toFixed(2)}x damage. Reward: ${reward.name}.`, value: difficultyLocked ? `Lv ${selectedDifficulty.unlockLevel}` : selectedDifficulty.name, disabled: difficultyLocked, onConfirm: () => onOpenBoss(entry.bossId, selectedDifficulty), } }), ], onBack), [difficultyLocked, difficultyOptions, mode, onBack, onDifficultyChange, onOpenBoss, selectedDifficulty], ) const activeEntry = actions.find((entry) => entry.key === selectedKey && !entry.disabled) ?? actions.find((entry) => !entry.disabled) const selected = (key: string) => activeEntry?.key === key useGameAction((action, device) => { if (device !== 'controller') return if (action === 'back') { onBack() return } if (action === 'confirm') { activeEntry?.onConfirm() return } if (!action.startsWith('navigate')) return const activeIndex = Math.max(0, actions.findIndex((entry) => entry.key === activeEntry?.key)) const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1 const nextIndex = nextEnabledIndex(actions, activeIndex, direction) const nextKey = actions[nextIndex]?.key ?? activeEntry?.key ?? 'back' setSelectedKey(nextKey) const nextBoss = MODE_ACTIONS[mode].find((entry) => `${mode}-${entry.bossId}` === nextKey) if (nextBoss) onPreviewBoss(nextBoss.bossId) }) return ( { setSelectedKey(`difficulty-${difficulty.slug}`) onDifficultyChange(difficulty.slug) }} selected={selected} />
{actions.filter((action) => !action.key.startsWith('difficulty-')).map((action) => ( ))}
) } export function Iwt2RoguelikeScreen({ contentType, onBack, onCancelQueue, onContentTypeChange, onStart, onVariantChange, queueing = false, queueMessage, variant, }: { contentType: Iwt2RoguelikeContentType onBack: () => void onCancelQueue: () => void onContentTypeChange: (contentType: Iwt2RoguelikeContentType) => void onStart: () => void onVariantChange: (variant: Iwt2RoguelikeVariant) => void queueing?: boolean queueMessage?: string variant: Iwt2RoguelikeVariant }) { const gameMode = getGameMode() const [selectedIndex, setSelectedIndex] = useState(0) const actions = useMemo(() => { const base = [ { key: 'back', label: 'Back', onConfirm: onBack, }, { key: 'variant-pve', label: 'PvE', onConfirm: () => onVariantChange('pve'), }, { key: 'variant-pvp', label: 'PvP', onConfirm: () => onVariantChange('pvp'), }, ] satisfies Iwt2NavAction[] if (variant === 'pve') { base.push( { key: 'pve-dungeon', label: 'Dungeon', onConfirm: () => onContentTypeChange('dungeon'), }, { key: 'pve-raid', label: 'Raid', onConfirm: () => onContentTypeChange('raid'), }, { key: 'pve-start', label: 'Start Run', onConfirm: onStart, }, ) return base } base.push( { key: 'pvp-dungeon', label: 'Dungeon', onConfirm: () => onContentTypeChange('dungeon'), }, { key: 'pvp-raid', label: 'Raid', onConfirm: () => onContentTypeChange('raid'), }, { key: 'pvp-stadium', label: 'Stadium', onConfirm: () => onContentTypeChange('stadium'), }, { key: 'pvp-start', label: 'Start Match', onConfirm: onStart, }, ) return base }, [onBack, onContentTypeChange, onStart, onVariantChange, variant]) const activeIndex = Math.min(selectedIndex, actions.length - 1) const selected = (key: string) => actions[activeIndex]?.key === key const open = (key: string) => actions.find((action) => action.key === key)?.onConfirm() const select = (key: string) => { const nextIndex = actions.findIndex((action) => action.key === key) if (nextIndex >= 0) setSelectedIndex(nextIndex) } const pveCardTitle = contentType === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike' const pveCardCopy = contentType === '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.' const pvpCardTitle = gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback' const pvpCardCopy = contentType === '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.' useGameAction((action, device) => { if (device !== 'controller') return if (queueing) { if (action === 'back' || action === 'confirm') onCancelQueue() return } if (action === 'back') onBack() else if (action.startsWith('navigate')) { setSelectedIndex((current) => moveSpatialSelection(actions, current, action)) } else if (action === 'confirm') { actions[activeIndex]?.onConfirm() } }) return (

Endless Draft

Roguelike

{variant === 'pve' && ( <>

Run Type

PvE Roguelike

{contentType === 'raid' ? 'R' : 'D'}
{pveCardTitle} {pveCardCopy}
)} {variant === 'pvp' && ( <>

Match Type

PvP Roguelike

{gameMode === 'offline' ? 'C' : 'Q'}
{pvpCardTitle} {queueMessage || pvpCardCopy}
)} {queueing && (
P V P

Matchmaking

Queuing for PvP

{queueMessage || 'Searching for an opponent...'}
)}
) } export function Iwt2RoguelikeUpgradeScreen({ activeBuffSummary, activeDebuffSummary, contentType, debuffChoices, onBack, onChoose, selfChoices, stage, variant, }: { activeBuffSummary?: string activeDebuffSummary?: string contentType: Iwt2RoguelikeContentType debuffChoices: Array> onBack: () => void onChoose: (buffId: Iwt2RoguelikeSelfBuffId, debuffId?: Iwt2RoguelikeOpponentDebuffId) => void selfChoices: Array> stage: number variant: Iwt2RoguelikeVariant }) { const [selectedBuffId, setSelectedBuffId] = useState() const [selectedDebuffId, setSelectedDebuffId] = useState() const [selectedIndex, setSelectedIndex] = useState(0) const activeBuffId = selfChoices.some((choice) => choice.id === selectedBuffId) ? selectedBuffId : undefined const activeDebuffId = debuffChoices.some((choice) => choice.id === selectedDebuffId) ? selectedDebuffId : undefined const entries = useMemo(() => { if (variant !== 'pvp') { return [ ...selfChoices.map((_, index) => ({ kind: 'upgradeBuff' as const, index, row: index, column: 0, })), { kind: 'upgradeLeave' as const, row: selfChoices.length, column: 0, }, ] } const nextEntries: Iwt2UpgradeNavEntry[] = [ ...selfChoices.map((_, index) => ({ kind: 'upgradeBuff' as const, index, row: index, column: 0, })), ...debuffChoices.map((_, index) => ({ kind: 'upgradeDebuff' as const, index, row: index, column: 1, })), { kind: 'upgradeContinue' as const, row: Math.max(selfChoices.length, debuffChoices.length), column: 1, disabled: !activeBuffId || !activeDebuffId, }, ] return nextEntries }, [activeBuffId, activeDebuffId, debuffChoices, selfChoices, variant]) const activeEntry = activeUpgradeEntry(entries, selectedIndex) const pvpUpgrade = variant === 'pvp' const buffHeader = pvpUpgrade ? 'Self Buff' : 'Run Buff' const headerEyebrow = pvpUpgrade && contentType === 'stadium' ? 'Stadium Buy Round' : 'Choose Edge' const title = pvpUpgrade ? contentType === 'stadium' ? `Round ${stage} Complete` : `Stage ${stage} Boss Cleared` : `Roguelike Stage ${stage} Complete` const continueLabel = contentType === 'stadium' ? 'Finished Buying' : 'Continue' function entrySelected(kind: Iwt2UpgradeNavEntry['kind'], index?: number) { if (!activeEntry || activeEntry.kind !== kind) return false if ('index' in activeEntry || index !== undefined) return 'index' in activeEntry && activeEntry.index === index return true } function setCursor(kind: Iwt2UpgradeNavEntry['kind'], index?: number) { const nextIndex = entries.findIndex((entry) => { if (entry.kind !== kind) return false if ('index' in entry || index !== undefined) return 'index' in entry && entry.index === index return true }) if (nextIndex >= 0) setSelectedIndex(nextIndex) } function openEntry(entry: Iwt2UpgradeNavEntry | undefined) { if (!entry || upgradeEntryDisabled(entry)) return if (entry.kind === 'upgradeBuff') { const choice = selfChoices[entry.index] if (!choice) return if (pvpUpgrade) setSelectedBuffId(choice.id) else onChoose(choice.id) } else if (entry.kind === 'upgradeDebuff') { const choice = debuffChoices[entry.index] if (choice) setSelectedDebuffId(choice.id) } else if (entry.kind === 'upgradeContinue') { if (activeBuffId && activeDebuffId) onChoose(activeBuffId, activeDebuffId) } else if (entry.kind === 'upgradeLeave') { onBack() } } useGameAction((action, device) => { if (device !== 'controller') return if (action === 'confirm') { openEntry(activeEntry) return } if (action.startsWith('navigate')) { setSelectedIndex((current) => moveUpgradeSelection(entries, current, action)) return } if (action === 'back') onBack() }) return (
{pvpUpgrade ? (

{headerEyebrow}

{title}

) : ( <>

{title}

Choose Upgrade

Pick one upgrade before the next fight.

)}
{buffHeader}
{selfChoices.map((choice, index) => ( ))}
{pvpUpgrade && (
Opponent Debuff
{debuffChoices.map((choice, index) => ( ))}
)}
{!pvpUpgrade && activeBuffSummary && (

Active: {activeBuffSummary}

)} {pvpUpgrade && (activeBuffSummary || activeDebuffSummary) && (

Buffs: {activeBuffSummary || 'None'} | Debuffs: {activeDebuffSummary || 'None'}

)} {pvpUpgrade ? ( ) : ( )}
) } export function Iwt2HunterProfileScreen({ onBack, save, }: { onBack: () => void save: Iwt2Save }) { const [activeTab, setActiveTab] = useState('stats') const [selectedIndex, setSelectedIndex] = useState(1) const [focusedItemKey, setFocusedItemKey] = useState(null) const screenRef = useRef(null) const { enabled: dualScreenEnabled } = useDualScreen() const bosses = useMemo(() => ( (Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => { const coins = iwt2BossCoinRewardsFor(bossId).map((coin) => ({ ...coin, quantity: save.collectionLog.dropsFound[coin.id] ?? 0, })) const pet = iwt2BossPetRewardFor(bossId) return { bossId, coins, drops: coins.reduce((total, coin) => total + coin.quantity, 0), encounter: IWT2_BOSS_METADATA[bossId], kills: save.collectionLog.bossKills[bossId] ?? 0, petId: pet.id, petName: pet.name, pets: save.collectionLog.bossPets[pet.id] ?? 0, } }) ), [save.collectionLog.bossKills, save.collectionLog.bossPets, save.collectionLog.dropsFound]) const [selectedBossId, setSelectedBossId] = useState(() => bosses[0]?.bossId ?? 'bulldrome') const selectedBoss = bosses.find((boss) => boss.bossId === selectedBossId) ?? bosses[0] const totalBossKills = bosses.reduce((total, boss) => total + boss.kills, 0) const totalDrops = bosses.reduce((total, boss) => total + boss.drops, 0) const totalPets = bosses.reduce((total, boss) => total + boss.pets, 0) const mostKilledBoss = bosses.reduce((best, boss) => { if (!best) return boss return boss.kills > best.kills ? boss : best }, null) const collectionItems = useMemo(() => { if (!selectedBoss) return [] return [ { chance: 'Defeats', glyph: 'K', key: `kills:${selectedBoss.bossId}`, name: 'Boss Kills', quantity: selectedBoss.kills, rarity: 'stat', source: selectedBoss.encounter.name, }, ...selectedBoss.coins.map((coin) => ({ chance: `Guaranteed 1-3 | iLvl ${coin.itemLevel}`, glyph: coin.glyph, key: `drop:${coin.id}`, name: coin.name, quantity: coin.quantity, rarity: coin.rarity, source: selectedBoss.encounter.name, })), { chance: '1 in 500', glyph: '*', key: `pet:${selectedBoss.petId}`, name: selectedBoss.petName, quantity: selectedBoss.pets, rarity: 'legendary', source: selectedBoss.encounter.name, }, ] }, [selectedBoss]) const statTiles = useMemo(() => [ { key: 'stat:total-kills', label: 'Total Boss Kills', value: totalBossKills, }, { detail: totalBossKills > 0 && mostKilledBoss ? `${mostKilledBoss.kills} kills` : '0 kills', key: 'stat:most-killed', label: 'Most Killed Boss', value: totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.name : 'None', }, { key: 'stat:hunter-level', label: 'Hunter Level', value: save.character.level, }, { key: 'stat:experience', label: 'Experience', value: `${save.character.experience} XP`, }, { key: 'stat:collection-drops', label: 'Collection Drops', value: totalDrops + totalPets, detail: `${totalPets} pets, ${save.inventory.length} inventory slots`, }, ], [mostKilledBoss, save.character.experience, save.character.level, save.inventory.length, totalBossKills, totalDrops, totalPets]) const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null const navEntries = useMemo(() => { const entries: Iwt2HunterProfileNavEntry[] = [ { kind: 'back', key: 'back', row: 0, column: 0 }, { kind: 'tab', key: 'tab:stats', row: 0, column: 1, tab: 'stats' }, { kind: 'tab', key: 'tab:collection', row: 0, column: 2, tab: 'collection' }, ] if (activeTab === 'stats') { entries.push(...statTiles.map((tile, index) => ({ column: index, index, key: tile.key, kind: 'stat' as const, row: 1, }))) } else { entries.push( ...bosses.map((boss, index) => ({ bossId: boss.bossId, column: 0, key: `boss:${boss.bossId}`, kind: 'boss' as const, row: index + 1, })), ...collectionItems.map((item, index) => ({ column: (index % IWT2_HUNTER_PROFILE_DROP_COLUMNS) + 1, itemKey: item.key, key: `item:${item.key}`, kind: 'item' as const, row: Math.floor(index / IWT2_HUNTER_PROFILE_DROP_COLUMNS) + 1, })), ) } return entries }, [activeTab, bosses, collectionItems, statTiles]) const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1)) const activeEntry = navEntries[activeIndex] const itemDetailState = useMemo(() => { if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null return { items: [ { detail: focusedItem.source, glyph: focusedItem.glyph, meta: focusedItem.chance, status: focusedItem.quantity > 0 ? 'Collected' : 'Missing', title: 'Drop Rate', }, { detail: 'I Want To Heal 2', meta: `${selectedBoss.kills}`, status: `${selectedBoss.drops} drops`, title: 'Boss Kills', }, ], mode: 'collection', subtitle: selectedBoss.encounter.name, summary: `Owned x${focusedItem.quantity}`, title: focusedItem.name, } }, [activeTab, focusedItem, selectedBoss]) useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled) function selected(entryKey: string) { return activeEntry?.key === entryKey } function selectKey(entryKey: string) { const index = navEntries.findIndex((entry) => entry.key === entryKey) if (index >= 0) setSelectedIndex(index) } function selectTab(tab: Iwt2HunterProfileTab) { setActiveTab(tab) setSelectedIndex(tab === 'stats' ? 1 : 2) } function selectBoss(bossId: Iwt2BossId) { setSelectedBossId(bossId) setFocusedItemKey(null) } function moveSelection(action: InputAction) { if (!action.startsWith('navigate') || navEntries.length === 0) return const nextIndex = moveHunterProfileSelection(navEntries, selectedIndex, action) const nextEntry = navEntries[nextIndex] setSelectedIndex(nextIndex) if (nextEntry?.kind === 'item') setFocusedItemKey(nextEntry.itemKey) } function openEntry(entry: Iwt2HunterProfileNavEntry | undefined) { if (!entry) return if (entry.kind === 'back') onBack() else if (entry.kind === 'tab') selectTab(entry.tab) else if (entry.kind === 'boss') selectBoss(entry.bossId) else if (entry.kind === 'item') setFocusedItemKey(entry.itemKey) } useEffect(() => { if (!activeEntry) return screenRef.current ?.querySelector('[data-game-selected="true"]') ?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) }, [activeEntry]) useGameAction((action, inputDevice) => { if (inputDevice !== 'controller') return if (action === 'back') { onBack() return } if (action === 'confirm') { openEntry(activeEntry) return } moveSelection(action) }) return (
{activeTab === 'stats' && (
{statTiles.map((tile) => (
selectKey(tile.key)} > {tile.label} {tile.value} {tile.detail && {tile.detail}}
))}
)} {activeTab === 'collection' && (
{bosses.map((boss) => ( ))}
{selectedBoss && (
{collectionItems.map((item) => ( ))}
)}
)}
) } function bossInitialsForProfile(name: string) { return name .split(/\s+/) .filter(Boolean) .slice(0, 2) .map((part) => part[0]?.toUpperCase() ?? '') .join('') } export function Iwt2CustomizeCharacterScreen({ onBack, onSaveUpdated, save, }: { onBack: () => void onSaveUpdated: (save: Iwt2Save) => void save: Iwt2Save }) { const [editingName, setEditingName] = useState(false) const activeAbilities = abilitiesForHealer(save.character.healerStyle) const abilityRows = activeAbilities.map((ability) => ({ key: ability.id, label: ability.name, detail: `${ability.manaCost} resource. ${ability.cooldownSeconds}s cooldown.`, value: ability.icon, })) const actions = useMemo(() => withBackAction([ { key: 'profile-name', label: 'Profile Name', detail: 'IWT2 hunter callsign shown in header.', value: save.character.name, onConfirm: () => setEditingName(true), }, ...IWT2_HEALER_ORDER.map((healerId) => { const healer = IWT2_HEALER_METADATA[healerId] return { key: `class-${healerId}`, label: healer.name, detail: healer.description, value: save.character.healerStyle === healerId ? 'Selected' : healer.icon, onConfirm: () => onSaveUpdated(updateIwt2CharacterSettings(save, { healerStyle: healerId, })), } }), ], onBack), [onBack, onSaveUpdated, save]) if (editingName) { return ( setEditingName(false)}> setEditingName(false)} onSave={(name) => { onSaveUpdated(updateIwt2CharacterSettings(save, { name })) setEditingName(false) }} /> ) } return (

Healing Class

Active Loadout

Ability Bar

Select a class, then start a run.
) } function Iwt2ProfileNameEditor({ name, onCancel, onSave, }: { name: string onCancel: () => void onSave: (name: string) => void }) { const [draft, setDraft] = useState(name) const [selectedIndex, setSelectedIndex] = useState(0) const entries = useMemo(() => [ ...IWT2_NAME_CHARACTERS.map((character) => ({ key: `char-${character}`, label: character, onConfirm: () => setDraft((current) => `${current}${character}`.slice(0, IWT2_NAME_MAX_LENGTH)), })), { key: 'space', label: 'Space', onConfirm: () => setDraft((current) => `${current} `.slice(0, IWT2_NAME_MAX_LENGTH)), }, { key: 'dash', label: '-', onConfirm: () => setDraft((current) => `${current}-`.slice(0, IWT2_NAME_MAX_LENGTH)), }, { key: 'erase', label: 'Erase', onConfirm: () => setDraft((current) => current.slice(0, -1)), }, { key: 'save', label: 'Save', onConfirm: () => onSave(draft), }, { key: 'cancel', label: 'Cancel', onConfirm: onCancel, }, ], [draft, onCancel, onSave]) useGameAction((action, device) => { if (device !== 'controller') return if (action === 'back') { onCancel() return } if (action === 'confirm') { entries[selectedIndex]?.onConfirm() return } if (action === 'navigateLeft') { setSelectedIndex((current) => Math.max(0, current - 1)) } else if (action === 'navigateRight') { setSelectedIndex((current) => Math.min(entries.length - 1, current + 1)) } else if (action === 'navigateUp') { setSelectedIndex((current) => Math.max(0, current - IWT2_NAME_EDITOR_COLUMNS)) } else if (action === 'navigateDown') { setSelectedIndex((current) => Math.min(entries.length - 1, current + IWT2_NAME_EDITOR_COLUMNS)) } }) return (
{entries.map((entry, index) => ( ))}
) } export function Iwt2CloudSaveScreen({ onBack, onSaveUpdated, onlineBackupsAvailable, save, }: { onBack: () => void onSaveUpdated: (save: Iwt2Save) => void onlineBackupsAvailable: boolean save: Iwt2Save }) { const [onlineSave, setOnlineSave] = useState(null) const [syncingOnlineSave, setSyncingOnlineSave] = useState(false) const [message, setMessage] = useState('') const statusMessage = message || (!onlineBackupsAvailable ? 'Online save unavailable in offline mode.' : '') const checkOnlineSave = useCallback(async (isCancelled: () => boolean) => { setSyncingOnlineSave(true) setMessage('Checking online save...') try { const result = await loadIwt2OnlineSave() if (isCancelled()) return setOnlineSave(result.save) setMessage(result.save ? 'Online save loaded.' : 'No online save yet.') } catch (reason) { if (isCancelled()) return setOnlineSave(null) setMessage(reason instanceof Error ? reason.message : 'Unable to check online save.') } finally { if (!isCancelled()) setSyncingOnlineSave(false) } }, []) useEffect(() => { let cancelled = false if (!onlineBackupsAvailable) { return () => { cancelled = true } } const timer = window.setTimeout(() => { void checkOnlineSave(() => cancelled) }, 0) return () => { cancelled = true window.clearTimeout(timer) } }, [checkOnlineSave, onlineBackupsAvailable]) const handleUseLocalSave = useCallback(async () => { if (!onlineBackupsAvailable) { setMessage('Using local save. Sign in online to update the server copy.') return } setSyncingOnlineSave(true) setMessage('Uploading local save...') try { const result = await writeIwt2OnlineSave(save) setOnlineSave(result.save) setMessage('Online save now uses local progress.') } catch (reason) { setMessage(reason instanceof Error ? reason.message : 'Unable to upload local save.') } finally { setSyncingOnlineSave(false) } }, [onlineBackupsAvailable, save]) const handleUseOnlineSave = useCallback(() => { if (!onlineBackupsAvailable || !onlineSave) return onSaveUpdated(onlineSave) setMessage('Local save now uses online progress.') }, [onlineBackupsAvailable, onlineSave, onSaveUpdated]) const handleUseNewSave = useCallback(() => { onSaveUpdated(createDefaultIwt2Save()) setMessage('Started a new local IWT2 save.') }, [onSaveUpdated]) const actions = useMemo(() => withBackAction([ { key: 'use-local', label: 'Use Local Save', detail: formatIwt2SaveSummary(save), value: onlineBackupsAvailable ? 'Upload' : 'Current', disabled: syncingOnlineSave, onConfirm: () => { void handleUseLocalSave() }, }, { key: 'use-online', label: 'Use Online Save', detail: syncingOnlineSave ? 'Checking online save...' : formatIwt2SaveSummary(onlineSave), value: onlineSave ? 'Download' : 'Empty', disabled: syncingOnlineSave || !onlineBackupsAvailable || !onlineSave, onConfirm: handleUseOnlineSave, }, { key: 'new-save', label: 'Use New Save File', detail: 'Start over with a fresh IWT2 character. Online save is not overwritten until you choose local save.', value: 'New', onConfirm: handleUseNewSave, }, ], onBack), [ handleUseLocalSave, handleUseNewSave, handleUseOnlineSave, onlineBackupsAvailable, onlineSave, onBack, save, syncingOnlineSave, ]) return ( {statusMessage &&

{statusMessage}

}
) } export function Iwt2GearUpgradeScreen({ onBack, onSaveUpdated, save, }: { onBack: () => void onSaveUpdated: (save: Iwt2Save) => void save: Iwt2Save }) { const [selectedClassId, setSelectedClassId] = useState('healer') const [selectedSlotId, setSelectedSlotId] = useState('weapon') const [selectedIndex, setSelectedIndex] = useState(1) const [message, setMessage] = useState('Gear upgrades use IWT2 boss coins only.') const classProgress = save.gearProgress[selectedClassId] const selectedSlot = classProgress.slots[selectedSlotId] const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId] const selectedBonus = gearBonusSummary(selectedRecipe.statId, selectedSlot.level, selectedClassId) const nextLevel = Math.min(5, selectedSlot.level + 1) const nextBonus = gearBonusSummary(selectedRecipe.statId, nextLevel, selectedClassId) const selectedClassName = gearClassDisplayName(selectedClassId, save) const upgradeCosts = iwt2GearUpgradeCosts(selectedClassId, selectedSlotId, selectedSlot.level) const canUpgrade = selectedSlot.level < 5 && canAffordIwt2Costs(save, upgradeCosts) const infusionUnlocked = isIwt2InfusionUnlocked(classProgress) const infusionAnchorSlot = selectedSlot.level >= 5 ? selectedSlotId : IWT2_GEAR_SLOTS.find((slotId) => classProgress.slots[slotId].level >= 5) ?? selectedSlotId const infusionAbilities = iwt2InfusionAbilitiesForClass(selectedClassId) const navEntries = useMemo(() => { const entries: Iwt2GearNavEntry[] = [{ kind: 'back', key: 'back', row: 0, column: 0 }] IWT2_PARTY_ORDER.forEach((classId, index) => { entries.push({ kind: 'class', key: `class:${classId}`, row: index + 1, column: 0, classId }) }) IWT2_GEAR_SLOTS.forEach((slotId, index) => { entries.push({ kind: 'slot', key: `slot:${slotId}`, row: index + 1, column: 1, slotId }) }) entries.push({ kind: 'upgrade', key: 'upgrade', row: 6, column: 1, disabled: !canUpgrade }) infusionAbilities.forEach((ability, index) => { const costs = iwt2InfusionCosts(selectedClassId, infusionAnchorSlot, ability.id) const selected = classProgress.infusionAbilityId === ability.id entries.push({ kind: 'infusion', key: `infusion:${ability.id}`, row: index + 1, column: 2, abilityId: ability.id, disabled: selected || !infusionUnlocked || !canAffordIwt2Costs(save, costs), }) }) return entries }, [canUpgrade, classProgress.infusionAbilityId, infusionAbilities, infusionAnchorSlot, infusionUnlocked, save, selectedClassId]) const activeEntry = navEntries[Math.min(selectedIndex, navEntries.length - 1)] ?? navEntries[0] useGameAction((action, device) => { if (device !== 'controller') return if (action === 'back') { onBack() return } if (action.startsWith('navigate')) { setSelectedIndex((current) => moveGearSelection(navEntries, current, action)) return } if (action === 'confirm' && activeEntry) { activateEntry(activeEntry) } }) function activateEntry(entry: Iwt2GearNavEntry) { if (entry.kind === 'back') { onBack() return } if (entry.kind === 'class') { setSelectedClassId(entry.classId) return } if (entry.kind === 'slot') { setSelectedSlotId(entry.slotId) return } if (entry.kind === 'upgrade') { if (entry.disabled) return try { const nextSave = upgradeIwt2GearSlot(save, selectedClassId, selectedSlotId) onSaveUpdated(nextSave) setMessage(`${selectedClassName} ${IWT2_GEAR_SLOT_LABELS[selectedSlotId]} upgraded to +${selectedSlot.level + 1}.`) } catch (error) { setMessage(error instanceof Error ? error.message : 'Upgrade failed.') } return } if (entry.kind === 'infusion') { if (entry.disabled) return try { const nextSave = setIwt2InfusionAbility(save, selectedClassId, infusionAnchorSlot, entry.abilityId) onSaveUpdated(nextSave) setMessage(`${IWT2_INFUSION_ABILITIES[entry.abilityId].name} infused into slot 6.`) } catch (error) { setMessage(error instanceof Error ? error.message : 'Infusion failed.') } } } return (
{IWT2_PARTY_ORDER.map((classId) => { const metadata = IWT2_CLASS_METADATA[classId] const selected = selectedClassId === classId const focused = activeEntry?.kind === 'class' && activeEntry.classId === classId const classInfusion = save.gearProgress[classId].infusionAbilityId const highest = Math.max(...IWT2_GEAR_SLOTS.map((slotId) => save.gearProgress[classId].slots[slotId].level)) const className = gearClassDisplayName(classId, save) const classSubtitle = gearClassSubtitle(classId, save) return ( ) })}
{IWT2_GEAR_SLOTS.map((slotId) => { const recipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][slotId] const slot = classProgress.slots[slotId] const focused = activeEntry?.kind === 'slot' && activeEntry.slotId === slotId const selected = selectedSlotId === slotId return ( ) })}

{IWT2_GEAR_SLOT_LABELS[selectedSlotId]} +{selectedSlot.level} {IWT2_GEAR_STAT_LABELS[selectedRecipe.statId]} from {bossName(selectedRecipe.primaryBossId)} and {bossName(selectedRecipe.secondaryBossId)} coins.

Current bonus {selectedBonus.text} {selectedSlot.level >= 5 ? 'Max rank' : `Upgrade preview +${selectedSlot.level} -> +${nextLevel}`} {selectedSlot.level >= 5 ? infusionAnchorText(classProgress.slots[selectedSlotId].level) : `${selectedBonus.label}: ${selectedBonus.value} -> ${nextBonus.value}`}
{upgradeCosts.length === 0 ? ( Max rank reached. ) : upgradeCosts.map((cost) => { const owned = inventoryQuantity(save.inventory, cost.itemId) return ( = cost.quantity ? 'met' : 'missing'} key={cost.itemId}> {owned}/{cost.quantity} {cost.itemName} ) })}
{infusionAbilities.map((ability) => { const selected = classProgress.infusionAbilityId === ability.id const focused = activeEntry?.kind === 'infusion' && activeEntry.abilityId === ability.id const costs = iwt2InfusionCosts(selectedClassId, infusionAnchorSlot, ability.id) const disabled = selected || !infusionUnlocked || !canAffordIwt2Costs(save, costs) return ( ) })}
{message}
) } function gearClassDisplayName(classId: Iwt2PlayerClassId, save: Iwt2Save): string { if (classId === 'healer') return save.character.name return IWT2_CLASS_METADATA[classId].name } function gearClassSubtitle(classId: Iwt2PlayerClassId, save: Iwt2Save): string { if (classId === 'healer') return IWT2_HEALER_METADATA[save.character.healerStyle].name const role = IWT2_CLASS_METADATA[classId].role return role === 'damage' ? 'Damage' : 'Tank' } function gearBonusSummary( statId: Iwt2GearStatId, level: number, classId: Iwt2PlayerClassId, ): { label: string, text: string, value: string } { const bonus = Math.max(0, level) if (statId === 'maxHealth') return gearBonus('Max health', `+${bonus * 4}%`) if (statId === 'moveSpeed') return gearBonus('Move speed', `+${formatPercent(bonus * 2.5)}%`) if (statId === 'damage') return gearBonus('Attack damage', `+${bonus * 4}%`) if (statId === 'attackCooldown') { const label = classId === 'healer' ? 'Ability cooldown' : 'Attack cooldown' return gearBonus(label, `-${bonus * 3}%`) } if (statId === 'projectileSpeed') return gearBonus('Projectile speed', `+${bonus * 3}%`) if (statId === 'hazardDamageTaken') return gearBonus('Hazard damage taken', `-${bonus * 3}%`) if (statId === 'stunResist') return gearBonus('Stun/knockdown duration', `-${bonus * 8}%`) if (statId === 'healingPower') return gearBonus('Ability healing', `+${bonus * 4}%`) return gearBonus('Bonus', '+0%') } function gearBonus(label: string, value: string): { label: string, text: string, value: string } { return { label, text: `${label} ${value}`, value } } function infusionAnchorText(level: number): string { return level >= 5 ? 'Infusion anchor available.' : 'No further bonus.' } function formatPercent(value: number): string { return Number.isInteger(value) ? String(value) : value.toFixed(1) } function clampToEnabled(actions: Iwt2NavAction[], index: number) { if (actions.length === 0) return 0 const bounded = Math.min(Math.max(0, index), actions.length - 1) if (!actions[bounded]?.disabled) return bounded for (let offset = 1; offset < actions.length; offset += 1) { const previous = bounded - offset const next = bounded + offset if (previous >= 0 && !actions[previous]?.disabled) return previous if (next < actions.length && !actions[next]?.disabled) return next } return 0 } function nextEnabledIndex(actions: Iwt2NavAction[], current: number, direction: -1 | 1) { if (actions.length === 0) return 0 let index = current for (let steps = 0; steps < actions.length; steps += 1) { index = Math.min(actions.length - 1, Math.max(0, index + direction)) if (!actions[index]?.disabled) return index if (index === 0 || index === actions.length - 1) break } return clampToEnabled(actions, current) } function moveGearSelection(entries: Iwt2GearNavEntry[], current: number, action: string): number { if (entries.length === 0) return 0 const active = entries[Math.min(current, entries.length - 1)] ?? entries[0] const candidates = entries .map((entry, index) => ({ entry, index })) .filter(({ entry, index }) => { if (index === current) return false if (action === 'navigateLeft') return entry.column < active.column if (action === 'navigateRight') return entry.column > active.column if (action === 'navigateUp') return entry.column === active.column && entry.row < active.row if (action === 'navigateDown') return entry.column === active.column && entry.row > active.row return false }) if (candidates.length === 0) return current candidates.sort((a, b) => { const primaryA = action === 'navigateLeft' || action === 'navigateRight' ? Math.abs(a.entry.column - active.column) : Math.abs(a.entry.row - active.row) const primaryB = action === 'navigateLeft' || action === 'navigateRight' ? Math.abs(b.entry.column - active.column) : Math.abs(b.entry.row - active.row) if (primaryA !== primaryB) return primaryA - primaryB const secondaryA = action === 'navigateLeft' || action === 'navigateRight' ? Math.abs(a.entry.row - active.row) : Math.abs(a.entry.column - active.column) const secondaryB = action === 'navigateLeft' || action === 'navigateRight' ? Math.abs(b.entry.row - active.row) : Math.abs(b.entry.column - active.column) return secondaryA - secondaryB }) return candidates[0]?.index ?? current } function selectGearEntry( entries: Iwt2GearNavEntry[], setSelectedIndex: (value: number) => void, key: string, ) { const index = entries.findIndex((entry) => entry.key === key) if (index >= 0) setSelectedIndex(index) } function bossName(bossId: Iwt2BossId): string { return IWT2_BOSS_METADATA[bossId].name } function infusionCostText(save: Iwt2Save, costs: Array<{ itemId: string, itemName: string, quantity: number }>): string { return costs .map((cost) => `${inventoryQuantity(save.inventory, cost.itemId)}/${cost.quantity} ${cost.itemName}`) .join(' | ') } function activeUpgradeEntry(entries: Iwt2UpgradeNavEntry[], index: number) { if (entries.length === 0) return undefined return entries[Math.min(index, entries.length - 1)] } function moveHunterProfileSelection( entries: Iwt2HunterProfileNavEntry[], current: number, action: InputAction, ) { if (!action.startsWith('navigate') || entries.length === 0) return current const bounded = Math.min(current, entries.length - 1) const active = entries[bounded] if (!active) return 0 const candidates = entries .map((entry, index) => ({ entry, index })) .filter(({ index }) => index !== bounded) .filter(({ entry }) => { if (action === 'navigateLeft') return entry.column < active.column if (action === 'navigateRight') return entry.column > active.column if (action === 'navigateUp') return entry.row < active.row return entry.row > active.row }) if (candidates.length === 0) return bounded candidates.sort((a, b) => { const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column) const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column) const aSecondary = action === 'navigateLeft' || action === 'navigateRight' ? Math.abs(a.entry.row - active.row) : Math.abs(a.entry.column - active.column) const bSecondary = action === 'navigateLeft' || action === 'navigateRight' ? Math.abs(b.entry.row - active.row) : Math.abs(b.entry.column - active.column) return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index }) return candidates[0]?.index ?? bounded } function upgradeEntryDisabled(entry: Iwt2UpgradeNavEntry) { return entry.kind === 'upgradeContinue' && Boolean(entry.disabled) } function moveUpgradeSelection(entries: Iwt2UpgradeNavEntry[], current: number, action: string) { if (entries.length === 0) return 0 const bounded = Math.min(current, entries.length - 1) const active = entries[bounded] const continueIndex = entries.findIndex((entry) => entry.kind === 'upgradeContinue' || entry.kind === 'upgradeLeave') const lastChoiceIndex = Math.max(0, continueIndex >= 0 ? continueIndex - 1 : entries.length - 1) if (action === 'navigateRight') return Math.min(lastChoiceIndex, bounded + 1) if (action === 'navigateLeft') return Math.max(0, bounded - 1) if (action === 'navigateDown') return continueIndex >= 0 ? continueIndex : bounded if (action === 'navigateUp' && active?.kind === 'upgradeContinue') return lastChoiceIndex if (action === 'navigateUp' && active?.kind === 'upgradeLeave') return lastChoiceIndex return bounded } function moveSpatialSelection(actions: Iwt2NavAction[], current: number, action: string) { if (actions.length === 0) return 0 const bounded = Math.min(current, actions.length - 1) const active = actions[bounded] if (!active) return bounded const activePosition = roguelikeActionPosition(active.key) const candidates = actions .map((entry, index) => ({ entry, index, position: roguelikeActionPosition(entry.key) })) .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 roguelikeActionPosition(key: string): Iwt2NavPosition { if (key === 'back') return { row: 0, column: 0 } if (key === 'variant-pve') return { row: 1, column: 0 } if (key === 'variant-pvp') return { row: 1, column: 1 } if (key === 'pve-dungeon' || key === 'pvp-dungeon') return { row: 2, column: 0 } if (key === 'pve-raid' || key === 'pvp-raid') return { row: 2, column: 1 } if (key === 'pvp-stadium') return { row: 2, column: 2 } if (key === 'pvp-start') return { row: 3, column: 1 } return { row: 3, column: 0 } } export function Iwt2SettingsScreen({ onBack }: { onBack: () => void }) { const { controllerIconStyle, directPartyTargeting, setControllerIconStyle, setDirectPartyTargeting, } = useInput() const actions = useMemo(() => withBackAction([ { key: 'direct-targeting', label: 'Target Party Members With Bindings', detail: 'When enabled, party frames show direct target button icons instead of D-pad cycling.', value: directPartyTargeting ? 'On' : 'Off', onConfirm: () => setDirectPartyTargeting(!directPartyTargeting), }, ...CONTROLLER_ICON_OPTIONS.map((style) => ({ key: `icons-${style}`, label: `${CONTROLLER_ICON_LABELS[style]} Button Icons`, detail: 'Changes controller glyphs shown on IWT2 frames and spell controls.', value: controllerIconStyle === style ? 'Selected' : '', onConfirm: () => setControllerIconStyle(style), })), ], onBack), [ controllerIconStyle, directPartyTargeting, onBack, setControllerIconStyle, setDirectPartyTargeting, ]) return (
{CONTROLLER_ICON_LABELS[controllerIconStyle]}
) }