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, groupHealTargets, partyDamageOutput, tankPressureTargets, type CombatLogEntry, type PartyMember, type Spell, } from '../game' import type { Ability, CharacterProfile, Difficulty, Dungeon, DungeonEncounter, } from '../profile' import { useGameAction, useInput, type InputAction, } from '../input' import { ControllerBindingLabel } from './ControllerIcons' import { DualScreenTopCombat, useDualScreen, useDualScreenPublisher, type DualScreenCombatState, } 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 FloatingCombatText = { id: number memberId: string value: number } type SinglePlayerCombatState = { party: PartyMember[] resource: number enemyHealth: number cooldowns: Record 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 clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)) } function effectiveMaxHealth(member: PartyMember) { return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1))) } function healAmount(member: PartyMember, amount: number, multiplier = 1) { return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1) * multiplier) } function healMember(member: PartyMember, amount: number, multiplier = 1) { return clamp(member.health + healAmount(member, amount, multiplier), 0, effectiveMaxHealth(member)) } function memberHotEffects(member: PartyMember) { if (member.hotEffects?.length) return member.hotEffects return member.hotTicks > 0 ? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }] : [] } function effectId(prefix: string) { return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}` } function addHotEffect(member: PartyMember, spell: Spell, ticks = 5) { const nextEffect = { id: effectId(spell.id), spellId: spell.id, label: spell.name, ticks, power: Math.max(1, Math.round(spell.power / 2)), } const currentEffects = memberHotEffects(member).filter((effect) => effect.spellId !== spell.id) return [...currentEffects, nextEffect] } function addBounceHeal(member: PartyMember, spell: Spell) { return [ ...(member.bounceHeals ?? []), { id: effectId(spell.id), label: spell.name, charges: 4, power: spell.power, }, ] } function tickHotEffects(effects: PartyMember['hotEffects']) { return (effects ?? []) .map((effect) => ({ ...effect, ticks: effect.ticks - 1 })) .filter((effect) => effect.ticks > 0) } function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) { return upgrades.filter((upgrade) => upgrade.id === id).length } function slotLabel(slot: SlotKey, spells: Spell[], labelMode: RoguelikeAbilityLabelMode) { const spell = spells.find((candidate) => candidate.key === slot) if (labelMode === 'ability' && spell) return spell.name return `Slot ${slot}` } function buildRoguelikeUpgrades( spells: Spell[], labelMode: RoguelikeAbilityLabelMode, ): RoguelikeUpgrade[] { const slotUpgrades = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => { const label = slotLabel(slot, spells, labelMode) return [ { id: `slot${slot}-extra-target` as RoguelikeUpgradeId, name: `${label}: +1 target`, description: `${label} affects 1 additional ally when possible.`, }, { id: `slot${slot}-cost-down` as RoguelikeUpgradeId, name: `${label}: -25% cost`, description: `${label} costs 25% less resource.`, }, { id: `slot${slot}-cooldown-down` as RoguelikeUpgradeId, name: `${label}: -25% cooldown`, description: `${label} recharges 25% faster.`, }, ] }) 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[], ) { const counts = new Map() upgrades.forEach((upgrade) => counts.set(upgrade.id, (counts.get(upgrade.id) ?? 0) + 1)) return Array.from(counts.entries()) .map(([id, count]) => { const name = catalog.find((upgrade) => upgrade.id === id)?.name ?? id return count > 1 ? `${name} x${count}` : name }) .join(', ') } function cooldownMultiplier(spell: Spell, upgrades: RoguelikeUpgrade[]) { return 0.75 ** upgradeStackCount(upgrades, `slot${spell.key as SlotKey}-cooldown-down` as RoguelikeUpgradeId) } function spellResourceCost(spell: Spell, upgrades: RoguelikeUpgrade[], freeCastReady: boolean) { const adjustedCost = Math.ceil( spell.cost * (0.75 ** upgradeStackCount(upgrades, `slot${spell.key as SlotKey}-cost-down` as RoguelikeUpgradeId)), ) return freeCastReady && upgradeStackCount(upgrades, 'fifth-cast-free') > 0 ? 0 : adjustedCost } function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell { const kinds: Record = { direct_heal: 'direct', direct_hot: 'direct', heal_over_time: 'hot', party_heal: 'group', party_hot: 'group', party_absorb: 'group', absorb: 'shield', damage_reduction: 'damage_reduction', bounce_heal: 'bounce_heal', cleanse: 'cleanse', } return { id: String(ability.id), key, name: ability.name, description: ability.description, cost: ability.cost, cooldown: ability.cooldown, power: ability.power + healingPower, glyph: ability.glyph, kind: kinds[ability.spellType] ?? 'direct', effectType: ability.spellType, } } function getCurrentPart(encounterIndex: number) { return Math.floor(encounterIndex / 3) + 1 } function chooseRandom(items: T[], count: number) { const pool = [...items] const result: T[] = [] while (pool.length > 0 && result.length < count) { const index = Math.floor(Math.random() * pool.length) result.push(pool.splice(index, 1)[0]) } return result } function formatEffectTime(ticks: number) { const seconds = (ticks * TICK_MS) / 1000 return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s` } function mechanicLabel(mechanic: RoguelikeMechanic) { const labels: Record = { '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 encounterThreat = (encounter: DungeonEncounter) => ( encounter.maxHealth + encounter.damage * 18 + encounter.tankDamage * 10 + encounter.partyDamage * 18 ) const trashPool = [...pool.filter((encounter) => !encounter.isBoss)] .sort((left, right) => encounterThreat(left) - encounterThreat(right)) const bossPool = [...pool.filter((encounter) => encounter.isBoss)] .sort((left, right) => encounterThreat(left) - encounterThreat(right)) const trashCandidateCount = Math.min(trashPool.length, 4 + stage * (mode === 'raid' ? 1 : 2)) const bossCandidateCount = Math.min(bossPool.length, 2 + Math.floor((stage + 1) / 2)) const selectedTrash = chooseRandom(trashPool.slice(0, trashCandidateCount), 2) const selectedBoss = chooseRandom(bossPool.slice(0, bossCandidateCount), 1)[0] ?? trashPool[0] ?? pool[0] const healthScale = 0.64 + stage * (mode === 'raid' ? 0.15 : 0.11) const damageScale = 0.58 + stage * (mode === 'raid' ? 0.13 : 0.1) const mechanics = chooseRandom(ROGUELIKE_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4)) return [...selectedTrash, selectedBoss].map((encounter, index) => { const isBoss = index === 2 return { ...encounter, id: 900000 + stage * 10 + index, sequence: (stage - 1) * 3 + index + 1, isBoss, encounterType: isBoss ? 'boss' : 'trash', enemyName: isBoss ? `${encounter.enemyName} ${stage}` : encounter.enemyName, description: isBoss ? `Roguelike boss with ${mechanics.map(mechanicLabel).join(', ')}.` : encounter.description, maxHealth: Math.round(encounter.maxHealth * difficulty.healthMultiplier * healthScale), damage: Math.round(encounter.damage * difficulty.damageMultiplier * damageScale), tankDamage: Math.round(encounter.tankDamage * difficulty.damageMultiplier * damageScale), partyDamage: Math.round(encounter.partyDamage * (0.9 + stage * 0.05)), lootTables: [], roguelikeMechanics: isBoss ? mechanics : [], } }) } export function CombatScreen({ difficulty, dungeon, hardMode = false, marathonMode = false, profile, startPart = 1, roguelikeMode, roguelikeUpgradeTiming = 'boss', roguelikeAbilityLabelMode = 'ability', roguelikeEncounterPool, onExit, onProfileUpdated, }: { difficulty: Difficulty dungeon: Dungeon hardMode?: boolean marathonMode?: boolean profile: CharacterProfile startPart?: number roguelikeMode?: RoguelikeMode roguelikeUpgradeTiming?: RoguelikeUpgradeTiming roguelikeAbilityLabelMode?: RoguelikeAbilityLabelMode roguelikeEncounterPool?: DungeonEncounter[] onExit: () => 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(() => 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 = profile.abilitySlots.flatMap((abilityId, index) => { const ability = gameClass.spells.find((candidate) => candidate.id === abilityId) return ability ? [toCombatSpell(ability, String(index + 1), 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(() => ({ party: partyTemplate, resource: maxResource, enemyHealth: encounters[initialEncounterIndex].maxHealth * enemyCount, cooldowns: {}, elapsedTicks: 0, castsTowardFree: 0, freeCastReady: false, }), [encounters, enemyCount, initialEncounterIndex, maxResource, partyTemplate]) const [combatState, setCombatState] = useState(() => 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([ { id: 1, text: `${dungeon.name} begins.`, tone: 'system' }, ]) const [reward, setReward] = useState(null) const [rewardError, setRewardError] = useState('') const [lootRolls, setLootRolls] = useState([]) const [showEndLog, setShowEndLog] = useState(false) const [floatingTexts, setFloatingTexts] = useState([]) const [roguelikeUpgrades, setRoguelikeUpgrades] = useState([]) const [upgradeChoices, setUpgradeChoices] = useState([]) const [marathonBossesDefeated, setMarathonBossesDefeated] = useState(0) const rewardClaimedRef = useRef(false) const profileRefreshedRef = useRef(false) const rolledEncounterIdsRef = useRef(new Set()) const runTokenRef = useRef(crypto.randomUUID()) const resourceSpentRef = useRef(0) const runStartedAtRef = useRef(0) const partStartTimesRef = useRef>({}) const nextLogId = useRef(2) const nextFloatingTextId = useRef(1) 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(performance.now()) 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 = encounters .slice(firstEncounterIndex, encounterIndex + 1) .filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id)) .length * enemyCount 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 activeEffects = useMemo( () => { const effects = new Set( 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() statusRef.current = status pausedRef.current = paused speedMultiplierRef.current = speedMultiplier useEffect(() => { const now = Date.now() runStartedAtRef.current = now partStartTimesRef.current = { [startPart]: now } }, [startPart]) useEffect(() => { if (!paused) return window.requestAnimationFrame(() => { document.querySelector('.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('[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) => [entry, ...current].slice(0, 60)) }, []) const addFloatingHeal = useCallback((memberId: string, value: number) => { if (value <= 0) return const id = nextFloatingTextId.current++ setFloatingTexts((current) => [...current, { id, memberId, value }]) window.setTimeout(() => { setFloatingTexts((current) => current.filter((entry) => entry.id !== id)) }, 900) }, []) 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', ) }) .catch((reason: unknown) => { addLog( reason instanceof Error ? reason.message : 'The loot roll failed.', 'danger', ) }) }, [addLog, difficulty.id], ) const resetRun = useCallback(() => { const nextRoguelikeEncounters = roguelikeMode ? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode) : [] const nextEncounters = roguelikeMode ? nextRoguelikeEncounters : staticEncounters const freshParty = partyTemplate.map((member) => ({ ...member })) setCombat({ party: freshParty, resource: maxResource, enemyHealth: nextEncounters[initialEncounterIndex].maxHealth * enemyCount, cooldowns: {}, elapsedTicks: 0, castsTowardFree: 0, freeCastReady: false, }) if (roguelikeMode) setRoguelikeEncounters(nextRoguelikeEncounters) setRoguelikeStage(1) setSelectedTargetId(partyTemplate[0].id) setEncounterIndex(initialEncounterIndex) setStatus('playing') setPaused(false) setTargetGroup(0) setReward(null) setRewardError('') setLootRolls([]) setShowEndLog(false) setFloatingTexts([]) setRoguelikeUpgrades([]) setUpgradeChoices([]) setMarathonBossesDefeated(0) rewardClaimedRef.current = false profileRefreshedRef.current = false rolledEncounterIdsRef.current = new Set() runTokenRef.current = crypto.randomUUID() marathonBossesDefeatedRef.current = 0 resourceSpentRef.current = 0 runStartedAtRef.current = Date.now() partStartTimesRef.current = { [startPart]: runStartedAtRef.current } setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }]) }, [difficulty, enemyCount, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters]) const castSpell = useCallback( (spell: Spell) => { const current = combatRef.current const effectiveCost = spellResourceCost(spell, roguelikeUpgrades, current.freeCastReady) if (status !== 'playing' || current.cooldowns[spell.id] > 0 || current.resource < 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 extraTarget = (blockedIds: string[]) => current.party .filter((member) => member.health > 0 && !blockedIds.includes(member.id)) .sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0] const effectSpell = (name: string) => { const ability = gameClass.spells.find((candidate) => candidate.name === name) return ability ? toCombatSpell(ability, `effect-${ability.id}`, healingPower) : null } const renewEffect = effectSpell('Renew') const shieldEffect = effectSpell('Sun Ward') const healingMultiplier = (member: PartyMember) => activeEffects.has('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1 const directTargets = new Set([targetId]) const hotTargets = new Set() const shieldTargets = new Set() const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId) const groupTargets = new Set( spell.kind === 'group' ? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id) : [], ) if (spell.kind === 'hot' || spell.effectType === 'direct_hot') hotTargets.add(targetId) if (spell.kind === 'shield') shieldTargets.add(targetId) if (spell.name === 'Mend' && activeEffects.has('mend_extra_target')) { const extra = extraTarget([targetId]) if (extra) directTargets.add(extra.id) } if (spell.name === 'Renew' && activeEffects.has('renew_extra_target')) { const extra = extraTarget([targetId]) if (extra) hotTargets.add(extra.id) } if (spell.name === 'Mend' && activeEffects.has('mend_applies_renew')) { hotTargets.add(targetId) } for (let index = 0; index < extraTargets; index += 1) { if (spell.kind === 'group') break if (spell.kind === 'hot') { const extra = extraTarget([...hotTargets]) if (extra) hotTargets.add(extra.id) continue } if (spell.kind === 'shield') { const extra = extraTarget([...shieldTargets]) if (extra) shieldTargets.add(extra.id) continue } const extra = extraTarget([...directTargets]) if (extra) directTargets.add(extra.id) } const nextParty = current.party.map((member) => { if (member.health <= 0) return member if (spell.kind === 'group') { if (!groupTargets.has(member.id)) return member if (spell.effectType === 'party_absorb') { const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost'))) return { ...member, shield: Math.max(member.shield, power) } } if (spell.effectType === 'party_hot') { return { ...member, hotTicks: 0, hotEffects: addHotEffect(member, spell), } } const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost'))) const nextHealth = healMember(member, power, healingMultiplier(member)) addFloatingHeal(member.id, Math.max(0, nextHealth - member.health)) const nextShield = spell.name === 'Radiance' && activeEffects.has('radiance_applies_shield') ? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.3)) : member.shield return { ...member, health: nextHealth, shield: nextShield, hotTicks: spell.name === 'Radiance' && activeEffects.has('radiance_applies_renew') ? 0 : member.hotTicks, hotEffects: spell.name === 'Radiance' && activeEffects.has('radiance_applies_renew') && renewEffect ? addHotEffect(member, renewEffect, 3) : member.hotEffects, } } if ( !directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id) && !(member.id === targetId && (spell.kind === 'damage_reduction' || spell.kind === 'bounce_heal')) ) return member if (spell.kind === 'shield') { const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost'))) return { ...member, hotTicks: activeEffects.has('shield_applies_renew') && renewEffect ? 0 : member.hotTicks, hotEffects: activeEffects.has('shield_applies_renew') && renewEffect ? addHotEffect(member, renewEffect) : member.hotEffects, shield: Math.max(member.shield, power), } } if (spell.kind === 'damage_reduction') { return { ...member, damageReductionTicks: 12 } } if (spell.kind === 'bounce_heal') { return { ...member, bounceHeals: addBounceHeal(member, spell) } } if (spell.kind === 'cleanse') { return { ...member, health: healMember(member, spell.power, healingMultiplier(member)), debuff: undefined, debuffTicks: undefined, poisonStacks: undefined, maxHealthPenaltyTicks: undefined, healingReductionTicks: undefined, } } const nextHealth = directTargets.has(member.id) ? healMember(member, spell.power, healingMultiplier(member)) : member.health if (nextHealth > member.health) addFloatingHeal(member.id, nextHealth - member.health) const nextShield = spell.name === 'Mend' && directTargets.has(member.id) && activeEffects.has('mend_applies_shield') ? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.5)) : member.shield const appliedHotSpell = spell.name === 'Mend' && activeEffects.has('mend_applies_renew') && renewEffect ? renewEffect : spell return { ...member, health: nextHealth, shield: nextShield, hotTicks: 0, hotEffects: hotTargets.has(member.id) ? addHotEffect(member, appliedHotSpell) : member.hotEffects, } }) const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free') const nextFreeCastReady = freeCastStacks > 0 && current.freeCastReady ? false : current.freeCastReady const nextCastsTowardFree = freeCastStacks > 0 ? current.freeCastReady ? 0 : current.castsTowardFree + 1 >= 5 ? 0 : current.castsTowardFree + 1 : current.castsTowardFree const gainedFreeCast = freeCastStacks > 0 && !current.freeCastReady && current.castsTowardFree + 1 >= 5 resourceSpentRef.current += effectiveCost const nextCooldowns = { ...current.cooldowns, } if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) { const radiance = spells.find((candidate) => candidate.name === 'Radiance') if (radiance) nextCooldowns[radiance.id] = Math.max(0, (nextCooldowns[radiance.id] ?? 0) - 2) } nextCooldowns[spell.id] = spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades) setCombat({ ...current, party: nextParty, resource: current.resource - effectiveCost, cooldowns: nextCooldowns, castsTowardFree: nextCastsTowardFree, freeCastReady: gainedFreeCast || nextFreeCastReady, }) addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal') }, [activeEffects, addFloatingHeal, addLog, gameClass.spells, healingPower, roguelikeUpgrades, setCombat, spells, 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 selectRelativeTarget = useCallback((direction: -1 | 1) => { const living = combatRef.current.party.filter((member) => member.health > 0) if (living.length === 0) return const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current) const nextIndex = currentIndex < 0 ? 0 : (currentIndex + direction + living.length) % living.length setSelectedTargetId(living[nextIndex].id) }, [setSelectedTargetId]) const selectDirectionalTarget = useCallback((action: InputAction) => { const columns = dungeon.partySize >= 10 ? 6 : 3 const currentIndex = combatRef.current.party.findIndex((member) => member.id === selectedIdRef.current) if (currentIndex < 0) { setSelectedTargetId(combatRef.current.party[0].id) return } const currentRow = Math.floor(currentIndex / columns) const currentColumn = currentIndex % columns const candidates = combatRef.current.party .map((member, index) => ({ member, index, row: Math.floor(index / columns), column: index % columns, })) .filter(({ index, row, column }) => { if (index === currentIndex) return false if (action === 'navigateLeft') return row === currentRow && column < currentColumn if (action === 'navigateRight') return row === currentRow && column > currentColumn if (action === 'navigateUp') return row < currentRow return row > currentRow }) .sort((a, b) => { const aPrimary = action === 'navigateLeft' || action === 'navigateRight' ? Math.abs(a.column - currentColumn) : Math.abs(a.row - currentRow) const bPrimary = action === 'navigateLeft' || action === 'navigateRight' ? Math.abs(b.column - currentColumn) : Math.abs(b.row - currentRow) const aSecondary = action === 'navigateLeft' || action === 'navigateRight' ? 0 : Math.abs(a.column - currentColumn) const bSecondary = action === 'navigateLeft' || action === 'navigateRight' ? 0 : Math.abs(b.column - currentColumn) return aPrimary - bPrimary || aSecondary - bSecondary }) if (candidates[0]) setSelectedTargetId(candidates[0].member.id) }, [dungeon.partySize, setSelectedTargetId]) const selectDirectTarget = useCallback((slot: number) => { const index = slot + (dungeon.partySize > 6 ? targetGroup * 6 : 0) const member = combatRef.current.party[index] if (member) setSelectedTargetId(member.id) }, [dungeon.partySize, setSelectedTargetId, targetGroup]) 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 groupCount = Math.max(1, Math.ceil(combatRef.current.party.length / 6)) const next = ((current + 1) % groupCount) as 0 | 1 | 2 const selectedIndex = combatRef.current.party.findIndex((member) => member.id === selectedIdRef.current) const nextMember = combatRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6] if (nextMember) setSelectedTargetId(nextMember.id) return next }) 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 = spells.find((candidate) => candidate.key === String(slot + 1)) if (spell) castSpell(spell) }) const runCombatTick = useCallback(() => { const current = combatRef.current const nextElapsedTicks = current.elapsedTicks + 1 const nextCooldowns = Object.fromEntries( Object.entries(current.cooldowns).map(([id, seconds]) => [ id, Math.max(0, seconds - TICK_MS / 1000), ]), ) let nextResource = clamp(current.resource + 2.4, 0, 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 pendingJumpHeals: Array<{ targetId: string heal: NonNullable[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) const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id ? Math.max(1, (member.poisonStacks ?? 0) + 1) : member.poisonStacks ?? 0 if (nextPoisonStacks > 0) damage += Math.round((4 + nextPoisonStacks * 4) * difficulty.damageMultiplier) damage *= enemyCount if ((member.damageReductionTicks ?? 0) > 0) { damage = Math.round(damage * 0.5) } if (member.shield > 0 && activeEffects.has('shielded_damage_reduction')) { damage = Math.round(damage * 0.8) } const absorbed = Math.min(member.shield, damage) const hotEffects = memberHotEffects(member) const healingMultiplier = member.shield > 0 && activeEffects.has('shielded_healing_bonus') ? 1.2 : 1 let healing = hotEffects.reduce((total, effect) => total + healAmount(member, effect.power, healingMultiplier), 0) let nextBounceHeals = [...(member.bounceHeals ?? [])] if (damage > 0 && nextBounceHeals.length > 0) { nextBounceHeals = nextBounceHeals.flatMap((effect) => { healing += healAmount(member, effect.power, healingMultiplier) const nextCharges = effect.charges - 1 if (nextCharges <= 0) return [] const jumpTargets = current.party.filter((candidate) => candidate.health > 0 && candidate.id !== member.id) const jumpTarget = jumpTargets[Math.floor(Math.random() * jumpTargets.length)] ?? member pendingJumpHeals.push({ targetId: jumpTarget.id, heal: { ...effect, charges: nextCharges }, }) return [] }) } if (healing > 0) addFloatingHeal(member.id, healing) const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id ? 15 : Math.max(0, (member.maxHealthPenaltyTicks ?? 0) - 1) const nextHealingReductionTicks = appliesHealingReduction && member.id === primaryTarget.id ? 15 : Math.max(0, (member.healingReductionTicks ?? 0) - 1) const nextEffectiveMaxHealth = Math.max(1, Math.round(member.maxHealth * (nextMaxHealthPenaltyTicks > 0 ? 0.75 : 1))) const nextDebuffTicks = appliesDebuff && member.id === primaryTarget.id ? 8 : Math.max(0, (member.debuffTicks ?? 0) - 1) return { ...member, health: clamp(clamp(member.health + healing, 0, nextEffectiveMaxHealth) - damage + absorbed, 0, nextEffectiveMaxHealth), shield: Math.max(0, member.shield - damage), hotTicks: 0, hotEffects: tickHotEffects(hotEffects), bounceHeals: nextBounceHeals, damageReductionTicks: Math.max(0, (member.damageReductionTicks ?? 0) - 1), debuff: nextDebuffTicks > 0 ? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff) : undefined, debuffTicks: nextDebuffTicks > 0 ? nextDebuffTicks : undefined, poisonStacks: nextPoisonStacks, maxHealthPenaltyTicks: nextMaxHealthPenaltyTicks, healingReductionTicks: nextHealingReductionTicks, } }) const nextParty = damagedParty.map((member) => { const jumped = pendingJumpHeals.filter((jump) => jump.targetId === member.id) if (jumped.length === 0) return member return { ...member, bounceHeals: [ ...(member.bounceHeals ?? []), ...jumped.map((jump) => jump.heal), ], } }) 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) } } 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, isPartBoss, isFinalBoss, isRoguelike, marathonMode, upgradesEveryEncounter, roguelikeUpgradeCatalog, roguelikeUpgrades, maxResource, gameClass.resourceName, requestLootRoll, 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 = 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() const dualScreenState = useMemo(() => ({ 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, log, status, resource, maxResource, resourceName: gameClass.resourceName, playerIsAlive, spells: profile.abilitySlots.map((abilityId, slotIndex) => { const spell = spells.find((candidate) => candidate.key === String(slotIndex + 1)) return abilityId && spell ? { ...spell, cost: spellResourceCost(spell, roguelikeUpgrades, freeCastReady), slotIndex, remaining: cooldowns[spell.id] ?? 0, } : null }), activeDevice: lastDevice, bindings: bindings[lastDevice], controllerIconStyle, directPartyTargeting, paused, targetGroup, speedMultiplier, }), [ bindings, controllerIconStyle, cooldowns, contentName, difficulty.name, dungeon.name, dungeon.partySize, directPartyTargeting, encounter.description, encounter.enemyName, encounter.isBoss, encounterMaxHealth, hardMode, enemyHealth, encounterIndex, encounters.length, gameClass.resourceName, log, lastDevice, maxResource, paused, party, playerIsAlive, profile.abilitySlots, resource, selectedId, spells, freeCastReady, floatingTexts, roguelikeUpgrades, speedMultiplier, status, targetGroup, ]) useDualScreenPublisher(dualScreenState, dualScreenEnabled) return (
{!dualScreenEnabled &&

{difficulty.name} - Item Level {difficulty.droppedItemLevel}

{dungeon.name}

{encounters.map((item, index) => ( {index + 1} ))}
} {!dualScreenEnabled && ( <>
{encounter.enemyName} {Math.ceil(enemyHealth)} / {encounterMaxHealth}
{hardMode ? (
{enemyHealthSegments.map((segment) => (
{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}
))}
) : (
)}

{encounter.description}

{playerIsAlive ? `${gameClass.resourceName} ${Math.floor(resource)} / ${maxResource}` : `${profile.character.name} is defeated`} {speedMultiplier === 2 && 2x speed}
= 10 ? 'raid-party-grid' : ''}`}> {party.map((member) => ( ))}
)} {dualScreenEnabled && ( )} {paused && status === 'playing' && (

Game Paused

{dungeon.name}

Combat is stopped. Resume the fight or leave the current run.

)} {status === 'upgrade-choice' && (

{encounter.isBoss ? `Roguelike Stage ${roguelikeStage} Complete` : `Encounter ${encounterIndex + 1} Complete`}

Choose Upgrade

Pick one upgrade before the next fight.

Run Buff
{upgradeChoices.map((upgrade) => ( ))}
{roguelikeUpgrades.length > 0 && (

Active: {summarizeUpgradeStacks(roguelikeUpgrades, roguelikeUpgradeCatalog)}

)}
)} {status !== 'playing' && status !== 'part-complete' && status !== 'marathon-choice' && status !== 'upgrade-choice' && (

{status === 'won' ? `${contentName} Complete` : 'Party Defeated'}

{status === 'won' ? 'The Warden Falls' : 'The Ashes Claim You'}

{status === 'won' ? (
{!reward && !rewardError &&

Recording victory...

} {rewardError &&

{rewardError}

} {reward && ( <>

+{reward.experienceGained} XP

{reward.levelsGained > 0 && (

Level {reward.previousLevel} to {reward.newLevel} +{reward.talentPointsGained} talent point

)} {reward.unlockedAbilities.map((ability) => (

{ability.glyph} Ability Unlocked: {ability.name}

))} {!isRoguelike && ( <>

Component tier: item level {reward.droppedItemLevel}.

{reward.resourceSpent} {gameClass.resourceName} spent {reward.durationSeconds}s - iLvl {reward.averageItemLevel.toFixed(1)}

{lootRolls.map((roll) => (
{roll.encounterName} {roll.items.length > 0 ? roll.items .map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`) .join(', ') : 'No components dropped'}
))} {lootRolls.length < expectedLootRolls && ( Finishing loot rolls... )}
{reward.bonusItem && (

Full Run Bonus

{reward.bonusItem.glyph} {reward.bonusItem.name} Item Level {reward.bonusItem.itemLevel} x{reward.bonusItem.quantity} {reward.bonusItem.duplicate && (owned x{reward.bonusItem.quantityAfter})}
)} )} )}
) : isRoguelike ? (
{!reward && !rewardError &&

Recording roguelike progress...

} {rewardError &&

{rewardError}

} {reward && ( <>

+{reward.experienceGained} XP

{encounterIndex} encounters cleared.

{reward.levelsGained > 0 && (

Level {reward.previousLevel} to {reward.newLevel} +{reward.talentPointsGained} talent point

)} {reward.unlockedAbilities.map((ability) => (

{ability.glyph} Ability Unlocked: {ability.name}

))}

{reward.resourceSpent} {gameClass.resourceName} spent {reward.durationSeconds}s survived

)}
) : (

Balance efficient healing, shields, and cleansing to survive.

)} {log.length > 0 && ( <> {showEndLog && (
{log.slice().reverse().map((entry) => (
{entry.text}
))}
)} )}
)} {status === 'marathon-choice' && (

Marathon

{encounter.enemyName} Defeated

{marathonBossesDefeated} boss{marathonBossesDefeated === 1 ? '' : 'es'} defeated. Continue with current health and {gameClass.resourceName}, or end the hunt.

)} {status === 'part-complete' && (

{sectionName} Complete

{encounter.enemyName} Defeated

{canContinueAfterPart ? `Proceed to ${sectionName} ${currentPart + 1} or end the run?` : 'Run checkpoint complete.'}

{canContinueAfterPart && ( )}
)}
) }