import { useEffect, useMemo, useRef, useState } from 'react' import { useGameAction } from '../../input' import { getGameMode } from '../../gameRepository' import { startPvpQueueWithCpuFallback } from '../../pvpQueueLifecycle' import { summarizeChoiceStacks } from '../../combat/roguelikeUpgrades' import { useDualScreen, useDualScreenSetupPublisher, useDualScreenWorkshopPublisher, type DualScreenSetupState, type DualScreenWorkshopState, } from '../../dualScreen' import { BossArenaScreen } from './screens/BossArenaScreen' import { Iwt2CloudSaveScreen, Iwt2CustomizeCharacterScreen, Iwt2DungeonsScreen, Iwt2GearUpgradeScreen, Iwt2HunterProfileScreen, Iwt2ModeScreen, Iwt2RoguelikeScreen, Iwt2RoguelikeUpgradeScreen, Iwt2SettingsScreen, } from './screens/Iwt2ShellScreens' import { loadIwt2Save, writeIwt2Save, type Iwt2Save, } from './save/iwt2Repository' import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses' import { iwt2BossCoinRewardFor } from './content/bossRewards' import { findIwt2Difficulty, IWT2_DUNGEON_DIFFICULTIES, IWT2_RAID_DIFFICULTIES, type Iwt2Difficulty, } from './content/difficulties' import { abilitiesForHealer, IWT2_HEALER_METADATA } from './content/healerAbilities' import { buildIwt2OpponentDebuffChoices, buildIwt2SelfBuffChoices, IWT2_REVIVE_PARTY_CHOICE, type Iwt2RoguelikeChoice, type Iwt2RoguelikeContentType, type Iwt2RoguelikeOpponentDebuffId, type Iwt2RoguelikeSelfBuffId, type Iwt2RoguelikeVariant, } from './content/roguelike' import { createIwt2WeightedRoguelikeBossPair, createUniformIwt2RoguelikeBossPair, enabledIwt2RoguelikeBossPool, IWT2_PVP_ROGUELIKE_BOSS_ROSTER_LIMIT_ENABLED, IWT2_PVP_ROGUELIKE_ENABLED_BOSS_IDS, IWT2_PVP_STADIUM_BOSS_ROSTER_LIMIT_ENABLED, IWT2_PVP_STADIUM_ENABLED_BOSS_IDS, IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED, IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED, IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED, } from './content/roguelikeBossProgression' type Iwt2Screen = | 'menu' | 'arena' | 'cloud-save' | 'dungeons' | 'raids' | 'roguelike' | 'roguelike-arena' | 'roguelike-upgrade' | 'gear-upgrade' | 'hunter-profile' | 'customize-character' | 'settings' const IWT2_MENU_COLUMNS = 2 const IWT2_ROGUELIKE_CHOICE_COUNT = 3 const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65 const IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD = 5 type Iwt2RoguelikeRunState = { bossIds: Iwt2BossId[] bossesDefeated: number buffs: Iwt2RoguelikeSelfBuffId[] contentType: Iwt2RoguelikeContentType debuffs: Iwt2RoguelikeOpponentDebuffId[] debuffChoices: Array> selfChoices: Array> stage: number variant: Iwt2RoguelikeVariant } const MENU_ITEMS: Array<{ screen: Iwt2Screen title: string description: string glyph: string }> = [ { screen: 'dungeons', title: 'Dungeons', description: 'Queue into seven modular IWT2 boss arenas.', glyph: 'D', }, { screen: 'raids', title: 'Raids', description: 'Open raid assignments built from active IWT2 boss mechanics.', glyph: 'R', }, { screen: 'roguelike', title: 'Roguelike', description: 'Draft upgrades through escalating random encounters.', glyph: 'L', }, { screen: 'roguelike', title: 'PvP', description: 'Race another healer through roguelike encounters with buffs and sabotage.', glyph: 'P', }, { screen: 'gear-upgrade', title: 'Gear Upgrade', description: 'Spend boss coins on class gear slots and infusion abilities.', glyph: 'G', }, { screen: 'hunter-profile', title: 'Hunter Profile', description: 'IWT2 level, inventory summary, and collection log.', glyph: 'H', }, { screen: 'customize-character', title: 'Customize Character', description: 'Choose healer kit, armor palette, and IWT2 hunter callsign.', glyph: 'K', }, { screen: 'cloud-save', title: 'Backup Slot', description: 'Choose local, online, or fresh IWT2 progress.', glyph: 'C', }, { screen: 'settings', title: 'Settings', description: 'IWT2 targeting preference and controller icon style.', glyph: 'S', }, ] export function IWantToHeal2App({ onlineBackupsAvailable, onBackToGameSelect, }: { onlineBackupsAvailable: boolean onBackToGameSelect: () => void }) { const { enabled: dualScreenEnabled } = useDualScreen() const [screen, setScreen] = useState('menu') const [save, setSave] = useState(loadIwt2Save) const [selectedIndex, setSelectedIndex] = useState(0) const [selectedBossId, setSelectedBossId] = useState('bulldrome') const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon') const [arenaDifficulty, setArenaDifficulty] = useState(IWT2_DUNGEON_DIFFICULTIES[0]) const [selectedDungeonDifficultySlug, setSelectedDungeonDifficultySlug] = useState(IWT2_DUNGEON_DIFFICULTIES[0].slug) const [selectedRaidDifficultySlug, setSelectedRaidDifficultySlug] = useState(IWT2_RAID_DIFFICULTIES[0].slug) const [roguelikeVariant, setRoguelikeVariant] = useState('pve') const [roguelikeContentType, setRoguelikeContentType] = useState('dungeon') const [roguelikeRun, setRoguelikeRun] = useState(null) const [pvpQueueMessage, setPvpQueueMessage] = useState('') const [pvpQueueing, setPvpQueueing] = useState(false) const cancelPvpQueueRef = useRef<(() => void) | null>(null) useEffect(() => { writeIwt2Save(save) }, [save]) useEffect(() => () => { cancelPvpQueueRef.current?.() }, []) function cancelIwt2PvpQueue() { cancelPvpQueueRef.current?.() cancelPvpQueueRef.current = null setPvpQueueing(false) setPvpQueueMessage('') } const setupDualScreenState = useMemo( () => buildIwt2SetupDualScreenState(screen, selectedBossId, save, currentSetupDifficulty(screen, selectedDungeonDifficultySlug, selectedRaidDifficultySlug)), [save, screen, selectedBossId, selectedDungeonDifficultySlug, selectedRaidDifficultySlug], ) const workshopDualScreenState = useMemo( () => buildIwt2WorkshopDualScreenState(screen, save), [save, screen], ) useDualScreenSetupPublisher(setupDualScreenState, dualScreenEnabled) useDualScreenWorkshopPublisher(workshopDualScreenState, dualScreenEnabled) useGameAction((action, device) => { if (screen !== 'menu' || device !== 'controller') return if (action === 'back') { onBackToGameSelect() return } if (action === 'confirm') { openMenuItem(MENU_ITEMS[selectedIndex]) return } if (action === 'navigateUp' || action === 'navigateLeft') { const offset = action === 'navigateUp' ? IWT2_MENU_COLUMNS : 1 setSelectedIndex((current) => Math.max(0, current - offset)) } else if (action === 'navigateDown' || action === 'navigateRight') { const offset = action === 'navigateDown' ? IWT2_MENU_COLUMNS : 1 setSelectedIndex((current) => Math.min(MENU_ITEMS.length - 1, current + offset)) } }) function openMenuItem(item: (typeof MENU_ITEMS)[number]) { if (item.title === 'PvP') { setRoguelikeVariant('pvp') setScreen('roguelike') return } if (item.title === 'Roguelike') { setRoguelikeVariant('pve') setRoguelikeContentType((current) => current === 'stadium' ? 'dungeon' : current) } setScreen(item.screen) } if (screen === 'arena') { return ( setScreen('menu')} onSaveUpdated={setSave} /> ) } if (screen === 'roguelike-arena' && roguelikeRun) { return ( { setRoguelikeRun((current) => current ? { ...current, bossesDefeated: current.bossesDefeated + current.bossIds.length, ...buildRoguelikeChoices(save, current.variant), } : current) setScreen('roguelike-upgrade') }, stage: roguelikeRun.stage, variant: roguelikeRun.variant, }} save={save} onBack={() => setScreen('roguelike')} onMainMenu={() => setScreen('menu')} onPvpRequeue={() => { setScreen('roguelike') startIwt2RoguelikeRun() }} onSaveUpdated={setSave} /> ) } if (screen === 'roguelike-upgrade' && roguelikeRun) { return (
setScreen('roguelike')} onChoose={(buffId, debuffId) => { const nextRun = applyRoguelikeChoice(roguelikeRun, buffId, debuffId, save) setRoguelikeRun(nextRun) setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome') setScreen('roguelike-arena') }} />
) } if (screen === 'dungeons') { return (
setScreen('menu')} onDifficultyChange={setSelectedDungeonDifficultySlug} onOpenBoss={(bossId, difficulty) => { setArenaModeLabel(`${difficulty.name} Dungeon`) setArenaDifficulty(difficulty) setSelectedBossId(bossId) setScreen('arena') }} onPreviewBoss={setSelectedBossId} />
) } if (screen === 'roguelike') { return (
{ cancelIwt2PvpQueue() setScreen('menu') }} onCancelQueue={cancelIwt2PvpQueue} onContentTypeChange={setRoguelikeContentType} onStart={() => { startIwt2RoguelikeRun() }} onVariantChange={(nextVariant) => { cancelIwt2PvpQueue() setRoguelikeVariant(nextVariant) if (nextVariant === 'pve' && roguelikeContentType === 'stadium') { setRoguelikeContentType('dungeon') } }} queueing={pvpQueueing} queueMessage={pvpQueueMessage} />
) } if (screen === 'raids') { return (
setScreen('menu')} onDifficultyChange={setSelectedRaidDifficultySlug} onOpenBoss={(bossId, difficulty) => { setArenaModeLabel(`${difficulty.name} Raid`) setArenaDifficulty(difficulty) setSelectedBossId(bossId) setScreen('arena') }} onPreviewBoss={setSelectedBossId} />
) } if (screen === 'hunter-profile') { return (
setScreen('menu')} />
) } if (screen === 'gear-upgrade') { return (
setScreen('menu')} onBackToGameSelect={onBackToGameSelect} /> setScreen('menu')} onSaveUpdated={setSave} />
) } if (screen === 'customize-character') { return (
setScreen('menu')} onSaveUpdated={setSave} />
) } if (screen === 'cloud-save') { return (
setScreen('menu')} onSaveUpdated={setSave} />
) } if (screen === 'settings') { return (
setScreen('menu')} />
) } return (
{screen === 'menu' && (
{MENU_ITEMS.map((item, index) => ( ))}
)}
) function startIwt2RoguelikeRun() { cancelIwt2PvpQueue() if (roguelikeVariant !== 'pvp') { beginIwt2RoguelikeArena(roguelikeVariant, roguelikeContentType) return } const startStage = 1 setPvpQueueing(true) setPvpQueueMessage('Queuing for PvP...') cancelPvpQueueRef.current = startPvpQueueWithCpuFallback({ contentType: roguelikeContentType, startStage, gameMode: getGameMode(), liveMatchActive: () => false, onSearching: (message) => { setPvpQueueing(true) setPvpQueueMessage(message) }, onCpuMatch: (_difficulty, message) => { cancelPvpQueueRef.current = null setPvpQueueing(false) setPvpQueueMessage(message) beginIwt2RoguelikeArena('pvp', roguelikeContentType) }, onLiveMatch: (...liveMatchArgs) => { const message = liveMatchArgs[2] cancelPvpQueueRef.current = null setPvpQueueing(false) setPvpQueueMessage(message) beginIwt2RoguelikeArena('pvp', roguelikeContentType) }, messages: { offline: (difficulty) => `Offline mode. CPU ${difficulty} enters IWT2 ${formatRoguelikeContentType(roguelikeContentType)}.`, searching: `Searching IWT2 ${formatRoguelikeContentType(roguelikeContentType)} queue for 5s.`, notFound: (difficulty) => `No IWT2 opponent found after 5s. CPU ${difficulty} steps in.`, unavailable: (difficulty) => `PvP server unavailable. CPU ${difficulty} steps in.`, liveFound: () => `Opponent found. Starting IWT2 ${formatRoguelikeContentType(roguelikeContentType)} race.`, }, }) } function beginIwt2RoguelikeArena( variant: Iwt2RoguelikeVariant, contentType: Iwt2RoguelikeContentType, ) { const nextRun = createRoguelikeRun(save, variant, contentType) setRoguelikeRun(nextRun) setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome') setScreen('roguelike-arena') } } function formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) { if (contentType === 'raid') return 'Raid' if (contentType === 'stadium') return 'Stadium' return 'Dungeon' } function createRoguelikeRun( save: Iwt2Save, variant: Iwt2RoguelikeVariant, contentType: Iwt2RoguelikeContentType, ): Iwt2RoguelikeRunState { return { bossIds: createRoguelikeBossPair(variant, contentType, 1, 0), bossesDefeated: 0, buffs: [], contentType, debuffs: [], ...buildRoguelikeChoices(save, variant), stage: 1, variant, } } function buildRoguelikeChoices(save: Iwt2Save, variant: Iwt2RoguelikeVariant) { const abilities = abilitiesForHealer(save.character.healerStyle) const selfCatalog = [IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)] const debuffCatalog = buildIwt2OpponentDebuffChoices(abilities) return { selfChoices: variant === 'pvp' ? chooseRunChoicesWithPreferredFirst( selfCatalog, IWT2_ROGUELIKE_CHOICE_COUNT, isExtraTargetBuff, IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE, ) : chooseRunChoices(selfCatalog, IWT2_ROGUELIKE_CHOICE_COUNT), debuffChoices: variant === 'pvp' ? chooseRunChoices(debuffCatalog, IWT2_ROGUELIKE_CHOICE_COUNT) : [], } } function summarizeIwt2Buffs(save: Iwt2Save, buffs: Iwt2RoguelikeSelfBuffId[]) { if (buffs.length === 0) return '' const abilities = abilitiesForHealer(save.character.healerStyle) return summarizeChoiceStacks( buffs, [IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)], 'None', ) } function summarizeIwt2Debuffs(save: Iwt2Save, debuffs: Iwt2RoguelikeOpponentDebuffId[]) { if (debuffs.length === 0) return '' const abilities = abilitiesForHealer(save.character.healerStyle) return summarizeChoiceStacks( debuffs, buildIwt2OpponentDebuffChoices(abilities), 'None', ) } function applyRoguelikeChoice( run: Iwt2RoguelikeRunState, buffId: Iwt2RoguelikeSelfBuffId, debuffId: Iwt2RoguelikeOpponentDebuffId | undefined, save: Iwt2Save, ): Iwt2RoguelikeRunState { const nextDebuffs = debuffId ? [...run.debuffs, debuffId] : run.debuffs const nextBase = buffId === IWT2_REVIVE_PARTY_CHOICE.id ? { buffs: run.buffs, debuffs: nextDebuffs.slice(1), } : { buffs: [...run.buffs, buffId], debuffs: nextDebuffs, } return { ...run, ...nextBase, bossIds: createRoguelikeBossPair(run.variant, run.contentType, run.stage + 1, run.bossesDefeated), ...buildRoguelikeChoices(save, run.variant), stage: run.stage + 1, } } function createRoguelikeBossPair( variant: Iwt2RoguelikeVariant, contentType: Iwt2RoguelikeContentType, stage: number, bossesDefeated: number, ): Iwt2BossId[] { const weightedProgressionEnabled = variant === 'pve' ? IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED : contentType === 'stadium' ? IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED : IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED const bossPool = roguelikeBossPoolFor(variant, contentType) const count = bossesDefeated >= IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD ? 3 : 2 if (weightedProgressionEnabled) { return createIwt2WeightedRoguelikeBossPair(stage, Math.random, { bossPool, count }) } return createUniformIwt2RoguelikeBossPair(Math.random, { bossPool, count }) } function roguelikeBossPoolFor( variant: Iwt2RoguelikeVariant, contentType: Iwt2RoguelikeContentType, ): Iwt2BossId[] | undefined { if (variant !== 'pvp') return undefined if (contentType === 'stadium') { return IWT2_PVP_STADIUM_BOSS_ROSTER_LIMIT_ENABLED ? enabledIwt2RoguelikeBossPool(IWT2_PVP_STADIUM_ENABLED_BOSS_IDS) : undefined } return IWT2_PVP_ROGUELIKE_BOSS_ROSTER_LIMIT_ENABLED ? enabledIwt2RoguelikeBossPool(IWT2_PVP_ROGUELIKE_ENABLED_BOSS_IDS) : undefined } function chooseRunChoices(items: readonly T[], count: number): T[] { const pool = [...items] const choices: T[] = [] while (pool.length > 0 && choices.length < count) { const index = Math.floor(Math.random() * pool.length) const [choice] = pool.splice(index, 1) if (choice) choices.push(choice) } return choices } function chooseRunChoicesWithPreferredFirst( items: readonly T[], count: number, preferred: (item: T) => boolean, preferredChance: number, ): T[] { if (count <= 0) return [] const pool = [...items] const choices: T[] = [] const preferredPool = pool.filter(preferred) if (preferredPool.length > 0 && Math.random() < preferredChance) { const preferredChoice = preferredPool[Math.floor(Math.random() * preferredPool.length)] const preferredIndex = pool.indexOf(preferredChoice) if (preferredIndex >= 0) { const [choice] = pool.splice(preferredIndex, 1) if (choice) choices.push(choice) } } while (pool.length > 0 && choices.length < count) { const index = Math.floor(Math.random() * pool.length) const [choice] = pool.splice(index, 1) if (choice) choices.push(choice) } return choices } function isExtraTargetBuff(choice: Iwt2RoguelikeChoice) { return choice.id.endsWith('-extra-target') } function Iwt2Header({ onBack, onBackToGameSelect, save, title, }: { onBack?: () => void onBackToGameSelect: () => void save: Iwt2Save title?: string }) { return (
{title && {title}}
{save.character.name} IWT2 Level {save.character.level} {save.character.experience} XP
{onBack && ( )}
) } function buildIwt2SetupDualScreenState( screen: Iwt2Screen, selectedBossId: Iwt2BossId, save: Iwt2Save, difficulty: Iwt2Difficulty, ): DualScreenSetupState | null { if (screen !== 'dungeons' && screen !== 'raids') return null const boss = IWT2_BOSS_METADATA[selectedBossId] const raid = screen === 'raids' const coinReward = iwt2BossCoinRewardFor(selectedBossId, difficulty.slug) return { contentType: raid ? 'raid' : 'dungeon', description: raid ? `${boss.name} raid assignment. Tank holds aggro while party moves around modular boss mechanics. Reward: ${coinReward.name}.` : `${boss.name} arena. Heal the party through melee pressure, telegraphs, hazards, and stun recovery. Reward: ${coinReward.name}.`, difficultyName: difficulty.name, experience: Math.round(125 * difficulty.experienceMultiplier), initials: boss.icon, itemLevel: difficulty.droppedItemLevel, lockedReason: undefined, stats: { damage: `${difficulty.damageMultiplier.toFixed(2)}x`, health: `${difficulty.healthMultiplier.toFixed(2)}x`, loot: coinReward.name, xp: `${difficulty.experienceMultiplier.toFixed(1)}x`, }, subtitle: `${raid ? 'Raid' : 'Dungeon'} | 6 Players | ${IWT2_HEALER_METADATA[save.character.healerStyle].name}`, title: raid ? `${boss.name} Raid` : `${boss.name} Arena`, } } function currentSetupDifficulty( screen: Iwt2Screen, selectedDungeonDifficultySlug: string, selectedRaidDifficultySlug: string, ): Iwt2Difficulty { if (screen === 'raids') { return findIwt2Difficulty(IWT2_RAID_DIFFICULTIES, selectedRaidDifficultySlug) } return findIwt2Difficulty(IWT2_DUNGEON_DIFFICULTIES, selectedDungeonDifficultySlug) } function buildIwt2WorkshopDualScreenState( screen: Iwt2Screen, save: Iwt2Save, ): DualScreenWorkshopState | null { if (screen === 'customize-character') { const healer = IWT2_HEALER_METADATA[save.character.healerStyle] return { items: abilitiesForHealer(save.character.healerStyle).map((ability) => ({ glyph: ability.icon, meta: `${ability.manaCost} Mana | ${ability.cooldownSeconds}s cooldown`, status: `Slot ${ability.slot}`, title: ability.name, })), mode: 'class', subtitle: healer.description, summary: healer.name, title: 'Customize Character', } } return null }