Files
i-want-to-heal/src/modes/iwt2/IWantToHeal2App.tsx
T
2026-07-08 23:33:57 -04:00

788 lines
25 KiB
TypeScript

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<Iwt2RoguelikeChoice<Iwt2RoguelikeOpponentDebuffId>>
selfChoices: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>>
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<Iwt2Screen>('menu')
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
const [selectedIndex, setSelectedIndex] = useState(0)
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
const [arenaDifficulty, setArenaDifficulty] = useState<Iwt2Difficulty>(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<Iwt2RoguelikeVariant>('pve')
const [roguelikeContentType, setRoguelikeContentType] = useState<Iwt2RoguelikeContentType>('dungeon')
const [roguelikeRun, setRoguelikeRun] = useState<Iwt2RoguelikeRunState | null>(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<DualScreenSetupState | null>(
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save, currentSetupDifficulty(screen, selectedDungeonDifficultySlug, selectedRaidDifficultySlug)),
[save, screen, selectedBossId, selectedDungeonDifficultySlug, selectedRaidDifficultySlug],
)
const workshopDualScreenState = useMemo<DualScreenWorkshopState | null>(
() => 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 (
<BossArenaScreen
bossId={selectedBossId}
difficulty={arenaDifficulty}
key={`arena-${selectedBossId}-${arenaModeLabel}-${arenaDifficulty.slug}`}
modeLabel={arenaModeLabel}
save={save}
onBack={() => setScreen('menu')}
onSaveUpdated={setSave}
/>
)
}
if (screen === 'roguelike-arena' && roguelikeRun) {
return (
<BossArenaScreen
bossIds={roguelikeRun.bossIds}
bossId={selectedBossId}
key={`roguelike-${roguelikeRun.variant}-${roguelikeRun.contentType}-${roguelikeRun.stage}-${roguelikeRun.bossIds.join('-')}`}
roguelikeRun={{
buffs: roguelikeRun.buffs,
contentType: roguelikeRun.contentType,
debuffs: roguelikeRun.debuffs,
greenCoinThreshold: IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD,
bossesDefeated: roguelikeRun.bossesDefeated,
onVictory: () => {
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 (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2RoguelikeUpgradeScreen
activeBuffSummary={summarizeIwt2Buffs(save, roguelikeRun.buffs)}
activeDebuffSummary={summarizeIwt2Debuffs(save, roguelikeRun.debuffs)}
contentType={roguelikeRun.contentType}
debuffChoices={roguelikeRun.debuffChoices}
selfChoices={roguelikeRun.selfChoices}
stage={roguelikeRun.stage}
variant={roguelikeRun.variant}
onBack={() => setScreen('roguelike')}
onChoose={(buffId, debuffId) => {
const nextRun = applyRoguelikeChoice(roguelikeRun, buffId, debuffId, save)
setRoguelikeRun(nextRun)
setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
setScreen('roguelike-arena')
}}
/>
</main>
)
}
if (screen === 'dungeons') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2DungeonsScreen
difficultySlug={selectedDungeonDifficultySlug}
save={save}
onBack={() => setScreen('menu')}
onDifficultyChange={setSelectedDungeonDifficultySlug}
onOpenBoss={(bossId, difficulty) => {
setArenaModeLabel(`${difficulty.name} Dungeon`)
setArenaDifficulty(difficulty)
setSelectedBossId(bossId)
setScreen('arena')
}}
onPreviewBoss={setSelectedBossId}
/>
</main>
)
}
if (screen === 'roguelike') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2RoguelikeScreen
contentType={roguelikeContentType}
variant={roguelikeVariant}
onBack={() => {
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}
/>
</main>
)
}
if (screen === 'raids') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2ModeScreen
difficultySlug={selectedRaidDifficultySlug}
mode="Raids"
save={save}
onBack={() => setScreen('menu')}
onDifficultyChange={setSelectedRaidDifficultySlug}
onOpenBoss={(bossId, difficulty) => {
setArenaModeLabel(`${difficulty.name} Raid`)
setArenaDifficulty(difficulty)
setSelectedBossId(bossId)
setScreen('arena')
}}
onPreviewBoss={setSelectedBossId}
/>
</main>
)
}
if (screen === 'hunter-profile') {
return (
<main className="game-shell iwt2-shell">
<Iwt2HunterProfileScreen save={save} onBack={() => setScreen('menu')} />
</main>
)
}
if (screen === 'gear-upgrade') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header
save={save}
title="Gear Upgrade"
onBack={() => setScreen('menu')}
onBackToGameSelect={onBackToGameSelect}
/>
<Iwt2GearUpgradeScreen
save={save}
onBack={() => setScreen('menu')}
onSaveUpdated={setSave}
/>
</main>
)
}
if (screen === 'customize-character') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2CustomizeCharacterScreen
save={save}
onBack={() => setScreen('menu')}
onSaveUpdated={setSave}
/>
</main>
)
}
if (screen === 'cloud-save') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2CloudSaveScreen
save={save}
onlineBackupsAvailable={onlineBackupsAvailable}
onBack={() => setScreen('menu')}
onSaveUpdated={setSave}
/>
</main>
)
}
if (screen === 'settings') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2SettingsScreen onBack={() => setScreen('menu')} />
</main>
)
}
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
{screen === 'menu' && (
<section className="iwt2-menu-screen" data-game-nav-active="true">
<div className="iwt2-menu-grid">
{MENU_ITEMS.map((item, index) => (
<button
className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`}
aria-label={`${item.title}. ${item.description}`}
data-controller-nav="skip"
data-game-selected={selectedIndex === index ? 'true' : undefined}
key={`${item.screen}-${item.title}`}
onClick={() => openMenuItem(item)}
onPointerDown={() => setSelectedIndex(index)}
type="button"
>
<span>{item.glyph}</span>
<div>
<strong>{item.title}</strong>
<small>{item.description}</small>
</div>
</button>
))}
</div>
</section>
)}
</main>
)
function startIwt2RoguelikeRun() {
cancelIwt2PvpQueue()
if (roguelikeVariant !== 'pvp') {
beginIwt2RoguelikeArena(roguelikeVariant, roguelikeContentType)
return
}
const startStage = 1
setPvpQueueing(true)
setPvpQueueMessage('Queuing for PvP...')
cancelPvpQueueRef.current = startPvpQueueWithCpuFallback<unknown>({
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<T>(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<T>(
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<Iwt2RoguelikeSelfBuffId>) {
return choice.id.endsWith('-extra-target')
}
function Iwt2Header({
onBack,
onBackToGameSelect,
save,
title,
}: {
onBack?: () => void
onBackToGameSelect: () => void
save: Iwt2Save
title?: string
}) {
return (
<header className="topbar app-header">
<button className="brand-button" onClick={onBackToGameSelect} type="button">
<strong>Games</strong>
</button>
{title && <strong className="iwt2-header-title">{title}</strong>}
<div className="character-summary">
<strong>{save.character.name}</strong>
<small>IWT2 Level {save.character.level}</small>
<small>{save.character.experience} XP</small>
</div>
{onBack && (
<button className="back-button iwt2-header-back" data-controller-nav="skip" onClick={onBack} type="button">
Back
</button>
)}
</header>
)
}
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
}