import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import type { MovementVector } from '../../../input' import { useGameAction, useInput, useMovementVectorRef } from '../../../input' import { useDualScreen, useDualScreenPublisher, type DualScreenCombatState, } from '../../../dualScreen' import type { PartyMember, Role, Spell } from '../../../game' import { createInitialIwt2ArenaState, tickIwt2Arena, type Iwt2ArenaState, } from '../sim/arenaState' import type { Iwt2ArenaBounds, Iwt2EntityId } from '../sim' import { castIwt2HealerAbility } from '../sim' import { PhaserArena } from '../render/PhaserArena' import { recordIwt2BossKillReward, type Iwt2BossDropAward, type Iwt2BossPetAward, type Iwt2Save, } from '../save/iwt2Repository' import { AbilityBar } from '../components/AbilityBar' import { BossHud } from '../components/BossHud' import { PartyFrames } from '../components/PartyFrames' import { IWT2_CLASS_METADATA } from '../content/classes' import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls' import type { Iwt2Difficulty } from '../content/difficulties' import { abilitiesForHealer, type Iwt2HealerAbility, type Iwt2TriggeredAbilityEffect } from '../content/healerAbilities' import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses' import type { Iwt2RoguelikeContentType, Iwt2RoguelikeOpponentDebuffId, Iwt2RoguelikeSelfBuffId, Iwt2RoguelikeVariant, } from '../content/roguelike' import { createRoguelikePressureState, roguelikeIncomingDamageScale, } from '../sim/roguelikePressure' import { applyIwt2PveGearStats, applyIwt2PveGearToHealerAbilities } from '../sim/equipmentStats' import { IWT2_AEGIS_SCRIPT_DAMAGE_REDUCTION_BUFF_ID, IWT2_BARKSKIN_HOT_BONUS_BUFF_ID, IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID, } from '../content/roguelike' type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat' type OverlayAction = 'primary' | 'requeue' | 'menu' type OverlayNavEntry = { action: OverlayAction row: number } const DEFAULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [ { action: 'primary', row: 0 }, { action: 'menu', row: 1 }, ] const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [ { action: 'primary', row: 0 }, { action: 'requeue', row: 1 }, { action: 'menu', row: 2 }, ] const EMPTY_ROGUELIKE_BUFFS: Iwt2RoguelikeSelfBuffId[] = [] const IWT2_PVP_BOSS_HEALTH_MULTIPLIER = 0.7 const IWT2_TOP_PARTY_RAIL_WIDTH = 172 const IWT2_THOR_TOP_PARTY_RAIL_WIDTH = 154 const IWT2_THOR_TOP_BREAKPOINT_WIDTH = 1000 const IWT2_THOR_TOP_BREAKPOINT_HEIGHT = 620 type BossArenaScreenProps = { bossId: Iwt2BossId bossIds?: Iwt2BossId[] difficulty?: Iwt2Difficulty modeLabel?: string save: Iwt2Save onBack: () => void onMainMenu?: () => void onPvpRequeue?: () => void onSaveUpdated: (save: Iwt2Save) => void roguelikeRun?: { buffs: Iwt2RoguelikeSelfBuffId[] contentType: Iwt2RoguelikeContentType debuffs: Iwt2RoguelikeOpponentDebuffId[] onVictory: () => void stage: number variant: Iwt2RoguelikeVariant } } export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, onBack, onMainMenu, onPvpRequeue, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) { const bossMetadata = IWT2_BOSS_METADATA[bossId] const pvpRoguelike = roguelikeRun?.variant === 'pvp' const pveGearActive = roguelikeRun?.variant !== 'pvp' const bossHealthScale = roguelikeRun ? roguelikeBossHealthScale(roguelikeRun.stage) : 1 const difficultyHealthScale = difficulty?.healthMultiplier ?? 1 const difficultyDamageScale = difficulty?.damageMultiplier ?? 1 const roguelikeStage = roguelikeRun?.stage const roguelikeContentType = roguelikeRun?.contentType const roguelikeBuffs = roguelikeRun?.buffs ?? EMPTY_ROGUELIKE_BUFFS const roguelikeDamageScale = roguelikeRun ? roguelikeIncomingDamageScale(roguelikeRun.stage, roguelikeRun.contentType) : 1 const experienceMultiplier = difficulty?.experienceMultiplier ?? 1 const difficultySlug = difficulty?.slug ?? 'initiate' const pvpBossHealthScale = pvpRoguelike ? IWT2_PVP_BOSS_HEALTH_MULTIPLIER : 1 const combinedBossHealthScale = bossHealthScale * difficultyHealthScale * pvpBossHealthScale const combinedDamageScale = difficultyDamageScale * roguelikeDamageScale const arenaBounds = useMemo(() => createTopScreenArenaBounds(), []) const [arenaState, setArenaState] = useState(() => createArenaState( bossId, bossIds, combinedBossHealthScale, combinedDamageScale, roguelikeBuffs, createPressureState(roguelikeStage, roguelikeContentType), pveGearActive ? save.gearProgress : undefined, arenaBounds, )) const [opponentArenaState, setOpponentArenaState] = useState(() => ( pvpRoguelike ? createInitialIwt2ArenaState( bossId, bossIds, combinedBossHealthScale, combinedDamageScale, createPressureState(roguelikeStage, roguelikeContentType), arenaBounds, ) : null )) const [abilityCooldowns, setAbilityCooldowns] = useState>({}) const [status, setStatus] = useState('playing') const [selectedOverlayAction, setSelectedOverlayAction] = useState('primary') const [selectedPartyId, setSelectedPartyId] = useState('player-healer') const [dropAwards, setDropAwards] = useState([]) const [petAwards, setPetAwards] = useState([]) const stateRef = useRef(arenaState) const opponentStateRef = useRef(opponentArenaState) const statusRef = useRef(status) const selectedOverlayActionRef = useRef(selectedOverlayAction) const selectedPartyIdRef = useRef(selectedPartyId) const saveRef = useRef(save) const recordedKillIdsRef = useRef>(new Set()) const lastPublishTimeRef = useRef(0) const lastHudSignatureRef = useRef('') const abilityCooldownsRef = useRef>({}) const movementRef = useMovementVectorRef(status === 'playing') const { bindings, controllerIconStyle, directPartyTargeting, lastDevice, } = useInput() const { enabled: dualScreenEnabled } = useDualScreen() const activeBindings = bindings[lastDevice] useEffect(() => { stateRef.current = arenaState }, [arenaState]) useEffect(() => { opponentStateRef.current = opponentArenaState }, [opponentArenaState]) useEffect(() => { statusRef.current = status }, [status]) useEffect(() => { selectedOverlayActionRef.current = selectedOverlayAction }, [selectedOverlayAction]) useEffect(() => { selectedPartyIdRef.current = selectedPartyId }, [selectedPartyId]) useEffect(() => { saveRef.current = save }, [save]) const resetArena = useCallback(() => { const pressureState = createPressureState(roguelikeStage, roguelikeContentType) const next = createArenaState( bossId, bossIds, combinedBossHealthScale, combinedDamageScale, roguelikeBuffs, pressureState, pveGearActive ? save.gearProgress : undefined, arenaBounds, ) const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState( bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pressureState, arenaBounds, ) : null recordedKillIdsRef.current = new Set() abilityCooldownsRef.current = {} lastHudSignatureRef.current = arenaHudSignature(next) stateRef.current = next opponentStateRef.current = nextOpponentState setArenaState(next) setOpponentArenaState(nextOpponentState) setAbilityCooldowns({}) setDropAwards([]) setPetAwards([]) setSelectedOverlayAction('primary') setStatus('playing') }, [arenaBounds, bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pveGearActive, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage, save.gearProgress]) const showOverlay = useCallback((nextStatus: Exclude) => { setSelectedOverlayAction('primary') statusRef.current = nextStatus setStatus(nextStatus) }, []) const resumeArena = useCallback(() => { statusRef.current = 'playing' setStatus('playing') }, []) const abilities = useMemo( () => { const baseAbilities = abilitiesForHealer( save.character.healerStyle, pveGearActive ? save.gearProgress.healer.infusionAbilityId : null, ) const gearAbilities = pveGearActive ? applyIwt2PveGearToHealerAbilities(baseAbilities, save.gearProgress) : baseAbilities return applyRoguelikeModifiers( gearAbilities, roguelikeRun?.buffs ?? [], roguelikeRun?.debuffs ?? [], ) }, [pveGearActive, roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle, save.gearProgress], ) const castAbility = useCallback((ability: Iwt2HealerAbility) => { if (statusRef.current !== 'playing') return if ((abilityCooldownsRef.current[ability.id] ?? 0) > 0) return const result = castIwt2HealerAbility(stateRef.current, ability, selectedPartyIdRef.current) if (!result.cast) return const nextCooldowns = { ...abilityCooldownsRef.current, [ability.id]: ability.cooldownSeconds, } abilityCooldownsRef.current = nextCooldowns stateRef.current = result.state lastHudSignatureRef.current = arenaHudSignature(result.state) setAbilityCooldowns(nextCooldowns) setArenaState(result.state) }, []) const moveOverlaySelection = useCallback((action: string) => { setSelectedOverlayAction((current) => { const entries = overlayNavEntriesFor(statusRef.current, pvpRoguelike) const active = entries.find((entry) => entry.action === current) ?? entries[0] const candidates = entries.filter((entry) => { if (entry.action === current) return false if (action === 'navigateUp') return entry.row < active.row if (action === 'navigateDown') return entry.row > active.row return false }) if (candidates.length === 0) return current candidates.sort((a, b) => Math.abs(a.row - active.row) - Math.abs(b.row - active.row)) return candidates[0]?.action ?? current }) }, [pvpRoguelike]) const activateOverlayAction = useCallback((overlayAction = selectedOverlayActionRef.current) => { if (overlayAction === 'requeue') { onPvpRequeue?.() return } if (overlayAction === 'menu') { if (statusRef.current === 'paused') { const returnToMainMenu = onMainMenu ?? onBack returnToMainMenu() return } onBack() return } if (statusRef.current === 'paused') { resumeArena() return } if (statusRef.current === 'victory' && roguelikeRun) { roguelikeRun.onVictory() return } resetArena() }, [onBack, onMainMenu, onPvpRequeue, resetArena, resumeArena, roguelikeRun]) useGameAction((action, device) => { if (statusRef.current !== 'playing' && (device === 'controller' || device === 'pc')) { if (action.startsWith('navigate')) { moveOverlaySelection(action) return } if (action === 'confirm') { activateOverlayAction() return } if (action === 'back') { if (statusRef.current === 'paused') resumeArena() return } } if (action === 'pause' || action === 'back') { if (statusRef.current === 'playing') showOverlay('paused') else if (statusRef.current === 'paused') resumeArena() return } if (statusRef.current === 'playing' && action.startsWith('ability')) { const ability = abilities[IWT2_ABILITY_ACTIONS.indexOf(action)] if (ability) castAbility(ability) return } if ( device === 'controller' && statusRef.current === 'playing' && !directPartyTargeting && ( action === 'navigateUp' || action === 'navigateLeft' || action === 'previousTarget' || action === 'navigateDown' || action === 'navigateRight' || action === 'nextTarget' ) ) { const previous = action === 'navigateUp' || action === 'navigateLeft' || action === 'previousTarget' setSelectedPartyId((current) => nextTargetId(stateRef.current, current, previous ? -1 : 1)) return } if (action.startsWith('targetParty')) { if (statusRef.current !== 'playing') return const index = Number(action.replace('targetParty', '')) - 1 const target = stateRef.current.party[index] if (target) setSelectedPartyId(target.id) return } }) const onStep = useCallback((movement: MovementVector, dtSeconds: number) => { if (statusRef.current !== 'playing') return stateRef.current abilityCooldownsRef.current = tickCooldowns(abilityCooldownsRef.current, dtSeconds) const next = tickIwt2Arena(stateRef.current, { moveX: movement.x, moveY: movement.y, }, dtSeconds) stateRef.current = next let nextOpponentState = opponentStateRef.current if (pvpRoguelike && nextOpponentState) { const opponentMovement = iwt2OpponentMovement(nextOpponentState) nextOpponentState = tickIwt2Arena( nextOpponentState, { moveX: opponentMovement.x, moveY: opponentMovement.y, }, dtSeconds, ) opponentStateRef.current = nextOpponentState } const newlyDefeatedBosses = next.bosses.filter((boss) => boss.health <= 0 && !recordedKillIdsRef.current.has(boss.bossId)) if (newlyDefeatedBosses.length > 0) { const nextRecordedIds = new Set(recordedKillIdsRef.current) let updatedSave = saveRef.current const newDropAwards: Iwt2BossDropAward[] = [] const newPetAwards: Iwt2BossPetAward[] = [] for (const defeatedBoss of newlyDefeatedBosses) { nextRecordedIds.add(defeatedBoss.bossId) const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, { difficultySlug, experienceMultiplier, }) updatedSave = reward.save newDropAwards.push(reward.dropAwarded) if (reward.petAwarded) newPetAwards.push(reward.petAwarded) } recordedKillIdsRef.current = nextRecordedIds saveRef.current = updatedSave if (newDropAwards.length > 0) setDropAwards((current) => [...current, ...newDropAwards]) if (newPetAwards.length > 0) setPetAwards((current) => [...current, ...newPetAwards]) onSaveUpdated(updatedSave) } if (next.bosses.every((boss) => boss.health <= 0)) { if (pvpRoguelike && roguelikeRun) { statusRef.current = 'victory' setStatus('victory') roguelikeRun.onVictory() } else { showOverlay('victory') } } else if (next.party.every((member) => member.health <= 0)) { showOverlay('defeat') } const hudSignature = arenaHudSignature(next) if ( hudSignature !== lastHudSignatureRef.current || next.time - lastPublishTimeRef.current >= 0.08 || next.bosses.some((boss) => boss.health <= 0) ) { lastHudSignatureRef.current = hudSignature lastPublishTimeRef.current = next.time setArenaState(next) if (pvpRoguelike) setOpponentArenaState(nextOpponentState) setAbilityCooldowns(abilityCooldownsRef.current) } return next }, [difficultySlug, experienceMultiplier, onSaveUpdated, pvpRoguelike, roguelikeRun, showOverlay]) const targetBindings = directPartyTargeting ? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null) : undefined const playerMana = arenaState.party.find((member) => member.id === 'player-healer')?.mana ?? 0 const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0) const defeatedBossCount = arenaState.bosses.filter((boss) => boss.health <= 0).length const victoryExperience = Math.round(arenaState.bosses.length * 125 * experienceMultiplier) const bossTitle = formatBossEncounterTitle(arenaState.bosses) const overlayPrimaryLabel = status === 'paused' ? 'Resume' : status === 'victory' && roguelikeRun ? 'Choose Upgrade' : pvpRoguelike && status !== 'playing' ? 'Rematch' : 'Restart' const overlayTitle = status === 'victory' ? `${bossTitle} Down` : status === 'defeat' ? 'Party Defeated' : 'Arena Paused' const overlayEyebrow = status === 'victory' ? 'Hunt Complete' : status === 'defeat' ? 'Recovery' : 'Paused' const overlayTone = status === 'victory' ? 'is-victory' : status === 'defeat' ? 'is-defeat' : 'is-paused' const pauseTitle = arenaPauseTitle(roguelikeRun, modeLabel ?? bossMetadata.name) const pauseCopy = roguelikeRun?.variant === 'pvp' ? undefined : 'Combat is stopped. Continue the fight or return to the main menu.' const dualScreenState = useMemo( () => buildIwt2DualScreenCombatState({ abilities, arenaState, bindings: activeBindings, cooldowns: abilityCooldowns, controllerIconStyle, directPartyTargeting, modeLabel: modeLabel ?? 'Arena', opponentArenaState, playerMana, roguelikeRun, selectedPartyId, status, }), [ abilities, activeBindings, arenaState, abilityCooldowns, controllerIconStyle, directPartyTargeting, modeLabel, opponentArenaState, playerMana, roguelikeRun, selectedPartyId, status, ], ) useDualScreenPublisher(dualScreenState, dualScreenEnabled) return (
{status === 'paused' && (

Paused

{pauseTitle}

{pauseCopy &&

{pauseCopy}

}
)} {status !== 'playing' && status !== 'paused' && (
{arenaState.bosses.map((boss) => IWT2_BOSS_METADATA[boss.bossId].icon).join('')}

{overlayEyebrow}

{overlayTitle}

{roguelikeRun && (

{roguelikeRun.variant.toUpperCase()} {formatRoguelikeContentType(roguelikeRun.contentType)} Stage {roguelikeRun.stage}

)}
{formatArenaTime(arenaState.time)} Clear {alivePartyCount}/{arenaState.party.length} Standing {defeatedBossCount}/{arenaState.bosses.length} Bosses {Math.round(totalPartyDamage)} Damage
{status === 'victory' && (
+{victoryExperience} XP Log +{arenaState.bosses.length} {dropAwards.map((award) => ( {award.dropName}{award.quantity > 1 ? ` x${award.quantity}` : ''} ))} {petAwards.map((award) => ( {award.petName}{award.duplicate ? ` x${award.quantityAfter}` : ''} ))}
)}
{pvpRoguelike && ( )}
{lastDevice === 'controller' && ( D-pad selects, A confirms )}
)}
) } function formatArenaTime(seconds: number): string { const safeSeconds = Math.max(0, Math.floor(seconds)) const minutes = Math.floor(safeSeconds / 60) const remainder = safeSeconds % 60 return `${minutes}:${remainder.toString().padStart(2, '0')}` } function roguelikeBossHealthScale(stage: number): number { return 1 + Math.max(0, stage - 1) * 0.1 } function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] { if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES return DEFAULT_OVERLAY_NAV_ENTRIES } function arenaHudSignature(state: Iwt2ArenaState): string { return [ ...state.bosses.map((boss) => [ boss.id, Math.ceil(boss.health), boss.attackPhase, ].join(':')), state.hostileAdds.length, state.hazards.length, ...state.party.map((member) => [ member.id, Math.ceil(member.health), Math.ceil(member.shield), Math.ceil(member.mana), Math.ceil(Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds) * 10), member.hotEffects.map((effect) => `${effect.id}:${Math.ceil(effect.remainingSeconds)}:${Math.ceil(effect.nextTickInSeconds * 10)}`).join(','), ].join(':')), ].join('|') } function formatBossEncounterTitle(bosses: Iwt2ArenaState['bosses']): string { return bosses .map((boss) => IWT2_BOSS_METADATA[boss.bossId].name) .join(' + ') } function nextTargetId(state: Iwt2ArenaState, currentId: Iwt2EntityId, direction: -1 | 1): Iwt2EntityId { const living = state.party.filter((member) => member.health > 0) const targets = living.length > 0 ? living : state.party const currentIndex = Math.max(0, targets.findIndex((member) => member.id === currentId)) const nextIndex = (currentIndex + direction + targets.length) % targets.length return targets[nextIndex]?.id ?? currentId } function tickCooldowns(cooldowns: Record, dt: number) { let changed = false const next: Record = {} for (const [id, remaining] of Object.entries(cooldowns)) { const value = Math.max(0, remaining - dt) if (value !== remaining) changed = true if (value > 0) next[id] = value } return changed ? next : cooldowns } function createArenaState( bossId: Iwt2BossId, bossIds: Iwt2BossId[] | undefined, bossHealthScale: number, partyDamageTakenScale: number, buffs: Iwt2RoguelikeSelfBuffId[], roguelikePressure: ReturnType, gearProgress?: Iwt2Save['gearProgress'], bounds?: Iwt2ArenaBounds, ): Iwt2ArenaState { const baseState = createInitialIwt2ArenaState( bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure, bounds, ) const state = gearProgress ? applyIwt2PveGearStats(baseState, gearProgress) : baseState const shieldedDamageTakenMultiplier = shieldedDamageTakenMultiplierForBuffs(buffs) const shieldedHotHealingMultiplier = buffs.includes(IWT2_BARKSKIN_HOT_BONUS_BUFF_ID) ? 1.25 : undefined if (shieldedDamageTakenMultiplier === undefined && shieldedHotHealingMultiplier === undefined) return state return { ...state, party: state.party.map((member) => ({ ...member, shieldedDamageTakenMultiplier, shieldedHotHealingMultiplier, })), } } function createTopScreenArenaBounds(): Iwt2ArenaBounds { if (typeof window === 'undefined') return { width: 960, height: 540 } const thorTopLayout = window.innerWidth <= IWT2_THOR_TOP_BREAKPOINT_WIDTH && window.innerHeight <= IWT2_THOR_TOP_BREAKPOINT_HEIGHT const railWidth = thorTopLayout ? IWT2_THOR_TOP_PARTY_RAIL_WIDTH : IWT2_TOP_PARTY_RAIL_WIDTH return { width: Math.max(320, Math.round(window.innerWidth - railWidth)), height: Math.max(240, Math.round(window.innerHeight)), } } function createPressureState( stage: number | undefined, contentType: Iwt2RoguelikeContentType | undefined, ) { return stage && contentType ? createRoguelikePressureState(stage, contentType) : undefined } function shieldedDamageTakenMultiplierForBuffs(buffs: Iwt2RoguelikeSelfBuffId[]) { const multipliers: number[] = [] if (buffs.includes(IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID)) multipliers.push(0.5) if (buffs.includes(IWT2_AEGIS_SCRIPT_DAMAGE_REDUCTION_BUFF_ID)) multipliers.push(0.7) return multipliers.length > 0 ? Math.min(...multipliers) : undefined } function applyRoguelikeModifiers( abilities: Iwt2HealerAbility[], buffs: Iwt2RoguelikeSelfBuffId[], debuffs: Iwt2RoguelikeOpponentDebuffId[], ): Iwt2HealerAbility[] { if (buffs.length === 0 && debuffs.length === 0) return abilities const abilityById = new Map(abilities.map((ability) => [ability.id, ability])) const renewEffect = triggeredEffectFromAbility(abilityById.get('dawnweaver-renew')) const sunWardEffect = triggeredEffectFromAbility(abilityById.get('dawnweaver-sun-ward')) const halfSunWardEffect = triggeredEffectFromAbility(abilityById.get('dawnweaver-sun-ward'), 0.5) const seedOfLifeEffect = triggeredEffectFromAbility(abilityById.get('lifebinder-seed-of-life')) const barkskinEffect = triggeredEffectFromAbility(abilityById.get('lifebinder-barkskin')) const halfBarkskinEffect = triggeredEffectFromAbility(abilityById.get('lifebinder-barkskin'), 0.5) const mendingRuneEffect = triggeredEffectFromAbility(abilityById.get('runesage-mending-rune')) const aegisScriptEffect = triggeredEffectFromAbility(abilityById.get('runesage-aegis-script')) const halfAegisScriptEffect = triggeredEffectFromAbility(abilityById.get('runesage-aegis-script'), 0.5) return abilities.map((ability) => { const slot = String(ability.slot) const costDown = countStacks(buffs, `slot${slot}-cost-down`) const costUp = countStacks(debuffs, `opp-slot${slot}-cost-up`) const cooldownDown = countStacks(buffs, `slot${slot}-cooldown-down`) const cooldownUp = countStacks(debuffs, `opp-slot${slot}-cooldown-up`) const extraTargets = countStacks(buffs, `slot${slot}-extra-target`) const triggeredEffects = iwt2TriggeredEffectsForAbility({ ability, aegisScriptEffect, barkskinEffect, buffs, halfAegisScriptEffect, halfBarkskinEffect, halfSunWardEffect, mendingRuneEffect, renewEffect, seedOfLifeEffect, sunWardEffect, }) if (costDown === 0 && costUp === 0 && cooldownDown === 0 && cooldownUp === 0 && extraTargets === 0 && triggeredEffects.length === 0) return ability return { ...ability, cooldownSeconds: roundModifier(ability.cooldownSeconds * 0.75 ** cooldownDown * 1.25 ** cooldownUp), extraTargets: (ability.extraTargets ?? 0) + extraTargets, manaCost: Math.max(1, Math.ceil(ability.manaCost * 0.75 ** costDown * 1.25 ** costUp)), triggeredEffects: triggeredEffects.length > 0 ? triggeredEffects : ability.triggeredEffects, } }) } function iwt2TriggeredEffectsForAbility({ ability, aegisScriptEffect, barkskinEffect, buffs, halfAegisScriptEffect, halfBarkskinEffect, halfSunWardEffect, mendingRuneEffect, renewEffect, seedOfLifeEffect, sunWardEffect, }: { ability: Iwt2HealerAbility aegisScriptEffect: Iwt2TriggeredAbilityEffect | undefined barkskinEffect: Iwt2TriggeredAbilityEffect | undefined buffs: Iwt2RoguelikeSelfBuffId[] halfAegisScriptEffect: Iwt2TriggeredAbilityEffect | undefined halfBarkskinEffect: Iwt2TriggeredAbilityEffect | undefined halfSunWardEffect: Iwt2TriggeredAbilityEffect | undefined mendingRuneEffect: Iwt2TriggeredAbilityEffect | undefined renewEffect: Iwt2TriggeredAbilityEffect | undefined seedOfLifeEffect: Iwt2TriggeredAbilityEffect | undefined sunWardEffect: Iwt2TriggeredAbilityEffect | undefined }): Iwt2TriggeredAbilityEffect[] { const effects = [...ability.triggeredEffects ?? []] const addEffect = (effect: Iwt2TriggeredAbilityEffect | undefined) => { if (effect && !effects.some((existing) => existing.id === effect.id && existing.power === effect.power)) { effects.push(effect) } } if (ability.id === 'dawnweaver-mend') { if (buffs.includes('dawnweaver-mend-applies-renew')) addEffect(renewEffect) if (buffs.includes('dawnweaver-mend-applies-sun-ward')) addEffect(sunWardEffect) } else if (ability.id === 'dawnweaver-renew') { if (buffs.includes('dawnweaver-renew-applies-sun-ward')) addEffect(sunWardEffect) } else if (ability.id === 'dawnweaver-radiance') { if (buffs.includes('dawnweaver-radiance-applies-renew')) addEffect(renewEffect) if (buffs.includes('dawnweaver-radiance-applies-sun-ward')) addEffect(halfSunWardEffect) } else if (ability.id === 'dawnweaver-sun-ward') { if (buffs.includes('dawnweaver-sun-ward-applies-renew')) addEffect(renewEffect) } else if (ability.id === 'dawnweaver-purify') { if (buffs.includes('dawnweaver-purify-applies-renew')) addEffect(renewEffect) if (buffs.includes('dawnweaver-purify-applies-sun-ward')) addEffect(sunWardEffect) } else if (ability.id === 'lifebinder-verdant-touch') { if (buffs.includes('lifebinder-verdant-touch-applies-seed-of-life')) addEffect(seedOfLifeEffect) if (buffs.includes('lifebinder-verdant-touch-applies-barkskin')) addEffect(halfBarkskinEffect) } else if (ability.id === 'lifebinder-seed-of-life') { if (buffs.includes('lifebinder-seed-of-life-applies-barkskin')) addEffect(barkskinEffect) } else if (ability.id === 'lifebinder-wild-growth') { if (buffs.includes('lifebinder-wild-growth-applies-seed-of-life')) addEffect(seedOfLifeEffect) if (buffs.includes('lifebinder-wild-growth-applies-barkskin')) addEffect(halfBarkskinEffect) } else if (ability.id === 'lifebinder-barkskin') { if (buffs.includes('lifebinder-barkskin-applies-seed-of-life')) addEffect(seedOfLifeEffect) } else if (ability.id === 'lifebinder-purging-sap') { if (buffs.includes('lifebinder-purging-sap-applies-seed-of-life')) addEffect(seedOfLifeEffect) if (buffs.includes('lifebinder-purging-sap-applies-barkskin')) addEffect(barkskinEffect) } else if (ability.id === 'runesage-etched-mend') { if (buffs.includes('runesage-etched-mend-applies-mending-rune')) addEffect(mendingRuneEffect) if (buffs.includes('runesage-etched-mend-applies-aegis-script')) addEffect(halfAegisScriptEffect) } else if (ability.id === 'runesage-mending-rune') { if (buffs.includes('runesage-mending-rune-applies-aegis-script')) addEffect(aegisScriptEffect) } else if (ability.id === 'runesage-concordance') { if (buffs.includes('runesage-concordance-applies-mending-rune')) addEffect(mendingRuneEffect) if (buffs.includes('runesage-concordance-applies-aegis-script')) addEffect(halfAegisScriptEffect) } else if (ability.id === 'runesage-aegis-script') { if (buffs.includes('runesage-aegis-script-applies-mending-rune')) addEffect(mendingRuneEffect) } else if (ability.id === 'runesage-unravel') { if (buffs.includes('runesage-unravel-applies-mending-rune')) addEffect(mendingRuneEffect) if (buffs.includes('runesage-unravel-applies-aegis-script')) addEffect(aegisScriptEffect) } return effects } function triggeredEffectFromAbility( ability: Iwt2HealerAbility | undefined, strength = 1, ): Iwt2TriggeredAbilityEffect | undefined { if (!ability) return undefined return { effectType: ability.effectType, id: ability.id, kind: ability.kind, name: ability.name, power: Math.max(1, Math.round(ability.power * strength)), } } function countStacks(items: readonly string[], id: string) { return items.filter((item) => item === id).length } function roundModifier(value: number) { return Math.max(0.1, Math.round(value * 100) / 100) } function formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) { if (contentType === 'raid') return 'Raid' if (contentType === 'stadium') return 'Stadium' return 'Dungeon' } function arenaPauseTitle( roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined, fallbackTitle: string, ) { if (!roguelikeRun) return fallbackTitle if (roguelikeRun.contentType === 'stadium') return 'Stadium' if (roguelikeRun.variant === 'pvp') { return roguelikeRun.contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash' } return roguelikeRun.contentType === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike' } function buildIwt2DualScreenCombatState({ abilities, arenaState, bindings, cooldowns, controllerIconStyle, directPartyTargeting, modeLabel, opponentArenaState, playerMana, roguelikeRun, selectedPartyId, status, }: { abilities: Iwt2HealerAbility[] arenaState: Iwt2ArenaState bindings: DualScreenCombatState['bindings'] cooldowns: Record controllerIconStyle: DualScreenCombatState['controllerIconStyle'] directPartyTargeting: boolean modeLabel: string opponentArenaState: Iwt2ArenaState | null playerMana: number roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined selectedPartyId: Iwt2EntityId status: ArenaStatus }): DualScreenCombatState { const opponentHealer = opponentArenaState?.party.find((member) => member.id === 'player-healer') const arenaBossHealth = totalBossHealth(arenaState) const arenaBossMaxHealth = totalBossMaxHealth(arenaState) const pvpOpponentState = roguelikeRun?.variant === 'pvp' && opponentArenaState ? { opponentBuffSummary: `Stage ${roguelikeRun.stage}`, opponentClassName: `CPU Healer | ${formatRoguelikeContentType(roguelikeRun.contentType)}`, opponentDebuffSummary: formatIwt2DebuffSummary(roguelikeRun.debuffs), opponentArena: toDualScreenOpponentArena(opponentArenaState), opponentEnemyHealth: totalBossHealth(opponentArenaState), opponentMaxResource: opponentHealer?.maxMana ?? 100, opponentName: 'CPU Rival', opponentParty: opponentArenaState.party.map((member) => toDualScreenPartyMember(member, true)), opponentResource: opponentHealer?.mana ?? 100, opponentResourceName: 'Mana', } : {} return { ...pvpOpponentState, bindings, contentName: modeLabel, controllerIconStyle, difficultyName: 'IWT2', directPartyTargeting, dungeonName: `${formatBossEncounterTitle(arenaState.bosses)} Arena`, encounterCount: 1, encounterDescription: arenaState.boss.attackPhase, encounterHealth: arenaBossHealth, encounterIndex: 0, encounterIsBoss: true, encounterMaxHealth: arenaBossMaxHealth, encounterName: formatBossEncounterTitle(arenaState.bosses), floatingTexts: [], maxResource: 100, party: arenaState.party.map((member) => toDualScreenPartyMember(member)), partySize: arenaState.party.length, paused: status === 'paused', playerIsAlive: (arenaState.party.find((member) => member.id === 'player-healer')?.health ?? 0) > 0, resource: playerMana, resourceName: 'Mana', selectedId: selectedPartyId, speedMultiplier: 1, spells: abilities.map((ability, slotIndex) => toDualScreenSpell(ability, slotIndex, cooldowns[ability.id] ?? 0)), status: status === 'victory' ? 'won' : status === 'defeat' ? 'lost' : 'playing', targetGroup: 0, } } function totalBossHealth(state: Iwt2ArenaState): number { return state.bosses.reduce((total, boss) => total + Math.max(0, boss.health), 0) } function totalBossMaxHealth(state: Iwt2ArenaState): number { return state.bosses.reduce((total, boss) => total + boss.maxHealth, 0) } function toDualScreenOpponentArena(state: Iwt2ArenaState): NonNullable { return { bounds: state.bounds, bosses: state.bosses.map((boss) => { const metadata = IWT2_BOSS_METADATA[boss.bossId] return { id: boss.id, name: metadata.name, icon: metadata.icon, color: metadata.color, x: boss.position.x, y: boss.position.y, radius: boss.radius, health: boss.health, maxHealth: boss.maxHealth, } }), party: state.party.map((member) => { const metadata = IWT2_CLASS_METADATA[member.classId] return { id: member.id, icon: metadata.icon, color: metadata.color, x: member.position.x, y: member.position.y, radius: member.radius, health: member.health, maxHealth: member.maxHealth, isHealer: member.id === 'player-healer', } }), } } function toDualScreenPartyMember(member: Iwt2ArenaState['party'][number], opponent = false): PartyMember { const metadata = IWT2_CLASS_METADATA[member.classId] return { bounceHeals: [], health: member.health, hotEffects: member.hotEffects.map((effect) => ({ id: effect.id, label: effect.label, power: effect.power, spellId: effect.id, ticks: Math.ceil(effect.remainingSeconds), })), hotTicks: member.hotEffects.length, id: opponent ? `opponent-${member.id}` : member.id, maxHealth: member.maxHealth, name: opponent ? opponentPartyName(metadata.name) : metadata.name, role: toDualScreenRole(metadata.role), shield: member.shield, } } function opponentPartyName(name: string): string { if (name === 'Player Healer') return 'CPU Healer' return `CPU ${name.replace(' Tank', '')}` } function toDualScreenRole(role: (typeof IWT2_CLASS_METADATA)[keyof typeof IWT2_CLASS_METADATA]['role']): Role { if (role === 'tank') return 'Tank' if (role === 'healer') return 'Healer' return 'Damage' } function toDualScreenSpell( ability: Iwt2HealerAbility, slotIndex: number, remaining: number, ): Spell & { slotIndex: number; remaining: number } { return { cooldown: ability.cooldownSeconds, cost: ability.manaCost, description: 'IWT2 arena ability', effectType: ability.effectType, glyph: ability.icon, id: ability.id, key: String(ability.slot), kind: ability.kind, name: ability.name, power: ability.power, remaining, slotIndex, } } function iwt2OpponentMovement(state: Iwt2ArenaState): MovementVector { const healer = state.party.find((member) => member.id === 'player-healer') if (!healer || healer.health <= 0) return { x: 0, y: 0 } const orbitSeconds = Math.max(1, state.time) return normalizedArenaMovement({ x: Math.cos(orbitSeconds * 0.75), y: Math.sin(orbitSeconds * 0.75) * 0.55, }) } function normalizedArenaMovement(vector: MovementVector): MovementVector { const magnitude = Math.hypot(vector.x, vector.y) if (magnitude <= 1) return vector return { x: vector.x / magnitude, y: vector.y / magnitude, } } function formatIwt2DebuffSummary(debuffs: readonly string[]): string { if (debuffs.length === 0) return 'none' return debuffs .map((debuff) => debuff .replace('opp-', '') .replaceAll('-', ' ')) .join(', ') }