import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { INITIAL_PARTY, RAID_PARTY, DEFAULT_GROUP_HEAL_TARGETS, groupHealTargets, partyDamageOutput, tankPressureTargets, type CombatLogEntry, type PartyMember, type Spell, } from '../game' import { completeRoguelike, type DungeonReward } from '../profile' import type { Ability, CharacterProfile, DungeonEncounter } from '../profile' import type { GameMode } from '../gameRepository' import { ControllerBindingLabel } from './ControllerIcons' import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input' import { DualScreenTopCombat, useDualScreen, useDualScreenPublisher, type DualScreenCombatState, } from '../dualScreen' import { loadPvpRoguelikeCheckpoint, randomCpuDifficulty, recordCpuPvpLeaderboard, recordPvpRoguelikeCheckpoint, type CpuDifficulty, type PvpContentType, } from '../pvpRoguelike' const TICK_MS = 700 type BossMechanic = | 'party-pulse' | 'searing-mark' | 'max-health-cut' | 'healing-reduction' | 'ramping-poison' type PvpEncounter = DungeonEncounter & { bossMechanics?: BossMechanic[] sourceEncounterId?: number } type SlotKey = '1' | '2' | '3' | '4' | '5' type AbilityLabelMode = 'ability' | 'slot' type SelfBuffId = | `slot${SlotKey}-extra-target` | `slot${SlotKey}-cost-down` | `slot${SlotKey}-cooldown-down` | 'fifth-cast-free' | 'group-heal-boost' | 'shield-boost' type OpponentDebuffId = | `opp-slot${SlotKey}-cost-up` | `opp-slot${SlotKey}-cooldown-up` | 'opp-takes-more-damage' | 'opp-healing-reduced' | 'opp-resource-regen-down' | 'opp-cleanse-cooldown-up' | 'opp-purge-random-buff' type Choice = { id: T name: string description: string } type SideState = { party: PartyMember[] resource: number cooldowns: Record enemyHealth: number buffs: SelfBuffId[] debuffs: OpponentDebuffId[] castsTowardFree: number freeCastReady: boolean } type FloatingCombatText = { id: number memberId: string side: 'player' | 'cpu' value: number } type PvpRunSummary = { bossesKilled: number experienceGained: number previousLevel: number | null newLevel: number | null levelsGained: number talentPointsGained: number unlockedAbilities: DungeonReward['unlockedAbilities'] loot: Array> } const BOSS_MECHANICS: BossMechanic[] = [ 'party-pulse', 'searing-mark', 'max-health-cut', 'healing-reduction', 'ramping-poison', ] const CPU_BEHAVIOR: Record = { 1: { actionEveryTicks: 4, mistakeChance: 0.35, directHealThreshold: 0.52, groupHealThreshold: 0.48, hotThreshold: 0.58, shieldThreshold: 0.45 }, 2: { actionEveryTicks: 3, mistakeChance: 0.24, directHealThreshold: 0.58, groupHealThreshold: 0.55, hotThreshold: 0.66, shieldThreshold: 0.5 }, 3: { actionEveryTicks: 3, mistakeChance: 0.16, directHealThreshold: 0.64, groupHealThreshold: 0.6, hotThreshold: 0.72, shieldThreshold: 0.56 }, 4: { actionEveryTicks: 2, mistakeChance: 0.08, directHealThreshold: 0.7, groupHealThreshold: 0.66, hotThreshold: 0.78, shieldThreshold: 0.62 }, 5: { actionEveryTicks: 2, mistakeChance: 0.03, directHealThreshold: 0.76, groupHealThreshold: 0.72, hotThreshold: 0.82, shieldThreshold: 0.68 }, } function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)) } function chooseRandom(items: T[], count: number) { const pool = [...items] const result: T[] = [] while (pool.length > 0 && result.length < count) { result.push(pool.splice(Math.floor(Math.random() * pool.length), 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 createEmptyPvpRunSummary(): PvpRunSummary { return { bossesKilled: 0, experienceGained: 0, previousLevel: null, newLevel: null, levelsGained: 0, talentPointsGained: 0, unlockedAbilities: [], loot: [], } } function buffStacks(items: T[], id: T) { return items.filter((item) => item === id).length } function slotLabel(slot: SlotKey, spells: Spell[], labelMode: AbilityLabelMode) { const spell = spells.find((candidate) => candidate.key === slot) if (labelMode === 'ability' && spell) return spell.name return `Slot ${slot}` } function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array> { const slotChoices = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => { const label = slotLabel(slot, spells, labelMode) return [ { id: `slot${slot}-extra-target` as SelfBuffId, name: `${label}: +1 target`, description: `${label} affects 1 additional ally when possible.`, }, { id: `slot${slot}-cost-down` as SelfBuffId, name: `${label}: -25% cost`, description: `${label} costs 25% less resource.`, }, { id: `slot${slot}-cooldown-down` as SelfBuffId, name: `${label}: -25% cooldown`, description: `${label} recharges 25% faster.`, }, ] }) return [ ...slotChoices, { id: 'fifth-cast-free', name: 'Stored Momentum', description: 'After 5 casts, the 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 buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array> { const slotChoices = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => { const label = slotLabel(slot, spells, labelMode) return [ { id: `opp-slot${slot}-cost-up` as OpponentDebuffId, name: `${label}: +25% cost`, description: `Opponent ${label.toLowerCase()} costs 25% more resource.`, }, { id: `opp-slot${slot}-cooldown-up` as OpponentDebuffId, name: `${label}: +25% cooldown`, description: `Opponent ${label.toLowerCase()} recharges 25% slower.`, }, ] }) return [ ...slotChoices, { id: 'opp-takes-more-damage', name: 'Expose Weakness', description: 'Opponent takes 10% more damage.' }, { id: 'opp-healing-reduced', name: 'Blunted Recovery', description: 'Opponent healing is 15% weaker.' }, { id: 'opp-resource-regen-down', name: 'Mana Squeeze', description: 'Opponent resource regeneration is reduced by 25%.' }, { id: 'opp-cleanse-cooldown-up', name: 'Lingering Toxins', description: 'Opponent cleanse cooldown is 25% longer.' }, { id: 'opp-purge-random-buff', name: 'Strip Momentum', description: 'Remove 1 random buff from the opponent immediately.' }, ] } function toCombatSpell(ability: Ability, key: string): 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, glyph: ability.glyph, kind: kinds[ability.spellType] ?? 'direct', } } function effectiveMaxHealth(member: PartyMember) { return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1))) } function incomingDamageMultiplier(debuffs: OpponentDebuffId[]) { return 1.1 ** buffStacks(debuffs, 'opp-takes-more-damage') } function outgoingHealMultiplier(debuffs: OpponentDebuffId[]) { return 0.85 ** buffStacks(debuffs, 'opp-healing-reduced') } function healAmount(member: PartyMember, amount: number, debuffs: OpponentDebuffId[]) { const healingReduction = member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1 return Math.round(amount * healingReduction * outgoingHealMultiplier(debuffs)) } function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[]) { return clamp(member.health + healAmount(member, amount, debuffs), 0, effectiveMaxHealth(member)) } function cooldownMultiplier(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[]) { const slot = spell.key as SlotKey const downStacks = buffStacks(buffs, `slot${slot}-cooldown-down` as SelfBuffId) const upStacks = buffStacks(debuffs, `opp-slot${slot}-cooldown-up` as OpponentDebuffId) const cleansePenalty = spell.kind === 'cleanse' ? buffStacks(debuffs, 'opp-cleanse-cooldown-up') : 0 return (0.75 ** downStacks) * (1.25 ** (upStacks + cleansePenalty)) } function spellResourceCost(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[], freeCastReady: boolean) { const slot = spell.key as SlotKey const downStacks = buffStacks(buffs, `slot${slot}-cost-down` as SelfBuffId) const upStacks = buffStacks(debuffs, `opp-slot${slot}-cost-up` as OpponentDebuffId) const adjustedCost = Math.ceil(spell.cost * (0.75 ** downStacks) * (1.25 ** upStacks)) return freeCastReady && buffStacks(buffs, 'fifth-cast-free') > 0 ? 0 : adjustedCost } function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: PvpContentType): PvpEncounter[] { 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, 5 + stage * (kind === '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] ?? bossPool[0] ?? trashPool[0] ?? pool[0] const healthScale = 0.75 + stage * (kind === 'raid' ? 0.28 : 0.22) const damageScale = 0.8 + stage * (kind === 'raid' ? 0.18 : 0.14) const mechanics = chooseRandom(BOSS_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4)) return [...selectedTrash, selectedBoss].map((encounter, index) => { const isBoss = index === 2 return { ...encounter, sourceEncounterId: encounter.id, id: 910000 + stage * 10 + index, sequence: (stage - 1) * 3 + index + 1, isBoss, encounterType: isBoss ? 'boss' : 'trash', enemyName: isBoss ? `${encounter.enemyName} ${stage}` : encounter.enemyName, description: isBoss ? `PvP boss with ${mechanics.join(', ')}.` : encounter.description, maxHealth: Math.round(encounter.maxHealth * healthScale), damage: Math.round(encounter.damage * damageScale), tankDamage: Math.round(encounter.tankDamage * damageScale), partyDamage: Math.round(encounter.partyDamage * (0.85 + stage * 0.04)), lootTables: [], bossMechanics: isBoss ? mechanics : [], } }) } function starterSide(partyTemplate: PartyMember[], maxResource: number): SideState { return { party: partyTemplate.map((member) => ({ ...member })), resource: maxResource, cooldowns: {}, enemyHealth: 0, buffs: [], debuffs: [], castsTowardFree: 0, freeCastReady: false, } } function scoreSelfBuff(buff: Choice, spells: Spell[]) { if (buff.id === 'fifth-cast-free') return 8 if (buff.id === 'group-heal-boost') return 8 if (buff.id === 'shield-boost') return 6 const slot = buff.id.match(/slot([1-5])/i)?.[1] as SlotKey | undefined const spell = spells.find((candidate) => candidate.key === slot) if (!spell) return 5 if (buff.id.endsWith('extra-target')) { if (spell.kind === 'group') return 2 if (spell.kind === 'cleanse') return 7 return spell.kind === 'shield' ? 6 : 8 } if (buff.id.endsWith('cost-down')) return spell.cost >= 10 ? 8 : 6 return spell.cooldown >= 5 ? 8 : 6 } function scoreDebuff(debuff: Choice, opponentBuffCount: number) { if (debuff.id === 'opp-takes-more-damage') return 9 if (debuff.id === 'opp-healing-reduced') return 8 if (debuff.id === 'opp-resource-regen-down') return 7 if (debuff.id === 'opp-cleanse-cooldown-up') return 5 if (debuff.id === 'opp-purge-random-buff') return opponentBuffCount > 0 ? 8 : 2 if (debuff.id.endsWith('cost-up')) return 7 return 6 } function selectCpuChoice( choices: Array>, skill: CpuDifficulty, score: (choice: Choice) => number, ) { const ranked = [...choices].sort((left, right) => score(right) - score(left)) if (skill <= 2) return ranked[Math.floor(Math.random() * ranked.length)] if (skill === 3) return ranked[Math.floor(Math.random() * Math.min(2, ranked.length))] if (skill === 4) return ranked[Math.floor(Math.random() * Math.min(1, ranked.length))] return ranked[0] } function removeRandomBuff(side: SideState) { if (side.buffs.length === 0) return side const nextBuffs = [...side.buffs] nextBuffs.splice(Math.floor(Math.random() * nextBuffs.length), 1) return { ...side, buffs: nextBuffs } } function createLogEntry(nextLogId: { current: number }, text: string, tone: CombatLogEntry['tone']) { return { id: nextLogId.current++, text, tone } } function summarizeStacks(items: T[], catalog: Array>) { const counts = new Map() items.forEach((item) => counts.set(item, (counts.get(item) ?? 0) + 1)) return Array.from(counts.entries()) .map(([id, count]) => { const label = catalog.find((choice) => choice.id === id)?.name ?? id return count > 1 ? `${label} x${count}` : label }) .join(', ') } export function PvPRoguelikeScreen({ profile, gameMode, contentType, encounterPool, onExit, onProfileUpdated, }: { profile: CharacterProfile gameMode: GameMode contentType: PvpContentType encounterPool: DungeonEncounter[] onExit: () => void onProfileUpdated: (profile: CharacterProfile) => void }) { const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)! const starterSpells = useMemo(() => gameClass.spells .filter((spell) => spell.unlockLevel === 1) .slice(0, 5) .map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells]) const [abilityLabelMode] = useState('ability') const selfBuffChoicesCatalog = useMemo( () => buildSelfBuffChoices(starterSpells, abilityLabelMode), [abilityLabelMode, starterSpells], ) const opponentDebuffChoicesCatalog = useMemo( () => buildOpponentDebuffChoices(starterSpells, abilityLabelMode), [abilityLabelMode, starterSpells], ) const [checkpointStage, setCheckpointStage] = useState(() => loadPvpRoguelikeCheckpoint(profile.character.id, contentType), ) const [startStage, setStartStage] = useState(checkpointStage) const maxResource = gameClass.maxResource const partyTemplate = useMemo( () => (contentType === 'raid' ? RAID_PARTY : INITIAL_PARTY).map((member) => ({ ...member, name: member.id === 'mira' ? profile.character.name : member.name, })), [contentType, profile.character.name], ) const cpuPartyTemplate = useMemo( () => (contentType === 'raid' ? RAID_PARTY : INITIAL_PARTY).map((member) => ({ ...member, name: member.id === 'mira' ? 'CPU Healer' : member.name, })), [contentType], ) const [status, setStatus] = useState<'queueing' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing') const [stage, setStage] = useState(startStage) const [encounters, setEncounters] = useState(() => buildEncounterSegment(encounterPool, startStage, contentType)) const [encounterIndex, setEncounterIndex] = useState(0) const [playerSide, setPlayerSide] = useState(() => starterSide(partyTemplate, maxResource)) const [cpuSide, setCpuSide] = useState(() => starterSide(cpuPartyTemplate, maxResource)) const [selectedId, setSelectedId] = useState(partyTemplate[0].id) const selectedIdRef = useRef(partyTemplate[0].id) const [elapsedTicks, setElapsedTicks] = useState(0) const [cpuDifficulty, setCpuDifficulty] = useState(null) const [queueMessage, setQueueMessage] = useState('') const [log, setLog] = useState([{ id: 1, text: 'Queueing opponent...', tone: 'system' }]) const [reward, setReward] = useState(null) const [runSummary, setRunSummary] = useState(() => createEmptyPvpRunSummary()) const [rewardError, setRewardError] = useState('') const [showEndLog, setShowEndLog] = useState(false) const [floatingTexts, setFloatingTexts] = useState([]) const [playerBuffChoices, setPlayerBuffChoices] = useState>>([]) const [playerDebuffChoices, setPlayerDebuffChoices] = useState>>([]) const [selectedBuff, setSelectedBuff] = useState | null>(null) const [selectedDebuff, setSelectedDebuff] = useState | null>(null) const [encountersCleared, setEncountersCleared] = useState(0) const [paused, setPaused] = useState(false) const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0) const nextLogId = useRef(2) const nextFloatingTextId = useRef(1) const recordedRunRef = useRef(false) const rewardClaimedRef = useRef(false) const bossRewardClaimedRef = useRef(new Set()) const cpuDefeatedRef = useRef(false) const playerClearedEncounterRef = useRef(-1) const queuedMatchRef = useRef(false) const encounterPoolRef = useRef(encounterPool) const playerRef = useRef(playerSide) const cpuRef = useRef(cpuSide) const encounter = encounters[encounterIndex] const rewardDungeon = useMemo( () => profile.dungeons.find((candidate) => candidate.contentType === contentType) ?? profile.dungeons[0], [contentType, profile.dungeons], ) const rewardDifficulty = rewardDungeon.difficulties[0] const finalEncountersCleared = status === 'won' ? Math.max(encountersCleared, encounterIndex + 1) : encountersCleared const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1] const playerDone = playerSide.enemyHealth <= 0 const cpuDone = cpuSide.enemyHealth <= 0 const playerAlive = playerSide.party.some((member) => member.health > 0) const cpuAlive = cpuSide.party.some((member) => member.health > 0) const partyColumns = contentType === 'raid' ? 6 : 3 const { bindings, controllerIconStyle, directPartyTargeting, lastDevice, } = useInput() const { enabled: dualScreenEnabled, } = useDualScreen() const setSelectedTargetId = useCallback((id: string) => { selectedIdRef.current = id setSelectedId(id) }, []) const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => { setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60)) }, []) const addFloatingHeal = useCallback((side: 'player' | 'cpu', memberId: string, value: number) => { if (value <= 0) return const id = nextFloatingTextId.current++ setFloatingTexts((current) => [...current, { id, side, memberId, value }]) window.setTimeout(() => { setFloatingTexts((current) => current.filter((entry) => entry.id !== id)) }, 900) }, []) useEffect(() => { if (queuedMatchRef.current) return const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType) setCheckpointStage(loadedCheckpoint) setStartStage(loadedCheckpoint) }, [contentType, profile.character.id]) useEffect(() => { encounterPoolRef.current = encounterPool }, [encounterPool]) const awardBossReward = useCallback((encounterIndexValue: number) => { if (bossRewardClaimedRef.current.has(encounterIndexValue)) return bossRewardClaimedRef.current.add(encounterIndexValue) const rewardEncounter = encounters[encounterIndexValue] completeRoguelike( rewardDungeon.id, rewardDifficulty.id, 0, 0, Math.max(1, Math.round((elapsedTicks * TICK_MS) / 1000)), { bossesCleared: 1, experienceMode: 'pvp-boss-quarter-level', lootSourceEncounterId: rewardEncounter?.sourceEncounterId, roguelikeStage: stage, }, ) .then((result) => { setReward(result) setRunSummary((current) => { const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability])) result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability)) return { bossesKilled: current.bossesKilled + 1, experienceGained: current.experienceGained + result.experienceGained, previousLevel: current.previousLevel ?? result.previousLevel, newLevel: result.newLevel, levelsGained: current.levelsGained + result.levelsGained, talentPointsGained: current.talentPointsGained + result.talentPointsGained, unlockedAbilities: Array.from(unlockedById.values()), loot: result.bonusItem ? [...current.loot, result.bonusItem] : current.loot, } }) onProfileUpdated(result.profile) if (result.bonusItem) { addLog( `${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`, 'loot', ) } }) .catch((reason: unknown) => { setRewardError( reason instanceof Error ? reason.message : 'Unable to award roguelike experience.', ) }) }, [addLog, elapsedTicks, encounters, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id, stage]) const finishRoguelikeRun = useCallback(() => { if (rewardClaimedRef.current) return rewardClaimedRef.current = true }, []) useEffect(() => { setPlayerBuffChoices((current) => current .map((choice) => selfBuffChoicesCatalog.find((candidate) => candidate.id === choice.id)) .filter((choice): choice is Choice => Boolean(choice))) setPlayerDebuffChoices((current) => current .map((choice) => opponentDebuffChoicesCatalog.find((candidate) => candidate.id === choice.id)) .filter((choice): choice is Choice => Boolean(choice))) setSelectedBuff((current) => current ? selfBuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current : null) setSelectedDebuff((current) => current ? opponentDebuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current : null) }, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog]) const startMatch = useCallback((nextStartStage?: number) => { const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType) const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType) const firstEncounter = firstSegment[0] const basePlayer = starterSide(partyTemplate, maxResource) const baseCpu = starterSide(cpuPartyTemplate, maxResource) basePlayer.enemyHealth = firstEncounter.maxHealth baseCpu.enemyHealth = firstEncounter.maxHealth playerRef.current = basePlayer cpuRef.current = baseCpu nextLogId.current = 2 playerClearedEncounterRef.current = -1 queuedMatchRef.current = true bossRewardClaimedRef.current = new Set() setEncounters(firstSegment) setEncounterIndex(0) setCheckpointStage(matchStartStage) setStartStage(matchStartStage) setStage(matchStartStage) setElapsedTicks(0) setStatus('queueing') setPlayerSide(basePlayer) setCpuSide(baseCpu) setSelectedTargetId(partyTemplate[0].id) setPlayerBuffChoices([]) setPlayerDebuffChoices([]) setSelectedBuff(null) setSelectedDebuff(null) setEncountersCleared(0) setPaused(false) setTargetGroup(0) setReward(null) setRunSummary(createEmptyPvpRunSummary()) setRewardError('') setShowEndLog(false) setFloatingTexts([]) setCpuDifficulty(null) recordedRunRef.current = false rewardClaimedRef.current = false cpuDefeatedRef.current = false if (gameMode === 'offline') { const randomCpu = randomCpuDifficulty() setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`) setCpuDifficulty(randomCpu) setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`, tone: 'system' }]) const timer = window.setTimeout(() => { setStatus('playing') addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system') }, 500) return () => window.clearTimeout(timer) } setQueueMessage(`Searching queue. Stage ${matchStartStage} start ready.`) setLog([{ id: 1, text: `Searching queue. Stage ${matchStartStage} start ready.`, tone: 'system' }]) const timer = window.setTimeout(() => { const randomCpu = randomCpuDifficulty() setCpuDifficulty(randomCpu) setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`) setStatus('playing') addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${matchStartStage}.`, 'system') }, 1400) return () => window.clearTimeout(timer) }, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId]) useEffect(() => startMatch(), [startMatch]) const applySpell = useCallback(( current: SideState, setCurrent: React.Dispatch>, sideName: 'player' | 'cpu', buffs: SelfBuffId[], debuffs: OpponentDebuffId[], spell: Spell, targetId: string, ) => { const effectiveCost = spellResourceCost(spell, buffs, debuffs, current.freeCastReady) if (current.resource < effectiveCost || (current.cooldowns[spell.id] ?? 0) > 0) return false const target = current.party.find((member) => member.id === targetId && member.health > 0) if (!target) return false const livingTargets = current.party.filter((member) => member.health > 0) const extraTarget = (blockedIds: string[]) => livingTargets .filter((member) => !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(spell.kind === 'hot' ? [targetId] : []) const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : []) const extraTargets = buffStacks(buffs, `slot${spell.key as SlotKey}-extra-target` as SelfBuffId) const groupTargets = new Set( spell.kind === 'group' ? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id) : [], ) for (let index = 0; index < extraTargets; index += 1) { if (spell.kind === 'group') break if (spell.kind === 'hot') { const extra = extraTarget([...hotTargets]) if (extra) hotTargets.add(extra.id) continue } if (spell.kind === 'shield') { const extra = extraTarget([...shieldTargets]) if (extra) shieldTargets.add(extra.id) continue } const extra = extraTarget([...directTargets]) if (extra) directTargets.add(extra.id) } const nextParty = current.party.map((member) => { if (member.health <= 0) return member if (spell.kind === 'group') { if (!groupTargets.has(member.id)) return member const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost'))) const nextHealth = healMember(member, groupPower, debuffs) addFloatingHeal(sideName, 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 shieldPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'shield-boost'))) return { ...member, shield: Math.max(member.shield, shieldPower) } } if (spell.kind === 'cleanse') { const nextHealth = healMember(member, spell.power, debuffs) addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health)) return { ...member, health: nextHealth, debuff: undefined, debuffTicks: undefined, poisonStacks: undefined, maxHealthPenaltyTicks: undefined, healingReductionTicks: undefined, } } const nextHealth = directTargets.has(member.id) ? healMember(member, spell.power, debuffs) : member.health if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health) return { ...member, health: nextHealth, hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks, } }) const freeCastStacks = buffStacks(buffs, '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 const nextState: SideState = { ...current, party: nextParty, resource: current.resource - effectiveCost, cooldowns: { ...current.cooldowns, [spell.id]: spell.cooldown * cooldownMultiplier(spell, buffs, debuffs), }, castsTowardFree: nextCastsTowardFree, freeCastReady: gainedFreeCast || nextFreeCastReady, } setCurrent(nextState) return true }, [addFloatingHeal]) const castPlayerSpell = useCallback((spell: Spell) => { if (status !== 'playing' || playerDone || !playerAlive) return const targetId = selectedIdRef.current const succeeded = applySpell(playerRef.current, (value) => { const next = typeof value === 'function' ? value(playerRef.current) : value playerRef.current = next setPlayerSide(next) }, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, targetId) if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal') }, [addLog, applySpell, playerAlive, playerDone, status]) const selectRelativeTarget = useCallback((direction: -1 | 1) => { const living = playerRef.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 currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current) if (currentIndex < 0) { const firstLiving = playerRef.current.party.find((member) => member.health > 0) if (firstLiving) setSelectedTargetId(firstLiving.id) return } const currentRow = Math.floor(currentIndex / partyColumns) const currentColumn = currentIndex % partyColumns const candidates = playerRef.current.party .map((member, index) => ({ member, index, row: Math.floor(index / partyColumns), column: index % partyColumns, })) .filter(({ member, index, row, column }) => { if (member.health <= 0 || 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 horizontal = action === 'navigateLeft' || action === 'navigateRight' const aPrimary = horizontal ? Math.abs(a.column - currentColumn) : Math.abs(a.row - currentRow) const bPrimary = horizontal ? Math.abs(b.column - currentColumn) : Math.abs(b.row - currentRow) const aSecondary = horizontal ? 0 : Math.abs(a.column - currentColumn) const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn) return aPrimary - bPrimary || aSecondary - bSecondary }) if (candidates[0]) setSelectedTargetId(candidates[0].member.id) }, [partyColumns, setSelectedTargetId]) const selectDirectTarget = useCallback((slot: number) => { const index = slot + (contentType === 'raid' ? targetGroup * 6 : 0) const member = playerRef.current.party[index] if (member?.health > 0) setSelectedTargetId(member.id) }, [contentType, setSelectedTargetId, targetGroup]) const cpuTakeTurn = useCallback(() => { if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return if (elapsedTicks % cpuBehavior.actionEveryTicks !== 0) return if (Math.random() < cpuBehavior.mistakeChance) return const side = cpuRef.current const spells = starterSpells const living = side.party.filter((member) => member.health > 0) const lowest = [...living].sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0] const tank = living.find((member) => member.role === 'Tank') const wounded = living.filter((member) => member.health / effectiveMaxHealth(member) < cpuBehavior.directHealThreshold) const averageHealth = living.reduce((total, member) => total + member.health / effectiveMaxHealth(member), 0) / Math.max(1, living.length) const cleanseTarget = living.find((member) => member.debuff || (member.poisonStacks ?? 0) > 0) const renewTarget = living.find((member) => member.hotTicks <= 1 && member.health / effectiveMaxHealth(member) < cpuBehavior.hotThreshold) const shieldTarget = living.find((member) => member.role === 'Tank' && member.shield <= 5 && member.health / effectiveMaxHealth(member) < cpuBehavior.shieldThreshold) const ordered: Array<{ spell: Spell | undefined; targetId: string | null }> = [ { spell: cleanseTarget ? spells.find((candidate) => candidate.kind === 'cleanse') : undefined, targetId: cleanseTarget?.id ?? null }, { spell: averageHealth < cpuBehavior.groupHealThreshold ? spells.find((candidate) => candidate.kind === 'group') : undefined, targetId: lowest?.id ?? null }, { spell: shieldTarget ? spells.find((candidate) => candidate.kind === 'shield') : undefined, targetId: shieldTarget?.id ?? null }, { spell: wounded.length > 0 ? spells.find((candidate) => candidate.kind === 'direct') : undefined, targetId: lowest?.id ?? null }, { spell: renewTarget ? spells.find((candidate) => candidate.kind === 'hot') : undefined, targetId: renewTarget?.id ?? null }, { spell: tank ? spells.find((candidate) => candidate.kind === 'direct') : undefined, targetId: tank?.id ?? null }, ] for (const action of ordered) { if (!action.spell || !action.targetId) continue const succeeded = applySpell(cpuRef.current, (value) => { const next = typeof value === 'function' ? value(cpuRef.current) : value cpuRef.current = next setCpuSide(next) }, 'cpu', cpuRef.current.buffs, cpuRef.current.debuffs, action.spell, action.targetId) if (succeeded) return } }, [applySpell, cpuAlive, cpuBehavior, cpuDifficulty, cpuDone, elapsedTicks, starterSpells, status]) const advanceSide = useCallback((side: SideState, sideName: 'player' | 'cpu', encounterValue: PvpEncounter): SideState => { if (side.enemyHealth <= 0) return side const living = side.party.filter((member) => member.health > 0) if (living.length === 0) return side const primaryTarget = living[Math.floor(Math.random() * living.length)] const mechanics = encounterValue.bossMechanics ?? [] const bossPulse = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 7 === 0 && mechanics.includes('party-pulse') const appliesDebuff = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 11 === 0 && mechanics.includes('searing-mark') const appliesMaxHealthCut = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 13 === 0 && mechanics.includes('max-health-cut') const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction') const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison') const damageMultiplier = incomingDamageMultiplier(side.debuffs) const tankPressure = tankPressureTargets(side.party) const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id)) const nextParty = side.party.map((member) => { if (member.health <= 0) return member let damage = member.id === primaryTarget.id ? encounterValue.damage : 0 if (tankPressureIds.has(member.id)) { damage += Math.round(encounterValue.tankDamage * tankPressure.multiplier) } if (bossPulse) damage += 10 if (member.debuff) damage += 6 const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id ? Math.max(1, (member.poisonStacks ?? 0) + 1) : member.poisonStacks ?? 0 if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3 damage = Math.round(damage * damageMultiplier) const absorbed = Math.min(member.shield, damage) const healing = member.hotTicks > 0 ? healAmount(member, 6, side.debuffs) : 0 if (healing > 0) addFloatingHeal(sideName, member.id, healing) const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id ? 14 : Math.max(0, (member.maxHealthPenaltyTicks ?? 0) - 1) const nextHealingReductionTicks = appliesHealingReduction && member.id === primaryTarget.id ? 14 : Math.max(0, (member.healingReductionTicks ?? 0) - 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, Math.max(1, Math.round(member.maxHealth * (nextMaxHealthPenaltyTicks > 0 ? 0.75 : 1))), ), 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, } }) return { ...side, party: nextParty, resource: clamp( side.resource + 2.4 * (0.75 ** buffStacks(side.debuffs, 'opp-resource-regen-down')), 0, maxResource, ), cooldowns: Object.fromEntries( Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]), ), enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)), } }, [addFloatingHeal, elapsedTicks, maxResource]) const beginUpgradePhase = useCallback(() => { setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3)) setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3)) setSelectedBuff(null) setSelectedDebuff(null) setStatus('upgrade-choice') }, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog]) useEffect(() => { if (status !== 'playing' || paused || !encounter) return const timer = window.setInterval(() => { setElapsedTicks((value) => value + 1) cpuTakeTurn() const nextPlayer = advanceSide(playerRef.current, 'player', encounter) const nextCpu = advanceSide(cpuRef.current, 'cpu', encounter) if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) { playerClearedEncounterRef.current = encounterIndex setEncountersCleared((value) => value + 1) if (encounter.isBoss) { awardBossReward(encounterIndex) const nextCheckpoint = recordPvpRoguelikeCheckpoint( profile.character.id, contentType, stage, ) if (nextCheckpoint > checkpointStage) { setCheckpointStage(nextCheckpoint) addLog(`Stage ${nextCheckpoint} checkpoint unlocked.`, 'loot') } } } playerRef.current = nextPlayer cpuRef.current = nextCpu setPlayerSide(nextPlayer) setCpuSide(nextCpu) const nextPlayerAlive = nextPlayer.party.some((member) => member.health > 0) const nextCpuAlive = nextCpu.party.some((member) => member.health > 0) if (!nextPlayerAlive) { finishRoguelikeRun() setStatus('lost') addLog('Your party fell first.', 'danger') return } if (!nextCpuAlive && !cpuDefeatedRef.current) { cpuDefeatedRef.current = true addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot') } if (nextPlayer.enemyHealth <= 0) { if (encounter.isBoss && cpuDefeatedRef.current) { finishRoguelikeRun() setStatus('won') addLog('CPU defeated. Match complete.', 'loot') return } addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot') beginUpgradePhase() } }, TICK_MS) return () => window.clearInterval(timer) }, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, stage, status]) useEffect(() => { if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return recordedRunRef.current = true recordCpuPvpLeaderboard({ characterName: profile.character.name, className: profile.character.className, contentType, encountersCleared: finalEncountersCleared, cpuDifficulty, result: status === 'won' ? 'victory' : 'defeat', completedAt: new Date().toISOString(), }) }, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status]) useEffect(() => { if (status !== 'upgrade-choice') return window.requestAnimationFrame(() => focusFirstControl()) }, [status]) useEffect(() => { if (!paused) return window.requestAnimationFrame(() => focusFirstControl()) }, [paused]) const confirmUpgradeChoices = useCallback(() => { if (!selectedBuff || !selectedDebuff || !cpuDifficulty) return const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3) const cpuDebuffChoices = chooseRandom(opponentDebuffChoicesCatalog, 3) const cpuBuff = selectCpuChoice(cpuBuffChoices, cpuDifficulty, (choice) => scoreSelfBuff(choice, starterSpells)) const cpuDebuff = selectCpuChoice(cpuDebuffChoices, cpuDifficulty, (choice) => scoreDebuff(choice, playerRef.current.buffs.length)) let nextPlayer = { ...playerRef.current, buffs: [...playerRef.current.buffs, selectedBuff.id], } let nextCpu = { ...cpuRef.current, buffs: [...cpuRef.current.buffs, cpuBuff.id], } if (selectedDebuff.id === 'opp-purge-random-buff') { nextCpu = removeRandomBuff(nextCpu) } else { nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, selectedDebuff.id] } } if (cpuDebuff.id === 'opp-purge-random-buff') { nextPlayer = removeRandomBuff(nextPlayer) } else { nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, cpuDebuff.id] } } const clearedBoss = encounter.isBoss if (clearedBoss && cpuDefeatedRef.current) { finishRoguelikeRun() setStatus('won') addLog('CPU defeated. Match complete.', 'loot') return } const nextStage = clearedBoss ? stage + 1 : stage const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : [] const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1] if (!nextEncounter) { finishRoguelikeRun() setStatus('won') addLog('No further encounters remain.', 'loot') return } nextPlayer = { ...nextPlayer, party: nextPlayer.party.map((member) => ({ ...member, health: member.health <= 0 ? 0 : clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth), debuff: undefined, debuffTicks: undefined, poisonStacks: undefined, maxHealthPenaltyTicks: undefined, healingReductionTicks: undefined, })), resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource), cooldowns: {}, enemyHealth: nextEncounter.maxHealth, } nextCpu = { ...nextCpu, party: nextCpu.party.map((member) => ({ ...member, health: member.health <= 0 ? 0 : clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth), debuff: undefined, debuffTicks: undefined, poisonStacks: undefined, maxHealthPenaltyTicks: undefined, healingReductionTicks: undefined, })), resource: clamp(nextCpu.resource + Math.round(maxResource * 0.25), 0, maxResource), cooldowns: {}, enemyHealth: nextEncounter.maxHealth, } if (clearedBoss) { setStage(nextStage) setEncounters((current) => [...current, ...nextSegment]) } setEncounterIndex((value) => value + 1) setPlayerSide(nextPlayer) setCpuSide(nextCpu) playerRef.current = nextPlayer cpuRef.current = nextCpu setElapsedTicks(0) setStatus('playing') addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system') }, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells]) useGameAction((action) => { if (action === 'pause' || action === 'back') { if (status === 'playing') setPaused((value) => !value) return } if (paused || status !== 'playing') return if (action.startsWith('navigate')) { selectDirectionalTarget(action) return } if (action === 'previousTarget') { selectRelativeTarget(-1) return } if (action === 'nextTarget') { selectRelativeTarget(1) return } if (action.startsWith('targetParty')) { selectDirectTarget(Number(action.slice('targetParty'.length)) - 1) return } if (action === 'toggleTargetGroup') { if (contentType !== 'raid') return setTargetGroup((current) => { const groupCount = Math.max(1, Math.ceil(playerRef.current.party.length / 6)) const next = ((current + 1) % groupCount) as 0 | 1 | 2 const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current) const nextMember = playerRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6] if (nextMember?.health > 0) setSelectedTargetId(nextMember.id) return next }) return } if (action.startsWith('ability')) { const spell = starterSpells.find((candidate) => candidate.key === action.slice('ability'.length)) if (spell) castPlayerSpell(spell) } }) const dualScreenState = useMemo(() => ({ difficultyName: `Stage ${stage}`, dungeonName: encounter.enemyName, contentName: 'PvP Roguelike', encounterName: encounter.enemyName, encounterDescription: encounter.description, encounterHealth: playerSide.enemyHealth, encounterMaxHealth: encounter.maxHealth, encounterIsBoss: encounter.isBoss, encounterIndex, encounterCount: encounters.length, party: playerSide.party, partySize: playerSide.party.length, selectedId, log, status: status === 'queueing' ? 'playing' : status, resource: playerSide.resource, maxResource, resourceName: gameClass.resourceName, playerIsAlive: playerAlive, spells: starterSpells.map((spell, slotIndex) => ({ ...spell, cost: spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady), slotIndex, remaining: playerSide.cooldowns[spell.id] ?? 0, })), activeDevice: lastDevice, bindings: bindings[lastDevice], controllerIconStyle, directPartyTargeting, paused, targetGroup, }), [ bindings, controllerIconStyle, directPartyTargeting, encounter.description, encounter.enemyName, encounter.isBoss, encounter.maxHealth, encounterIndex, encounters.length, gameClass.resourceName, lastDevice, log, maxResource, paused, playerAlive, playerSide.buffs, playerSide.cooldowns, playerSide.debuffs, playerSide.enemyHealth, playerSide.freeCastReady, playerSide.party, playerSide.resource, selectedId, stage, starterSpells, status, targetGroup, ]) useDualScreenPublisher(dualScreenState, dualScreenEnabled) return (
{status === 'queueing' && (
P V P

{queueMessage}

)} {dualScreenEnabled && status !== 'queueing' && ( )} {!dualScreenEnabled && status !== 'queueing' && (

You

{profile.character.name}

{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}
{playerSide.party.map((member) => ( ))}

Buffs: {playerSide.buffs.length > 0 ? summarizeStacks(playerSide.buffs, selfBuffChoicesCatalog) : 'none'} | Debuffs: {playerSide.debuffs.length > 0 ? summarizeStacks(playerSide.debuffs, opponentDebuffChoicesCatalog) : 'none'}

Encounter {encounterIndex + 1}

{encounter.enemyName}

Stage {stage}{encounter.isBoss ? ' Boss' : ''}
Your clear
{Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}
CPU clear
{Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}
{starterSpells.map((spell) => { const remaining = playerSide.cooldowns[spell.id] ?? 0 const cost = spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady) return ( ) })}

CPU {cpuDifficulty} | Encounters cleared: {encountersCleared}

Opponent

CPU {cpuDifficulty}

{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}
{cpuSide.party.map((member) => (
{member.role[0]} {member.name} {Math.floor(member.health)} / {effectiveMaxHealth(member)}
{member.shield > 0 && } {Math.floor(member.health)} / {effectiveMaxHealth(member)}
{member.hotTicks > 0 && Renew {formatEffectTime(member.hotTicks)}} {member.shield > 0 && Shield {Math.ceil(member.shield)}} {member.debuff && member.debuffTicks && {member.debuff} {formatEffectTime(member.debuffTicks)}} {(member.poisonStacks ?? 0) > 0 && Poison {member.poisonStacks}} {(member.maxHealthPenaltyTicks ?? 0) > 0 && Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks ?? 0)}} {(member.healingReductionTicks ?? 0) > 0 && Healing -25% {formatEffectTime(member.healingReductionTicks ?? 0)}}
))}

