import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { completeDungeon, completeRoguelike, loadProfile, type DungeonReward, rollEncounterLoot, type LootRoll, } from '../profile' import { INITIAL_PARTY, RAID_PARTY, 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' 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) { return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1)) } function healMember(member: PartyMember, amount: number) { return clamp(member.health + healAmount(member, amount), 0, effectiveMaxHealth(member)) } 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'] 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', heal_over_time: 'hot', party_heal: 'group', absorb: 'shield', 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', } } 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, profile, startPart = 1, roguelikeMode, roguelikeUpgradeTiming = 'boss', roguelikeAbilityLabelMode = 'ability', roguelikeEncounterPool, onExit, onProfileUpdated, }: { difficulty: Difficulty dungeon: Dungeon 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' : dungeon.contentType === 'raid' ? 'Phase' : 'Part' const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon' const initialEncounterIndex = (startPart - 1) * 3 const initialCombatState = useMemo(() => ({ party: partyTemplate, resource: maxResource, enemyHealth: encounters[initialEncounterIndex].maxHealth, cooldowns: {}, elapsedTicks: 0, castsTowardFree: 0, freeCastReady: false, }), [encounters, 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' | 'upgrade-choice'>('playing') const [paused, setPaused] = useState(false) 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 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 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 { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState const encounter = encounters[encounterIndex] const currentPart = getCurrentPart(encounterIndex) const firstEncounterIndex = (startPart - 1) * 3 const expectedLootRolls = encounters .slice(firstEncounterIndex, encounterIndex + 1) .filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id)) .length 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 activeSetEffects = useMemo( () => isRoguelike ? new Set() : new Set(profile.setBonuses.filter((bonus) => bonus.active).map((bonus) => bonus.effectType)), [isRoguelike, profile.setBonuses], ) const { bindings, controllerIconStyle, directPartyTargeting, lastDevice, } = useInput() const { enabled: dualScreenEnabled, } = useDualScreen() statusRef.current = status pausedRef.current = paused 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) => { if (rolledEncounterIdsRef.current.has(encounterId)) return rolledEncounterIdsRef.current.add(encounterId) rollEncounterLoot(encounterId, difficulty.id, runTokenRef.current) .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, 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([]) rewardClaimedRef.current = false profileRefreshedRef.current = false rolledEncounterIdsRef.current = new Set() runTokenRef.current = crypto.randomUUID() resourceSpentRef.current = 0 runStartedAtRef.current = Date.now() partStartTimesRef.current = { [startPart]: runStartedAtRef.current } setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }]) }, [difficulty, 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 directTargets = new Set([targetId]) const hotTargets = new Set() const shieldTargets = new Set() if (spell.kind === 'hot') hotTargets.add(targetId) if (spell.kind === 'shield') shieldTargets.add(targetId) if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) { const extra = extraTarget([targetId]) if (extra) directTargets.add(extra.id) } if (spell.name === 'Renew' && activeSetEffects.has('renew_extra_target')) { const extra = extraTarget([targetId]) if (extra) hotTargets.add(extra.id) } if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) { hotTargets.add(targetId) } const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId) 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') { const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost'))) const nextHealth = healMember(member, power) addFloatingHeal(member.id, Math.max(0, nextHealth - member.health)) return { ...member, health: nextHealth } } if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member if (spell.kind === 'shield') { const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost'))) return { ...member, shield: Math.max(member.shield, power) } } if (spell.kind === 'cleanse') { return { ...member, health: healMember(member, spell.power), debuff: undefined, debuffTicks: undefined, poisonStacks: undefined, maxHealthPenaltyTicks: undefined, healingReductionTicks: undefined, } } const nextHealth = directTargets.has(member.id) ? healMember(member, spell.power) : member.health if (nextHealth > member.health) addFloatingHeal(member.id, nextHealth - member.health) return { ...member, health: nextHealth, hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks, } }) 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 setCombat({ ...current, party: nextParty, resource: current.resource - effectiveCost, cooldowns: { ...current.cooldowns, [spell.id]: spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades), }, castsTowardFree: nextCastsTowardFree, freeCastReady: gainedFreeCast || nextFreeCastReady, }) addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal') }, [activeSetEffects, addFloatingHeal, addLog, roguelikeUpgrades, setCombat, 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)], ) .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, 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, })) 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, 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, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat]) useGameAction((action, device) => { 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 nextParty = current.party.map((member) => { if (member.health <= 0) return member let damage = member.id === primaryTarget.id ? encounter.damage : 0 if (member.role === 'Tank') damage += encounter.tankDamage if (tankBuster && member.role === 'Tank') damage += Math.round(22 * difficulty.damageMultiplier) 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) const absorbed = Math.min(member.shield, damage) const healing = member.hotTicks > 0 ? healAmount(member, 6) : 0 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(member.health - damage + absorbed + healing, 0, nextEffectiveMaxHealth), shield: Math.max(0, member.shield - damage), hotTicks: Math.max(0, member.hotTicks - 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 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 - 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)) { requestLootRoll(encounter.id) } 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) { setCombat({ ...current, party: nextParty, resource: nextResource, cooldowns: nextCooldowns, elapsedTicks: nextElapsedTicks, enemyHealth: 0, }) setStatus('part-complete') addLog(`${encounter.enemyName} is defeated.`, 'loot') return } if (encounterIndex === encounters.length - 1) { 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, })) setEncounterIndex((value) => value + 1) setCombat({ ...current, party: recoveredParty, resource: nextResource, cooldowns: nextCooldowns, elapsedTicks: 0, enemyHealth: nextEncounter.maxHealth, }) addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system') }, [ addLog, addFloatingHeal, difficulty.damageMultiplier, encounter, encounterIndex, encounters, finishRun, finishRoguelikeRun, isPartBoss, isFinalBoss, isRoguelike, 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 dueTicks = Math.min(4, Math.floor((now - lastCombatTickAtRef.current) / TICK_MS)) if (dueTicks <= 0) return lastCombatTickAtRef.current += dueTicks * TICK_MS 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 / encounter.maxHealth) * 100 const dualScreenState = useMemo(() => ({ difficultyName: difficulty.name, dungeonName: dungeon.name, contentName, encounterName: encounter.enemyName, encounterDescription: encounter.description, encounterHealth: enemyHealth, encounterMaxHealth: encounter.maxHealth, encounterIsBoss: encounter.isBoss, encounterIndex, encounterCount: encounters.length, party, 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, }), [ bindings, controllerIconStyle, cooldowns, contentName, difficulty.name, dungeon.name, dungeon.partySize, directPartyTargeting, encounter.description, encounter.enemyName, encounter.isBoss, encounter.maxHealth, enemyHealth, encounterIndex, encounters.length, gameClass.resourceName, log, lastDevice, maxResource, paused, party, playerIsAlive, profile.abilitySlots, resource, selectedId, spells, freeCastReady, roguelikeUpgrades, 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)} / {encounter.maxHealth}

{encounter.description}

{playerIsAlive ? `${gameClass.resourceName} ${Math.floor(resource)} / ${maxResource}` : `${profile.character.name} is defeated`}
= 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.

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

Active: {summarizeUpgradeStacks(roguelikeUpgrades, roguelikeUpgradeCatalog)}

)}
)} {status !== 'playing' && status !== 'part-complete' && 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 === 'part-complete' && (

{sectionName} Complete

{encounter.enemyName} Defeated

Proceed to {sectionName} {currentPart + 1} or end the run?

)}
) }