import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react' import { DEFAULT_GROUP_HEAL_TARGETS, INITIAL_PARTY, groupHealTargets, tankPressureTargets, type CombatLogEntry, type PartyMember, type Spell, } from '../game' import { completeRoguelike, type DungeonReward } from '../profile' import type { Ability, CharacterProfile } from '../profile' import type { GameMode } from '../gameRepository' import { ControllerBindingLabel } from './ControllerIcons' import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input' import { useDualScreen, useDualScreenPublisher, type DualScreenCombatState } from '../dualScreen' import { cancelPvpQueue, checkPvpQueue, joinPvpQueue, publishPvpMatchState, randomCpuDifficulty, requestPvpRematch, submitPvpUpgradeChoice, type CpuDifficulty, type PvpMatchSide, type PvpMatchSnapshot, type PvpRematchResponse, } from '../pvpRoguelike' const TICK_MS = 700 const ROUND_START_SECONDS = 3 const SHOP_SECONDS = 60 const WIN_ROUNDS = 3 const MAX_RESOURCE = 100 const RESOURCE_REGEN_PER_TICK = 0.8 type SlotKey = '1' | '2' | '3' | '4' | '5' type StadiumBuffId = | `slot${SlotKey}-extra-target` | `slot${SlotKey}-cost-down` | `slot${SlotKey}-cooldown-down` | 'slot1-applies-renew' | 'slot1-applies-shield' | 'slot2-applies-shield' | 'slot2-double-duration' | 'slot3-applies-shield' | 'slot3-applies-renew' | 'slot4-applies-renew' | 'slot5-applies-renew' | 'slot5-applies-shield' | 'fifth-cast-free' | 'group-heal-boost' | 'shield-boost' type StadiumBuff = { id: StadiumBuffId name: string description: string category: SlotKey | 'misc' cost: 1 | 2 } type StadiumSideState = { party: PartyMember[] resource: number cooldowns: Record buffs: StadiumBuffId[] castsTowardFree: number freeCastReady: boolean survivalSeconds: number dampeningPercent: number roundIndex: number roundWins: number roundStatus: 'playing' | 'shop' | 'won' | 'lost' lastRoundOutcome?: 'win' | 'loss' | 'tie' shopReady: boolean } type FloatingCombatText = { id: number memberId: string side: 'player' | 'cpu' value: number } type LivePvpMatch = { id: string side: PvpMatchSide opponentSide: PvpMatchSide opponentName: string opponentClassName: string } type RewardSummary = { experienceGained: number previousLevel: number | null newLevel: number | null levelsGained: number talentPointsGained: number unlockedAbilities: DungeonReward['unlockedAbilities'] } const CPU_BEHAVIOR: Record = { 1: { actionEveryTicks: 4, mistakeChance: 0.35, directHealThreshold: 0.54, groupHealThreshold: 0.5, hotThreshold: 0.6, shieldThreshold: 0.48 }, 2: { actionEveryTicks: 3, mistakeChance: 0.24, directHealThreshold: 0.6, groupHealThreshold: 0.56, hotThreshold: 0.66, shieldThreshold: 0.54 }, 3: { actionEveryTicks: 3, mistakeChance: 0.16, directHealThreshold: 0.66, groupHealThreshold: 0.62, hotThreshold: 0.72, shieldThreshold: 0.6 }, 4: { actionEveryTicks: 2, mistakeChance: 0.08, directHealThreshold: 0.72, groupHealThreshold: 0.68, hotThreshold: 0.78, shieldThreshold: 0.66 }, 5: { actionEveryTicks: 2, mistakeChance: 0.03, directHealThreshold: 0.78, groupHealThreshold: 0.74, hotThreshold: 0.84, shieldThreshold: 0.72 }, } function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)) } function formatTime(seconds: number) { const total = Math.max(0, Math.floor(seconds)) const minutes = Math.floor(total / 60) const remaining = total % 60 return `${minutes}:${String(remaining).padStart(2, '0')}` } function createLogEntry(nextLogId: { current: number }, text: string, tone: CombatLogEntry['tone']) { return { id: nextLogId.current++, text, tone } } function effectiveMaxHealth(member: PartyMember) { return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1))) } function buffStacks(items: StadiumBuffId[], id: StadiumBuffId) { return items.filter((item) => item === id).length } function slotLabel(slot: SlotKey, spells: Spell[]) { const spell = spells.find((candidate) => candidate.key === slot) return spell ? `${spell.name} (Slot ${slot})` : `Slot ${slot}` } function slotSpellName(slot: SlotKey, spells: Spell[], fallback: string) { return spells.find((candidate) => candidate.key === slot)?.name ?? fallback } function buildStadiumBuffs(spells: Spell[]): StadiumBuff[] { const directName = slotSpellName('1', spells, 'Mend') const sustainName = slotSpellName('2', spells, 'Renew') const groupName = slotSpellName('3', spells, 'Radiance') const shieldName = slotSpellName('4', spells, 'Sun Ward') const cleanseName = slotSpellName('5', spells, 'Purify') const slotBuffs = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => { const label = slotLabel(slot, spells) const baseBuffs: StadiumBuff[] = [ { id: `slot${slot}-extra-target` as StadiumBuffId, name: '+1 target', description: `${label} affects 1 additional ally when possible.`, category: slot, cost: 2, }, { id: `slot${slot}-cost-down` as StadiumBuffId, name: '-25% cost', description: `${label} costs 25% less mana.`, category: slot, cost: 1, }, { id: `slot${slot}-cooldown-down` as StadiumBuffId, name: '-25% cooldown', description: `${label} recharges 25% faster.`, category: slot, cost: 1, }, ] const specialBuffs: Partial> = { 1: [ { id: 'slot1-applies-renew', name: `Applies ${sustainName}`, description: `${directName} also applies ${sustainName} to the target.`, category: slot, cost: 2, }, { id: 'slot1-applies-shield', name: `Applies ${shieldName}`, description: `${directName} also applies a ${shieldName} barrier to the target.`, category: slot, cost: 2, }, ], 2: [ { id: 'slot2-applies-shield', name: `Applies ${shieldName}`, description: `${sustainName} also applies a ${shieldName} barrier to the target.`, category: slot, cost: 2, }, { id: 'slot2-double-duration', name: 'Double Duration', description: `${sustainName} lasts twice as long or gains extra charges.`, category: slot, cost: 2, }, ], 3: [ { id: 'slot3-applies-shield', name: `Applies 50% ${shieldName}`, description: `${groupName} applies a ${shieldName} barrier at 50% strength to affected targets.`, category: slot, cost: 2, }, { id: 'slot3-applies-renew', name: `Applies ${sustainName}`, description: `${groupName} applies ${sustainName} to affected targets.`, category: slot, cost: 2, }, ], 4: [ { id: 'slot4-applies-renew', name: `Applies ${sustainName}`, description: `${shieldName} also applies ${sustainName} to the target.`, category: slot, cost: 2, }, ], 5: [ { id: 'slot5-applies-renew', name: `Applies ${sustainName}`, description: `${cleanseName} also applies ${sustainName} to the target.`, category: slot, cost: 2, }, { id: 'slot5-applies-shield', name: `Applies ${shieldName}`, description: `${cleanseName} also applies a ${shieldName} barrier to the target.`, category: slot, cost: 2, }, ], } return [...baseBuffs, ...(specialBuffs[slot] ?? [])] }) return [ ...slotBuffs, { id: 'fifth-cast-free', name: 'Stored Momentum', description: 'After 5 spell casts, your next cast is free.', category: 'misc', cost: 1, }, { id: 'group-heal-boost', name: 'Wide Radiance', description: 'Party healing is 25% stronger.', category: 'misc', cost: 1, }, { id: 'shield-boost', name: 'Dense Shields', description: 'Shield absorbs are 25% stronger.', category: 'misc', cost: 1, }, ] } function summarizeStacks(items: StadiumBuffId[], catalog: StadiumBuff[]) { const counts = new Map() items.forEach((item) => counts.set(item, (counts.get(item) ?? 0) + 1)) const summary = 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(', ') return summary || 'none' } function cooldownMultiplier(spell: Spell, buffs: StadiumBuffId[]) { return 0.75 ** buffStacks(buffs, `slot${spell.key as SlotKey}-cooldown-down` as StadiumBuffId) } function spellResourceCost(spell: Spell, buffs: StadiumBuffId[], freeCastReady: boolean) { if (freeCastReady && buffStacks(buffs, 'fifth-cast-free') > 0) return 0 return Math.ceil(spell.cost * (0.75 ** buffStacks(buffs, `slot${spell.key as SlotKey}-cost-down` as StadiumBuffId))) } function toCombatSpell(ability: Ability, key: string): Spell { const kinds: Record = { direct_heal: 'direct', direct_hot: 'direct', heal_over_time: 'hot', bounce_heal: 'bounce_heal', party_heal: 'group', party_hot: 'group', party_absorb: 'group', absorb: 'shield', damage_reduction: 'damage_reduction', 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', effectType: ability.spellType, } } function resetParty(partyTemplate: PartyMember[]) { return partyTemplate.map((member) => ({ ...member, health: member.maxHealth, shield: 0, hotTicks: 0, hotEffects: undefined, debuff: undefined, debuffTicks: undefined, poisonStacks: undefined, maxHealthPenaltyTicks: undefined, healingReductionTicks: undefined, damageReductionTicks: undefined, bounceHeals: undefined, })) } function starterSide(partyTemplate: PartyMember[], roundIndex: number, buffs: StadiumBuffId[] = [], roundWins = 0): StadiumSideState { return { party: resetParty(partyTemplate), resource: MAX_RESOURCE, cooldowns: {}, buffs, castsTowardFree: 0, freeCastReady: false, survivalSeconds: 0, dampeningPercent: 0, roundIndex, roundWins, roundStatus: 'playing', shopReady: false, } } function createEmptyRewardSummary(): RewardSummary { return { experienceGained: 0, previousLevel: null, newLevel: null, levelsGained: 0, talentPointsGained: 0, unlockedAbilities: [], } } export function PvpStadiumScreen({ profile, gameMode, onExit, onProfileUpdated, }: { profile: CharacterProfile gameMode: GameMode 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 buffCatalog = useMemo(() => buildStadiumBuffs(starterSpells), [starterSpells]) const partyTemplate = useMemo( () => INITIAL_PARTY.map((member) => ({ ...member, name: member.id === 'mira' ? profile.character.name : member.name, })), [profile.character.name], ) const cpuPartyTemplate = useMemo( () => INITIAL_PARTY.map((member) => ({ ...member, name: member.id === 'mira' ? 'CPU Healer' : member.name, })), [], ) const rewardDungeon = profile.dungeons.find((candidate) => candidate.contentType === 'dungeon') ?? profile.dungeons[0] const rewardDifficulty = rewardDungeon.difficulties[0] const [status, setStatus] = useState<'queueing' | 'round-countdown' | 'playing' | 'shop' | 'won' | 'lost'>('queueing') const [playerSide, setPlayerSide] = useState(() => starterSide(partyTemplate, 1)) const [cpuSide, setCpuSide] = useState(() => starterSide(cpuPartyTemplate, 1)) const [selectedId, setSelectedId] = useState(partyTemplate[0].id) const [roundIndex, setRoundIndex] = useState(1) const [roundWins, setRoundWins] = useState({ player: 0, opponent: 0 }) const [shopPoints, setShopPoints] = useState(0) const [shopReady, setShopReady] = useState(false) const [shopTimeLeft, setShopTimeLeft] = useState(SHOP_SECONDS) const [shopCategory, setShopCategory] = useState('1') const [roundCountdown, setRoundCountdown] = useState(ROUND_START_SECONDS) const [elapsedTicks, setElapsedTicks] = useState(0) const [cpuDifficulty, setCpuDifficulty] = useState(null) const [liveMatch, setLiveMatch] = useState(null) const [queueMessage, setQueueMessage] = useState('Searching Stadium queue...') const [rematchRequested, setRematchRequested] = useState(false) const [rematchMessage, setRematchMessage] = useState('') const [paused, setPaused] = useState(false) const [log, setLog] = useState([{ id: 1, text: 'Queueing Stadium opponent...', tone: 'system' }]) const [floatingTexts, setFloatingTexts] = useState([]) const [rewardSummary, setRewardSummary] = useState(() => createEmptyRewardSummary()) const [rewardError, setRewardError] = useState('') const [showEndLog, setShowEndLog] = useState(false) const selectedIdRef = useRef(partyTemplate[0].id) const playerRef = useRef(playerSide) const cpuRef = useRef(cpuSide) const liveMatchRef = useRef(null) const nextLogId = useRef(2) const nextFloatingTextId = useRef(1) const roundCountdownTimerRef = useRef(null) const shopEndsAtRef = useRef(0) const submittedShopRef = useRef(false) const awardedXpRef = useRef(new Set()) const queuedMatchRef = useRef(false) const roundResolvedRef = useRef(false) const loggedOpponentRoundRef = useRef('') const { bindings, controllerIconStyle, directPartyTargeting, lastDevice, } = useInput() const { enabled: dualScreenEnabled } = useDualScreen() const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}` const playerAlive = playerSide.party.some((member) => member.health > 0) const partyColumns = 3 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, 70)) }, []) 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) }, []) const clearRoundCountdown = useCallback(() => { if (roundCountdownTimerRef.current === null) return window.clearInterval(roundCountdownTimerRef.current) roundCountdownTimerRef.current = null }, []) const beginRoundCountdown = useCallback(() => { clearRoundCountdown() roundResolvedRef.current = false setRoundCountdown(ROUND_START_SECONDS) setStatus('round-countdown') const startedAt = Date.now() roundCountdownTimerRef.current = window.setInterval(() => { const remaining = Math.max(0, ROUND_START_SECONDS - (Date.now() - startedAt) / 1000) setRoundCountdown(remaining) if (remaining > 0) return clearRoundCountdown() setStatus((current) => current === 'round-countdown' ? 'playing' : current) }, 100) }, [clearRoundCountdown]) useEffect(() => () => clearRoundCountdown(), [clearRoundCountdown]) const awardXp = useCallback((key: string, mode: 'pvp-stadium-round-win-quarter-level' | 'pvp-stadium-round-loss-tenth-level' | 'pvp-stadium-match-half-level') => { if (awardedXpRef.current.has(key)) return awardedXpRef.current.add(key) completeRoguelike(rewardDungeon.id, rewardDifficulty.id, 0, 0, Math.max(1, Math.floor(playerRef.current.survivalSeconds || 1)), { bossesCleared: 0, fightsCleared: 1, experienceMode: mode, }) .then((result) => { setRewardSummary((current) => { const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability])) result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability)) return { 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()), } }) onProfileUpdated(result.profile) if (result.experienceGained > 0) addLog(`+${result.experienceGained} XP awarded.`, 'loot') }) .catch((reason: unknown) => { setRewardError(reason instanceof Error ? reason.message : 'Unable to award Stadium XP.') }) }, [addLog, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id]) const startLiveMatch = useCallback((match: PvpMatchSnapshot, side: PvpMatchSide, message?: string) => { const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a' const opponent = match.players[opponentSide] const basePlayer = starterSide(partyTemplate, 1) const baseOpponent = starterSide( cpuPartyTemplate.map((member) => ({ ...member, name: member.id === 'mira' ? opponent.characterName : member.name, })), 1, ) const nextLiveMatch = { id: match.id, side, opponentSide, opponentName: opponent.characterName, opponentClassName: opponent.className, } playerRef.current = basePlayer cpuRef.current = baseOpponent liveMatchRef.current = nextLiveMatch queuedMatchRef.current = true nextLogId.current = 2 awardedXpRef.current = new Set() roundResolvedRef.current = false setPlayerSide(basePlayer) setCpuSide(baseOpponent) setRoundIndex(1) setRoundWins({ player: 0, opponent: 0 }) setSelectedTargetId(partyTemplate[0].id) setElapsedTicks(0) setShopPoints(0) setShopReady(false) setCpuDifficulty(null) setLiveMatch(nextLiveMatch) setPaused(false) setRewardSummary(createEmptyRewardSummary()) setRewardError('') setShowEndLog(false) setFloatingTexts([]) setRematchRequested(false) setRematchMessage('') loggedOpponentRoundRef.current = '' const text = message ?? `${opponent.characterName} found. Stadium begins.` setQueueMessage(text) setLog([{ id: 1, text, tone: 'system' }]) beginRoundCountdown() }, [beginRoundCountdown, cpuPartyTemplate, partyTemplate, setSelectedTargetId]) const startMatch = useCallback(() => { clearRoundCountdown() const basePlayer = starterSide(partyTemplate, 1) const baseCpu = starterSide(cpuPartyTemplate, 1) playerRef.current = basePlayer cpuRef.current = baseCpu liveMatchRef.current = null queuedMatchRef.current = true nextLogId.current = 2 awardedXpRef.current = new Set() roundResolvedRef.current = false setPlayerSide(basePlayer) setCpuSide(baseCpu) setRoundIndex(1) setRoundWins({ player: 0, opponent: 0 }) setSelectedTargetId(partyTemplate[0].id) setElapsedTicks(0) setStatus('queueing') setShopPoints(0) setShopReady(false) setCpuDifficulty(null) setLiveMatch(null) setPaused(false) setRewardSummary(createEmptyRewardSummary()) setRewardError('') setShowEndLog(false) setFloatingTexts([]) setRematchRequested(false) setRematchMessage('') loggedOpponentRoundRef.current = '' const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => { setCpuDifficulty(randomCpu) setQueueMessage(message) setLog([{ id: 1, text: message, tone: 'system' }]) beginRoundCountdown() } if (gameMode === 'offline') { const randomCpu = randomCpuDifficulty() const timer = window.setTimeout(() => { beginCpuMatch(randomCpu, `Offline mode. CPU ${randomCpu} enters Stadium.`) }, 500) return () => window.clearTimeout(timer) } let cancelled = false let ticketId = '' let pollTimer: number | undefined setQueueMessage('Searching Stadium queue for 5s.') setLog([{ id: 1, text: 'Searching Stadium queue for 5s.', tone: 'system' }]) const beginLiveMatch = (match: PvpMatchSnapshot, side: PvpMatchSide) => { if (cancelled) return const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a' const opponent = match.players[opponentSide] startLiveMatch(match, side, `${opponent.characterName} found. Stadium begins.`) } const fallbackTimer = window.setTimeout(() => { if (cancelled || liveMatchRef.current) return cancelled = true if (ticketId) cancelPvpQueue(ticketId).catch(() => undefined) const randomCpu = randomCpuDifficulty() beginCpuMatch(randomCpu, `No Stadium player found after 5s. CPU ${randomCpu} steps in.`) }, 5000) const pollQueue = () => { if (!ticketId || cancelled) return checkPvpQueue(ticketId) .then((result) => { if (cancelled) return if (result.status === 'matched' && result.match && result.side) { window.clearTimeout(fallbackTimer) if (pollTimer) window.clearTimeout(pollTimer) beginLiveMatch(result.match, result.side) return } pollTimer = window.setTimeout(pollQueue, 500) }) .catch(() => { if (!cancelled) pollTimer = window.setTimeout(pollQueue, 700) }) } joinPvpQueue('stadium', 1) .then((result) => { if (cancelled) return ticketId = result.ticketId if (result.status === 'matched' && result.match && result.side) { window.clearTimeout(fallbackTimer) beginLiveMatch(result.match, result.side) return } pollTimer = window.setTimeout(pollQueue, 500) }) .catch(() => { if (cancelled) return window.clearTimeout(fallbackTimer) cancelled = true const randomCpu = randomCpuDifficulty() beginCpuMatch(randomCpu, `PvP server unavailable. CPU ${randomCpu} steps in.`) }) return () => { cancelled = true window.clearTimeout(fallbackTimer) if (pollTimer) window.clearTimeout(pollTimer) if (ticketId && !liveMatchRef.current) cancelPvpQueue(ticketId).catch(() => undefined) } }, [beginRoundCountdown, clearRoundCountdown, cpuPartyTemplate, gameMode, partyTemplate, setSelectedTargetId, startLiveMatch]) useEffect(() => startMatch(), [startMatch]) const applySpell = useCallback(( current: StadiumSideState, setCurrent: Dispatch>, sideName: 'player' | 'cpu', spell: Spell, targetId: string, ) => { const effectiveCost = spellResourceCost(spell, current.buffs, 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 dampenMultiplier = Math.max(0, 1 - current.dampeningPercent / 100) 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(spell.kind === 'direct' || spell.kind === 'cleanse' ? [targetId] : []) const hotTargets = new Set(spell.kind === 'hot' || spell.kind === 'bounce_heal' ? [targetId] : []) const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : []) const damageReductionTargets = new Set(spell.kind === 'damage_reduction' ? [targetId] : []) const extraTargets = buffStacks(current.buffs, `slot${spell.key as SlotKey}-extra-target` as StadiumBuffId) const groupTargets = new Set( spell.kind === 'group' ? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id) : [], ) const renewDuration = buffStacks(current.buffs, 'slot2-double-duration') > 0 && spell.key === '2' ? 10 : 5 const shieldEffect = starterSpells.find((candidate) => candidate.kind === 'shield') const shieldPower = (sourcePower: number, strength = 1) => Math.round( sourcePower * strength * (1.25 ** buffStacks(current.buffs, 'shield-boost')) * dampenMultiplier, ) if (spell.effectType === 'direct_hot') hotTargets.add(targetId) if (spell.key === '1' && buffStacks(current.buffs, 'slot1-applies-renew') > 0) hotTargets.add(targetId) if (spell.key === '1' && buffStacks(current.buffs, 'slot1-applies-shield') > 0) shieldTargets.add(targetId) if (spell.key === '2' && buffStacks(current.buffs, 'slot2-applies-shield') > 0) shieldTargets.add(targetId) if (spell.key === '4' && buffStacks(current.buffs, 'slot4-applies-renew') > 0) hotTargets.add(targetId) if (spell.key === '5' && buffStacks(current.buffs, 'slot5-applies-renew') > 0) hotTargets.add(targetId) if (spell.key === '5' && buffStacks(current.buffs, 'slot5-applies-shield') > 0) shieldTargets.add(targetId) for (let index = 0; index < extraTargets; index += 1) { if (spell.kind === 'group') break if (spell.kind === 'hot' || spell.kind === 'bounce_heal') { 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 } if (spell.kind === 'damage_reduction') { const extra = extraTarget([...damageReductionTargets]) if (extra) damageReductionTargets.add(extra.id) continue } const extra = extraTarget([...directTargets]) if (extra) directTargets.add(extra.id) } if (spell.effectType === 'direct_hot') directTargets.forEach((id) => hotTargets.add(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 isGroupAbsorb = spell.effectType === 'party_absorb' const isGroupHot = spell.effectType === 'party_hot' const boost = isGroupAbsorb ? buffStacks(current.buffs, 'shield-boost') : buffStacks(current.buffs, 'group-heal-boost') const power = Math.round(spell.power * (1.25 ** boost) * dampenMultiplier) const nextHealth = isGroupAbsorb || isGroupHot ? member.health : clamp(member.health + power, 0, effectiveMaxHealth(member)) if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health) const appliesShield = isGroupAbsorb || buffStacks(current.buffs, 'slot3-applies-shield') > 0 const appliesHot = isGroupHot || buffStacks(current.buffs, 'slot3-applies-renew') > 0 return { ...member, health: nextHealth, shield: appliesShield ? Math.max(member.shield, isGroupAbsorb ? power : shieldPower(shieldEffect?.power ?? spell.power, 0.5)) : member.shield, hotTicks: appliesHot ? Math.max(member.hotTicks, 5) : member.hotTicks, } } if ( !directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id) && !damageReductionTargets.has(member.id) ) return member if (spell.kind === 'shield') { return { ...member, shield: Math.max(member.shield, shieldPower(spell.power)), hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, 5) : member.hotTicks, } } if (spell.kind === 'damage_reduction') { return { ...member, damageReductionTicks: Math.max(member.damageReductionTicks ?? 0, 12), hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, 5) : member.hotTicks, } } if (spell.kind === 'cleanse') { const power = Math.round(spell.power * dampenMultiplier) const nextHealth = clamp(member.health + power, 0, effectiveMaxHealth(member)) addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health)) return { ...member, health: nextHealth, debuff: undefined, debuffTicks: undefined, poisonStacks: undefined, maxHealthPenaltyTicks: undefined, healingReductionTicks: undefined, shield: shieldTargets.has(member.id) ? Math.max(member.shield, shieldPower(shieldEffect?.power ?? spell.power)) : member.shield, hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, 5) : member.hotTicks, } } const power = directTargets.has(member.id) ? Math.round(spell.power * dampenMultiplier) : 0 const nextHealth = clamp(member.health + power, 0, effectiveMaxHealth(member)) if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health) return { ...member, health: nextHealth, shield: shieldTargets.has(member.id) ? Math.max(member.shield, shieldPower(shieldEffect?.power ?? spell.power)) : member.shield, hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, renewDuration) : member.hotTicks, } }) const freeBuff = buffStacks(current.buffs, 'fifth-cast-free') > 0 const wasFree = effectiveCost === 0 && current.freeCastReady const nextCasts = freeBuff ? (wasFree ? 0 : current.castsTowardFree + 1) : current.castsTowardFree const nextState = { ...current, party: nextParty, resource: current.resource - effectiveCost, cooldowns: { ...current.cooldowns, [spell.id]: spell.cooldown * cooldownMultiplier(spell, current.buffs), }, castsTowardFree: freeBuff && nextCasts >= 5 ? 0 : nextCasts, freeCastReady: freeBuff && nextCasts >= 5, } setCurrent(nextState) return true }, [addFloatingHeal, starterSpells]) const castPlayerSpell = useCallback((spell: Spell) => { if (status !== 'playing' || !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', spell, targetId) if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal') }, [addLog, applySpell, playerAlive, 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 member = playerRef.current.party[slot] if (member?.health > 0) setSelectedTargetId(member.id) }, [setSelectedTargetId]) const cpuTakeTurn = useCallback(() => { if (!cpuDifficulty || status !== 'playing') return const behavior = CPU_BEHAVIOR[cpuDifficulty] if (elapsedTicks % behavior.actionEveryTicks !== 0 || Math.random() < behavior.mistakeChance) return const side = cpuRef.current const living = side.party.filter((member) => member.health > 0) if (living.length === 0) return const lowest = [...living].sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0] const averageHealth = living.reduce((total, member) => total + member.health / effectiveMaxHealth(member), 0) / Math.max(1, living.length) const tank = living.find((member) => member.role === 'Tank') const cleanseTarget = living.find((member) => member.debuff || (member.poisonStacks ?? 0) > 0) const renewTarget = living.find((member) => member.hotTicks <= 1 && member.health / effectiveMaxHealth(member) < behavior.hotThreshold) const shieldTarget = living.find((member) => member.role === 'Tank' && member.shield <= 5 && member.health / effectiveMaxHealth(member) < behavior.shieldThreshold) const spellBySlot = (slot: SlotKey) => starterSpells.find((candidate) => candidate.key === slot) const ordered: Array<{ spell: Spell | undefined; targetId: string | null }> = [ { spell: cleanseTarget ? spellBySlot('5') : undefined, targetId: cleanseTarget?.id ?? null }, { spell: averageHealth < behavior.groupHealThreshold ? spellBySlot('3') : undefined, targetId: lowest?.id ?? null }, { spell: shieldTarget ? spellBySlot('4') : undefined, targetId: shieldTarget?.id ?? null }, { spell: lowest.health / effectiveMaxHealth(lowest) < behavior.directHealThreshold ? spellBySlot('1') : undefined, targetId: lowest.id }, { spell: renewTarget ? spellBySlot('2') : undefined, targetId: renewTarget?.id ?? null }, { spell: tank ? spellBySlot('1') : 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', action.spell, action.targetId) if (succeeded) return } }, [applySpell, cpuDifficulty, elapsedTicks, starterSpells, status]) const advanceBoss = useCallback((side: StadiumSideState) => { if (side.roundStatus !== 'playing') return side const nextSurvival = side.survivalSeconds + TICK_MS / 1000 const dampeningPercent = Math.floor(nextSurvival / 5) const dampenMultiplier = Math.max(0, 1 - dampeningPercent / 100) const living = side.party.filter((member) => member.health > 0) if (living.length === 0) return side const spikeTarget = living[Math.floor(Math.random() * living.length)] const tankIds = new Set(tankPressureTargets(side.party).targets.map((member) => member.id)) const pulse = elapsedTicks > 0 && elapsedTicks % 5 === 0 const spike = elapsedTicks > 0 && elapsedTicks % 8 === 0 const nextParty = side.party.map((member) => { if (member.health <= 0) return member let damage = tankIds.has(member.id) ? 8 : 0 if (pulse) damage += 9 if (spike && member.id === spikeTarget.id) damage += 22 const mitigatedDamage = member.damageReductionTicks && member.damageReductionTicks > 0 ? Math.ceil(damage * 0.5) : damage const hotHealing = member.hotTicks > 0 ? Math.round(6 * dampenMultiplier) : 0 const absorbed = Math.min(member.shield, mitigatedDamage) const nextHealth = clamp(member.health - mitigatedDamage + absorbed + hotHealing, 0, effectiveMaxHealth(member)) return { ...member, health: nextHealth, shield: Math.max(0, member.shield - mitigatedDamage), hotTicks: Math.max(0, member.hotTicks - 1), damageReductionTicks: member.damageReductionTicks ? Math.max(0, member.damageReductionTicks - 1) : undefined, debuff: spike && member.id === spikeTarget.id ? 'Marked' : member.debuff, debuffTicks: spike && member.id === spikeTarget.id ? 4 : member.debuffTicks ? Math.max(0, member.debuffTicks - 1) : undefined, } }) return { ...side, party: nextParty, resource: clamp(side.resource + RESOURCE_REGEN_PER_TICK, 0, MAX_RESOURCE), cooldowns: Object.fromEntries( Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]), ), survivalSeconds: nextSurvival, dampeningPercent, } }, [elapsedTicks]) const beginShop = useCallback((outcome: 'win' | 'loss' | 'tie', nextWins: { player: number; opponent: number }) => { const points = outcome === 'loss' ? 4 : 3 shopEndsAtRef.current = Date.now() + SHOP_SECONDS * 1000 submittedShopRef.current = false setShopPoints(points) setShopReady(false) setShopTimeLeft(SHOP_SECONDS) setRoundWins(nextWins) setStatus('shop') setPlayerSide((current) => { const next = { ...current, roundStatus: 'shop' as const, lastRoundOutcome: outcome, shopReady: false, roundWins: nextWins.player } playerRef.current = next return next }) if (!liveMatchRef.current) { let cpuPoints = outcome === 'win' ? 4 : 3 const purchases: StadiumBuffId[] = [] while (cpuPoints > 0) { const affordable = buffCatalog.filter((buff) => buff.cost <= cpuPoints) if (affordable.length === 0) break const selected = affordable[Math.floor(Math.random() * affordable.length)] purchases.push(selected.id) cpuPoints -= selected.cost } setCpuSide((current) => { const next = { ...current, buffs: [...current.buffs, ...purchases], roundStatus: 'shop' as const, roundWins: nextWins.opponent } cpuRef.current = next return next }) } }, [buffCatalog]) const finishRound = useCallback((outcome: 'win' | 'loss' | 'tie') => { if (status !== 'playing') return if (roundResolvedRef.current) return roundResolvedRef.current = true const key = `round-${roundIndex}-${outcome}` if (outcome === 'win' || outcome === 'tie') { awardXp(key, 'pvp-stadium-round-win-quarter-level') } else { awardXp(key, 'pvp-stadium-round-loss-tenth-level') } const nextWins = { player: roundWins.player + (outcome === 'win' ? 1 : 0), opponent: roundWins.opponent + (outcome === 'loss' ? 1 : 0), } addLog( outcome === 'win' ? `Round ${roundIndex} won.` : outcome === 'loss' ? `Round ${roundIndex} lost.` : `Round ${roundIndex} tied.`, outcome === 'loss' ? 'danger' : 'loot', ) if (nextWins.player >= WIN_ROUNDS) { setRoundWins(nextWins) setStatus('won') setPlayerSide((current) => { const next = { ...current, roundStatus: 'won' as const, lastRoundOutcome: outcome, roundWins: nextWins.player } playerRef.current = next return next }) awardXp('match-win', 'pvp-stadium-match-half-level') return } if (nextWins.opponent >= WIN_ROUNDS) { setRoundWins(nextWins) setStatus('lost') setPlayerSide((current) => { const next = { ...current, roundStatus: 'lost' as const, lastRoundOutcome: outcome, roundWins: nextWins.player } playerRef.current = next return next }) return } beginShop(outcome, nextWins) }, [addLog, awardXp, beginShop, roundIndex, roundWins, status]) useEffect(() => { if (status !== 'playing' || paused) return const timer = window.setInterval(() => { setElapsedTicks((value) => value + 1) if (!liveMatchRef.current) cpuTakeTurn() const nextPlayer = advanceBoss(playerRef.current) const nextCpu = liveMatchRef.current ? cpuRef.current : advanceBoss(cpuRef.current) 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 && (!liveMatchRef.current && !nextCpuAlive)) finishRound('tie') else if (!nextPlayerAlive) finishRound('loss') else if (!liveMatchRef.current && !nextCpuAlive) finishRound('win') }, TICK_MS) return () => window.clearInterval(timer) }, [advanceBoss, cpuTakeTurn, finishRound, paused, status]) const startNextRound = useCallback(() => { const nextRound = roundIndex + 1 const nextPlayer = starterSide(partyTemplate, nextRound, playerRef.current.buffs, roundWins.player) const nextCpu = starterSide(cpuPartyTemplate, nextRound, cpuRef.current.buffs, roundWins.opponent) playerRef.current = nextPlayer cpuRef.current = nextCpu roundResolvedRef.current = false loggedOpponentRoundRef.current = '' setRoundIndex(nextRound) setPlayerSide(nextPlayer) setCpuSide(nextCpu) setSelectedTargetId(partyTemplate[0].id) setElapsedTicks(0) setShopReady(false) setShopPoints(0) addLog(`Round ${nextRound} starts. HP and mana restored.`, 'system') if (liveMatchRef.current) { publishPvpMatchState(liveMatchRef.current.id, { state: nextPlayer, status: 'playing', stage: nextRound, encounterIndex: nextRound, encountersCleared: roundWins.player, enemyHealth: 0, alive: true, elapsedTicks: 0, }).catch(() => undefined) } beginRoundCountdown() }, [addLog, beginRoundCountdown, cpuPartyTemplate, partyTemplate, roundIndex, roundWins.opponent, roundWins.player, setSelectedTargetId]) const finishShop = useCallback(() => { if (shopReady || status !== 'shop') return setShopReady(true) submittedShopRef.current = true setPlayerSide((current) => { const next = { ...current, shopReady: true } playerRef.current = next return next }) if (liveMatchRef.current) { submitPvpUpgradeChoice(liveMatchRef.current.id, { encounterIndex: roundIndex, buffId: 'stadium-shop', debuffId: '', purchases: playerRef.current.buffs, shopReady: true, }).catch(() => undefined) return } startNextRound() }, [roundIndex, shopReady, startNextRound, status]) useEffect(() => { if (status !== 'shop' || shopReady) return const updateTimer = () => { const remaining = Math.max(0, (shopEndsAtRef.current - Date.now()) / 1000) setShopTimeLeft(remaining) if (remaining <= 0) finishShop() } updateTimer() const timer = window.setInterval(updateTimer, 200) return () => window.clearInterval(timer) }, [finishShop, shopReady, status]) useEffect(() => { if (!liveMatch || status === 'queueing') return let stopped = false const syncMatch = () => { publishPvpMatchState(liveMatch.id, { state: playerRef.current, status: status === 'round-countdown' ? 'playing' : status, stage: roundIndex, encounterIndex: roundIndex, encountersCleared: roundWins.player, enemyHealth: 0, alive: playerRef.current.party.some((member) => member.health > 0), elapsedTicks, }) .then((snapshot) => { if (stopped) return const opponentState = snapshot.states[liveMatch.opponentSide] if (opponentState && opponentState.roundIndex >= roundIndex) { cpuRef.current = opponentState setCpuSide(opponentState) } const opponentStatus = snapshot.statuses[liveMatch.opponentSide] if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') setStatus('lost') if (opponentStatus === 'lost' && status !== 'won' && status !== 'lost') setStatus('won') if (!opponentState) return if (opponentState.roundIndex < roundIndex) return if (status === 'playing' && opponentState.roundIndex === roundIndex && opponentState.roundStatus === 'shop' && opponentState.lastRoundOutcome) { const key = `${roundIndex}-${opponentState.lastRoundOutcome}` if (loggedOpponentRoundRef.current === key) return loggedOpponentRoundRef.current = key if (opponentState.lastRoundOutcome === 'loss') finishRound('win') else if (opponentState.lastRoundOutcome === 'win') finishRound('loss') else finishRound('tie') } if ( status === 'shop' && shopReady && ( (opponentState.roundIndex === roundIndex && opponentState.shopReady) || opponentState.roundIndex > roundIndex ) ) { startNextRound() } }) .catch(() => undefined) } syncMatch() const timer = window.setInterval(syncMatch, 700) return () => { stopped = true window.clearInterval(timer) } }, [elapsedTicks, finishRound, liveMatch, roundIndex, roundWins.player, shopReady, startNextRound, status]) const buyBuff = useCallback((buff: StadiumBuff) => { if (status !== 'shop' || shopReady || shopPoints < buff.cost) return let purchased = false setShopPoints((points) => { if (points < buff.cost) return points purchased = true return points - buff.cost }) if (!purchased) return setPlayerSide((current) => { const next = { ...current, buffs: [...current.buffs, buff.id] } playerRef.current = next return next }) addLog(`${buff.name} purchased.`, 'loot') }, [addLog, shopPoints, shopReady, status]) const handleRematch = useCallback(() => { if (!liveMatch || rematchRequested) return let cancelled = false let attempts = 0 setRematchRequested(true) setRematchMessage(`Waiting for ${liveMatch.opponentName} to rematch...`) const handleResponse = (result: PvpRematchResponse) => { if (cancelled) return if (result.status === 'matched' && result.match && result.side) { startLiveMatch(result.match, result.side, `Rematch against ${liveMatch.opponentName} begins.`) return } attempts += 1 if (attempts >= 180) { setRematchRequested(false) setRematchMessage('Rematch expired.') return } window.setTimeout(pollRematch, 700) } const pollRematch = () => { requestPvpRematch(liveMatch.id) .then(handleResponse) .catch((reason: unknown) => { if (cancelled) return attempts += 1 if (attempts >= 10) { setRematchRequested(false) setRematchMessage(reason instanceof Error ? reason.message : 'Unable to request rematch.') return } window.setTimeout(pollRematch, 900) }) } pollRematch() return () => { cancelled = true } }, [liveMatch, rematchRequested, startLiveMatch]) useEffect(() => { if (status !== 'shop') return window.requestAnimationFrame(() => focusFirstControl()) }, [status]) useEffect(() => { if (!paused) return window.requestAnimationFrame(() => focusFirstControl()) }, [paused]) 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.startsWith('ability')) { const spell = starterSpells.find((candidate) => candidate.key === action.slice('ability'.length)) if (spell) castPlayerSpell(spell) } }) const dualScreenState = useMemo(() => ({ difficultyName: 'Equalized iLvl 10', dungeonName: 'Stadium', contentName: 'Stadium', encounterName: 'Iron Arbiter', encounterDescription: 'Survive escalating arena pressure.', encounterHealth: 0, encounterMaxHealth: 1, encounterIsBoss: true, encounterIndex: roundIndex - 1, encounterCount: 5, party: playerSide.party, opponentName: opponentLabel, opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'), opponentParty: cpuSide.party, opponentResource: cpuSide.resource, opponentEnemyHealth: 0, opponentBuffSummary: summarizeStacks(cpuSide.buffs, buffCatalog), opponentDebuffSummary: `Dampening ${playerSide.dampeningPercent}%`, floatingTexts: floatingTexts .filter((entry) => entry.side === 'player') .map(({ id, memberId, value }) => ({ id, memberId, value })), partySize: playerSide.party.length, selectedId, log, status: status === 'queueing' || status === 'round-countdown' ? 'playing' : status === 'shop' ? 'upgrade-choice' : status, resource: playerSide.resource, maxResource: MAX_RESOURCE, resourceName: gameClass.resourceName, playerIsAlive: playerAlive, spells: starterSpells.map((spell, slotIndex) => ({ ...spell, cost: spellResourceCost(spell, playerSide.buffs, playerSide.freeCastReady), slotIndex, remaining: playerSide.cooldowns[spell.id] ?? 0, })), activeDevice: lastDevice, bindings: bindings[lastDevice], controllerIconStyle, directPartyTargeting, paused, targetGroup: 0, speedMultiplier: 1, stadium: { dampeningPercent: playerSide.dampeningPercent, roundIndex, playerWins: roundWins.player, opponentWins: roundWins.opponent, survivalSeconds: playerSide.survivalSeconds, opponentSurvivalSeconds: cpuSide.survivalSeconds, }, }), [ bindings, buffCatalog, controllerIconStyle, cpuDifficulty, cpuSide.buffs, cpuSide.party, cpuSide.resource, cpuSide.survivalSeconds, directPartyTargeting, floatingTexts, gameClass.resourceName, lastDevice, liveMatch?.opponentClassName, log, opponentLabel, paused, playerAlive, playerSide.buffs, playerSide.cooldowns, playerSide.dampeningPercent, playerSide.freeCastReady, playerSide.party, playerSide.resource, playerSide.survivalSeconds, roundIndex, roundWins.opponent, roundWins.player, selectedId, starterSpells, status, ]) useDualScreenPublisher(dualScreenState, dualScreenEnabled) const visibleBuffs = buffCatalog.filter((buff) => buff.category === shopCategory) const categoryLabel = shopCategory === 'misc' ? 'Miscellaneous' : slotLabel(shopCategory, starterSpells) return (
{status === 'queueing' && (
P V P

{queueMessage}

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

Stadium

Round {roundIndex} / Best of 5

{roundWins.player} - {roundWins.opponent}

Dampening {playerSide.dampeningPercent}%

Survival {formatTime(playerSide.survivalSeconds)} | Equalized iLvl 10
Iron Arbiter No boss health | Next pulse in {Math.max(0, 5 - (elapsedTicks % 5) * (TICK_MS / 1000)).toFixed(1)}s
{playerSide.party.map((member, index) => { const action = `targetParty${index + 1}` as InputAction const targetBinding = directPartyTargeting ? bindings[lastDevice][action] : null return ( ) })}
{starterSpells.map((spell, slotIndex) => { const remaining = playerSide.cooldowns[spell.id] ?? 0 const cost = spellResourceCost(spell, playerSide.buffs, playerSide.freeCastReady) const percent = remaining > 0 ? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100) : 0 return ( ) })}
{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}
)} {!dualScreenEnabled && status !== 'queueing' && (

Stadium

Round {roundIndex} / Best of 5

{roundWins.player} - {roundWins.opponent}

Dampening {playerSide.dampeningPercent}%

Survival {formatTime(playerSide.survivalSeconds)} | Equalized iLvl 10
Iron Arbiter No boss health | Next pulse in {Math.max(0, 5 - (elapsedTicks % 5) * (TICK_MS / 1000)).toFixed(1)}s

You

{profile.character.name}

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

Buffs: {summarizeStacks(playerSide.buffs, buffCatalog)}

Opponent

{opponentLabel}

Survival {formatTime(cpuSide.survivalSeconds)}
{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}
{cpuSide.party.map((member) => (
{member.role[0]} {member.name}
{member.shield > 0 && } {Math.floor(member.health)} / {effectiveMaxHealth(member)}
{member.hotTicks > 0 && Renew} {member.shield > 0 && Shield {Math.ceil(member.shield)}} {member.debuff && {member.debuff}}
))}

Buffs: {summarizeStacks(cpuSide.buffs, buffCatalog)}

{starterSpells.map((spell) => { const remaining = playerSide.cooldowns[spell.id] ?? 0 const cost = spellResourceCost(spell, playerSide.buffs, playerSide.freeCastReady) return ( ) })}
)} {status === 'round-countdown' && (

Round Starts

{Math.max(1, Math.ceil(roundCountdown))}

)} {status === 'shop' && (

Stadium Buy Round

Round {roundIndex} Complete

{shopTimeLeft.toFixed(0)}s
Points: {shopPoints} Score: {roundWins.player} - {roundWins.opponent} {shopReady && Waiting for opponent...}

{categoryLabel} Buffs

{visibleBuffs.map((buff) => ( ))}

Active: {summarizeStacks(playerSide.buffs, buffCatalog)}

)} {paused && (

Paused

Stadium

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

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

{status === 'won' ? 'Stadium Won' : `${opponentLabel} Wins`}

Final score {roundWins.player} - {roundWins.opponent}

+{rewardSummary.experienceGained} XP

{rewardError &&

{rewardError}

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

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

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

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

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

{rematchMessage}

} )}
)}
) }