Buffs: {cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none'} | Debuffs: {cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none'}

)} {status === 'upgrade-choice' && (
Self Buff
{playerBuffChoices.map((choice) => ( ))}
Opponent Debuff
{playerDebuffChoices.map((choice) => ( ))}
)} {paused && (

Paused

{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}

)} {(status === 'won' || status === 'lost') && (

{status === 'won' ? 'Victory' : 'Defeat'}

{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}

{finalEncountersCleared} encounters cleared.

{runSummary.bossesKilled} bosses killed.

+{runSummary.experienceGained} XP

{runSummary.bossesKilled > 0 && !reward && !rewardError &&

Final boss rewards still recording...

} {rewardError &&

{rewardError}

} {runSummary.levelsGained > 0 && runSummary.previousLevel !== null && runSummary.newLevel !== null && (

Level {runSummary.previousLevel} to {runSummary.newLevel} +{runSummary.talentPointsGained} talent point{runSummary.talentPointsGained === 1 ? '' : 's'}

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

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

))}
{runSummary.loot.length > 0 ? runSummary.loot.map((item, index) => (
Boss {index + 1} {item.glyph} {item.name} x{item.quantity} {item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
)) : (
Loot No boss loot awarded
)}
{reward && runSummary.bossesKilled === 0 && ( <>

+{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}

))} {reward.bonusItem && (

{reward.bonusItem.glyph} {reward.bonusItem.name} x{reward.bonusItem.quantity} {reward.bonusItem.duplicate ? ` (owned x${reward.bonusItem.quantityAfter})` : ''}

)} )}
{log.length > 0 && ( <> {showEndLog && (
{log.slice().reverse().map((entry) => (
{entry.text}
))}
)} )}
)}
) }