Files
i-want-to-heal/src/components/CombatScreen.tsx
T
2026-06-19 21:29:44 -04:00

1536 lines
59 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
completeDungeon,
completeRoguelike,
loadProfile,
type DungeonReward,
rollEncounterLoot,
type LootRoll,
} from '../profile'
import {
INITIAL_PARTY,
RAID_PARTY,
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<string, number>
elapsedTicks: number
castsTowardFree: number
freeCastReady: boolean
}
const ROGUELIKE_MECHANICS: RoguelikeMechanic[] = [
'party-pulse',
'searing-mark',
'max-health-cut',
'healing-reduction',
'tank-buster',
'resource-drain',
'ramping-poison',
]
function 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<RoguelikeUpgradeId, number>()
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<string, Spell['kind']> = {
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<T>(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<RoguelikeMechanic, string> = {
'party-pulse': 'party pulse',
'searing-mark': 'damage mark',
'max-health-cut': 'max health cut',
'healing-reduction': 'healing reduction',
'tank-buster': 'tank buster',
'resource-drain': 'resource drain',
'ramping-poison': 'ramping poison',
}
return labels[mechanic]
}
function makeRoguelikeSegment(
pool: DungeonEncounter[],
stage: number,
difficulty: Difficulty,
mode: RoguelikeMode,
): RoguelikeEncounter[] {
const 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<RoguelikeEncounter[]>(() =>
roguelikeMode ? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode) : [],
)
const encounters = isRoguelike ? roguelikeEncounters : staticEncounters
const gameClass = profile.classes.find(
(candidate) => candidate.id === profile.character.classId,
)!
const healingPower = isRoguelike ? 0 : profile.gearStats.healingPower
const spells = 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<SinglePlayerCombatState>(() => ({
party: partyTemplate,
resource: maxResource,
enemyHealth: encounters[initialEncounterIndex].maxHealth,
cooldowns: {},
elapsedTicks: 0,
castsTowardFree: 0,
freeCastReady: false,
}), [encounters, initialEncounterIndex, maxResource, partyTemplate])
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
const [paused, setPaused] = useState(false)
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
const [log, setLog] = useState<CombatLogEntry[]>([
{ id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
])
const [reward, setReward] = useState<DungeonReward | null>(null)
const [rewardError, setRewardError] = useState('')
const [lootRolls, setLootRolls] = useState<LootRoll[]>([])
const [showEndLog, setShowEndLog] = useState(false)
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([])
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
const rewardClaimedRef = useRef(false)
const profileRefreshedRef = useRef(false)
const rolledEncounterIdsRef = useRef(new Set<number>())
const runTokenRef = useRef(crypto.randomUUID())
const resourceSpentRef = useRef(0)
const runStartedAtRef = useRef(0)
const partStartTimesRef = useRef<Record<number, number>>({})
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<string>()
: 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<HTMLButtonElement>('.pause-screen button')?.focus({ preventScroll: true })
})
}, [paused])
const setCombat = useCallback((
nextState: SinglePlayerCombatState | ((current: SinglePlayerCombatState) => SinglePlayerCombatState),
) => {
const next = typeof nextState === 'function'
? nextState(combatRef.current)
: nextState
combatRef.current = next
setSelectedId(selectedIdRef.current)
setCombatState(next)
}, [])
const syncSelectedTargetDom = useCallback((id: string) => {
document.querySelectorAll<HTMLButtonElement>('[data-party-member-id]').forEach((button) => {
const selected = button.dataset.partyMemberId === id
button.classList.toggle('selected', selected)
button.setAttribute('aria-pressed', String(selected))
})
}, [])
const setSelectedTargetId = useCallback((id: string) => {
if (selectedIdRef.current === id) return
selectedIdRef.current = id
syncSelectedTargetDom(id)
}, [syncSelectedTargetDom])
useEffect(() => {
syncSelectedTargetDom(selectedIdRef.current)
}, [combatState, syncSelectedTargetDom])
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
const entry = { id: nextLogId.current++, text, tone }
setLog((current) => [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<string>()
const shieldTargets = new Set<string>()
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<DualScreenCombatState>(() => ({
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 (
<main
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
data-combat-active={paused ? 'false' : 'true'}
>
{!dualScreenEnabled && <header className="topbar">
<div>
<p className="eyebrow">{difficulty.name} - Item Level {difficulty.droppedItemLevel}</p>
<h1>{dungeon.name}</h1>
</div>
<div className="combat-header-actions">
<div className="run-progress" aria-label={`${contentName} progress`}>
{encounters.map((item, index) => (
<span className={index < encounterIndex ? 'complete' : index === encounterIndex ? 'active' : ''} key={item.id}>
{index + 1}
</span>
))}
</div>
<button className="back-button" onClick={() => setPaused(true)} type="button">Pause</button>
</div>
</header>}
{!dualScreenEnabled && (
<>
<section className="enemy-card">
<div className="enemy-portrait" aria-hidden="true">
{encounter.isBoss ? <img src={encounter.imageUrl} alt="" /> : 'M'}
</div>
<div className="enemy-info">
<div className="bar-label">
<strong>{encounter.enemyName}</strong>
<span>{Math.ceil(enemyHealth)} / {encounter.maxHealth}</span>
</div>
<div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></div>
<p>{encounter.description}</p>
</div>
</section>
<div className="combat-layout">
<section className="party-panel">
<div className="party-panel-top">
<div className="mana-wrap party-mana-wrap">
<span>
{playerIsAlive
? `${gameClass.resourceName} ${Math.floor(resource)} / ${maxResource}`
: `${profile.character.name} is defeated`}
</span>
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
</div>
</div>
<div className={`party-grid ${dungeon.partySize >= 10 ? 'raid-party-grid' : ''}`}>
{party.map((member) => (
<button
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
data-party-member-id={member.id}
key={member.id}
onClick={() => setSelectedTargetId(member.id)}
aria-pressed={selectedId === member.id}
type="button"
>
<span className="target-marker" aria-hidden="true">
<i />
Target
</span>
<div className="member-header">
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
<strong>{member.name}</strong>
<small>{Math.ceil(member.health)} / {effectiveMaxHealth(member)}</small>
</div>
<div className="bar member-health">
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
<em className="health-text">{Math.ceil(member.health)} / {effectiveMaxHealth(member)}</em>
</div>
<div className="floating-combat-texts" aria-hidden="true">
{floatingTexts
.filter((entry) => entry.memberId === member.id)
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
</div>
<div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>}
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
{member.poisonStacks && member.poisonStacks > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
{member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>}
{member.healingReductionTicks && member.healingReductionTicks > 0 && <span className="debuff">Healing -25% {formatEffectTime(member.healingReductionTicks)}</span>}
</div>
</button>
))}
</div>
</section>
<aside className="combat-log combat-side-rail">
<div className="panel-heading">
<div><p className="eyebrow">Actions</p><h2>Skills</h2></div>
</div>
<div className="spell-bar six-slots vertical-spell-bar">
{profile.abilitySlots.map((abilityId, slotIndex) => {
const spell = spells.find((candidate) => candidate.key === String(slotIndex + 1))
if (!abilityId || !spell) {
return (
<div className="spell empty-spell" key={`rail-empty-${slotIndex}`}>
<kbd>{slotIndex + 1}</kbd><strong>Empty</strong>
</div>
)
}
const remaining = cooldowns[spell.id] ?? 0
const effectiveCost = spellResourceCost(spell, roguelikeUpgrades, freeCastReady)
return (
<button
className="spell"
disabled={!playerIsAlive || resource < effectiveCost || remaining > 0 || status !== 'playing'}
key={`rail-${spell.id}`}
onClick={() => castSpell(spell)}
title={spell.description}
type="button"
>
<kbd>
<ControllerBindingLabel
binding={bindings[lastDevice][`ability${slotIndex + 1}` as InputAction]}
compact
iconStyle={controllerIconStyle}
/>
</kbd>
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
<strong>{spell.name}</strong>
<small>{effectiveCost} {gameClass.resourceName}</small>
{remaining > 0 && <i>{remaining.toFixed(1)}</i>}
</button>
)
})}
</div>
</aside>
</div>
</>
)}
{dualScreenEnabled && (
<DualScreenTopCombat
state={dualScreenState}
onSelectTarget={setSelectedTargetId}
/>
)}
{paused && status === 'playing' && (
<div className="pause-screen" role="dialog" aria-modal="true">
<div>
<p className="eyebrow">Game Paused</p>
<h2>{dungeon.name}</h2>
<p>Combat is stopped. Resume the fight or leave the current run.</p>
<button onClick={() => setPaused(false)} type="button">Resume</button>
<button className="secondary-result-button" onClick={onExit} type="button">
Leave {contentName}
</button>
</div>
</div>
)}
{status === 'upgrade-choice' && (
<div className="result-screen">
<div>
<p className="eyebrow">
{encounter.isBoss
? `Roguelike Stage ${roguelikeStage} Complete`
: `Encounter ${encounterIndex + 1} Complete`}
</p>
<h2>Choose Upgrade</h2>
<p>Pick one upgrade before the next fight.</p>
<div className="upgrade-choice-grid">
{upgradeChoices.map((upgrade) => (
<button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button">
<strong>{upgrade.name}</strong>
<small>{upgrade.description}</small>
</button>
))}
</div>
{roguelikeUpgrades.length > 0 && (
<p className="roguelike-upgrade-list">
Active: {summarizeUpgradeStacks(roguelikeUpgrades, roguelikeUpgradeCatalog)}
</p>
)}
<button className="secondary-result-button" onClick={onExit} type="button">Leave Roguelike</button>
</div>
</div>
)}
{status !== 'playing' && status !== 'part-complete' && status !== 'upgrade-choice' && (
<div className="result-screen">
<div>
<p className="eyebrow">{status === 'won' ? `${contentName} Complete` : 'Party Defeated'}</p>
<h2>{status === 'won' ? 'The Warden Falls' : 'The Ashes Claim You'}</h2>
{status === 'won' ? (
<div className="reward-summary">
{!reward && !rewardError && <p>Recording victory...</p>}
{rewardError && <p className="reward-error">{rewardError}</p>}
{reward && (
<>
<p>+{reward.experienceGained} XP</p>
{reward.levelsGained > 0 && (
<p className="level-gain">
Level {reward.previousLevel} to {reward.newLevel}
<small>+{reward.talentPointsGained} talent point</small>
</p>
)}
{reward.unlockedAbilities.map((ability) => (
<p className="ability-unlock" key={ability.id}>
<span>{ability.glyph}</span>
Ability Unlocked: {ability.name}
</p>
))}
{!isRoguelike && (
<>
<p>Component tier: item level {reward.droppedItemLevel}.</p>
<p className="efficiency-result">
{reward.resourceSpent} {gameClass.resourceName} spent
<small>
{reward.durationSeconds}s - iLvl {reward.averageItemLevel.toFixed(1)}
</small>
</p>
<div className="run-loot-rolls">
{lootRolls.map((roll) => (
<div className={roll.dropped ? 'dropped' : 'empty'} key={roll.encounterId}>
<strong>{roll.encounterName}</strong>
<span>
{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'}
</span>
</div>
))}
{lootRolls.length < expectedLootRolls && (
<small>Finishing loot rolls...</small>
)}
</div>
{reward.bonusItem && (
<div className="bonus-item">
<p className="eyebrow">Full Run Bonus</p>
<div className="bonus-item-detail">
<span>{reward.bonusItem.glyph}</span>
<strong className={`rarity-${reward.bonusItem.rarity}`}>{reward.bonusItem.name}</strong>
<small>Item Level {reward.bonusItem.itemLevel} x{reward.bonusItem.quantity}</small>
{reward.bonusItem.duplicate && <small> (owned x{reward.bonusItem.quantityAfter})</small>}
</div>
</div>
)}
</>
)}
</>
)}
</div>
) : isRoguelike ? (
<div className="reward-summary">
{!reward && !rewardError && <p>Recording roguelike progress...</p>}
{rewardError && <p className="reward-error">{rewardError}</p>}
{reward && (
<>
<p>+{reward.experienceGained} XP</p>
<p>{encounterIndex} encounters cleared.</p>
{reward.levelsGained > 0 && (
<p className="level-gain">
Level {reward.previousLevel} to {reward.newLevel}
<small>+{reward.talentPointsGained} talent point</small>
</p>
)}
{reward.unlockedAbilities.map((ability) => (
<p className="ability-unlock" key={ability.id}>
<span>{ability.glyph}</span>
Ability Unlocked: {ability.name}
</p>
))}
<p className="efficiency-result">
{reward.resourceSpent} {gameClass.resourceName} spent
<small>{reward.durationSeconds}s survived</small>
</p>
</>
)}
</div>
) : (
<p>Balance efficient healing, shields, and cleansing to survive.</p>
)}
{log.length > 0 && (
<>
<button className="secondary-result-button" onClick={() => setShowEndLog((value) => !value)} type="button">
{showEndLog ? 'Hide Combat Log' : 'View Combat Log'}
</button>
{showEndLog && (
<div className="result-log">
{log.slice().reverse().map((entry) => (
<div className={`log-entry ${entry.tone}`} key={entry.id}>{entry.text}</div>
))}
</div>
)}
</>
)}
<button onClick={resetRun} type="button">Run Again</button>
<button className="secondary-result-button" onClick={onExit} type="button">Leave {contentName}</button>
</div>
</div>
)}
{status === 'part-complete' && (
<div className="result-screen">
<div>
<p className="eyebrow">{sectionName} Complete</p>
<h2>{encounter.enemyName} Defeated</h2>
<p>Proceed to {sectionName} {currentPart + 1} or end the run?</p>
<button
onClick={() => {
const nextIndex = encounterIndex + 1
partStartTimesRef.current[currentPart + 1] = Date.now()
const nextEncounter = encounters[nextIndex]
const current = combatRef.current
const recoveredParty = current.party.map((member) => ({
...member,
health: clamp(member.health + 35, 0, member.maxHealth),
debuff: undefined,
debuffTicks: undefined,
}))
setEncounterIndex(nextIndex)
setCombat({
...current,
party: recoveredParty,
enemyHealth: nextEncounter.maxHealth,
elapsedTicks: 0,
})
setStatus('playing')
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
}}
type="button"
>
Continue to {sectionName} {currentPart + 1}
</button>
<button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
End Run
</button>
</div>
</div>
)}
</main>
)
}