import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react' import { DEFAULT_GROUP_HEAL_TARGETS, INITIAL_PARTY, tankPressureTargets, type CombatLogEntry, type PartyMember, type Spell, } from '../game' import { completeRoguelike, recordPvpMatch } from '../profile' import type { CharacterProfile, DungeonEncounter } from '../profile' import type { GameMode } from '../gameRepository' import { roguelikeCoinItemLevel } from '../shared/rewardRules.mjs' import { PartyMemberFrame } from './PartyFrames' import { SpellBar } from './SpellBars' import { barFillStyle } from './barStyles' import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input' import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer' import { useSpellSlots } from '../hooks/useSpellSlots' import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText' import { usePartyTargeting } from '../hooks/usePartyTargeting' import { useDualScreen, useDualScreenPublisher } from '../dualScreen' import { clamp, createLogEntry, effectiveMaxHealth, toCombatSpell, } from '../combat/rules' import { createStackCounts, summarizeStackCounts, type StackCounts } from '../combat/stackCounts' import { hasSpellStack, spellCooldownMultiplier, spellExtraTargets, spellPowerMultiplier, spellResourceCost as modifiedSpellResourceCost, } from '../combat/spellModifiers' import { regenerateResource } from '../combat/combatTick' import { advanceMemberTick } from '../combat/combatEngine' import { buildStadiumDualScreenState } from '../combat/dualScreenPayloads' import { applyPvpSpellCast } from '../combat/pvpSpellCasting' import { pruneExpiredCooldowns, } from '../combat/spellCasting' import { appendCombatLog, STADIUM_COMBAT_LOG_LIMIT, } from '../combat/combatPresentation' import { buildSpellTargetPlan, type SpellEffectProfile, } from '../combat/spellEffects' import { runCpuHealTurn, type CpuTurnBehavior } from '../combat/cpuAi' import { chooseStadiumCpuPurchases, resolveStadiumRound, stadiumShopPointsForOutcome, type StadiumExperienceMode, type StadiumRoundOutcome, } from '../combat/stadiumLifecycle' import { createStadiumLiveMatchStart, createStadiumMatchStart, createStadiumStarterSide, } from '../combat/stadiumMatchSetup' import { createEmptyRewardSummary, mergeDungeonRewardSummary, type RunBossCoinAward, type RewardSummaryBase, } from '../combat/rewardSummaries' import { PvpRunLootList, RewardXpSummary } from './RewardPanels' import { ResultScreen } from './ResultScreen' import { publishPvpMatchState, submitPvpUpgradeChoice, type CpuDifficulty, type PvpMatchSide, type PvpMatchSnapshot, } from '../pvpRoguelike' import { startPvpQueueWithCpuFallback } from '../pvpQueueLifecycle' import { usePvpLiveMatchSync } from '../pvpLiveLifecycle' const TICK_MS = 700 const ROUND_START_SECONDS = 3 const SHOP_SECONDS = 60 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?: StadiumRoundOutcome shopReady: boolean } type LivePvpMatch = { id: string side: PvpMatchSide opponentSide: PvpMatchSide opponentName: string opponentClassName: string } 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 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 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 starterSpellsForClass(gameClass: CharacterProfile['classes'][number]) { return gameClass.spells .filter((spell) => spell.unlockLevel === 1) .slice(0, 5) .map((spell, index) => toCombatSpell(spell, String(index + 1))) } function randomCpuClass(classes: CharacterProfile['classes'], fallbackId: CharacterProfile['classes'][number]['id']) { const pool = classes.length > 0 ? classes : [] return pool[Math.floor(Math.random() * pool.length)] ?? classes.find((candidate) => candidate.id === fallbackId) } function stadiumBossCoinSource(bosses: DungeonEncounter[], stage: number) { const targetItemLevel = roguelikeCoinItemLevel(stage) const eligible = bosses.filter((boss) => boss.lootTables.some((item) => item.itemLevel === targetItemLevel)) const pool = eligible.length > 0 ? eligible : bosses return pool.length > 0 ? pool[(stage - 1) % pool.length] : undefined } 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[]) { return summarizeStackCounts(createStackCounts(items), catalog, 'none') } function cooldownMultiplier(spell: Spell, buffs: StackCounts) { return spellCooldownMultiplier(spell, { stacks: buffs, id: (slot) => `slot${slot as SlotKey}-cooldown-down` as StadiumBuffId, }) } function spellResourceCost(spell: Spell, buffs: StackCounts, freeCastReady: boolean) { return modifiedSpellResourceCost({ spell, costDown: { stacks: buffs, id: (slot) => `slot${slot as SlotKey}-cost-down` as StadiumBuffId, }, freeCastReady, freeCast: { stacks: buffs, id: 'fifth-cast-free', }, }) } 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 [cpuGameClass, setCpuGameClass] = useState(() => randomCpuClass(profile.classes, gameClass.id) ?? gameClass) const starterSpells = useMemo(() => starterSpellsForClass(gameClass), [gameClass]) const cpuStarterSpells = useMemo(() => starterSpellsForClass(cpuGameClass), [cpuGameClass]) const buffCatalog = useMemo(() => buildStadiumBuffs(starterSpells), [starterSpells]) const cpuBuffCatalog = useMemo(() => buildStadiumBuffs(cpuStarterSpells), [cpuStarterSpells]) 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 stadiumCoinBosses = useMemo( () => profile.dungeons .flatMap((candidate) => candidate.encounters) .filter((candidate) => candidate.isBoss && candidate.lootTables.length > 0), [profile.dungeons], ) const [status, setStatus] = useState<'queueing' | 'round-countdown' | 'playing' | 'shop' | 'won' | 'lost'>('queueing') const [playerSide, setPlayerSide] = useState(() => createStadiumStarterSide({ partyTemplate, maxResource: MAX_RESOURCE, roundIndex: 1, })) const [cpuSide, setCpuSide] = useState(() => createStadiumStarterSide({ partyTemplate: cpuPartyTemplate, maxResource: MAX_RESOURCE, roundIndex: 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 [shopCategory, setShopCategory] = useState('1') const [elapsedTicks, setElapsedTicks] = useState(0) const [cpuDifficulty, setCpuDifficulty] = useState(null) const [liveMatch, setLiveMatch] = useState(null) const [queueMessage, setQueueMessage] = useState('Searching Stadium queue...') const [paused, setPaused] = useState(false) const [log, setLog] = useState([{ id: 1, text: 'Queueing Stadium opponent...', tone: 'system' }]) const { playerFloatingTextsByMember, cpuFloatingTextsByMember, dualScreenFloatingTexts, addFloatingText, clearFloatingTexts, } = useSidedFloatingCombatText() const [rewardSummary, setRewardSummary] = useState(() => createEmptyRewardSummary()) const [stadiumBossCoins, setStadiumBossCoins] = useState([]) 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 submittedShopRef = useRef(false) const awardedXpRef = useRef(new Set()) const matchStatsRecordedRef = useRef(false) const queuedMatchRef = useRef(false) const roundResolvedRef = useRef(false) const loggedOpponentRoundRef = useRef('') const { bindings, controllerIconStyle, directPartyTargeting, lastDevice, } = useInput() const { enabled: dualScreenEnabled } = useDualScreen() const activeBindings = bindings[lastDevice] const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}` const playerAlive = playerSide.party.some((member) => member.health > 0) const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs]) const playerSpellSlotCost = useCallback( (spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady), [playerBuffCounts, playerSide.freeCastReady], ) const playerSpellSlots = useSpellSlots({ spells: starterSpells, cooldowns: playerSide.cooldowns, active: status === 'playing' && !paused, cost: playerSpellSlotCost, }) const opponentBuffSummary = useMemo( () => summarizeStacks(cpuSide.buffs, liveMatch ? buffCatalog : cpuBuffCatalog), [buffCatalog, cpuBuffCatalog, cpuSide.buffs, liveMatch], ) const playerBuffSummary = useMemo( () => summarizeStacks(playerSide.buffs, buffCatalog), [buffCatalog, playerSide.buffs], ) const opponentDebuffSummary = useMemo( () => `Dampening ${playerSide.dampeningPercent}%`, [playerSide.dampeningPercent], ) const partyColumns = 3 const setSelectedTargetId = useCallback((id: string) => { selectedIdRef.current = id setSelectedId(id) }, []) const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => { setLog((current) => appendCombatLog(current, createLogEntry(nextLogId, text, tone), STADIUM_COMBAT_LOG_LIMIT)) }, []) const addFloatingHeal = useCallback((side: 'player' | 'cpu', memberId: string, value: number) => { addFloatingText({ side, memberId, value }) }, [addFloatingText]) const { timeLeft: roundCountdown, start: startRoundCountdown, clear: clearRoundCountdown, } = useRoundCountdown({ seconds: ROUND_START_SECONDS, active: status === 'round-countdown', onComplete: () => { setStatus((current) => current === 'round-countdown' ? 'playing' : current) }, }) const { timeLeft: shopTimeLeft, start: startShopTimer, reset: resetShopTimer, } = useDeadlineTimer({ initialSeconds: SHOP_SECONDS, active: status === 'shop' && !shopReady, intervalMs: 200, onExpire: () => finishShop(), }) const beginRoundCountdown = useCallback(() => { clearRoundCountdown() roundResolvedRef.current = false startRoundCountdown() setStatus('round-countdown') }, [clearRoundCountdown, startRoundCountdown]) const awardXp = useCallback(( key: string, mode: StadiumExperienceMode, coinSource?: { encounter: DungeonEncounter; stage: number; label: string }, ) => { 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, lootSourceEncounterId: coinSource?.encounter.id, roguelikeStage: coinSource?.stage, }) .then((result) => { setRewardSummary((current) => mergeDungeonRewardSummary(current, result)) if (result.bonusItem && coinSource) { setStadiumBossCoins((current) => [...current, { ...result.bonusItem!, sourceLabel: coinSource.label, }]) } onProfileUpdated(result.profile) if (result.experienceGained > 0) addLog(`+${result.experienceGained} XP awarded.`, 'loot') 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 Stadium XP.') }) }, [addLog, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id]) const startLiveMatch = useCallback((match: PvpMatchSnapshot, side: PvpMatchSide, message?: string) => { const setup = createStadiumLiveMatchStart({ match, side, partyTemplate, opponentPartyTemplate: cpuPartyTemplate, maxResource: MAX_RESOURCE, message, }) playerRef.current = setup.playerSide cpuRef.current = setup.opponentSide liveMatchRef.current = setup.liveMatch queuedMatchRef.current = true nextLogId.current = 2 awardedXpRef.current = new Set() matchStatsRecordedRef.current = false roundResolvedRef.current = false setPlayerSide(setup.playerSide) setCpuSide(setup.opponentSide) setRoundIndex(setup.defaults.roundIndex) setRoundWins(setup.defaults.roundWins) setSelectedTargetId(partyTemplate[0].id) setElapsedTicks(setup.defaults.elapsedTicks) setShopPoints(setup.defaults.shopPoints) setShopReady(setup.defaults.shopReady) resetShopTimer(SHOP_SECONDS) setCpuDifficulty(null) setLiveMatch(setup.liveMatch) setPaused(setup.defaults.paused) setRewardSummary(createEmptyRewardSummary()) setStadiumBossCoins([]) setRewardError(setup.defaults.rewardError) setShowEndLog(setup.defaults.showEndLog) clearFloatingTexts() loggedOpponentRoundRef.current = '' setQueueMessage(setup.logText) setLog([{ id: 1, text: setup.logText, tone: 'system' }]) beginRoundCountdown() }, [beginRoundCountdown, clearFloatingTexts, cpuPartyTemplate, partyTemplate, resetShopTimer, setSelectedTargetId]) const startMatch = useCallback(() => { clearRoundCountdown() const setup = createStadiumMatchStart({ partyTemplate, opponentPartyTemplate: cpuPartyTemplate, maxResource: MAX_RESOURCE, }) playerRef.current = setup.playerSide cpuRef.current = setup.opponentSide liveMatchRef.current = null queuedMatchRef.current = true nextLogId.current = 2 awardedXpRef.current = new Set() matchStatsRecordedRef.current = false roundResolvedRef.current = false setPlayerSide(setup.playerSide) setCpuSide(setup.opponentSide) setRoundIndex(setup.defaults.roundIndex) setRoundWins(setup.defaults.roundWins) setSelectedTargetId(partyTemplate[0].id) setElapsedTicks(setup.defaults.elapsedTicks) setStatus('queueing') setShopPoints(setup.defaults.shopPoints) setShopReady(setup.defaults.shopReady) resetShopTimer(SHOP_SECONDS) setCpuDifficulty(null) setLiveMatch(null) setPaused(setup.defaults.paused) setRewardSummary(createEmptyRewardSummary()) setStadiumBossCoins([]) setRewardError(setup.defaults.rewardError) setShowEndLog(setup.defaults.showEndLog) clearFloatingTexts() loggedOpponentRoundRef.current = '' const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => { setCpuGameClass(randomCpuClass(profile.classes, gameClass.id) ?? gameClass) setCpuDifficulty(randomCpu) setQueueMessage(message) setLog([{ id: 1, text: message, tone: 'system' }]) beginRoundCountdown() } return startPvpQueueWithCpuFallback({ contentType: 'stadium', startStage: 1, gameMode, liveMatchActive: () => Boolean(liveMatchRef.current), onSearching: (message) => { setQueueMessage(message) setLog([{ id: 1, text: message, tone: 'system' }]) }, onCpuMatch: beginCpuMatch, onLiveMatch: (match, side, message) => startLiveMatch(match, side, message), messages: { offline: (difficulty) => `Offline mode. CPU ${difficulty} enters Stadium.`, searching: 'Searching Stadium queue for 5s.', notFound: (difficulty) => `No Stadium player found after 5s. CPU ${difficulty} steps in.`, unavailable: (difficulty) => `PvP server unavailable. CPU ${difficulty} steps in.`, liveFound: (match, side) => { const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a' return `${match.players[opponentSide].characterName} found. Stadium begins.` }, }, }) }, [beginRoundCountdown, clearFloatingTexts, clearRoundCountdown, cpuPartyTemplate, gameClass, gameMode, partyTemplate, profile.classes, resetShopTimer, setSelectedTargetId, startLiveMatch]) useEffect(() => { const frame = window.requestAnimationFrame(() => startMatch()) return () => window.cancelAnimationFrame(frame) }, [startMatch]) const applySpell = useCallback(( current: StadiumSideState, setCurrent: Dispatch>, sideName: 'player' | 'cpu', spells: Spell[], spell: Spell, targetId: string, ) => { const buffCounts = createStackCounts(current.buffs) const effectiveCost = spellResourceCost(spell, buffCounts, current.freeCastReady) const dampenMultiplier = Math.max(0, 1 - current.dampeningPercent / 100) const hasBuff = (id: StadiumBuffId) => hasSpellStack({ stacks: buffCounts, id }) const extraTargets = spellExtraTargets(spell, { stacks: buffCounts, id: (slot) => `slot${slot as SlotKey}-extra-target` as StadiumBuffId, }) const renewDuration = hasBuff('slot2-double-duration') && spell.key === '2' ? 10 : 5 const shieldEffect = spells.find((candidate) => candidate.kind === 'shield') const shieldPower = (sourcePower: number, strength = 1) => Math.round( sourcePower * strength * spellPowerMultiplier({ stacks: buffCounts, id: 'shield-boost' }) * dampenMultiplier, ) const appliesRenew = spell.effectType === 'direct_hot' || (spell.key === '1' && hasBuff('slot1-applies-renew')) || (spell.key === '4' && hasBuff('slot4-applies-renew')) || (spell.key === '5' && hasBuff('slot5-applies-renew')) const appliesShield = (spell.key === '1' && hasBuff('slot1-applies-shield')) || (spell.key === '2' && hasBuff('slot2-applies-shield')) || (spell.key === '5' && hasBuff('slot5-applies-shield')) const { directTargets, hotTargets, shieldTargets, damageReductionTargets, groupTargets, } = buildSpellTargetPlan({ party: current.party, spell, targetId, extraTargets, directTarget: spell.kind === 'direct' || spell.kind === 'cleanse', hotTarget: spell.kind === 'hot' || spell.kind === 'bounce_heal' || appliesRenew, shieldTarget: spell.kind === 'shield' || appliesShield, damageReductionTarget: spell.kind === 'damage_reduction', groupTargetCount: DEFAULT_GROUP_HEAL_TARGETS + extraTargets, extraTargetMode: { hot: 'hot', bounce_heal: 'hot', shield: 'shield', damage_reduction: 'damageReduction', }, }) if (spell.effectType === 'direct_hot') directTargets.forEach((id) => hotTargets.add(id)) const groupHealBoost = spellPowerMultiplier({ stacks: buffCounts, id: 'group-heal-boost' }) const spellEffectProfile: SpellEffectProfile = { modeName: 'stadium', heal: (member, power) => clamp(member.health + power, 0, effectiveMaxHealth(member)), healingMultiplier: () => 1, power: { direct: (source) => Math.round(source.power * dampenMultiplier), cleanse: (source) => Math.round(source.power * dampenMultiplier), groupHeal: (source) => Math.round(source.power * groupHealBoost * dampenMultiplier), groupAbsorb: (source) => shieldPower(source.power), shield: shieldPower, }, hot: { mode: 'ticks', defaultTicks: renewDuration, groupTicks: 5, radianceTicks: 5, merge: 'max', groupMerge: 'max', }, effects: { renewSpell: undefined, shieldSpell: shieldEffect, groupAbsorbOnly: (source) => source.effectType === 'party_absorb', groupHotOnly: (source) => source.effectType === 'party_hot', groupAppliesShield: (source) => source.effectType === 'party_absorb' || hasBuff('slot3-applies-shield'), groupAppliesHot: (source) => source.effectType === 'party_hot' || hasBuff('slot3-applies-renew'), shieldAppliesHot: () => false, hotSpellForDirect: (source) => source, }, ratios: { groupShield: 0.5, directShield: 1, }, damageReductionTicks: 12, floatingHeals: { group: true, direct: true, cleanse: true, }, bounceHeals: false, } return applyPvpSpellCast({ current, spell, targetId, resourceCost: effectiveCost, targetPlan: { directTargets, hotTargets, shieldTargets, damageReductionTargets, groupTargets, }, profile: spellEffectProfile, setCurrent, emitFloatingHeal: (memberId, value) => addFloatingHeal(sideName, memberId, value), cooldownMultiplier: cooldownMultiplier(spell, buffCounts), freeCast: { enabled: hasBuff('fifth-cast-free'), wasReady: effectiveCost === 0 && current.freeCastReady, }, }) }, [addFloatingHeal]) 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', starterSpells, spell, targetId) if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal') }, [addLog, applySpell, playerAlive, starterSpells, status]) const getTargetParty = useCallback(() => playerRef.current.party, []) const { selectRelativeTarget, selectDirectionalTarget, selectDirectTarget, } = usePartyTargeting({ getParty: getTargetParty, selectedIdRef, setSelectedTargetId, columns: partyColumns, livingOnly: true, }) const cpuTakeTurn = useCallback(() => { if (!cpuDifficulty || status !== 'playing') return const behavior = CPU_BEHAVIOR[cpuDifficulty] runCpuHealTurn({ side: cpuRef.current, elapsedTicks, spells: cpuStarterSpells, behavior, preferSlots: true, applySpell: (side, spell, targetId) => { applySpell(side, (value) => { const next = typeof value === 'function' ? value(cpuRef.current) : value cpuRef.current = next setCpuSide(next) }, 'cpu', cpuStarterSpells, spell, targetId) }, }) }, [applySpell, cpuDifficulty, cpuStarterSpells, elapsedTicks, status]) const advanceBoss = useCallback((side: StadiumSideState) => { if (side.roundStatus !== 'playing') return side const party = side.party const nextSurvival = side.survivalSeconds + TICK_MS / 1000 const dampeningPercent = Math.floor(nextSurvival / 5) const dampenMultiplier = Math.max(0, 1 - dampeningPercent / 100) const hotHealing = Math.round(6 * dampenMultiplier) const living = 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(party).targets.map((member) => member.id)) const pulse = elapsedTicks > 0 && elapsedTicks % 5 === 0 const spike = elapsedTicks > 0 && elapsedTicks % 8 === 0 const nextParty = 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 result = advanceMemberTick({ member, party, damage, hotHealing, hotTicks: 'ticks', applyDebuff: spike && member.id === spikeTarget.id ? { label: 'Marked', ticks: 4 } : undefined, decrementDamageReduction: true, damageReductionRounding: 'ceil', }) return { ...result.member, damageReductionTicks: result.member.damageReductionTicks || undefined, debuffTicks: result.member.debuffTicks || undefined, } }) return { ...side, party: nextParty, resource: regenerateResource(side.resource, RESOURCE_REGEN_PER_TICK, MAX_RESOURCE), cooldowns: pruneExpiredCooldowns(side.cooldowns), survivalSeconds: nextSurvival, dampeningPercent, } }, [elapsedTicks]) const beginShop = useCallback((outcome: StadiumRoundOutcome, nextWins: { player: number; opponent: number }) => { const points = stadiumShopPointsForOutcome(outcome, 'player') submittedShopRef.current = false setShopPoints(points) setShopReady(false) startShopTimer() 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) { const purchases = chooseStadiumCpuPurchases( cpuBuffCatalog, stadiumShopPointsForOutcome(outcome, 'opponent'), ) setCpuSide((current) => { const next = { ...current, buffs: [...current.buffs, ...purchases], roundStatus: 'shop' as const, roundWins: nextWins.opponent } cpuRef.current = next return next }) } }, [cpuBuffCatalog, startShopTimer]) const finishRound = useCallback((outcome: StadiumRoundOutcome) => { if (status !== 'playing') return if (roundResolvedRef.current) return roundResolvedRef.current = true const result = resolveStadiumRound({ outcome, roundIndex, wins: roundWins }) const roundCoinSource = stadiumBossCoinSource(stadiumCoinBosses, roundIndex) awardXp(result.roundExperience.key, result.roundExperience.mode, roundCoinSource ? { encounter: roundCoinSource, stage: roundIndex, label: `Round ${roundIndex}: ${roundCoinSource.enemyName}` } : undefined) addLog(result.log.text, result.log.tone) if (result.status === 'won') { setRoundWins(result.nextWins) setStatus(result.status) setPlayerSide((current) => { const next = { ...current, roundStatus: result.playerRoundStatus, lastRoundOutcome: outcome, roundWins: result.nextWins.player } playerRef.current = next return next }) if (result.matchExperience) { const matchCoinStage = roundIndex + 1 const matchCoinSource = stadiumBossCoinSource(stadiumCoinBosses, matchCoinStage) awardXp(result.matchExperience.key, result.matchExperience.mode, matchCoinSource ? { encounter: matchCoinSource, stage: matchCoinStage, label: `Match Win: ${matchCoinSource.enemyName}` } : undefined) } return } if (result.status === 'lost') { setRoundWins(result.nextWins) setStatus(result.status) setPlayerSide((current) => { const next = { ...current, roundStatus: result.playerRoundStatus, lastRoundOutcome: outcome, roundWins: result.nextWins.player } playerRef.current = next return next }) return } beginShop(outcome, result.nextWins) }, [addLog, awardXp, beginShop, roundIndex, roundWins, stadiumCoinBosses, 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]) useEffect(() => { if (status !== 'won' && status !== 'lost') return if (matchStatsRecordedRef.current) return matchStatsRecordedRef.current = true recordPvpMatch(status === 'won') .then(onProfileUpdated) .catch((reason: unknown) => { addLog( reason instanceof Error ? reason.message : 'Unable to record PvP match.', 'danger', ) }) }, [addLog, onProfileUpdated, status]) const startNextRound = useCallback(() => { const nextRound = roundIndex + 1 const nextPlayer = createStadiumStarterSide({ partyTemplate, maxResource: MAX_RESOURCE, roundIndex: nextRound, buffs: playerRef.current.buffs, roundWins: roundWins.player, }) const nextCpu = createStadiumStarterSide({ partyTemplate: cpuPartyTemplate, maxResource: MAX_RESOURCE, roundIndex: nextRound, buffs: cpuRef.current.buffs, roundWins: 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 const liveMatch = liveMatchRef.current if (!liveMatch) { startNextRound() return } setShopReady(true) submittedShopRef.current = true const readyPlayer = { ...playerRef.current, shopReady: true } playerRef.current = readyPlayer setPlayerSide(readyPlayer) submitPvpUpgradeChoice(liveMatch.id, { encounterIndex: roundIndex, buffId: 'stadium-shop', debuffId: '', purchases: playerRef.current.buffs, shopReady: true, }).catch(() => undefined) }, [roundIndex, shopReady, startNextRound, status]) const { rematchRequested, rematchMessage, handleRematch } = usePvpLiveMatchSync({ liveMatch, syncEnabled: status !== 'queueing', startLiveMatch, getPayload: () => ({ state: playerRef.current, status: status === 'shop' || status === 'won' || status === 'lost' ? status : 'playing', stage: roundIndex, encounterIndex: roundIndex, encountersCleared: roundWins.player, enemyHealth: 0, alive: playerRef.current.party.some((member) => member.health > 0), elapsedTicks, }), onSnapshot: (snapshot, currentLiveMatch) => { const opponentState = snapshot.states[currentLiveMatch.opponentSide] if (opponentState && opponentState.roundIndex >= roundIndex) { cpuRef.current = opponentState setCpuSide(opponentState) } const opponentStatus = snapshot.statuses[currentLiveMatch.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() } }, }) 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]) 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(() => buildStadiumDualScreenState({ 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 ? `${cpuGameClass.name} | CPU ${cpuDifficulty}` : cpuGameClass.name), opponentParty: cpuSide.party, opponentEnemyHealth: 0, opponentResource: cpuSide.resource, opponentMaxResource: MAX_RESOURCE, opponentResourceName: liveMatch ? gameClass.resourceName : cpuGameClass.resourceName, opponentBuffSummary, opponentDebuffSummary, floatingTexts: dualScreenFloatingTexts, partySize: playerSide.party.length, selectedId, status: status === 'queueing' || status === 'round-countdown' ? 'playing' : status === 'shop' ? 'upgrade-choice' : status, resource: playerSide.resource, maxResource: MAX_RESOURCE, resourceName: gameClass.resourceName, playerIsAlive: playerAlive, spells: playerSpellSlots, bindings: activeBindings, controllerIconStyle, directPartyTargeting, paused, stadium: { dampeningPercent: playerSide.dampeningPercent, roundIndex, playerWins: roundWins.player, opponentWins: roundWins.opponent, survivalSeconds: playerSide.survivalSeconds, opponentSurvivalSeconds: cpuSide.survivalSeconds, }, }), [ activeBindings, controllerIconStyle, cpuDifficulty, cpuGameClass.name, cpuGameClass.resourceName, cpuSide.party, cpuSide.resource, cpuSide.survivalSeconds, directPartyTargeting, dualScreenFloatingTexts, gameClass.resourceName, liveMatch, opponentBuffSummary, opponentDebuffSummary, opponentLabel, paused, playerAlive, playerSide.dampeningPercent, playerSide.party, playerSide.resource, playerSide.survivalSeconds, roundIndex, roundWins.opponent, roundWins.player, selectedId, playerSpellSlots, status, ]) useDualScreenPublisher(dualScreenState, dualScreenEnabled) const visibleBuffs = useMemo( () => buffCatalog.filter((buff) => buff.category === shopCategory), [buffCatalog, 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 ? activeBindings[action] : null return ( ) })}
{playerSpellSlots.map((spell, slotIndex) => { if (!spell) return null const remaining = spell.remaining 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: {playerBuffSummary}

Opponent

{opponentLabel}

{liveMatch?.opponentClassName ?? cpuGameClass.name} | Survival {formatTime(cpuSide.survivalSeconds)}
{liveMatch ? gameClass.resourceName : cpuGameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}
{cpuSide.party.map((member) => ( ))}

Buffs: {opponentBuffSummary}

)} {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: {playerBuffSummary}

)} {paused && (

Paused

Stadium

)} {(status === 'won' || status === 'lost') && ( startMatch() }, { label: 'Back to Roguelike', onClick: onExit, className: 'secondary-result-button' }, ]} eyebrow={status === 'won' ? 'Victory' : 'Defeat'} log={log} onToggleLog={() => setShowEndLog((value) => !value)} rematch={{ visible: Boolean(liveMatch), requested: rematchRequested, message: rematchMessage, onRematch: handleRematch, }} showLog={showEndLog} title={status === 'won' ? 'Stadium Won' : `${opponentLabel} Wins`} >

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

Run totals

{rewardError &&

{rewardError}

}
)}
) }