Files
i-want-to-heal/src/components/PvpRoguelikeScreen.tsx
T
Warren H 3a8d5ad8c5 changes
2026-06-18 22:28:04 -04:00

1403 lines
61 KiB
TypeScript

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