Files
i-want-to-heal/src/components/CombatScreen.tsx
T
2026-07-06 11:46:53 -04:00

1585 lines
58 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
completeDungeon,
completeRoguelike,
loadProfile,
type DungeonReward,
rollEncounterLoot,
type LootRoll,
} from '../profile'
import {
INITIAL_PARTY,
RAID_PARTY,
DEFAULT_GROUP_HEAL_TARGETS,
partyDamageOutput,
tankPressureTargets,
type CombatLogEntry,
type PartyMember,
type Spell,
} from '../game'
import type {
CharacterProfile,
Difficulty,
Dungeon,
DungeonEncounter,
} from '../profile'
import {
chooseRandom,
clamp,
effectiveMaxHealth,
healMember,
toCombatSpell,
} from '../combat/rules'
import {
nextTargetGroupSelection,
} from '../combat/targeting'
import { buildRoguelikeSegment } from '../combat/encounters'
import {
buildSelfSlotUpgradeChoices,
summarizeChoiceStacks,
} from '../combat/roguelikeUpgrades'
import { createStackCounts, type StackCounts } from '../combat/stackCounts'
import {
hasSpellStack,
spellCooldownMultiplier,
spellExtraTargets,
spellPowerMultiplier,
spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers'
import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick, attachJumpedBounceHeals } from '../combat/combatEngine'
import { applyCastStateUpdate } from '../combat/combatStateTransitions'
import { buildCombatDualScreenState } from '../combat/dualScreenPayloads'
import {
createPveCombatState,
createPveRoguelikeRunStart,
} from '../combat/pveRoguelikeRunSetup'
import type { RunBossCoinAward } from '../combat/rewardSummaries'
import {
appendCombatLog,
type BasicFloatingCombatText,
} from '../combat/combatPresentation'
import {
canCastSpell,
pruneExpiredCooldowns,
reduceCooldown,
} from '../combat/spellCasting'
import {
applySpellEffectProfile,
buildSpellTargetPlan,
type SpellEffectProfile,
} from '../combat/spellEffects'
import {
useGameAction,
useInput,
} from '../input'
import { useFloatingCombatText } from '../hooks/useFloatingCombatText'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { usePartyTargeting } from '../hooks/usePartyTargeting'
import { PartyMemberFrame } from './PartyFrames'
import { ResourceBar, SpellBar } from './SpellBars'
import { barFillStyle } from './barStyles'
import { BonusItemReward, LootRollList, PvpRunLootList, RewardXpSummary } from './RewardPanels'
import { ResultScreen } from './ResultScreen'
import {
DualScreenTopCombat,
useDualScreen,
useDualScreenPublisher,
} from '../dualScreen'
const TICK_MS = 700
type RoguelikeMode = 'dungeon' | 'raid'
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
type RoguelikeAbilityLabelMode = 'ability' | 'slot'
type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
type RoguelikeMechanic =
| 'party-pulse'
| 'searing-mark'
| 'max-health-cut'
| 'healing-reduction'
| 'tank-buster'
| 'resource-drain'
| 'ramping-poison'
type RoguelikeEncounter = DungeonEncounter & {
roguelikeMechanics?: RoguelikeMechanic[]
}
type RoguelikeUpgradeId =
| `slot${SlotKey}-extra-target`
| `slot${SlotKey}-cost-down`
| `slot${SlotKey}-cooldown-down`
| 'fifth-cast-free'
| 'group-heal-boost'
| 'shield-boost'
type RoguelikeUpgrade = {
id: RoguelikeUpgradeId
name: string
description: string
}
type SinglePlayerCombatState = {
party: PartyMember[]
resource: number
enemyHealth: number
cooldowns: Record<string, number>
elapsedTicks: number
castsTowardFree: number
freeCastReady: boolean
}
const ROGUELIKE_MECHANICS: RoguelikeMechanic[] = [
'party-pulse',
'searing-mark',
'max-health-cut',
'healing-reduction',
'tank-buster',
'resource-drain',
'ramping-poison',
]
function buildRoguelikeUpgrades(
spells: Spell[],
labelMode: RoguelikeAbilityLabelMode,
): RoguelikeUpgrade[] {
const slotUpgrades = buildSelfSlotUpgradeChoices<RoguelikeUpgradeId>({
slots: ['1', '2', '3', '4', '5', '6'] as SlotKey[],
spells,
labelMode,
})
return [
...slotUpgrades,
{
id: 'fifth-cast-free',
name: 'Stored Momentum',
description: 'After 5 spell casts, your next cast is free.',
},
{
id: 'group-heal-boost',
name: 'Wide Radiance',
description: 'Party healing is 25% stronger.',
},
{
id: 'shield-boost',
name: 'Dense Shields',
description: 'Shield absorbs are 25% stronger.',
},
]
}
function summarizeUpgradeStacks(
upgrades: RoguelikeUpgrade[],
catalog: RoguelikeUpgrade[],
) {
return summarizeChoiceStacks(
upgrades.map((upgrade) => upgrade.id),
catalog,
)
}
function cooldownMultiplier(spell: Spell, upgrades: StackCounts<RoguelikeUpgradeId>) {
return spellCooldownMultiplier(spell, {
stacks: upgrades,
id: (slot) => `slot${slot as SlotKey}-cooldown-down` as RoguelikeUpgradeId,
})
}
function spellResourceCost(spell: Spell, upgrades: StackCounts<RoguelikeUpgradeId>, freeCastReady: boolean) {
return modifiedSpellResourceCost({
spell,
costDown: {
stacks: upgrades,
id: (slot) => `slot${slot as SlotKey}-cost-down` as RoguelikeUpgradeId,
},
freeCastReady,
freeCast: {
stacks: upgrades,
id: 'fifth-cast-free',
},
})
}
function getCurrentPart(encounterIndex: number) {
return Math.floor(encounterIndex / 3) + 1
}
function chooseOtherLivingMember(living: PartyMember[], member: PartyMember) {
if (living.length <= 1) return member
let remaining = Math.floor(Math.random() * (living.length - 1))
for (const candidate of living) {
if (candidate.id === member.id) continue
if (remaining === 0) return candidate
remaining -= 1
}
return member
}
function mechanicLabel(mechanic: RoguelikeMechanic) {
const labels: Record<RoguelikeMechanic, string> = {
'party-pulse': 'party pulse',
'searing-mark': 'damage mark',
'max-health-cut': 'max health cut',
'healing-reduction': 'healing reduction',
'tank-buster': 'tank buster',
'resource-drain': 'resource drain',
'ramping-poison': 'ramping poison',
}
return labels[mechanic]
}
function makeRoguelikeSegment(
pool: DungeonEncounter[],
stage: number,
difficulty: Difficulty,
mode: RoguelikeMode,
): RoguelikeEncounter[] {
const mechanics = chooseRandom(ROGUELIKE_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
const stageOneDamageScale = 0.58 + (mode === 'raid' ? 0.13 : 0.1)
return buildRoguelikeSegment<RoguelikeMechanic, { roguelikeMechanics: RoguelikeMechanic[] }>({
pool,
stage,
mechanics,
trashCandidateCount: (trashCount) => Math.min(trashCount, 4 + stage * (mode === 'raid' ? 1 : 2)),
bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 1) / 2)),
healthScale: difficulty.healthMultiplier * (0.64 + stage * (mode === 'raid' ? 0.15 : 0.11)),
damageScale: difficulty.damageMultiplier * stageOneDamageScale,
partyDamageScale: 0.95,
idBase: 900000,
bossDescription: (selectedMechanics) => `Roguelike boss with ${selectedMechanics.map(mechanicLabel).join(', ')}.`,
extraFields: (_encounter, isBoss, selectedMechanics) => ({
roguelikeMechanics: isBoss ? selectedMechanics : [],
}),
}) as RoguelikeEncounter[]
}
export function CombatScreen({
difficulty,
dungeon,
hardMode = false,
marathonMode = false,
profile,
startPart = 1,
roguelikeMode,
roguelikeUpgradeTiming = 'boss',
roguelikeAbilityLabelMode = 'ability',
roguelikeEncounterPool,
onExit,
onMainMenu = onExit,
onProfileUpdated,
}: {
difficulty: Difficulty
dungeon: Dungeon
hardMode?: boolean
marathonMode?: boolean
profile: CharacterProfile
startPart?: number
roguelikeMode?: RoguelikeMode
roguelikeUpgradeTiming?: RoguelikeUpgradeTiming
roguelikeAbilityLabelMode?: RoguelikeAbilityLabelMode
roguelikeEncounterPool?: DungeonEncounter[]
onExit: () => void
onMainMenu?: () => void
onProfileUpdated: (profile: CharacterProfile) => void
}) {
const staticEncounters = useMemo(
() => dungeon.encounters.map((encounter) => ({
...encounter,
maxHealth: Math.round(encounter.maxHealth * difficulty.healthMultiplier),
damage: Math.round(encounter.damage * difficulty.damageMultiplier),
tankDamage: Math.round(encounter.tankDamage * difficulty.damageMultiplier),
})),
[difficulty.damageMultiplier, difficulty.healthMultiplier, dungeon.encounters],
)
const isRoguelike = Boolean(roguelikeMode)
const roguelikePool = roguelikeEncounterPool ?? dungeon.encounters
const [roguelikeStage, setRoguelikeStage] = useState(1)
const [roguelikeEncounters, setRoguelikeEncounters] = useState<RoguelikeEncounter[]>(() =>
roguelikeMode ? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode) : [],
)
const encounters = isRoguelike ? roguelikeEncounters : staticEncounters
const gameClass = profile.classes.find(
(candidate) => candidate.id === profile.character.classId,
)!
const healingPower = isRoguelike ? 0 : profile.gearStats.healingPower
const spells = useMemo(() => {
const abilityById = new Map(gameClass.spells.map((ability) => [ability.id, ability]))
return profile.abilitySlots.flatMap((abilityId, index) => {
const ability = abilityId === null ? undefined : abilityById.get(abilityId)
return ability
? [toCombatSpell(ability, String(index + 1), healingPower)]
: []
})
}, [gameClass.spells, healingPower, profile.abilitySlots])
const spellByKey = useMemo(
() => new Map(spells.map((spell) => [spell.key, spell])),
[spells],
)
const spellByName = useMemo(
() => new Map(spells.map((spell) => [spell.name, spell])),
[spells],
)
const effectSpellByName = useMemo(
() => new Map(gameClass.spells.map((ability) => [
ability.name,
toCombatSpell(ability, `effect-${ability.id}`, healingPower),
])),
[gameClass.spells, healingPower],
)
const roguelikeUpgradeCatalog = useMemo(
() => buildRoguelikeUpgrades(spells, roguelikeAbilityLabelMode),
[roguelikeAbilityLabelMode, spells],
)
const maxResource = gameClass.maxResource + (isRoguelike ? 0 : profile.gearStats.maxResourceBonus)
const partyTemplate = useMemo(
() => (dungeon.partySize >= 10 ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
...member,
name: member.id === 'mira' ? profile.character.name : member.name,
})),
[dungeon.partySize, profile.character.name],
)
const sectionName = isRoguelike ? 'Stage' : 'Run'
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
const initialEncounterIndex = (startPart - 1) * 3
const enemyCount = hardMode ? 2 : 1
const initialCombatState = useMemo<SinglePlayerCombatState>(() => createPveCombatState({
partyTemplate,
maxResource,
encounter: encounters[initialEncounterIndex],
enemyCount,
}), [encounters, enemyCount, initialEncounterIndex, maxResource, partyTemplate])
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'marathon-choice' | 'upgrade-choice'>('playing')
const [paused, setPaused] = useState(false)
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
const [log, setLog] = useState<CombatLogEntry[]>([
{ id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
])
const [reward, setReward] = useState<DungeonReward | null>(null)
const [rewardError, setRewardError] = useState('')
const [lootRolls, setLootRolls] = useState<LootRoll[]>([])
const [roguelikeBossCoins, setRoguelikeBossCoins] = useState<RunBossCoinAward[]>([])
const [showEndLog, setShowEndLog] = useState(false)
const {
floatingTexts,
floatingTextsByMember,
addFloatingText,
clearFloatingTexts,
} = useFloatingCombatText<BasicFloatingCombatText>()
const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([])
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
const [marathonBossesDefeated, setMarathonBossesDefeated] = useState(0)
const rewardClaimedRef = useRef(false)
const profileRefreshedRef = useRef(false)
const rolledEncounterIdsRef = useRef(new Set<string>())
const recordedBossKillIdsRef = useRef(new Set<string>())
const runTokenRef = useRef(crypto.randomUUID())
const resourceSpentRef = useRef(0)
const runStartedAtRef = useRef(0)
const partStartTimesRef = useRef<Record<number, number>>({})
const nextLogId = useRef(2)
const marathonBossesDefeatedRef = useRef(0)
const combatRef = useRef(initialCombatState)
const selectedIdRef = useRef(partyTemplate[0].id)
const runCombatTickRef = useRef<() => void>(() => {})
const combatClockActiveRef = useRef(false)
const lastCombatTickAtRef = useRef(0)
const statusRef = useRef(status)
const pausedRef = useRef(paused)
const speedMultiplierRef = useRef<1 | 2>(speedMultiplier)
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
const encounter = encounters[encounterIndex]
const encounterMaxHealth = encounter.maxHealth * enemyCount
const currentPart = getCurrentPart(encounterIndex)
const completedSections = dungeon.contentType === 'raid'
? profile.completedRaidPhases
: profile.completedDungeonParts
const canContinueAfterPart = !hardMode || completedSections >= currentPart + 1
const firstEncounterIndex = (startPart - 1) * 3
const expectedLootRolls = useMemo(
() => encounters
.slice(firstEncounterIndex, encounterIndex + 1)
.filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id))
.length * enemyCount,
[difficulty.id, encounters, encounterIndex, enemyCount, firstEncounterIndex],
)
const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2
const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1
const playerHealer = party.find((member) => member.id === 'mira')
const playerIsAlive = Boolean(playerHealer && playerHealer.health > 0)
const upgradesEveryEncounter = roguelikeUpgradeTiming === 'encounter'
const roguelikeUpgradeCounts = useMemo(
() => createStackCounts(roguelikeUpgrades.map((upgrade) => upgrade.id)),
[roguelikeUpgrades],
)
const activeEffects = useMemo(
() => {
const effects = new Set<string>(
gameClass.talents
.filter((talent) => talent.rank > 0)
.map((talent) => talent.effectType),
)
if (!isRoguelike) {
profile.setBonuses
.filter((bonus) => bonus.active)
.forEach((bonus) => effects.add(bonus.effectType))
}
return effects
},
[gameClass.talents, isRoguelike, profile.setBonuses],
)
const {
bindings,
controllerIconStyle,
directPartyTargeting,
lastDevice,
} = useInput()
const {
enabled: dualScreenEnabled,
} = useDualScreen()
const activeBindings = bindings[lastDevice]
useEffect(() => {
lastCombatTickAtRef.current = performance.now()
}, [])
useEffect(() => {
statusRef.current = status
pausedRef.current = paused
speedMultiplierRef.current = speedMultiplier
}, [paused, speedMultiplier, status])
useEffect(() => {
const now = Date.now()
runStartedAtRef.current = now
partStartTimesRef.current = { [startPart]: now }
}, [startPart])
useEffect(() => {
if (!paused) return
window.requestAnimationFrame(() => {
document.querySelector<HTMLButtonElement>('.pause-screen button')?.focus({ preventScroll: true })
})
}, [paused])
const setCombat = useCallback((
nextState: SinglePlayerCombatState | ((current: SinglePlayerCombatState) => SinglePlayerCombatState),
) => {
const next = typeof nextState === 'function'
? nextState(combatRef.current)
: nextState
combatRef.current = next
setSelectedId(selectedIdRef.current)
setCombatState(next)
}, [])
const syncSelectedTargetDom = useCallback((id: string) => {
document.querySelectorAll<HTMLButtonElement>('[data-party-member-id]').forEach((button) => {
const selected = button.dataset.partyMemberId === id
button.classList.toggle('selected', selected)
button.setAttribute('aria-pressed', String(selected))
})
}, [])
const setSelectedTargetId = useCallback((id: string) => {
if (selectedIdRef.current === id) return
selectedIdRef.current = id
syncSelectedTargetDom(id)
}, [syncSelectedTargetDom])
useEffect(() => {
syncSelectedTargetDom(selectedIdRef.current)
}, [combatState, syncSelectedTargetDom])
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
const entry = { id: nextLogId.current++, text, tone }
setLog((current) => appendCombatLog(current, entry))
}, [])
const addFloatingHeal = useCallback((memberId: string, value: number) => {
addFloatingText({ memberId, value })
}, [addFloatingText])
const requestLootRoll = useCallback(
(encounterId: number, rollIndex = 0) => {
const rollKey = `${encounterId}:${rollIndex}:${marathonBossesDefeatedRef.current}`
if (rolledEncounterIdsRef.current.has(rollKey)) return
rolledEncounterIdsRef.current.add(rollKey)
const runToken = `${runTokenRef.current}-${marathonBossesDefeatedRef.current}-${rollIndex}`
rollEncounterLoot(encounterId, difficulty.id, runToken)
.then((result) => {
setLootRolls((current) => [...current, result])
const awarded = result.items
.map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`)
.join(', ')
addLog(
result.dropped && awarded
? `${result.encounterName} awarded ${awarded}.`
: `${result.encounterName} dropped no components.`,
result.dropped ? 'loot' : 'system',
)
if (result.petAwarded) {
addLog(
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
'loot',
)
}
})
.catch((reason: unknown) => {
addLog(
reason instanceof Error ? reason.message : 'The loot roll failed.',
'danger',
)
})
},
[addLog, difficulty.id],
)
const recordRoguelikeBossKill = useCallback((encounter: DungeonEncounter) => {
if (!isRoguelike || !encounter.isBoss) return
const key = `${runTokenRef.current}:${encounter.id}:${encounterIndex}`
if (recordedBossKillIdsRef.current.has(key)) return
recordedBossKillIdsRef.current.add(key)
completeRoguelike(
dungeon.id,
difficulty.id,
0,
0,
Math.max(1, Math.round((Date.now() - runStartedAtRef.current) / 1000)),
{
bossesCleared: 0,
fightsCleared: 0,
lootSourceEncounterId: encounter.id,
roguelikeStage,
},
)
.then((result) => {
onProfileUpdated(result.profile)
if (result.bonusItem) {
setRoguelikeBossCoins((current) => [...current, {
...result.bonusItem!,
sourceLabel: encounter.enemyName,
}])
addLog(
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
'loot',
)
}
if (result.petAwarded) {
addLog(
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
'loot',
)
}
})
.catch((reason: unknown) => {
addLog(
reason instanceof Error ? reason.message : 'Unable to award boss coins.',
'danger',
)
})
}, [addLog, difficulty.id, dungeon.id, encounterIndex, isRoguelike, onProfileUpdated, roguelikeStage])
const resetRun = useCallback(() => {
const nextRoguelikeEncounters = roguelikeMode
? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode)
: []
const nextEncounters = roguelikeMode ? nextRoguelikeEncounters : staticEncounters
const setup = createPveRoguelikeRunStart({
partyTemplate,
maxResource,
encounters: nextEncounters,
initialEncounterIndex,
enemyCount,
})
setCombat(setup.combatState)
if (roguelikeMode) setRoguelikeEncounters(nextRoguelikeEncounters)
setRoguelikeStage(setup.defaults.roguelikeStage)
setSelectedTargetId(setup.defaults.selectedId)
setEncounterIndex(setup.defaults.encounterIndex)
setStatus(setup.defaults.status)
setPaused(setup.defaults.paused)
setTargetGroup(setup.defaults.targetGroup)
setReward(setup.defaults.reward)
setRewardError(setup.defaults.rewardError)
setLootRolls(setup.defaults.lootRolls)
setRoguelikeBossCoins([])
setShowEndLog(setup.defaults.showEndLog)
clearFloatingTexts()
setRoguelikeUpgrades(setup.defaults.roguelikeUpgrades)
setUpgradeChoices(setup.defaults.upgradeChoices)
setMarathonBossesDefeated(setup.defaults.marathonBossesDefeated)
rewardClaimedRef.current = false
profileRefreshedRef.current = false
rolledEncounterIdsRef.current = new Set()
recordedBossKillIdsRef.current = new Set()
runTokenRef.current = crypto.randomUUID()
marathonBossesDefeatedRef.current = 0
resourceSpentRef.current = setup.defaults.resourceSpent
runStartedAtRef.current = Date.now()
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
setLog([{ id: nextLogId.current++, ...setup.defaults.log }])
}, [clearFloatingTexts, difficulty, enemyCount, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
const castSpell = useCallback(
(spell: Spell) => {
const current = combatRef.current
const effectiveCost = spellResourceCost(spell, roguelikeUpgradeCounts, current.freeCastReady)
if (status !== 'playing' || !canCastSpell(spell, current.resource, current.cooldowns, effectiveCost)) return
const healer = current.party.find((member) => member.id === 'mira')
if (!healer || healer.health <= 0) return
const targetId = selectedIdRef.current
const selected = current.party.find((member) => member.id === targetId)
if (!selected || selected.health <= 0) return
const renewEffect = effectSpellByName.get('Renew') ?? null
const shieldEffect = effectSpellByName.get('Sun Ward') ?? null
const healingMultiplier = (member: PartyMember) =>
activeEffects.has('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1
const extraTargets = spellExtraTargets(spell, {
stacks: roguelikeUpgradeCounts,
id: (slot) => `slot${slot as SlotKey}-extra-target` as RoguelikeUpgradeId,
})
const {
directTargets,
hotTargets,
shieldTargets,
groupTargets,
} = buildSpellTargetPlan({
party: current.party,
spell,
targetId,
extraTargets,
directTarget: true,
hotTarget: spell.kind === 'hot' || spell.effectType === 'direct_hot',
shieldTarget: spell.kind === 'shield',
groupTargetCount: DEFAULT_GROUP_HEAL_TARGETS + extraTargets,
extraTargetMode: {
hot: 'hot',
shield: 'shield',
},
beforeExtraTargetBuckets: [
...(spell.name === 'Mend' && activeEffects.has('mend_extra_target') ? ['direct' as const] : []),
...(spell.name === 'Renew' && activeEffects.has('renew_extra_target') ? ['hot' as const] : []),
],
})
if (spell.name === 'Mend' && activeEffects.has('mend_applies_renew')) {
hotTargets.add(targetId)
}
if (spell.name === 'Mend' && activeEffects.has('mend_applies_shield') && shieldEffect) {
directTargets.forEach((id) => shieldTargets.add(id))
}
const shieldBoost = spellPowerMultiplier({ stacks: roguelikeUpgradeCounts, id: 'shield-boost' })
const groupHealBoost = spellPowerMultiplier({ stacks: roguelikeUpgradeCounts, id: 'group-heal-boost' })
const spellEffectProfile: SpellEffectProfile = {
modeName: 'pve',
heal: healMember,
healingMultiplier,
power: {
direct: (source) => source.power,
cleanse: (source) => source.power,
groupHeal: (source) => Math.round(source.power * groupHealBoost),
groupAbsorb: (source) => Math.round(source.power * shieldBoost),
shield: (sourcePower, strength = 1) => Math.round(sourcePower * strength * shieldBoost),
},
hot: {
mode: 'effects',
defaultTicks: 5,
groupTicks: 5,
radianceTicks: 3,
merge: 'replace',
},
effects: {
renewSpell: renewEffect,
shieldSpell: shieldEffect,
groupAbsorbOnly: (source) => source.effectType === 'party_absorb',
groupHotOnly: (source) => source.effectType === 'party_hot',
groupAppliesShield: (source) => source.effectType === 'party_absorb'
|| (source.name === 'Radiance' && activeEffects.has('radiance_applies_shield')),
groupAppliesHot: (source) => source.name === 'Radiance' && activeEffects.has('radiance_applies_renew'),
shieldAppliesHot: () => activeEffects.has('shield_applies_renew') && Boolean(renewEffect),
hotSpellForDirect: (source) => source.name === 'Mend' && activeEffects.has('mend_applies_renew') && renewEffect
? renewEffect
: source,
},
ratios: {
groupShield: 0.3,
directShield: 0.5,
},
damageReductionTicks: 12,
floatingHeals: {
group: true,
direct: true,
cleanse: false,
},
bounceHeals: true,
}
const { party: nextParty, floatingHeals } = applySpellEffectProfile({
party: current.party,
spell,
targetId,
plan: {
directTargets,
hotTargets,
shieldTargets,
damageReductionTargets: new Set(),
groupTargets,
},
profile: spellEffectProfile,
})
floatingHeals.forEach((event) => addFloatingHeal(event.memberId, event.value))
resourceSpentRef.current += effectiveCost
let nextCooldowns = current.cooldowns
if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) {
const radiance = spellByName.get('Radiance')
if (radiance) nextCooldowns = reduceCooldown(nextCooldowns, radiance.id, 2)
}
setCombat(applyCastStateUpdate({
current,
party: nextParty,
cooldowns: nextCooldowns,
spell,
resourceCost: effectiveCost,
cooldownMultiplier: cooldownMultiplier(spell, roguelikeUpgradeCounts),
freeCast: {
enabled: hasSpellStack({ stacks: roguelikeUpgradeCounts, id: 'fifth-cast-free' }),
wasReady: current.freeCastReady,
},
}))
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
},
[activeEffects, addFloatingHeal, addLog, effectSpellByName, roguelikeUpgradeCounts, setCombat, spellByName, status],
)
const finishRun = useCallback(
(completedPart: number, runStartPart: number) => {
if (rewardClaimedRef.current) return
rewardClaimedRef.current = true
const now = Date.now()
const pTimes = partStartTimesRef.current
const partDuration = (part: number) => {
const start = pTimes[part]
if (!start) return 0
const next = pTimes[part + 1] ?? now
return Math.max(1, Math.round((next - start) / 1000))
}
completeDungeon(
dungeon.id,
difficulty.id,
resourceSpentRef.current,
Math.max(1, Math.round((now - runStartedAtRef.current) / 1000)),
completedPart,
runStartPart,
[partDuration(1), partDuration(2), partDuration(3)],
hardMode,
)
.then((result) => {
setReward(result)
onProfileUpdated(result.profile)
setStatus('won')
})
.catch((reason: unknown) => {
setRewardError(
reason instanceof Error ? reason.message : 'Unable to award experience.',
)
})
},
[difficulty.id, dungeon.id, hardMode, onProfileUpdated],
)
const finishRoguelikeRun = useCallback(
(encountersCleared: number) => {
if (rewardClaimedRef.current) return
rewardClaimedRef.current = true
completeRoguelike(
dungeon.id,
difficulty.id,
encountersCleared,
resourceSpentRef.current,
Math.max(1, Math.round((Date.now() - runStartedAtRef.current) / 1000)),
)
.then((result) => {
setReward(result)
onProfileUpdated(result.profile)
})
.catch((reason: unknown) => {
setRewardError(
reason instanceof Error ? reason.message : 'Unable to award roguelike experience.',
)
})
},
[difficulty.id, dungeon.id, onProfileUpdated],
)
const getTargetParty = useCallback(() => combatRef.current.party, [])
const {
selectRelativeTarget,
selectDirectionalTarget,
selectDirectTarget,
} = usePartyTargeting({
getParty: getTargetParty,
selectedIdRef,
setSelectedTargetId,
columns: dungeon.partySize >= 10 ? 6 : 3,
relativeLivingOnly: true,
directionalLivingOnly: false,
directLivingOnly: false,
directTargetGroup: dungeon.partySize > 6 ? targetGroup : 0,
})
const chooseRoguelikeUpgrade = useCallback((upgrade: RoguelikeUpgrade) => {
if (!roguelikeMode) return
const current = combatRef.current
const clearedBoss = encounters[encounterIndex]?.isBoss ?? false
const recoveredParty = current.party.map((member) => ({
...member,
health: member.health <= 0
? 0
: clamp(member.health + Math.round(member.maxHealth * 0.35), 0, member.maxHealth),
debuff: undefined,
debuffTicks: undefined,
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
hotEffects: [],
bounceHeals: [],
damageReductionTicks: undefined,
}))
const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage
const nextSegment = clearedBoss
? makeRoguelikeSegment(roguelikePool, nextStage, difficulty, roguelikeMode)
: []
const nextEncounter = clearedBoss
? nextSegment[0]
: encounters[encounterIndex + 1]
if (!nextEncounter) return
setRoguelikeUpgrades((current) => [...current, upgrade])
if (clearedBoss) {
setRoguelikeStage(nextStage)
setRoguelikeEncounters((current) => [...current, ...nextSegment])
}
setEncounterIndex((current) => current + 1)
setCombat({
...current,
party: recoveredParty,
enemyHealth: nextEncounter.maxHealth * enemyCount,
elapsedTicks: 0,
cooldowns: {},
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
})
setUpgradeChoices([])
setStatus('playing')
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
}, [addLog, difficulty, encounterIndex, encounters, enemyCount, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
useGameAction((action, device) => {
if (action === 'toggleSpeed') {
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
return
}
if (action === 'pause' || (action === 'back' && device === 'pc')) {
if (status === 'playing') setPaused((value) => !value)
return
}
if (paused || status !== 'playing') return
if (action.startsWith('navigate')) {
selectDirectionalTarget(action)
return
}
if (action.startsWith('targetParty')) {
selectDirectTarget(Number(action.slice('targetParty'.length)) - 1)
return
}
if (action === 'toggleTargetGroup') {
if (dungeon.partySize <= 6) return
setTargetGroup((current) => {
const next = nextTargetGroupSelection(combatRef.current.party, selectedIdRef.current, current)
if (next.selectedId) setSelectedTargetId(next.selectedId)
return next.group as 0 | 1 | 2
})
return
}
if (action === 'previousTarget') {
selectRelativeTarget(-1)
return
}
if (action === 'nextTarget') {
selectRelativeTarget(1)
return
}
if (!action.startsWith('ability')) return
const slot = Number(action.slice('ability'.length)) - 1
const spell = spellByKey.get(String(slot + 1))
if (spell) castSpell(spell)
})
const runCombatTick = useCallback(() => {
const current = combatRef.current
const nextElapsedTicks = current.elapsedTicks + 1
const nextCooldowns = pruneExpiredCooldowns(current.cooldowns)
let nextResource = regenerateResource(current.resource, 2.4, maxResource)
const living = current.party.filter((member) => member.health > 0)
if (living.length === 0) {
if (isRoguelike) finishRoguelikeRun(encounterIndex)
setStatus('lost')
addLog('The party has fallen.', 'danger')
return
}
const primaryTarget = living[Math.floor(Math.random() * living.length)]
const mechanics = (encounter as RoguelikeEncounter).roguelikeMechanics ?? []
const useDefaultBossMechanics = encounter.isBoss && mechanics.length === 0
const bossPulse = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 7 === 0
&& (useDefaultBossMechanics || mechanics.includes('party-pulse'))
const appliesDebuff = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 11 === 0
&& (useDefaultBossMechanics || mechanics.includes('searing-mark'))
const appliesMaxHealthCut = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 13 === 0
&& mechanics.includes('max-health-cut')
const appliesHealingReduction = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 9 === 0
&& mechanics.includes('healing-reduction')
const tankBuster = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 8 === 0
&& mechanics.includes('tank-buster')
const resourceDrain = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 10 === 0
&& mechanics.includes('resource-drain')
const appliesPoison = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 12 === 0
&& mechanics.includes('ramping-poison')
if (bossPulse) addLog(`${encounter.enemyName} unleashes party-wide damage.`, 'danger')
if (appliesDebuff) addLog(`${primaryTarget.name} is afflicted by Searing Mark.`, 'danger')
if (appliesPoison) addLog(`${primaryTarget.name} is poisoned. Dispel it before it ramps.`, 'danger')
if (appliesMaxHealthCut) addLog(`${primaryTarget.name}'s max health is reduced.`, 'danger')
if (appliesHealingReduction) addLog(`${primaryTarget.name} receives reduced healing.`, 'danger')
if (tankBuster) addLog(`${encounter.enemyName} crushes the tanks.`, 'danger')
if (resourceDrain) {
nextResource = clamp(nextResource - 8, 0, maxResource)
addLog(`${encounter.enemyName} drains ${gameClass.resourceName}.`, 'danger')
}
const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
const tankPressure = tankPressureTargets(current.party)
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
const hasShieldedHealingBonus = activeEffects.has('shielded_healing_bonus')
const shieldedDamageMultiplier = activeEffects.has('shielded_damage_reduction') ? 0.8 : undefined
const pendingJumpHeals: Array<{
targetId: string
heal: NonNullable<PartyMember['bounceHeals']>[number]
}> = []
const damagedParty = current.party.map((member) => {
if (member.health <= 0) return member
let damage = member.id === primaryTarget.id ? encounter.damage : 0
if (tankPressureIds.has(member.id)) {
damage += Math.round(encounter.tankDamage * tankPressure.multiplier)
}
if (tankBuster && tankPressureIds.has(member.id)) {
damage += Math.round(22 * difficulty.damageMultiplier * tankPressure.multiplier)
}
if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier)
if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier)
damage *= enemyCount
const healingMultiplier = member.shield > 0 && hasShieldedHealingBonus ? 1.2 : 1
const hasBounceHeals = Boolean(member.bounceHeals?.length)
const result = advanceMemberTick({
member,
party: current.party,
damage,
hotHealing: 0,
hotTicks: 'effects',
healingMultiplier,
shieldedDamageMultiplier,
applyDebuff: appliesDebuff && member.id === primaryTarget.id
? { label: 'Searing Mark', ticks: 8 }
: undefined,
applyPoisonStacks: appliesPoison && member.id === primaryTarget.id,
poisonDamage: (stacks) => Math.round((4 + stacks * 4) * difficulty.damageMultiplier),
applyMaxHealthPenaltyTicks: appliesMaxHealthCut && member.id === primaryTarget.id ? 15 : undefined,
applyHealingReductionTicks: appliesHealingReduction && member.id === primaryTarget.id ? 15 : undefined,
decrementDamageReduction: true,
useBounceHeals: hasBounceHeals,
jumpTarget: hasBounceHeals ? () => chooseOtherLivingMember(living, member) : undefined,
})
if (result.floatingHeal > 0) addFloatingHeal(member.id, result.floatingHeal)
pendingJumpHeals.push(...result.jumpedBounceHeals)
return result.member
})
const nextParty = attachJumpedBounceHeals(damagedParty, pendingJumpHeals)
const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
if (
healerBeforeDamage
&& healerBeforeDamage.health > 0
&& healerAfterDamage
&& healerAfterDamage.health <= 0
) {
addLog(`${profile.character.name} has fallen. Healing is no longer available.`, 'danger')
}
if (nextParty.every((member) => member.health <= 0)) {
setCombat({
...current,
party: nextParty,
resource: nextResource,
cooldowns: nextCooldowns,
elapsedTicks: nextElapsedTicks,
enemyHealth: current.enemyHealth,
})
if (isRoguelike) finishRoguelikeRun(encounterIndex)
setStatus('lost')
addLog('The party has fallen.', 'danger')
return
}
const nextEnemyHealth = current.enemyHealth - partyDamageOutput(nextParty, encounter.partyDamage)
if (nextEnemyHealth > 0) {
setCombat({
...current,
party: nextParty,
resource: nextResource,
cooldowns: nextCooldowns,
elapsedTicks: nextElapsedTicks,
enemyHealth: nextEnemyHealth,
})
return
}
if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) {
for (let rollIndex = 0; rollIndex < enemyCount; rollIndex += 1) {
requestLootRoll(encounter.id, rollIndex)
}
}
recordRoguelikeBossKill(encounter)
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
setCombat({
...current,
party: nextParty,
resource: nextResource,
cooldowns: nextCooldowns,
elapsedTicks: nextElapsedTicks,
enemyHealth: 0,
})
setUpgradeChoices(chooseRandom(roguelikeUpgradeCatalog, 3))
setStatus('upgrade-choice')
addLog(`${encounter.enemyName} defeated. Choose an upgrade.`, 'loot')
return
}
if (isPartBoss && !isFinalBoss) {
const nextMarathonKills = marathonBossesDefeatedRef.current + 1
marathonBossesDefeatedRef.current = nextMarathonKills
setMarathonBossesDefeated(nextMarathonKills)
setCombat({
...current,
party: nextParty,
resource: nextResource,
cooldowns: nextCooldowns,
elapsedTicks: nextElapsedTicks,
enemyHealth: 0,
})
setStatus(marathonMode && encounter.isBoss ? 'marathon-choice' : 'part-complete')
addLog(`${encounter.enemyName} is defeated.`, 'loot')
return
}
if (encounterIndex === encounters.length - 1) {
if (marathonMode && encounter.isBoss) {
const nextMarathonKills = marathonBossesDefeatedRef.current + 1
marathonBossesDefeatedRef.current = nextMarathonKills
setMarathonBossesDefeated(nextMarathonKills)
setCombat({
...current,
party: nextParty,
resource: nextResource,
cooldowns: nextCooldowns,
elapsedTicks: nextElapsedTicks,
enemyHealth: 0,
})
setStatus('marathon-choice')
addLog(`${encounter.enemyName} is defeated. Continue marathon or end the hunt.`, 'loot')
return
}
setCombat({
...current,
party: nextParty,
resource: nextResource,
cooldowns: nextCooldowns,
elapsedTicks: nextElapsedTicks,
enemyHealth: 0,
})
finishRun(currentPart, startPart)
addLog(`${encounter.enemyName} is defeated. Rolling its loot table.`, 'loot')
return
}
const nextEncounter = encounters[encounterIndex + 1]
const recoveredParty = nextParty.map((member) => ({
...member,
health: member.health <= 0
? 0
: clamp(member.health + 35, 0, effectiveMaxHealth(member)),
debuff: undefined,
debuffTicks: undefined,
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
hotEffects: [],
bounceHeals: [],
damageReductionTicks: undefined,
}))
setEncounterIndex((value) => value + 1)
setCombat({
...current,
party: recoveredParty,
resource: nextResource,
cooldowns: nextCooldowns,
elapsedTicks: 0,
enemyHealth: nextEncounter.maxHealth * enemyCount,
})
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
}, [
activeEffects,
addLog,
addFloatingHeal,
difficulty.damageMultiplier,
enemyCount,
encounter,
encounterIndex,
encounters,
finishRun,
finishRoguelikeRun,
difficulty.id,
isPartBoss,
isFinalBoss,
isRoguelike,
marathonMode,
upgradesEveryEncounter,
roguelikeUpgradeCatalog,
maxResource,
gameClass.resourceName,
requestLootRoll,
recordRoguelikeBossKill,
profile.character.name,
setCombat,
startPart,
currentPart,
])
useEffect(() => {
runCombatTickRef.current = runCombatTick
}, [runCombatTick])
useEffect(() => {
if (status === 'playing' && !paused) {
if (!combatClockActiveRef.current) {
lastCombatTickAtRef.current = performance.now()
combatClockActiveRef.current = true
}
return
}
combatClockActiveRef.current = false
}, [paused, status])
useEffect(() => {
const timer = window.setInterval(() => {
if (
!combatClockActiveRef.current
|| statusRef.current !== 'playing'
|| pausedRef.current
) return
const now = performance.now()
const tickMs = TICK_MS / speedMultiplierRef.current
const dueTicks = Math.min(8, Math.floor((now - lastCombatTickAtRef.current) / tickMs))
if (dueTicks <= 0) return
lastCombatTickAtRef.current += dueTicks * tickMs
for (let index = 0; index < dueTicks; index += 1) {
if (statusRef.current !== 'playing' || pausedRef.current) return
runCombatTickRef.current()
}
}, 50)
return () => window.clearInterval(timer)
}, [])
useEffect(() => {
if (
!reward
|| lootRolls.length < expectedLootRolls
|| profileRefreshedRef.current
) return
profileRefreshedRef.current = true
loadProfile()
.then(onProfileUpdated)
.catch(() => {
profileRefreshedRef.current = false
})
}, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward])
const enemyPercent = (enemyHealth / encounterMaxHealth) * 100
const enemyHealthSegments = useMemo(
() => Array.from({ length: enemyCount }, (_, index) => {
const remaining = clamp(enemyHealth - encounter.maxHealth * index, 0, encounter.maxHealth)
return {
index,
health: remaining,
percent: (remaining / encounter.maxHealth) * 100,
}
}).reverse(),
[enemyCount, enemyHealth, encounter.maxHealth],
)
const spellSlotCost = useCallback(
(spell: Spell) => spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady),
[freeCastReady, roguelikeUpgradeCounts],
)
const spellSlots = useSpellSlots({
spells,
abilitySlots: profile.abilitySlots,
cooldowns,
active: status === 'playing' && !paused,
cost: spellSlotCost,
})
const dualScreenState = useMemo(() => buildCombatDualScreenState({
difficultyName: difficulty.name,
dungeonName: dungeon.name,
contentName,
encounterName: encounter.enemyName,
encounterDescription: encounter.description,
encounterHealth: enemyHealth,
encounterMaxHealth,
encounterIsBoss: encounter.isBoss,
encounterIndex,
encounterCount: encounters.length,
party,
floatingTexts,
partySize: dungeon.partySize,
selectedId,
status,
resource,
maxResource,
resourceName: gameClass.resourceName,
playerIsAlive,
spells: spellSlots,
bindings: activeBindings,
controllerIconStyle,
directPartyTargeting,
paused,
targetGroup,
speedMultiplier,
}), [
activeBindings,
controllerIconStyle,
contentName,
difficulty.name,
dungeon.name,
dungeon.partySize,
directPartyTargeting,
encounter.description,
encounter.enemyName,
encounter.isBoss,
encounterMaxHealth,
enemyHealth,
encounterIndex,
encounters.length,
gameClass.resourceName,
maxResource,
paused,
party,
playerIsAlive,
resource,
selectedId,
spellSlots,
floatingTexts,
speedMultiplier,
status,
targetGroup,
])
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
return (
<main
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
data-combat-active={status === 'playing' && !paused ? 'true' : 'false'}
>
{!dualScreenEnabled && <header className="topbar">
<div>
<p className="eyebrow">{difficulty.name} - Item Level {difficulty.droppedItemLevel}</p>
<h1>{dungeon.name}</h1>
</div>
<div className="combat-header-actions">
<div className="run-progress" aria-label={`${contentName} progress`}>
{encounters.map((item, index) => (
<span className={index < encounterIndex ? 'complete' : index === encounterIndex ? 'active' : ''} key={item.id}>
{index + 1}
</span>
))}
</div>
<button className="back-button" onClick={() => setPaused(true)} type="button">Pause</button>
</div>
</header>}
{!dualScreenEnabled && (
<>
<section className="enemy-card">
<div className="enemy-portrait" aria-hidden="true">
{encounter.isBoss ? <img src={encounter.imageUrl} alt="" /> : 'M'}
</div>
<div className="enemy-info">
<div className="bar-label">
<strong>{encounter.enemyName}</strong>
<span>{Math.ceil(enemyHealth)} / {encounterMaxHealth}</span>
</div>
{hardMode ? (
<div className="hard-enemy-bars">
{enemyHealthSegments.map((segment) => (
<div className="bar enemy-health" key={segment.index}>
<span style={barFillStyle(segment.percent)} />
<em>{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}</em>
</div>
))}
</div>
) : (
<div className="bar enemy-health"><span style={barFillStyle(enemyPercent)} /></div>
)}
<p>{encounter.description}</p>
</div>
</section>
<div className="combat-layout">
<section className="party-panel">
<div className="party-panel-top">
<ResourceBar
maxResource={maxResource}
resource={resource}
resourceName={gameClass.resourceName}
speedMultiplier={speedMultiplier}
unavailableText={playerIsAlive ? undefined : `${profile.character.name} is defeated`}
/>
</div>
<div className={`party-grid ${dungeon.partySize >= 10 ? 'raid-party-grid' : ''}`}>
{party.map((member) => (
<PartyMemberFrame
key={member.id}
member={member}
selected={selectedId === member.id}
floatingTexts={floatingTextsByMember.get(member.id) ?? []}
showTargetMarker
onSelect={setSelectedTargetId}
/>
))}
</div>
</section>
<aside className="combat-log combat-side-rail">
<div className="panel-heading">
<div><p className="eyebrow">Actions</p><h2>Skills</h2></div>
</div>
<SpellBar
bindings={activeBindings}
canCast={playerIsAlive && status === 'playing'}
iconStyle={controllerIconStyle}
onCast={castSpell}
resource={resource}
resourceName={gameClass.resourceName}
spells={spellSlots}
/>
</aside>
</div>
</>
)}
{dualScreenEnabled && (
<DualScreenTopCombat
state={dualScreenState}
onCastSpell={castSpell}
onSelectTarget={setSelectedTargetId}
/>
)}
{paused && status === 'playing' && (
<div className="pause-screen" role="dialog" aria-modal="true">
<div>
<p className="eyebrow">Game Paused</p>
<h2>{dungeon.name}</h2>
<p>Combat is stopped. Continue the fight or return to the main menu.</p>
<button onClick={() => setPaused(false)} type="button">Continue</button>
<button className="secondary-result-button" onClick={onMainMenu} type="button">
Main Menu
</button>
</div>
</div>
)}
{status === 'upgrade-choice' && (
<div className="result-screen">
<div className="pvp-upgrade-dialog pve-upgrade-dialog">
<p className="eyebrow">
{encounter.isBoss
? `Roguelike Stage ${roguelikeStage} Complete`
: `Encounter ${encounterIndex + 1} Complete`}
</p>
<h2>Choose Upgrade</h2>
<p>Pick one upgrade before the next fight.</p>
<div className="pvp-choice-columns">
<div>
<strong>Run Buff</strong>
<div className="upgrade-choice-grid">
{upgradeChoices.map((upgrade) => (
<button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button">
<strong>{upgrade.name}</strong>
<small>{upgrade.description}</small>
</button>
))}
</div>
</div>
</div>
{roguelikeUpgrades.length > 0 && (
<p className="roguelike-upgrade-list">
Active: {summarizeUpgradeStacks(roguelikeUpgrades, roguelikeUpgradeCatalog)}
</p>
)}
<button className="secondary-result-button" onClick={onExit} type="button">Leave Roguelike</button>
</div>
</div>
)}
{status !== 'playing' && status !== 'part-complete' && status !== 'marathon-choice' && status !== 'upgrade-choice' && (
<ResultScreen
actions={[
{ label: 'Run Again', onClick: resetRun },
{ label: `Leave ${contentName}`, onClick: onExit, className: 'secondary-result-button' },
]}
eyebrow={status === 'won' ? `${contentName} Complete` : 'Party Defeated'}
log={log}
onToggleLog={() => setShowEndLog((value) => !value)}
showLog={showEndLog}
title={status === 'won' ? 'The Warden Falls' : 'The Ashes Claim You'}
>
{status === 'won' ? (
<div className="reward-summary">
{!reward && !rewardError && <p>Recording victory...</p>}
{rewardError && <p className="reward-error">{rewardError}</p>}
{reward && (
<>
{isRoguelike && <p>Run totals</p>}
<RewardXpSummary reward={reward} />
{isRoguelike && (
<PvpRunLootList loot={roguelikeBossCoins} emptyLabel="No boss coins collected this run." />
)}
{!isRoguelike && (
<>
<p>Component tier: item level {reward.droppedItemLevel}.</p>
<p className="efficiency-result">
{reward.resourceSpent} {gameClass.resourceName} spent
<small>
{reward.durationSeconds}s - iLvl {reward.averageItemLevel.toFixed(1)}
</small>
</p>
<LootRollList rolls={lootRolls} expectedRolls={expectedLootRolls} />
<BonusItemReward item={reward.bonusItem} eyebrow="Full Run Bonus" />
</>
)}
</>
)}
</div>
) : isRoguelike ? (
<div className="reward-summary">
{!reward && !rewardError && <p>Recording roguelike progress...</p>}
{rewardError && <p className="reward-error">{rewardError}</p>}
{reward && (
<>
<RewardXpSummary reward={reward} />
<p>{encounterIndex} encounters cleared.</p>
<PvpRunLootList loot={roguelikeBossCoins} emptyLabel="No boss coins collected this run." />
<p className="efficiency-result">
{reward.resourceSpent} {gameClass.resourceName} spent
<small>{reward.durationSeconds}s survived</small>
</p>
</>
)}
</div>
) : (
<p>Balance efficient healing, shields, and cleansing to survive.</p>
)}
</ResultScreen>
)}
{status === 'marathon-choice' && (
<div className="result-screen">
<div>
<p className="eyebrow">Marathon</p>
<h2>{encounter.enemyName} Defeated</h2>
<p>
{marathonBossesDefeated} boss{marathonBossesDefeated === 1 ? '' : 'es'} defeated.
Continue with current health and {gameClass.resourceName}, or end the hunt.
</p>
<button
onClick={() => {
const current = combatRef.current
setCombat({
...current,
enemyHealth: encounter.maxHealth * enemyCount,
elapsedTicks: 0,
})
setStatus('playing')
addLog(`Marathon continues. Another ${encounter.enemyName} appears.`, 'danger')
}}
type="button"
>
Continue Marathon
</button>
<button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
End
</button>
</div>
</div>
)}
{status === 'part-complete' && (
<div className="result-screen">
<div>
<p className="eyebrow">{sectionName} Complete</p>
<h2>{encounter.enemyName} Defeated</h2>
<p>{canContinueAfterPart ? `Proceed to ${sectionName} ${currentPart + 1} or end the run?` : 'Run checkpoint complete.'}</p>
{canContinueAfterPart && (
<button
onClick={() => {
const nextIndex = encounterIndex + 1
partStartTimesRef.current[currentPart + 1] = Date.now()
const nextEncounter = encounters[nextIndex]
const current = combatRef.current
const recoveredParty = current.party.map((member) => ({
...member,
health: clamp(member.health + 35, 0, member.maxHealth),
debuff: undefined,
debuffTicks: undefined,
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
hotEffects: [],
bounceHeals: [],
damageReductionTicks: undefined,
}))
setEncounterIndex(nextIndex)
setCombat({
...current,
party: recoveredParty,
enemyHealth: nextEncounter.maxHealth * enemyCount,
elapsedTicks: 0,
})
setStatus('playing')
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
}}
type="button"
>
Continue to {sectionName} {currentPart + 1}
</button>
)}
<button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
End Run
</button>
</div>
</div>
)}
</main>
)
}