diff --git a/IWantToHeal-Thor-v1.1.0.apk b/IWantToHeal-Thor-v1.1.0.apk new file mode 100644 index 0000000..6d18657 Binary files /dev/null and b/IWantToHeal-Thor-v1.1.0.apk differ diff --git a/IWantToHeal-Thor-v1.1.2.apk b/IWantToHeal-Thor-v1.1.2.apk new file mode 100644 index 0000000..7f10384 Binary files /dev/null and b/IWantToHeal-Thor-v1.1.2.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 9996d3b..8f97013 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 79 - versionName "1.0.60" + versionCode 81 + versionName "1.1.2" 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/db/schema.sql b/db/schema.sql index da115d9..898e830 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -254,6 +254,61 @@ CREATE TABLE IF NOT EXISTS character_inventory ( PRIMARY KEY (character_id, item_id) ); +CREATE TABLE IF NOT EXISTS action_characters ( + id INTEGER PRIMARY KEY, + account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE, + class_id INTEGER NOT NULL REFERENCES classes(id), + name TEXT NOT NULL, + level INTEGER NOT NULL DEFAULT 1, + experience INTEGER NOT NULL DEFAULT 0, + bulldrome_coins INTEGER NOT NULL DEFAULT 0, + bulldrome_normal_clears INTEGER NOT NULL DEFAULT 0, + bulldrome_hard_clears INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (account_id, class_id) +); + +CREATE TABLE IF NOT EXISTS action_gear_items ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL, + name TEXT NOT NULL, + slot TEXT NOT NULL CHECK (slot IN ('weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket')), + item_level INTEGER NOT NULL CHECK (item_level BETWEEN 1 AND 5), + source TEXT NOT NULL DEFAULT 'bulldrome', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS action_character_inventory ( + id INTEGER PRIMARY KEY, + action_character_id INTEGER NOT NULL REFERENCES action_characters(id) ON DELETE CASCADE, + item_id INTEGER REFERENCES items(id), + action_gear_item_id INTEGER REFERENCES action_gear_items(id), + quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0), + equipped INTEGER NOT NULL DEFAULT 0 CHECK (equipped IN (0, 1)) +); + +CREATE TABLE IF NOT EXISTS action_dungeon_runs ( + id INTEGER PRIMARY KEY, + action_character_id INTEGER NOT NULL REFERENCES action_characters(id) ON DELETE CASCADE, + dungeon_id INTEGER NOT NULL REFERENCES dungeons(id), + difficulty TEXT NOT NULL CHECK (difficulty IN ('normal', 'hard')), + result TEXT NOT NULL CHECK (result IN ('clear', 'wipe')), + coins_awarded INTEGER NOT NULL DEFAULT 0, + duration_seconds INTEGER NOT NULL DEFAULT 0, + completed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS action_encounter_loot_rolls ( + id INTEGER PRIMARY KEY, + action_character_id INTEGER NOT NULL REFERENCES action_characters(id) ON DELETE CASCADE, + dungeon_run_id INTEGER REFERENCES action_dungeon_runs(id) ON DELETE CASCADE, + encounter_id INTEGER NOT NULL REFERENCES encounters(id), + item_id INTEGER NOT NULL REFERENCES items(id), + quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0), + difficulty TEXT NOT NULL CHECK (difficulty IN ('normal', 'hard')), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + CREATE TABLE IF NOT EXISTS encounter_loot_rolls ( id INTEGER PRIMARY KEY, character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, diff --git a/server/game-api.mjs b/server/game-api.mjs index 33f01b3..82d4ca1 100644 --- a/server/game-api.mjs +++ b/server/game-api.mjs @@ -10,6 +10,11 @@ import { isIP } from 'node:net' import { extname, resolve, sep } from 'node:path' import { DatabaseSync } from 'node:sqlite' import { catalogPayload } from './catalog.mjs' +import { + catchUpExperienceReward as catchUpExperienceRewardForTarget, + coinDropQuantity, + roguelikeCoinItemLevel, +} from '../src/shared/rewardRules.mjs' const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url)) const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url)) @@ -251,10 +256,7 @@ function catchUpExperienceReward(database, accountId, characterId, baseReward, c FROM level_progression WHERE level = ? `).get(targetLevel)?.experienceRequired ?? currentExperience - const gap = Math.max(0, targetExperience - currentExperience) - if (gap <= 0) return baseReward - const doubledBase = Math.min(baseReward, Math.ceil(gap / 2)) - return doubledBase * 2 + (baseReward - doubledBase) + return catchUpExperienceRewardForTarget(baseReward, currentExperience, targetExperience) } function normalizeUsername(value) { @@ -1345,17 +1347,6 @@ function formatLootRoll(database, context, record, dropChance) { } } -function coinDropQuantity() { - const roll = Math.random() - if (roll < 0.15) return 3 - if (roll < 0.5) return 2 - return 1 -} - -function roguelikeCoinItemLevel(stage) { - return Math.min(25, 5 + Math.max(0, Math.floor(stage / 5)) * 5) -} - function awardRoguelikeCoin(database, characterId, sourceEncounterId, stage) { if (!sourceEncounterId || !stage) return null const coin = database.prepare(` @@ -2309,13 +2300,15 @@ function completeRoguelike(database, characterId, accountId, runMetrics) { ? 'pvp-boss-quarter-level' : runMetrics?.experienceMode === 'pvp-fight-twelfth-level' ? 'pvp-fight-twelfth-level' - : 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' + : runMetrics?.experienceMode === 'pvp-match-win-half-level' + ? 'pvp-match-win-half-level' + : 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) @@ -2419,7 +2412,8 @@ function completeRoguelike(database, characterId, accountId, runMetrics) { `).get(newExperience).level } } else if ( - experienceMode === 'pvp-stadium-round-win-quarter-level' + experienceMode === 'pvp-match-win-half-level' + || experienceMode === 'pvp-stadium-round-win-quarter-level' || experienceMode === 'pvp-stadium-round-loss-tenth-level' || experienceMode === 'pvp-stadium-match-half-level' ) { diff --git a/src/App.css b/src/App.css index 902660b..a87abdd 100644 --- a/src/App.css +++ b/src/App.css @@ -626,6 +626,7 @@ textarea:focus-visible, .dual-top-member { background: var(--panel-light); border: 2px solid #0a0b0e; + contain: layout paint style; min-height: 120px; outline: 2px solid #3a3944; padding: 14px; @@ -983,6 +984,7 @@ textarea:focus-visible, .dual-opponent-member { background: var(--panel-light); border: 2px solid #0a0b0e; + contain: layout paint style; min-width: 0; outline: 2px solid #3a3944; padding: 9px; @@ -5292,6 +5294,7 @@ h2 { display: block; height: 100%; transition: width 180ms linear; + will-change: width; } .enemy-health { @@ -5367,6 +5370,7 @@ h2 { .party-member { background: var(--panel-light); border: 2px solid #0a0b0e; + contain: layout style; color: var(--ink); cursor: pointer; min-height: 92px; @@ -5565,6 +5569,7 @@ h2 { text-shadow: 1px 1px #102016; top: 28px; transform: translateX(-50%); + will-change: opacity, transform; white-space: nowrap; } @@ -5767,6 +5772,7 @@ h2 { .spell { background: var(--panel-light); border: 2px solid #090a0d; + contain: layout paint style; color: var(--ink); cursor: pointer; min-height: 112px; diff --git a/src/App.tsx b/src/App.tsx index 5152ede..59220e6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,6 @@ -import { useEffect, useState } from 'react' +import { lazy, Suspense, useEffect, useMemo, useState } from 'react' import './App.css' -import { CombatScreen } from './components/CombatScreen' 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 { loadCpuPvpLeaderboard, type CpuPvpLeaderboardEntry, @@ -28,6 +21,14 @@ import { } from './gameRepository' import { focusFirstControl } from './input.tsx' +const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen }))) +const CustomizeScreen = lazy(() => import('./components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen }))) +const EquipmentScreen = lazy(() => import('./components/EquipmentScreen').then((module) => ({ default: module.EquipmentScreen }))) +const PvPRoguelikeScreen = lazy(() => import('./components/PvpRoguelikeScreen').then((module) => ({ default: module.PvPRoguelikeScreen }))) +const PvpStadiumScreen = lazy(() => import('./components/PvpStadiumScreen').then((module) => ({ default: module.PvpStadiumScreen }))) +const TalentScreen = lazy(() => import('./components/TalentScreen').then((module) => ({ default: module.TalentScreen }))) +const SettingsScreen = lazy(() => import('./components/SettingsScreen').then((module) => ({ default: module.SettingsScreen }))) + type Screen = | 'menu' | 'dungeons' @@ -67,6 +68,17 @@ function activityInitials(name: string) { .join('') } +function ScreenLoading() { + return ( +
+
+

Opening Chronicle

+

Loading...

+
+
+ ) +} + type RoguelikeUpgradeTiming = 'boss' | 'encounter' type RoguelikeVariant = 'pve' | 'pvp' type RoguelikeAbilityLabelMode = 'ability' | 'slot' @@ -148,9 +160,138 @@ function App() { const [cpuLeaderboard, setCpuLeaderboard] = useState([]) useEffect(() => { - setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType)) + const frame = window.requestAnimationFrame(() => { + setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType)) + }) + return () => window.cancelAnimationFrame(frame) }, [pvpContentType, screen, roguelikeVariant]) + const profileDungeons = profile?.dungeons + const dungeonOptions = useMemo( + () => profileDungeons?.filter((candidate) => candidate.contentType === 'dungeon') ?? [], + [profileDungeons], + ) + const raidOptions = useMemo( + () => profileDungeons?.filter((candidate) => candidate.contentType === 'raid') ?? [], + [profileDungeons], + ) + const selectedDungeonOption = useMemo( + () => dungeonOptions.find((candidate) => candidate.id === selectedDungeonId) ?? dungeonOptions[0], + [dungeonOptions, selectedDungeonId], + ) + const selectedRaidOption = useMemo( + () => raidOptions.find((candidate) => candidate.id === selectedRaidId) ?? raidOptions[0], + [raidOptions, selectedRaidId], + ) + const activityOptions = useMemo( + () => screen === 'raids' ? raidOptions : dungeonOptions, + [dungeonOptions, raidOptions, screen], + ) + const combatDungeonOption = useMemo( + () => { + if (!profileDungeons) return undefined + return combatContentId < 0 + ? profileDungeons.find((candidate) => candidate.contentType === roguelikeKind) ?? profileDungeons[0] + : profileDungeons.find((candidate) => candidate.id === combatContentId) ?? profileDungeons[0] + }, + [combatContentId, profileDungeons, roguelikeKind], + ) + const combatDifficultyOption = useMemo( + () => combatDungeonOption?.difficulties.find( + (candidate) => candidate.id === selectedDifficultyId, + ) ?? combatDungeonOption?.difficulties[0], + [combatDungeonOption, selectedDifficultyId], + ) + const roguelikePool = useMemo( + () => profileDungeons + ?.filter((candidate) => candidate.contentType === roguelikeKind) + .flatMap((candidate) => candidate.encounters) ?? [], + [profileDungeons, roguelikeKind], + ) + const pvpPool = useMemo( + () => profileDungeons + ?.filter((candidate) => candidate.contentType === pvpContentType) + .flatMap((candidate) => candidate.encounters) ?? [], + [profileDungeons, pvpContentType], + ) + const tierOptions = useMemo( + () => activityOptions + .flatMap((option) => option.difficulties) + .filter((difficulty, index, all) => ( + all.findIndex((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel) === index + )) + .sort((a, b) => a.droppedItemLevel - b.droppedItemLevel), + [activityOptions], + ) + const savedDifficulty = useMemo( + () => profileDungeons + ?.flatMap((option) => option.difficulties) + .find((candidate) => candidate.id === selectedDifficultyId), + [profileDungeons, selectedDifficultyId], + ) + const selectedTier = useMemo( + () => { + const characterLevel = profile?.character.level ?? 0 + const savedTier = tierOptions.find((candidate) => ( + candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel + && characterLevel >= candidate.unlockLevel + )) + if (savedTier) return savedTier + for (let index = tierOptions.length - 1; index >= 0; index -= 1) { + if (characterLevel >= tierOptions[index].unlockLevel) return tierOptions[index] + } + return tierOptions[0] + }, + [profile?.character.level, savedDifficulty?.droppedItemLevel, tierOptions], + ) + const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0 + const activityPageCount = Math.max(1, Math.ceil(activityOptions.length / ACTIVITY_PAGE_SIZE)) + const currentActivityPage = Math.min(activityPage, activityPageCount - 1) + const pagedActivityOptions = useMemo( + () => activityOptions.slice( + currentActivityPage * ACTIVITY_PAGE_SIZE, + currentActivityPage * ACTIVITY_PAGE_SIZE + ACTIVITY_PAGE_SIZE, + ), + [activityOptions, currentActivityPage], + ) + const selectedActivityOption = useMemo( + () => { + const fallback = screen === 'raids' && selectedRaidOption ? selectedRaidOption : selectedDungeonOption + const selectedActivityId = screen === 'raids' && selectedRaidOption ? selectedRaidOption.id : selectedDungeonOption?.id + return activityOptions.find((candidate) => candidate.id === selectedActivityId) + ?? activityOptions[0] + ?? fallback + }, + [activityOptions, screen, selectedDungeonOption, selectedRaidOption], + ) + const selectedDifficultyOption = useMemo( + () => selectedActivityOption?.difficulties.find( + (candidate) => candidate.droppedItemLevel === selectedTierItemLevel, + ) ?? selectedActivityOption?.difficulties[0], + [selectedActivityOption, selectedTierItemLevel], + ) + const lootPreviewEncounters = useMemo( + () => [...(selectedActivityOption?.encounters ?? [])] + .filter((encounter) => encounter.isBoss) + .sort((a, b) => lootSort === 'boss' + ? a.enemyName.localeCompare(b.enemyName) || a.sequence - b.sequence + : a.sequence - b.sequence), + [lootSort, selectedActivityOption?.encounters], + ) + const lootPreviewByEncounterId = useMemo( + () => new Map(lootPreviewEncounters.map((encounter) => [ + encounter.id, + encounter.lootTables.filter((entry) => entry.difficultyId === selectedDifficultyOption?.id), + ])), + [lootPreviewEncounters, selectedDifficultyOption?.id], + ) + const leaderboardEntries = useMemo( + () => selectedActivityOption?.leaderboards[leaderboardCategory].filter( + (entry) => entry.difficultyId === selectedDifficultyOption?.id, + ) ?? [], + [leaderboardCategory, selectedActivityOption?.leaderboards, selectedDifficultyOption?.id], + ) + function acceptSession(session: AuthSession) { setAccount(session.account) setProfile(session.profile) @@ -224,65 +365,61 @@ function App() { } if (screen === 'combat') { - const dungeon = combatContentId < 0 - ? profile.dungeons.find((candidate) => candidate.contentType === roguelikeKind) ?? profile.dungeons[0] - : profile.dungeons.find((candidate) => candidate.id === combatContentId) ?? profile.dungeons[0] - const difficulty = dungeon.difficulties.find( - (candidate) => candidate.id === selectedDifficultyId, - ) ?? dungeon.difficulties[0] - const roguelikePool = profile.dungeons - .filter((candidate) => candidate.contentType === roguelikeKind) - .flatMap((candidate) => candidate.encounters) + const dungeon = combatDungeonOption ?? profile.dungeons[0] + const difficulty = combatDifficultyOption ?? dungeon.difficulties[0] return ( - 0} - profile={profile} - roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined} - roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined} - roguelikeAbilityLabelMode={combatContentId < 0 ? roguelikeAbilityLabelMode : undefined} - roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined} - startPart={1} - onExit={() => { - setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons') - }} - onProfileUpdated={setProfile} - /> + }> + 0} + profile={profile} + roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined} + roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined} + roguelikeAbilityLabelMode={combatContentId < 0 ? roguelikeAbilityLabelMode : undefined} + roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined} + startPart={1} + onExit={() => { + setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons') + }} + onProfileUpdated={setProfile} + /> + ) } if (screen === 'pvp') { if (pvpContentType === 'stadium') { return ( - }> + { + setRoguelikeVariant('pvp') + setScreen('roguelike') + }} + onProfileUpdated={setProfile} + profile={profile} + /> + + ) + } + return ( + }> + { + setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType)) setRoguelikeVariant('pvp') setScreen('roguelike') }} onProfileUpdated={setProfile} profile={profile} /> - ) - } - const pvpPool = profile.dungeons - .filter((candidate) => candidate.contentType === pvpContentType) - .flatMap((candidate) => candidate.encounters) - return ( - { - setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType)) - setRoguelikeVariant('pvp') - setScreen('roguelike') - }} - onProfileUpdated={setProfile} - profile={profile} - /> + ) } @@ -293,13 +430,7 @@ function App() { const experiencePercent = profile.character.level >= profile.maxLevel ? 100 : Math.min(100, (experienceIntoLevel / experienceForLevel) * 100) - const dungeonOptions = profile.dungeons.filter((candidate) => candidate.contentType === 'dungeon') - const raidOptions = profile.dungeons.filter((candidate) => candidate.contentType === 'raid') - const dungeon = dungeonOptions.find((candidate) => candidate.id === selectedDungeonId) - ?? dungeonOptions[0]! - const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId) - ?? raidOptions[0] - const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions + const dungeon = selectedDungeonOption ?? profile.dungeons[0]! const startPveRoguelike = () => { const baseDungeon = dungeonOptions[0] const baseRaid = raidOptions[0] @@ -313,47 +444,15 @@ function App() { setSelectedMarathonMode(false) setScreen('combat') } - const tierOptions = activityOptions - .flatMap((option) => option.difficulties) - .filter((difficulty, index, all) => ( - all.findIndex((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel) === index - )) - .sort((a, b) => a.droppedItemLevel - b.droppedItemLevel) - const savedDifficulty = profile.dungeons - .flatMap((option) => option.difficulties) - .find((candidate) => candidate.id === selectedDifficultyId) - const selectedTier = tierOptions.find((candidate) => ( - candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel - && profile.character.level >= candidate.unlockLevel - )) - ?? tierOptions.slice().reverse().find((candidate) => profile.character.level >= candidate.unlockLevel) - ?? tierOptions[0] - const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0 - const activityPageCount = Math.max(1, Math.ceil(activityOptions.length / ACTIVITY_PAGE_SIZE)) - const currentActivityPage = Math.min(activityPage, activityPageCount - 1) - const pagedActivityOptions = activityOptions.slice( - currentActivityPage * ACTIVITY_PAGE_SIZE, - currentActivityPage * ACTIVITY_PAGE_SIZE + ACTIVITY_PAGE_SIZE, - ) const activityPageStart = activityOptions.length === 0 ? 0 : currentActivityPage * ACTIVITY_PAGE_SIZE + 1 const activityPageEnd = Math.min(activityOptions.length, (currentActivityPage + 1) * ACTIVITY_PAGE_SIZE) - const selectedActivityId = screen === 'raids' && raid ? raid.id : dungeon.id - const activity = activityOptions.find((candidate) => candidate.id === selectedActivityId) - ?? activityOptions[0] - ?? (screen === 'raids' && raid ? raid : dungeon) - const selectedDifficulty = activity.difficulties.find( - (candidate) => candidate.droppedItemLevel === selectedTierItemLevel, - ) ?? activity.difficulties[0] + const activity = selectedActivityOption ?? dungeon + const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0] const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel const cloudSync = getCloudSyncStatus() const canShowCloudSync = account.id !== -1 && cloudSync.available - const lootPreviewEncounters = [...activity.encounters] - .filter((encounter) => encounter.isBoss) - .sort((a, b) => lootSort === 'boss' - ? a.enemyName.localeCompare(b.enemyName) || a.sequence - b.sequence - : a.sequence - b.sequence) return (
@@ -851,9 +950,7 @@ function App() {

{lootPreviewEncounters.map((encounter) => { - const loot = encounter.lootTables.filter( - (entry) => entry.difficultyId === selectedDifficulty.id, - ) + const loot = lootPreviewByEncounterId.get(encounter.id) ?? [] return (
@@ -937,8 +1034,7 @@ function App() { Resource Time
- {activity.leaderboards[leaderboardCategory] - .filter((entry) => entry.difficultyId === selectedDifficulty.id) + {leaderboardEntries .map((entry) => (
#{entry.rank} @@ -950,9 +1046,7 @@ function App() { {entry.durationSeconds}s
))} - {activity.leaderboards[leaderboardCategory].filter( - (entry) => entry.difficultyId === selectedDifficulty.id, - ).length === 0 && ( + {leaderboardEntries.length === 0 && (
{gameMode === 'offline' ? 'Connect with an online character to compete in rankings.' @@ -971,33 +1065,35 @@ function App() { )} - {screen === 'customize' && ( - setScreen('menu')} - onSaved={setProfile} - /> - )} + }> + {screen === 'customize' && ( + setScreen('menu')} + onSaved={setProfile} + /> + )} - {screen === 'talents' && ( - setScreen('menu')} - onUpdated={setProfile} - /> - )} + {screen === 'talents' && ( + setScreen('menu')} + onUpdated={setProfile} + /> + )} - {screen === 'equipment' && ( - setScreen('menu')} - onUpdated={setProfile} - /> - )} + {screen === 'equipment' && ( + setScreen('menu')} + onUpdated={setProfile} + /> + )} - {screen === 'settings' && ( - setScreen('menu')} /> - )} + {screen === 'settings' && ( + setScreen('menu')} /> + )} +
) diff --git a/src/combat/README.md b/src/combat/README.md new file mode 100644 index 0000000..a5a4455 --- /dev/null +++ b/src/combat/README.md @@ -0,0 +1,116 @@ +# Combat Modules + +Shared combat code is split by responsibility: + +- `spellEffects.ts` owns spell target plans and the shared spell-effect applier. +- `combatEngine.ts` owns repeated per-member tick math: damage reduction, shields, HoTs, poison, debuff timers, and bounce-heal jumps. +- `combatPresentation.ts` owns UI-facing caps and grouping for combat logs and floating combat text. +- Screen files provide `SpellEffectProfile` objects for mode-specific numbers. +- `spellCasting.ts` owns cast readiness and cooldown writes. +- `pvpSpellCasting.ts` owns the shared PvP cast pipeline: readiness validation, live target validation, effect application, floating-heal emission, and side-state writes. +- `combatTick.ts` owns small tick helpers such as resource regeneration and cooldown decay. +- `cpuAi.ts` owns CPU healer timing, mistake rolls, spell choice, and turn execution. +- `stackCounts.ts` owns stacked buff/upgrade count maps and summaries. +- `spellModifiers.ts` owns stack-based spell modifiers such as cost, cooldown, extra targets, free casts, and power multipliers. +- `combatStateTransitions.ts` owns shared combat state writes such as applying a completed cast to party, resource, cooldown, and free-cast fields. +- `stadiumLifecycle.ts` owns pure Stadium round/shop decisions: round outcome resolution, shop point awards, and CPU purchase selection. +- `pvpRoguelikeLifecycle.ts` owns pure PvP roguelike lifecycle decisions: live snapshot outcomes, combat tick outcomes, upgrade-complete checks, and next-stage progression. +- `pvpRoguelikeUpgrades.ts` owns PvP roguelike upgrade application, revive cleanup, party recovery, resource restore, cooldown reset, and next enemy health setup. +- `pvpRoguelikeMatchSetup.ts` owns PvP roguelike match-start side creation, first segment setup, live opponent naming, and shared reset defaults. +- `stadiumMatchSetup.ts` owns Stadium starter side creation, local/live match setup, live opponent naming, and shared reset defaults. +- `pveRoguelikeRunSetup.ts` owns PvE combat-state creation and roguelike run reset defaults. +- `rewardSummaries.ts` owns reward summary initialization, unlocked ability merging, XP/level/talent accumulation, and PvP roguelike boss loot accumulation. +- `dualScreenPayloads.ts` owns dual-screen combat payload construction and mode-specific status normalization. +- `pvpLiveLifecycle.ts` owns live PvP polling, local progress publication, and rematch request state. + +## SpellEffectProfile + +Use a profile when spell behavior is the same shape but different by mode. + +Examples: + +- PvE stores Renew-style effects in `hotEffects`; PvP modes mostly use `hotTicks`. +- Stadium applies dampening through profile power callbacks. +- PvE cleanse intentionally does not emit floating heal text; PvP modes do. +- Shield ratios such as Radiance `0.3`, Mend `0.5`, and Stadium group shield `0.5` belong in profiles. + +When adding a spell rule, prefer adding a named profile field or callback instead of branching in screen components. + +## StackCounts + +Use `createStackCounts` once per party state, upgrade list, or cast snapshot when repeated math needs stack totals. Then pass the map to spell cost, cooldown, target, and effect helpers. This keeps stacked upgrade behavior consistent and avoids repeated `filter`/scan work in combat render and cast paths. + +## Spell Modifiers + +Use `spellModifiers.ts` for stacked upgrade math instead of open-coded exponent formulas in screen components. Screens still own mode-specific IDs, but shared helpers own the math: + +- slot cost/cooldown reducers default to `0.75 ** stacks`. +- opponent slot penalties default to `1.25 ** stacks`. +- power boosts default to `1.25 ** stacks`. +- free-cast checks use a named stack modifier and the mode's existing `freeCastReady` rules. + +## State Transitions + +Use `applyCastStateUpdate` after a spell effect profile returns the next party. Screens can still prepare mode-specific cooldown adjustments before the spell cooldown lands, but the final resource spend, cooldown write, party replacement, and free-cast progress should go through the transition helper. + +## PvP Spell Casting + +Use `applyPvpSpellCast` after a PvP screen builds its mode-specific target plan and `SpellEffectProfile`. Keep balance inputs such as roguelike effects, Stadium dampening, shop buffs, cooldown tweaks, and free-cast flags in the screen; let the helper own the repeated validate/apply/emit/write pipeline. + +## CPU AI + +Use `runCpuHealTurn` for PvP CPU turns. Screens still own mode-specific behavior tables and apply-spell adapters, while `cpuAi.ts` owns timing checks, mistake rolls, action selection, and invoking the selected spell. + +## Stadium Lifecycle + +Use `resolveStadiumRound` for win/loss/tie decisions instead of open-coding score checks in `PvpStadiumScreen`. Use `stadiumShopPointsForOutcome` and `chooseStadiumCpuPurchases` for shop setup so player and CPU point rules stay in one place. + +## PvP Roguelike Lifecycle + +Use `resolvePvpRoguelikeCombatOutcome` after each tick advances both sides. Use `resolvePvpRoguelikeLiveSnapshot` when a live match snapshot arrives. Use `nextPvpRoguelikeStage` after upgrade choices to decide whether to continue, advance stage, or complete the match. Components should execute the returned intent with React setters instead of duplicating the decision tree. + +## PvP Roguelike Upgrades + +Use `applyPvpRoguelikeUpgradeChoice` to apply selected buffs, incoming debuffs, and revive-buff debuff cleanup. Use `preparePvpRoguelikeNextEncounter` after progression chooses the next encounter so recovery, 25% resource restore, cooldown reset, and enemy health setup stay consistent between live and CPU matches. + +## PvP Roguelike Match Setup + +Use `createPvpRoguelikeMatchStart` for local/CPU starts and `createPvpRoguelikeLiveMatchStart` for live matches. These helpers build starter sides, set first encounter health, name live opponents, and provide reset defaults so rematch, checkpoint start, and queue fallback do not duplicate initial state construction. + +## Stadium Match Setup + +Use `createStadiumMatchStart` for queue/CPU starts, `createStadiumLiveMatchStart` for live matches, and `createStadiumStarterSide` for advancing to the next round. These helpers reset party state, carry purchased buffs and round wins where needed, and provide shared reset defaults for queue, rematch, and live match starts. + +## PvE Run Setup + +Use `createPveCombatState` for initial single-player combat state and `createPveRoguelikeRunStart` when resetting a run. This keeps party cloning, resource setup, enemy health, cooldown/free-cast reset, and UI reset defaults in one place. + +## Reward Summaries + +Use `createEmptyRewardSummary`, `createEmptyPvpRunSummary`, `mergeDungeonRewardSummary`, and `mergePvpRunRewardSummary` instead of open-coding reward accumulation in screens. This keeps XP, level, talent points, unlocked abilities, and boss loot merge behavior consistent. + +Presentation components for these summaries live in `components/RewardPanels.tsx`: use `RewardXpSummary`, `BonusItemReward`, `LootRollList`, and `PvpRunLootList` instead of duplicating reward JSX in result screens. + +## Dual-Screen Payloads + +Use the builders in `dualScreenPayloads.ts` when publishing combat state to the secondary display. Keep render-derived arrays such as stripped floating text and opponent summaries memoized before calling the builder, then let the builder normalize mode status values like queueing, countdown, and Stadium shop into the bottom-screen status model. + +## Live PvP Sync + +Use `usePvpLiveMatchSync` for live match polling and rematch controls. Screens should provide only mode-specific progress payloads and opponent snapshot rules; the hook owns publish/poll intervals, rematch pending state, expiry, and error messages. + +## Timers + +Use `useRoundCountdown` for PvP round starts and `useDeadlineTimer` for mode deadlines such as PvP roguelike upgrade choice and Stadium shop. Keep countdown expiry behavior mode-specific, but avoid open-coded interval refs and `Date.now()` loops in screens. + +## Floating Combat Text + +Use `useFloatingCombatText` for single-sided combat screens and `useSidedFloatingCombatText` for PvP screens. The hooks own IDs, expiry cleanup, member grouping, side filtering, and dual-screen text stripping so screens only emit heal events. + +## Party Targeting + +Use `usePartyTargeting` for combat-screen navigation wrappers. Keep raw grid/direct selection rules in `combat/targeting.ts`; the hook owns relative, directional, and direct target callbacks around the current party ref, selected-id ref, grid columns, living-target flags, and target group. + +## Render Allocation + +Keep repeated combat-render derivations memoized near their source state: active input bindings, stack summaries, visible shop choices, enemy health segments, and stripped floating text payloads. Avoid calling summary helpers or `bindings[lastDevice]` repeatedly inside JSX loops. diff --git a/src/combat/combatEngine.ts b/src/combat/combatEngine.ts new file mode 100644 index 0000000..b2f9750 --- /dev/null +++ b/src/combat/combatEngine.ts @@ -0,0 +1,170 @@ +import type { PartyMember } from '../game' +import { + clamp, + healAmount, + memberHotEffects, + tickHotEffects, +} from './rules' + +export type MemberTickInput = { + member: PartyMember + party: PartyMember[] + damage: number + hotHealing: number + hotTicks: 'effects' | 'ticks' + healingMultiplier?: number + damageReductionMultiplier?: number + damageReductionRounding?: 'round' | 'ceil' + shieldedDamageMultiplier?: number + applyDebuff?: { + label: string + ticks: number + } + applyPoisonStacks?: boolean + poisonDamage?: (stacks: number) => number + applyMaxHealthPenaltyTicks?: number + applyHealingReductionTicks?: number + decrementDamageReduction?: boolean + useBounceHeals?: boolean + jumpTarget?: () => PartyMember | undefined +} + +export type MemberTickResult = { + member: PartyMember + floatingHeal: number + jumpedBounceHeals: Array<{ + targetId: string + heal: NonNullable[number] + }> +} + +/** + * Applies one combat tick to a party member. Screen-level engines still decide + * who is targeted and when fights end; this helper owns repeated health math: + * damage reduction, shields, HoT healing, debuff timers, poison, and bounce heals. + */ +export function advanceMemberTick({ + member, + party, + damage, + hotHealing, + hotTicks, + healingMultiplier = 1, + damageReductionMultiplier = 0.5, + damageReductionRounding = 'round', + shieldedDamageMultiplier, + applyDebuff, + applyPoisonStacks = false, + poisonDamage = () => 0, + applyMaxHealthPenaltyTicks, + applyHealingReductionTicks, + decrementDamageReduction = false, + useBounceHeals = false, + jumpTarget, +}: MemberTickInput): MemberTickResult { + if (member.health <= 0) { + return { member, floatingHeal: 0, jumpedBounceHeals: [] } + } + + let nextDamage = damage + const nextPoisonStacks = applyPoisonStacks + ? Math.max(1, (member.poisonStacks ?? 0) + 1) + : member.poisonStacks ?? 0 + if (nextPoisonStacks > 0) nextDamage += poisonDamage(nextPoisonStacks) + + if ((member.damageReductionTicks ?? 0) > 0) { + const reducedDamage = nextDamage * damageReductionMultiplier + nextDamage = damageReductionRounding === 'ceil' + ? Math.ceil(reducedDamage) + : Math.round(reducedDamage) + } + if (member.shield > 0 && shieldedDamageMultiplier !== undefined) { + nextDamage = Math.round(nextDamage * shieldedDamageMultiplier) + } + + const absorbed = Math.min(member.shield, nextDamage) + const hotEffects = hotTicks === 'effects' ? memberHotEffects(member) : [] + let healing = hotTicks === 'effects' + ? hotEffects.reduce((total, effect) => total + healAmount(member, effect.power, healingMultiplier), 0) + : member.hotTicks > 0 ? healAmount(member, hotHealing, healingMultiplier) : 0 + + let nextBounceHeals = [...(member.bounceHeals ?? [])] + const jumpedBounceHeals: MemberTickResult['jumpedBounceHeals'] = [] + if (useBounceHeals && nextDamage > 0 && nextBounceHeals.length > 0) { + nextBounceHeals = nextBounceHeals.flatMap((effect) => { + healing += healAmount(member, effect.power, healingMultiplier) + const nextCharges = effect.charges - 1 + if (nextCharges <= 0) return [] + const target = jumpTarget?.() ?? party.find((candidate) => candidate.health > 0 && candidate.id !== member.id) ?? member + jumpedBounceHeals.push({ + targetId: target.id, + heal: { ...effect, charges: nextCharges }, + }) + return [] + }) + } + + const nextMaxHealthPenaltyTicks = applyMaxHealthPenaltyTicks !== undefined + ? applyMaxHealthPenaltyTicks + : Math.max(0, (member.maxHealthPenaltyTicks ?? 0) - 1) + const nextHealingReductionTicks = applyHealingReductionTicks !== undefined + ? applyHealingReductionTicks + : Math.max(0, (member.healingReductionTicks ?? 0) - 1) + const nextDebuffTicks = applyDebuff + ? applyDebuff.ticks + : Math.max(0, (member.debuffTicks ?? 0) - 1) + const nextEffectiveMaxHealth = Math.max(1, Math.round( + member.maxHealth * (nextMaxHealthPenaltyTicks > 0 ? 0.75 : 1), + )) + + return { + member: { + ...member, + health: clamp( + clamp(member.health + healing, 0, nextEffectiveMaxHealth) - nextDamage + absorbed, + 0, + nextEffectiveMaxHealth, + ), + shield: Math.max(0, member.shield - nextDamage), + hotTicks: hotTicks === 'effects' ? 0 : Math.max(0, member.hotTicks - 1), + hotEffects: hotTicks === 'effects' ? tickHotEffects(hotEffects) : member.hotEffects, + bounceHeals: useBounceHeals ? nextBounceHeals : member.bounceHeals, + damageReductionTicks: decrementDamageReduction + ? Math.max(0, (member.damageReductionTicks ?? 0) - 1) + : member.damageReductionTicks, + debuff: nextDebuffTicks > 0 + ? applyDebuff?.label ?? member.debuff + : undefined, + debuffTicks: nextDebuffTicks > 0 ? nextDebuffTicks : undefined, + poisonStacks: nextPoisonStacks, + maxHealthPenaltyTicks: nextMaxHealthPenaltyTicks, + healingReductionTicks: nextHealingReductionTicks, + }, + floatingHeal: healing, + jumpedBounceHeals, + } +} + +export function attachJumpedBounceHeals( + party: PartyMember[], + jumpedBounceHeals: MemberTickResult['jumpedBounceHeals'], +) { + if (jumpedBounceHeals.length === 0) return party + const jumpedByTarget = new Map>() + for (const jump of jumpedBounceHeals) { + const current = jumpedByTarget.get(jump.targetId) + if (current) current.push(jump.heal) + else jumpedByTarget.set(jump.targetId, [jump.heal]) + } + return party.map((member) => { + const jumped = jumpedByTarget.get(member.id) + if (!jumped || jumped.length === 0) return member + return { + ...member, + bounceHeals: [ + ...(member.bounceHeals ?? []), + ...jumped, + ], + } + }) +} diff --git a/src/combat/combatPresentation.ts b/src/combat/combatPresentation.ts new file mode 100644 index 0000000..3ab8cda --- /dev/null +++ b/src/combat/combatPresentation.ts @@ -0,0 +1,41 @@ +import type { CombatLogEntry } from '../game' + +export type BasicFloatingCombatText = { + id: number + memberId: string + value: number +} + +export const DEFAULT_COMBAT_LOG_LIMIT = 60 +export const STADIUM_COMBAT_LOG_LIMIT = 70 +export const DEFAULT_FLOATING_TEXT_LIMIT = 48 + +export function appendCombatLog( + current: CombatLogEntry[], + entry: CombatLogEntry, + limit = DEFAULT_COMBAT_LOG_LIMIT, +) { + return [entry, ...current].slice(0, limit) +} + +export function appendFloatingText( + current: T[], + entry: T, + limit = DEFAULT_FLOATING_TEXT_LIMIT, +) { + return [...current, entry].slice(-limit) +} + +export function groupFloatingTextsByMember(texts: T[]) { + const groups = new Map() + texts.forEach((entry) => { + const current = groups.get(entry.memberId) + if (current) current.push(entry) + else groups.set(entry.memberId, [entry]) + }) + return groups +} + +export function stripFloatingTextSide(texts: T[]): BasicFloatingCombatText[] { + return texts.map(({ id, memberId, value }) => ({ id, memberId, value })) +} diff --git a/src/combat/combatStateTransitions.ts b/src/combat/combatStateTransitions.ts new file mode 100644 index 0000000..c815b2a --- /dev/null +++ b/src/combat/combatStateTransitions.ts @@ -0,0 +1,50 @@ +import type { PartyMember, Spell } from '../game' +import { putSpellOnCooldown } from './spellCasting' +import { advanceFreeCastProgress } from './spellEffects' + +export type CastStateFields = { + party: TParty + resource: number + cooldowns: Record + castsTowardFree: number + freeCastReady: boolean +} + +export function applyCastStateUpdate< + TParty extends readonly PartyMember[], + TState extends CastStateFields, +>({ + current, + party, + spell, + resourceCost, + cooldownMultiplier = 1, + cooldowns = current.cooldowns, + freeCast = { enabled: false, wasReady: false }, +}: { + current: TState + party: TParty + spell: Spell + resourceCost: number + cooldownMultiplier?: number + cooldowns?: Record + freeCast?: { + enabled: boolean + wasReady: boolean + } +}) { + const freeCastProgress = advanceFreeCastProgress({ + enabled: freeCast.enabled, + wasReady: freeCast.wasReady, + castsTowardFree: current.castsTowardFree, + }) + + return { + ...current, + party, + resource: current.resource - resourceCost, + cooldowns: putSpellOnCooldown(cooldowns, spell, cooldownMultiplier), + castsTowardFree: freeCastProgress.castsTowardFree, + freeCastReady: freeCastProgress.freeCastReady, + } +} diff --git a/src/combat/combatTick.ts b/src/combat/combatTick.ts new file mode 100644 index 0000000..047c8a4 --- /dev/null +++ b/src/combat/combatTick.ts @@ -0,0 +1,17 @@ +import { clamp } from './rules' + +export function tickSeconds(tickMs: number) { + return tickMs / 1000 +} + +export function advanceCooldowns(cooldowns: Record, elapsedSeconds: number) { + const nextCooldowns: Record = {} + for (const id in cooldowns) { + nextCooldowns[id] = Math.max(0, cooldowns[id] - elapsedSeconds) + } + return nextCooldowns +} + +export function regenerateResource(resource: number, amount: number, maxResource: number) { + return clamp(resource + amount, 0, maxResource) +} diff --git a/src/combat/cpuAi.ts b/src/combat/cpuAi.ts new file mode 100644 index 0000000..0e06f89 --- /dev/null +++ b/src/combat/cpuAi.ts @@ -0,0 +1,112 @@ +import type { PartyMember, Spell } from '../game' +import { effectiveMaxHealth } from './rules' + +export type CpuHealBehavior = { + directHealThreshold: number + groupHealThreshold: number + hotThreshold: number + shieldThreshold: number +} + +export type CpuTurnBehavior = CpuHealBehavior & { + actionEveryTicks: number + mistakeChance: number +} + +export function shouldCpuAct({ + elapsedTicks, + behavior, +}: { + elapsedTicks: number + behavior: CpuTurnBehavior +}) { + return elapsedTicks % behavior.actionEveryTicks === 0 && Math.random() >= behavior.mistakeChance +} + +export function chooseCpuHealActions({ + party, + spells, + behavior, + preferSlots = false, +}: { + party: PartyMember[] + spells: Spell[] + behavior: CpuHealBehavior + preferSlots?: boolean +}) { + let livingCount = 0 + let healthRatioTotal = 0 + let lowest: PartyMember | null = null + let tank: PartyMember | null = null + let cleanseTarget: PartyMember | null = null + let renewTarget: PartyMember | null = null + let shieldTarget: PartyMember | null = null + let woundedCount = 0 + + for (const member of party) { + if (member.health <= 0) continue + livingCount += 1 + const ratio = member.health / effectiveMaxHealth(member) + healthRatioTotal += ratio + if (!lowest || ratio < lowest.health / effectiveMaxHealth(lowest)) lowest = member + if (!tank && member.role === 'Tank') tank = member + if (!cleanseTarget && (member.debuff || (member.poisonStacks ?? 0) > 0)) cleanseTarget = member + if (!renewTarget && member.hotTicks <= 1 && ratio < behavior.hotThreshold) renewTarget = member + if (!shieldTarget && member.role === 'Tank' && member.shield <= 5 && ratio < behavior.shieldThreshold) { + shieldTarget = member + } + if (ratio < behavior.directHealThreshold) woundedCount += 1 + } + + if (!lowest || livingCount === 0) return [] + const averageHealth = healthRatioTotal / livingCount + const spellByKind = (kind: Spell['kind']) => spells.find((candidate) => candidate.kind === kind) + const spellBySlot = (slot: string) => spells.find((candidate) => candidate.key === slot) + const direct = preferSlots ? spellBySlot('1') : spellByKind('direct') + const hot = preferSlots ? spellBySlot('2') : spellByKind('hot') + const group = preferSlots ? spellBySlot('3') : spellByKind('group') + const shield = preferSlots ? spellBySlot('4') : spellByKind('shield') + const cleanse = preferSlots ? spellBySlot('5') : spellByKind('cleanse') + const ordered: Array<{ spell: Spell | undefined; targetId: string | null }> = [ + { spell: cleanseTarget ? cleanse : undefined, targetId: cleanseTarget?.id ?? null }, + { spell: averageHealth < behavior.groupHealThreshold ? group : undefined, targetId: lowest.id }, + { spell: shieldTarget ? shield : undefined, targetId: shieldTarget?.id ?? null }, + { spell: woundedCount > 0 ? direct : undefined, targetId: lowest.id }, + { spell: renewTarget ? hot : undefined, targetId: renewTarget?.id ?? null }, + { spell: tank ? direct : undefined, targetId: tank?.id ?? null }, + ] + + return ordered + .filter((action): action is { spell: Spell; targetId: string } => Boolean(action.spell && action.targetId)) +} + +export function chooseCpuHealAction(options: Parameters[0]) { + return chooseCpuHealActions(options)[0] ?? null +} + +export function runCpuHealTurn({ + side, + elapsedTicks, + spells, + behavior, + preferSlots = false, + applySpell, +}: { + side: TSide & { party: PartyMember[] } + elapsedTicks: number + spells: Spell[] + behavior: CpuTurnBehavior + preferSlots?: boolean + applySpell: (side: TSide, spell: Spell, targetId: string) => void +}) { + if (!shouldCpuAct({ elapsedTicks, behavior })) return false + const action = chooseCpuHealAction({ + party: side.party, + spells, + behavior, + preferSlots, + }) + if (!action) return false + applySpell(side, action.spell, action.targetId) + return true +} diff --git a/src/combat/dualScreenPayloads.ts b/src/combat/dualScreenPayloads.ts new file mode 100644 index 0000000..3ad72ab --- /dev/null +++ b/src/combat/dualScreenPayloads.ts @@ -0,0 +1,35 @@ +import type { DualScreenCombatState } from '../dualScreen' + +export function buildCombatDualScreenState(state: DualScreenCombatState): DualScreenCombatState { + return state +} + +export function buildPvpRoguelikeDualScreenState({ + status, + ...state +}: Omit & { + status: DualScreenCombatState['status'] | 'queueing' | 'round-countdown' +}): DualScreenCombatState { + return { + ...state, + status: status === 'queueing' || status === 'round-countdown' ? 'playing' : status, + } +} + +export function buildStadiumDualScreenState({ + status, + ...state +}: Omit & { + status: DualScreenCombatState['status'] | 'queueing' | 'round-countdown' | 'shop' +}): DualScreenCombatState { + return { + ...state, + status: status === 'queueing' || status === 'round-countdown' + ? 'playing' + : status === 'shop' + ? 'upgrade-choice' + : status, + targetGroup: 0, + speedMultiplier: 1, + } +} diff --git a/src/combat/encounters.ts b/src/combat/encounters.ts new file mode 100644 index 0000000..ea11b46 --- /dev/null +++ b/src/combat/encounters.ts @@ -0,0 +1,71 @@ +import type { DungeonEncounter } from '../profile' +import { chooseRandom } from './rules' + +export function encounterThreat(encounter: DungeonEncounter) { + return ( + encounter.maxHealth + + encounter.damage * 18 + + encounter.tankDamage * 10 + + encounter.partyDamage * 18 + ) +} + +export function buildRoguelikeSegment({ + pool, + stage, + mechanics, + trashCandidateCount, + bossCandidateCount, + healthScale, + damageScale, + partyDamageScale, + idBase, + bossDescription, + bossName, + fallbackBossFirst = false, + extraFields, +}: { + pool: DungeonEncounter[] + stage: number + mechanics: TMechanic[] + trashCandidateCount: (trashCount: number) => number + bossCandidateCount: (bossCount: number) => number + healthScale: number + damageScale: number + partyDamageScale: number + idBase: number + bossDescription: (mechanics: TMechanic[]) => string + bossName?: (encounter: DungeonEncounter) => string + fallbackBossFirst?: boolean + extraFields?: (encounter: DungeonEncounter, isBoss: boolean, mechanics: TMechanic[]) => TExtra +}) { + const trashPool = [...pool.filter((encounter) => !encounter.isBoss)] + .sort((left, right) => encounterThreat(left) - encounterThreat(right)) + const bossPool = [...pool.filter((encounter) => encounter.isBoss)] + .sort((left, right) => encounterThreat(left) - encounterThreat(right)) + const selectedTrash = chooseRandom(trashPool.slice(0, trashCandidateCount(trashPool.length)), 2) + const selectedBoss = chooseRandom(bossPool.slice(0, bossCandidateCount(bossPool.length)), 1)[0] + ?? (fallbackBossFirst ? bossPool[0] : trashPool[0]) + ?? trashPool[0] + ?? pool[0] + const selectedMechanics = mechanics + + return [...selectedTrash, selectedBoss].map((encounter, index) => { + const isBoss = index === 2 + return { + ...encounter, + id: idBase + stage * 10 + index, + sequence: (stage - 1) * 3 + index + 1, + isBoss, + encounterType: isBoss ? 'boss' : 'trash', + enemyName: isBoss ? (bossName?.(encounter) ?? `${encounter.enemyName} ${stage}`) : encounter.enemyName, + description: isBoss ? bossDescription(selectedMechanics) : encounter.description, + maxHealth: Math.round(encounter.maxHealth * healthScale), + damage: Math.round(encounter.damage * damageScale), + tankDamage: Math.round(encounter.tankDamage * damageScale), + partyDamage: Math.round(encounter.partyDamage * partyDamageScale), + lootTables: [], + ...(extraFields?.(encounter, isBoss, selectedMechanics) ?? {} as TExtra), + } + }) +} diff --git a/src/combat/pveRoguelikeRunSetup.ts b/src/combat/pveRoguelikeRunSetup.ts new file mode 100644 index 0000000..9fbc216 --- /dev/null +++ b/src/combat/pveRoguelikeRunSetup.ts @@ -0,0 +1,74 @@ +import type { PartyMember } from '../game' + +export type PveCombatState = { + party: PartyMember[] + resource: number + enemyHealth: number + cooldowns: Record + elapsedTicks: number + castsTowardFree: number + freeCastReady: boolean +} + +export function createPveCombatState({ + partyTemplate, + maxResource, + encounter, + enemyCount, +}: { + partyTemplate: readonly PartyMember[] + maxResource: number + encounter: TEncounter + enemyCount: number +}): PveCombatState { + return { + party: partyTemplate.map((member) => ({ ...member })), + resource: maxResource, + enemyHealth: encounter.maxHealth * enemyCount, + cooldowns: {}, + elapsedTicks: 0, + castsTowardFree: 0, + freeCastReady: false, + } +} + +export function createPveRoguelikeRunStart({ + partyTemplate, + maxResource, + encounters, + initialEncounterIndex, + enemyCount, +}: { + partyTemplate: readonly PartyMember[] + maxResource: number + encounters: readonly TEncounter[] + initialEncounterIndex: number + enemyCount: number +}) { + return { + combatState: createPveCombatState({ + partyTemplate, + maxResource, + encounter: encounters[initialEncounterIndex], + enemyCount, + }), + defaults: { + roguelikeStage: 1, + selectedId: partyTemplate[0].id, + encounterIndex: initialEncounterIndex, + status: 'playing' as const, + paused: false, + targetGroup: 0 as const, + reward: null, + rewardError: '', + lootRolls: [], + showEndLog: false, + floatingTexts: [], + roguelikeUpgrades: [], + upgradeChoices: [], + marathonBossesDefeated: 0, + resourceSpent: 0, + log: { text: 'A new run begins.', tone: 'system' as const }, + }, + } +} diff --git a/src/combat/pvpRoguelikeLifecycle.ts b/src/combat/pvpRoguelikeLifecycle.ts new file mode 100644 index 0000000..3ef7ebe --- /dev/null +++ b/src/combat/pvpRoguelikeLifecycle.ts @@ -0,0 +1,153 @@ +export type PvpRoguelikeStatus = 'queueing' | 'round-countdown' | 'playing' | 'upgrade-choice' | 'won' | 'lost' +export type PvpRoguelikeLogTone = 'system' | 'heal' | 'danger' | 'loot' + +export type PvpRoguelikeCombatOutcome = + | { + type: 'none' + } + | { + type: 'lost' + status: 'lost' + log: { text: string; tone: PvpRoguelikeLogTone } + } + | { + type: 'won' + status: 'won' + markOpponentDefeated: boolean + log: { text: string; tone: PvpRoguelikeLogTone } + } + | { + type: 'upgrade-choice' + status: 'upgrade-choice' + log: { text: string; tone: PvpRoguelikeLogTone } + } + +export function resolvePvpRoguelikeCombatOutcome({ + playerAlive, + opponentAlive, + playerCleared, + encounterIsBoss, + encounterName, + opponentDefeated, + liveMatchActive, + opponentLabel, +}: { + playerAlive: boolean + opponentAlive: boolean + playerCleared: boolean + encounterIsBoss: boolean + encounterName: string + opponentDefeated: boolean + liveMatchActive: boolean + opponentLabel: string +}): PvpRoguelikeCombatOutcome { + if (!playerAlive) { + return { + type: 'lost', + status: 'lost', + log: { text: 'Your party fell first.', tone: 'danger' }, + } + } + if (!liveMatchActive && !opponentAlive && !opponentDefeated) { + return { + type: 'won', + status: 'won', + markOpponentDefeated: true, + log: { text: `${opponentLabel} fell. Match complete.`, tone: 'loot' }, + } + } + if (!playerCleared) return { type: 'none' } + if (encounterIsBoss && opponentDefeated) { + return { + type: 'won', + status: 'won', + markOpponentDefeated: false, + log: { text: `${opponentLabel} defeated. Match complete.`, tone: 'loot' }, + } + } + return { + type: 'upgrade-choice', + status: 'upgrade-choice', + log: { text: `${encounterName} cleared. Choose your next edge.`, tone: 'loot' }, + } +} + +export function resolvePvpRoguelikeLiveSnapshot({ + currentStatus, + opponentStatus, + opponentAlive, + alreadyLoggedOpponentDone, + opponentName, +}: { + currentStatus: PvpRoguelikeStatus + opponentStatus?: string + opponentAlive?: boolean + alreadyLoggedOpponentDone: boolean + opponentName: string +}) { + if (currentStatus === 'won' || currentStatus === 'lost') return { type: 'none' as const } + if ((opponentStatus === 'lost' || opponentAlive === false) && !alreadyLoggedOpponentDone) { + return { + type: 'won' as const, + status: 'won' as const, + markOpponentDefeated: true, + log: { text: `${opponentName} fell. Match complete.`, tone: 'loot' as const }, + } + } + if (opponentStatus === 'won') { + return { + type: 'lost' as const, + status: 'lost' as const, + log: { text: `${opponentName} finished first.`, tone: 'danger' as const }, + } + } + return { type: 'none' as const } +} + +export function resolvePvpRoguelikeUpgradeCompletion({ + clearedBoss, + opponentDefeated, + opponentLabel, +}: { + clearedBoss: boolean + opponentDefeated: boolean + opponentLabel: string +}) { + if (!clearedBoss || !opponentDefeated) return { type: 'none' as const } + return { + type: 'won' as const, + status: 'won' as const, + log: { text: `${opponentLabel} defeated. Match complete.`, tone: 'loot' as const }, + } +} + +export function nextPvpRoguelikeStage({ + clearedBoss, + stage, + encounterIndex, + encounters, + nextSegment, +}: { + clearedBoss: boolean + stage: number + encounterIndex: number + encounters: readonly TEncounter[] + nextSegment: readonly TEncounter[] +}) { + const nextStage = clearedBoss ? stage + 1 : stage + const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1] + if (!nextEncounter) { + return { + type: 'complete' as const, + status: 'won' as const, + log: { text: 'No further encounters remain.', tone: 'loot' as const }, + } + } + return { + type: 'next' as const, + nextStage, + nextEncounter, + nextEncounterIndex: encounterIndex + 1, + appendSegment: clearedBoss, + } +} diff --git a/src/combat/pvpRoguelikeMatchSetup.ts b/src/combat/pvpRoguelikeMatchSetup.ts new file mode 100644 index 0000000..46e7d10 --- /dev/null +++ b/src/combat/pvpRoguelikeMatchSetup.ts @@ -0,0 +1,140 @@ +import type { PartyMember } from '../game' +import type { PvpMatchSnapshot, PvpMatchSide } from '../pvpRoguelike' + +export type PvpRoguelikeSetupSide = { + party: PartyMember[] + resource: number + cooldowns: Record + enemyHealth: number + buffs: TBuff[] + debuffs: TDebuff[] + castsTowardFree: number + freeCastReady: boolean +} + +export type PvpRoguelikeLiveMatchSetup = { + id: string + side: PvpMatchSide + opponentSide: PvpMatchSide + opponentName: string + opponentClassName: string +} + +export function createPvpRoguelikeStarterSide( + partyTemplate: readonly PartyMember[], + maxResource: number, +): PvpRoguelikeSetupSide { + return { + party: partyTemplate.map((member) => ({ ...member })), + resource: maxResource, + cooldowns: {}, + enemyHealth: 0, + buffs: [], + debuffs: [], + castsTowardFree: 0, + freeCastReady: false, + } +} + +export function createPvpRoguelikeMatchStart< + TBuff extends string, + TDebuff extends string, + TEncounter extends { maxHealth: number }, +>({ + startStage, + encounterPool, + buildSegment, + partyTemplate, + opponentPartyTemplate, + maxResource, + upgradeChoiceSeconds, +}: { + startStage: number + encounterPool: readonly TEncounter[] + buildSegment: (pool: readonly TEncounter[], stage: number) => TEncounter[] + partyTemplate: readonly PartyMember[] + opponentPartyTemplate: readonly PartyMember[] + maxResource: number + upgradeChoiceSeconds: number +}) { + const firstSegment = buildSegment(encounterPool, startStage) + const firstEncounter = firstSegment[0] + const playerSide = createPvpRoguelikeStarterSide(partyTemplate, maxResource) + const opponentSide = createPvpRoguelikeStarterSide(opponentPartyTemplate, maxResource) + playerSide.enemyHealth = firstEncounter.maxHealth + opponentSide.enemyHealth = firstEncounter.maxHealth + + return { + startStage, + firstSegment, + firstEncounter, + playerSide, + opponentSide, + defaults: { + encounterIndex: 0, + elapsedTicks: 0, + encountersCleared: 0, + paused: false, + targetGroup: 0 as const, + upgradeTimeLeft: upgradeChoiceSeconds, + liveUpgradePending: false, + rewardError: '', + showEndLog: false, + }, + } +} + +export function createPvpRoguelikeLiveMatchStart< + TBuff extends string, + TDebuff extends string, + TEncounter extends { maxHealth: number }, +>({ + match, + side, + encounterPool, + buildSegment, + partyTemplate, + opponentPartyTemplate, + maxResource, + upgradeChoiceSeconds, + message, +}: { + match: PvpMatchSnapshot> + side: PvpMatchSide + encounterPool: readonly TEncounter[] + buildSegment: (pool: readonly TEncounter[], stage: number) => TEncounter[] + partyTemplate: readonly PartyMember[] + opponentPartyTemplate: readonly PartyMember[] + maxResource: number + upgradeChoiceSeconds: number + message?: string +}) { + const opponentSideId: PvpMatchSide = side === 'a' ? 'b' : 'a' + const opponent = match.players[opponentSideId] + const opponentTemplate = opponentPartyTemplate.map((member) => ({ + ...member, + name: member.id === 'mira' ? opponent.characterName : member.name, + })) + const setup = createPvpRoguelikeMatchStart({ + startStage: match.startStage, + encounterPool, + buildSegment, + partyTemplate, + opponentPartyTemplate: opponentTemplate, + maxResource, + upgradeChoiceSeconds, + }) + const liveMatch: PvpRoguelikeLiveMatchSetup = { + id: match.id, + side, + opponentSide: opponentSideId, + opponentName: opponent.characterName, + opponentClassName: opponent.className, + } + + return { + ...setup, + liveMatch, + logText: message ?? `${opponent.characterName} found. Stage ${match.startStage} begins.`, + } +} diff --git a/src/combat/pvpRoguelikeUpgrades.ts b/src/combat/pvpRoguelikeUpgrades.ts new file mode 100644 index 0000000..baea307 --- /dev/null +++ b/src/combat/pvpRoguelikeUpgrades.ts @@ -0,0 +1,77 @@ +import type { PartyMember } from '../game' + +export type PvpRoguelikeUpgradeSide = { + party: PartyMember[] + resource: number + cooldowns: Record + enemyHealth: number + buffs: TBuff[] + debuffs: TDebuff[] +} + +function removeRandomItem(items: readonly T[], random = Math.random) { + if (items.length === 0) return [...items] + const removedIndex = Math.floor(random() * items.length) + return items.filter((_, index) => index !== removedIndex) +} + +export function recoverPartyForNextPvpRoguelikeEncounter(party: readonly PartyMember[]) { + return party.map((member) => ({ + ...member, + health: member.maxHealth, + debuff: undefined, + debuffTicks: undefined, + poisonStacks: undefined, + maxHealthPenaltyTicks: undefined, + healingReductionTicks: undefined, + })) +} + +export function applyPvpRoguelikeUpgradeChoice< + TBuff extends string, + TDebuff extends string, + TSide extends PvpRoguelikeUpgradeSide, +>({ + side, + buffId, + incomingDebuffId, + reviveBuffId, + random, +}: { + side: TSide + buffId: TBuff + incomingDebuffId?: TDebuff + reviveBuffId: TBuff + random?: () => number +}) { + const reviveSelected = buffId === reviveBuffId + const nextBuffs = reviveSelected ? side.buffs : [...side.buffs, buffId] + const nextDebuffs = incomingDebuffId ? [...side.debuffs, incomingDebuffId] : side.debuffs + return { + ...side, + buffs: nextBuffs, + debuffs: reviveSelected ? removeRandomItem(nextDebuffs, random) : nextDebuffs, + } +} + +export function preparePvpRoguelikeNextEncounter< + TBuff extends string, + TDebuff extends string, + TSide extends PvpRoguelikeUpgradeSide, +>({ + side, + maxResource, + enemyMaxHealth, +}: { + side: TSide + maxResource: number + enemyMaxHealth: number +}) { + return { + ...side, + party: recoverPartyForNextPvpRoguelikeEncounter(side.party), + resource: maxResource, + cooldowns: {}, + enemyHealth: enemyMaxHealth, + } +} diff --git a/src/combat/pvpSpellCasting.ts b/src/combat/pvpSpellCasting.ts new file mode 100644 index 0000000..cb2b809 --- /dev/null +++ b/src/combat/pvpSpellCasting.ts @@ -0,0 +1,58 @@ +import type { PartyMember, Spell } from '../game' +import { applyCastStateUpdate, type CastStateFields } from './combatStateTransitions' +import { canCastSpell } from './spellCasting' +import { applySpellEffectProfile, type SpellEffectProfile, type SpellTargetPlan } from './spellEffects' + +export function applyPvpSpellCast>({ + current, + spell, + targetId, + resourceCost, + targetPlan, + profile, + setCurrent, + emitFloatingHeal, + cooldownMultiplier = 1, + cooldowns, + freeCast, +}: { + current: TState + spell: Spell + targetId: string + resourceCost: number + targetPlan: SpellTargetPlan + profile: SpellEffectProfile + setCurrent: (next: TState) => void + emitFloatingHeal: (memberId: string, value: number) => void + cooldownMultiplier?: number + cooldowns?: Record + freeCast?: { + enabled: boolean + wasReady: boolean + } +}) { + if (!canCastSpell(spell, current.resource, current.cooldowns, resourceCost)) return false + const target = current.party.find((member) => member.id === targetId && member.health > 0) + if (!target) return false + + const { party: nextParty, floatingHeals } = applySpellEffectProfile({ + party: current.party, + spell, + targetId, + plan: targetPlan, + profile, + }) + floatingHeals.forEach((event) => emitFloatingHeal(event.memberId, event.value)) + + const nextState = applyCastStateUpdate({ + current, + party: nextParty, + cooldowns, + spell, + resourceCost, + cooldownMultiplier, + freeCast, + }) + setCurrent(nextState) + return true +} diff --git a/src/combat/rewardSummaries.ts b/src/combat/rewardSummaries.ts new file mode 100644 index 0000000..82a3c0b --- /dev/null +++ b/src/combat/rewardSummaries.ts @@ -0,0 +1,70 @@ +import type { DungeonReward } from '../profile' + +export type RewardSummaryBase = { + experienceGained: number + previousLevel: number | null + newLevel: number | null + levelsGained: number + talentPointsGained: number + unlockedAbilities: DungeonReward['unlockedAbilities'] +} + +export type PvpRunRewardSummary = RewardSummaryBase & { + bossesKilled: number + loot: Array> +} + +export function createEmptyRewardSummary(): RewardSummaryBase { + return { + experienceGained: 0, + previousLevel: null, + newLevel: null, + levelsGained: 0, + talentPointsGained: 0, + unlockedAbilities: [], + } +} + +export function createEmptyPvpRunSummary(): PvpRunRewardSummary { + return { + bossesKilled: 0, + ...createEmptyRewardSummary(), + loot: [], + } +} + +export function mergeUnlockedAbilities( + current: DungeonReward['unlockedAbilities'], + next: DungeonReward['unlockedAbilities'], +) { + const unlockedById = new Map(current.map((ability) => [ability.id, ability])) + next.forEach((ability) => unlockedById.set(ability.id, ability)) + return Array.from(unlockedById.values()) +} + +export function mergeDungeonRewardSummary( + current: TSummary, + reward: DungeonReward, +) { + return { + ...current, + experienceGained: current.experienceGained + reward.experienceGained, + previousLevel: current.previousLevel ?? reward.previousLevel, + newLevel: reward.newLevel, + levelsGained: current.levelsGained + reward.levelsGained, + talentPointsGained: current.talentPointsGained + reward.talentPointsGained, + unlockedAbilities: mergeUnlockedAbilities(current.unlockedAbilities, reward.unlockedAbilities), + } +} + +export function mergePvpRunRewardSummary( + current: PvpRunRewardSummary, + reward: DungeonReward, + { bossKilled }: { bossKilled: boolean }, +) { + return { + ...mergeDungeonRewardSummary(current, reward), + bossesKilled: current.bossesKilled + (bossKilled ? 1 : 0), + loot: reward.bonusItem ? [...current.loot, reward.bonusItem] : current.loot, + } +} diff --git a/src/combat/roguelikeUpgrades.ts b/src/combat/roguelikeUpgrades.ts new file mode 100644 index 0000000..b825bf4 --- /dev/null +++ b/src/combat/roguelikeUpgrades.ts @@ -0,0 +1,81 @@ +import type { Spell } from '../game' +import { createStackCounts, summarizeStackCounts } from './stackCounts' + +export type UpgradeChoice = { + id: T + name: string + description: string +} + +export function slotLabel(slot: string, spells: Spell[], labelMode: 'ability' | 'slot') { + const spell = spells.find((candidate) => candidate.key === slot) + if (labelMode === 'ability' && spell) return spell.name + return `Slot ${slot}` +} + +export function buildSelfSlotUpgradeChoices({ + slots, + spells, + labelMode, + idPrefix = 'slot', +}: { + slots: readonly string[] + spells: Spell[] + labelMode: 'ability' | 'slot' + idPrefix?: string +}) { + return slots.flatMap((slot) => { + const label = slotLabel(slot, spells, labelMode) + return [ + { + id: `${idPrefix}${slot}-extra-target` as T, + name: `${label}: +1 target`, + description: `${label} affects 1 additional ally when possible.`, + }, + { + id: `${idPrefix}${slot}-cost-down` as T, + name: `${label}: -25% cost`, + description: `${label} costs 25% less resource.`, + }, + { + id: `${idPrefix}${slot}-cooldown-down` as T, + name: `${label}: -25% cooldown`, + description: `${label} recharges 25% faster.`, + }, + ] + }) +} + +export function buildOpponentSlotDebuffChoices({ + slots, + spells, + labelMode, +}: { + slots: readonly string[] + spells: Spell[] + labelMode: 'ability' | 'slot' +}) { + return slots.flatMap((slot) => { + const label = slotLabel(slot, spells, labelMode) + return [ + { + id: `opp-slot${slot}-cost-up` as T, + name: `${label}: +25% cost`, + description: `Opponent ${label.toLowerCase()} costs 25% more resource.`, + }, + { + id: `opp-slot${slot}-cooldown-up` as T, + name: `${label}: +25% cooldown`, + description: `Opponent ${label.toLowerCase()} recharges 25% slower.`, + }, + ] + }) +} + +export function summarizeChoiceStacks( + items: T[], + catalog: Array>, + emptyLabel = '', +) { + return summarizeStackCounts(createStackCounts(items), catalog, emptyLabel) +} diff --git a/src/combat/rules.ts b/src/combat/rules.ts new file mode 100644 index 0000000..cb9687a --- /dev/null +++ b/src/combat/rules.ts @@ -0,0 +1,132 @@ +import type { PartyMember, Spell } from '../game' +import type { Ability } from '../profile' +import { createStackCounts, stackCount } from './stackCounts' + +export const DEFAULT_TICK_MS = 700 + +export function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +export function chooseRandom(items: T[], count: number) { + const pool = [...items] + const result: T[] = [] + while (pool.length > 0 && result.length < count) { + const index = Math.floor(Math.random() * pool.length) + result.push(pool.splice(index, 1)[0]) + } + return result +} + +export function effectiveMaxHealth(member: PartyMember) { + return Math.max( + 1, + Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)), + ) +} + +export function healAmount(member: PartyMember, amount: number, multiplier = 1) { + return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1) * multiplier) +} + +export function healMember(member: PartyMember, amount: number, multiplier = 1) { + return clamp(member.health + healAmount(member, amount, multiplier), 0, effectiveMaxHealth(member)) +} + +export function memberHotEffects(member: PartyMember) { + if (member.hotEffects?.length) return member.hotEffects + return member.hotTicks > 0 + ? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }] + : [] +} + +export function effectId(prefix: string) { + return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}` +} + +export function addHotEffect(member: PartyMember, spell: Spell, ticks = 5) { + const nextEffect = { + id: effectId(spell.id), + spellId: spell.id, + label: spell.name, + ticks, + power: Math.max(1, Math.round(spell.power / 2)), + } + const currentEffects = memberHotEffects(member).filter((effect) => effect.spellId !== spell.id) + return [...currentEffects, nextEffect] +} + +export function addBounceHeal(member: PartyMember, spell: Spell) { + return [ + ...(member.bounceHeals ?? []), + { + id: effectId(spell.id), + label: spell.name, + charges: 4, + power: spell.power, + }, + ] +} + +export function tickHotEffects(effects: PartyMember['hotEffects']) { + return (effects ?? []) + .map((effect) => ({ ...effect, ticks: effect.ticks - 1 })) + .filter((effect) => effect.ticks > 0) +} + +export function formatEffectTime(ticks: number, tickMs = DEFAULT_TICK_MS) { + const seconds = (ticks * tickMs) / 1000 + return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s` +} + +export function buffStacks(items: readonly T[], id: T) { + return stackCount(createStackCounts(items), id) +} + +export function createLogEntry(nextLogId: { current: number }, text: string, tone: 'system' | 'heal' | 'danger' | 'loot') { + return { id: nextLogId.current++, text, tone } +} + +export function toCombatSpell(ability: Ability, key: string, healingPower = 0): Spell { + const kinds: Record = { + direct_heal: 'direct', + direct_hot: 'direct', + heal_over_time: 'hot', + party_heal: 'group', + party_hot: 'group', + party_absorb: 'group', + absorb: 'shield', + damage_reduction: 'damage_reduction', + bounce_heal: 'bounce_heal', + cleanse: 'cleanse', + } + return { + id: String(ability.id), + key, + name: ability.name, + description: ability.description, + cost: ability.cost, + cooldown: ability.cooldown, + power: ability.power + healingPower, + glyph: ability.glyph, + kind: kinds[ability.spellType] ?? 'direct', + effectType: ability.spellType, + } +} + +export function resetParty(partyTemplate: PartyMember[]) { + return partyTemplate.map((member) => ({ + ...member, + health: member.maxHealth, + shield: 0, + hotTicks: 0, + hotEffects: undefined, + debuff: undefined, + debuffTicks: undefined, + poisonStacks: undefined, + maxHealthPenaltyTicks: undefined, + healingReductionTicks: undefined, + damageReductionTicks: undefined, + bounceHeals: undefined, + })) +} diff --git a/src/combat/spellCasting.ts b/src/combat/spellCasting.ts new file mode 100644 index 0000000..e958da4 --- /dev/null +++ b/src/combat/spellCasting.ts @@ -0,0 +1,21 @@ +import type { Spell } from '../game' + +export function canCastSpell( + spell: Spell, + resource: number, + cooldowns: Record, + resourceCost: number, +) { + return resource >= resourceCost && (cooldowns[spell.id] ?? 0) <= 0 +} + +export function putSpellOnCooldown( + cooldowns: Record, + spell: Spell, + cooldownMultiplier = 1, +) { + return { + ...cooldowns, + [spell.id]: spell.cooldown * cooldownMultiplier, + } +} diff --git a/src/combat/spellEffects.ts b/src/combat/spellEffects.ts new file mode 100644 index 0000000..2e932eb --- /dev/null +++ b/src/combat/spellEffects.ts @@ -0,0 +1,363 @@ +import { DEFAULT_GROUP_HEAL_TARGETS, groupHealTargets, type PartyMember, type Spell } from '../game' +import { addBounceHeal, addHotEffect } from './rules' + +export type SpellTargetBucket = 'direct' | 'hot' | 'shield' | 'damageReduction' + +export type SpellTargetPlan = { + directTargets: Set + hotTargets: Set + shieldTargets: Set + damageReductionTargets: Set + groupTargets: Set +} + +export type ExtraTargetMode = Partial> + +function hasBlockedId(blockedIds: Iterable, id: string) { + if (blockedIds instanceof Set) return blockedIds.has(id) + for (const blockedId of blockedIds) { + if (blockedId === id) return true + } + return false +} + +export function lowestHealthExtraTarget(party: PartyMember[], blockedIds: Iterable) { + let target: PartyMember | undefined + let targetRatio = Number.POSITIVE_INFINITY + for (const member of party) { + if (member.health <= 0 || hasBlockedId(blockedIds, member.id)) continue + const healthRatio = member.health / member.maxHealth + if (healthRatio < targetRatio) { + target = member + targetRatio = healthRatio + } + } + return target +} + +export function addLowestHealthExtraTarget( + plan: SpellTargetPlan, + party: PartyMember[], + bucket: SpellTargetBucket, +) { + const targetSet = bucket === 'direct' + ? plan.directTargets + : bucket === 'hot' + ? plan.hotTargets + : bucket === 'shield' + ? plan.shieldTargets + : plan.damageReductionTargets + const extra = lowestHealthExtraTarget(party, targetSet) + if (extra) targetSet.add(extra.id) +} + +export function buildSpellTargetPlan({ + party, + spell, + targetId, + extraTargets, + directTarget, + hotTarget, + shieldTarget, + damageReductionTarget, + groupTargetCount = DEFAULT_GROUP_HEAL_TARGETS, + extraTargetMode, + beforeExtraTargetBuckets = [], +}: { + party: PartyMember[] + spell: Spell + targetId: string + extraTargets: number + directTarget: boolean + hotTarget: boolean + shieldTarget: boolean + damageReductionTarget?: boolean + groupTargetCount?: number + extraTargetMode: ExtraTargetMode + beforeExtraTargetBuckets?: SpellTargetBucket[] +}): SpellTargetPlan { + const plan: SpellTargetPlan = { + directTargets: new Set(directTarget ? [targetId] : []), + hotTargets: new Set(hotTarget ? [targetId] : []), + shieldTargets: new Set(shieldTarget ? [targetId] : []), + damageReductionTargets: new Set(damageReductionTarget ? [targetId] : []), + groupTargets: new Set( + spell.kind === 'group' + ? groupHealTargets(party, groupTargetCount).map((member) => member.id) + : [], + ), + } + + beforeExtraTargetBuckets.forEach((bucket) => { + addLowestHealthExtraTarget(plan, party, bucket) + }) + + const bucket = extraTargetMode[spell.kind] ?? 'direct' + for (let index = 0; index < extraTargets; index += 1) { + if (spell.kind === 'group') break + addLowestHealthExtraTarget(plan, party, bucket) + } + + return plan +} + +export function clearNegativeEffects(member: PartyMember) { + return { + ...member, + debuff: undefined, + debuffTicks: undefined, + poisonStacks: undefined, + maxHealthPenaltyTicks: undefined, + healingReductionTicks: undefined, + } +} + +export type FloatingHealEvent = { + memberId: string + value: number +} + +export type SpellEffectProfile = { + /** + * Profiles keep mode-specific spell math out of screen components. PvE uses + * stacked HoT effect objects; PvP modes mostly use legacy hotTicks. Stadium + * also applies dampening through the power callbacks below. + */ + modeName: string + heal: (member: PartyMember, power: number, multiplier: number) => number + healingMultiplier: (member: PartyMember) => number + power: { + direct: (spell: Spell) => number + cleanse: (spell: Spell) => number + groupHeal: (spell: Spell) => number + groupAbsorb: (spell: Spell) => number + shield: (sourcePower: number, strength?: number) => number + } + hot: { + mode: 'effects' | 'ticks' + defaultTicks: number + groupTicks: number + radianceTicks: number + merge: 'replace' | 'max' + groupMerge?: 'replace' | 'max' + } + effects: { + renewSpell?: Spell | null + shieldSpell?: Spell | null + groupAbsorbOnly: (spell: Spell) => boolean + groupHotOnly: (spell: Spell) => boolean + groupAppliesShield: (spell: Spell) => boolean + groupAppliesHot: (spell: Spell) => boolean + shieldAppliesHot: (spell: Spell) => boolean + hotSpellForDirect: (spell: Spell) => Spell + } + ratios: { + groupShield: number + directShield: number + } + damageReductionTicks: number + floatingHeals: { + group: boolean + direct: boolean + cleanse: boolean + } + bounceHeals: boolean +} + +function applyHot( + member: PartyMember, + spell: Spell, + profile: SpellEffectProfile, + ticks = profile.hot.defaultTicks, + merge = profile.hot.merge, +) { + if (profile.hot.mode === 'effects') { + return { + ...member, + hotTicks: 0, + hotEffects: addHotEffect(member, spell, ticks), + } + } + return { + ...member, + hotTicks: merge === 'max' + ? Math.max(member.hotTicks, ticks) + : ticks, + } +} + +function recordFloatingHeal( + events: FloatingHealEvent[], + enabled: boolean, + member: PartyMember, + nextHealth: number, +) { + if (enabled && nextHealth > member.health) { + events.push({ memberId: member.id, value: nextHealth - member.health }) + } +} + +export function applySpellEffectProfile({ + party, + spell, + targetId, + plan, + profile, +}: { + party: PartyMember[] + spell: Spell + targetId: string + plan: SpellTargetPlan + profile: SpellEffectProfile +}) { + const floatingHeals: FloatingHealEvent[] = [] + const nextParty = party.map((member) => { + if (member.health <= 0) return member + + if (spell.kind === 'group') { + if (!plan.groupTargets.has(member.id)) return member + const isGroupAbsorb = profile.effects.groupAbsorbOnly(spell) + if (profile.effects.groupHotOnly(spell)) { + return applyHot(member, spell, profile, profile.hot.groupTicks, profile.hot.groupMerge) + } + + const power = isGroupAbsorb + ? profile.power.groupAbsorb(spell) + : profile.power.groupHeal(spell) + const nextHealth = isGroupAbsorb + ? member.health + : profile.heal(member, power, profile.healingMultiplier(member)) + recordFloatingHeal(floatingHeals, profile.floatingHeals.group, member, nextHealth) + + let nextMember: PartyMember = { + ...member, + health: nextHealth, + shield: profile.effects.groupAppliesShield(spell) + ? Math.max( + member.shield, + isGroupAbsorb + ? power + : profile.power.shield( + profile.effects.shieldSpell?.power ?? spell.power, + profile.ratios.groupShield, + ), + ) + : member.shield, + } + if (profile.effects.groupAppliesHot(spell) && profile.effects.renewSpell) { + nextMember = applyHot(nextMember, profile.effects.renewSpell, profile, profile.hot.radianceTicks, profile.hot.groupMerge) + } else if (profile.effects.groupAppliesHot(spell)) { + nextMember = applyHot(nextMember, spell, profile, profile.hot.groupTicks, profile.hot.groupMerge) + } + return nextMember + } + + if ( + !plan.directTargets.has(member.id) + && !plan.hotTargets.has(member.id) + && !plan.shieldTargets.has(member.id) + && !plan.damageReductionTargets.has(member.id) + && !(member.id === targetId && spell.kind === 'bounce_heal' && profile.bounceHeals) + ) return member + + if (spell.kind === 'shield') { + let nextMember: PartyMember = { + ...member, + shield: Math.max(member.shield, profile.power.shield(spell.power)), + } + if (plan.hotTargets.has(member.id) || profile.effects.shieldAppliesHot(spell)) { + nextMember = applyHot(nextMember, profile.effects.renewSpell ?? spell, profile) + } + return nextMember + } + + if (spell.kind === 'damage_reduction') { + return { + ...member, + damageReductionTicks: profile.hot.merge === 'max' + ? Math.max(member.damageReductionTicks ?? 0, profile.damageReductionTicks) + : profile.damageReductionTicks, + hotTicks: plan.hotTargets.has(member.id) + ? (profile.hot.merge === 'max' + ? Math.max(member.hotTicks, profile.hot.defaultTicks) + : profile.hot.defaultTicks) + : member.hotTicks, + } + } + + if (spell.kind === 'bounce_heal' && profile.bounceHeals) { + return { ...member, bounceHeals: addBounceHeal(member, spell) } + } + + if (spell.kind === 'cleanse') { + const nextHealth = profile.heal(member, profile.power.cleanse(spell), profile.healingMultiplier(member)) + recordFloatingHeal(floatingHeals, profile.floatingHeals.cleanse, member, nextHealth) + let nextMember: PartyMember = { + ...clearNegativeEffects(member), + health: nextHealth, + shield: plan.shieldTargets.has(member.id) + ? Math.max( + member.shield, + profile.power.shield(profile.effects.shieldSpell?.power ?? spell.power), + ) + : member.shield, + } + if (plan.hotTargets.has(member.id)) { + nextMember = applyHot(nextMember, profile.effects.renewSpell ?? spell, profile) + } + return nextMember + } + + const nextHealth = plan.directTargets.has(member.id) + ? profile.heal(member, profile.power.direct(spell), profile.healingMultiplier(member)) + : member.health + recordFloatingHeal(floatingHeals, profile.floatingHeals.direct, member, nextHealth) + let nextMember: PartyMember = { + ...member, + health: nextHealth, + shield: plan.shieldTargets.has(member.id) + ? Math.max( + member.shield, + profile.power.shield( + profile.effects.shieldSpell?.power ?? spell.power, + profile.ratios.directShield, + ), + ) + : member.shield, + } + if (plan.hotTargets.has(member.id)) { + nextMember = applyHot(nextMember, profile.effects.hotSpellForDirect(spell), profile) + } + return nextMember + }) + + return { party: nextParty, floatingHeals } +} + +export function advanceFreeCastProgress({ + enabled, + wasReady, + castsTowardFree, +}: { + enabled: boolean + wasReady: boolean + castsTowardFree: number +}) { + if (!enabled) { + return { + castsTowardFree, + freeCastReady: wasReady, + } + } + if (wasReady) { + return { + castsTowardFree: 0, + freeCastReady: false, + } + } + const nextCasts = castsTowardFree + 1 + return { + castsTowardFree: nextCasts >= 5 ? 0 : nextCasts, + freeCastReady: nextCasts >= 5, + } +} diff --git a/src/combat/spellModifiers.ts b/src/combat/spellModifiers.ts new file mode 100644 index 0000000..419576a --- /dev/null +++ b/src/combat/spellModifiers.ts @@ -0,0 +1,81 @@ +import type { Spell } from '../game' +import { stackCount, type StackCounts } from './stackCounts' + +export type SlotModifier = { + stacks: StackCounts + id: (slot: string) => T + multiplier?: number +} + +export type StackModifier = { + stacks: StackCounts + id: T + multiplier?: number +} + +function slotKey(spell: Spell) { + return String(spell.key) +} + +function slotModifierStacks(spell: Spell, modifier?: SlotModifier) { + return modifier ? stackCount(modifier.stacks, modifier.id(slotKey(spell))) : 0 +} + +function stackModifierStacks(modifier?: StackModifier) { + return modifier ? stackCount(modifier.stacks, modifier.id) : 0 +} + +function stackMultiplier(modifier?: StackModifier) { + if (!modifier) return 1 + return (modifier.multiplier ?? 1.25) ** stackModifierStacks(modifier) +} + +export function spellSlotMultiplier( + spell: Spell, + positive?: SlotModifier, + negative?: SlotModifier, +) { + const positiveMultiplier = (positive?.multiplier ?? 0.75) ** slotModifierStacks(spell, positive) + const negativeMultiplier = (negative?.multiplier ?? 1.25) ** slotModifierStacks(spell, negative) + return positiveMultiplier * negativeMultiplier +} + +export function spellCooldownMultiplier( + spell: Spell, + cooldownDown?: SlotModifier, + cooldownUp?: SlotModifier, +) { + return spellSlotMultiplier(spell, cooldownDown, cooldownUp) +} + +export function spellResourceCost({ + spell, + costDown, + costUp, + freeCastReady = false, + freeCast, +}: { + spell: Spell + costDown?: SlotModifier + costUp?: SlotModifier + freeCastReady?: boolean + freeCast?: StackModifier +}) { + if (freeCastReady && stackModifierStacks(freeCast) > 0) return 0 + return Math.ceil(spell.cost * spellSlotMultiplier(spell, costDown, costUp)) +} + +export function spellExtraTargets( + spell: Spell, + extraTarget: SlotModifier, +) { + return slotModifierStacks(spell, extraTarget) +} + +export function spellPowerMultiplier(modifier: StackModifier) { + return stackMultiplier(modifier) +} + +export function hasSpellStack(modifier: StackModifier) { + return stackModifierStacks(modifier) > 0 +} diff --git a/src/combat/stackCounts.ts b/src/combat/stackCounts.ts new file mode 100644 index 0000000..93dfcf0 --- /dev/null +++ b/src/combat/stackCounts.ts @@ -0,0 +1,25 @@ +export type StackCounts = ReadonlyMap + +export function createStackCounts(items: readonly T[]): StackCounts { + const counts = new Map() + items.forEach((item) => counts.set(item, (counts.get(item) ?? 0) + 1)) + return counts +} + +export function stackCount(counts: StackCounts, id: T) { + return counts.get(id) ?? 0 +} + +export function summarizeStackCounts( + counts: StackCounts, + catalog: readonly TChoice[], + emptyLabel = '', +) { + 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 || emptyLabel +} diff --git a/src/combat/stadiumLifecycle.ts b/src/combat/stadiumLifecycle.ts new file mode 100644 index 0000000..d7a6246 --- /dev/null +++ b/src/combat/stadiumLifecycle.ts @@ -0,0 +1,81 @@ +export type StadiumRoundOutcome = 'win' | 'loss' | 'tie' +export type StadiumWins = { + player: number + opponent: number +} +export type StadiumRoundStatus = 'playing' | 'shop' | 'won' | 'lost' +export type StadiumExperienceMode = + | 'pvp-stadium-round-win-quarter-level' + | 'pvp-stadium-round-loss-tenth-level' + | 'pvp-stadium-match-half-level' + +export const DEFAULT_STADIUM_WIN_ROUNDS = 3 + +export function stadiumShopPointsForOutcome(outcome: StadiumRoundOutcome, side: 'player' | 'opponent') { + if (side === 'player') return outcome === 'loss' ? 4 : 3 + return outcome === 'win' ? 4 : 3 +} + +export function resolveStadiumRound({ + outcome, + roundIndex, + wins, + winRounds = DEFAULT_STADIUM_WIN_ROUNDS, +}: { + outcome: StadiumRoundOutcome + roundIndex: number + wins: StadiumWins + winRounds?: number +}) { + const roundExperienceMode: StadiumExperienceMode = outcome === 'loss' + ? 'pvp-stadium-round-loss-tenth-level' + : 'pvp-stadium-round-win-quarter-level' + const logTone = outcome === 'loss' ? 'danger' as const : 'loot' as const + const nextWins = { + player: wins.player + (outcome === 'win' ? 1 : 0), + opponent: wins.opponent + (outcome === 'loss' ? 1 : 0), + } + const status: StadiumRoundStatus = nextWins.player >= winRounds + ? 'won' + : nextWins.opponent >= winRounds + ? 'lost' + : 'shop' + + return { + nextWins, + status, + playerRoundStatus: status, + roundExperience: { + key: `round-${roundIndex}-${outcome}`, + mode: roundExperienceMode, + }, + matchExperience: status === 'won' + ? { key: 'match-win', mode: 'pvp-stadium-match-half-level' as StadiumExperienceMode } + : null, + log: { + text: outcome === 'win' + ? `Round ${roundIndex} won.` + : outcome === 'loss' + ? `Round ${roundIndex} lost.` + : `Round ${roundIndex} tied.`, + tone: logTone, + }, + } +} + +export function chooseStadiumCpuPurchases( + catalog: readonly TBuff[], + points: number, + random = Math.random, +) { + let remaining = points + const purchases: TId[] = [] + while (remaining > 0) { + const affordable = catalog.filter((buff) => buff.cost <= remaining) + if (affordable.length === 0) break + const selected = affordable[Math.floor(random() * affordable.length)] + purchases.push(selected.id) + remaining -= selected.cost + } + return purchases +} diff --git a/src/combat/stadiumMatchSetup.ts b/src/combat/stadiumMatchSetup.ts new file mode 100644 index 0000000..a1a0b65 --- /dev/null +++ b/src/combat/stadiumMatchSetup.ts @@ -0,0 +1,124 @@ +import type { PartyMember } from '../game' +import type { PvpMatchSnapshot, PvpMatchSide } from '../pvpRoguelike' +import { resetParty } from './rules' +import type { StadiumRoundOutcome, StadiumWins } from './stadiumLifecycle' + +export type StadiumSetupSide = { + party: PartyMember[] + resource: number + cooldowns: Record + buffs: TBuff[] + castsTowardFree: number + freeCastReady: boolean + survivalSeconds: number + dampeningPercent: number + roundIndex: number + roundWins: number + roundStatus: 'playing' | 'shop' | 'won' | 'lost' + lastRoundOutcome?: StadiumRoundOutcome + shopReady: boolean +} + +export type StadiumLiveMatchSetup = { + id: string + side: PvpMatchSide + opponentSide: PvpMatchSide + opponentName: string + opponentClassName: string +} + +export function createStadiumStarterSide({ + partyTemplate, + maxResource, + roundIndex, + buffs = [], + roundWins = 0, +}: { + partyTemplate: readonly PartyMember[] + maxResource: number + roundIndex: number + buffs?: TBuff[] + roundWins?: number +}): StadiumSetupSide { + return { + party: resetParty([...partyTemplate]), + resource: maxResource, + cooldowns: {}, + buffs, + castsTowardFree: 0, + freeCastReady: false, + survivalSeconds: 0, + dampeningPercent: 0, + roundIndex, + roundWins, + roundStatus: 'playing', + shopReady: false, + } +} + +export function createStadiumMatchStart({ + partyTemplate, + opponentPartyTemplate, + maxResource, +}: { + partyTemplate: readonly PartyMember[] + opponentPartyTemplate: readonly PartyMember[] + maxResource: number +}) { + const roundIndex = 1 + return { + playerSide: createStadiumStarterSide({ partyTemplate, maxResource, roundIndex }), + opponentSide: createStadiumStarterSide({ partyTemplate: opponentPartyTemplate, maxResource, roundIndex }), + defaults: { + roundIndex, + roundWins: { player: 0, opponent: 0 } satisfies StadiumWins, + elapsedTicks: 0, + shopPoints: 0, + shopReady: false, + paused: false, + rewardError: '', + showEndLog: false, + }, + } +} + +export function createStadiumLiveMatchStart({ + match, + side, + partyTemplate, + opponentPartyTemplate, + maxResource, + message, +}: { + match: PvpMatchSnapshot> + side: PvpMatchSide + partyTemplate: readonly PartyMember[] + opponentPartyTemplate: readonly PartyMember[] + maxResource: number + message?: string +}) { + const opponentSideId: PvpMatchSide = side === 'a' ? 'b' : 'a' + const opponent = match.players[opponentSideId] + const opponentTemplate = opponentPartyTemplate.map((member) => ({ + ...member, + name: member.id === 'mira' ? opponent.characterName : member.name, + })) + const setup = createStadiumMatchStart({ + partyTemplate, + opponentPartyTemplate: opponentTemplate, + maxResource, + }) + const liveMatch: StadiumLiveMatchSetup = { + id: match.id, + side, + opponentSide: opponentSideId, + opponentName: opponent.characterName, + opponentClassName: opponent.className, + } + + return { + ...setup, + liveMatch, + logText: message ?? `${opponent.characterName} found. Stadium begins.`, + } +} diff --git a/src/combat/targeting.ts b/src/combat/targeting.ts new file mode 100644 index 0000000..4911c6b --- /dev/null +++ b/src/combat/targeting.ts @@ -0,0 +1,98 @@ +import type { PartyMember } from '../game' + +export type NavigateAction = 'navigateLeft' | 'navigateRight' | 'navigateUp' | 'navigateDown' + +type TargetOptions = { + livingOnly?: boolean +} + +function canTarget(member: PartyMember, options: TargetOptions) { + return !options.livingOnly || member.health > 0 +} + +export function selectRelativePartyTarget( + party: PartyMember[], + selectedId: string, + direction: -1 | 1, + options: TargetOptions = {}, +) { + const candidates = party.filter((member) => canTarget(member, options)) + if (candidates.length === 0) return null + const currentIndex = candidates.findIndex((member) => member.id === selectedId) + const nextIndex = currentIndex < 0 + ? 0 + : (currentIndex + direction + candidates.length) % candidates.length + return candidates[nextIndex]?.id ?? null +} + +export function selectDirectionalPartyTarget( + party: PartyMember[], + selectedId: string, + action: NavigateAction, + columns: number, + options: TargetOptions = {}, +) { + const currentIndex = party.findIndex((member) => member.id === selectedId) + if (currentIndex < 0) { + return party.find((member) => canTarget(member, options))?.id ?? null + } + const currentRow = Math.floor(currentIndex / columns) + const currentColumn = currentIndex % columns + const horizontal = action === 'navigateLeft' || action === 'navigateRight' + const candidate = party + .map((member, index) => ({ + member, + index, + row: Math.floor(index / columns), + column: index % columns, + })) + .filter(({ member, index, row, column }) => { + if (!canTarget(member, options) || index === currentIndex) return false + if (action === 'navigateLeft') return row === currentRow && column < currentColumn + if (action === 'navigateRight') return row === currentRow && column > currentColumn + if (action === 'navigateUp') return row < currentRow + return row > currentRow + }) + .sort((left, right) => { + const leftPrimary = horizontal + ? Math.abs(left.column - currentColumn) + : Math.abs(left.row - currentRow) + const rightPrimary = horizontal + ? Math.abs(right.column - currentColumn) + : Math.abs(right.row - currentRow) + const leftSecondary = horizontal ? 0 : Math.abs(left.column - currentColumn) + const rightSecondary = horizontal ? 0 : Math.abs(right.column - currentColumn) + return leftPrimary - rightPrimary || leftSecondary - rightSecondary + })[0] + return candidate?.member.id ?? null +} + +export function selectDirectPartyTarget( + party: PartyMember[], + slot: number, + options: TargetOptions & { + targetGroup?: number + groupSize?: number + } = {}, +) { + const groupSize = options.groupSize ?? 6 + const index = slot + (options.targetGroup ?? 0) * groupSize + const member = party[index] + return member && canTarget(member, options) ? member.id : null +} + +export function nextTargetGroupSelection( + party: PartyMember[], + selectedId: string, + currentGroup: number, + groupSize = 6, +) { + const groupCount = Math.max(1, Math.ceil(party.length / groupSize)) + const nextGroup = (currentGroup + 1) % groupCount + const selectedIndex = party.findIndex((member) => member.id === selectedId) + const slot = selectedIndex < 0 ? 0 : selectedIndex % groupSize + return { + group: nextGroup, + selectedId: party[slot + nextGroup * groupSize]?.id ?? null, + } +} diff --git a/src/components/CombatScreen.tsx b/src/components/CombatScreen.tsx index 7734001..3d93ec9 100644 --- a/src/components/CombatScreen.tsx +++ b/src/components/CombatScreen.tsx @@ -11,7 +11,6 @@ import { INITIAL_PARTY, RAID_PARTY, DEFAULT_GROUP_HEAL_TARGETS, - groupHealTargets, partyDamageOutput, tankPressureTargets, type CombatLogEntry, @@ -19,23 +18,66 @@ import { type Spell, } from '../game' import type { - Ability, CharacterProfile, Difficulty, Dungeon, DungeonEncounter, } from '../profile' +import { + chooseRandom, + clamp, + effectiveMaxHealth, + healMember, + toCombatSpell, +} from '../combat/rules' +import { + nextTargetGroupSelection, +} from '../combat/targeting' +import { buildRoguelikeSegment } from '../combat/encounters' +import { + buildSelfSlotUpgradeChoices, + summarizeChoiceStacks, +} from '../combat/roguelikeUpgrades' +import { createStackCounts, type StackCounts } from '../combat/stackCounts' +import { + hasSpellStack, + spellCooldownMultiplier, + spellExtraTargets, + spellPowerMultiplier, + spellResourceCost as modifiedSpellResourceCost, +} from '../combat/spellModifiers' +import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick' +import { advanceMemberTick, attachJumpedBounceHeals } from '../combat/combatEngine' +import { applyCastStateUpdate } from '../combat/combatStateTransitions' +import { buildCombatDualScreenState } from '../combat/dualScreenPayloads' +import { + createPveCombatState, + createPveRoguelikeRunStart, +} from '../combat/pveRoguelikeRunSetup' +import { + appendCombatLog, + type BasicFloatingCombatText, +} from '../combat/combatPresentation' +import { canCastSpell } from '../combat/spellCasting' +import { + applySpellEffectProfile, + buildSpellTargetPlan, + type SpellEffectProfile, +} from '../combat/spellEffects' import { useGameAction, useInput, - type InputAction, } from '../input' -import { ControllerBindingLabel } from './ControllerIcons' +import { useFloatingCombatText } from '../hooks/useFloatingCombatText' +import { usePartyTargeting } from '../hooks/usePartyTargeting' +import { PartyMemberFrame } from './PartyFrames' +import { ResourceBar, SpellBar, type SpellSlot } from './SpellBars' +import { BonusItemReward, LootRollList, RewardXpSummary } from './RewardPanels' +import { ResultScreen } from './ResultScreen' import { DualScreenTopCombat, useDualScreen, useDualScreenPublisher, - type DualScreenCombatState, } from '../dualScreen' const TICK_MS = 700 @@ -71,12 +113,6 @@ type RoguelikeUpgrade = { description: string } -type FloatingCombatText = { - id: number - memberId: string - value: number -} - type SinglePlayerCombatState = { party: PartyMember[] resource: number @@ -97,96 +133,14 @@ const ROGUELIKE_MECHANICS: RoguelikeMechanic[] = [ 'ramping-poison', ] -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)) -} - -function effectiveMaxHealth(member: PartyMember) { - return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1))) -} - -function healAmount(member: PartyMember, amount: number, multiplier = 1) { - return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1) * multiplier) -} - -function healMember(member: PartyMember, amount: number, multiplier = 1) { - return clamp(member.health + healAmount(member, amount, multiplier), 0, effectiveMaxHealth(member)) -} - -function memberHotEffects(member: PartyMember) { - if (member.hotEffects?.length) return member.hotEffects - return member.hotTicks > 0 - ? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }] - : [] -} - -function effectId(prefix: string) { - return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}` -} - -function addHotEffect(member: PartyMember, spell: Spell, ticks = 5) { - const nextEffect = { - id: effectId(spell.id), - spellId: spell.id, - label: spell.name, - ticks, - power: Math.max(1, Math.round(spell.power / 2)), - } - const currentEffects = memberHotEffects(member).filter((effect) => effect.spellId !== spell.id) - return [...currentEffects, nextEffect] -} - -function addBounceHeal(member: PartyMember, spell: Spell) { - return [ - ...(member.bounceHeals ?? []), - { - id: effectId(spell.id), - label: spell.name, - charges: 4, - power: spell.power, - }, - ] -} - -function tickHotEffects(effects: PartyMember['hotEffects']) { - return (effects ?? []) - .map((effect) => ({ ...effect, ticks: effect.ticks - 1 })) - .filter((effect) => effect.ticks > 0) -} - -function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) { - return upgrades.filter((upgrade) => upgrade.id === id).length -} - -function slotLabel(slot: SlotKey, spells: Spell[], labelMode: RoguelikeAbilityLabelMode) { - const spell = spells.find((candidate) => candidate.key === slot) - if (labelMode === 'ability' && spell) return spell.name - return `Slot ${slot}` -} - function buildRoguelikeUpgrades( spells: Spell[], labelMode: RoguelikeAbilityLabelMode, ): RoguelikeUpgrade[] { - const slotUpgrades = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => { - const label = slotLabel(slot, spells, labelMode) - return [ - { - id: `slot${slot}-extra-target` as RoguelikeUpgradeId, - name: `${label}: +1 target`, - description: `${label} affects 1 additional ally when possible.`, - }, - { - id: `slot${slot}-cost-down` as RoguelikeUpgradeId, - name: `${label}: -25% cost`, - description: `${label} costs 25% less resource.`, - }, - { - id: `slot${slot}-cooldown-down` as RoguelikeUpgradeId, - name: `${label}: -25% cooldown`, - description: `${label} recharges 25% faster.`, - }, - ] + const slotUpgrades = buildSelfSlotUpgradeChoices({ + slots: ['1', '2', '3', '4', '5', '6'] as SlotKey[], + spells, + labelMode, }) return [ ...slotUpgrades, @@ -212,71 +166,47 @@ function summarizeUpgradeStacks( upgrades: RoguelikeUpgrade[], catalog: RoguelikeUpgrade[], ) { - const counts = new Map() - upgrades.forEach((upgrade) => counts.set(upgrade.id, (counts.get(upgrade.id) ?? 0) + 1)) - return Array.from(counts.entries()) - .map(([id, count]) => { - const name = catalog.find((upgrade) => upgrade.id === id)?.name ?? id - return count > 1 ? `${name} x${count}` : name - }) - .join(', ') -} - -function cooldownMultiplier(spell: Spell, upgrades: RoguelikeUpgrade[]) { - return 0.75 ** upgradeStackCount(upgrades, `slot${spell.key as SlotKey}-cooldown-down` as RoguelikeUpgradeId) -} - -function spellResourceCost(spell: Spell, upgrades: RoguelikeUpgrade[], freeCastReady: boolean) { - const adjustedCost = Math.ceil( - spell.cost * (0.75 ** upgradeStackCount(upgrades, `slot${spell.key as SlotKey}-cost-down` as RoguelikeUpgradeId)), + return summarizeChoiceStacks( + upgrades.map((upgrade) => upgrade.id), + catalog, ) - return freeCastReady && upgradeStackCount(upgrades, 'fifth-cast-free') > 0 ? 0 : adjustedCost } -function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell { - const kinds: Record = { - direct_heal: 'direct', - direct_hot: 'direct', - heal_over_time: 'hot', - party_heal: 'group', - party_hot: 'group', - party_absorb: 'group', - absorb: 'shield', - damage_reduction: 'damage_reduction', - bounce_heal: 'bounce_heal', - cleanse: 'cleanse', - } - return { - id: String(ability.id), - key, - name: ability.name, - description: ability.description, - cost: ability.cost, - cooldown: ability.cooldown, - power: ability.power + healingPower, - glyph: ability.glyph, - kind: kinds[ability.spellType] ?? 'direct', - effectType: ability.spellType, - } +function cooldownMultiplier(spell: Spell, upgrades: StackCounts) { + return spellCooldownMultiplier(spell, { + stacks: upgrades, + id: (slot) => `slot${slot as SlotKey}-cooldown-down` as RoguelikeUpgradeId, + }) +} + +function spellResourceCost(spell: Spell, upgrades: StackCounts, freeCastReady: boolean) { + return modifiedSpellResourceCost({ + spell, + costDown: { + stacks: upgrades, + id: (slot) => `slot${slot as SlotKey}-cost-down` as RoguelikeUpgradeId, + }, + freeCastReady, + freeCast: { + stacks: upgrades, + id: 'fifth-cast-free', + }, + }) } function getCurrentPart(encounterIndex: number) { return Math.floor(encounterIndex / 3) + 1 } -function chooseRandom(items: T[], count: number) { - const pool = [...items] - const result: T[] = [] - while (pool.length > 0 && result.length < count) { - const index = Math.floor(Math.random() * pool.length) - result.push(pool.splice(index, 1)[0]) +function chooseOtherLivingMember(living: PartyMember[], member: PartyMember) { + if (living.length <= 1) return member + let remaining = Math.floor(Math.random() * (living.length - 1)) + for (const candidate of living) { + if (candidate.id === member.id) continue + if (remaining === 0) return candidate + remaining -= 1 } - return result -} - -function formatEffectTime(ticks: number) { - const seconds = (ticks * TICK_MS) / 1000 - return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s` + return member } function mechanicLabel(mechanic: RoguelikeMechanic) { @@ -298,43 +228,22 @@ function makeRoguelikeSegment( difficulty: Difficulty, mode: RoguelikeMode, ): RoguelikeEncounter[] { - const encounterThreat = (encounter: DungeonEncounter) => ( - encounter.maxHealth - + encounter.damage * 18 - + encounter.tankDamage * 10 - + encounter.partyDamage * 18 - ) - const trashPool = [...pool.filter((encounter) => !encounter.isBoss)] - .sort((left, right) => encounterThreat(left) - encounterThreat(right)) - const bossPool = [...pool.filter((encounter) => encounter.isBoss)] - .sort((left, right) => encounterThreat(left) - encounterThreat(right)) - const trashCandidateCount = Math.min(trashPool.length, 4 + stage * (mode === 'raid' ? 1 : 2)) - const bossCandidateCount = Math.min(bossPool.length, 2 + Math.floor((stage + 1) / 2)) - const selectedTrash = chooseRandom(trashPool.slice(0, trashCandidateCount), 2) - const selectedBoss = chooseRandom(bossPool.slice(0, bossCandidateCount), 1)[0] ?? trashPool[0] ?? pool[0] - const healthScale = 0.64 + stage * (mode === 'raid' ? 0.15 : 0.11) - const damageScale = 0.58 + stage * (mode === 'raid' ? 0.13 : 0.1) const mechanics = chooseRandom(ROGUELIKE_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4)) - return [...selectedTrash, selectedBoss].map((encounter, index) => { - const isBoss = index === 2 - return { - ...encounter, - id: 900000 + stage * 10 + index, - sequence: (stage - 1) * 3 + index + 1, - isBoss, - encounterType: isBoss ? 'boss' : 'trash', - enemyName: isBoss ? `${encounter.enemyName} ${stage}` : encounter.enemyName, - description: isBoss - ? `Roguelike boss with ${mechanics.map(mechanicLabel).join(', ')}.` - : encounter.description, - maxHealth: Math.round(encounter.maxHealth * difficulty.healthMultiplier * healthScale), - damage: Math.round(encounter.damage * difficulty.damageMultiplier * damageScale), - tankDamage: Math.round(encounter.tankDamage * difficulty.damageMultiplier * damageScale), - partyDamage: Math.round(encounter.partyDamage * (0.9 + stage * 0.05)), - lootTables: [], - roguelikeMechanics: isBoss ? mechanics : [], - } - }) + return buildRoguelikeSegment({ + pool, + stage, + mechanics, + trashCandidateCount: (trashCount) => Math.min(trashCount, 4 + stage * (mode === 'raid' ? 1 : 2)), + bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 1) / 2)), + healthScale: difficulty.healthMultiplier * (0.64 + stage * (mode === 'raid' ? 0.15 : 0.11)), + damageScale: difficulty.damageMultiplier * (0.58 + stage * (mode === 'raid' ? 0.13 : 0.1)), + partyDamageScale: 0.9 + stage * 0.05, + idBase: 900000, + bossDescription: (selectedMechanics) => `Roguelike boss with ${selectedMechanics.map(mechanicLabel).join(', ')}.`, + extraFields: (_encounter, isBoss, selectedMechanics) => ({ + roguelikeMechanics: isBoss ? selectedMechanics : [], + }), + }) as RoguelikeEncounter[] } export function CombatScreen({ @@ -384,12 +293,30 @@ export function CombatScreen({ (candidate) => candidate.id === profile.character.classId, )! const healingPower = isRoguelike ? 0 : profile.gearStats.healingPower - const spells = profile.abilitySlots.flatMap((abilityId, index) => { - const ability = gameClass.spells.find((candidate) => candidate.id === abilityId) - return ability - ? [toCombatSpell(ability, String(index + 1), healingPower)] - : [] - }) + const spells = useMemo(() => { + const abilityById = new Map(gameClass.spells.map((ability) => [ability.id, ability])) + return profile.abilitySlots.flatMap((abilityId, index) => { + const ability = abilityId === null ? undefined : abilityById.get(abilityId) + return ability + ? [toCombatSpell(ability, String(index + 1), healingPower)] + : [] + }) + }, [gameClass.spells, healingPower, profile.abilitySlots]) + const spellByKey = useMemo( + () => new Map(spells.map((spell) => [spell.key, spell])), + [spells], + ) + const spellByName = useMemo( + () => new Map(spells.map((spell) => [spell.name, spell])), + [spells], + ) + const effectSpellByName = useMemo( + () => new Map(gameClass.spells.map((ability) => [ + ability.name, + toCombatSpell(ability, `effect-${ability.id}`, healingPower), + ])), + [gameClass.spells, healingPower], + ) const roguelikeUpgradeCatalog = useMemo( () => buildRoguelikeUpgrades(spells, roguelikeAbilityLabelMode), [roguelikeAbilityLabelMode, spells], @@ -406,14 +333,11 @@ export function CombatScreen({ const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon' const initialEncounterIndex = (startPart - 1) * 3 const enemyCount = hardMode ? 2 : 1 - const initialCombatState = useMemo(() => ({ - party: partyTemplate, - resource: maxResource, - enemyHealth: encounters[initialEncounterIndex].maxHealth * enemyCount, - cooldowns: {}, - elapsedTicks: 0, - castsTowardFree: 0, - freeCastReady: false, + const initialCombatState = useMemo(() => createPveCombatState({ + partyTemplate, + maxResource, + encounter: encounters[initialEncounterIndex], + enemyCount, }), [encounters, enemyCount, initialEncounterIndex, maxResource, partyTemplate]) const [combatState, setCombatState] = useState(() => initialCombatState) const [selectedId, setSelectedId] = useState(partyTemplate[0].id) @@ -429,7 +353,12 @@ export function CombatScreen({ const [rewardError, setRewardError] = useState('') const [lootRolls, setLootRolls] = useState([]) const [showEndLog, setShowEndLog] = useState(false) - const [floatingTexts, setFloatingTexts] = useState([]) + const { + floatingTexts, + floatingTextsByMember, + addFloatingText, + clearFloatingTexts, + } = useFloatingCombatText() const [roguelikeUpgrades, setRoguelikeUpgrades] = useState([]) const [upgradeChoices, setUpgradeChoices] = useState([]) const [marathonBossesDefeated, setMarathonBossesDefeated] = useState(0) @@ -441,13 +370,12 @@ export function CombatScreen({ const runStartedAtRef = useRef(0) const partStartTimesRef = useRef>({}) const nextLogId = useRef(2) - const nextFloatingTextId = useRef(1) const marathonBossesDefeatedRef = useRef(0) const combatRef = useRef(initialCombatState) const selectedIdRef = useRef(partyTemplate[0].id) const runCombatTickRef = useRef<() => void>(() => {}) const combatClockActiveRef = useRef(false) - const lastCombatTickAtRef = useRef(performance.now()) + const lastCombatTickAtRef = useRef(0) const statusRef = useRef(status) const pausedRef = useRef(paused) const speedMultiplierRef = useRef<1 | 2>(speedMultiplier) @@ -460,15 +388,22 @@ export function CombatScreen({ : profile.completedDungeonParts const canContinueAfterPart = !hardMode || completedSections >= currentPart + 1 const firstEncounterIndex = (startPart - 1) * 3 - const expectedLootRolls = encounters - .slice(firstEncounterIndex, encounterIndex + 1) - .filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id)) - .length * enemyCount + const expectedLootRolls = useMemo( + () => encounters + .slice(firstEncounterIndex, encounterIndex + 1) + .filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id)) + .length * enemyCount, + [difficulty.id, encounters, encounterIndex, enemyCount, firstEncounterIndex], + ) const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2 const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1 const playerHealer = party.find((member) => member.id === 'mira') const playerIsAlive = Boolean(playerHealer && playerHealer.health > 0) const upgradesEveryEncounter = roguelikeUpgradeTiming === 'encounter' + const roguelikeUpgradeCounts = useMemo( + () => createStackCounts(roguelikeUpgrades.map((upgrade) => upgrade.id)), + [roguelikeUpgrades], + ) const activeEffects = useMemo( () => { const effects = new Set( @@ -494,10 +429,17 @@ export function CombatScreen({ const { enabled: dualScreenEnabled, } = useDualScreen() + const activeBindings = bindings[lastDevice] - statusRef.current = status - pausedRef.current = paused - speedMultiplierRef.current = speedMultiplier + useEffect(() => { + lastCombatTickAtRef.current = performance.now() + }, []) + + useEffect(() => { + statusRef.current = status + pausedRef.current = paused + speedMultiplierRef.current = speedMultiplier + }, [paused, speedMultiplier, status]) useEffect(() => { const now = Date.now() @@ -543,17 +485,12 @@ export function CombatScreen({ const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => { const entry = { id: nextLogId.current++, text, tone } - setLog((current) => [entry, ...current].slice(0, 60)) + setLog((current) => appendCombatLog(current, entry)) }, []) const addFloatingHeal = useCallback((memberId: string, value: number) => { - if (value <= 0) return - const id = nextFloatingTextId.current++ - setFloatingTexts((current) => [...current, { id, memberId, value }]) - window.setTimeout(() => { - setFloatingTexts((current) => current.filter((entry) => entry.id !== id)) - }, 900) - }, []) + addFloatingText({ memberId, value }) + }, [addFloatingText]) const requestLootRoll = useCallback( (encounterId: number, rollIndex = 0) => { @@ -589,221 +526,168 @@ export function CombatScreen({ ? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode) : [] const nextEncounters = roguelikeMode ? nextRoguelikeEncounters : staticEncounters - const freshParty = partyTemplate.map((member) => ({ ...member })) - setCombat({ - party: freshParty, - resource: maxResource, - enemyHealth: nextEncounters[initialEncounterIndex].maxHealth * enemyCount, - cooldowns: {}, - elapsedTicks: 0, - castsTowardFree: 0, - freeCastReady: false, + const setup = createPveRoguelikeRunStart({ + partyTemplate, + maxResource, + encounters: nextEncounters, + initialEncounterIndex, + enemyCount, }) + setCombat(setup.combatState) if (roguelikeMode) setRoguelikeEncounters(nextRoguelikeEncounters) - setRoguelikeStage(1) - setSelectedTargetId(partyTemplate[0].id) - setEncounterIndex(initialEncounterIndex) - setStatus('playing') - setPaused(false) - setTargetGroup(0) - setReward(null) - setRewardError('') - setLootRolls([]) - setShowEndLog(false) - setFloatingTexts([]) - setRoguelikeUpgrades([]) - setUpgradeChoices([]) - setMarathonBossesDefeated(0) + setRoguelikeStage(setup.defaults.roguelikeStage) + setSelectedTargetId(setup.defaults.selectedId) + setEncounterIndex(setup.defaults.encounterIndex) + setStatus(setup.defaults.status) + setPaused(setup.defaults.paused) + setTargetGroup(setup.defaults.targetGroup) + setReward(setup.defaults.reward) + setRewardError(setup.defaults.rewardError) + setLootRolls(setup.defaults.lootRolls) + setShowEndLog(setup.defaults.showEndLog) + clearFloatingTexts() + setRoguelikeUpgrades(setup.defaults.roguelikeUpgrades) + setUpgradeChoices(setup.defaults.upgradeChoices) + setMarathonBossesDefeated(setup.defaults.marathonBossesDefeated) rewardClaimedRef.current = false profileRefreshedRef.current = false rolledEncounterIdsRef.current = new Set() runTokenRef.current = crypto.randomUUID() marathonBossesDefeatedRef.current = 0 - resourceSpentRef.current = 0 + resourceSpentRef.current = setup.defaults.resourceSpent runStartedAtRef.current = Date.now() partStartTimesRef.current = { [startPart]: runStartedAtRef.current } - setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }]) - }, [difficulty, enemyCount, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters]) + setLog([{ id: nextLogId.current++, ...setup.defaults.log }]) + }, [clearFloatingTexts, difficulty, enemyCount, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters]) const castSpell = useCallback( (spell: Spell) => { const current = combatRef.current - const effectiveCost = spellResourceCost(spell, roguelikeUpgrades, current.freeCastReady) - if (status !== 'playing' || current.cooldowns[spell.id] > 0 || current.resource < effectiveCost) return + const effectiveCost = spellResourceCost(spell, roguelikeUpgradeCounts, current.freeCastReady) + if (status !== 'playing' || !canCastSpell(spell, current.resource, current.cooldowns, effectiveCost)) return const healer = current.party.find((member) => member.id === 'mira') if (!healer || healer.health <= 0) return const targetId = selectedIdRef.current const selected = current.party.find((member) => member.id === targetId) if (!selected || selected.health <= 0) return - const extraTarget = (blockedIds: string[]) => current.party - .filter((member) => member.health > 0 && !blockedIds.includes(member.id)) - .sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0] - const effectSpell = (name: string) => { - const ability = gameClass.spells.find((candidate) => candidate.name === name) - return ability ? toCombatSpell(ability, `effect-${ability.id}`, healingPower) : null - } - const renewEffect = effectSpell('Renew') - const shieldEffect = effectSpell('Sun Ward') + const renewEffect = effectSpellByName.get('Renew') ?? null + const shieldEffect = effectSpellByName.get('Sun Ward') ?? null const healingMultiplier = (member: PartyMember) => activeEffects.has('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1 - const directTargets = new Set([targetId]) - const hotTargets = new Set() - const shieldTargets = new Set() - const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId) - const groupTargets = new Set( - spell.kind === 'group' - ? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id) - : [], - ) - if (spell.kind === 'hot' || spell.effectType === 'direct_hot') hotTargets.add(targetId) - if (spell.kind === 'shield') shieldTargets.add(targetId) - if (spell.name === 'Mend' && activeEffects.has('mend_extra_target')) { - const extra = extraTarget([targetId]) - if (extra) directTargets.add(extra.id) - } - if (spell.name === 'Renew' && activeEffects.has('renew_extra_target')) { - const extra = extraTarget([targetId]) - if (extra) hotTargets.add(extra.id) - } + const extraTargets = spellExtraTargets(spell, { + stacks: roguelikeUpgradeCounts, + id: (slot) => `slot${slot as SlotKey}-extra-target` as RoguelikeUpgradeId, + }) + const { + directTargets, + hotTargets, + shieldTargets, + groupTargets, + } = buildSpellTargetPlan({ + party: current.party, + spell, + targetId, + extraTargets, + directTarget: true, + hotTarget: spell.kind === 'hot' || spell.effectType === 'direct_hot', + shieldTarget: spell.kind === 'shield', + groupTargetCount: DEFAULT_GROUP_HEAL_TARGETS + extraTargets, + extraTargetMode: { + hot: 'hot', + shield: 'shield', + }, + beforeExtraTargetBuckets: [ + ...(spell.name === 'Mend' && activeEffects.has('mend_extra_target') ? ['direct' as const] : []), + ...(spell.name === 'Renew' && activeEffects.has('renew_extra_target') ? ['hot' as const] : []), + ], + }) if (spell.name === 'Mend' && activeEffects.has('mend_applies_renew')) { hotTargets.add(targetId) } - 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) + if (spell.name === 'Mend' && activeEffects.has('mend_applies_shield') && shieldEffect) { + directTargets.forEach((id) => shieldTargets.add(id)) } - const nextParty = current.party.map((member) => { - if (member.health <= 0) return member - if (spell.kind === 'group') { - if (!groupTargets.has(member.id)) return member - if (spell.effectType === 'party_absorb') { - const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost'))) - return { ...member, shield: Math.max(member.shield, power) } - } - if (spell.effectType === 'party_hot') { - return { - ...member, - hotTicks: 0, - hotEffects: addHotEffect(member, spell), - } - } - const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost'))) - const nextHealth = healMember(member, power, healingMultiplier(member)) - addFloatingHeal(member.id, Math.max(0, nextHealth - member.health)) - const nextShield = spell.name === 'Radiance' && activeEffects.has('radiance_applies_shield') - ? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.3)) - : member.shield - return { - ...member, - health: nextHealth, - shield: nextShield, - hotTicks: spell.name === 'Radiance' && activeEffects.has('radiance_applies_renew') ? 0 : member.hotTicks, - hotEffects: spell.name === 'Radiance' && activeEffects.has('radiance_applies_renew') && renewEffect - ? addHotEffect(member, renewEffect, 3) - : member.hotEffects, - } - } - if ( - !directTargets.has(member.id) - && !hotTargets.has(member.id) - && !shieldTargets.has(member.id) - && !(member.id === targetId && (spell.kind === 'damage_reduction' || spell.kind === 'bounce_heal')) - ) return member - if (spell.kind === 'shield') { - const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost'))) - return { - ...member, - hotTicks: activeEffects.has('shield_applies_renew') && renewEffect ? 0 : member.hotTicks, - hotEffects: activeEffects.has('shield_applies_renew') && renewEffect - ? addHotEffect(member, renewEffect) - : member.hotEffects, - shield: Math.max(member.shield, power), - } - } - if (spell.kind === 'damage_reduction') { - return { ...member, damageReductionTicks: 12 } - } - if (spell.kind === 'bounce_heal') { - return { ...member, bounceHeals: addBounceHeal(member, spell) } - } - if (spell.kind === 'cleanse') { - return { - ...member, - health: healMember(member, spell.power, healingMultiplier(member)), - debuff: undefined, - debuffTicks: undefined, - poisonStacks: undefined, - maxHealthPenaltyTicks: undefined, - healingReductionTicks: undefined, - } - } - const nextHealth = directTargets.has(member.id) - ? healMember(member, spell.power, healingMultiplier(member)) - : member.health - if (nextHealth > member.health) addFloatingHeal(member.id, nextHealth - member.health) - const nextShield = spell.name === 'Mend' && directTargets.has(member.id) && activeEffects.has('mend_applies_shield') - ? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.5)) - : member.shield - const appliedHotSpell = spell.name === 'Mend' && activeEffects.has('mend_applies_renew') && renewEffect - ? renewEffect - : spell - return { - ...member, - health: nextHealth, - shield: nextShield, - hotTicks: 0, - hotEffects: hotTargets.has(member.id) - ? addHotEffect(member, appliedHotSpell) - : member.hotEffects, - } - }) - const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free') - const nextFreeCastReady = freeCastStacks > 0 && current.freeCastReady - ? false - : current.freeCastReady - const nextCastsTowardFree = freeCastStacks > 0 - ? current.freeCastReady - ? 0 - : current.castsTowardFree + 1 >= 5 - ? 0 - : current.castsTowardFree + 1 - : current.castsTowardFree - const gainedFreeCast = freeCastStacks > 0 - && !current.freeCastReady - && current.castsTowardFree + 1 >= 5 - resourceSpentRef.current += effectiveCost - const nextCooldowns = { - ...current.cooldowns, + const shieldBoost = spellPowerMultiplier({ stacks: roguelikeUpgradeCounts, id: 'shield-boost' }) + const groupHealBoost = spellPowerMultiplier({ stacks: roguelikeUpgradeCounts, id: 'group-heal-boost' }) + const spellEffectProfile: SpellEffectProfile = { + modeName: 'pve', + heal: healMember, + healingMultiplier, + power: { + direct: (source) => source.power, + cleanse: (source) => source.power, + groupHeal: (source) => Math.round(source.power * groupHealBoost), + groupAbsorb: (source) => Math.round(source.power * shieldBoost), + shield: (sourcePower, strength = 1) => Math.round(sourcePower * strength * shieldBoost), + }, + hot: { + mode: 'effects', + defaultTicks: 5, + groupTicks: 5, + radianceTicks: 3, + merge: 'replace', + }, + effects: { + renewSpell: renewEffect, + shieldSpell: shieldEffect, + groupAbsorbOnly: (source) => source.effectType === 'party_absorb', + groupHotOnly: (source) => source.effectType === 'party_hot', + groupAppliesShield: (source) => source.effectType === 'party_absorb' + || (source.name === 'Radiance' && activeEffects.has('radiance_applies_shield')), + groupAppliesHot: (source) => source.name === 'Radiance' && activeEffects.has('radiance_applies_renew'), + shieldAppliesHot: () => activeEffects.has('shield_applies_renew') && Boolean(renewEffect), + hotSpellForDirect: (source) => source.name === 'Mend' && activeEffects.has('mend_applies_renew') && renewEffect + ? renewEffect + : source, + }, + ratios: { + groupShield: 0.3, + directShield: 0.5, + }, + damageReductionTicks: 12, + floatingHeals: { + group: true, + direct: true, + cleanse: false, + }, + bounceHeals: true, } + const { party: nextParty, floatingHeals } = applySpellEffectProfile({ + party: current.party, + spell, + targetId, + plan: { + directTargets, + hotTargets, + shieldTargets, + damageReductionTargets: new Set(), + groupTargets, + }, + profile: spellEffectProfile, + }) + floatingHeals.forEach((event) => addFloatingHeal(event.memberId, event.value)) + resourceSpentRef.current += effectiveCost + const nextCooldowns = { ...current.cooldowns } if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) { - const radiance = spells.find((candidate) => candidate.name === 'Radiance') + const radiance = spellByName.get('Radiance') if (radiance) nextCooldowns[radiance.id] = Math.max(0, (nextCooldowns[radiance.id] ?? 0) - 2) } - nextCooldowns[spell.id] = spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades) - - setCombat({ - ...current, + setCombat(applyCastStateUpdate({ + current, party: nextParty, - resource: current.resource - effectiveCost, cooldowns: nextCooldowns, - castsTowardFree: nextCastsTowardFree, - freeCastReady: gainedFreeCast || nextFreeCastReady, - }) + spell, + resourceCost: effectiveCost, + cooldownMultiplier: cooldownMultiplier(spell, roguelikeUpgradeCounts), + freeCast: { + enabled: hasSpellStack({ stacks: roguelikeUpgradeCounts, id: 'fifth-cast-free' }), + wasReady: current.freeCastReady, + }, + })) addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal') }, - [activeEffects, addFloatingHeal, addLog, gameClass.spells, healingPower, roguelikeUpgrades, setCombat, spells, status], + [activeEffects, addFloatingHeal, addLog, effectSpellByName, roguelikeUpgradeCounts, setCombat, spellByName, status], ) const finishRun = useCallback( @@ -866,62 +750,21 @@ export function CombatScreen({ [difficulty.id, dungeon.id, onProfileUpdated], ) - const selectRelativeTarget = useCallback((direction: -1 | 1) => { - const living = combatRef.current.party.filter((member) => member.health > 0) - if (living.length === 0) return - const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current) - const nextIndex = currentIndex < 0 - ? 0 - : (currentIndex + direction + living.length) % living.length - setSelectedTargetId(living[nextIndex].id) - }, [setSelectedTargetId]) - - const selectDirectionalTarget = useCallback((action: InputAction) => { - const columns = dungeon.partySize >= 10 ? 6 : 3 - const currentIndex = combatRef.current.party.findIndex((member) => member.id === selectedIdRef.current) - if (currentIndex < 0) { - setSelectedTargetId(combatRef.current.party[0].id) - return - } - const currentRow = Math.floor(currentIndex / columns) - const currentColumn = currentIndex % columns - const candidates = combatRef.current.party - .map((member, index) => ({ - member, - index, - row: Math.floor(index / columns), - column: index % columns, - })) - .filter(({ index, row, column }) => { - if (index === currentIndex) return false - if (action === 'navigateLeft') return row === currentRow && column < currentColumn - if (action === 'navigateRight') return row === currentRow && column > currentColumn - if (action === 'navigateUp') return row < currentRow - return row > currentRow - }) - .sort((a, b) => { - const aPrimary = action === 'navigateLeft' || action === 'navigateRight' - ? Math.abs(a.column - currentColumn) - : Math.abs(a.row - currentRow) - const bPrimary = action === 'navigateLeft' || action === 'navigateRight' - ? Math.abs(b.column - currentColumn) - : Math.abs(b.row - currentRow) - const aSecondary = action === 'navigateLeft' || action === 'navigateRight' - ? 0 - : Math.abs(a.column - currentColumn) - const bSecondary = action === 'navigateLeft' || action === 'navigateRight' - ? 0 - : Math.abs(b.column - currentColumn) - return aPrimary - bPrimary || aSecondary - bSecondary - }) - if (candidates[0]) setSelectedTargetId(candidates[0].member.id) - }, [dungeon.partySize, setSelectedTargetId]) - - const selectDirectTarget = useCallback((slot: number) => { - const index = slot + (dungeon.partySize > 6 ? targetGroup * 6 : 0) - const member = combatRef.current.party[index] - if (member) setSelectedTargetId(member.id) - }, [dungeon.partySize, setSelectedTargetId, targetGroup]) + const getTargetParty = useCallback(() => combatRef.current.party, []) + const { + selectRelativeTarget, + selectDirectionalTarget, + selectDirectTarget, + } = usePartyTargeting({ + getParty: getTargetParty, + selectedIdRef, + setSelectedTargetId, + columns: dungeon.partySize >= 10 ? 6 : 3, + relativeLivingOnly: true, + directionalLivingOnly: false, + directLivingOnly: false, + directTargetGroup: dungeon.partySize > 6 ? targetGroup : 0, + }) const chooseRoguelikeUpgrade = useCallback((upgrade: RoguelikeUpgrade) => { if (!roguelikeMode) return @@ -989,12 +832,9 @@ export function CombatScreen({ if (action === 'toggleTargetGroup') { if (dungeon.partySize <= 6) return setTargetGroup((current) => { - const groupCount = Math.max(1, Math.ceil(combatRef.current.party.length / 6)) - const next = ((current + 1) % groupCount) as 0 | 1 | 2 - const selectedIndex = combatRef.current.party.findIndex((member) => member.id === selectedIdRef.current) - const nextMember = combatRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6] - if (nextMember) setSelectedTargetId(nextMember.id) - return next + const next = nextTargetGroupSelection(combatRef.current.party, selectedIdRef.current, current) + if (next.selectedId) setSelectedTargetId(next.selectedId) + return next.group as 0 | 1 | 2 }) return } @@ -1008,20 +848,15 @@ export function CombatScreen({ } if (!action.startsWith('ability')) return const slot = Number(action.slice('ability'.length)) - 1 - const spell = spells.find((candidate) => candidate.key === String(slot + 1)) + const spell = spellByKey.get(String(slot + 1)) if (spell) castSpell(spell) }) const runCombatTick = useCallback(() => { const current = combatRef.current const nextElapsedTicks = current.elapsedTicks + 1 - const nextCooldowns = Object.fromEntries( - Object.entries(current.cooldowns).map(([id, seconds]) => [ - id, - Math.max(0, seconds - TICK_MS / 1000), - ]), - ) - let nextResource = clamp(current.resource + 2.4, 0, maxResource) + const nextCooldowns = advanceCooldowns(current.cooldowns, tickSeconds(TICK_MS)) + let nextResource = regenerateResource(current.resource, 2.4, maxResource) const living = current.party.filter((member) => member.health > 0) if (living.length === 0) { @@ -1062,6 +897,8 @@ export function CombatScreen({ const healerBeforeDamage = current.party.find((member) => member.id === 'mira') const tankPressure = tankPressureTargets(current.party) const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id)) + const hasShieldedHealingBonus = activeEffects.has('shielded_healing_bonus') + const shieldedDamageMultiplier = activeEffects.has('shielded_damage_reduction') ? 0.8 : undefined const pendingJumpHeals: Array<{ targetId: string heal: NonNullable[number] @@ -1077,75 +914,33 @@ export function CombatScreen({ } if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier) if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier) - const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id - ? Math.max(1, (member.poisonStacks ?? 0) + 1) - : member.poisonStacks ?? 0 - if (nextPoisonStacks > 0) damage += Math.round((4 + nextPoisonStacks * 4) * difficulty.damageMultiplier) damage *= enemyCount - if ((member.damageReductionTicks ?? 0) > 0) { - damage = Math.round(damage * 0.5) - } - if (member.shield > 0 && activeEffects.has('shielded_damage_reduction')) { - damage = Math.round(damage * 0.8) - } - const absorbed = Math.min(member.shield, damage) - const hotEffects = memberHotEffects(member) - const healingMultiplier = member.shield > 0 && activeEffects.has('shielded_healing_bonus') ? 1.2 : 1 - let healing = hotEffects.reduce((total, effect) => total + healAmount(member, effect.power, healingMultiplier), 0) - let nextBounceHeals = [...(member.bounceHeals ?? [])] - if (damage > 0 && nextBounceHeals.length > 0) { - nextBounceHeals = nextBounceHeals.flatMap((effect) => { - healing += healAmount(member, effect.power, healingMultiplier) - const nextCharges = effect.charges - 1 - if (nextCharges <= 0) return [] - const jumpTargets = current.party.filter((candidate) => candidate.health > 0 && candidate.id !== member.id) - const jumpTarget = jumpTargets[Math.floor(Math.random() * jumpTargets.length)] ?? member - pendingJumpHeals.push({ - targetId: jumpTarget.id, - heal: { ...effect, charges: nextCharges }, - }) - return [] - }) - } - if (healing > 0) addFloatingHeal(member.id, healing) - const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id - ? 15 - : Math.max(0, (member.maxHealthPenaltyTicks ?? 0) - 1) - const nextHealingReductionTicks = appliesHealingReduction && member.id === primaryTarget.id - ? 15 - : Math.max(0, (member.healingReductionTicks ?? 0) - 1) - const nextEffectiveMaxHealth = Math.max(1, Math.round(member.maxHealth * (nextMaxHealthPenaltyTicks > 0 ? 0.75 : 1))) - const nextDebuffTicks = appliesDebuff && member.id === primaryTarget.id - ? 8 - : Math.max(0, (member.debuffTicks ?? 0) - 1) - return { - ...member, - health: clamp(clamp(member.health + healing, 0, nextEffectiveMaxHealth) - damage + absorbed, 0, nextEffectiveMaxHealth), - shield: Math.max(0, member.shield - damage), - hotTicks: 0, - hotEffects: tickHotEffects(hotEffects), - bounceHeals: nextBounceHeals, - damageReductionTicks: Math.max(0, (member.damageReductionTicks ?? 0) - 1), - debuff: nextDebuffTicks > 0 - ? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff) + const healingMultiplier = member.shield > 0 && hasShieldedHealingBonus ? 1.2 : 1 + const hasBounceHeals = Boolean(member.bounceHeals?.length) + const result = advanceMemberTick({ + member, + party: current.party, + damage, + hotHealing: 0, + hotTicks: 'effects', + healingMultiplier, + shieldedDamageMultiplier, + applyDebuff: appliesDebuff && member.id === primaryTarget.id + ? { label: 'Searing Mark', ticks: 8 } : undefined, - debuffTicks: nextDebuffTicks > 0 ? nextDebuffTicks : undefined, - poisonStacks: nextPoisonStacks, - maxHealthPenaltyTicks: nextMaxHealthPenaltyTicks, - healingReductionTicks: nextHealingReductionTicks, - } - }) - const nextParty = damagedParty.map((member) => { - const jumped = pendingJumpHeals.filter((jump) => jump.targetId === member.id) - if (jumped.length === 0) return member - return { - ...member, - bounceHeals: [ - ...(member.bounceHeals ?? []), - ...jumped.map((jump) => jump.heal), - ], - } + applyPoisonStacks: appliesPoison && member.id === primaryTarget.id, + poisonDamage: (stacks) => Math.round((4 + stacks * 4) * difficulty.damageMultiplier), + applyMaxHealthPenaltyTicks: appliesMaxHealthCut && member.id === primaryTarget.id ? 15 : undefined, + applyHealingReductionTicks: appliesHealingReduction && member.id === primaryTarget.id ? 15 : undefined, + decrementDamageReduction: true, + useBounceHeals: hasBounceHeals, + jumpTarget: hasBounceHeals ? () => chooseOtherLivingMember(living, member) : undefined, + }) + if (result.floatingHeal > 0) addFloatingHeal(member.id, result.floatingHeal) + pendingJumpHeals.push(...result.jumpedBounceHeals) + return result.member }) + const nextParty = attachJumpedBounceHeals(damagedParty, pendingJumpHeals) const healerAfterDamage = nextParty.find((member) => member.id === 'mira') if ( @@ -1289,13 +1084,13 @@ export function CombatScreen({ encounters, finishRun, finishRoguelikeRun, + difficulty.id, isPartBoss, isFinalBoss, isRoguelike, marathonMode, upgradesEveryEncounter, roguelikeUpgradeCatalog, - roguelikeUpgrades, maxResource, gameClass.resourceName, requestLootRoll, @@ -1355,15 +1150,29 @@ export function CombatScreen({ }, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward]) const enemyPercent = (enemyHealth / encounterMaxHealth) * 100 - const enemyHealthSegments = Array.from({ length: enemyCount }, (_, index) => { - const remaining = clamp(enemyHealth - encounter.maxHealth * index, 0, encounter.maxHealth) - return { - index, - health: remaining, - percent: (remaining / encounter.maxHealth) * 100, - } - }).reverse() - const dualScreenState = useMemo(() => ({ + const enemyHealthSegments = useMemo( + () => Array.from({ length: enemyCount }, (_, index) => { + const remaining = clamp(enemyHealth - encounter.maxHealth * index, 0, encounter.maxHealth) + return { + index, + health: remaining, + percent: (remaining / encounter.maxHealth) * 100, + } + }).reverse(), + [enemyCount, enemyHealth, encounter.maxHealth], + ) + const spellSlots = useMemo(() => profile.abilitySlots.map((abilityId, slotIndex) => { + const spell = spellByKey.get(String(slotIndex + 1)) + return abilityId && spell + ? { + ...spell, + cost: spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady), + slotIndex, + remaining: cooldowns[spell.id] ?? 0, + } + : null + }), [cooldowns, freeCastReady, profile.abilitySlots, roguelikeUpgradeCounts, spellByKey]) + const dualScreenState = useMemo(() => buildCombatDualScreenState({ difficultyName: difficulty.name, dungeonName: dungeon.name, contentName, @@ -1378,34 +1187,21 @@ export function CombatScreen({ floatingTexts, partySize: dungeon.partySize, selectedId, - log, status, resource, maxResource, resourceName: gameClass.resourceName, playerIsAlive, - spells: profile.abilitySlots.map((abilityId, slotIndex) => { - const spell = spells.find((candidate) => candidate.key === String(slotIndex + 1)) - return abilityId && spell - ? { - ...spell, - cost: spellResourceCost(spell, roguelikeUpgrades, freeCastReady), - slotIndex, - remaining: cooldowns[spell.id] ?? 0, - } - : null - }), - activeDevice: lastDevice, - bindings: bindings[lastDevice], + spells: spellSlots, + bindings: activeBindings, controllerIconStyle, directPartyTargeting, paused, targetGroup, speedMultiplier, }), [ - bindings, + activeBindings, controllerIconStyle, - cooldowns, contentName, difficulty.name, dungeon.name, @@ -1415,24 +1211,18 @@ export function CombatScreen({ encounter.enemyName, encounter.isBoss, encounterMaxHealth, - hardMode, enemyHealth, encounterIndex, encounters.length, gameClass.resourceName, - log, - lastDevice, maxResource, paused, party, playerIsAlive, - profile.abilitySlots, resource, selectedId, - spells, - freeCastReady, + spellSlots, floatingTexts, - roguelikeUpgrades, speedMultiplier, status, targetGroup, @@ -1490,60 +1280,25 @@ export function CombatScreen({
-
-
- - {playerIsAlive - ? `${gameClass.resourceName} ${Math.floor(resource)} / ${maxResource}` - : `${profile.character.name} is defeated`} - - {speedMultiplier === 2 && 2x speed} -
-
+
+
= 10 ? 'raid-party-grid' : ''}`}> {party.map((member) => ( - + member={member} + selected={selectedId === member.id} + floatingTexts={floatingTextsByMember.get(member.id) ?? []} + showTargetMarker + onSelect={setSelectedTargetId} + /> ))}
@@ -1552,42 +1307,15 @@ export function CombatScreen({

Actions

Skills

-
- {profile.abilitySlots.map((abilityId, slotIndex) => { - const spell = spells.find((candidate) => candidate.key === String(slotIndex + 1)) - if (!abilityId || !spell) { - return ( -
- {slotIndex + 1}Empty -
- ) - } - const remaining = cooldowns[spell.id] ?? 0 - const effectiveCost = spellResourceCost(spell, roguelikeUpgrades, freeCastReady) - return ( - - ) - })} -
+
@@ -1649,29 +1377,24 @@ export function CombatScreen({ )} {status !== 'playing' && status !== 'part-complete' && status !== 'marathon-choice' && status !== 'upgrade-choice' && ( -
-
-

{status === 'won' ? `${contentName} Complete` : 'Party Defeated'}

-

{status === 'won' ? 'The Warden Falls' : 'The Ashes Claim You'}

+ setShowEndLog((value) => !value)} + showLog={showEndLog} + title={status === 'won' ? 'The Warden Falls' : 'The Ashes Claim You'} + > {status === 'won' ? (
{!reward && !rewardError &&

Recording victory...

} {rewardError &&

{rewardError}

} {reward && ( <> -

+{reward.experienceGained} XP

- {reward.levelsGained > 0 && ( -

- Level {reward.previousLevel} to {reward.newLevel} - +{reward.talentPointsGained} talent point -

- )} - {reward.unlockedAbilities.map((ability) => ( -

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

- ))} + {!isRoguelike && ( <>

Component tier: item level {reward.droppedItemLevel}.

@@ -1681,34 +1404,8 @@ export function CombatScreen({ {reward.durationSeconds}s - iLvl {reward.averageItemLevel.toFixed(1)}

-
- {lootRolls.map((roll) => ( -
- {roll.encounterName} - - {roll.items.length > 0 - ? roll.items - .map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`) - .join(', ') - : 'No components dropped'} - -
- ))} - {lootRolls.length < expectedLootRolls && ( - Finishing loot rolls... - )} -
- {reward.bonusItem && ( -
-

Full Run Bonus

-
- {reward.bonusItem.glyph} - {reward.bonusItem.name} - Item Level {reward.bonusItem.itemLevel} x{reward.bonusItem.quantity} - {reward.bonusItem.duplicate && (owned x{reward.bonusItem.quantityAfter})} -
-
- )} + + )} @@ -1720,20 +1417,8 @@ export function CombatScreen({ {rewardError &&

{rewardError}

} {reward && ( <> -

+{reward.experienceGained} XP

+

{encounterIndex} encounters cleared.

- {reward.levelsGained > 0 && ( -

- Level {reward.previousLevel} to {reward.newLevel} - +{reward.talentPointsGained} talent point -

- )} - {reward.unlockedAbilities.map((ability) => ( -

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

- ))}

{reward.resourceSpent} {gameClass.resourceName} spent {reward.durationSeconds}s survived @@ -1744,24 +1429,7 @@ export function CombatScreen({ ) : (

Balance efficient healing, shields, and cleansing to survive.

)} - {log.length > 0 && ( - <> - - {showEndLog && ( -
- {log.slice().reverse().map((entry) => ( -
{entry.text}
- ))} -
- )} - - )} - - -
-
+ )} {status === 'marathon-choice' && (
diff --git a/src/components/ControllerIcons.tsx b/src/components/ControllerIcons.tsx index 3f8414e..95f396c 100644 --- a/src/components/ControllerIcons.tsx +++ b/src/components/ControllerIcons.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties } from 'react' +import { memo, type CSSProperties } from 'react' import { bindingLabel, compactBindingLabel, @@ -31,7 +31,7 @@ function faceButtonFor(binding: string, iconStyle: ControllerIconStyle) { return FACE_BUTTONS[iconStyle][Number(binding.slice(6))] ?? null } -function FaceIcon({ +const FaceIcon = memo(function FaceIcon({ color, iconStyle, label, @@ -52,9 +52,9 @@ function FaceIcon({ {label} ) -} +}) -export function ControllerBindingLabel({ +export const ControllerBindingLabel = memo(function ControllerBindingLabel({ binding, compact = false, iconStyle, @@ -78,9 +78,9 @@ export function ControllerBindingLabel({ } return <>{compact ? compactBindingLabel(binding, iconStyle) : title} -} +}) -export function ControllerStylePreview({ iconStyle }: { iconStyle: ControllerIconStyle }) { +export const ControllerStylePreview = memo(function ControllerStylePreview({ iconStyle }: { iconStyle: ControllerIconStyle }) { return ( ) -} +}) diff --git a/src/components/EquipmentScreen.tsx b/src/components/EquipmentScreen.tsx index 4082ad1..d0dde80 100644 --- a/src/components/EquipmentScreen.tsx +++ b/src/components/EquipmentScreen.tsx @@ -84,6 +84,7 @@ export function EquipmentScreen({ const [upgrading, setUpgrading] = useState(false) const [showSetBonuses, setShowSetBonuses] = useState(false) const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>(mode ?? 'equipment') + const activeEquipmentTab = mode ?? equipmentTab const [inventoryPage, setInventoryPage] = useState(0) const [recipePage, setRecipePage] = useState(0) const [message, setMessage] = useState('') @@ -97,10 +98,6 @@ export function EquipmentScreen({ const [selectedRecipeId, setSelectedRecipeId] = useState( firstRecipe?.id ?? null, ) - const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId) - const selectedRecipeRequiresUpgrade = selectedRecipe - ? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel) - : false const selectedItemRecipe = selectedItem ? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id) : undefined @@ -132,9 +129,10 @@ export function EquipmentScreen({ 1, Math.ceil(visibleInventory.length / EQUIPMENT_LIST_PAGE_SIZE), ) + const activeInventoryPage = Math.min(inventoryPage, inventoryPageCount - 1) const inventoryPageItems = visibleInventory.slice( - inventoryPage * EQUIPMENT_LIST_PAGE_SIZE, - (inventoryPage + 1) * EQUIPMENT_LIST_PAGE_SIZE, + activeInventoryPage * EQUIPMENT_LIST_PAGE_SIZE, + (activeInventoryPage + 1) * EQUIPMENT_LIST_PAGE_SIZE, ) const [slotFilter, setSlotFilter] = useState('all') @@ -172,42 +170,28 @@ export function EquipmentScreen({ 1, Math.ceil(filteredRecipes.length / CRAFTING_LIST_PAGE_SIZE), ) + const activeRecipePage = Math.min(recipePage, recipePageCount - 1) const recipePageItems = filteredRecipes.slice( - recipePage * CRAFTING_LIST_PAGE_SIZE, - (recipePage + 1) * CRAFTING_LIST_PAGE_SIZE, + activeRecipePage * CRAFTING_LIST_PAGE_SIZE, + (activeRecipePage + 1) * CRAFTING_LIST_PAGE_SIZE, ) + const activeSelectedRecipeId = filteredRecipes.some((recipe) => recipe.id === selectedRecipeId) + ? selectedRecipeId + : filteredRecipes[0]?.id ?? null + const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === activeSelectedRecipeId) + const selectedRecipeRequiresUpgrade = selectedRecipe + ? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel) + : false useEffect(() => { window.scrollTo(0, scrollRef.current) }, [profile]) useEffect(() => { - setInventoryPage((current) => Math.min(current, inventoryPageCount - 1)) - }, [inventoryPageCount]) - - useEffect(() => { - setRecipePage((current) => Math.min(current, recipePageCount - 1)) - }, [recipePageCount]) - - useEffect(() => { - if (filteredRecipes.length === 0) { - setSelectedRecipeId(null) - return - } - if (!filteredRecipes.some((recipe) => recipe.id === selectedRecipeId)) { - setSelectedRecipeId(filteredRecipes[0].id) - } - }, [filteredRecipes, selectedRecipeId]) - - useEffect(() => { - if (equipmentTab === 'crafting') { + if (activeEquipmentTab === 'crafting') { loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {}) } - }, [equipmentTab]) - - useEffect(() => { - if (mode) setEquipmentTab(mode) - }, [mode]) + }, [activeEquipmentTab, onUpdated]) function saveScroll() { scrollRef.current = window.scrollY @@ -330,7 +314,7 @@ export function EquipmentScreen({ } const workshopState = useMemo(() => { - if (equipmentTab === 'crafting') { + if (activeEquipmentTab === 'crafting') { if (!selectedRecipe) { return { mode: 'crafting', @@ -416,7 +400,7 @@ export function EquipmentScreen({ : []), ], } - }, [comparisonItem, equipmentTab, selectedItem, selectedRecipe, upgradeRecipe]) + }, [activeEquipmentTab, comparisonItem, selectedItem, selectedRecipe, upgradeRecipe]) useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled) @@ -449,14 +433,14 @@ export function EquipmentScreen({ {showModeTabs && ( )} - {equipmentTab === 'equipment' ? ( + {activeEquipmentTab === 'equipment' ? ( <>
{selectedItem ? ( @@ -577,11 +561,11 @@ export function EquipmentScreen({
{visibleInventory.length > EQUIPMENT_LIST_PAGE_SIZE && ( setInventoryPage((current) => Math.min(inventoryPageCount - 1, current + 1))} - onPrevious={() => setInventoryPage((current) => Math.max(0, current - 1))} - nextDisabled={inventoryPage >= inventoryPageCount - 1} - previousDisabled={inventoryPage <= 0} + label={`Page ${activeInventoryPage + 1} / ${inventoryPageCount}`} + onNext={() => setInventoryPage(Math.min(inventoryPageCount - 1, activeInventoryPage + 1))} + onPrevious={() => setInventoryPage(Math.max(0, activeInventoryPage - 1))} + nextDisabled={activeInventoryPage >= inventoryPageCount - 1} + previousDisabled={activeInventoryPage <= 0} /> )} @@ -666,7 +650,7 @@ export function EquipmentScreen({ {filteredRecipes.length === 0 ? (

No recipes match filters.

@@ -674,7 +658,7 @@ export function EquipmentScreen({
{recipePageItems.map((recipe) => ( + member={member} + onSelect={setSelectedTargetId} + selected={selectedId === member.id} + showHealthText + /> ))}

- Buffs: {playerSide.buffs.length > 0 ? summarizeStacks(playerSide.buffs, selfBuffChoicesCatalog) : 'none'} | Debuffs: {playerSide.debuffs.length > 0 ? summarizeStacks(playerSide.debuffs, opponentDebuffChoicesCatalog) : 'none'} + Buffs: {playerBuffSummary} | Debuffs: {playerDebuffSummary}

@@ -1794,65 +1475,32 @@ export function PvPRoguelikeScreen({
{cpuSide.party.map((member) => ( -
-
- {member.role[0]} - {member.name} - {Math.floor(member.health)} / {effectiveMaxHealth(member)} -
-
- - {member.shield > 0 && } - {Math.floor(member.health)} / {effectiveMaxHealth(member)} -
- -
- {member.hotTicks > 0 && Renew {formatEffectTime(member.hotTicks)}} - {member.shield > 0 && Shield {Math.ceil(member.shield)}} - {member.debuff && member.debuffTicks && {member.debuff} {formatEffectTime(member.debuffTicks)}} - {(member.poisonStacks ?? 0) > 0 && Poison {member.poisonStacks}} - {(member.maxHealthPenaltyTicks ?? 0) > 0 && Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks ?? 0)}} - {(member.healingReductionTicks ?? 0) > 0 && Healing -25% {formatEffectTime(member.healingReductionTicks ?? 0)}} -
-
+ ))}

- Buffs: {cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none'} | Debuffs: {cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none'} + Buffs: {opponentBuffSummary} | Debuffs: {opponentDebuffSummary}

-
- {starterSpells.map((spell) => { - const remaining = playerSide.cooldowns[spell.id] ?? 0 - const cost = spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady) - return ( - - ) - })} -
+ )} @@ -1929,95 +1577,38 @@ export function PvPRoguelikeScreen({ )} {(status === 'won' || status === 'lost') && ( -
-
-

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

-

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

+ 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.

{runSummary.bossesKilled} bosses killed.

-

+{runSummary.experienceGained} XP

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

Final boss rewards still recording...

} {rewardError &&

{rewardError}

} - {runSummary.levelsGained > 0 && runSummary.previousLevel !== null && runSummary.newLevel !== null && ( -

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

- )} - {runSummary.unlockedAbilities.map((ability) => ( -

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

- ))} -
- {runSummary.loot.length > 0 ? runSummary.loot.map((item, index) => ( -
- Boss {index + 1} - - {item.glyph} {item.name} x{item.quantity} - {item.duplicate ? ` (owned x${item.quantityAfter})` : ''} - -
- )) : ( -
- Loot - No boss loot awarded -
- )} -
+ {reward && runSummary.bossesKilled === 0 && ( <> -

+{reward.experienceGained} XP

- {reward.levelsGained > 0 && ( -

- Level {reward.previousLevel} to {reward.newLevel} - +{reward.talentPointsGained} talent point -

- )} - {reward.unlockedAbilities.map((ability) => ( -

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

- ))} - {reward.bonusItem && ( -

- {reward.bonusItem.glyph} - {reward.bonusItem.name} x{reward.bonusItem.quantity} - {reward.bonusItem.duplicate ? ` (owned x${reward.bonusItem.quantityAfter})` : ''} -

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

{rematchMessage}

} - - )} - - -
-
+ )} diff --git a/src/components/PvpStadiumScreen.tsx b/src/components/PvpStadiumScreen.tsx index ad0bd4d..097fe7f 100644 --- a/src/components/PvpStadiumScreen.tsx +++ b/src/components/PvpStadiumScreen.tsx @@ -2,36 +2,80 @@ import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type 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 { completeRoguelike } from '../profile' +import type { CharacterProfile } from '../profile' import type { GameMode } from '../gameRepository' -import { ControllerBindingLabel } from './ControllerIcons' +import { PartyMemberFrame } from './PartyFrames' +import { SpellBar, type SpellSlot } from './SpellBars' import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input' -import { useDualScreen, useDualScreenPublisher, type DualScreenCombatState } from '../dualScreen' +import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer' +import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText' +import { usePartyTargeting } from '../hooks/usePartyTargeting' +import { useDualScreen, useDualScreenPublisher } from '../dualScreen' +import { + clamp, + createLogEntry, + effectiveMaxHealth, + toCombatSpell, +} from '../combat/rules' +import { createStackCounts, summarizeStackCounts, type StackCounts } from '../combat/stackCounts' +import { + hasSpellStack, + spellCooldownMultiplier, + spellExtraTargets, + spellPowerMultiplier, + spellResourceCost as modifiedSpellResourceCost, +} from '../combat/spellModifiers' +import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick' +import { advanceMemberTick } from '../combat/combatEngine' +import { buildStadiumDualScreenState } from '../combat/dualScreenPayloads' +import { applyPvpSpellCast } from '../combat/pvpSpellCasting' +import { + appendCombatLog, + STADIUM_COMBAT_LOG_LIMIT, +} from '../combat/combatPresentation' +import { + buildSpellTargetPlan, + type SpellEffectProfile, +} from '../combat/spellEffects' +import { runCpuHealTurn, type CpuTurnBehavior } from '../combat/cpuAi' +import { + chooseStadiumCpuPurchases, + resolveStadiumRound, + stadiumShopPointsForOutcome, + type StadiumExperienceMode, + type StadiumRoundOutcome, +} from '../combat/stadiumLifecycle' +import { + createStadiumLiveMatchStart, + createStadiumMatchStart, + createStadiumStarterSide, +} from '../combat/stadiumMatchSetup' +import { + createEmptyRewardSummary, + mergeDungeonRewardSummary, + type RewardSummaryBase, +} from '../combat/rewardSummaries' +import { RewardXpSummary } from './RewardPanels' +import { ResultScreen } from './ResultScreen' import { - cancelPvpQueue, - checkPvpQueue, - joinPvpQueue, publishPvpMatchState, - randomCpuDifficulty, - requestPvpRematch, submitPvpUpgradeChoice, type CpuDifficulty, type PvpMatchSide, type PvpMatchSnapshot, - type PvpRematchResponse, } from '../pvpRoguelike' +import { startPvpQueueWithCpuFallback } from '../pvpQueueLifecycle' +import { usePvpLiveMatchSync } from '../pvpLiveLifecycle' const TICK_MS = 700 const ROUND_START_SECONDS = 3 const SHOP_SECONDS = 60 -const WIN_ROUNDS = 3 const MAX_RESOURCE = 100 const RESOURCE_REGEN_PER_TICK = 0.8 @@ -73,17 +117,10 @@ type StadiumSideState = { roundIndex: number roundWins: number roundStatus: 'playing' | 'shop' | 'won' | 'lost' - lastRoundOutcome?: 'win' | 'loss' | 'tie' + lastRoundOutcome?: StadiumRoundOutcome shopReady: boolean } -type FloatingCombatText = { - id: number - memberId: string - side: 'player' | 'cpu' - value: number -} - type LivePvpMatch = { id: string side: PvpMatchSide @@ -92,23 +129,7 @@ type LivePvpMatch = { opponentClassName: string } -type RewardSummary = { - experienceGained: number - previousLevel: number | null - newLevel: number | null - levelsGained: number - talentPointsGained: number - unlockedAbilities: DungeonReward['unlockedAbilities'] -} - -const CPU_BEHAVIOR: Record = { +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 }, @@ -116,10 +137,6 @@ const CPU_BEHAVIOR: Record 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}` @@ -283,96 +288,29 @@ function buildStadiumBuffs(spells: Spell[]): StadiumBuff[] { } 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' + return summarizeStackCounts(createStackCounts(items), catalog, 'none') } -function cooldownMultiplier(spell: Spell, buffs: StadiumBuffId[]) { - return 0.75 ** buffStacks(buffs, `slot${spell.key as SlotKey}-cooldown-down` as StadiumBuffId) +function cooldownMultiplier(spell: Spell, buffs: StackCounts) { + return spellCooldownMultiplier(spell, { + stacks: buffs, + id: (slot) => `slot${slot 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', - direct_hot: 'direct', - heal_over_time: 'hot', - bounce_heal: 'bounce_heal', - party_heal: 'group', - party_hot: 'group', - party_absorb: 'group', - absorb: 'shield', - damage_reduction: 'damage_reduction', - cleanse: 'cleanse', - } - return { - id: String(ability.id), - key, - name: ability.name, - description: ability.description, - cost: ability.cost, - cooldown: ability.cooldown, - power: ability.power, - glyph: ability.glyph, - kind: kinds[ability.spellType] ?? 'direct', - effectType: ability.spellType, - } -} - -function resetParty(partyTemplate: PartyMember[]) { - return partyTemplate.map((member) => ({ - ...member, - health: member.maxHealth, - shield: 0, - hotTicks: 0, - hotEffects: undefined, - debuff: undefined, - debuffTicks: undefined, - poisonStacks: undefined, - maxHealthPenaltyTicks: undefined, - healingReductionTicks: undefined, - damageReductionTicks: undefined, - bounceHeals: undefined, - })) -} - -function starterSide(partyTemplate: PartyMember[], roundIndex: number, buffs: StadiumBuffId[] = [], roundWins = 0): StadiumSideState { - return { - party: resetParty(partyTemplate), - resource: MAX_RESOURCE, - cooldowns: {}, - buffs, - castsTowardFree: 0, - freeCastReady: false, - survivalSeconds: 0, - dampeningPercent: 0, - roundIndex, - roundWins, - roundStatus: 'playing', - shopReady: false, - } -} - -function createEmptyRewardSummary(): RewardSummary { - return { - experienceGained: 0, - previousLevel: null, - newLevel: null, - levelsGained: 0, - talentPointsGained: 0, - unlockedAbilities: [], - } +function spellResourceCost(spell: Spell, buffs: StackCounts, freeCastReady: boolean) { + return modifiedSpellResourceCost({ + spell, + costDown: { + stacks: buffs, + id: (slot) => `slot${slot as SlotKey}-cost-down` as StadiumBuffId, + }, + freeCastReady, + freeCast: { + stacks: buffs, + id: 'fifth-cast-free', + }, + }) } export function PvpStadiumScreen({ @@ -409,26 +347,36 @@ export function PvpStadiumScreen({ 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 [playerSide, setPlayerSide] = useState(() => createStadiumStarterSide({ + partyTemplate, + maxResource: MAX_RESOURCE, + roundIndex: 1, + })) + const [cpuSide, setCpuSide] = useState(() => createStadiumStarterSide({ + partyTemplate: cpuPartyTemplate, + maxResource: MAX_RESOURCE, + roundIndex: 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 { + playerFloatingTextsByMember, + cpuFloatingTextsByMember, + dualScreenFloatingTexts, + addFloatingText, + clearFloatingTexts, + } = useSidedFloatingCombatText() + const [rewardSummary, setRewardSummary] = useState(() => createEmptyRewardSummary()) const [rewardError, setRewardError] = useState('') const [showEndLog, setShowEndLog] = useState(false) const selectedIdRef = useRef(partyTemplate[0].id) @@ -436,9 +384,6 @@ export function PvpStadiumScreen({ 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) @@ -451,8 +396,28 @@ export function PvpStadiumScreen({ lastDevice, } = useInput() const { enabled: dualScreenEnabled } = useDualScreen() + const activeBindings = bindings[lastDevice] const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}` const playerAlive = playerSide.party.some((member) => member.health > 0) + const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs]) + const playerSpellSlots = useMemo(() => starterSpells.map((spell, slotIndex) => ({ + ...spell, + cost: spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady), + slotIndex, + remaining: playerSide.cooldowns[spell.id] ?? 0, + })), [playerBuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells]) + const opponentBuffSummary = useMemo( + () => summarizeStacks(cpuSide.buffs, buffCatalog), + [buffCatalog, cpuSide.buffs], + ) + const playerBuffSummary = useMemo( + () => summarizeStacks(playerSide.buffs, buffCatalog), + [buffCatalog, playerSide.buffs], + ) + const opponentDebuffSummary = useMemo( + () => `Dampening ${playerSide.dampeningPercent}%`, + [playerSide.dampeningPercent], + ) const partyColumns = 3 const setSelectedTargetId = useCallback((id: string) => { @@ -461,42 +426,44 @@ export function PvpStadiumScreen({ }, []) const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => { - setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 70)) + setLog((current) => appendCombatLog(current, createLogEntry(nextLogId, text, tone), STADIUM_COMBAT_LOG_LIMIT)) }, []) 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) - }, []) + addFloatingText({ side, memberId, value }) + }, [addFloatingText]) - const clearRoundCountdown = useCallback(() => { - if (roundCountdownTimerRef.current === null) return - window.clearInterval(roundCountdownTimerRef.current) - roundCountdownTimerRef.current = null - }, []) + 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: shopTimeLeft, + start: startShopTimer, + reset: resetShopTimer, + } = useDeadlineTimer({ + initialSeconds: SHOP_SECONDS, + active: status === 'shop' && !shopReady, + intervalMs: 200, + onExpire: () => finishShop(), + }) const beginRoundCountdown = useCallback(() => { clearRoundCountdown() roundResolvedRef.current = false - setRoundCountdown(ROUND_START_SECONDS) + startRoundCountdown() 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]) + }, [clearRoundCountdown, startRoundCountdown]) - 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') => { + const awardXp = useCallback((key: string, mode: StadiumExperienceMode) => { 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)), { @@ -505,18 +472,7 @@ export function PvpStadiumScreen({ 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()), - } - }) + setRewardSummary((current) => mergeDungeonRewardSummary(current, result)) onProfileUpdated(result.profile) if (result.experienceGained > 0) addLog(`+${result.experienceGained} XP awarded.`, 'loot') }) @@ -526,83 +482,74 @@ export function PvpStadiumScreen({ }, [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, + const setup = createStadiumLiveMatchStart({ + match, side, - opponentSide, - opponentName: opponent.characterName, - opponentClassName: opponent.className, - } - playerRef.current = basePlayer - cpuRef.current = baseOpponent - liveMatchRef.current = nextLiveMatch + partyTemplate, + opponentPartyTemplate: cpuPartyTemplate, + maxResource: MAX_RESOURCE, + message, + }) + playerRef.current = setup.playerSide + cpuRef.current = setup.opponentSide + liveMatchRef.current = setup.liveMatch queuedMatchRef.current = true nextLogId.current = 2 awardedXpRef.current = new Set() roundResolvedRef.current = false - setPlayerSide(basePlayer) - setCpuSide(baseOpponent) - setRoundIndex(1) - setRoundWins({ player: 0, opponent: 0 }) + setPlayerSide(setup.playerSide) + setCpuSide(setup.opponentSide) + setRoundIndex(setup.defaults.roundIndex) + setRoundWins(setup.defaults.roundWins) setSelectedTargetId(partyTemplate[0].id) - setElapsedTicks(0) - setShopPoints(0) - setShopReady(false) + setElapsedTicks(setup.defaults.elapsedTicks) + setShopPoints(setup.defaults.shopPoints) + setShopReady(setup.defaults.shopReady) + resetShopTimer(SHOP_SECONDS) setCpuDifficulty(null) - setLiveMatch(nextLiveMatch) - setPaused(false) + setLiveMatch(setup.liveMatch) + setPaused(setup.defaults.paused) setRewardSummary(createEmptyRewardSummary()) - setRewardError('') - setShowEndLog(false) - setFloatingTexts([]) - setRematchRequested(false) - setRematchMessage('') + setRewardError(setup.defaults.rewardError) + setShowEndLog(setup.defaults.showEndLog) + clearFloatingTexts() loggedOpponentRoundRef.current = '' - const text = message ?? `${opponent.characterName} found. Stadium begins.` - setQueueMessage(text) - setLog([{ id: 1, text, tone: 'system' }]) + setQueueMessage(setup.logText) + setLog([{ id: 1, text: setup.logText, tone: 'system' }]) beginRoundCountdown() - }, [beginRoundCountdown, cpuPartyTemplate, partyTemplate, setSelectedTargetId]) + }, [beginRoundCountdown, clearFloatingTexts, cpuPartyTemplate, partyTemplate, resetShopTimer, setSelectedTargetId]) const startMatch = useCallback(() => { clearRoundCountdown() - const basePlayer = starterSide(partyTemplate, 1) - const baseCpu = starterSide(cpuPartyTemplate, 1) - playerRef.current = basePlayer - cpuRef.current = baseCpu + const setup = createStadiumMatchStart({ + partyTemplate, + opponentPartyTemplate: cpuPartyTemplate, + maxResource: MAX_RESOURCE, + }) + playerRef.current = setup.playerSide + cpuRef.current = setup.opponentSide liveMatchRef.current = null queuedMatchRef.current = true nextLogId.current = 2 awardedXpRef.current = new Set() roundResolvedRef.current = false - setPlayerSide(basePlayer) - setCpuSide(baseCpu) - setRoundIndex(1) - setRoundWins({ player: 0, opponent: 0 }) + setPlayerSide(setup.playerSide) + setCpuSide(setup.opponentSide) + setRoundIndex(setup.defaults.roundIndex) + setRoundWins(setup.defaults.roundWins) setSelectedTargetId(partyTemplate[0].id) - setElapsedTicks(0) + setElapsedTicks(setup.defaults.elapsedTicks) setStatus('queueing') - setShopPoints(0) - setShopReady(false) + setShopPoints(setup.defaults.shopPoints) + setShopReady(setup.defaults.shopReady) + resetShopTimer(SHOP_SECONDS) setCpuDifficulty(null) setLiveMatch(null) - setPaused(false) + setPaused(setup.defaults.paused) setRewardSummary(createEmptyRewardSummary()) - setRewardError('') - setShowEndLog(false) - setFloatingTexts([]) - setRematchRequested(false) - setRematchMessage('') + setRewardError(setup.defaults.rewardError) + setShowEndLog(setup.defaults.showEndLog) + clearFloatingTexts() loggedOpponentRoundRef.current = '' const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => { setCpuDifficulty(randomCpu) @@ -610,75 +557,34 @@ export function PvpStadiumScreen({ 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]) + return startPvpQueueWithCpuFallback({ + contentType: 'stadium', + startStage: 1, + 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 Stadium.`, + searching: 'Searching Stadium queue for 5s.', + notFound: (difficulty) => `No Stadium 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. Stadium begins.` + }, + }, + }) + }, [beginRoundCountdown, clearFloatingTexts, clearRoundCountdown, cpuPartyTemplate, gameMode, partyTemplate, resetShopTimer, setSelectedTargetId, startLiveMatch]) - useEffect(() => startMatch(), [startMatch]) + useEffect(() => { + const frame = window.requestAnimationFrame(() => startMatch()) + return () => window.cancelAnimationFrame(frame) + }, [startMatch]) const applySpell = useCallback(( current: StadiumSideState, @@ -687,148 +593,118 @@ export function PvpStadiumScreen({ 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 buffCounts = createStackCounts(current.buffs) + const effectiveCost = spellResourceCost(spell, buffCounts, current.freeCastReady) 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(spell.kind === 'direct' || spell.kind === 'cleanse' ? [targetId] : []) - const hotTargets = new Set(spell.kind === 'hot' || spell.kind === 'bounce_heal' ? [targetId] : []) - const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : []) - const damageReductionTargets = new Set(spell.kind === 'damage_reduction' ? [targetId] : []) - const extraTargets = buffStacks(current.buffs, `slot${spell.key as SlotKey}-extra-target` as StadiumBuffId) - const groupTargets = new Set( - spell.kind === 'group' - ? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id) - : [], - ) - const renewDuration = buffStacks(current.buffs, 'slot2-double-duration') > 0 && spell.key === '2' ? 10 : 5 + const hasBuff = (id: StadiumBuffId) => hasSpellStack({ stacks: buffCounts, id }) + const extraTargets = spellExtraTargets(spell, { + stacks: buffCounts, + id: (slot) => `slot${slot as SlotKey}-extra-target` as StadiumBuffId, + }) + const renewDuration = hasBuff('slot2-double-duration') && spell.key === '2' ? 10 : 5 const shieldEffect = starterSpells.find((candidate) => candidate.kind === 'shield') const shieldPower = (sourcePower: number, strength = 1) => Math.round( sourcePower * strength - * (1.25 ** buffStacks(current.buffs, 'shield-boost')) + * spellPowerMultiplier({ stacks: buffCounts, id: 'shield-boost' }) * dampenMultiplier, ) - if (spell.effectType === 'direct_hot') hotTargets.add(targetId) - if (spell.key === '1' && buffStacks(current.buffs, 'slot1-applies-renew') > 0) hotTargets.add(targetId) - if (spell.key === '1' && buffStacks(current.buffs, 'slot1-applies-shield') > 0) shieldTargets.add(targetId) - if (spell.key === '2' && buffStacks(current.buffs, 'slot2-applies-shield') > 0) shieldTargets.add(targetId) - if (spell.key === '4' && buffStacks(current.buffs, 'slot4-applies-renew') > 0) hotTargets.add(targetId) - if (spell.key === '5' && buffStacks(current.buffs, 'slot5-applies-renew') > 0) hotTargets.add(targetId) - if (spell.key === '5' && buffStacks(current.buffs, 'slot5-applies-shield') > 0) shieldTargets.add(targetId) - for (let index = 0; index < extraTargets; index += 1) { - if (spell.kind === 'group') break - if (spell.kind === 'hot' || spell.kind === 'bounce_heal') { - const extra = extraTarget([...hotTargets]) - if (extra) hotTargets.add(extra.id) - continue - } - if (spell.kind === 'shield') { - const extra = extraTarget([...shieldTargets]) - if (extra) shieldTargets.add(extra.id) - continue - } - if (spell.kind === 'damage_reduction') { - const extra = extraTarget([...damageReductionTargets]) - if (extra) damageReductionTargets.add(extra.id) - continue - } - const extra = extraTarget([...directTargets]) - if (extra) directTargets.add(extra.id) - } - if (spell.effectType === 'direct_hot') directTargets.forEach((id) => hotTargets.add(id)) - const nextParty = current.party.map((member) => { - if (member.health <= 0) return member - if (spell.kind === 'group') { - if (!groupTargets.has(member.id)) return member - const isGroupAbsorb = spell.effectType === 'party_absorb' - const isGroupHot = spell.effectType === 'party_hot' - const boost = isGroupAbsorb ? buffStacks(current.buffs, 'shield-boost') : buffStacks(current.buffs, 'group-heal-boost') - const power = Math.round(spell.power * (1.25 ** boost) * dampenMultiplier) - const nextHealth = isGroupAbsorb || isGroupHot ? member.health : clamp(member.health + power, 0, effectiveMaxHealth(member)) - if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health) - const appliesShield = isGroupAbsorb || buffStacks(current.buffs, 'slot3-applies-shield') > 0 - const appliesHot = isGroupHot || buffStacks(current.buffs, 'slot3-applies-renew') > 0 - return { - ...member, - health: nextHealth, - shield: appliesShield - ? Math.max(member.shield, isGroupAbsorb ? power : shieldPower(shieldEffect?.power ?? spell.power, 0.5)) - : member.shield, - hotTicks: appliesHot ? Math.max(member.hotTicks, 5) : member.hotTicks, - } - } - if ( - !directTargets.has(member.id) - && !hotTargets.has(member.id) - && !shieldTargets.has(member.id) - && !damageReductionTargets.has(member.id) - ) return member - if (spell.kind === 'shield') { - return { - ...member, - shield: Math.max(member.shield, shieldPower(spell.power)), - hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, 5) : member.hotTicks, - } - } - if (spell.kind === 'damage_reduction') { - return { - ...member, - damageReductionTicks: Math.max(member.damageReductionTicks ?? 0, 12), - hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, 5) : member.hotTicks, - } - } - if (spell.kind === 'cleanse') { - const power = Math.round(spell.power * dampenMultiplier) - const nextHealth = clamp(member.health + power, 0, effectiveMaxHealth(member)) - addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health)) - return { - ...member, - health: nextHealth, - debuff: undefined, - debuffTicks: undefined, - poisonStacks: undefined, - maxHealthPenaltyTicks: undefined, - healingReductionTicks: undefined, - shield: shieldTargets.has(member.id) - ? Math.max(member.shield, shieldPower(shieldEffect?.power ?? spell.power)) - : member.shield, - hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, 5) : member.hotTicks, - } - } - const power = directTargets.has(member.id) ? Math.round(spell.power * dampenMultiplier) : 0 - const nextHealth = clamp(member.health + power, 0, effectiveMaxHealth(member)) - if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health) - return { - ...member, - health: nextHealth, - shield: shieldTargets.has(member.id) - ? Math.max(member.shield, shieldPower(shieldEffect?.power ?? spell.power)) - : member.shield, - hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, renewDuration) : member.hotTicks, - } - }) - const freeBuff = buffStacks(current.buffs, 'fifth-cast-free') > 0 - const wasFree = effectiveCost === 0 && current.freeCastReady - const nextCasts = freeBuff ? (wasFree ? 0 : current.castsTowardFree + 1) : current.castsTowardFree - const nextState = { - ...current, - party: nextParty, - resource: current.resource - effectiveCost, - cooldowns: { - ...current.cooldowns, - [spell.id]: spell.cooldown * cooldownMultiplier(spell, current.buffs), + const appliesRenew = spell.effectType === 'direct_hot' + || (spell.key === '1' && hasBuff('slot1-applies-renew')) + || (spell.key === '4' && hasBuff('slot4-applies-renew')) + || (spell.key === '5' && hasBuff('slot5-applies-renew')) + const appliesShield = (spell.key === '1' && hasBuff('slot1-applies-shield')) + || (spell.key === '2' && hasBuff('slot2-applies-shield')) + || (spell.key === '5' && hasBuff('slot5-applies-shield')) + const { + directTargets, + hotTargets, + shieldTargets, + damageReductionTargets, + groupTargets, + } = buildSpellTargetPlan({ + party: current.party, + spell, + targetId, + extraTargets, + directTarget: spell.kind === 'direct' || spell.kind === 'cleanse', + hotTarget: spell.kind === 'hot' || spell.kind === 'bounce_heal' || appliesRenew, + shieldTarget: spell.kind === 'shield' || appliesShield, + damageReductionTarget: spell.kind === 'damage_reduction', + groupTargetCount: DEFAULT_GROUP_HEAL_TARGETS + extraTargets, + extraTargetMode: { + hot: 'hot', + bounce_heal: 'hot', + shield: 'shield', + damage_reduction: 'damageReduction', }, - castsTowardFree: freeBuff && nextCasts >= 5 ? 0 : nextCasts, - freeCastReady: freeBuff && nextCasts >= 5, + }) + if (spell.effectType === 'direct_hot') directTargets.forEach((id) => hotTargets.add(id)) + const groupHealBoost = spellPowerMultiplier({ stacks: buffCounts, id: 'group-heal-boost' }) + const spellEffectProfile: SpellEffectProfile = { + modeName: 'stadium', + heal: (member, power) => clamp(member.health + power, 0, effectiveMaxHealth(member)), + healingMultiplier: () => 1, + power: { + direct: (source) => Math.round(source.power * dampenMultiplier), + cleanse: (source) => Math.round(source.power * dampenMultiplier), + groupHeal: (source) => Math.round(source.power * groupHealBoost * dampenMultiplier), + groupAbsorb: (source) => shieldPower(source.power), + shield: shieldPower, + }, + hot: { + mode: 'ticks', + defaultTicks: renewDuration, + groupTicks: 5, + radianceTicks: 5, + merge: 'max', + groupMerge: 'max', + }, + effects: { + renewSpell: undefined, + shieldSpell: shieldEffect, + groupAbsorbOnly: (source) => source.effectType === 'party_absorb', + groupHotOnly: (source) => source.effectType === 'party_hot', + groupAppliesShield: (source) => source.effectType === 'party_absorb' + || hasBuff('slot3-applies-shield'), + groupAppliesHot: (source) => source.effectType === 'party_hot' + || hasBuff('slot3-applies-renew'), + shieldAppliesHot: () => false, + hotSpellForDirect: (source) => source, + }, + ratios: { + groupShield: 0.5, + directShield: 1, + }, + damageReductionTicks: 12, + floatingHeals: { + group: true, + direct: true, + cleanse: true, + }, + bounceHeals: false, } - setCurrent(nextState) - return true + return applyPvpSpellCast({ + current, + spell, + targetId, + resourceCost: effectiveCost, + targetPlan: { + directTargets, + hotTargets, + shieldTargets, + damageReductionTargets, + groupTargets, + }, + profile: spellEffectProfile, + setCurrent, + emitFloatingHeal: (memberId, value) => addFloatingHeal(sideName, memberId, value), + cooldownMultiplier: cooldownMultiplier(spell, buffCounts), + freeCast: { + enabled: hasBuff('fifth-cast-free'), + wasReady: effectiveCost === 0 && current.freeCastReady, + }, + }) }, [addFloatingHeal, starterSpells]) const castPlayerSpell = useCallback((spell: Spell) => { @@ -842,139 +718,90 @@ export function PvpStadiumScreen({ if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal') }, [addLog, applySpell, playerAlive, status]) - const selectRelativeTarget = useCallback((direction: -1 | 1) => { - const living = playerRef.current.party.filter((member) => member.health > 0) - if (living.length === 0) return - const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current) - const nextIndex = currentIndex < 0 - ? 0 - : (currentIndex + direction + living.length) % living.length - setSelectedTargetId(living[nextIndex].id) - }, [setSelectedTargetId]) - - const selectDirectionalTarget = useCallback((action: InputAction) => { - const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current) - if (currentIndex < 0) { - const firstLiving = playerRef.current.party.find((member) => member.health > 0) - if (firstLiving) setSelectedTargetId(firstLiving.id) - return - } - const currentRow = Math.floor(currentIndex / partyColumns) - const currentColumn = currentIndex % partyColumns - const candidates = playerRef.current.party - .map((member, index) => ({ - member, - index, - row: Math.floor(index / partyColumns), - column: index % partyColumns, - })) - .filter(({ member, index, row, column }) => { - if (member.health <= 0 || index === currentIndex) return false - if (action === 'navigateLeft') return row === currentRow && column < currentColumn - if (action === 'navigateRight') return row === currentRow && column > currentColumn - if (action === 'navigateUp') return row < currentRow - return row > currentRow - }) - .sort((a, b) => { - const horizontal = action === 'navigateLeft' || action === 'navigateRight' - const aPrimary = horizontal ? Math.abs(a.column - currentColumn) : Math.abs(a.row - currentRow) - const bPrimary = horizontal ? Math.abs(b.column - currentColumn) : Math.abs(b.row - currentRow) - const aSecondary = horizontal ? 0 : Math.abs(a.column - currentColumn) - const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn) - return aPrimary - bPrimary || aSecondary - bSecondary - }) - if (candidates[0]) setSelectedTargetId(candidates[0].member.id) - }, [partyColumns, setSelectedTargetId]) - - const selectDirectTarget = useCallback((slot: number) => { - const member = playerRef.current.party[slot] - if (member?.health > 0) setSelectedTargetId(member.id) - }, [setSelectedTargetId]) + const getTargetParty = useCallback(() => playerRef.current.party, []) + const { + selectRelativeTarget, + selectDirectionalTarget, + selectDirectTarget, + } = usePartyTargeting({ + getParty: getTargetParty, + selectedIdRef, + setSelectedTargetId, + columns: partyColumns, + livingOnly: true, + }) const cpuTakeTurn = useCallback(() => { if (!cpuDifficulty || status !== 'playing') return const behavior = CPU_BEHAVIOR[cpuDifficulty] - if (elapsedTicks % behavior.actionEveryTicks !== 0 || Math.random() < behavior.mistakeChance) return - const side = cpuRef.current - const living = side.party.filter((member) => member.health > 0) - if (living.length === 0) return - const lowest = [...living].sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0] - const averageHealth = living.reduce((total, member) => total + member.health / effectiveMaxHealth(member), 0) / Math.max(1, living.length) - const tank = living.find((member) => member.role === 'Tank') - const cleanseTarget = living.find((member) => member.debuff || (member.poisonStacks ?? 0) > 0) - const renewTarget = living.find((member) => member.hotTicks <= 1 && member.health / effectiveMaxHealth(member) < behavior.hotThreshold) - const shieldTarget = living.find((member) => member.role === 'Tank' && member.shield <= 5 && member.health / effectiveMaxHealth(member) < behavior.shieldThreshold) - const spellBySlot = (slot: SlotKey) => starterSpells.find((candidate) => candidate.key === slot) - const ordered: Array<{ spell: Spell | undefined; targetId: string | null }> = [ - { spell: cleanseTarget ? spellBySlot('5') : undefined, targetId: cleanseTarget?.id ?? null }, - { spell: averageHealth < behavior.groupHealThreshold ? spellBySlot('3') : undefined, targetId: lowest?.id ?? null }, - { spell: shieldTarget ? spellBySlot('4') : undefined, targetId: shieldTarget?.id ?? null }, - { spell: lowest.health / effectiveMaxHealth(lowest) < behavior.directHealThreshold ? spellBySlot('1') : undefined, targetId: lowest.id }, - { spell: renewTarget ? spellBySlot('2') : undefined, targetId: renewTarget?.id ?? null }, - { spell: tank ? spellBySlot('1') : undefined, targetId: tank?.id ?? null }, - ] - for (const action of ordered) { - if (!action.spell || !action.targetId) continue - const succeeded = applySpell(cpuRef.current, (value) => { - const next = typeof value === 'function' ? value(cpuRef.current) : value - cpuRef.current = next - setCpuSide(next) - }, 'cpu', action.spell, action.targetId) - if (succeeded) return - } + runCpuHealTurn({ + side: cpuRef.current, + elapsedTicks, + spells: starterSpells, + behavior, + preferSlots: true, + applySpell: (side, spell, targetId) => { + applySpell(side, (value) => { + const next = typeof value === 'function' ? value(cpuRef.current) : value + cpuRef.current = next + setCpuSide(next) + }, 'cpu', spell, targetId) + }, + }) }, [applySpell, cpuDifficulty, elapsedTicks, starterSpells, status]) const advanceBoss = useCallback((side: StadiumSideState) => { if (side.roundStatus !== 'playing') return side + const party = side.party 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) + const hotHealing = Math.round(6 * dampenMultiplier) + const living = 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 tankIds = new Set(tankPressureTargets(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) => { + const nextParty = party.map((member) => { if (member.health <= 0) return member let damage = tankIds.has(member.id) ? 8 : 0 if (pulse) damage += 9 if (spike && member.id === spikeTarget.id) damage += 22 - const mitigatedDamage = member.damageReductionTicks && member.damageReductionTicks > 0 - ? Math.ceil(damage * 0.5) - : damage - const hotHealing = member.hotTicks > 0 ? Math.round(6 * dampenMultiplier) : 0 - const absorbed = Math.min(member.shield, mitigatedDamage) - const nextHealth = clamp(member.health - mitigatedDamage + absorbed + hotHealing, 0, effectiveMaxHealth(member)) + const result = advanceMemberTick({ + member, + party, + damage, + hotHealing, + hotTicks: 'ticks', + applyDebuff: spike && member.id === spikeTarget.id + ? { label: 'Marked', ticks: 4 } + : undefined, + decrementDamageReduction: true, + damageReductionRounding: 'ceil', + }) return { - ...member, - health: nextHealth, - shield: Math.max(0, member.shield - mitigatedDamage), - hotTicks: Math.max(0, member.hotTicks - 1), - damageReductionTicks: member.damageReductionTicks ? Math.max(0, member.damageReductionTicks - 1) : undefined, - debuff: spike && member.id === spikeTarget.id ? 'Marked' : member.debuff, - debuffTicks: spike && member.id === spikeTarget.id ? 4 : member.debuffTicks ? Math.max(0, member.debuffTicks - 1) : undefined, + ...result.member, + damageReductionTicks: result.member.damageReductionTicks || undefined, + debuffTicks: result.member.debuffTicks || undefined, } }) return { ...side, party: nextParty, - resource: clamp(side.resource + RESOURCE_REGEN_PER_TICK, 0, MAX_RESOURCE), - cooldowns: Object.fromEntries( - Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]), - ), + resource: regenerateResource(side.resource, RESOURCE_REGEN_PER_TICK, MAX_RESOURCE), + cooldowns: advanceCooldowns(side.cooldowns, tickSeconds(TICK_MS)), 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 + const beginShop = useCallback((outcome: StadiumRoundOutcome, nextWins: { player: number; opponent: number }) => { + const points = stadiumShopPointsForOutcome(outcome, 'player') submittedShopRef.current = false setShopPoints(points) setShopReady(false) - setShopTimeLeft(SHOP_SECONDS) + startShopTimer() setRoundWins(nextWins) setStatus('shop') setPlayerSide((current) => { @@ -983,67 +810,47 @@ export function PvpStadiumScreen({ 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 - } + const purchases = chooseStadiumCpuPurchases( + buffCatalog, + stadiumShopPointsForOutcome(outcome, 'opponent'), + ) setCpuSide((current) => { const next = { ...current, buffs: [...current.buffs, ...purchases], roundStatus: 'shop' as const, roundWins: nextWins.opponent } cpuRef.current = next return next }) } - }, [buffCatalog]) + }, [buffCatalog, startShopTimer]) - const finishRound = useCallback((outcome: 'win' | 'loss' | 'tie') => { + const finishRound = useCallback((outcome: StadiumRoundOutcome) => { if (status !== 'playing') return if (roundResolvedRef.current) return roundResolvedRef.current = true - const key = `round-${roundIndex}-${outcome}` - if (outcome === 'win' || outcome === 'tie') { - awardXp(key, 'pvp-stadium-round-win-quarter-level') - } else { - awardXp(key, 'pvp-stadium-round-loss-tenth-level') - } - const nextWins = { - player: roundWins.player + (outcome === 'win' ? 1 : 0), - opponent: roundWins.opponent + (outcome === 'loss' ? 1 : 0), - } - addLog( - outcome === 'win' - ? `Round ${roundIndex} won.` - : outcome === 'loss' - ? `Round ${roundIndex} lost.` - : `Round ${roundIndex} tied.`, - outcome === 'loss' ? 'danger' : 'loot', - ) - if (nextWins.player >= WIN_ROUNDS) { - setRoundWins(nextWins) - setStatus('won') + const result = resolveStadiumRound({ outcome, roundIndex, wins: roundWins }) + awardXp(result.roundExperience.key, result.roundExperience.mode) + addLog(result.log.text, result.log.tone) + if (result.status === 'won') { + setRoundWins(result.nextWins) + setStatus(result.status) setPlayerSide((current) => { - const next = { ...current, roundStatus: 'won' as const, lastRoundOutcome: outcome, roundWins: nextWins.player } + const next = { ...current, roundStatus: result.playerRoundStatus, lastRoundOutcome: outcome, roundWins: result.nextWins.player } playerRef.current = next return next }) - awardXp('match-win', 'pvp-stadium-match-half-level') + if (result.matchExperience) awardXp(result.matchExperience.key, result.matchExperience.mode) return } - if (nextWins.opponent >= WIN_ROUNDS) { - setRoundWins(nextWins) - setStatus('lost') + if (result.status === 'lost') { + setRoundWins(result.nextWins) + setStatus(result.status) setPlayerSide((current) => { - const next = { ...current, roundStatus: 'lost' as const, lastRoundOutcome: outcome, roundWins: nextWins.player } + const next = { ...current, roundStatus: result.playerRoundStatus, lastRoundOutcome: outcome, roundWins: result.nextWins.player } playerRef.current = next return next }) return } - beginShop(outcome, nextWins) + beginShop(outcome, result.nextWins) }, [addLog, awardXp, beginShop, roundIndex, roundWins, status]) useEffect(() => { @@ -1068,8 +875,20 @@ export function PvpStadiumScreen({ 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) + const nextPlayer = createStadiumStarterSide({ + partyTemplate, + maxResource: MAX_RESOURCE, + roundIndex: nextRound, + buffs: playerRef.current.buffs, + roundWins: roundWins.player, + }) + const nextCpu = createStadiumStarterSide({ + partyTemplate: cpuPartyTemplate, + maxResource: MAX_RESOURCE, + roundIndex: nextRound, + buffs: cpuRef.current.buffs, + roundWins: roundWins.opponent, + }) playerRef.current = nextPlayer cpuRef.current = nextCpu roundResolvedRef.current = false @@ -1119,72 +938,51 @@ export function PvpStadiumScreen({ 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 && opponentState.roundIndex >= roundIndex) { - cpuRef.current = opponentState - setCpuSide(opponentState) - } - const opponentStatus = snapshot.statuses[liveMatch.opponentSide] - if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') setStatus('lost') - if (opponentStatus === 'lost' && status !== 'won' && status !== 'lost') setStatus('won') - if (!opponentState) return - if (opponentState.roundIndex < roundIndex) return - if (status === 'playing' && opponentState.roundIndex === roundIndex && opponentState.roundStatus === 'shop' && opponentState.lastRoundOutcome) { - const key = `${roundIndex}-${opponentState.lastRoundOutcome}` - if (loggedOpponentRoundRef.current === key) return - loggedOpponentRoundRef.current = key - if (opponentState.lastRoundOutcome === 'loss') finishRound('win') - else if (opponentState.lastRoundOutcome === 'win') finishRound('loss') - else finishRound('tie') - } - if ( - status === 'shop' - && shopReady - && ( - (opponentState.roundIndex === roundIndex && opponentState.shopReady) - || opponentState.roundIndex > roundIndex - ) - ) { - startNextRound() - } - }) - .catch(() => undefined) - } - syncMatch() - const timer = window.setInterval(syncMatch, 700) - return () => { - stopped = true - window.clearInterval(timer) - } - }, [elapsedTicks, finishRound, liveMatch, roundIndex, roundWins.player, shopReady, startNextRound, status]) + const { rematchRequested, rematchMessage, handleRematch } = usePvpLiveMatchSync({ + liveMatch, + syncEnabled: status !== 'queueing', + startLiveMatch, + getPayload: () => ({ + state: playerRef.current, + status: status === 'shop' || status === 'won' || status === 'lost' ? status : 'playing', + stage: roundIndex, + encounterIndex: roundIndex, + encountersCleared: roundWins.player, + enemyHealth: 0, + alive: playerRef.current.party.some((member) => member.health > 0), + elapsedTicks, + }), + onSnapshot: (snapshot, currentLiveMatch) => { + const opponentState = snapshot.states[currentLiveMatch.opponentSide] + if (opponentState && opponentState.roundIndex >= roundIndex) { + cpuRef.current = opponentState + setCpuSide(opponentState) + } + const opponentStatus = snapshot.statuses[currentLiveMatch.opponentSide] + if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') setStatus('lost') + if (opponentStatus === 'lost' && status !== 'won' && status !== 'lost') setStatus('won') + if (!opponentState) return + if (opponentState.roundIndex < roundIndex) return + if (status === 'playing' && opponentState.roundIndex === roundIndex && opponentState.roundStatus === 'shop' && opponentState.lastRoundOutcome) { + const key = `${roundIndex}-${opponentState.lastRoundOutcome}` + if (loggedOpponentRoundRef.current === key) return + loggedOpponentRoundRef.current = key + if (opponentState.lastRoundOutcome === 'loss') finishRound('win') + else if (opponentState.lastRoundOutcome === 'win') finishRound('loss') + else finishRound('tie') + } + if ( + status === 'shop' + && shopReady + && ( + (opponentState.roundIndex === roundIndex && opponentState.shopReady) + || opponentState.roundIndex > roundIndex + ) + ) { + startNextRound() + } + }, + }) const buyBuff = useCallback((buff: StadiumBuff) => { if (status !== 'shop' || shopReady || shopPoints < buff.cost) return @@ -1203,46 +1001,6 @@ export function PvpStadiumScreen({ 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()) @@ -1281,7 +1039,7 @@ export function PvpStadiumScreen({ } }) - const dualScreenState = useMemo(() => ({ + const dualScreenState = useMemo(() => buildStadiumDualScreenState({ difficultyName: 'Equalized iLvl 10', dungeonName: 'Stadium', contentName: 'Stadium', @@ -1296,34 +1054,22 @@ export function PvpStadiumScreen({ 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 })), + opponentBuffSummary, + opponentDebuffSummary, + floatingTexts: dualScreenFloatingTexts, 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], + spells: playerSpellSlots, + bindings: activeBindings, controllerIconStyle, directPartyTargeting, paused, - targetGroup: 0, - speedMultiplier: 1, stadium: { dampeningPercent: playerSide.dampeningPercent, roundIndex, @@ -1333,27 +1079,21 @@ export function PvpStadiumScreen({ opponentSurvivalSeconds: cpuSide.survivalSeconds, }, }), [ - bindings, - buffCatalog, + activeBindings, controllerIconStyle, cpuDifficulty, - cpuSide.buffs, cpuSide.party, - cpuSide.resource, cpuSide.survivalSeconds, directPartyTargeting, - floatingTexts, + dualScreenFloatingTexts, gameClass.resourceName, - lastDevice, liveMatch?.opponentClassName, - log, + opponentBuffSummary, + opponentDebuffSummary, opponentLabel, paused, playerAlive, - playerSide.buffs, - playerSide.cooldowns, playerSide.dampeningPercent, - playerSide.freeCastReady, playerSide.party, playerSide.resource, playerSide.survivalSeconds, @@ -1361,12 +1101,15 @@ export function PvpStadiumScreen({ roundWins.opponent, roundWins.player, selectedId, - starterSpells, + playerSpellSlots, status, ]) useDualScreenPublisher(dualScreenState, dualScreenEnabled) - const visibleBuffs = buffCatalog.filter((buff) => buff.category === shopCategory) + const visibleBuffs = useMemo( + () => buffCatalog.filter((buff) => buff.category === shopCategory), + [buffCatalog, shopCategory], + ) const categoryLabel = shopCategory === 'misc' ? 'Miscellaneous' : slotLabel(shopCategory, starterSpells) return ( @@ -1402,43 +1145,19 @@ export function PvpStadiumScreen({
{playerSide.party.map((member, index) => { const action = `targetParty${index + 1}` as InputAction - const targetBinding = directPartyTargeting ? bindings[lastDevice][action] : null + const targetBinding = directPartyTargeting ? activeBindings[action] : null return ( - + member={member} + onSelect={setSelectedTargetId} + selected={selectedId === member.id} + targetBinding={targetBinding} + /> ) })}
@@ -1447,7 +1166,7 @@ export function PvpStadiumScreen({
{starterSpells.map((spell, slotIndex) => { const remaining = playerSide.cooldowns[spell.id] ?? 0 - const cost = spellResourceCost(spell, playerSide.buffs, playerSide.freeCastReady) + const cost = spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady) const percent = remaining > 0 ? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100) : 0 @@ -1508,30 +1227,20 @@ export function PvpStadiumScreen({
{playerSide.party.map((member) => ( - + ))}
-

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

+

Buffs: {playerBuffSummary}

@@ -1548,54 +1257,31 @@ export function PvpStadiumScreen({
{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)}

+

Buffs: {opponentBuffSummary}

-
- {starterSpells.map((spell) => { - const remaining = playerSide.cooldowns[spell.id] ?? 0 - const cost = spellResourceCost(spell, playerSide.buffs, playerSide.freeCastReady) - return ( - - ) - })} -
+ )} @@ -1644,7 +1330,7 @@ export function PvpStadiumScreen({ ))} -

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

+

Active: {playerBuffSummary}

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

{rematchMessage}

} - - )} - - - - + )} diff --git a/src/components/ResultScreen.tsx b/src/components/ResultScreen.tsx new file mode 100644 index 0000000..6674719 --- /dev/null +++ b/src/components/ResultScreen.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from 'react' +import type { CombatLogEntry } from '../game' +import { RematchControls, ResultLogToggle } from './RewardPanels' + +type ResultAction = { + label: string + onClick: () => void + className?: string + disabled?: boolean +} + +type ResultRematch = { + visible: boolean + requested: boolean + message: string + onRematch: () => void +} + +export function ResultScreen({ + eyebrow, + title, + children, + log, + showLog, + onToggleLog, + rematch, + actions, +}: { + eyebrow: string + title: string + children?: ReactNode + log?: CombatLogEntry[] + showLog?: boolean + onToggleLog?: () => void + rematch?: ResultRematch + actions?: ResultAction[] +}) { + return ( +
+
+

{eyebrow}

+

{title}

+ {children} + {log && onToggleLog && ( + + )} + {rematch?.visible && ( + + )} + {actions?.map((action) => ( + + ))} +
+
+ ) +} diff --git a/src/components/RewardPanels.tsx b/src/components/RewardPanels.tsx new file mode 100644 index 0000000..cddb341 --- /dev/null +++ b/src/components/RewardPanels.tsx @@ -0,0 +1,187 @@ +import type { CombatLogEntry } from '../game' +import type { DungeonReward, LootRoll } from '../profile' + +type BonusItem = NonNullable + +export function LevelGain({ + previousLevel, + newLevel, + talentPointsGained, +}: { + previousLevel: number | null + newLevel: number | null + talentPointsGained: number +}) { + if (!previousLevel || !newLevel || talentPointsGained <= 0) return null + return ( +

+ Level {previousLevel} to {newLevel} + +{talentPointsGained} talent point +

+ ) +} + +export function AbilityUnlocks({ + abilities, +}: { + abilities: DungeonReward['unlockedAbilities'] +}) { + return ( + <> + {abilities.map((ability) => ( +

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

+ ))} + + ) +} + +export function RewardXpSummary({ + reward, +}: { + reward: { + experienceGained: number + previousLevel: number | null + newLevel: number | null + talentPointsGained: number + unlockedAbilities: DungeonReward['unlockedAbilities'] + } +}) { + return ( + <> +

+{reward.experienceGained} XP

+ + + + ) +} + +export function BonusItemReward({ + item, + eyebrow, + compact = false, +}: { + item?: BonusItem | null + eyebrow?: string + compact?: boolean +}) { + if (!item) return null + if (compact) { + return ( +

+ {item.glyph} + {item.name} x{item.quantity} + {item.duplicate ? ` (owned x${item.quantityAfter})` : ''} +

+ ) + } + return ( +
+ {eyebrow &&

{eyebrow}

} +
+ {item.glyph} + {item.name} + Item Level {item.itemLevel} x{item.quantity} + {item.duplicate && (owned x{item.quantityAfter})} +
+
+ ) +} + +export function LootRollList({ + rolls, + expectedRolls, +}: { + rolls: LootRoll[] + expectedRolls: number +}) { + return ( +
+ {rolls.map((roll) => ( +
+ {roll.encounterName} + + {roll.items.length > 0 + ? roll.items + .map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`) + .join(', ') + : 'No components dropped'} + +
+ ))} + {rolls.length < expectedRolls && Finishing loot rolls...} +
+ ) +} + +export function PvpRunLootList({ + loot, +}: { + loot: BonusItem[] +}) { + return ( +
+ {loot.length > 0 ? loot.map((item, index) => ( +
+ Boss {index + 1} + + {item.glyph} {item.name} x{item.quantity} + {item.duplicate ? ` (owned x${item.quantityAfter})` : ''} + +
+ )) : ( +
+ Loot + No boss loot awarded +
+ )} +
+ ) +} + +export function ResultLogToggle({ + log, + showEndLog, + onToggle, +}: { + log: CombatLogEntry[] + showEndLog: boolean + onToggle: () => void +}) { + if (log.length === 0) return null + return ( + <> + + {showEndLog && ( +
+ {log.slice().reverse().map((entry) => ( +
{entry.text}
+ ))} +
+ )} + + ) +} + +export function RematchControls({ + requested, + message, + onRematch, +}: { + requested: boolean + message: string + onRematch: () => void +}) { + return ( + <> + + {message &&

{message}

} + + ) +} diff --git a/src/components/SpellBars.tsx b/src/components/SpellBars.tsx new file mode 100644 index 0000000..4ef1882 --- /dev/null +++ b/src/components/SpellBars.tsx @@ -0,0 +1,131 @@ +import { memo } from 'react' +import type { ControllerIconStyle } from '../input' +import type { Spell } from '../game' +import { ControllerBindingLabel } from './ControllerIcons' + +export type SpellSlot = (Spell & { + cost: number + remaining: number + slotIndex: number +}) | null + +export const ResourceBar = memo(function ResourceBar({ + resource, + maxResource, + resourceName, + speedMultiplier, + unavailableText, +}: { + resource: number + maxResource: number + resourceName: string + speedMultiplier?: 1 | 2 + unavailableText?: string +}) { + return ( +
+ + {unavailableText ?? `${resourceName} ${Math.floor(resource)} / ${maxResource}`} + + {speedMultiplier === 2 && 2x speed} +
+
+ ) +}) + +export const SpellButton = memo(function SpellButton({ + spell, + binding, + iconStyle, + resourceName, + disabled, + onCast, + emptyKeyPrefix = 'empty', +}: { + spell: SpellSlot + binding?: string + iconStyle: ControllerIconStyle + resourceName: string + disabled?: boolean + onCast: (spell: Spell) => void + emptyKeyPrefix?: string +}) { + if (!spell) { + return ( +
+ {emptyKeyPrefix}Empty +
+ ) + } + return ( + + ) +}) + +export const SpellBar = memo(function SpellBar({ + spells, + bindings, + iconStyle, + resource, + resourceName, + canCast, + onCast, + className = 'spell-bar six-slots vertical-spell-bar', +}: { + spells: SpellSlot[] + bindings: Record + iconStyle: ControllerIconStyle + resource: number + resourceName: string + canCast: boolean + onCast: (spell: Spell) => void + className?: string +}) { + return ( +
+ {spells.map((spell, slotIndex) => { + if (!spell) { + return ( +
+ {slotIndex + 1}Empty +
+ ) + } + return ( + 0} + iconStyle={iconStyle} + key={spell.id} + onCast={onCast} + resourceName={resourceName} + spell={spell} + /> + ) + })} +
+ ) +}) diff --git a/src/components/TalentScreen.tsx b/src/components/TalentScreen.tsx index 526153e..e017d4b 100644 --- a/src/components/TalentScreen.tsx +++ b/src/components/TalentScreen.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { allocateTalent, resetTalents, @@ -57,24 +57,16 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P ?? gameClass.talents[0] ?? null const effectPageCount = Math.max(1, Math.ceil(gameClass.talents.length / EFFECTS_PER_PAGE)) + const activeEffectPage = Math.min(effectPage, effectPageCount - 1) const visibleTalents = gameClass.talents.slice( - effectPage * EFFECTS_PER_PAGE, - effectPage * EFFECTS_PER_PAGE + EFFECTS_PER_PAGE, + activeEffectPage * EFFECTS_PER_PAGE, + activeEffectPage * EFFECTS_PER_PAGE + EFFECTS_PER_PAGE, ) useEffect(() => { window.scrollTo(0, scrollRef.current) }, [profile]) - useEffect(() => { - if (selectedTalentId && gameClass.talents.some((talent) => talent.id === selectedTalentId)) return - setSelectedTalentId(selectedTalent?.id ?? null) - }, [gameClass.talents, selectedTalent?.id, selectedTalentId]) - - useEffect(() => { - setEffectPage((page) => Math.min(page, effectPageCount - 1)) - }, [effectPageCount]) - function saveScroll() { scrollRef.current = window.scrollY } @@ -123,9 +115,8 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P } } - const workshopState = useMemo(() => { - if (!isEffectClass) return null - return { + const workshopState: DualScreenWorkshopState | null = isEffectClass + ? { mode: 'talents', title: 'Spell Effects', subtitle: `${selectedEffects.length}/${capacity} active`, @@ -140,7 +131,7 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P status: talent.rank > 0 ? 'Selected' : '', })), } - }, [capacity, gameClass.talents, isEffectClass, selectedEffects.length, selectedTalent]) + : null useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled) @@ -265,16 +256,16 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P {effectPageCount > 1 && (
- {effectPage + 1}/{effectPageCount} + {activeEffectPage + 1}/{effectPageCount} + member={member} + onSelect={onSelectTarget} + selected={state.selectedId === member.id} + targetBinding={targetBinding} + /> ) })}
diff --git a/src/game.ts b/src/game.ts index fce0782..9950d25 100644 --- a/src/game.ts +++ b/src/game.ts @@ -184,17 +184,25 @@ export const ENCOUNTERS: Encounter[] = [ ] export function partyDamageOutput(party: PartyMember[], baseDamage: number) { - const livingCount = party.filter((member) => member.health > 0).length + let livingCount = 0 + for (const member of party) { + if (member.health > 0) livingCount += 1 + } return Math.round(baseDamage * (livingCount / Math.max(1, party.length))) } export function tankPressureTargets(party: PartyMember[]) { - const living = party.filter((member) => member.health > 0) - const tanks = living.filter((member) => member.role === 'Tank') + const tanks: PartyMember[] = [] + let damageDealer: PartyMember | undefined + for (const member of party) { + if (member.health <= 0) continue + if (member.role === 'Tank') { + tanks.push(member) + } else if (member.role === 'Damage' && (!damageDealer || member.health > damageDealer.health)) { + damageDealer = member + } + } if (tanks.length > 0) return { targets: tanks, multiplier: 1 } - const damageDealer = living - .filter((member) => member.role === 'Damage') - .sort((left, right) => right.health - left.health)[0] return { targets: damageDealer ? [damageDealer] : [], multiplier: TANKLESS_DAMAGE_MULTIPLIER, @@ -202,8 +210,19 @@ export function tankPressureTargets(party: PartyMember[]) { } export function groupHealTargets(party: PartyMember[], targetCount = DEFAULT_GROUP_HEAL_TARGETS) { - return party - .filter((member) => member.health > 0) - .sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth)) - .slice(0, targetCount) + const targets: PartyMember[] = [] + for (const member of party) { + if (member.health <= 0) continue + const healthRatio = member.health / member.maxHealth + let insertAt = targets.length + while ( + insertAt > 0 + && healthRatio < targets[insertAt - 1].health / targets[insertAt - 1].maxHealth + ) { + insertAt -= 1 + } + targets.splice(insertAt, 0, member) + if (targets.length > targetCount) targets.pop() + } + return targets } diff --git a/src/gameRepository.ts b/src/gameRepository.ts index 4186f8b..e426f5f 100644 --- a/src/gameRepository.ts +++ b/src/gameRepository.ts @@ -1,5 +1,14 @@ import starterProfile from './offline-starter-profile.json' import { bundledCatalogHash } from './offline-catalog-meta' +import { + catchUpExperienceReward as catchUpExperienceRewardForTarget, + coinDropQuantity, + experienceForLevel, + roguelikeCoinItemLevel, + scaledCurrentLevelExperience, + scaledPvpBossExperience, + scaledPvpFightExperience, +} from './shared/rewardRules.mjs' import type { Account, AuthSession, @@ -37,7 +46,7 @@ export interface GameRepository { durationSeconds: number, options?: { bossesCleared?: number - 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' + experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' | 'pvp-match-win-half-level' | 'pvp-stadium-round-win-quarter-level' | 'pvp-stadium-round-loss-tenth-level' | 'pvp-stadium-match-half-level' fightsCleared?: number lootSourceEncounterId?: number roguelikeStage?: number @@ -115,6 +124,7 @@ const catalogBundleKey = 'chronicle.catalog.bundleHash.v1' const authTokenKey = 'chronicle.authToken.v1' const offlineAccount = { id: -1, username: 'Offline' } const ABILITY_SLOT_COUNT = 6 +let activeCatalogCache: CatalogCache | null = null function clone(value: T): T { return structuredClone(value) @@ -303,6 +313,7 @@ function readCatalogCache(): CatalogCache | null { if (localStorage.getItem(catalogBundleKey) !== bundledCatalogHash) { localStorage.removeItem(catalogCacheKey) localStorage.setItem(catalogBundleKey, bundledCatalogHash) + activeCatalogCache = null return null } const serialized = localStorage.getItem(catalogCacheKey) @@ -319,10 +330,13 @@ function readCatalogCache(): CatalogCache | null { function writeCatalogCache(cache: CatalogCache) { localStorage.setItem(catalogBundleKey, bundledCatalogHash) localStorage.setItem(catalogCacheKey, JSON.stringify(cache)) + activeCatalogCache = cache } function activeCatalog(): CatalogCache { - return readCatalogCache() ?? bundledCatalog() + if (activeCatalogCache) return activeCatalogCache + activeCatalogCache = readCatalogCache() ?? bundledCatalog() + return activeCatalogCache } function buildProfile(save: OfflineSave): CharacterProfile { @@ -454,10 +468,6 @@ function selectUpgradeRecipe( return candidates.find((recipe) => recipe.item.itemLevel === nextItemLevel) } -function experienceForLevel(level: number) { - return (level - 1) * (level - 1) * 100 -} - function catchUpExperienceReward( baseReward: number, currentExperience: number, @@ -465,11 +475,11 @@ function catchUpExperienceReward( targetLevel: number, ) { if (targetLevel <= currentLevel) return baseReward - const targetExperience = experienceForLevel(targetLevel) - const gap = Math.max(0, targetExperience - currentExperience) - if (gap <= 0) return baseReward - const doubledBase = Math.min(baseReward, Math.ceil(gap / 2)) - return doubledBase * 2 + (baseReward - doubledBase) + return catchUpExperienceRewardForTarget( + baseReward, + currentExperience, + experienceForLevel(targetLevel), + ) } function highestOtherClassLevel(save: OfflineSave) { @@ -479,77 +489,6 @@ function highestOtherClassLevel(save: OfflineSave) { .reduce((highest, [, character]) => Math.max(highest, character.level), 0) } -function scaledPvpBossExperience( - startingExperience: number, - startingLevel: number, - bossesCleared: number, - maxLevel: number, - targetLevel = startingLevel, -) { - let experience = startingExperience - let level = startingLevel - const maxExperience = experienceForLevel(maxLevel) - for (let bossIndex = 0; bossIndex < bossesCleared && experience < maxExperience; bossIndex += 1) { - const currentLevelFloor = experienceForLevel(level) - const nextLevelExperience = level >= maxLevel - ? maxExperience - : experienceForLevel(level + 1) - const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) - const rewardRate = targetLevel > level ? 0.5 : 0.25 - experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate)) - while (level < maxLevel && experienceForLevel(level + 1) <= experience) { - level += 1 - } - } - return { experience, level } -} - -function scaledPvpFightExperience( - startingExperience: number, - startingLevel: number, - fightsCleared: number, - maxLevel: number, - targetLevel = startingLevel, -) { - let experience = startingExperience - let level = startingLevel - const maxExperience = experienceForLevel(maxLevel) - for (let fightIndex = 0; fightIndex < fightsCleared && experience < maxExperience; fightIndex += 1) { - const currentLevelFloor = experienceForLevel(level) - const nextLevelExperience = level >= maxLevel - ? maxExperience - : experienceForLevel(level + 1) - const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) - const rewardRate = targetLevel > level ? 1 / 6 : 1 / 12 - experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate)) - while (level < maxLevel && experienceForLevel(level + 1) <= experience) { - level += 1 - } - } - 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))) } @@ -631,17 +570,6 @@ function rollWeightedLootEntry(entries: T[]): return entries[entries.length - 1] } -function coinDropQuantity() { - const roll = Math.random() - if (roll < 0.15) return 3 - if (roll < 0.5) return 2 - return 1 -} - -function roguelikeCoinItemLevel(stage: number) { - return Math.min(25, 5 + Math.max(0, Math.floor(stage / 5)) * 5) -} - function awardRoguelikeCoin( profile: CharacterProfile, sourceEncounterId: number | undefined, @@ -1197,13 +1125,15 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { profile.maxLevel, highestOtherClassLevel(save), ) - : 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 + : options?.experienceMode === 'pvp-match-win-half-level' + ? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.5) + : 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/hooks/useCountdownTimer.ts b/src/hooks/useCountdownTimer.ts new file mode 100644 index 0000000..9daac08 --- /dev/null +++ b/src/hooks/useCountdownTimer.ts @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +type DeadlineTimerOptions = { + initialSeconds: number + active: boolean + intervalMs?: number + onExpire: () => void +} + +type RoundCountdownOptions = { + seconds: number + active: boolean + intervalMs?: number + onComplete: () => void +} + +export function useDeadlineTimer({ + initialSeconds, + active, + intervalMs = 100, + onExpire, +}: DeadlineTimerOptions) { + const [timeLeft, setTimeLeft] = useState(initialSeconds) + const deadlineRef = useRef(0) + const timerRef = useRef(null) + const onExpireRef = useRef(onExpire) + + useEffect(() => { + onExpireRef.current = onExpire + }, [onExpire]) + + const clearTimer = useCallback(() => { + if (timerRef.current === null) return + window.clearInterval(timerRef.current) + timerRef.current = null + }, []) + + const tick = useCallback(() => { + if (deadlineRef.current <= 0) return + const remaining = Math.max(0, (deadlineRef.current - Date.now()) / 1000) + setTimeLeft(remaining) + if (remaining > 0) return + deadlineRef.current = 0 + clearTimer() + onExpireRef.current() + }, [clearTimer]) + + const start = useCallback((seconds = initialSeconds) => { + clearTimer() + deadlineRef.current = Date.now() + seconds * 1000 + setTimeLeft(seconds) + timerRef.current = window.setInterval(tick, intervalMs) + }, [clearTimer, initialSeconds, intervalMs, tick]) + + const clear = useCallback(() => { + deadlineRef.current = 0 + clearTimer() + }, [clearTimer]) + + const reset = useCallback((seconds = initialSeconds) => { + clear() + setTimeLeft(seconds) + }, [clear, initialSeconds]) + + useEffect(() => { + if (!active) clearTimer() + }, [active, clearTimer]) + + useEffect(() => () => clearTimer(), [clearTimer]) + + return { + timeLeft, + start, + clear, + reset, + } +} + +export function useRoundCountdown({ + seconds, + active, + intervalMs = 100, + onComplete, +}: RoundCountdownOptions) { + return useDeadlineTimer({ + initialSeconds: seconds, + active, + intervalMs, + onExpire: onComplete, + }) +} diff --git a/src/hooks/useFloatingCombatText.ts b/src/hooks/useFloatingCombatText.ts new file mode 100644 index 0000000..4680d5f --- /dev/null +++ b/src/hooks/useFloatingCombatText.ts @@ -0,0 +1,104 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + appendFloatingText, + groupFloatingTextsByMember, + stripFloatingTextSide, + type BasicFloatingCombatText, +} from '../combat/combatPresentation' + +type FloatingSide = 'player' | 'cpu' +type SideFloatingCombatText = BasicFloatingCombatText & { + side: FloatingSide +} + +type FloatingCombatTextOptions = { + durationMs?: number +} + +const FLOATING_TEXT_CLEANUP_INTERVAL_MS = 100 + +export function useFloatingCombatText({ + durationMs = 900, +}: FloatingCombatTextOptions = {}) { + const [floatingTexts, setFloatingTexts] = useState([]) + const nextId = useRef(1) + const expirationsRef = useRef(new Map()) + + const clearFloatingTexts = useCallback(() => { + expirationsRef.current.clear() + setFloatingTexts([]) + }, []) + + const addFloatingText = useCallback((entry: Omit) => { + if (entry.value <= 0) return + const id = nextId.current++ + expirationsRef.current.set(id, performance.now() + durationMs) + setFloatingTexts((current) => appendFloatingText(current, { ...entry, id } as TText)) + }, [durationMs]) + + useEffect(() => { + const timer = window.setInterval(() => { + const now = performance.now() + let removed = false + for (const [id, expiresAt] of expirationsRef.current) { + if (expiresAt > now) continue + expirationsRef.current.delete(id) + removed = true + } + if (!removed) return + setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id))) + }, FLOATING_TEXT_CLEANUP_INTERVAL_MS) + return () => window.clearInterval(timer) + }, []) + + const floatingTextsByMember = useMemo( + () => groupFloatingTextsByMember(floatingTexts), + [floatingTexts], + ) + + return { + floatingTexts, + floatingTextsByMember, + addFloatingText, + clearFloatingTexts, + } +} + +export function useSidedFloatingCombatText(options?: FloatingCombatTextOptions) { + const { + floatingTexts, + addFloatingText, + clearFloatingTexts, + } = useFloatingCombatText(options) + const playerFloatingTexts = useMemo( + () => floatingTexts.filter((entry) => entry.side === 'player'), + [floatingTexts], + ) + const cpuFloatingTexts = useMemo( + () => floatingTexts.filter((entry) => entry.side === 'cpu'), + [floatingTexts], + ) + const playerFloatingTextsByMember = useMemo( + () => groupFloatingTextsByMember(playerFloatingTexts), + [playerFloatingTexts], + ) + const cpuFloatingTextsByMember = useMemo( + () => groupFloatingTextsByMember(cpuFloatingTexts), + [cpuFloatingTexts], + ) + const dualScreenFloatingTexts = useMemo( + () => stripFloatingTextSide(playerFloatingTexts), + [playerFloatingTexts], + ) + + return { + floatingTexts, + playerFloatingTexts, + cpuFloatingTexts, + playerFloatingTextsByMember, + cpuFloatingTextsByMember, + dualScreenFloatingTexts, + addFloatingText, + clearFloatingTexts, + } +} diff --git a/src/hooks/usePartyTargeting.ts b/src/hooks/usePartyTargeting.ts new file mode 100644 index 0000000..2551df1 --- /dev/null +++ b/src/hooks/usePartyTargeting.ts @@ -0,0 +1,65 @@ +import { useCallback, type RefObject } from 'react' +import type { PartyMember } from '../game' +import { + selectDirectionalPartyTarget, + selectDirectPartyTarget, + selectRelativePartyTarget, + type NavigateAction, +} from '../combat/targeting' +import type { InputAction } from '../input' + +type PartyTargetingOptions = { + getParty: () => PartyMember[] + selectedIdRef: RefObject + setSelectedTargetId: (id: string) => void + columns: number + livingOnly?: boolean + relativeLivingOnly?: boolean + directionalLivingOnly?: boolean + directLivingOnly?: boolean + directTargetGroup?: number +} + +export function usePartyTargeting({ + getParty, + selectedIdRef, + setSelectedTargetId, + columns, + livingOnly = true, + relativeLivingOnly = livingOnly, + directionalLivingOnly = livingOnly, + directLivingOnly = livingOnly, + directTargetGroup = 0, +}: PartyTargetingOptions) { + const selectRelativeTarget = useCallback((direction: -1 | 1) => { + const targetId = selectRelativePartyTarget(getParty(), selectedIdRef.current, direction, { + livingOnly: relativeLivingOnly, + }) + if (targetId) setSelectedTargetId(targetId) + }, [getParty, relativeLivingOnly, selectedIdRef, setSelectedTargetId]) + + const selectDirectionalTarget = useCallback((action: InputAction) => { + const targetId = selectDirectionalPartyTarget( + getParty(), + selectedIdRef.current, + action as NavigateAction, + columns, + { livingOnly: directionalLivingOnly }, + ) + if (targetId) setSelectedTargetId(targetId) + }, [columns, directionalLivingOnly, getParty, selectedIdRef, setSelectedTargetId]) + + const selectDirectTarget = useCallback((slot: number) => { + const targetId = selectDirectPartyTarget(getParty(), slot, { + livingOnly: directLivingOnly, + targetGroup: directTargetGroup, + }) + if (targetId) setSelectedTargetId(targetId) + }, [directLivingOnly, directTargetGroup, getParty, setSelectedTargetId]) + + return { + selectRelativeTarget, + selectDirectionalTarget, + selectDirectTarget, + } +} diff --git a/src/input.tsx b/src/input.tsx index d42d1ba..5540a31 100644 --- a/src/input.tsx +++ b/src/input.tsx @@ -9,6 +9,7 @@ import { useState, type ReactNode, } from 'react' +import { Capacitor } from '@capacitor/core' export type InputDevice = 'pc' | 'controller' export type ControllerIconStyle = 'xbox' | 'playstation' | 'nintendo' @@ -126,6 +127,9 @@ const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1' const GAME_ACTION_EVENT = 'ashen-halls-game-action' const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller' const FOCUSABLE_SELECTOR = 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])' +const GAMEPAD_COMBAT_POLL_MS = 1000 / 60 +const GAMEPAD_MENU_POLL_MS = 1000 / 30 +const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250 let lastControllerFocus: HTMLElement | null = null @@ -400,6 +404,14 @@ function gamepadTokens(gamepad: Gamepad) { return tokens } +function isCombatActive() { + return Boolean(document.querySelector('[data-combat-active="true"]')) +} + +function firstConnectedGamepad() { + return Array.from(navigator.getGamepads?.() ?? []).find(Boolean) ?? null +} + function setInputValue(input: HTMLInputElement | HTMLTextAreaElement, nextValue: string) { const prototype = input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype @@ -422,6 +434,7 @@ export function InputProvider({ children }: { children: ReactNode }) { const keyboardInputRef = useRef(keyboardInput) const previousTokensRef = useRef(new Set()) const repeatRef = useRef>({}) + const gamepadConnectedRef = useRef(Capacitor.isNativePlatform()) useEffect(() => { bindingsRef.current = bindings @@ -664,9 +677,21 @@ export function InputProvider({ children }: { children: ReactNode }) { }, []) useEffect(() => { - let frame = 0 - const poll = (time: number) => { - const gamepad = Array.from(navigator.getGamepads?.() ?? []).find(Boolean) + let timer = 0 + const nextDelay = () => { + if (!Capacitor.isNativePlatform() && !gamepadConnectedRef.current) { + return GAMEPAD_BROWSER_DISCONNECTED_POLL_MS + } + return isCombatActive() ? GAMEPAD_COMBAT_POLL_MS : GAMEPAD_MENU_POLL_MS + } + const clearControllerState = () => { + previousTokensRef.current = new Set() + repeatRef.current = {} + } + const poll = () => { + const time = performance.now() + const gamepad = firstConnectedGamepad() + gamepadConnectedRef.current = Capacitor.isNativePlatform() || Boolean(gamepad) const currentTokens = gamepad ? gamepadTokens(gamepad) : new Set() const previousTokens = previousTokensRef.current @@ -692,10 +717,24 @@ export function InputProvider({ children }: { children: ReactNode }) { if (!currentTokens.has(token)) delete repeatRef.current[token] }) previousTokensRef.current = currentTokens - frame = window.requestAnimationFrame(poll) + if (!gamepad) clearControllerState() + timer = window.setTimeout(poll, nextDelay()) + } + const onGamepadConnected = () => { + gamepadConnectedRef.current = true + } + const onGamepadDisconnected = () => { + gamepadConnectedRef.current = Boolean(firstConnectedGamepad()) + if (!gamepadConnectedRef.current) clearControllerState() + } + window.addEventListener('gamepadconnected', onGamepadConnected) + window.addEventListener('gamepaddisconnected', onGamepadDisconnected) + timer = window.setTimeout(poll, 0) + return () => { + window.clearTimeout(timer) + window.removeEventListener('gamepadconnected', onGamepadConnected) + window.removeEventListener('gamepaddisconnected', onGamepadDisconnected) } - frame = window.requestAnimationFrame(poll) - return () => window.cancelAnimationFrame(frame) }, [assignBinding, dispatchControllerToken]) const contextValue = useMemo(() => ({ diff --git a/src/profile.ts b/src/profile.ts index 0f997db..4764267 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' | 'pvp-stadium-round-win-quarter-level' | 'pvp-stadium-round-loss-tenth-level' | 'pvp-stadium-match-half-level' + experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' | 'pvp-match-win-half-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/pvpLiveLifecycle.ts b/src/pvpLiveLifecycle.ts new file mode 100644 index 0000000..1877880 --- /dev/null +++ b/src/pvpLiveLifecycle.ts @@ -0,0 +1,221 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + publishPvpMatchState, + requestPvpRematch, + type PvpMatchSnapshot, + type PvpMatchSide, + type PvpMatchStatus, + type PvpRematchResponse, +} from './pvpRoguelike' + +export type LiveMatchProgressPayload = { + state: TSideState + status: PvpMatchStatus + stage: number + encounterIndex: number + encountersCleared: number + enemyHealth: number + alive: boolean + elapsedTicks: number +} + +type StartLiveMatchSyncOptions = { + matchId: string + intervalMs?: number + getPayload: () => LiveMatchProgressPayload + onSnapshot: (snapshot: PvpMatchSnapshot) => void + onError?: (reason: unknown) => void +} + +type RequestLiveRematchOptions = { + matchId: string + maxPendingAttempts?: number + maxFailureAttempts?: number + pendingDelayMs?: number + failureDelayMs?: number + onMatched: (result: Required, 'match' | 'side'>>) => void + onExpired: () => void + onFailure: (reason: unknown) => void +} + +type LivePvpMatchBase = { + id: string + opponentName: string +} + +type UsePvpLiveMatchSyncOptions = { + liveMatch: TLiveMatch | null + syncEnabled: boolean + getPayload: (liveMatch: TLiveMatch) => LiveMatchProgressPayload + onSnapshot: (snapshot: PvpMatchSnapshot, liveMatch: TLiveMatch) => void + onError?: (reason: unknown) => void + startLiveMatch: (match: PvpMatchSnapshot, side: PvpMatchSide, message?: string) => void +} + +type RematchState = { + matchId: string | null + requested: boolean + message: string +} + +/** + * React wrapper for live PvP state sync and rematch UX. Mode screens provide + * payload and snapshot rules; shared polling and rematch state live here. + */ +export function usePvpLiveMatchSync({ + liveMatch, + syncEnabled, + getPayload, + onSnapshot, + onError, + startLiveMatch, +}: UsePvpLiveMatchSyncOptions) { + const [rematchState, setRematchState] = useState({ + matchId: null, + requested: false, + message: '', + }) + const getPayloadRef = useRef(getPayload) + const onSnapshotRef = useRef(onSnapshot) + const onErrorRef = useRef(onError) + const startLiveMatchRef = useRef(startLiveMatch) + const liveMatchId = liveMatch?.id + + useEffect(() => { + getPayloadRef.current = getPayload + onSnapshotRef.current = onSnapshot + onErrorRef.current = onError + startLiveMatchRef.current = startLiveMatch + }, [getPayload, onError, onSnapshot, startLiveMatch]) + + useEffect(() => { + if (!liveMatch || !syncEnabled) return undefined + return startLiveMatchSync({ + matchId: liveMatch.id, + getPayload: () => getPayloadRef.current(liveMatch), + onSnapshot: (snapshot) => onSnapshotRef.current(snapshot, liveMatch), + onError: (reason) => onErrorRef.current?.(reason), + }) + }, [liveMatch, syncEnabled]) + + const handleRematch = useCallback(() => { + if (!liveMatch || (rematchState.matchId === liveMatch.id && rematchState.requested)) return undefined + const { id, opponentName } = liveMatch + setRematchState({ + matchId: id, + requested: true, + message: `Waiting for ${opponentName} to rematch...`, + }) + return requestLiveRematch({ + matchId: id, + onMatched: (result) => { + startLiveMatchRef.current(result.match, result.side, `Rematch against ${opponentName} begins.`) + }, + onExpired: () => { + setRematchState({ matchId: id, requested: false, message: 'Rematch expired.' }) + }, + onFailure: (reason) => { + setRematchState({ + matchId: id, + requested: false, + message: reason instanceof Error ? reason.message : 'Unable to request rematch.', + }) + }, + }) + }, [liveMatch, rematchState.matchId, rematchState.requested]) + + const currentRematchState = rematchState.matchId === liveMatchId + ? rematchState + : { matchId: liveMatchId ?? null, requested: false, message: '' } + + return { + rematchRequested: currentRematchState.requested, + rematchMessage: currentRematchState.message, + handleRematch, + } +} + +/** + * Polls the backend rematch endpoint until both players accept, the request + * expires, or repeated network failures make the rematch unavailable. + */ +export function requestLiveRematch({ + matchId, + maxPendingAttempts = 180, + maxFailureAttempts = 10, + pendingDelayMs = 700, + failureDelayMs = 900, + onMatched, + onExpired, + onFailure, +}: RequestLiveRematchOptions) { + let cancelled = false + let attempts = 0 + + const schedule = (callback: () => void, delay: number) => { + window.setTimeout(callback, delay) + } + + const handleResponse = (result: PvpRematchResponse) => { + if (cancelled) return + if (result.status === 'matched' && result.match && result.side) { + onMatched({ match: result.match, side: result.side }) + return + } + attempts += 1 + if (attempts >= maxPendingAttempts) { + onExpired() + return + } + schedule(pollRematch, pendingDelayMs) + } + + const pollRematch = () => { + requestPvpRematch(matchId) + .then(handleResponse) + .catch((reason: unknown) => { + if (cancelled) return + attempts += 1 + if (attempts >= maxFailureAttempts) { + onFailure(reason) + return + } + schedule(pollRematch, failureDelayMs) + }) + } + + pollRematch() + return () => { + cancelled = true + } +} + +/** + * Publishes local live-match progress and polls opponent state on a fixed + * interval. Mode-specific snapshot handling stays in the caller. + */ +export function startLiveMatchSync({ + matchId, + intervalMs = 700, + getPayload, + onSnapshot, + onError = () => undefined, +}: StartLiveMatchSyncOptions) { + let stopped = false + const syncMatch = () => { + publishPvpMatchState(matchId, getPayload()) + .then((snapshot) => { + if (!stopped) onSnapshot(snapshot) + }) + .catch((reason: unknown) => { + if (!stopped) onError(reason) + }) + } + + syncMatch() + const timer = window.setInterval(syncMatch, intervalMs) + return () => { + stopped = true + window.clearInterval(timer) + } +} diff --git a/src/pvpQueueLifecycle.ts b/src/pvpQueueLifecycle.ts new file mode 100644 index 0000000..d399717 --- /dev/null +++ b/src/pvpQueueLifecycle.ts @@ -0,0 +1,107 @@ +import { + cancelPvpQueue, + checkPvpQueue, + joinPvpQueue, + randomCpuDifficulty, + type CpuDifficulty, + type PvpContentType, + type PvpMatchSnapshot, + type PvpMatchSide, +} from './pvpRoguelike' +import type { GameMode } from './gameRepository' + +export function startPvpQueueWithCpuFallback({ + contentType, + startStage, + gameMode, + liveMatchActive, + onSearching, + onCpuMatch, + onLiveMatch, + messages, +}: { + contentType: PvpContentType + startStage: number + gameMode: GameMode + liveMatchActive: () => boolean + onSearching: (message: string) => void + onCpuMatch: (difficulty: CpuDifficulty, message: string) => void + onLiveMatch: (match: PvpMatchSnapshot, side: PvpMatchSide, message: string) => void + messages: { + offline: (difficulty: CpuDifficulty) => string + searching: string + notFound: (difficulty: CpuDifficulty) => string + unavailable: (difficulty: CpuDifficulty) => string + liveFound: (match: PvpMatchSnapshot, side: PvpMatchSide) => string + } +}) { + if (gameMode === 'offline') { + const difficulty = randomCpuDifficulty() + const timer = window.setTimeout(() => { + onCpuMatch(difficulty, messages.offline(difficulty)) + }, 500) + return () => window.clearTimeout(timer) + } + + let cancelled = false + let ticketId = '' + let pollTimer: number | undefined + onSearching(messages.searching) + + const beginLiveMatch = (match: PvpMatchSnapshot, side: PvpMatchSide) => { + if (cancelled) return + onLiveMatch(match, side, messages.liveFound(match, side)) + } + + const fallbackTimer = window.setTimeout(() => { + if (cancelled || liveMatchActive()) return + cancelled = true + if (ticketId) cancelPvpQueue(ticketId).catch(() => undefined) + const difficulty = randomCpuDifficulty() + onCpuMatch(difficulty, messages.notFound(difficulty)) + }, 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(contentType, startStage) + .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 difficulty = randomCpuDifficulty() + onCpuMatch(difficulty, messages.unavailable(difficulty)) + }) + + return () => { + cancelled = true + window.clearTimeout(fallbackTimer) + if (pollTimer) window.clearTimeout(pollTimer) + if (ticketId && !liveMatchActive()) cancelPvpQueue(ticketId).catch(() => undefined) + } +} diff --git a/src/shared/rewardRules.d.mts b/src/shared/rewardRules.d.mts new file mode 100644 index 0000000..efffb02 --- /dev/null +++ b/src/shared/rewardRules.d.mts @@ -0,0 +1,31 @@ +export function experienceForLevel(level: number): number +export function catchUpExperienceReward( + baseReward: number, + currentExperience: number, + targetExperience: number, +): number +export function scaledPvpBossExperience( + startingExperience: number, + startingLevel: number, + bossesCleared: number, + maxLevel: number, + targetLevel?: number, + levelExperience?: (level: number) => number, +): { experience: number; level: number } +export function scaledPvpFightExperience( + startingExperience: number, + startingLevel: number, + fightsCleared: number, + maxLevel: number, + targetLevel?: number, + levelExperience?: (level: number) => number, +): { experience: number; level: number } +export function scaledCurrentLevelExperience( + startingExperience: number, + startingLevel: number, + maxLevel: number, + rate: number, + levelExperience?: (level: number) => number, +): { experience: number; level: number } +export function coinDropQuantity(): number +export function roguelikeCoinItemLevel(stage: number): number diff --git a/src/shared/rewardRules.mjs b/src/shared/rewardRules.mjs new file mode 100644 index 0000000..54b6c64 --- /dev/null +++ b/src/shared/rewardRules.mjs @@ -0,0 +1,95 @@ +export function experienceForLevel(level) { + return 100 * (level - 1) ** 2 +} + +export function catchUpExperienceReward(baseReward, currentExperience, targetExperience) { + const gap = Math.max(0, targetExperience - currentExperience) + if (gap <= 0) return baseReward + const doubledBase = Math.min(baseReward, Math.ceil(gap / 2)) + return doubledBase * 2 + (baseReward - doubledBase) +} + +export function scaledPvpBossExperience( + startingExperience, + startingLevel, + bossesCleared, + maxLevel, + targetLevel = startingLevel, + levelExperience = experienceForLevel, +) { + let experience = startingExperience + let level = startingLevel + const maxExperience = levelExperience(maxLevel) + for (let bossIndex = 0; bossIndex < bossesCleared && experience < maxExperience; bossIndex += 1) { + const currentLevelFloor = levelExperience(level) + const nextLevelExperience = level >= maxLevel + ? maxExperience + : levelExperience(level + 1) + const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) + const rewardRate = targetLevel > level ? 0.5 : 0.25 + experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate)) + while (level < maxLevel && levelExperience(level + 1) <= experience) { + level += 1 + } + } + return { experience, level } +} + +export function scaledPvpFightExperience( + startingExperience, + startingLevel, + fightsCleared, + maxLevel, + targetLevel = startingLevel, + levelExperience = experienceForLevel, +) { + let experience = startingExperience + let level = startingLevel + const maxExperience = levelExperience(maxLevel) + for (let fightIndex = 0; fightIndex < fightsCleared && experience < maxExperience; fightIndex += 1) { + const currentLevelFloor = levelExperience(level) + const nextLevelExperience = level >= maxLevel + ? maxExperience + : levelExperience(level + 1) + const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) + const rewardRate = targetLevel > level ? 1 / 6 : 1 / 12 + experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate)) + while (level < maxLevel && levelExperience(level + 1) <= experience) { + level += 1 + } + } + return { experience, level } +} + +export function scaledCurrentLevelExperience( + startingExperience, + startingLevel, + maxLevel, + rate, + levelExperience = experienceForLevel, +) { + let experience = startingExperience + let level = startingLevel + const maxExperience = levelExperience(maxLevel) + const currentLevelFloor = levelExperience(level) + const nextLevelExperience = level >= maxLevel + ? maxExperience + : levelExperience(level + 1) + const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) + experience = Math.min(maxExperience, experience + Math.round(levelBand * rate)) + while (level < maxLevel && levelExperience(level + 1) <= experience) { + level += 1 + } + return { experience, level } +} + +export function coinDropQuantity() { + const roll = Math.random() + if (roll < 0.15) return 3 + if (roll < 0.5) return 2 + return 1 +} + +export function roguelikeCoinItemLevel(stage) { + return Math.min(25, 5 + Math.max(0, Math.floor(stage / 5)) * 5) +}