Files
i-want-to-heal/src/components/PvpRoguelikeScreen.tsx
T
2026-07-05 22:48:56 -04:00

1837 lines
72 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
INITIAL_PARTY,
RAID_PARTY,
DEFAULT_GROUP_HEAL_TARGETS,
partyDamageOutput,
tankPressureTargets,
type CombatLogEntry,
type PartyMember,
type Spell,
} from '../game'
import { completeRoguelike, recordPvpMatch, type DungeonReward } from '../profile'
import type { CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository'
import { PartyMemberFrame } from './PartyFrames'
import { SpellBar } from './SpellBars'
import { barFillStyle } from './barStyles'
import { focusFirstControl, useGameAction, useInput } from '../input'
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
import { usePartyTargeting } from '../hooks/usePartyTargeting'
import {
chooseRandom,
createLogEntry,
healMember,
toCombatSpell,
} from '../combat/rules'
import { buildRoguelikeSegment } from '../combat/encounters'
import { chooseCpuHealActions, type CpuTurnBehavior } from '../combat/cpuAi'
import {
buildOpponentSlotDebuffChoices,
buildSelfSlotUpgradeChoices,
summarizeChoiceStacks,
} from '../combat/roguelikeUpgrades'
import { createStackCounts, type StackCounts } from '../combat/stackCounts'
import {
spellCooldownMultiplier,
spellExtraTargets,
spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers'
import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick } from '../combat/combatEngine'
import { buildPvpRoguelikeDualScreenState } from '../combat/dualScreenPayloads'
import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
import {
pruneExpiredCooldowns,
reduceCooldown,
} from '../combat/spellCasting'
import {
nextPvpRoguelikeStage,
resolvePvpRoguelikeCombatOutcome,
resolvePvpRoguelikeUpgradeCompletion,
resolvePvpRoguelikeLiveSnapshot,
type PvpRoguelikeStatus,
} from '../combat/pvpRoguelikeLifecycle'
import {
applyPvpRoguelikeUpgradeChoice,
preparePvpRoguelikeNextEncounter,
} from '../combat/pvpRoguelikeUpgrades'
import {
createPvpRoguelikeLiveMatchStart,
createPvpRoguelikeMatchStart,
createPvpRoguelikeStarterSide,
} from '../combat/pvpRoguelikeMatchSetup'
import {
createEmptyPvpRunSummary,
mergePvpRunRewardSummary,
type PvpRunRewardSummary,
} from '../combat/rewardSummaries'
import {
appendCombatLog,
} from '../combat/combatPresentation'
import {
buildSpellTargetPlan,
type SpellEffectProfile,
} from '../combat/spellEffects'
import { BonusItemReward, PvpRunLootList, RewardXpSummary } from './RewardPanels'
import { ResultScreen } from './ResultScreen'
import {
DualScreenTopCombat,
useDualScreen,
useDualScreenPublisher,
} from '../dualScreen'
import {
loadPvpRoguelikeCheckpoint,
loadPvpMatch,
recordCpuPvpLeaderboard,
recordPvpRoguelikeCheckpoint,
submitPvpUpgradeChoice,
type CpuDifficulty,
type PvpMatchSnapshot,
type PvpMatchSide,
type PvpContentType,
type PvpUpgradeChoicePayload,
} from '../pvpRoguelike'
import { startPvpQueueWithCpuFallback } from '../pvpQueueLifecycle'
import { usePvpLiveMatchSync } from '../pvpLiveLifecycle'
const TICK_MS = 700
const ROUND_START_SECONDS = 3
const UPGRADE_CHOICE_SECONDS = 15
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' | '6'
type DraftSlotKey = Exclude<SlotKey, '6'>
type AbilityLabelMode = 'ability' | 'slot'
type SelfBuffId =
| 'revive-party-members'
| `slot${DraftSlotKey}-extra-target`
| `slot${DraftSlotKey}-cost-down`
| `slot${DraftSlotKey}-cooldown-down`
type OpponentDebuffId =
| `opp-slot${DraftSlotKey}-cost-up`
| `opp-slot${DraftSlotKey}-cooldown-up`
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 LivePvpMatch = {
id: string
side: PvpMatchSide
opponentSide: PvpMatchSide
opponentName: string
opponentClassName: string
}
type PvpOverlayNavEntry =
| { kind: 'queueBack'; row: number; column: number }
| { kind: 'pauseResume'; row: number; column: number }
| { kind: 'pauseLeave'; row: number; column: number }
| { kind: 'upgradeBuff'; index: number; row: number; column: number }
| { kind: 'upgradeDebuff'; index: number; row: number; column: number }
| { kind: 'upgradeContinue'; row: number; column: number; disabled?: boolean }
const REVIVE_PARTY_CHOICE: Choice<SelfBuffId> = {
id: 'revive-party-members',
name: 'Revive Party Members',
description: 'Revive fallen party members before the next fight.',
}
const BOSS_MECHANICS: BossMechanic[] = [
'party-pulse',
'searing-mark',
'max-health-cut',
'healing-reduction',
'ramping-poison',
]
const CPU_BEHAVIOR: Record<CpuDifficulty, CpuTurnBehavior> = {
1: { actionEveryTicks: 3, mistakeChance: 0.28, directHealThreshold: 0.56, groupHealThreshold: 0.52, hotThreshold: 0.62, shieldThreshold: 0.5 },
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 buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> {
return buildSelfSlotUpgradeChoices<SelfBuffId>({
slots: ['1', '2', '3', '4', '5'] as DraftSlotKey[],
spells,
labelMode,
})
}
function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> {
return buildOpponentSlotDebuffChoices<OpponentDebuffId>({
slots: ['1', '2', '3', '4', '5'] as DraftSlotKey[],
spells,
labelMode,
})
}
function cooldownMultiplier(spell: Spell, buffs: StackCounts<SelfBuffId>, debuffs: StackCounts<OpponentDebuffId>) {
return spellCooldownMultiplier(
spell,
{
stacks: buffs,
id: (slot) => `slot${slot as SlotKey}-cooldown-down` as SelfBuffId,
},
{
stacks: debuffs,
id: (slot) => `opp-slot${slot as SlotKey}-cooldown-up` as OpponentDebuffId,
},
)
}
function spellResourceCost(spell: Spell, buffs: StackCounts<SelfBuffId>, debuffs: StackCounts<OpponentDebuffId>, freeCastReady: boolean) {
void freeCastReady
return modifiedSpellResourceCost({
spell,
costDown: {
stacks: buffs,
id: (slot) => `slot${slot as SlotKey}-cost-down` as SelfBuffId,
},
costUp: {
stacks: debuffs,
id: (slot) => `opp-slot${slot as SlotKey}-cost-up` as OpponentDebuffId,
},
})
}
function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: PvpContentType): PvpEncounter[] {
const mechanics = chooseRandom(BOSS_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
const stageOneDamageScale = 0.8 + (kind === 'raid' ? 0.18 : 0.14)
return buildRoguelikeSegment<BossMechanic, {
bossMechanics: BossMechanic[]
sourceEncounterId?: number
}>({
pool,
stage,
mechanics,
fallbackBossFirst: true,
trashCandidateCount: (trashCount) => Math.min(trashCount, 5 + stage * (kind === 'raid' ? 1 : 2)),
bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 1) / 2)),
healthScale: 0.75 + stage * (kind === 'raid' ? 0.28 : 0.22),
damageScale: stageOneDamageScale,
partyDamageScale: 0.89,
idBase: 910000,
bossDescription: (selectedMechanics) => `PvP boss with ${selectedMechanics.join(', ')}.`,
extraFields: (encounter, isBoss, selectedMechanics) => ({
sourceEncounterId: encounter.id,
bossMechanics: isBoss ? selectedMechanics : [],
}),
}) as PvpEncounter[]
}
function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
if (buff.id === 'revive-party-members') return 10
const slot = buff.id.match(/slot([1-6])/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) {
void opponentBuffCount
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 summarizeStacks<T extends string>(items: T[], catalog: Array<Choice<T>>) {
return summarizeChoiceStacks(items, catalog)
}
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, 6)
.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<PvpRoguelikeStatus>('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>(() => createPvpRoguelikeStarterSide<SelfBuffId, OpponentDebuffId>(partyTemplate, maxResource))
const [cpuSide, setCpuSide] = useState<SideState>(() => createPvpRoguelikeStarterSide<SelfBuffId, OpponentDebuffId>(cpuPartyTemplate, maxResource))
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
const selectedIdRef = useRef(partyTemplate[0].id)
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
const [liveMatch, setLiveMatch] = useState<LivePvpMatch | null>(null)
const [liveUpgradePending, setLiveUpgradePending] = useState(false)
const [queueMessage, setQueueMessage] = useState('')
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
const [reward, setReward] = useState<DungeonReward | null>(null)
const [runSummary, setRunSummary] = useState<PvpRunRewardSummary>(() => createEmptyPvpRunSummary())
const [rewardError, setRewardError] = useState('')
const [showEndLog, setShowEndLog] = useState(false)
const {
playerFloatingTextsByMember,
cpuFloatingTextsByMember,
dualScreenFloatingTexts,
addFloatingText,
clearFloatingTexts,
} = useSidedFloatingCombatText()
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 [overlaySelectedIndex, setOverlaySelectedIndex] = useState(0)
const [encountersCleared, setEncountersCleared] = useState(0)
const [paused, setPaused] = useState(false)
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
const nextLogId = useRef(2)
const elapsedTicksRef = useRef(0)
const recordedRunRef = useRef(false)
const matchStatsRecordedRef = useRef(false)
const rewardClaimedRef = useRef(false)
const matchWinRewardClaimedRef = useRef(false)
const bossRewardClaimedRef = useRef(new Set<number>())
const cpuDefeatedRef = useRef(false)
const playerClearedEncounterRef = useRef(-1)
const queuedMatchRef = useRef(false)
const autoSubmittedUpgradeRef = useRef(false)
const liveMatchRef = useRef<LivePvpMatch | null>(null)
const loggedOpponentDoneRef = useRef(false)
const pendingLiveUpgradeRef = useRef<{
encounterIndex: number
buff: Choice<SelfBuffId>
debuff: Choice<OpponentDebuffId>
} | null>(null)
const encounterPoolRef = useRef(encounterPool)
const playerRef = useRef(playerSide)
const cpuRef = useRef(cpuSide)
const encounter = encounters[encounterIndex]
const rewardDungeon = useMemo(
() => profile.dungeons.find((candidate) => candidate.contentType === contentType) ?? profile.dungeons[0],
[contentType, profile.dungeons],
)
const rewardDifficulty = rewardDungeon.difficulties[0]
const finalEncountersCleared = status === 'won'
? Math.max(encountersCleared, encounterIndex + 1)
: encountersCleared
const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1]
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
const activeSpellEffects = useMemo(
() => new Set(
gameClass.talents
.filter((talent) => talent.rank > 0)
.map((talent) => talent.effectType),
),
[gameClass.talents],
)
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 playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
const playerDebuffCounts = useMemo(() => createStackCounts(playerSide.debuffs), [playerSide.debuffs])
const playerSpellSlotCost = useCallback(
(spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady),
[playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady],
)
const playerSpellSlots = useSpellSlots({
spells: starterSpells,
cooldowns: playerSide.cooldowns,
active: status === 'playing' && !paused,
cost: playerSpellSlotCost,
})
const opponentBuffSummary = useMemo(
() => cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
[cpuSide.buffs, selfBuffChoicesCatalog],
)
const opponentDebuffSummary = useMemo(
() => cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none',
[cpuSide.debuffs, opponentDebuffChoicesCatalog],
)
const playerBuffSummary = useMemo(
() => playerSide.buffs.length > 0 ? summarizeStacks(playerSide.buffs, selfBuffChoicesCatalog) : 'none',
[playerSide.buffs, selfBuffChoicesCatalog],
)
const playerDebuffSummary = useMemo(
() => playerSide.debuffs.length > 0 ? summarizeStacks(playerSide.debuffs, opponentDebuffChoicesCatalog) : 'none',
[opponentDebuffChoicesCatalog, playerSide.debuffs],
)
const partyColumns = contentType === 'raid' ? 6 : 3
const {
bindings,
controllerIconStyle,
directPartyTargeting,
lastDevice,
} = useInput()
const {
enabled: dualScreenEnabled,
} = useDualScreen()
const activeBindings = bindings[lastDevice]
const setSelectedTargetId = useCallback((id: string) => {
selectedIdRef.current = id
setSelectedId(id)
}, [])
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
setLog((current) => appendCombatLog(current, createLogEntry(nextLogId, text, tone)))
}, [])
const {
timeLeft: roundCountdown,
start: startRoundCountdown,
clear: clearRoundCountdown,
} = useRoundCountdown({
seconds: ROUND_START_SECONDS,
active: status === 'round-countdown',
onComplete: () => {
setStatus((current) => current === 'round-countdown' ? 'playing' : current)
},
})
const {
timeLeft: upgradeTimeLeft,
start: startUpgradeTimer,
reset: resetUpgradeTimer,
} = useDeadlineTimer({
initialSeconds: UPGRADE_CHOICE_SECONDS,
active: status === 'upgrade-choice' && !liveUpgradePending,
onExpire: () => {
if (autoSubmittedUpgradeRef.current) return
autoSubmittedUpgradeRef.current = true
const autoBuff = selectedBuff ?? chooseRandom(playerBuffChoices, 1)[0]
const autoDebuff = selectedDebuff ?? chooseRandom(playerDebuffChoices, 1)[0]
if (autoBuff) setSelectedBuff(autoBuff)
if (autoDebuff) setSelectedDebuff(autoDebuff)
if (autoBuff && autoDebuff) {
addLog('Upgrade timer expired. Random choices selected.', 'system')
confirmUpgradeChoices(autoBuff, autoDebuff)
}
},
})
const addFloatingHeal = useCallback((side: 'player' | 'cpu', memberId: string, value: number) => {
addFloatingText({ side, memberId, value })
}, [addFloatingText])
const beginRoundCountdown = useCallback((message?: string) => {
clearRoundCountdown()
startRoundCountdown()
setStatus('round-countdown')
if (message) addLog(message, 'system')
}, [addLog, clearRoundCountdown, startRoundCountdown])
useEffect(() => {
if (queuedMatchRef.current) return
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
setCheckpointStage(loadedCheckpoint)
setStartStage(loadedCheckpoint)
}, [contentType, profile.character.id])
useEffect(() => {
encounterPoolRef.current = encounterPool
}, [encounterPool])
const awardEncounterReward = useCallback((encounterIndexValue: number) => {
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
bossRewardClaimedRef.current.add(encounterIndexValue)
const rewardEncounter = encounters[encounterIndexValue]
const isBossReward = Boolean(rewardEncounter?.isBoss)
completeRoguelike(
rewardDungeon.id,
rewardDifficulty.id,
0,
0,
Math.max(1, Math.round((elapsedTicksRef.current * TICK_MS) / 1000)),
{
bossesCleared: isBossReward ? 1 : 0,
fightsCleared: 1,
experienceMode: isBossReward ? 'pvp-boss-quarter-level' : 'pvp-fight-twelfth-level',
lootSourceEncounterId: isBossReward ? rewardEncounter?.sourceEncounterId : undefined,
roguelikeStage: isBossReward ? stage : undefined,
},
)
.then((result) => {
setReward(result)
setRunSummary((current) => mergePvpRunRewardSummary(current, result, {
bossKilled: isBossReward,
sourceLabel: rewardEncounter?.enemyName,
}))
onProfileUpdated(result.profile)
if (result.experienceGained > 0) {
addLog(`+${result.experienceGained} XP awarded.`, 'loot')
}
if (result.bonusItem) {
addLog(
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
'loot',
)
}
if (result.petAwarded) {
addLog(
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
'loot',
)
}
})
.catch((reason: unknown) => {
setRewardError(
reason instanceof Error ? reason.message : 'Unable to award roguelike experience.',
)
})
}, [addLog, encounters, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id, stage])
const awardMatchWinReward = useCallback(() => {
if (matchWinRewardClaimedRef.current) return
matchWinRewardClaimedRef.current = true
completeRoguelike(
rewardDungeon.id,
rewardDifficulty.id,
0,
0,
Math.max(1, Math.round((elapsedTicksRef.current * TICK_MS) / 1000)),
{
bossesCleared: 0,
fightsCleared: 0,
experienceMode: 'pvp-match-win-half-level',
},
)
.then((result) => {
setReward(result)
setRunSummary((current) => mergePvpRunRewardSummary(current, result, {
bossKilled: false,
sourceLabel: 'Match Win',
}))
onProfileUpdated(result.profile)
if (result.experienceGained > 0) {
addLog(`Match win bonus: +${result.experienceGained} XP.`, 'loot')
}
})
.catch((reason: unknown) => {
setRewardError(
reason instanceof Error ? reason.message : 'Unable to award match win experience.',
)
})
}, [addLog, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id])
const finishRoguelikeRun = useCallback((result: 'won' | 'lost') => {
if (rewardClaimedRef.current) return
rewardClaimedRef.current = true
if (result === 'won') awardMatchWinReward()
}, [awardMatchWinReward])
useEffect(() => {
const frame = window.requestAnimationFrame(() => {
setPlayerBuffChoices((current) => current
.map((choice) => choice.id === REVIVE_PARTY_CHOICE.id
? REVIVE_PARTY_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
? (current.id === REVIVE_PARTY_CHOICE.id ? REVIVE_PARTY_CHOICE : selfBuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current)
: null)
setSelectedDebuff((current) => current
? opponentDebuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current
: null)
})
return () => window.cancelAnimationFrame(frame)
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
const startLiveMatch = useCallback((
match: PvpMatchSnapshot<SideState>,
side: PvpMatchSide,
message?: string,
) => {
const setup = createPvpRoguelikeLiveMatchStart<SelfBuffId, OpponentDebuffId, PvpEncounter>({
match,
side,
encounterPool: encounterPoolRef.current,
buildSegment: (pool, nextStage) => buildEncounterSegment([...pool], nextStage, contentType),
partyTemplate,
opponentPartyTemplate: cpuPartyTemplate,
maxResource,
upgradeChoiceSeconds: UPGRADE_CHOICE_SECONDS,
message,
})
playerRef.current = setup.playerSide
cpuRef.current = setup.opponentSide
liveMatchRef.current = setup.liveMatch
nextLogId.current = 2
playerClearedEncounterRef.current = -1
queuedMatchRef.current = true
bossRewardClaimedRef.current = new Set()
elapsedTicksRef.current = setup.defaults.elapsedTicks
setEncounters(setup.firstSegment)
setEncounterIndex(setup.defaults.encounterIndex)
setCheckpointStage(setup.startStage)
setStartStage(setup.startStage)
setStage(setup.startStage)
setPlayerSide(setup.playerSide)
setCpuSide(setup.opponentSide)
setSelectedTargetId(partyTemplate[0].id)
setPlayerBuffChoices([])
setPlayerDebuffChoices([])
setSelectedBuff(null)
setSelectedDebuff(null)
resetUpgradeTimer(setup.defaults.upgradeTimeLeft)
autoSubmittedUpgradeRef.current = false
setEncountersCleared(setup.defaults.encountersCleared)
setPaused(setup.defaults.paused)
setTargetGroup(setup.defaults.targetGroup)
setReward(null)
setRunSummary(createEmptyPvpRunSummary())
setRewardError(setup.defaults.rewardError)
setShowEndLog(setup.defaults.showEndLog)
clearFloatingTexts()
setCpuDifficulty(null)
setLiveMatch(setup.liveMatch)
setLiveUpgradePending(setup.defaults.liveUpgradePending)
pendingLiveUpgradeRef.current = null
loggedOpponentDoneRef.current = false
recordedRunRef.current = false
matchStatsRecordedRef.current = false
rewardClaimedRef.current = false
matchWinRewardClaimedRef.current = false
cpuDefeatedRef.current = false
setQueueMessage(setup.logText)
setLog([{ id: 1, text: setup.logText, tone: 'system' }])
beginRoundCountdown()
}, [beginRoundCountdown, clearFloatingTexts, contentType, cpuPartyTemplate, maxResource, partyTemplate, resetUpgradeTimer, setSelectedTargetId])
const startMatch = useCallback((nextStartStage?: number) => {
clearRoundCountdown()
const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
const setup = createPvpRoguelikeMatchStart<SelfBuffId, OpponentDebuffId, PvpEncounter>({
startStage: matchStartStage,
encounterPool: encounterPoolRef.current,
buildSegment: (pool, nextStage) => buildEncounterSegment([...pool], nextStage, contentType),
partyTemplate,
opponentPartyTemplate: cpuPartyTemplate,
maxResource,
upgradeChoiceSeconds: UPGRADE_CHOICE_SECONDS,
})
playerRef.current = setup.playerSide
cpuRef.current = setup.opponentSide
nextLogId.current = 2
playerClearedEncounterRef.current = -1
queuedMatchRef.current = true
bossRewardClaimedRef.current = new Set()
elapsedTicksRef.current = setup.defaults.elapsedTicks
setEncounters(setup.firstSegment)
setEncounterIndex(setup.defaults.encounterIndex)
setCheckpointStage(setup.startStage)
setStartStage(setup.startStage)
setStage(setup.startStage)
setStatus('queueing')
setOverlaySelectedIndex(0)
setPlayerSide(setup.playerSide)
setCpuSide(setup.opponentSide)
setSelectedTargetId(partyTemplate[0].id)
setPlayerBuffChoices([])
setPlayerDebuffChoices([])
setSelectedBuff(null)
setSelectedDebuff(null)
resetUpgradeTimer(setup.defaults.upgradeTimeLeft)
autoSubmittedUpgradeRef.current = false
setEncountersCleared(setup.defaults.encountersCleared)
setPaused(setup.defaults.paused)
setTargetGroup(setup.defaults.targetGroup)
setReward(null)
setRunSummary(createEmptyPvpRunSummary())
setRewardError(setup.defaults.rewardError)
setShowEndLog(setup.defaults.showEndLog)
clearFloatingTexts()
setCpuDifficulty(null)
setLiveMatch(null)
liveMatchRef.current = null
setLiveUpgradePending(setup.defaults.liveUpgradePending)
pendingLiveUpgradeRef.current = null
loggedOpponentDoneRef.current = false
recordedRunRef.current = false
matchStatsRecordedRef.current = false
rewardClaimedRef.current = false
matchWinRewardClaimedRef.current = false
cpuDefeatedRef.current = false
const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => {
liveMatchRef.current = null
setLiveMatch(null)
setCpuDifficulty(randomCpu)
setQueueMessage(message)
setLog([{ id: 1, text: message, tone: 'system' }])
beginRoundCountdown(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`)
}
return startPvpQueueWithCpuFallback<SideState>({
contentType,
startStage: matchStartStage,
gameMode,
liveMatchActive: () => Boolean(liveMatchRef.current),
onSearching: (message) => {
setQueueMessage(message)
setLog([{ id: 1, text: message, tone: 'system' }])
},
onCpuMatch: beginCpuMatch,
onLiveMatch: (match, side, message) => startLiveMatch(match, side, message),
messages: {
offline: (difficulty) => `Offline mode. CPU ${difficulty} enters at stage ${matchStartStage}.`,
searching: `Searching queue for 5s. Stage ${matchStartStage} start ready.`,
notFound: (difficulty) => `No queued player found after 5s. CPU ${difficulty} steps in.`,
unavailable: (difficulty) => `PvP server unavailable. CPU ${difficulty} steps in.`,
liveFound: (match, side) => {
const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a'
return `${match.players[opponentSide].characterName} found. Stage ${match.startStage} begins.`
},
},
})
}, [beginRoundCountdown, clearFloatingTexts, clearRoundCountdown, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, resetUpgradeTimer, setSelectedTargetId, startLiveMatch])
useEffect(() => startMatch(), [startMatch])
const { rematchRequested, rematchMessage, handleRematch } = usePvpLiveMatchSync<SideState, LivePvpMatch>({
liveMatch,
syncEnabled: status !== 'queueing',
startLiveMatch,
getPayload: () => ({
state: playerRef.current,
status: status === 'upgrade-choice' ? 'upgrade-choice' : status === 'won' || status === 'lost' ? status : 'playing',
stage,
encounterIndex,
encountersCleared,
enemyHealth: playerRef.current.enemyHealth,
alive: playerRef.current.party.some((member) => member.health > 0),
elapsedTicks: elapsedTicksRef.current,
}),
onSnapshot: (snapshot, currentLiveMatch) => {
const opponentState = snapshot.states[currentLiveMatch.opponentSide]
if (opponentState) {
cpuRef.current = opponentState
setCpuSide(opponentState)
}
const opponentStatus = snapshot.statuses[currentLiveMatch.opponentSide]
const opponentAlive = snapshot.progress[currentLiveMatch.opponentSide]?.alive
const outcome = resolvePvpRoguelikeLiveSnapshot({
currentStatus: status,
opponentStatus,
opponentAlive,
alreadyLoggedOpponentDone: loggedOpponentDoneRef.current,
opponentName: currentLiveMatch.opponentName,
})
if (outcome.type === 'won') {
loggedOpponentDoneRef.current = true
cpuDefeatedRef.current = true
finishRoguelikeRun('won')
setStatus(outcome.status)
addLog(outcome.log.text, outcome.log.tone)
}
if (outcome.type === 'lost') {
finishRoguelikeRun('lost')
setStatus(outcome.status)
addLog(outcome.log.text, outcome.log.tone)
}
},
})
const applySpell = useCallback((
current: SideState,
setCurrent: React.Dispatch<React.SetStateAction<SideState>>,
sideName: 'player' | 'cpu',
buffs: SelfBuffId[],
debuffs: OpponentDebuffId[],
spell: Spell,
targetId: string,
) => {
const buffCounts = createStackCounts(buffs)
const debuffCounts = createStackCounts(debuffs)
const effectiveCost = spellResourceCost(spell, buffCounts, debuffCounts, current.freeCastReady)
const hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType)
const renewEffect = starterSpells.find((candidate) => candidate.kind === 'hot')
const shieldEffect = starterSpells.find((candidate) => candidate.kind === 'shield')
const radianceEffect = starterSpells.find((candidate) => candidate.kind === 'group')
const healingMultiplier = (member: PartyMember) =>
hasSpellEffect('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1
const extraTargets = spellExtraTargets(spell, {
stacks: buffCounts,
id: (slot) => `slot${slot as SlotKey}-extra-target` as SelfBuffId,
})
const {
directTargets,
hotTargets,
shieldTargets,
groupTargets,
} = buildSpellTargetPlan({
party: current.party,
spell,
targetId,
extraTargets,
directTarget: true,
hotTarget: spell.kind === 'hot',
shieldTarget: spell.kind === 'shield',
groupTargetCount: DEFAULT_GROUP_HEAL_TARGETS + extraTargets,
extraTargetMode: {
hot: 'hot',
shield: 'shield',
},
})
if (spell.kind === 'direct' && hasSpellEffect('mend_applies_renew') && renewEffect) {
directTargets.forEach((id) => hotTargets.add(id))
}
if (spell.kind === 'direct' && hasSpellEffect('mend_applies_shield') && shieldEffect) {
directTargets.forEach((id) => shieldTargets.add(id))
}
if (spell.kind === 'shield' && hasSpellEffect('shield_applies_renew') && renewEffect) {
shieldTargets.forEach((id) => hotTargets.add(id))
}
const spellEffectProfile: SpellEffectProfile = {
modeName: 'pvp-roguelike',
heal: healMember,
healingMultiplier,
power: {
direct: (source) => source.power,
cleanse: (source) => source.power,
groupHeal: (source) => source.power,
groupAbsorb: (source) => source.power,
shield: (sourcePower, strength = 1) => Math.round(sourcePower * strength),
},
hot: {
mode: 'ticks',
defaultTicks: 5,
groupTicks: 5,
radianceTicks: 3,
merge: 'replace',
groupMerge: 'max',
},
effects: {
renewSpell: renewEffect,
shieldSpell: shieldEffect,
groupAbsorbOnly: () => false,
groupHotOnly: () => false,
groupAppliesShield: () => hasSpellEffect('radiance_applies_shield'),
groupAppliesHot: () => hasSpellEffect('radiance_applies_renew') && Boolean(renewEffect),
shieldAppliesHot: () => false,
hotSpellForDirect: (source) => source,
},
ratios: {
groupShield: 0.3,
directShield: 0.5,
},
damageReductionTicks: 12,
floatingHeals: {
group: true,
direct: true,
cleanse: true,
},
bounceHeals: false,
}
const targetPlan = {
directTargets,
hotTargets,
shieldTargets,
damageReductionTargets: new Set<string>(),
groupTargets,
}
let nextCooldowns = current.cooldowns
if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) {
nextCooldowns = reduceCooldown(nextCooldowns, radianceEffect.id, 2)
}
return applyPvpSpellCast({
current,
spell,
targetId,
resourceCost: effectiveCost,
targetPlan,
profile: spellEffectProfile,
setCurrent,
emitFloatingHeal: (memberId, value) => addFloatingHeal(sideName, memberId, value),
cooldowns: nextCooldowns,
cooldownMultiplier: cooldownMultiplier(spell, buffCounts, debuffCounts),
freeCast: {
enabled: false,
wasReady: false,
},
})
}, [activeSpellEffects, addFloatingHeal, starterSpells])
const castPlayerSpell = useCallback((spell: Spell) => {
if (status !== 'playing' || playerDone || !playerAlive) return
const targetId = selectedIdRef.current
const succeeded = applySpell(playerRef.current, (value) => {
const next = typeof value === 'function' ? value(playerRef.current) : value
playerRef.current = next
setPlayerSide(next)
}, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, targetId)
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
}, [addLog, applySpell, playerAlive, playerDone, status])
const getTargetParty = useCallback(() => playerRef.current.party, [])
const {
selectRelativeTarget,
selectDirectionalTarget,
selectDirectTarget,
} = usePartyTargeting({
getParty: getTargetParty,
selectedIdRef,
setSelectedTargetId,
columns: partyColumns,
livingOnly: true,
directTargetGroup: contentType === 'raid' ? targetGroup : 0,
})
const cpuTakeTurn = useCallback(() => {
if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
const elapsedTicks = elapsedTicksRef.current
if (elapsedTicks % cpuBehavior.actionEveryTicks !== 0) return
if (Math.random() < cpuBehavior.mistakeChance) return
const side = cpuRef.current
const actions = chooseCpuHealActions({ party: side.party, spells: starterSpells, behavior: cpuBehavior })
for (const action of actions) {
const cast = 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 (cast) return
}
}, [applySpell, cpuAlive, cpuBehavior, cpuDifficulty, cpuDone, starterSpells, status])
const advanceSide = useCallback((side: SideState, sideName: 'player' | 'cpu', encounterValue: PvpEncounter): SideState => {
if (side.enemyHealth <= 0) return side
const party = side.party
const living = 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 elapsedTicks = elapsedTicksRef.current
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 playerEffectsActive = sideName === 'player'
const hasShieldedHealingBonus = playerEffectsActive && activeSpellEffects.has('shielded_healing_bonus')
const shieldedDamageMultiplier = playerEffectsActive && activeSpellEffects.has('shielded_damage_reduction') ? 0.8 : undefined
const tankPressure = tankPressureTargets(party)
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
const nextParty = party.map((member) => {
if (member.health <= 0) return member
let damage = member.id === primaryTarget.id ? encounterValue.damage : 0
if (tankPressureIds.has(member.id)) {
damage += Math.round(encounterValue.tankDamage * tankPressure.multiplier)
}
if (bossPulse) damage += 10
if (member.debuff) damage += 6
const healingMultiplier = member.shield > 0 && hasShieldedHealingBonus ? 1.2 : 1
const result = advanceMemberTick({
member,
party,
damage,
hotHealing: 6,
hotTicks: 'ticks',
healingMultiplier,
shieldedDamageMultiplier,
applyDebuff: appliesDebuff && member.id === primaryTarget.id
? { label: 'Searing Mark', ticks: 8 }
: undefined,
applyPoisonStacks: appliesPoison && member.id === primaryTarget.id,
poisonDamage: (stacks) => 3 + stacks * 3,
applyMaxHealthPenaltyTicks: appliesMaxHealthCut && member.id === primaryTarget.id ? 14 : undefined,
applyHealingReductionTicks: appliesHealingReduction && member.id === primaryTarget.id ? 14 : undefined,
})
if (result.floatingHeal > 0) addFloatingHeal(sideName, member.id, result.floatingHeal)
return result.member
})
return {
...side,
party: nextParty,
resource: regenerateResource(side.resource, 2.4, maxResource),
cooldowns: pruneExpiredCooldowns(side.cooldowns),
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
}
}, [activeSpellEffects, addFloatingHeal, maxResource])
const beginUpgradePhase = useCallback(() => {
autoSubmittedUpgradeRef.current = false
startUpgradeTimer()
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
setSelectedBuff(null)
setSelectedDebuff(null)
setOverlaySelectedIndex(0)
setStatus('upgrade-choice')
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog, startUpgradeTimer])
useEffect(() => {
if (status !== 'playing' || paused || !encounter) return
const timer = window.setInterval(() => {
elapsedTicksRef.current += 1
if (!liveMatch) cpuTakeTurn()
const nextPlayer = advanceSide(playerRef.current, 'player', encounter)
const nextCpu = liveMatch ? cpuRef.current : advanceSide(cpuRef.current, 'cpu', encounter)
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
playerClearedEncounterRef.current = encounterIndex
setEncountersCleared((value) => value + 1)
awardEncounterReward(encounterIndex)
if (encounter.isBoss) {
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)
const outcome = resolvePvpRoguelikeCombatOutcome({
playerAlive: nextPlayerAlive,
opponentAlive: nextCpuAlive,
playerCleared: nextPlayer.enemyHealth <= 0,
encounterIsBoss: encounter.isBoss,
encounterName: encounter.enemyName,
opponentDefeated: cpuDefeatedRef.current,
liveMatchActive: Boolean(liveMatch),
opponentLabel,
})
if (outcome.type === 'lost') {
finishRoguelikeRun('lost')
setStatus(outcome.status)
addLog(outcome.log.text, outcome.log.tone)
return
}
if (outcome.type === 'won') {
if (outcome.markOpponentDefeated) {
cpuDefeatedRef.current = true
}
finishRoguelikeRun('won')
setStatus(outcome.status)
addLog(outcome.log.text, outcome.log.tone)
return
}
if (outcome.type === 'upgrade-choice') {
addLog(outcome.log.text, outcome.log.tone)
beginUpgradePhase()
}
}, TICK_MS / speedMultiplier)
return () => window.clearInterval(timer)
}, [addLog, advanceSide, awardEncounterReward, beginUpgradePhase, checkpointStage, contentType, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, liveMatch, opponentLabel, paused, profile.character.id, speedMultiplier, 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 !== 'won' && status !== 'lost') return
if (matchStatsRecordedRef.current) return
matchStatsRecordedRef.current = true
recordPvpMatch(status === 'won')
.then(onProfileUpdated)
.catch((reason: unknown) => {
addLog(
reason instanceof Error ? reason.message : 'Unable to record PvP match.',
'danger',
)
})
}, [addLog, onProfileUpdated, status])
useEffect(() => {
if (status !== 'upgrade-choice') return
window.requestAnimationFrame(() => focusFirstControl())
}, [status])
useEffect(() => {
if (!paused) return
window.requestAnimationFrame(() => focusFirstControl())
}, [paused])
const confirmUpgradeChoices = useCallback((
forcedBuff?: Choice<SelfBuffId>,
forcedDebuff?: Choice<OpponentDebuffId>,
) => {
const chosenBuff = forcedBuff ?? selectedBuff
const chosenDebuff = forcedDebuff ?? selectedDebuff
if (!chosenBuff || !chosenDebuff) return
if (liveMatch) {
const submittedBuff = chosenBuff
const submittedDebuff = chosenDebuff
const clearedEncounterIndex = encounterIndex
const applyLiveUpgrade = (opponentChoice: PvpUpgradeChoicePayload) => {
const incomingDebuffId = opponentDebuffChoicesCatalog.some((choice) => choice.id === opponentChoice.debuffId)
? opponentChoice.debuffId as OpponentDebuffId
: undefined
let nextPlayer = applyPvpRoguelikeUpgradeChoice({
side: playerRef.current,
buffId: submittedBuff.id,
incomingDebuffId,
reviveBuffId: REVIVE_PARTY_CHOICE.id,
})
const clearedBoss = encounter.isBoss
const completion = resolvePvpRoguelikeUpgradeCompletion({
clearedBoss,
opponentDefeated: cpuDefeatedRef.current,
opponentLabel: liveMatch.opponentName,
})
if (completion.type === 'won') {
finishRoguelikeRun('won')
setStatus(completion.status)
setLiveUpgradePending(false)
addLog(completion.log.text, completion.log.tone)
return
}
const nextStage = clearedBoss ? stage + 1 : stage
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
const progression = nextPvpRoguelikeStage({
clearedBoss,
stage,
encounterIndex,
encounters,
nextSegment,
})
if (progression.type === 'complete') {
finishRoguelikeRun('won')
setStatus(progression.status)
setLiveUpgradePending(false)
addLog(progression.log.text, progression.log.tone)
return
}
nextPlayer = preparePvpRoguelikeNextEncounter({
side: nextPlayer,
maxResource,
enemyMaxHealth: progression.nextEncounter.maxHealth,
})
if (clearedBoss) {
setStage(progression.nextStage)
setEncounters((current) => [...current, ...nextSegment])
}
setEncounterIndex(progression.nextEncounterIndex)
setPlayerSide(nextPlayer)
playerRef.current = nextPlayer
elapsedTicksRef.current = 0
setLiveUpgradePending(false)
pendingLiveUpgradeRef.current = null
beginRoundCountdown()
const opponentDebuff = opponentDebuffChoicesCatalog.find((choice) => choice.id === opponentChoice.debuffId)
addLog(
`You chose ${submittedBuff.name} and ${submittedDebuff.name}. ${liveMatch.opponentName} chose ${opponentDebuff?.name ?? 'an opponent debuff'}.`,
'system',
)
}
setLiveUpgradePending(true)
pendingLiveUpgradeRef.current = {
encounterIndex: clearedEncounterIndex,
buff: submittedBuff,
debuff: submittedDebuff,
}
addLog(`Waiting for ${liveMatch.opponentName} to choose.`, 'system')
submitPvpUpgradeChoice(liveMatch.id, {
encounterIndex: clearedEncounterIndex,
buffId: submittedBuff.id,
debuffId: submittedDebuff.id,
}).catch((reason: unknown) => {
setLiveUpgradePending(false)
addLog(reason instanceof Error ? reason.message : 'Unable to submit PvP upgrade choice.', 'danger')
})
let attempts = 0
const waitForOpponent = () => {
attempts += 1
loadPvpMatch<SideState>(liveMatch.id)
.then((snapshot) => {
const opponentChoice = snapshot.upgradeChoices[liveMatch.opponentSide]?.[String(clearedEncounterIndex)]
if (opponentChoice) {
applyLiveUpgrade(opponentChoice)
return
}
if (attempts < 120 && pendingLiveUpgradeRef.current) {
window.setTimeout(waitForOpponent, 500)
}
})
.catch(() => {
if (attempts < 120 && pendingLiveUpgradeRef.current) {
window.setTimeout(waitForOpponent, 700)
}
})
}
window.setTimeout(waitForOpponent, 250)
return
}
if (!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 = applyPvpRoguelikeUpgradeChoice({
side: playerRef.current,
buffId: chosenBuff.id,
incomingDebuffId: cpuDebuff.id,
reviveBuffId: REVIVE_PARTY_CHOICE.id,
})
let nextCpu = applyPvpRoguelikeUpgradeChoice({
side: cpuRef.current,
buffId: cpuBuff.id,
incomingDebuffId: chosenDebuff.id,
reviveBuffId: REVIVE_PARTY_CHOICE.id,
})
const clearedBoss = encounter.isBoss
const completion = resolvePvpRoguelikeUpgradeCompletion({
clearedBoss,
opponentDefeated: cpuDefeatedRef.current,
opponentLabel: 'CPU',
})
if (completion.type === 'won') {
finishRoguelikeRun('won')
setStatus(completion.status)
addLog(completion.log.text, completion.log.tone)
return
}
const nextStage = clearedBoss ? stage + 1 : stage
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
const progression = nextPvpRoguelikeStage({
clearedBoss,
stage,
encounterIndex,
encounters,
nextSegment,
})
if (progression.type === 'complete') {
finishRoguelikeRun('won')
setStatus(progression.status)
addLog(progression.log.text, progression.log.tone)
return
}
nextPlayer = preparePvpRoguelikeNextEncounter({
side: nextPlayer,
maxResource,
enemyMaxHealth: progression.nextEncounter.maxHealth,
})
nextCpu = preparePvpRoguelikeNextEncounter({
side: nextCpu,
maxResource,
enemyMaxHealth: progression.nextEncounter.maxHealth,
})
if (clearedBoss) {
setStage(progression.nextStage)
setEncounters((current) => [...current, ...nextSegment])
}
setEncounterIndex(progression.nextEncounterIndex)
setPlayerSide(nextPlayer)
setCpuSide(nextCpu)
playerRef.current = nextPlayer
cpuRef.current = nextCpu
elapsedTicksRef.current = 0
beginRoundCountdown()
addLog(`You chose ${chosenBuff.name} and ${chosenDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
}, [addLog, beginRoundCountdown, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, liveMatch, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
function pvpOverlayEntries(): PvpOverlayNavEntry[] {
if (status === 'queueing') return [{ kind: 'queueBack', row: 0, column: 0 }]
if (paused) {
return [
{ kind: 'pauseResume', row: 0, column: 0 },
{ kind: 'pauseLeave', row: 1, column: 0 },
]
}
if (status !== 'upgrade-choice') return []
const entries: PvpOverlayNavEntry[] = [
...playerBuffChoices.map((_, index) => ({
kind: 'upgradeBuff' as const,
index,
row: index,
column: 0,
})),
...playerDebuffChoices.map((_, index) => ({
kind: 'upgradeDebuff' as const,
index,
row: index,
column: 1,
})),
]
entries.push({
kind: 'upgradeContinue',
row: Math.max(playerBuffChoices.length, playerDebuffChoices.length),
column: 1,
disabled: !selectedBuff || !selectedDebuff || liveUpgradePending,
})
return entries
}
function pvpOverlayEntryDisabled(entry: PvpOverlayNavEntry) {
return entry.kind === 'upgradeContinue' && Boolean(entry.disabled)
}
function activePvpOverlayEntry(entries = pvpOverlayEntries()) {
const bounded = Math.min(overlaySelectedIndex, entries.length - 1)
const active = entries[bounded]
if (active && !pvpOverlayEntryDisabled(active)) return active
return entries.find((entry) => !pvpOverlayEntryDisabled(entry))
}
function pvpOverlayEntrySelected(kind: PvpOverlayNavEntry['kind'], index?: number) {
const active = activePvpOverlayEntry()
if (!active || active.kind !== kind) return false
if ('index' in active || index !== undefined) return 'index' in active && active.index === index
return true
}
function setPvpOverlayCursor(kind: PvpOverlayNavEntry['kind'], index?: number) {
const entries = pvpOverlayEntries()
const nextIndex = entries.findIndex((entry) => {
if (entry.kind !== kind) return false
if ('index' in entry || index !== undefined) return 'index' in entry && entry.index === index
return true
})
if (nextIndex >= 0) setOverlaySelectedIndex(nextIndex)
}
function movePvpOverlaySelection(action: string) {
const entries = pvpOverlayEntries()
const enabledEntries = entries
.map((entry, index) => ({ entry, index }))
.filter(({ entry }) => !pvpOverlayEntryDisabled(entry))
if (enabledEntries.length === 0) return
setOverlaySelectedIndex((current) => {
const candidate = entries[Math.min(current, entries.length - 1)]
const active = candidate && !pvpOverlayEntryDisabled(candidate)
? candidate
: enabledEntries[0]?.entry
if (!active) return current
const candidates = enabledEntries.filter(({ entry }) => {
if (entry === active) return false
if (action === 'navigateLeft') return entry.row === active.row && entry.column < active.column
if (action === 'navigateRight') return entry.row === active.row && entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return entries.findIndex((entry) => entry === active)
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || a.index - b.index
})
return candidates[0]?.index ?? current
})
}
function openPvpOverlayEntry(entry: PvpOverlayNavEntry | undefined) {
if (!entry || pvpOverlayEntryDisabled(entry)) return
if (entry.kind === 'queueBack') onExit()
else if (entry.kind === 'pauseResume') setPaused(false)
else if (entry.kind === 'pauseLeave') onExit()
else if (entry.kind === 'upgradeBuff') setSelectedBuff(playerBuffChoices[entry.index] ?? null)
else if (entry.kind === 'upgradeDebuff') setSelectedDebuff(playerDebuffChoices[entry.index] ?? null)
else if (entry.kind === 'upgradeContinue') confirmUpgradeChoices()
}
useGameAction((action) => {
if (status === 'queueing' || status === 'round-countdown') {
if (action === 'back' || action === 'pause') onExit()
if (status === 'queueing' && action === 'confirm') openPvpOverlayEntry(activePvpOverlayEntry())
return
}
if (paused) {
if (action === 'back' || action === 'pause') {
setPaused(false)
return
}
if (action === 'confirm') {
openPvpOverlayEntry(activePvpOverlayEntry())
return
}
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
return
}
if (status === 'upgrade-choice') {
if (action === 'confirm') {
openPvpOverlayEntry(activePvpOverlayEntry())
return
}
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
return
}
if (action === 'toggleSpeed') {
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
return
}
if (action === 'pause' || action === 'back') {
if (status === 'playing') {
setOverlaySelectedIndex(0)
setPaused((value) => !value)
}
return
}
if (paused || status !== 'playing') return
if (action.startsWith('navigate')) {
selectDirectionalTarget(action)
return
}
if (action === 'previousTarget') {
selectRelativeTarget(-1)
return
}
if (action === 'nextTarget') {
selectRelativeTarget(1)
return
}
if (action.startsWith('targetParty')) {
selectDirectTarget(Number(action.slice('targetParty'.length)) - 1)
return
}
if (action === 'toggleTargetGroup') {
if (contentType !== 'raid') return
setTargetGroup((current) => {
const groupCount = Math.max(1, Math.ceil(playerRef.current.party.length / 6))
const next = ((current + 1) % groupCount) as 0 | 1 | 2
const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
const nextMember = playerRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
if (nextMember?.health > 0) setSelectedTargetId(nextMember.id)
return next
})
return
}
if (action.startsWith('ability')) {
const spell = starterSpells.find((candidate) => candidate.key === action.slice('ability'.length))
if (spell) castPlayerSpell(spell)
}
})
const dualScreenState = useMemo(() => buildPvpRoguelikeDualScreenState({
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,
opponentName: opponentLabel,
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
opponentParty: cpuSide.party,
opponentEnemyHealth: cpuSide.enemyHealth,
opponentResource: cpuSide.resource,
opponentMaxResource: maxResource,
opponentResourceName: gameClass.resourceName,
opponentBuffSummary,
opponentDebuffSummary,
floatingTexts: dualScreenFloatingTexts,
partySize: playerSide.party.length,
selectedId,
status: status === 'queueing' || status === 'round-countdown' ? 'playing' : status,
resource: playerSide.resource,
maxResource,
resourceName: gameClass.resourceName,
playerIsAlive: playerAlive,
spells: playerSpellSlots,
bindings: activeBindings,
controllerIconStyle,
directPartyTargeting,
paused,
targetGroup,
speedMultiplier,
}), [
activeBindings,
controllerIconStyle,
cpuDifficulty,
cpuSide.enemyHealth,
cpuSide.party,
cpuSide.resource,
directPartyTargeting,
encounter.description,
encounter.enemyName,
encounter.isBoss,
encounter.maxHealth,
encounterIndex,
encounters.length,
dualScreenFloatingTexts,
gameClass.resourceName,
liveMatch?.opponentClassName,
maxResource,
opponentBuffSummary,
opponentDebuffSummary,
opponentLabel,
paused,
playerAlive,
playerSide.enemyHealth,
playerSide.party,
playerSide.resource,
selectedId,
speedMultiplier,
stage,
playerSpellSlots,
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 pvp-match-queue-panel" data-game-nav-active="true">
<div className="placeholder-runes">P V P</div>
<p>{queueMessage}</p>
<button
className={`secondary-result-button ${pvpOverlayEntrySelected('queueBack') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={onExit}
onPointerDown={() => setPvpOverlayCursor('queueBack')}
type="button"
>
Back
</button>
</div>
)}
{dualScreenEnabled && status !== 'queueing' && (
<DualScreenTopCombat
state={dualScreenState}
onCastSpell={castPlayerSpell}
onSelectTarget={setSelectedTargetId}
/>
)}
{!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>
<small>{encounter.enemyName} | Stage {stage}{encounter.isBoss ? ' Boss' : ''}</small>
</div>
<div className="pvp-side-bars">
<div className="pvp-clear-wrap">
<span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span>
<div className="bar enemy-health boss-bar">
<span style={barFillStyle((playerSide.enemyHealth / encounter.maxHealth) * 100)} />
</div>
</div>
<div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar"><span style={barFillStyle((playerSide.resource / maxResource) * 100)} /></div>
</div>
</div>
</div>
<div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
{playerSide.party.map((member) => (
<PartyMemberFrame
deadClassName="down"
effectMode="timed-basic"
floatingTexts={playerFloatingTextsByMember.get(member.id) ?? []}
key={`player-${member.id}`}
member={member}
onSelect={setSelectedTargetId}
selected={selectedId === member.id}
showHealthText
/>
))}
</div>
<p className="roguelike-upgrade-list">
Buffs: {playerBuffSummary} | Debuffs: {playerDebuffSummary}
</p>
</section>
<section className="combat-panel pvp-side">
<div className="encounter-header">
<div>
<p className="eyebrow">Opponent</p>
<h2>{opponentLabel}</h2>
<small>{liveMatch ? liveMatch.opponentClassName : `CPU ${cpuDifficulty}`} | Encounters cleared: {encountersCleared}</small>
</div>
<div className="pvp-side-bars">
<div className="pvp-clear-wrap">
<span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span>
<div className="bar enemy-health boss-bar">
<span style={barFillStyle((cpuSide.enemyHealth / encounter.maxHealth) * 100)} />
</div>
</div>
<div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span>
<div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / maxResource) * 100)} /></div>
</div>
</div>
</div>
<div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
{cpuSide.party.map((member) => (
<PartyMemberFrame
as="div"
deadClassName="down"
effectMode="timed-basic"
floatingTexts={cpuFloatingTextsByMember.get(member.id) ?? []}
key={`cpu-${member.id}`}
member={member}
showHealthText
/>
))}
</div>
<p className="roguelike-upgrade-list">
Buffs: {opponentBuffSummary} | Debuffs: {opponentDebuffSummary}
</p>
</section>
<SpellBar
bindings={activeBindings}
canCast={status === 'playing' && !playerDone && playerAlive}
className="pvp-bottom-spell-bar"
focusable={false}
iconStyle={controllerIconStyle}
onCast={castPlayerSpell}
resource={playerSide.resource}
resourceName={gameClass.resourceName}
spells={playerSpellSlots}
/>
</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 === 'upgrade-choice' && (
<div className="result-screen" data-game-nav-active="true">
<div className="pvp-upgrade-dialog">
<div className="pvp-upgrade-header">
<div>
<p className="eyebrow">Choose Edge</p>
<h2>{encounter.isBoss ? `Stage ${stage} Boss Cleared` : `${encounter.enemyName} Cleared`}</h2>
</div>
<strong className={upgradeTimeLeft <= 3 ? 'danger' : ''}>{upgradeTimeLeft.toFixed(1)}s</strong>
</div>
<div className="pvp-choice-columns">
<div>
<strong>{playerBuffChoices.length === 1 && playerBuffChoices[0]?.id === REVIVE_PARTY_CHOICE.id ? 'Recovery' : 'Self Buff'}</strong>
<div className="upgrade-choice-grid">
{playerBuffChoices.map((choice) => (
<button
className={`${selectedBuff?.id === choice.id ? 'selected-upgrade' : ''} ${pvpOverlayEntrySelected('upgradeBuff', playerBuffChoices.indexOf(choice)) ? 'game-selected' : ''}`}
data-controller-nav="skip"
key={choice.id}
onClick={() => setSelectedBuff(choice)}
onPointerDown={() => setPvpOverlayCursor('upgradeBuff', playerBuffChoices.indexOf(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' : ''} ${pvpOverlayEntrySelected('upgradeDebuff', playerDebuffChoices.indexOf(choice)) ? 'game-selected' : ''}`}
data-controller-nav="skip"
key={choice.id}
onClick={() => setSelectedDebuff(choice)}
onPointerDown={() => setPvpOverlayCursor('upgradeDebuff', playerDebuffChoices.indexOf(choice))}
type="button"
>
<strong>{choice.name}</strong>
<small>{choice.description}</small>
</button>
))}
</div>
</div>
</div>
{liveUpgradePending && <p>Waiting for opponent choice...</p>}
<button
className={`secondary-result-button ${pvpOverlayEntrySelected('upgradeContinue') ? 'game-selected' : ''}`}
data-controller-nav="skip"
disabled={!selectedBuff || !selectedDebuff || liveUpgradePending}
onClick={() => confirmUpgradeChoices()}
onPointerDown={() => setPvpOverlayCursor('upgradeContinue')}
type="button"
>
{liveUpgradePending ? 'Waiting' : 'Continue'}
</button>
</div>
</div>
)}
{paused && (
<div className="pause-screen" data-game-nav-active="true">
<div>
<p className="eyebrow">Paused</p>
<h2>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
<button
className={pvpOverlayEntrySelected('pauseResume') ? 'game-selected' : ''}
data-controller-nav="skip"
onClick={() => setPaused(false)}
onPointerDown={() => setPvpOverlayCursor('pauseResume')}
type="button"
>
Resume
</button>
<button
className={`secondary-result-button ${pvpOverlayEntrySelected('pauseLeave') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={onExit}
onPointerDown={() => setPvpOverlayCursor('pauseLeave')}
type="button"
>
Leave
</button>
</div>
</div>
)}
{(status === 'won' || status === 'lost') && (
<ResultScreen
actions={[
{ label: 'Queue Next Match', onClick: () => startMatch() },
{ label: 'Back to Roguelike', onClick: onExit, className: 'secondary-result-button' },
]}
eyebrow={status === 'won' ? 'Victory' : 'Defeat'}
log={log}
onToggleLog={() => setShowEndLog((value) => !value)}
rematch={{
visible: Boolean(liveMatch),
requested: rematchRequested,
message: rematchMessage,
onRematch: handleRematch,
}}
showLog={showEndLog}
title={status === 'won' ? `${opponentLabel} Falls` : `${opponentLabel} Wins`}
>
<p>{finalEncountersCleared} encounters cleared.</p>
<div className="reward-summary">
<p>Run totals</p>
<p>{runSummary.bossesKilled} bosses killed.</p>
<RewardXpSummary reward={runSummary} />
{runSummary.bossesKilled > 0 && !reward && !rewardError && <p>Final boss rewards still recording...</p>}
{rewardError && <p className="reward-error">{rewardError}</p>}
<PvpRunLootList loot={runSummary.loot} emptyLabel="No boss coins collected this run." />
{reward && runSummary.bossesKilled === 0 && (
<>
<RewardXpSummary reward={reward} />
<BonusItemReward item={reward.bonusItem} compact />
</>
)}
</div>
</ResultScreen>
)}
</section>
</main>
)
}