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 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 = { id: T name: string description: string } type SideState = { party: PartyMember[] resource: number cooldowns: Record 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 = { 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 = { 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> { return buildSelfSlotUpgradeChoices({ slots: ['1', '2', '3', '4', '5'] as DraftSlotKey[], spells, labelMode, }) } function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array> { return buildOpponentSlotDebuffChoices({ slots: ['1', '2', '3', '4', '5'] as DraftSlotKey[], spells, labelMode, }) } function cooldownMultiplier(spell: Spell, buffs: StackCounts, debuffs: StackCounts) { 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, debuffs: StackCounts, 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({ 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, 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, opponentBuffCount: number) { void opponentBuffCount if (debuff.id.endsWith('cost-up')) return 7 return 6 } function selectCpuChoice( choices: Array>, skill: CpuDifficulty, score: (choice: Choice) => 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(items: T[], catalog: Array>) { 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('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') const [stage, setStage] = useState(startStage) const [encounters, setEncounters] = useState(() => buildEncounterSegment(encounterPool, startStage, contentType)) const [encounterIndex, setEncounterIndex] = useState(0) const [playerSide, setPlayerSide] = useState(() => createPvpRoguelikeStarterSide(partyTemplate, maxResource)) const [cpuSide, setCpuSide] = useState(() => createPvpRoguelikeStarterSide(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(null) const [liveMatch, setLiveMatch] = useState(null) const [liveUpgradePending, setLiveUpgradePending] = useState(false) const [queueMessage, setQueueMessage] = useState('') const [log, setLog] = useState([{ id: 1, text: 'Queueing opponent...', tone: 'system' }]) const [reward, setReward] = useState(null) const [runSummary, setRunSummary] = useState(() => createEmptyPvpRunSummary()) const [rewardError, setRewardError] = useState('') const [showEndLog, setShowEndLog] = useState(false) const { playerFloatingTextsByMember, cpuFloatingTextsByMember, dualScreenFloatingTexts, addFloatingText, clearFloatingTexts, } = useSidedFloatingCombatText() const [playerBuffChoices, setPlayerBuffChoices] = useState>>([]) const [playerDebuffChoices, setPlayerDebuffChoices] = useState>>([]) const [selectedBuff, setSelectedBuff] = useState | null>(null) const [selectedDebuff, setSelectedDebuff] = useState | 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()) const cpuDefeatedRef = useRef(false) const playerClearedEncounterRef = useRef(-1) const queuedMatchRef = useRef(false) const autoSubmittedUpgradeRef = useRef(false) const liveMatchRef = useRef(null) const loggedOpponentDoneRef = useRef(false) const pendingLiveUpgradeRef = useRef<{ encounterIndex: number buff: Choice debuff: Choice } | 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 => Boolean(choice))) setPlayerDebuffChoices((current) => current .map((choice) => opponentDebuffChoicesCatalog.find((candidate) => candidate.id === choice.id)) .filter((choice): choice is Choice => 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, side: PvpMatchSide, message?: string, ) => { const setup = createPvpRoguelikeLiveMatchStart({ 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({ 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({ 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({ 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>, 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(), 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, forcedDebuff?: Choice, ) => { 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(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 (
{status === 'queueing' && (
P V P

{queueMessage}

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

You

{profile.character.name}

{encounter.enemyName} | Stage {stage}{encounter.isBoss ? ' Boss' : ''}
Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}
{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource} {speedMultiplier === 2 && 2x speed}
{playerSide.party.map((member) => ( ))}

Buffs: {playerBuffSummary} | Debuffs: {playerDebuffSummary}

Opponent

{opponentLabel}

{liveMatch ? liveMatch.opponentClassName : `CPU ${cpuDifficulty}`} | Encounters cleared: {encountersCleared}
{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}
{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}
{cpuSide.party.map((member) => ( ))}

Buffs: {opponentBuffSummary} | Debuffs: {opponentDebuffSummary}

)} {status === 'round-countdown' && (

Round Starts

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

)} {status === 'upgrade-choice' && (

Choose Edge

{encounter.isBoss ? `Stage ${stage} Boss Cleared` : `${encounter.enemyName} Cleared`}

{upgradeTimeLeft.toFixed(1)}s
{playerBuffChoices.length === 1 && playerBuffChoices[0]?.id === REVIVE_PARTY_CHOICE.id ? 'Recovery' : 'Self Buff'}
{playerBuffChoices.map((choice) => ( ))}
Opponent Debuff
{playerDebuffChoices.map((choice) => ( ))}
{liveUpgradePending &&

Waiting for opponent choice...

}
)} {paused && (

Paused

{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}

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

{finalEncountersCleared} encounters cleared.

Run totals

{runSummary.bossesKilled} bosses killed.

{runSummary.bossesKilled > 0 && !reward && !rewardError &&

Final boss rewards still recording...

} {rewardError &&

{rewardError}

} {reward && runSummary.bossesKilled === 0 && ( <> )}
)}
) }