Files
i-want-to-heal/src/components/PvpStadiumScreen.tsx
T
2026-06-23 13:13:57 -04:00

1721 lines
70 KiB
TypeScript

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<string, number>
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<CpuDifficulty, {
actionEveryTicks: number
mistakeChance: number
directHealThreshold: number
groupHealThreshold: number
hotThreshold: number
shieldThreshold: number
}> = {
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<Record<SlotKey, StadiumBuff[]>> = {
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<StadiumBuffId, number>()
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<string, Spell['kind']> = {
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<StadiumSideState>(() => starterSide(partyTemplate, 1))
const [cpuSide, setCpuSide] = useState<StadiumSideState>(() => 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<SlotKey | 'misc'>('1')
const [roundCountdown, setRoundCountdown] = useState(ROUND_START_SECONDS)
const [elapsedTicks, setElapsedTicks] = useState(0)
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
const [liveMatch, setLiveMatch] = useState<LivePvpMatch | null>(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<CombatLogEntry[]>([{ id: 1, text: 'Queueing Stadium opponent...', tone: 'system' }])
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
const [rewardSummary, setRewardSummary] = useState<RewardSummary>(() => 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<LivePvpMatch | null>(null)
const nextLogId = useRef(2)
const nextFloatingTextId = useRef(1)
const roundCountdownTimerRef = useRef<number | null>(null)
const shopEndsAtRef = useRef(0)
const submittedShopRef = useRef(false)
const awardedXpRef = useRef(new Set<string>())
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<StadiumSideState>, 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<StadiumSideState>, 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<StadiumSideState>(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<StadiumSideState>('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<SetStateAction<StadiumSideState>>,
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<string>(spell.kind === 'direct' || spell.kind === 'cleanse' ? [targetId] : [])
const hotTargets = new Set<string>(spell.kind === 'hot' || spell.kind === 'bounce_heal' ? [targetId] : [])
const shieldTargets = new Set<string>(spell.kind === 'shield' ? [targetId] : [])
const damageReductionTargets = new Set<string>(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<StadiumSideState>(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<StadiumSideState>(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<StadiumSideState>) => {
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<StadiumSideState>(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<DualScreenCombatState>(() => ({
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 (
<main className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`} data-combat-active={status === 'playing' && !paused ? 'true' : 'false'}>
<section className="content-screen stadium-screen">
{status === 'queueing' && (
<div className="placeholder-panel">
<div className="placeholder-runes">P V P</div>
<p>{queueMessage}</p>
</div>
)}
{dualScreenEnabled && status !== 'queueing' && (
<div className="dual-top-main stadium-dual-top">
<header className="stadium-header">
<div>
<p className="eyebrow">Stadium</p>
<h2>Round {roundIndex} / Best of 5</h2>
</div>
<strong>{roundWins.player} - {roundWins.opponent}</strong>
<div>
<p>Dampening {playerSide.dampeningPercent}%</p>
<small>Survival {formatTime(playerSide.survivalSeconds)} | Equalized iLvl 10</small>
</div>
</header>
<section className="stadium-pressure-panel">
<strong>Iron Arbiter</strong>
<span>No boss health | Next pulse in {Math.max(0, 5 - (elapsedTicks % 5) * (TICK_MS / 1000)).toFixed(1)}s</span>
</section>
<section className="dual-top-party">
<div className="dual-top-party-grid">
{playerSide.party.map((member, index) => {
const action = `targetParty${index + 1}` as InputAction
const targetBinding = directPartyTargeting ? bindings[lastDevice][action] : null
return (
<button
className={`dual-top-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
data-party-member-id={member.id}
key={member.id}
onClick={() => setSelectedTargetId(member.id)}
type="button"
>
<div className="member-header">
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
<strong>{member.name}</strong>
<small>{Math.ceil(member.health)} / {effectiveMaxHealth(member)}</small>
</div>
<div className="bar member-health">
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
</div>
<div className="floating-combat-texts" aria-hidden="true">
{floatingTexts
.filter((entry) => entry.side === 'player' && entry.memberId === member.id)
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
</div>
{targetBinding && (
<div className="member-target-key">
<ControllerBindingLabel
binding={targetBinding}
iconStyle={controllerIconStyle}
/>
</div>
)}
<div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew</span>}
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
{member.debuff && <span className="debuff">{member.debuff}</span>}
</div>
</button>
)
})}
</div>
</section>
<section className="dual-top-spell-strip">
{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 (
<button
className="dual-top-spell"
disabled={status !== 'playing' || !playerAlive || remaining > 0 || playerSide.resource < cost || paused}
key={spell.id}
onClick={() => castPlayerSpell(spell)}
type="button"
>
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
{remaining > 0 && <i style={{ height: `${percent}%` }} />}
{remaining > 0 && <small>{remaining.toFixed(0)}</small>}
<span className="sr-only">{slotIndex + 1}. {spell.name}</span>
</button>
)
})}
<div className="dual-top-resource">
<strong>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</strong>
<div className="bar mana-bar">
<span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} />
</div>
</div>
</section>
</div>
)}
{!dualScreenEnabled && status !== 'queueing' && (
<div className="stadium-board">
<header className="stadium-header">
<div>
<p className="eyebrow">Stadium</p>
<h2>Round {roundIndex} / Best of 5</h2>
</div>
<strong>{roundWins.player} - {roundWins.opponent}</strong>
<div>
<p>Dampening {playerSide.dampeningPercent}%</p>
<small>Survival {formatTime(playerSide.survivalSeconds)} | Equalized iLvl 10</small>
</div>
</header>
<section className="stadium-pressure-panel">
<strong>Iron Arbiter</strong>
<span>No boss health | Next pulse in {Math.max(0, 5 - (elapsedTicks % 5) * (TICK_MS / 1000)).toFixed(1)}s</span>
</section>
<section className="combat-panel stadium-side">
<div className="encounter-header">
<div>
<p className="eyebrow">You</p>
<h2>{profile.character.name}</h2>
</div>
<div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</span>
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} /></div>
</div>
</div>
<div className="party-grid pvp-party-grid">
{playerSide.party.map((member) => (
<button className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`} key={member.id} onClick={() => setSelectedTargetId(member.id)} type="button">
<div className="member-header">
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
<strong>{member.name}</strong>
</div>
<div className="bar member-health">
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
<em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
</div>
<div className="floating-combat-texts" aria-hidden="true">
{floatingTexts
.filter((entry) => entry.side === 'player' && entry.memberId === member.id)
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
</div>
<div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew</span>}
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
{member.debuff && <span className="debuff">{member.debuff}</span>}
</div>
</button>
))}
</div>
<p className="roguelike-upgrade-list">Buffs: {summarizeStacks(playerSide.buffs, buffCatalog)}</p>
</section>
<section className="combat-panel stadium-side">
<div className="encounter-header">
<div>
<p className="eyebrow">Opponent</p>
<h2>{opponentLabel}</h2>
<small>Survival {formatTime(cpuSide.survivalSeconds)}</small>
</div>
<div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / MAX_RESOURCE) * 100}%` }} /></div>
</div>
</div>
<div className="party-grid pvp-party-grid">
{cpuSide.party.map((member) => (
<div className={`party-member ${member.health <= 0 ? 'down' : ''}`} key={member.id}>
<div className="member-header">
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
<strong>{member.name}</strong>
</div>
<div className="bar member-health">
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
<em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
</div>
<div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew</span>}
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
{member.debuff && <span className="debuff">{member.debuff}</span>}
</div>
</div>
))}
</div>
<p className="roguelike-upgrade-list">Buffs: {summarizeStacks(cpuSide.buffs, buffCatalog)}</p>
</section>
<section className="pvp-bottom-spell-bar" aria-label="Player abilities">
{starterSpells.map((spell) => {
const remaining = playerSide.cooldowns[spell.id] ?? 0
const cost = spellResourceCost(spell, playerSide.buffs, playerSide.freeCastReady)
return (
<button
className="spell"
disabled={status !== 'playing' || !playerAlive || remaining > 0 || playerSide.resource < cost}
key={spell.id}
onClick={() => castPlayerSpell(spell)}
type="button"
>
<kbd>
<ControllerBindingLabel
binding={bindings[lastDevice][`ability${Number(spell.key)}` as InputAction]}
compact
iconStyle={controllerIconStyle}
/>
</kbd>
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
<strong>{spell.name}</strong>
<small>{cost} {gameClass.resourceName}</small>
{remaining > 0 && <i>{remaining.toFixed(1)}</i>}
</button>
)
})}
</section>
</div>
)}
{status === 'round-countdown' && (
<div className="pvp-round-countdown">
<div>
<p className="eyebrow">Round Starts</p>
<h2>{Math.max(1, Math.ceil(roundCountdown))}</h2>
</div>
</div>
)}
{status === 'shop' && (
<div className="result-screen">
<div className="stadium-shop-dialog">
<div className="pvp-upgrade-header">
<div>
<p className="eyebrow">Stadium Buy Round</p>
<h2>Round {roundIndex} Complete</h2>
</div>
<strong>{shopTimeLeft.toFixed(0)}s</strong>
</div>
<div className="stadium-shop-summary">
<span>Points: {shopPoints}</span>
<span>Score: {roundWins.player} - {roundWins.opponent}</span>
{shopReady && <span>Waiting for opponent...</span>}
</div>
<div className="stadium-shop-layout">
<nav className="stadium-shop-tabs">
{(['1', '2', '3', '4', '5'] as SlotKey[]).map((slot) => (
<button className={shopCategory === slot ? 'active' : ''} key={slot} onClick={() => setShopCategory(slot)} type="button">
{slotLabel(slot, starterSpells)}
</button>
))}
<button className={shopCategory === 'misc' ? 'active' : ''} onClick={() => setShopCategory('misc')} type="button">
Miscellaneous
</button>
</nav>
<section>
<h3>{categoryLabel} Buffs</h3>
<div className="stadium-shop-grid">
{visibleBuffs.map((buff) => (
<button disabled={shopReady || shopPoints < buff.cost} key={buff.id} onClick={() => buyBuff(buff)} type="button">
<strong>[{buff.cost}] {buff.name}</strong>
<small>{buff.description}</small>
</button>
))}
</div>
<p>Active: {summarizeStacks(playerSide.buffs, buffCatalog)}</p>
</section>
</div>
<button className="secondary-result-button" disabled={shopReady} onClick={finishShop} type="button">
{shopReady ? 'Finished' : 'Finished Buying'}
</button>
</div>
</div>
)}
{paused && (
<div className="pause-screen">
<div>
<p className="eyebrow">Paused</p>
<h2>Stadium</h2>
<button onClick={() => setPaused(false)} type="button">Resume</button>
<button className="secondary-result-button" onClick={onExit} type="button">Leave</button>
</div>
</div>
)}
{(status === 'won' || status === 'lost') && (
<div className="result-screen">
<div>
<p className="eyebrow">{status === 'won' ? 'Victory' : 'Defeat'}</p>
<h2>{status === 'won' ? 'Stadium Won' : `${opponentLabel} Wins`}</h2>
<p>Final score {roundWins.player} - {roundWins.opponent}</p>
<div className="reward-summary">
<p>+{rewardSummary.experienceGained} XP</p>
{rewardError && <p className="reward-error">{rewardError}</p>}
{rewardSummary.levelsGained > 0 && rewardSummary.previousLevel !== null && rewardSummary.newLevel !== null && (
<p className="level-gain">
Level {rewardSummary.previousLevel} to {rewardSummary.newLevel}
<small>+{rewardSummary.talentPointsGained} talent point{rewardSummary.talentPointsGained === 1 ? '' : 's'}</small>
</p>
)}
{rewardSummary.unlockedAbilities.map((ability) => (
<p className="ability-unlock" key={ability.id}>
<span>{ability.glyph}</span>
Ability Unlocked: {ability.name}
</p>
))}
</div>
{log.length > 0 && (
<>
<button className="secondary-result-button" onClick={() => setShowEndLog((value) => !value)} type="button">
{showEndLog ? 'Hide Combat Log' : 'View Combat Log'}
</button>
{showEndLog && (
<div className="result-log">
{log.slice().reverse().map((entry) => (
<div className={`log-entry ${entry.tone}`} key={entry.id}>{entry.text}</div>
))}
</div>
)}
</>
)}
{liveMatch && (
<>
<button disabled={rematchRequested} onClick={handleRematch} type="button">
{rematchRequested ? 'Waiting for Rematch' : 'Rematch'}
</button>
{rematchMessage && <p>{rematchMessage}</p>}
</>
)}
<button onClick={() => startMatch()} type="button">Queue Next Match</button>
<button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button>
</div>
</div>
)}
</section>
</main>
)
}