diff --git a/IWantToHeal-Thor-v1.0.58.apk b/IWantToHeal-Thor-v1.0.58.apk new file mode 100644 index 0000000..1d3bb6f Binary files /dev/null and b/IWantToHeal-Thor-v1.0.58.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index cdf232a..1d73607 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 76 - versionName "1.0.57" + versionCode 77 + versionName "1.0.58" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/server/game-api.mjs b/server/game-api.mjs index 5f93c6a..f9ec35a 100644 --- a/server/game-api.mjs +++ b/server/game-api.mjs @@ -2309,7 +2309,13 @@ function completeRoguelike(database, characterId, accountId, runMetrics) { ? 'pvp-boss-quarter-level' : runMetrics?.experienceMode === 'pvp-fight-twelfth-level' ? 'pvp-fight-twelfth-level' - : 'default' + : runMetrics?.experienceMode === 'pvp-stadium-round-win-quarter-level' + ? 'pvp-stadium-round-win-quarter-level' + : runMetrics?.experienceMode === 'pvp-stadium-round-loss-tenth-level' + ? 'pvp-stadium-round-loss-tenth-level' + : runMetrics?.experienceMode === 'pvp-stadium-match-half-level' + ? 'pvp-stadium-match-half-level' + : 'default' const fightsCleared = Number(runMetrics?.fightsCleared ?? encountersCleared) const resourceSpent = Number(runMetrics?.resourceSpent) const durationSeconds = Number(runMetrics?.durationSeconds) @@ -2412,6 +2418,35 @@ function completeRoguelike(database, characterId, accountId, runMetrics) { WHERE experience_required <= ? `).get(newExperience).level } + } else if ( + experienceMode === 'pvp-stadium-round-win-quarter-level' + || experienceMode === 'pvp-stadium-round-loss-tenth-level' + || experienceMode === 'pvp-stadium-match-half-level' + ) { + const currentLevelFloor = database.prepare(` + SELECT experience_required AS experienceRequired + FROM level_progression + WHERE level = ? + `).get(newLevel).experienceRequired + const nextLevelExperience = newLevel >= maxLevel + ? maxExperience + : database.prepare(` + SELECT experience_required AS experienceRequired + FROM level_progression + WHERE level = ? + `).get(newLevel + 1).experienceRequired + const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) + const rewardRate = experienceMode === 'pvp-stadium-round-win-quarter-level' + ? 0.25 + : experienceMode === 'pvp-stadium-round-loss-tenth-level' + ? 0.1 + : 0.5 + newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate)) + newLevel = database.prepare(` + SELECT MAX(level) AS level + FROM level_progression + WHERE experience_required <= ? + `).get(newExperience).level } else { const baseExperienceReward = Math.round( dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3), @@ -2564,7 +2599,7 @@ function cleanupPvpMemory(now = Date.now()) { } function validatePvpContentType(value) { - if (value !== 'dungeon' && value !== 'raid') { + if (value !== 'dungeon' && value !== 'raid' && value !== 'stadium') { throw new Error('The PvP content type is invalid.') } return value @@ -2736,7 +2771,7 @@ function requirePvpMatchForSession(session, matchId) { function updatePvpMatchState(session, matchId, payload) { const { match, side } = requirePvpMatchForSession(session, matchId) - const status = ['playing', 'upgrade-choice', 'won', 'lost'].includes(payload.status) + const status = ['playing', 'upgrade-choice', 'shop', 'won', 'lost'].includes(payload.status) ? payload.status : 'playing' const progress = { @@ -2762,6 +2797,8 @@ function submitPvpUpgradeChoice(session, matchId, payload) { encounterIndex, buffId: String(payload.buffId ?? ''), debuffId: String(payload.debuffId ?? ''), + purchases: Array.isArray(payload.purchases) ? payload.purchases.map((purchase) => String(purchase)) : [], + shopReady: Boolean(payload.shopReady), } match.updatedAt = Date.now() return pvpSnapshot(match) diff --git a/src/App.css b/src/App.css index a7fc71d..c7a1cba 100644 --- a/src/App.css +++ b/src/App.css @@ -6399,6 +6399,149 @@ h2 { box-shadow: inset 0 0 0 2px #6e5727; } +.stadium-screen { + min-height: 100dvh; +} + +.stadium-board { + display: grid; + gap: 14px; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + min-height: calc(100dvh - 72px); +} + +.stadium-header, +.stadium-pressure-panel { + align-items: center; + background: var(--panel); + border: 3px solid #0c0d11; + box-shadow: 4px 4px 0 #08090c; + display: flex; + grid-column: 1 / -1; + justify-content: space-between; + outline: 2px solid var(--edge); + padding: 12px 16px; +} + +.stadium-header h2 { + font-size: 20px; +} + +.stadium-header > strong { + color: var(--gold); + font-family: 'Press Start 2P', monospace; + font-size: 26px; +} + +.stadium-header p, +.stadium-header small, +.stadium-pressure-panel span { + color: var(--muted); + font-size: 13px; +} + +.stadium-pressure-panel strong { + color: var(--ink); + font-family: 'Press Start 2P', monospace; + font-size: 16px; +} + +.stadium-side { + min-height: 0; +} + +.stadium-shop-dialog { + max-width: 1180px !important; + padding: 16px !important; + text-align: left !important; + width: min(1180px, calc(100vw - 32px)); +} + +.stadium-shop-summary { + color: var(--muted); + display: flex; + flex-wrap: wrap; + font-family: 'Press Start 2P', monospace; + font-size: 12px; + gap: 16px; + margin-bottom: 12px; +} + +.stadium-shop-layout { + display: grid; + gap: 14px; + grid-template-columns: 240px minmax(0, 1fr); +} + +.stadium-shop-tabs { + display: grid; + gap: 8px; +} + +.stadium-shop-tabs button, +.stadium-shop-grid button { + background: #252833; + border: 2px solid #0b0c0f; + color: var(--ink); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + outline: 2px solid #4d4c58; +} + +.stadium-shop-tabs button { + font-size: 9px; + margin: 0; + padding: 10px; + text-align: left; +} + +.stadium-shop-tabs button.active { + background: #303427; + outline-color: var(--gold); +} + +.stadium-shop-layout h3 { + font-size: 15px; + margin-bottom: 10px; +} + +.stadium-shop-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.stadium-shop-grid button { + display: grid; + gap: 8px; + margin: 0; + min-height: 110px; + padding: 12px; + text-align: left; +} + +.stadium-shop-grid button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.stadium-shop-grid strong { + color: #ffe8a5; + font-size: 10px; + line-height: 1.25; +} + +.stadium-shop-grid small, +.stadium-shop-layout p { + color: #d3d9e6; + font-size: 13px; + line-height: 1.2; +} + +.dual-opponent-progress.stadium { + grid-template-columns: 1fr 1fr; +} + .result-screen button, .pause-screen button { background: var(--gold); @@ -6911,11 +7054,24 @@ h2 { } .pvp-board, + .stadium-board, + .stadium-shop-layout, .pvp-choice-columns, .pvp-choice-columns .upgrade-choice-grid { grid-template-columns: 1fr; } + .stadium-header, + .stadium-pressure-panel, + .stadium-shop-summary { + align-items: stretch; + flex-direction: column; + } + + .stadium-shop-grid { + grid-template-columns: 1fr; + } + .active-target-card, .mana-wrap { width: 100%; diff --git a/src/App.tsx b/src/App.tsx index 595b9fa..5152ede 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { AuthScreen } from './components/AuthScreen' import { CustomizeScreen } from './components/CustomizeScreen' import { EquipmentScreen } from './components/EquipmentScreen' import { PvPRoguelikeScreen } from './components/PvpRoguelikeScreen' +import { PvpStadiumScreen } from './components/PvpStadiumScreen' import { TalentScreen } from './components/TalentScreen' import { SettingsScreen } from './components/SettingsScreen' import { @@ -253,6 +254,19 @@ function App() { } if (screen === 'pvp') { + if (pvpContentType === 'stadium') { + return ( + { + setRoguelikeVariant('pvp') + setScreen('roguelike') + }} + onProfileUpdated={setProfile} + profile={profile} + /> + ) + } const pvpPool = profile.dungeons .filter((candidate) => candidate.contentType === pvpContentType) .flatMap((candidate) => candidate.encounters) @@ -547,6 +561,13 @@ function App() { > Raid +
@@ -554,9 +575,11 @@ function App() {
{gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'} - {gameMode === 'offline' - ? 'Offline mode always places you against a random CPU 1-5.' - : 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'} + {pvpContentType === 'stadium' + ? 'Best-of-5 survival with dampening, equalized gear, and after-round buff buying.' + : gameMode === 'offline' + ? 'Offline mode always places you against a random CPU 1-5.' + : 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'}
diff --git a/src/components/PvpStadiumScreen.tsx b/src/components/PvpStadiumScreen.tsx new file mode 100644 index 0000000..7a063fa --- /dev/null +++ b/src/components/PvpStadiumScreen.tsx @@ -0,0 +1,1368 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import { + DEFAULT_GROUP_HEAL_TARGETS, + INITIAL_PARTY, + groupHealTargets, + tankPressureTargets, + type CombatLogEntry, + type PartyMember, + type Spell, +} from '../game' +import { completeRoguelike, type DungeonReward } from '../profile' +import type { Ability, CharacterProfile } from '../profile' +import type { GameMode } from '../gameRepository' +import { ControllerBindingLabel } from './ControllerIcons' +import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input' +import { useDualScreen, useDualScreenPublisher, type DualScreenCombatState } from '../dualScreen' +import { + cancelPvpQueue, + checkPvpQueue, + joinPvpQueue, + publishPvpMatchState, + randomCpuDifficulty, + requestPvpRematch, + submitPvpUpgradeChoice, + type CpuDifficulty, + type PvpMatchSide, + type PvpMatchSnapshot, + type PvpRematchResponse, +} from '../pvpRoguelike' + +const TICK_MS = 700 +const ROUND_START_SECONDS = 3 +const SHOP_SECONDS = 60 +const WIN_ROUNDS = 3 +const MAX_RESOURCE = 100 + +type SlotKey = '1' | '2' | '3' | '4' | '5' +type StadiumBuffId = + | `slot${SlotKey}-extra-target` + | `slot${SlotKey}-cost-down` + | `slot${SlotKey}-cooldown-down` + | 'fifth-cast-free' + | 'group-heal-boost' + | 'shield-boost' + +type StadiumBuff = { + id: StadiumBuffId + name: string + description: string + category: SlotKey | 'misc' + cost: 1 | 2 +} + +type StadiumSideState = { + party: PartyMember[] + resource: number + cooldowns: Record + buffs: StadiumBuffId[] + castsTowardFree: number + freeCastReady: boolean + survivalSeconds: number + dampeningPercent: number + roundIndex: number + roundWins: number + roundStatus: 'playing' | 'shop' | 'won' | 'lost' + lastRoundOutcome?: 'win' | 'loss' | 'tie' + shopReady: boolean +} + +type FloatingCombatText = { + id: number + memberId: string + side: 'player' | 'cpu' + value: number +} + +type LivePvpMatch = { + id: string + side: PvpMatchSide + opponentSide: PvpMatchSide + opponentName: string + opponentClassName: string +} + +type RewardSummary = { + experienceGained: number + previousLevel: number | null + newLevel: number | null + levelsGained: number + talentPointsGained: number + unlockedAbilities: DungeonReward['unlockedAbilities'] +} + +const CPU_BEHAVIOR: Record = { + 1: { actionEveryTicks: 4, mistakeChance: 0.35, directHealThreshold: 0.54, groupHealThreshold: 0.5, hotThreshold: 0.6, shieldThreshold: 0.48 }, + 2: { actionEveryTicks: 3, mistakeChance: 0.24, directHealThreshold: 0.6, groupHealThreshold: 0.56, hotThreshold: 0.66, shieldThreshold: 0.54 }, + 3: { actionEveryTicks: 3, mistakeChance: 0.16, directHealThreshold: 0.66, groupHealThreshold: 0.62, hotThreshold: 0.72, shieldThreshold: 0.6 }, + 4: { actionEveryTicks: 2, mistakeChance: 0.08, directHealThreshold: 0.72, groupHealThreshold: 0.68, hotThreshold: 0.78, shieldThreshold: 0.66 }, + 5: { actionEveryTicks: 2, mistakeChance: 0.03, directHealThreshold: 0.78, groupHealThreshold: 0.74, hotThreshold: 0.84, shieldThreshold: 0.72 }, +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function formatTime(seconds: number) { + const total = Math.max(0, Math.floor(seconds)) + const minutes = Math.floor(total / 60) + const remaining = total % 60 + return `${minutes}:${String(remaining).padStart(2, '0')}` +} + +function createLogEntry(nextLogId: { current: number }, text: string, tone: CombatLogEntry['tone']) { + return { id: nextLogId.current++, text, tone } +} + +function effectiveMaxHealth(member: PartyMember) { + return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1))) +} + +function buffStacks(items: StadiumBuffId[], id: StadiumBuffId) { + return items.filter((item) => item === id).length +} + +function slotLabel(slot: SlotKey, spells: Spell[]) { + const spell = spells.find((candidate) => candidate.key === slot) + return spell ? `${spell.name} (Slot ${slot})` : `Slot ${slot}` +} + +function buildStadiumBuffs(spells: Spell[]): StadiumBuff[] { + const slotBuffs = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => { + const label = slotLabel(slot, spells) + return [ + { + id: `slot${slot}-extra-target` as StadiumBuffId, + name: '+1 target', + description: `${label} affects 1 additional ally when possible.`, + category: slot, + cost: 2, + }, + { + id: `slot${slot}-cost-down` as StadiumBuffId, + name: '-25% cost', + description: `${label} costs 25% less mana.`, + category: slot, + cost: 1, + }, + { + id: `slot${slot}-cooldown-down` as StadiumBuffId, + name: '-25% cooldown', + description: `${label} recharges 25% faster.`, + category: slot, + cost: 1, + }, + ] satisfies StadiumBuff[] + }) + return [ + ...slotBuffs, + { + id: 'fifth-cast-free', + name: 'Stored Momentum', + description: 'After 5 spell casts, your next cast is free.', + category: 'misc', + cost: 1, + }, + { + id: 'group-heal-boost', + name: 'Wide Radiance', + description: 'Party healing is 25% stronger.', + category: 'misc', + cost: 1, + }, + { + id: 'shield-boost', + name: 'Dense Shields', + description: 'Shield absorbs are 25% stronger.', + category: 'misc', + cost: 1, + }, + ] +} + +function summarizeStacks(items: StadiumBuffId[], catalog: StadiumBuff[]) { + const counts = new Map() + items.forEach((item) => counts.set(item, (counts.get(item) ?? 0) + 1)) + const summary = Array.from(counts.entries()) + .map(([id, count]) => { + const label = catalog.find((choice) => choice.id === id)?.name ?? id + return count > 1 ? `${label} x${count}` : label + }) + .join(', ') + return summary || 'none' +} + +function cooldownMultiplier(spell: Spell, buffs: StadiumBuffId[]) { + return 0.75 ** buffStacks(buffs, `slot${spell.key as SlotKey}-cooldown-down` as StadiumBuffId) +} + +function spellResourceCost(spell: Spell, buffs: StadiumBuffId[], freeCastReady: boolean) { + if (freeCastReady && buffStacks(buffs, 'fifth-cast-free') > 0) return 0 + return Math.ceil(spell.cost * (0.75 ** buffStacks(buffs, `slot${spell.key as SlotKey}-cost-down` as StadiumBuffId))) +} + +function toCombatSpell(ability: Ability, key: string): Spell { + const kinds: Record = { + direct_heal: 'direct', + heal_over_time: 'hot', + party_heal: 'group', + absorb: 'shield', + cleanse: 'cleanse', + } + return { + id: String(ability.id), + key, + name: ability.name, + description: ability.description, + cost: ability.cost, + cooldown: ability.cooldown, + power: ability.power, + glyph: ability.glyph, + kind: kinds[ability.spellType] ?? 'direct', + } +} + +function resetParty(partyTemplate: PartyMember[]) { + return partyTemplate.map((member) => ({ + ...member, + health: member.maxHealth, + shield: 0, + hotTicks: 0, + hotEffects: undefined, + debuff: undefined, + debuffTicks: undefined, + poisonStacks: undefined, + maxHealthPenaltyTicks: undefined, + healingReductionTicks: undefined, + })) +} + +function starterSide(partyTemplate: PartyMember[], roundIndex: number, buffs: StadiumBuffId[] = [], roundWins = 0): StadiumSideState { + return { + party: resetParty(partyTemplate), + resource: MAX_RESOURCE, + cooldowns: {}, + buffs, + castsTowardFree: 0, + freeCastReady: false, + survivalSeconds: 0, + dampeningPercent: 0, + roundIndex, + roundWins, + roundStatus: 'playing', + shopReady: false, + } +} + +function createEmptyRewardSummary(): RewardSummary { + return { + experienceGained: 0, + previousLevel: null, + newLevel: null, + levelsGained: 0, + talentPointsGained: 0, + unlockedAbilities: [], + } +} + +export function PvpStadiumScreen({ + profile, + gameMode, + onExit, + onProfileUpdated, +}: { + profile: CharacterProfile + gameMode: GameMode + onExit: () => void + onProfileUpdated: (profile: CharacterProfile) => void +}) { + const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)! + const starterSpells = useMemo(() => gameClass.spells + .filter((spell) => spell.unlockLevel === 1) + .slice(0, 5) + .map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells]) + const buffCatalog = useMemo(() => buildStadiumBuffs(starterSpells), [starterSpells]) + const partyTemplate = useMemo( + () => INITIAL_PARTY.map((member) => ({ + ...member, + name: member.id === 'mira' ? profile.character.name : member.name, + })), + [profile.character.name], + ) + const cpuPartyTemplate = useMemo( + () => INITIAL_PARTY.map((member) => ({ + ...member, + name: member.id === 'mira' ? 'CPU Healer' : member.name, + })), + [], + ) + const rewardDungeon = profile.dungeons.find((candidate) => candidate.contentType === 'dungeon') ?? profile.dungeons[0] + const rewardDifficulty = rewardDungeon.difficulties[0] + const [status, setStatus] = useState<'queueing' | 'round-countdown' | 'playing' | 'shop' | 'won' | 'lost'>('queueing') + const [playerSide, setPlayerSide] = useState(() => starterSide(partyTemplate, 1)) + const [cpuSide, setCpuSide] = useState(() => starterSide(cpuPartyTemplate, 1)) + const [selectedId, setSelectedId] = useState(partyTemplate[0].id) + const [roundIndex, setRoundIndex] = useState(1) + const [roundWins, setRoundWins] = useState({ player: 0, opponent: 0 }) + const [shopPoints, setShopPoints] = useState(0) + const [shopReady, setShopReady] = useState(false) + const [shopTimeLeft, setShopTimeLeft] = useState(SHOP_SECONDS) + const [shopCategory, setShopCategory] = useState('1') + const [roundCountdown, setRoundCountdown] = useState(ROUND_START_SECONDS) + const [elapsedTicks, setElapsedTicks] = useState(0) + const [cpuDifficulty, setCpuDifficulty] = useState(null) + const [liveMatch, setLiveMatch] = useState(null) + const [queueMessage, setQueueMessage] = useState('Searching Stadium queue...') + const [rematchRequested, setRematchRequested] = useState(false) + const [rematchMessage, setRematchMessage] = useState('') + const [paused, setPaused] = useState(false) + const [log, setLog] = useState([{ id: 1, text: 'Queueing Stadium opponent...', tone: 'system' }]) + const [floatingTexts, setFloatingTexts] = useState([]) + const [rewardSummary, setRewardSummary] = useState(() => createEmptyRewardSummary()) + const [rewardError, setRewardError] = useState('') + const [showEndLog, setShowEndLog] = useState(false) + const selectedIdRef = useRef(partyTemplate[0].id) + const playerRef = useRef(playerSide) + const cpuRef = useRef(cpuSide) + const liveMatchRef = useRef(null) + const nextLogId = useRef(2) + const nextFloatingTextId = useRef(1) + const roundCountdownTimerRef = useRef(null) + const shopEndsAtRef = useRef(0) + const submittedShopRef = useRef(false) + const awardedXpRef = useRef(new Set()) + const queuedMatchRef = useRef(false) + const loggedOpponentRoundRef = useRef('') + const { + bindings, + controllerIconStyle, + directPartyTargeting, + lastDevice, + } = useInput() + const { enabled: dualScreenEnabled } = useDualScreen() + const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}` + const playerAlive = playerSide.party.some((member) => member.health > 0) + + const setSelectedTargetId = useCallback((id: string) => { + selectedIdRef.current = id + setSelectedId(id) + }, []) + + const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => { + setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 70)) + }, []) + + const addFloatingHeal = useCallback((side: 'player' | 'cpu', memberId: string, value: number) => { + if (value <= 0) return + const id = nextFloatingTextId.current++ + setFloatingTexts((current) => [...current, { id, side, memberId, value }]) + window.setTimeout(() => { + setFloatingTexts((current) => current.filter((entry) => entry.id !== id)) + }, 900) + }, []) + + const clearRoundCountdown = useCallback(() => { + if (roundCountdownTimerRef.current === null) return + window.clearInterval(roundCountdownTimerRef.current) + roundCountdownTimerRef.current = null + }, []) + + const beginRoundCountdown = useCallback(() => { + clearRoundCountdown() + setRoundCountdown(ROUND_START_SECONDS) + setStatus('round-countdown') + const startedAt = Date.now() + roundCountdownTimerRef.current = window.setInterval(() => { + const remaining = Math.max(0, ROUND_START_SECONDS - (Date.now() - startedAt) / 1000) + setRoundCountdown(remaining) + if (remaining > 0) return + clearRoundCountdown() + setStatus((current) => current === 'round-countdown' ? 'playing' : current) + }, 100) + }, [clearRoundCountdown]) + + useEffect(() => () => clearRoundCountdown(), [clearRoundCountdown]) + + const awardXp = useCallback((key: string, mode: 'pvp-stadium-round-win-quarter-level' | 'pvp-stadium-round-loss-tenth-level' | 'pvp-stadium-match-half-level') => { + if (awardedXpRef.current.has(key)) return + awardedXpRef.current.add(key) + completeRoguelike(rewardDungeon.id, rewardDifficulty.id, 0, 0, Math.max(1, Math.floor(playerRef.current.survivalSeconds || 1)), { + bossesCleared: 0, + fightsCleared: 1, + experienceMode: mode, + }) + .then((result) => { + setRewardSummary((current) => { + const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability])) + result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability)) + return { + experienceGained: current.experienceGained + result.experienceGained, + previousLevel: current.previousLevel ?? result.previousLevel, + newLevel: result.newLevel, + levelsGained: current.levelsGained + result.levelsGained, + talentPointsGained: current.talentPointsGained + result.talentPointsGained, + unlockedAbilities: Array.from(unlockedById.values()), + } + }) + onProfileUpdated(result.profile) + if (result.experienceGained > 0) addLog(`+${result.experienceGained} XP awarded.`, 'loot') + }) + .catch((reason: unknown) => { + setRewardError(reason instanceof Error ? reason.message : 'Unable to award Stadium XP.') + }) + }, [addLog, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id]) + + const startLiveMatch = useCallback((match: PvpMatchSnapshot, side: PvpMatchSide, message?: string) => { + const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a' + const opponent = match.players[opponentSide] + const basePlayer = starterSide(partyTemplate, 1) + const baseOpponent = starterSide( + cpuPartyTemplate.map((member) => ({ + ...member, + name: member.id === 'mira' ? opponent.characterName : member.name, + })), + 1, + ) + const nextLiveMatch = { + id: match.id, + side, + opponentSide, + opponentName: opponent.characterName, + opponentClassName: opponent.className, + } + playerRef.current = basePlayer + cpuRef.current = baseOpponent + liveMatchRef.current = nextLiveMatch + queuedMatchRef.current = true + nextLogId.current = 2 + awardedXpRef.current = new Set() + setPlayerSide(basePlayer) + setCpuSide(baseOpponent) + setRoundIndex(1) + setRoundWins({ player: 0, opponent: 0 }) + setSelectedTargetId(partyTemplate[0].id) + setElapsedTicks(0) + setShopPoints(0) + setShopReady(false) + setCpuDifficulty(null) + setLiveMatch(nextLiveMatch) + setPaused(false) + setRewardSummary(createEmptyRewardSummary()) + setRewardError('') + setShowEndLog(false) + setFloatingTexts([]) + setRematchRequested(false) + setRematchMessage('') + loggedOpponentRoundRef.current = '' + const text = message ?? `${opponent.characterName} found. Stadium begins.` + setQueueMessage(text) + setLog([{ id: 1, text, tone: 'system' }]) + beginRoundCountdown() + }, [beginRoundCountdown, cpuPartyTemplate, partyTemplate, setSelectedTargetId]) + + const startMatch = useCallback(() => { + clearRoundCountdown() + const basePlayer = starterSide(partyTemplate, 1) + const baseCpu = starterSide(cpuPartyTemplate, 1) + playerRef.current = basePlayer + cpuRef.current = baseCpu + liveMatchRef.current = null + queuedMatchRef.current = true + nextLogId.current = 2 + awardedXpRef.current = new Set() + setPlayerSide(basePlayer) + setCpuSide(baseCpu) + setRoundIndex(1) + setRoundWins({ player: 0, opponent: 0 }) + setSelectedTargetId(partyTemplate[0].id) + setElapsedTicks(0) + setStatus('queueing') + setShopPoints(0) + setShopReady(false) + setCpuDifficulty(null) + setLiveMatch(null) + setPaused(false) + setRewardSummary(createEmptyRewardSummary()) + setRewardError('') + setShowEndLog(false) + setFloatingTexts([]) + setRematchRequested(false) + setRematchMessage('') + loggedOpponentRoundRef.current = '' + const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => { + setCpuDifficulty(randomCpu) + setQueueMessage(message) + setLog([{ id: 1, text: message, tone: 'system' }]) + beginRoundCountdown() + } + if (gameMode === 'offline') { + const randomCpu = randomCpuDifficulty() + const timer = window.setTimeout(() => { + beginCpuMatch(randomCpu, `Offline mode. CPU ${randomCpu} enters Stadium.`) + }, 500) + return () => window.clearTimeout(timer) + } + let cancelled = false + let ticketId = '' + let pollTimer: number | undefined + setQueueMessage('Searching Stadium queue for 5s.') + setLog([{ id: 1, text: 'Searching Stadium queue for 5s.', tone: 'system' }]) + const beginLiveMatch = (match: PvpMatchSnapshot, side: PvpMatchSide) => { + if (cancelled) return + const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a' + const opponent = match.players[opponentSide] + startLiveMatch(match, side, `${opponent.characterName} found. Stadium begins.`) + } + const fallbackTimer = window.setTimeout(() => { + if (cancelled || liveMatchRef.current) return + cancelled = true + if (ticketId) cancelPvpQueue(ticketId).catch(() => undefined) + const randomCpu = randomCpuDifficulty() + beginCpuMatch(randomCpu, `No Stadium player found after 5s. CPU ${randomCpu} steps in.`) + }, 5000) + const pollQueue = () => { + if (!ticketId || cancelled) return + checkPvpQueue(ticketId) + .then((result) => { + if (cancelled) return + if (result.status === 'matched' && result.match && result.side) { + window.clearTimeout(fallbackTimer) + if (pollTimer) window.clearTimeout(pollTimer) + beginLiveMatch(result.match, result.side) + return + } + pollTimer = window.setTimeout(pollQueue, 500) + }) + .catch(() => { + if (!cancelled) pollTimer = window.setTimeout(pollQueue, 700) + }) + } + joinPvpQueue('stadium', 1) + .then((result) => { + if (cancelled) return + ticketId = result.ticketId + if (result.status === 'matched' && result.match && result.side) { + window.clearTimeout(fallbackTimer) + beginLiveMatch(result.match, result.side) + return + } + pollTimer = window.setTimeout(pollQueue, 500) + }) + .catch(() => { + if (cancelled) return + window.clearTimeout(fallbackTimer) + cancelled = true + const randomCpu = randomCpuDifficulty() + beginCpuMatch(randomCpu, `PvP server unavailable. CPU ${randomCpu} steps in.`) + }) + return () => { + cancelled = true + window.clearTimeout(fallbackTimer) + if (pollTimer) window.clearTimeout(pollTimer) + if (ticketId && !liveMatchRef.current) cancelPvpQueue(ticketId).catch(() => undefined) + } + }, [beginRoundCountdown, clearRoundCountdown, cpuPartyTemplate, gameMode, partyTemplate, setSelectedTargetId, startLiveMatch]) + + useEffect(() => startMatch(), [startMatch]) + + const applySpell = useCallback(( + current: StadiumSideState, + setCurrent: Dispatch>, + sideName: 'player' | 'cpu', + spell: Spell, + targetId: string, + ) => { + const effectiveCost = spellResourceCost(spell, current.buffs, current.freeCastReady) + if (current.resource < effectiveCost || (current.cooldowns[spell.id] ?? 0) > 0) return false + const target = current.party.find((member) => member.id === targetId && member.health > 0) + if (!target) return false + const dampenMultiplier = Math.max(0, 1 - current.dampeningPercent / 100) + const livingTargets = current.party.filter((member) => member.health > 0) + const extraTarget = (blockedIds: string[]) => livingTargets + .filter((member) => !blockedIds.includes(member.id)) + .sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0] + const directTargets = new Set([targetId]) + const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : []) + const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : []) + const extraTargets = buffStacks(current.buffs, `slot${spell.key as SlotKey}-extra-target` as StadiumBuffId) + const groupTargets = new Set( + spell.kind === 'group' + ? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id) + : [], + ) + for (let index = 0; index < extraTargets; index += 1) { + if (spell.kind === 'group') break + if (spell.kind === 'hot') { + const extra = extraTarget([...hotTargets]) + if (extra) hotTargets.add(extra.id) + continue + } + if (spell.kind === 'shield') { + const extra = extraTarget([...shieldTargets]) + if (extra) shieldTargets.add(extra.id) + continue + } + const extra = extraTarget([...directTargets]) + if (extra) directTargets.add(extra.id) + } + const nextParty = current.party.map((member) => { + if (member.health <= 0) return member + if (spell.kind === 'group') { + if (!groupTargets.has(member.id)) return member + const power = Math.round(spell.power * (1.25 ** buffStacks(current.buffs, 'group-heal-boost')) * dampenMultiplier) + const nextHealth = clamp(member.health + power, 0, effectiveMaxHealth(member)) + addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health)) + return { ...member, health: nextHealth } + } + if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member + if (spell.kind === 'shield') { + const shieldPower = Math.round(spell.power * (1.25 ** buffStacks(current.buffs, 'shield-boost')) * dampenMultiplier) + return { + ...member, + shield: Math.max(member.shield, shieldPower), + hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks, + } + } + if (spell.kind === 'cleanse') { + const power = Math.round(spell.power * dampenMultiplier) + const nextHealth = clamp(member.health + power, 0, effectiveMaxHealth(member)) + addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health)) + return { + ...member, + health: nextHealth, + debuff: undefined, + debuffTicks: undefined, + poisonStacks: undefined, + maxHealthPenaltyTicks: undefined, + healingReductionTicks: undefined, + } + } + const power = directTargets.has(member.id) ? Math.round(spell.power * dampenMultiplier) : 0 + const nextHealth = clamp(member.health + power, 0, effectiveMaxHealth(member)) + if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health) + return { + ...member, + health: nextHealth, + hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks, + } + }) + const freeBuff = buffStacks(current.buffs, 'fifth-cast-free') > 0 + const wasFree = effectiveCost === 0 && current.freeCastReady + const nextCasts = freeBuff ? (wasFree ? 0 : current.castsTowardFree + 1) : current.castsTowardFree + const nextState = { + ...current, + party: nextParty, + resource: current.resource - effectiveCost, + cooldowns: { + ...current.cooldowns, + [spell.id]: spell.cooldown * cooldownMultiplier(spell, current.buffs), + }, + castsTowardFree: freeBuff && nextCasts >= 5 ? 0 : nextCasts, + freeCastReady: freeBuff && nextCasts >= 5, + } + setCurrent(nextState) + return true + }, [addFloatingHeal]) + + const castPlayerSpell = useCallback((spell: Spell) => { + if (status !== 'playing' || !playerAlive) return + const targetId = selectedIdRef.current + const succeeded = applySpell(playerRef.current, (value) => { + const next = typeof value === 'function' ? value(playerRef.current) : value + playerRef.current = next + setPlayerSide(next) + }, 'player', spell, targetId) + if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal') + }, [addLog, applySpell, playerAlive, status]) + + const cpuTakeTurn = useCallback(() => { + if (!cpuDifficulty || status !== 'playing') return + const behavior = CPU_BEHAVIOR[cpuDifficulty] + if (elapsedTicks % behavior.actionEveryTicks !== 0 || Math.random() < behavior.mistakeChance) return + const side = cpuRef.current + const living = side.party.filter((member) => member.health > 0) + if (living.length === 0) return + const lowest = [...living].sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0] + const averageHealth = living.reduce((total, member) => total + member.health / effectiveMaxHealth(member), 0) / Math.max(1, living.length) + const tank = living.find((member) => member.role === 'Tank') + const cleanseTarget = living.find((member) => member.debuff || (member.poisonStacks ?? 0) > 0) + const renewTarget = living.find((member) => member.hotTicks <= 1 && member.health / effectiveMaxHealth(member) < behavior.hotThreshold) + const shieldTarget = living.find((member) => member.role === 'Tank' && member.shield <= 5 && member.health / effectiveMaxHealth(member) < behavior.shieldThreshold) + const ordered: Array<{ spell: Spell | undefined; targetId: string | null }> = [ + { spell: cleanseTarget ? starterSpells.find((candidate) => candidate.kind === 'cleanse') : undefined, targetId: cleanseTarget?.id ?? null }, + { spell: averageHealth < behavior.groupHealThreshold ? starterSpells.find((candidate) => candidate.kind === 'group') : undefined, targetId: lowest?.id ?? null }, + { spell: shieldTarget ? starterSpells.find((candidate) => candidate.kind === 'shield') : undefined, targetId: shieldTarget?.id ?? null }, + { spell: lowest.health / effectiveMaxHealth(lowest) < behavior.directHealThreshold ? starterSpells.find((candidate) => candidate.kind === 'direct') : undefined, targetId: lowest.id }, + { spell: renewTarget ? starterSpells.find((candidate) => candidate.kind === 'hot') : undefined, targetId: renewTarget?.id ?? null }, + { spell: tank ? starterSpells.find((candidate) => candidate.kind === 'direct') : undefined, targetId: tank?.id ?? null }, + ] + for (const action of ordered) { + if (!action.spell || !action.targetId) continue + const succeeded = applySpell(cpuRef.current, (value) => { + const next = typeof value === 'function' ? value(cpuRef.current) : value + cpuRef.current = next + setCpuSide(next) + }, 'cpu', action.spell, action.targetId) + if (succeeded) return + } + }, [applySpell, cpuDifficulty, elapsedTicks, starterSpells, status]) + + const advanceBoss = useCallback((side: StadiumSideState) => { + if (side.roundStatus !== 'playing') return side + const nextSurvival = side.survivalSeconds + TICK_MS / 1000 + const dampeningPercent = Math.floor(nextSurvival / 5) + const dampenMultiplier = Math.max(0, 1 - dampeningPercent / 100) + const living = side.party.filter((member) => member.health > 0) + if (living.length === 0) return side + const spikeTarget = living[Math.floor(Math.random() * living.length)] + const tankIds = new Set(tankPressureTargets(side.party).targets.map((member) => member.id)) + const pulse = elapsedTicks > 0 && elapsedTicks % 5 === 0 + const spike = elapsedTicks > 0 && elapsedTicks % 8 === 0 + const nextParty = side.party.map((member) => { + if (member.health <= 0) return member + let damage = tankIds.has(member.id) ? 8 : 0 + if (pulse) damage += 9 + if (spike && member.id === spikeTarget.id) damage += 22 + const hotHealing = member.hotTicks > 0 ? Math.round(6 * dampenMultiplier) : 0 + const absorbed = Math.min(member.shield, damage) + const nextHealth = clamp(member.health - damage + absorbed + hotHealing, 0, effectiveMaxHealth(member)) + return { + ...member, + health: nextHealth, + shield: Math.max(0, member.shield - damage), + hotTicks: Math.max(0, member.hotTicks - 1), + debuff: spike && member.id === spikeTarget.id ? 'Marked' : member.debuff, + debuffTicks: spike && member.id === spikeTarget.id ? 4 : member.debuffTicks ? Math.max(0, member.debuffTicks - 1) : undefined, + } + }) + return { + ...side, + party: nextParty, + resource: clamp(side.resource + 2.4, 0, MAX_RESOURCE), + cooldowns: Object.fromEntries( + Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]), + ), + survivalSeconds: nextSurvival, + dampeningPercent, + } + }, [elapsedTicks]) + + const beginShop = useCallback((outcome: 'win' | 'loss' | 'tie', nextWins: { player: number; opponent: number }) => { + const points = outcome === 'loss' ? 4 : 3 + shopEndsAtRef.current = Date.now() + SHOP_SECONDS * 1000 + submittedShopRef.current = false + setShopPoints(points) + setShopReady(false) + setShopTimeLeft(SHOP_SECONDS) + setRoundWins(nextWins) + setStatus('shop') + setPlayerSide((current) => { + const next = { ...current, roundStatus: 'shop' as const, lastRoundOutcome: outcome, shopReady: false, roundWins: nextWins.player } + playerRef.current = next + return next + }) + if (!liveMatchRef.current) { + let cpuPoints = outcome === 'win' ? 4 : 3 + const purchases: StadiumBuffId[] = [] + while (cpuPoints > 0) { + const affordable = buffCatalog.filter((buff) => buff.cost <= cpuPoints) + if (affordable.length === 0) break + const selected = affordable[Math.floor(Math.random() * affordable.length)] + purchases.push(selected.id) + cpuPoints -= selected.cost + } + setCpuSide((current) => { + const next = { ...current, buffs: [...current.buffs, ...purchases], roundStatus: 'shop' as const, roundWins: nextWins.opponent } + cpuRef.current = next + return next + }) + } + }, [buffCatalog]) + + const finishRound = useCallback((outcome: 'win' | 'loss' | 'tie') => { + if (status !== 'playing') return + const key = `round-${roundIndex}-${outcome}` + if (outcome === 'win' || outcome === 'tie') { + awardXp(key, 'pvp-stadium-round-win-quarter-level') + } else { + awardXp(key, 'pvp-stadium-round-loss-tenth-level') + } + const nextWins = { + player: roundWins.player + (outcome === 'win' ? 1 : 0), + opponent: roundWins.opponent + (outcome === 'loss' ? 1 : 0), + } + addLog( + outcome === 'win' + ? `Round ${roundIndex} won.` + : outcome === 'loss' + ? `Round ${roundIndex} lost.` + : `Round ${roundIndex} tied.`, + outcome === 'loss' ? 'danger' : 'loot', + ) + if (nextWins.player >= WIN_ROUNDS) { + setRoundWins(nextWins) + setStatus('won') + setPlayerSide((current) => { + const next = { ...current, roundStatus: 'won' as const, lastRoundOutcome: outcome, roundWins: nextWins.player } + playerRef.current = next + return next + }) + awardXp('match-win', 'pvp-stadium-match-half-level') + return + } + if (nextWins.opponent >= WIN_ROUNDS) { + setRoundWins(nextWins) + setStatus('lost') + setPlayerSide((current) => { + const next = { ...current, roundStatus: 'lost' as const, lastRoundOutcome: outcome, roundWins: nextWins.player } + playerRef.current = next + return next + }) + return + } + beginShop(outcome, nextWins) + }, [addLog, awardXp, beginShop, roundIndex, roundWins, status]) + + useEffect(() => { + if (status !== 'playing' || paused) return + const timer = window.setInterval(() => { + setElapsedTicks((value) => value + 1) + if (!liveMatchRef.current) cpuTakeTurn() + const nextPlayer = advanceBoss(playerRef.current) + const nextCpu = liveMatchRef.current ? cpuRef.current : advanceBoss(cpuRef.current) + playerRef.current = nextPlayer + cpuRef.current = nextCpu + setPlayerSide(nextPlayer) + setCpuSide(nextCpu) + const nextPlayerAlive = nextPlayer.party.some((member) => member.health > 0) + const nextCpuAlive = nextCpu.party.some((member) => member.health > 0) + if (!nextPlayerAlive && (!liveMatchRef.current && !nextCpuAlive)) finishRound('tie') + else if (!nextPlayerAlive) finishRound('loss') + else if (!liveMatchRef.current && !nextCpuAlive) finishRound('win') + }, TICK_MS) + return () => window.clearInterval(timer) + }, [advanceBoss, cpuTakeTurn, finishRound, paused, status]) + + const startNextRound = useCallback(() => { + const nextRound = roundIndex + 1 + const nextPlayer = starterSide(partyTemplate, nextRound, playerRef.current.buffs, roundWins.player) + const nextCpu = starterSide(cpuPartyTemplate, nextRound, cpuRef.current.buffs, roundWins.opponent) + playerRef.current = nextPlayer + cpuRef.current = nextCpu + setRoundIndex(nextRound) + setPlayerSide(nextPlayer) + setCpuSide(nextCpu) + setSelectedTargetId(partyTemplate[0].id) + setElapsedTicks(0) + setShopReady(false) + setShopPoints(0) + beginRoundCountdown() + }, [beginRoundCountdown, cpuPartyTemplate, partyTemplate, roundIndex, roundWins.opponent, roundWins.player, setSelectedTargetId]) + + const finishShop = useCallback(() => { + if (shopReady || status !== 'shop') return + setShopReady(true) + submittedShopRef.current = true + setPlayerSide((current) => { + const next = { ...current, shopReady: true } + playerRef.current = next + return next + }) + if (liveMatchRef.current) { + submitPvpUpgradeChoice(liveMatchRef.current.id, { + encounterIndex: roundIndex, + buffId: 'stadium-shop', + debuffId: '', + purchases: playerRef.current.buffs, + shopReady: true, + }).catch(() => undefined) + return + } + startNextRound() + }, [roundIndex, shopReady, startNextRound, status]) + + useEffect(() => { + if (status !== 'shop' || shopReady) return + const updateTimer = () => { + const remaining = Math.max(0, (shopEndsAtRef.current - Date.now()) / 1000) + setShopTimeLeft(remaining) + if (remaining <= 0) finishShop() + } + updateTimer() + const timer = window.setInterval(updateTimer, 200) + return () => window.clearInterval(timer) + }, [finishShop, shopReady, status]) + + useEffect(() => { + if (!liveMatch || status === 'queueing') return + let stopped = false + const syncMatch = () => { + publishPvpMatchState(liveMatch.id, { + state: playerRef.current, + status: status === 'round-countdown' ? 'playing' : status, + stage: roundIndex, + encounterIndex: roundIndex, + encountersCleared: roundWins.player, + enemyHealth: 0, + alive: playerRef.current.party.some((member) => member.health > 0), + elapsedTicks, + }) + .then((snapshot) => { + if (stopped) return + const opponentState = snapshot.states[liveMatch.opponentSide] + if (opponentState) { + cpuRef.current = opponentState + setCpuSide(opponentState) + } + const opponentStatus = snapshot.statuses[liveMatch.opponentSide] + if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') setStatus('lost') + if (opponentStatus === 'lost' && status !== 'won' && status !== 'lost') setStatus('won') + if (!opponentState) return + if (status === 'playing' && opponentState.roundIndex === roundIndex && opponentState.roundStatus === 'shop' && opponentState.lastRoundOutcome) { + const key = `${roundIndex}-${opponentState.lastRoundOutcome}` + if (loggedOpponentRoundRef.current === key) return + loggedOpponentRoundRef.current = key + if (opponentState.lastRoundOutcome === 'loss') finishRound('win') + else if (opponentState.lastRoundOutcome === 'win') finishRound('loss') + else finishRound('tie') + } + if (status === 'shop' && shopReady && opponentState.roundIndex === roundIndex && opponentState.shopReady) { + startNextRound() + } + }) + .catch(() => undefined) + } + syncMatch() + const timer = window.setInterval(syncMatch, 700) + return () => { + stopped = true + window.clearInterval(timer) + } + }, [elapsedTicks, finishRound, liveMatch, roundIndex, roundWins.player, shopReady, startNextRound, status]) + + const buyBuff = useCallback((buff: StadiumBuff) => { + if (status !== 'shop' || shopReady || shopPoints < buff.cost) return + let purchased = false + setShopPoints((points) => { + if (points < buff.cost) return points + purchased = true + return points - buff.cost + }) + if (!purchased) return + setPlayerSide((current) => { + const next = { ...current, buffs: [...current.buffs, buff.id] } + playerRef.current = next + return next + }) + addLog(`${buff.name} purchased.`, 'loot') + }, [addLog, shopPoints, shopReady, status]) + + const handleRematch = useCallback(() => { + if (!liveMatch || rematchRequested) return + let cancelled = false + let attempts = 0 + setRematchRequested(true) + setRematchMessage(`Waiting for ${liveMatch.opponentName} to rematch...`) + const handleResponse = (result: PvpRematchResponse) => { + if (cancelled) return + if (result.status === 'matched' && result.match && result.side) { + startLiveMatch(result.match, result.side, `Rematch against ${liveMatch.opponentName} begins.`) + return + } + attempts += 1 + if (attempts >= 180) { + setRematchRequested(false) + setRematchMessage('Rematch expired.') + return + } + window.setTimeout(pollRematch, 700) + } + const pollRematch = () => { + requestPvpRematch(liveMatch.id) + .then(handleResponse) + .catch((reason: unknown) => { + if (cancelled) return + attempts += 1 + if (attempts >= 10) { + setRematchRequested(false) + setRematchMessage(reason instanceof Error ? reason.message : 'Unable to request rematch.') + return + } + window.setTimeout(pollRematch, 900) + }) + } + pollRematch() + return () => { + cancelled = true + } + }, [liveMatch, rematchRequested, startLiveMatch]) + + useEffect(() => { + if (status !== 'shop') return + window.requestAnimationFrame(() => focusFirstControl()) + }, [status]) + + useGameAction((action) => { + if (action === 'pause') { + if (status === 'playing') setPaused((value) => !value) + return + } + if (action.startsWith('targetParty')) { + const index = Number(action.slice('targetParty'.length)) - 1 + const member = playerRef.current.party[index] + if (member?.health > 0) setSelectedTargetId(member.id) + return + } + if (action.startsWith('ability')) { + const spell = starterSpells.find((candidate) => candidate.key === action.slice('ability'.length)) + if (spell) castPlayerSpell(spell) + } + }) + + const dualScreenState = useMemo(() => ({ + difficultyName: 'Equalized iLvl 10', + dungeonName: 'Stadium', + contentName: 'Stadium', + encounterName: 'Iron Arbiter', + encounterDescription: 'Survive escalating arena pressure.', + encounterHealth: 0, + encounterMaxHealth: 1, + encounterIsBoss: true, + encounterIndex: roundIndex - 1, + encounterCount: 5, + party: playerSide.party, + opponentName: opponentLabel, + opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'), + opponentParty: cpuSide.party, + opponentResource: cpuSide.resource, + opponentEnemyHealth: 0, + opponentBuffSummary: summarizeStacks(cpuSide.buffs, buffCatalog), + opponentDebuffSummary: `Dampening ${playerSide.dampeningPercent}%`, + floatingTexts: floatingTexts + .filter((entry) => entry.side === 'player') + .map(({ id, memberId, value }) => ({ id, memberId, value })), + partySize: playerSide.party.length, + selectedId, + log, + status: status === 'queueing' || status === 'round-countdown' ? 'playing' : status === 'shop' ? 'upgrade-choice' : status, + resource: playerSide.resource, + maxResource: MAX_RESOURCE, + resourceName: gameClass.resourceName, + playerIsAlive: playerAlive, + spells: starterSpells.map((spell, slotIndex) => ({ + ...spell, + cost: spellResourceCost(spell, playerSide.buffs, playerSide.freeCastReady), + slotIndex, + remaining: playerSide.cooldowns[spell.id] ?? 0, + })), + activeDevice: lastDevice, + bindings: bindings[lastDevice], + controllerIconStyle, + directPartyTargeting, + paused, + targetGroup: 0, + speedMultiplier: 1, + stadium: { + dampeningPercent: playerSide.dampeningPercent, + roundIndex, + playerWins: roundWins.player, + opponentWins: roundWins.opponent, + survivalSeconds: playerSide.survivalSeconds, + opponentSurvivalSeconds: cpuSide.survivalSeconds, + }, + }), [ + bindings, + buffCatalog, + controllerIconStyle, + cpuDifficulty, + cpuSide.buffs, + cpuSide.party, + cpuSide.resource, + cpuSide.survivalSeconds, + directPartyTargeting, + floatingTexts, + gameClass.resourceName, + lastDevice, + liveMatch?.opponentClassName, + log, + opponentLabel, + paused, + playerAlive, + playerSide.buffs, + playerSide.cooldowns, + playerSide.dampeningPercent, + playerSide.freeCastReady, + playerSide.party, + playerSide.resource, + playerSide.survivalSeconds, + roundIndex, + roundWins.opponent, + roundWins.player, + selectedId, + starterSpells, + status, + ]) + useDualScreenPublisher(dualScreenState, dualScreenEnabled) + + const visibleBuffs = buffCatalog.filter((buff) => buff.category === shopCategory) + const categoryLabel = shopCategory === 'misc' ? 'Miscellaneous' : slotLabel(shopCategory, starterSpells) + + return ( +
+
+ {status === 'queueing' && ( +
+
P V P
+

{queueMessage}

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

Stadium

+

Round {roundIndex} / Best of 5

+
+ {roundWins.player} - {roundWins.opponent} +
+

Dampening {playerSide.dampeningPercent}%

+ Survival {formatTime(playerSide.survivalSeconds)} | Equalized iLvl 10 +
+
+ +
+ Iron Arbiter + No boss health | Next pulse in {Math.max(0, 5 - (elapsedTicks % 5) * (TICK_MS / 1000)).toFixed(1)}s +
+ +
+
+
+

You

+

{profile.character.name}

+
+
+ {gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE} +
+
+
+
+ {playerSide.party.map((member) => ( + + ))} +
+

Buffs: {summarizeStacks(playerSide.buffs, buffCatalog)}

+
+ +
+
+
+

Opponent

+

{opponentLabel}

+ Survival {formatTime(cpuSide.survivalSeconds)} +
+
+ {gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE} +
+
+
+
+ {cpuSide.party.map((member) => ( +
+
+ {member.role[0]} + {member.name} +
+
+ + {member.shield > 0 && } + {Math.floor(member.health)} / {effectiveMaxHealth(member)} +
+
+ {member.hotTicks > 0 && Renew} + {member.shield > 0 && Shield {Math.ceil(member.shield)}} + {member.debuff && {member.debuff}} +
+
+ ))} +
+

Buffs: {summarizeStacks(cpuSide.buffs, buffCatalog)}

+
+ +
+ {starterSpells.map((spell) => { + const remaining = playerSide.cooldowns[spell.id] ?? 0 + const cost = spellResourceCost(spell, playerSide.buffs, playerSide.freeCastReady) + return ( + + ) + })} +
+
+ )} + + {status === 'round-countdown' && ( +
+
+

Round Starts

+

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

+
+
+ )} + + {status === 'shop' && ( +
+
+
+
+

Stadium Buy Round

+

Round {roundIndex} Complete

+
+ {shopTimeLeft.toFixed(0)}s +
+
+ Points: {shopPoints} + Score: {roundWins.player} - {roundWins.opponent} + {shopReady && Waiting for opponent...} +
+
+ +
+

{categoryLabel} Buffs

+
+ {visibleBuffs.map((buff) => ( + + ))} +
+

Active: {summarizeStacks(playerSide.buffs, buffCatalog)}

+
+
+ +
+
+ )} + + {paused && ( +
+
+

Paused

+

Stadium

+ + +
+
+ )} + + {(status === 'won' || status === 'lost') && ( +
+
+

{status === 'won' ? 'Victory' : 'Defeat'}

+

{status === 'won' ? 'Stadium Won' : `${opponentLabel} Wins`}

+

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

+
+

+{rewardSummary.experienceGained} XP

+ {rewardError &&

{rewardError}

} + {rewardSummary.levelsGained > 0 && rewardSummary.previousLevel !== null && rewardSummary.newLevel !== null && ( +

+ Level {rewardSummary.previousLevel} to {rewardSummary.newLevel} + +{rewardSummary.talentPointsGained} talent point{rewardSummary.talentPointsGained === 1 ? '' : 's'} +

+ )} + {rewardSummary.unlockedAbilities.map((ability) => ( +

+ {ability.glyph} + Ability Unlocked: {ability.name} +

+ ))} +
+ {log.length > 0 && ( + <> + + {showEndLog && ( +
+ {log.slice().reverse().map((entry) => ( +
{entry.text}
+ ))} +
+ )} + + )} + {liveMatch && ( + <> + + {rematchMessage &&

{rematchMessage}

} + + )} + + +
+
+ )} +
+
+ ) +} diff --git a/src/dualScreen.tsx b/src/dualScreen.tsx index d5bab3f..a7e0919 100644 --- a/src/dualScreen.tsx +++ b/src/dualScreen.tsx @@ -67,6 +67,14 @@ export type DualScreenCombatState = { paused: boolean targetGroup: 0 | 1 | 2 speedMultiplier: 1 | 2 + stadium?: { + dampeningPercent: number + roundIndex: number + playerWins: number + opponentWins: number + survivalSeconds: number + opponentSurvivalSeconds: number + } } export type DualScreenWorkshopState = { @@ -138,6 +146,11 @@ function memberHotEffects(member: PartyMember) { : [] } +function formatDualTime(seconds: number) { + const total = Math.max(0, Math.floor(seconds)) + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}` +} + export function DualScreenProvider({ children }: { children: ReactNode }) { const [enabled, setEnabledState] = useState( () => localStorage.getItem(STORAGE_KEY) === 'true', @@ -446,21 +459,34 @@ export function DualScreenBottomDisplay() { {state.opponentParty && {state.opponentClassName}}
- {state.opponentParty ? 'Live PvP' : `Encounter ${state.encounterIndex + 1}/${state.encounterCount}`} + {state.stadium ? `Round ${state.stadium.roundIndex} | ${state.stadium.playerWins}-${state.stadium.opponentWins}` : state.opponentParty ? 'Live PvP' : `Encounter ${state.encounterIndex + 1}/${state.encounterCount}`}
{state.opponentParty ? ( <> -
-
-

Opponent Clear

- {Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth} -
-
- -
-
+ {state.stadium ? ( +
+
+

Dampening

+ {state.stadium.dampeningPercent}% +
+
+

Survival

+ {formatDualTime(state.stadium.opponentSurvivalSeconds)} +
+
+ ) : ( +
+
+

Opponent Clear

+ {Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth} +
+
+ +
+
+ )}
6 ? 'raid' : ''}`}> {state.opponentParty.map((member) => ( diff --git a/src/gameRepository.ts b/src/gameRepository.ts index bd2d829..4186f8b 100644 --- a/src/gameRepository.ts +++ b/src/gameRepository.ts @@ -37,7 +37,7 @@ export interface GameRepository { durationSeconds: number, options?: { bossesCleared?: number - experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' + experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' | 'pvp-stadium-round-win-quarter-level' | 'pvp-stadium-round-loss-tenth-level' | 'pvp-stadium-match-half-level' fightsCleared?: number lootSourceEncounterId?: number roguelikeStage?: number @@ -529,6 +529,27 @@ function scaledPvpFightExperience( return { experience, level } } +function scaledCurrentLevelExperience( + startingExperience: number, + startingLevel: number, + maxLevel: number, + rate: number, +) { + let experience = startingExperience + let level = startingLevel + const maxExperience = experienceForLevel(maxLevel) + const currentLevelFloor = experienceForLevel(level) + const nextLevelExperience = level >= maxLevel + ? maxExperience + : experienceForLevel(level + 1) + const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) + experience = Math.min(maxExperience, experience + Math.round(levelBand * rate)) + while (level < maxLevel && experienceForLevel(level + 1) <= experience) { + level += 1 + } + return { experience, level } +} + function talentEffectCapacity(level: number) { return Math.min(4, Math.max(0, Math.floor(level / 5))) } @@ -1176,7 +1197,13 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { profile.maxLevel, highestOtherClassLevel(save), ) - : null + : options?.experienceMode === 'pvp-stadium-round-win-quarter-level' + ? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.25) + : options?.experienceMode === 'pvp-stadium-round-loss-tenth-level' + ? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.1) + : options?.experienceMode === 'pvp-stadium-match-half-level' + ? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.5) + : null const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3)) const newExperience = scaledReward ? scaledReward.experience diff --git a/src/profile.ts b/src/profile.ts index 5330225..0f997db 100644 --- a/src/profile.ts +++ b/src/profile.ts @@ -349,7 +349,7 @@ export async function completeRoguelike( durationSeconds: number, options?: { bossesCleared?: number - experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' + experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' | 'pvp-stadium-round-win-quarter-level' | 'pvp-stadium-round-loss-tenth-level' | 'pvp-stadium-match-half-level' fightsCleared?: number lootSourceEncounterId?: number roguelikeStage?: number diff --git a/src/pvpRoguelike.ts b/src/pvpRoguelike.ts index e18f505..8de9d93 100644 --- a/src/pvpRoguelike.ts +++ b/src/pvpRoguelike.ts @@ -1,8 +1,9 @@ import { requestGameApiJson } from './gameRepository' -export type PvpContentType = 'dungeon' | 'raid' +export type PvpContentType = 'dungeon' | 'raid' | 'stadium' export type CpuDifficulty = 1 | 2 | 3 | 4 | 5 export type PvpMatchSide = 'a' | 'b' +export type PvpMatchStatus = 'playing' | 'upgrade-choice' | 'shop' | 'won' | 'lost' export type PvpPlayerInfo = { side: PvpMatchSide @@ -16,6 +17,8 @@ export type PvpUpgradeChoicePayload = { encounterIndex: number buffId: string debuffId: string + purchases?: string[] + shopReady?: boolean } export type PvpMatchSnapshot = { @@ -25,7 +28,7 @@ export type PvpMatchSnapshot = { createdAt: number players: Record states: Partial> - statuses: Partial> + statuses: Partial> progress: Partial( matchId: string, payload: { state: TSideState - status: 'playing' | 'upgrade-choice' | 'won' | 'lost' + status: PvpMatchStatus stage: number encounterIndex: number encountersCleared: number