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 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({ 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) { return spellCooldownMultiplier(spell, { stacks: upgrades, id: (slot) => `slot${slot as SlotKey}-cooldown-down` as RoguelikeUpgradeId, }) } function spellResourceCost(spell: Spell, upgrades: StackCounts, 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 = { '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({ 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(() => 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(() => createPveCombatState({ partyTemplate, maxResource, encounter: encounters[initialEncounterIndex], enemyCount, }), [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 [roguelikeBossCoins, setRoguelikeBossCoins] = useState([]) const [showEndLog, setShowEndLog] = useState(false) const { floatingTexts, floatingTextsByMember, addFloatingText, clearFloatingTexts, } = useFloatingCombatText() 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 recordedBossKillIdsRef = useRef(new Set()) const runTokenRef = useRef(crypto.randomUUID()) const resourceSpentRef = useRef(0) const runStartedAtRef = useRef(0) const partStartTimesRef = useRef>({}) 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( 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('.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) => 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[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 (
{!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}

= 10 ? 'raid-party-grid' : ''}`}> {party.map((member) => ( ))}
)} {dualScreenEnabled && ( )} {paused && status === 'playing' && (

Game Paused

{dungeon.name}

Combat is stopped. Continue the fight or return to the main menu.

)} {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' && ( setShowEndLog((value) => !value)} showLog={showEndLog} title={status === 'won' ? 'The Warden Falls' : 'The Ashes Claim You'} > {status === 'won' ? (
{!reward && !rewardError &&

Recording victory...

} {rewardError &&

{rewardError}

} {reward && ( <> {isRoguelike &&

Run totals

} {isRoguelike && ( )} {!isRoguelike && ( <>

Component tier: item level {reward.droppedItemLevel}.

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

)} )}
) : isRoguelike ? (
{!reward && !rewardError &&

Recording roguelike progress...

} {rewardError &&

{rewardError}

} {reward && ( <>

{encounterIndex} encounters cleared.

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

)}
) : (

Balance efficient healing, shields, and cleansing to survive.

)}
)} {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 && ( )}
)}
) }