739 lines
23 KiB
TypeScript
739 lines
23 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'
|
|
|
|
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
|
|
|
|
type Iwt2RoguelikeRunState = {
|
|
bossIds: Iwt2BossId[]
|
|
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,
|
|
onVictory: () => {
|
|
setRoguelikeRun((current) => current
|
|
? {
|
|
...current,
|
|
...buildRoguelikeChoices(save, current.variant),
|
|
}
|
|
: current)
|
|
setScreen('roguelike-upgrade')
|
|
},
|
|
stage: roguelikeRun.stage,
|
|
variant: roguelikeRun.variant,
|
|
}}
|
|
save={save}
|
|
onBack={() => setScreen('roguelike')}
|
|
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: createRandomRoguelikeBossPair(),
|
|
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: createRandomRoguelikeBossPair(),
|
|
...buildRoguelikeChoices(save, run.variant),
|
|
stage: run.stage + 1,
|
|
}
|
|
}
|
|
|
|
function createRandomRoguelikeBossPair(): Iwt2BossId[] {
|
|
const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
|
|
return chooseRunChoices(pool, 2)
|
|
}
|
|
|
|
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
|
|
}
|