From b3291c3b5e560b91cf776c6f057ed91cfb46b668 Mon Sep 17 00:00:00 2001 From: Unknown Date: Fri, 24 Apr 2026 13:20:10 +0200 Subject: [PATCH] Phase 3: Split computed-stats.ts by responsibility --- src/lib/game/computed-stats.ts | 500 +---------------------------- src/lib/game/utils/combat-utils.ts | 322 +++++++++++++++++++ src/lib/game/utils/floor-utils.ts | 24 ++ src/lib/game/utils/formatting.ts | 19 ++ src/lib/game/utils/index.ts | 35 ++ src/lib/game/utils/mana-utils.ts | 145 +++++++++ 6 files changed, 555 insertions(+), 490 deletions(-) create mode 100644 src/lib/game/utils/combat-utils.ts create mode 100644 src/lib/game/utils/floor-utils.ts create mode 100644 src/lib/game/utils/formatting.ts create mode 100644 src/lib/game/utils/index.ts create mode 100644 src/lib/game/utils/mana-utils.ts diff --git a/src/lib/game/computed-stats.ts b/src/lib/game/computed-stats.ts index 4f9af83..7f4d397 100755 --- a/src/lib/game/computed-stats.ts +++ b/src/lib/game/computed-stats.ts @@ -1,492 +1,12 @@ // ─── Computed Stats and Utility Functions ─────────────────────────────────────── -// This module contains all computed stat functions and utility helpers -// extracted from the main store for better organization +// This module now re-exports from focused utility modules for better organization +// +// The functions have been split into: +// - ./utils/formatting.ts - Number formatting (fmt, fmtDec) +// - ./utils/floor-utils.ts - Floor functions (getFloorMaxHP, getFloorElement) +// - ./utils/mana-utils.ts - Mana calculations (computeMaxMana, computeElementMax, etc.) +// - ./utils/combat-utils.ts - Combat functions (calcDamage, calcInsight, getTotalDPS, etc.) +// +// All exports are maintained for backward compatibility. -import type { GameState, SpellCost, EquipmentInstance } from './types'; -import { - GUARDIANS, - SPELLS_DEF, - FLOOR_ELEM_CYCLE, - HOURS_PER_TICK, - MAX_DAY, - INCURSION_START_DAY, - ELEMENT_OPPOSITES, - ELEMENTS, - TICK_MS, -} from './constants'; -import type { ComputedEffects } from './upgrade-effects'; -import { getUnifiedEffects, type UnifiedEffects } from './effects'; -import { EQUIPMENT_TYPES } from './data/equipment'; -import { ENCHANTMENT_EFFECTS } from './data/enchantment-effects'; - -// ─── Default Effects Constant ─────────────────────────────────────────────────── - -// Default empty effects for when effects aren't provided -export const DEFAULT_EFFECTS: ComputedEffects = { - maxManaMultiplier: 1, - maxManaBonus: 0, - regenMultiplier: 1, - regenBonus: 0, - clickManaMultiplier: 1, - clickManaBonus: 0, - meditationEfficiency: 1, - spellCostMultiplier: 1, - conversionEfficiency: 1, - baseDamageMultiplier: 1, - baseDamageBonus: 0, - attackSpeedMultiplier: 1, - critChanceBonus: 0, - critDamageMultiplier: 1.5, - elementalDamageMultiplier: 1, - studySpeedMultiplier: 1, - studyCostMultiplier: 1, - progressRetention: 0, - instantStudyChance: 0, - freeStudyChance: 0, - elementCapMultiplier: 1, - elementCapBonus: 0, - conversionCostMultiplier: 1, - doubleCraftChance: 0, - permanentRegenBonus: 0, - specials: new Set(), - activeUpgrades: [], -}; - -// ─── Number Formatting Functions ──────────────────────────────────────────────── - -export function fmt(n: number): string { - if (!isFinite(n) || isNaN(n)) return '0'; - if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'; - if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'; - if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'; - return Math.floor(n).toString(); -} - -export function fmtDec(n: number, d: number = 1): string { - return isFinite(n) ? n.toFixed(d) : '0'; -} - -// ─── Floor Functions ──────────────────────────────────────────────────────────── - -export function getFloorMaxHP(floor: number): number { - if (GUARDIANS[floor]) return GUARDIANS[floor].hp; - // Improved scaling: slower early game, faster late game - const baseHP = 100; - const floorScaling = floor * 50; - const exponentialScaling = Math.pow(floor, 1.7); - return Math.floor(baseHP + floorScaling + exponentialScaling); -} - -export function getFloorElement(floor: number): string { - return FLOOR_ELEM_CYCLE[(floor - 1) % FLOOR_ELEM_CYCLE.length]; -} - -// ─── Equipment Spell Helper ───────────────────────────────────────────────────── - -// Get all spells from equipped caster weapons (staves, wands, etc.) -// Returns array of { spellId, equipmentInstanceId } -export function getActiveEquipmentSpells( - equippedInstances: Record, - equipmentInstances: Record -): Array<{ spellId: string; equipmentId: string }> { - const spells: Array<{ spellId: string; equipmentId: string }> = []; - - // Check main hand and off hand for caster equipment - const weaponSlots = ['mainHand', 'offHand'] as const; - - for (const slot of weaponSlots) { - const instanceId = equippedInstances[slot]; - if (!instanceId) continue; - - const instance = equipmentInstances[instanceId]; - if (!instance) continue; - - // Check if this is a caster-type equipment - const equipType = EQUIPMENT_TYPES[instance.typeId]; - if (!equipType || equipType.category !== 'caster') continue; - - // Get spells from enchantments - for (const ench of instance.enchantments) { - const effectDef = ENCHANTMENT_EFFECTS[ench.effectId]; - if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) { - spells.push({ - spellId: effectDef.effect.spellId, - equipmentId: instanceId, - }); - } - } - } - - return spells; -} - -// ─── Skill Level Helper ───────────────────────────────────────────────────────── - -// Helper to get effective skill level accounting for tiers -export function getEffectiveSkillLevel( - skills: Record, - baseSkillId: string, - skillTiers: Record = {} -): { level: number; tier: number; tierMultiplier: number } { - // Find the highest tier the player has for this base skill - const currentTier = skillTiers[baseSkillId] || 1; - - // Look for the tiered skill ID (e.g., manaFlow_t2) - const tieredSkillId = currentTier > 1 ? `${baseSkillId}_t${currentTier}` : baseSkillId; - const level = skills[tieredSkillId] || skills[baseSkillId] || 0; - - // Tier multiplier: each tier is 10x more powerful - const tierMultiplier = Math.pow(10, currentTier - 1); - - return { level, tier: currentTier, tierMultiplier }; -} - -// ─── Computed Stat Functions ──────────────────────────────────────────────────── - -export function computeMaxMana( - state: Pick, - effects?: ComputedEffects | UnifiedEffects -): number { - const pu = state.prestigeUpgrades; - const base = - 100 + - (state.skills.manaWell || 0) * 100 + - (pu.manaWell || 0) * 500; - - // If effects not provided, compute unified effects (includes equipment) - if (!effects && state.equipmentInstances && state.equippedInstances) { - effects = getUnifiedEffects(state as any); - } - - // Apply effects if available (now includes equipment bonuses) - if (effects) { - return Math.floor((base + effects.maxManaBonus) * effects.maxManaMultiplier); - } - return base; -} - -export function computeElementMax( - state: Pick, - effects?: ComputedEffects -): number { - const pu = state.prestigeUpgrades; - const base = 10 + (state.skills.elemAttune || 0) * 50 + (pu.elementalAttune || 0) * 25; - - // Apply upgrade effects if provided - if (effects) { - return Math.floor((base + effects.elementCapBonus) * effects.elementCapMultiplier); - } - return base; -} - -export function computeRegen( - state: Pick, - effects?: ComputedEffects | UnifiedEffects -): number { - const pu = state.prestigeUpgrades; - const temporalBonus = 1 + (pu.temporalEcho || 0) * 0.1; - const base = - 2 + - (state.skills.manaFlow || 0) * 1 + - (state.skills.manaSpring || 0) * 2 + - (pu.manaFlow || 0) * 0.5; - - let regen = base * temporalBonus; - - // If effects not provided, compute unified effects (includes equipment) - if (!effects && state.equipmentInstances && state.equippedInstances) { - effects = getUnifiedEffects(state as any); - } - - // Apply effects if available (now includes equipment bonuses) - if (effects) { - regen = (regen + effects.regenBonus + effects.permanentRegenBonus) * effects.regenMultiplier; - } - - return regen; -} - -/** - * Compute regen with dynamic special effects (needs current mana, max mana, incursion) - */ -export function computeEffectiveRegen( - state: Pick, - effects?: ComputedEffects | UnifiedEffects -): number { - // Base regen from existing function - let regen = computeRegen(state, effects); - - const maxMana = computeMaxMana(state, effects); - const currentMana = state.rawMana; - const incursionStrength = state.incursionStrength || 0; - - // Apply incursion penalty - regen *= (1 - incursionStrength); - - return regen; -} - -export function computeClickMana( - state: Pick, - effects?: ComputedEffects | UnifiedEffects -): number { - const base = - 1 + - (state.skills.manaTap || 0) * 1 + - (state.skills.manaSurge || 0) * 3; - - // If effects not provided, compute unified effects (includes equipment) - if (!effects && state.equipmentInstances && state.equippedInstances) { - effects = getUnifiedEffects(state as any); - } - - // Apply effects if available (now includes equipment bonuses) - if (effects) { - return Math.floor((base + effects.clickManaBonus) * effects.clickManaMultiplier); - } - return base; -} - -// ─── Damage Calculation Helpers ───────────────────────────────────────────────── - -// Elemental damage bonus: +50% if spell element opposes floor element (super effective) -// -25% if spell element matches its own opposite (weak) -export function getElementalBonus(spellElem: string, floorElem: string): number { - if (spellElem === 'raw') return 1.0; // Raw mana has no elemental bonus - - if (spellElem === floorElem) return 1.25; // Same element: +25% damage - - // Check for super effective first: spell is the opposite of floor - // e.g., casting water (opposite of fire) at fire floor = super effective - if (ELEMENT_OPPOSITES[floorElem] === spellElem) return 1.5; // Super effective: +50% damage - - // Check for weak: spell's opposite matches floor - // e.g., casting fire (whose opposite is water) at water floor = weak - if (ELEMENT_OPPOSITES[spellElem] === floorElem) return 0.75; // Weak: -25% damage - - return 1.0; // Neutral -} - -export function calcDamage( - state: Pick, - spellId: string, - floorElem?: string -): number { - const sp = SPELLS_DEF[spellId]; - if (!sp) return 5; - const skills = state.skills; - const baseDmg = sp.dmg + (skills.combatTrain || 0) * 5; - const pct = 1 + (skills.arcaneFury || 0) * 0.1; - - // Elemental mastery bonus - const elemMasteryBonus = 1 + (skills.elementalMastery || 0) * 0.15; - - // Guardian bane bonus - const guardianBonus = floorElem && GUARDIANS[Object.values(GUARDIANS).find(g => g.element === floorElem)?.hp ? 0 : 0] - ? 1 + (skills.guardianBane || 0) * 0.2 - : 1; - - const critChance = (skills.precision || 0) * 0.05; - const pactMult = state.signedPacts.reduce( - (m, f) => m * (GUARDIANS[f]?.pact || 1), - 1 - ); - - let damage = baseDmg * pct * pactMult * elemMasteryBonus; - - // Apply elemental bonus if floor element provided - if (floorElem) { - damage *= getElementalBonus(sp.elem, floorElem); - } - - // Apply crit - if (Math.random() < critChance) { - damage *= 1.5; - } - - return damage; -} - -// ─── Insight Calculation ──────────────────────────────────────────────────────── - -export function calcInsight(state: Pick): number { - const pu = state.prestigeUpgrades; - const skillBonus = 1 + (state.skills.insightHarvest || 0) * 0.1; - const mult = (1 + (pu.insightAmp || 0) * 0.25) * skillBonus; - return Math.floor( - (state.maxFloorReached * 15 + state.totalManaGathered / 500 + state.signedPacts.length * 150) * mult - ); -} - -// ─── Meditation Bonus ─────────────────────────────────────────────────────────── - -// Meditation bonus now affects regen rate directly -export function getMeditationBonus(meditateTicks: number, skills: Record, meditationEfficiency: number = 1): number { - const hasMeditation = skills.meditation === 1; - const hasDeepTrance = skills.deepTrance === 1; - const hasVoidMeditation = skills.voidMeditation === 1; - - const hours = meditateTicks * HOURS_PER_TICK; - - // Base meditation: ramps up over 4 hours to 1.5x - let bonus = 1 + Math.min(hours / 4, 0.5); - - // With Meditation Focus: up to 2.5x after 4 hours - if (hasMeditation && hours >= 4) { - bonus = 2.5; - } - - // With Deep Trance: up to 3.0x after 6 hours - if (hasDeepTrance && hours >= 6) { - bonus = 3.0; - } - - // With Void Meditation: up to 5.0x after 8 hours - if (hasVoidMeditation && hours >= 8) { - bonus = 5.0; - } - - // Apply meditation efficiency from upgrades (Deep Wellspring, etc.) - bonus *= meditationEfficiency; - - return bonus; -} - -// ─── Incursion Strength ───────────────────────────────────────────────────────── - -export function getIncursionStrength(day: number, hour: number): number { - if (day < INCURSION_START_DAY) return 0; - const totalHours = (day - INCURSION_START_DAY) * 24 + hour; - const maxHours = (MAX_DAY - INCURSION_START_DAY) * 24; - return Math.min(0.95, (totalHours / maxHours) * 0.95); -} - -// ─── Spell Cost Helpers ───────────────────────────────────────────────────────── - -// Check if player can afford spell cost -export function canAffordSpellCost( - cost: SpellCost, - rawMana: number, - elements: Record -): boolean { - if (cost.type === 'raw') { - return rawMana >= cost.amount; - } else { - const elem = elements[cost.element || '']; - return elem && elem.unlocked && elem.current >= cost.amount; - } -} - -// Deduct spell cost from appropriate mana pool -export function deductSpellCost( - cost: SpellCost, - rawMana: number, - elements: Record -): { rawMana: number; elements: Record } { - const newElements = { ...elements }; - - if (cost.type === 'raw') { - // Clamp to 0 to prevent negative mana - return { rawMana: Math.max(0, rawMana - cost.amount), elements: newElements }; - } else if (cost.element && newElements[cost.element]) { - newElements[cost.element] = { - ...newElements[cost.element], - current: Math.max(0, newElements[cost.element].current - cost.amount) - }; - return { rawMana, elements: newElements }; - } - - return { rawMana, elements: newElements }; -} - -// ─── Damage Breakdown Helper ─────────────────────────────────────────────────── - -export interface DamageBreakdown { - base: number; - combatTrainBonus: number; - arcaneFuryMult: number; - elemMasteryMult: number; - guardianBaneMult: number; - pactMult: number; - precisionChance: number; - elemBonus: number; - elemBonusText: string; - total: number; -} - -export function getDamageBreakdown( - state: Pick, - activeSpellId: string, - floorElem: string, - isGuardianFloor: boolean -): DamageBreakdown | null { - const spell = SPELLS_DEF[activeSpellId]; - if (!spell) return null; - - const baseDmg = spell.dmg; - const combatTrainBonus = (state.skills.combatTrain || 0) * 5; - const arcaneFuryMult = 1 + (state.skills.arcaneFury || 0) * 0.1; - const elemMasteryMult = 1 + (state.skills.elementalMastery || 0) * 0.15; - const guardianBaneMult = isGuardianFloor ? (1 + (state.skills.guardianBane || 0) * 0.2) : 1; - const pactMult = state.signedPacts.reduce((m, f) => m * (GUARDIANS[f]?.pact || 1), 1); - const precisionChance = (state.skills.precision || 0) * 0.05; - - // Elemental bonus - let elemBonus = 1.0; - let elemBonusText = ''; - if (spell.elem !== 'raw' && floorElem) { - if (spell.elem === floorElem) { - elemBonus = 1.25; - elemBonusText = '+25% same element'; - } else if (ELEMENTS[spell.elem] && ELEMENT_OPPOSITES[floorElem] === spell.elem) { - elemBonus = 1.5; - elemBonusText = '+50% super effective'; - } - } - - return { - base: baseDmg, - combatTrainBonus, - arcaneFuryMult, - elemMasteryMult, - guardianBaneMult, - pactMult, - precisionChance, - elemBonus, - elemBonusText, - total: calcDamage(state, activeSpellId, floorElem) - }; -} - -// ─── Total DPS Calculation ───────────────────────────────────────────────────── - -export function getTotalDPS( - state: Pick, - upgradeEffects: { attackSpeedMultiplier: number }, - floorElem: string -): number { - const quickCastBonus = 1 + (state.skills.quickCast || 0) * 0.05; - const attackSpeedMult = upgradeEffects.attackSpeedMultiplier; - const castsPerSecondMult = HOURS_PER_TICK / (TICK_MS / 1000); - - const activeEquipmentSpells = getActiveEquipmentSpells( - state.equippedInstances, - state.equipmentInstances - ); - - let totalDPS = 0; - - for (const { spellId } of activeEquipmentSpells) { - const spell = SPELLS_DEF[spellId]; - if (!spell) continue; - - const spellCastSpeed = spell.castSpeed || 1; - const totalCastSpeed = spellCastSpeed * quickCastBonus * attackSpeedMult; - const damagePerCast = calcDamage(state, spellId, floorElem); - const castsPerSecond = totalCastSpeed * castsPerSecondMult; - - totalDPS += damagePerCast * castsPerSecond; - } - - return totalDPS; -} +export * from './utils/index'; diff --git a/src/lib/game/utils/combat-utils.ts b/src/lib/game/utils/combat-utils.ts new file mode 100644 index 0000000..cfe867d --- /dev/null +++ b/src/lib/game/utils/combat-utils.ts @@ -0,0 +1,322 @@ +// ─── Combat Utility Functions ───────────────────────────────────────────────── + +import type { GameState, SpellCost, EquipmentInstance } from '../types'; +import { + GUARDIANS, + SPELLS_DEF, + FLOOR_ELEM_CYCLE, + HOURS_PER_TICK, + MAX_DAY, + INCURSION_START_DAY, + ELEMENT_OPPOSITES, + ELEMENTS, + TICK_MS, +} from '../constants'; +import { EQUIPMENT_TYPES } from '../data/equipment'; +import { ENCHANTMENT_EFFECTS } from '../data/enchantment-effects'; + +// ─── Equipment Spell Helper ───────────────────────────────────────────────────── + +// Get all spells from equipped caster weapons (staves, wands, etc.) +// Returns array of { spellId, equipmentInstanceId } +export function getActiveEquipmentSpells( + equippedInstances: Record, + equipmentInstances: Record +): Array<{ spellId: string; equipmentId: string }> { + const spells: Array<{ spellId: string; equipmentId: string }> = []; + + // Check main hand and off hand for caster equipment + const weaponSlots = ['mainHand', 'offHand'] as const; + + for (const slot of weaponSlots) { + const instanceId = equippedInstances[slot]; + if (!instanceId) continue; + + const instance = equipmentInstances[instanceId]; + if (!instance) continue; + + // Check if this is a caster-type equipment + const equipType = EQUIPMENT_TYPES[instance.typeId]; + if (!equipType || equipType.category !== 'caster') continue; + + // Get spells from enchantments + for (const ench of instance.enchantments) { + const effectDef = ENCHANTMENT_EFFECTS[ench.effectId]; + if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) { + spells.push({ + spellId: effectDef.effect.spellId, + equipmentId: instanceId, + }); + } + } + } + + return spells; +} + +// ─── Skill Level Helper ───────────────────────────────────────────────────────── + +// Helper to get effective skill level accounting for tiers +export function getEffectiveSkillLevel( + skills: Record, + baseSkillId: string, + skillTiers: Record = {} +): { level: number; tier: number; tierMultiplier: number } { + // Find the highest tier the player has for this base skill + const currentTier = skillTiers[baseSkillId] || 1; + + // Look for the tiered skill ID (e.g., manaFlow_t2) + const tieredSkillId = currentTier > 1 ? `${baseSkillId}_t${currentTier}` : baseSkillId; + const level = skills[tieredSkillId] || skills[baseSkillId] || 0; + + // Tier multiplier: each tier is 10x more powerful + const tierMultiplier = Math.pow(10, currentTier - 1); + + return { level, tier: currentTier, tierMultiplier }; +} + +// ─── Elemental Damage Bonus ───────────────────────────────────────────────────── + +// Elemental damage bonus: +50% if spell element opposes floor element (super effective) +// -25% if spell element matches its own opposite (weak) +export function getElementalBonus(spellElem: string, floorElem: string): number { + if (spellElem === 'raw') return 1.0; // Raw mana has no elemental bonus + + if (spellElem === floorElem) return 1.25; // Same element: +25% damage + + // Check for super effective first: spell is the opposite of floor + // e.g., casting water (opposite of fire) at fire floor = super effective + if (ELEMENT_OPPOSITES[floorElem] === spellElem) return 1.5; // Super effective: +50% damage + + // Check for weak: spell's opposite matches floor + // e.g., casting fire (whose opposite is water) at water floor = weak + if (ELEMENT_OPPOSITES[spellElem] === floorElem) return 0.75; // Weak: -25% damage + + return 1.0; // Neutral +} + +// ─── Damage Calculation ───────────────────────────────────────────────────────── + +export function calcDamage( + state: Pick, + spellId: string, + floorElem?: string +): number { + const sp = SPELLS_DEF[spellId]; + if (!sp) return 5; + const skills = state.skills; + const baseDmg = sp.dmg + (skills.combatTrain || 0) * 5; + const pct = 1 + (skills.arcaneFury || 0) * 0.1; + + // Elemental mastery bonus + const elemMasteryBonus = 1 + (skills.elementalMastery || 0) * 0.15; + + // Guardian bane bonus + const guardianBonus = floorElem && GUARDIANS[Object.values(GUARDIANS).find(g => g.element === floorElem)?.hp ? 0 : 0] + ? 1 + (skills.guardianBane || 0) * 0.2 + : 1; + + const critChance = (skills.precision || 0) * 0.05; + const pactMult = state.signedPacts.reduce( + (m, f) => m * (GUARDIANS[f]?.pact || 1), + 1 + ); + + let damage = baseDmg * pct * pactMult * elemMasteryBonus; + + // Apply elemental bonus if floor element provided + if (floorElem) { + damage *= getElementalBonus(sp.elem, floorElem); + } + + // Apply crit + if (Math.random() < critChance) { + damage *= 1.5; + } + + return damage; +} + +// ─── Insight Calculation ──────────────────────────────────────────────────────── + +export function calcInsight(state: Pick): number { + const pu = state.prestigeUpgrades; + const skillBonus = 1 + (state.skills.insightHarvest || 0) * 0.1; + const mult = (1 + (pu.insightAmp || 0) * 0.25) * skillBonus; + return Math.floor( + (state.maxFloorReached * 15 + state.totalManaGathered / 500 + state.signedPacts.length * 150) * mult + ); +} + +// ─── Meditation Bonus ─────────────────────────────────────────────────────────── + +// Meditation bonus now affects regen rate directly +export function getMeditationBonus(meditateTicks: number, skills: Record, meditationEfficiency: number = 1): number { + const hasMeditation = skills.meditation === 1; + const hasDeepTrance = skills.deepTrance === 1; + const hasVoidMeditation = skills.voidMeditation === 1; + + const hours = meditateTicks * HOURS_PER_TICK; + + // Base meditation: ramps up over 4 hours to 1.5x + let bonus = 1 + Math.min(hours / 4, 0.5); + + // With Meditation Focus: up to 2.5x after 4 hours + if (hasMeditation && hours >= 4) { + bonus = 2.5; + } + + // With Deep Trance: up to 3.0x after 6 hours + if (hasDeepTrance && hours >= 6) { + bonus = 3.0; + } + + // With Void Meditation: up to 5.0x after 8 hours + if (hasVoidMeditation && hours >= 8) { + bonus = 5.0; + } + + // Apply meditation efficiency from upgrades (Deep Wellspring, etc.) + bonus *= meditationEfficiency; + + return bonus; +} + +// ─── Incursion Strength ───────────────────────────────────────────────────────── + +export function getIncursionStrength(day: number, hour: number): number { + if (day < INCURSION_START_DAY) return 0; + const totalHours = (day - INCURSION_START_DAY) * 24 + hour; + const maxHours = (MAX_DAY - INCURSION_START_DAY) * 24; + return Math.min(0.95, (totalHours / maxHours) * 0.95); +} + +// ─── Spell Cost Helpers ───────────────────────────────────────────────────────── + +// Check if player can afford spell cost +export function canAffordSpellCost( + cost: SpellCost, + rawMana: number, + elements: Record +): boolean { + if (cost.type === 'raw') { + return rawMana >= cost.amount; + } else { + const elem = elements[cost.element || '']; + return elem && elem.unlocked && elem.current >= cost.amount; + } +} + +// Deduct spell cost from appropriate mana pool +export function deductSpellCost( + cost: SpellCost, + rawMana: number, + elements: Record +): { rawMana: number; elements: Record } { + const newElements = { ...elements }; + + if (cost.type === 'raw') { + // Clamp to 0 to prevent negative mana + return { rawMana: Math.max(0, rawMana - cost.amount), elements: newElements }; + } else if (cost.element && newElements[cost.element]) { + newElements[cost.element] = { + ...newElements[cost.element], + current: Math.max(0, newElements[cost.element].current - cost.amount) + }; + return { rawMana, elements: newElements }; + } + + return { rawMana, elements: newElements }; +} + +// ─── Damage Breakdown Helper ─────────────────────────────────────────────────── + +export interface DamageBreakdown { + base: number; + combatTrainBonus: number; + arcaneFuryMult: number; + elemMasteryMult: number; + guardianBaneMult: number; + pactMult: number; + precisionChance: number; + elemBonus: number; + elemBonusText: string; + total: number; +} + +export function getDamageBreakdown( + state: Pick, + activeSpellId: string, + floorElem: string, + isGuardianFloor: boolean +): DamageBreakdown | null { + const spell = SPELLS_DEF[activeSpellId]; + if (!spell) return null; + + const baseDmg = spell.dmg; + const combatTrainBonus = (state.skills.combatTrain || 0) * 5; + const arcaneFuryMult = 1 + (state.skills.arcaneFury || 0) * 0.1; + const elemMasteryMult = 1 + (state.skills.elementalMastery || 0) * 0.15; + const guardianBaneMult = isGuardianFloor ? (1 + (state.skills.guardianBane || 0) * 0.2) : 1; + const pactMult = state.signedPacts.reduce((m, f) => m * (GUARDIANS[f]?.pact || 1), 1); + const precisionChance = (state.skills.precision || 0) * 0.05; + + // Elemental bonus + let elemBonus = 1.0; + let elemBonusText = ''; + if (spell.elem !== 'raw' && floorElem) { + if (spell.elem === floorElem) { + elemBonus = 1.25; + elemBonusText = '+25% same element'; + } else if (ELEMENTS[spell.elem] && ELEMENT_OPPOSITES[floorElem] === spell.elem) { + elemBonus = 1.5; + elemBonusText = '+50% super effective'; + } + } + + return { + base: baseDmg, + combatTrainBonus, + arcaneFuryMult, + elemMasteryMult, + guardianBaneMult, + pactMult, + precisionChance, + elemBonus, + elemBonusText, + total: calcDamage(state, activeSpellId, floorElem) + }; +} + +// ─── Total DPS Calculation ───────────────────────────────────────────────────── + +export function getTotalDPS( + state: Pick, + upgradeEffects: { attackSpeedMultiplier: number }, + floorElem: string +): number { + const quickCastBonus = 1 + (state.skills.quickCast || 0) * 0.05; + const attackSpeedMult = upgradeEffects.attackSpeedMultiplier; + const castsPerSecondMult = HOURS_PER_TICK / (TICK_MS / 1000); + + const activeEquipmentSpells = getActiveEquipmentSpells( + state.equippedInstances, + state.equipmentInstances + ); + + let totalDPS = 0; + + for (const { spellId } of activeEquipmentSpells) { + const spell = SPELLS_DEF[spellId]; + if (!spell) continue; + + const spellCastSpeed = spell.castSpeed || 1; + const totalCastSpeed = spellCastSpeed * quickCastBonus * attackSpeedMult; + const damagePerCast = calcDamage(state, spellId, floorElem); + const castsPerSecond = totalCastSpeed * castsPerSecondMult; + + totalDPS += damagePerCast * castsPerSecond; + } + + return totalDPS; +} diff --git a/src/lib/game/utils/floor-utils.ts b/src/lib/game/utils/floor-utils.ts new file mode 100644 index 0000000..761a061 --- /dev/null +++ b/src/lib/game/utils/floor-utils.ts @@ -0,0 +1,24 @@ +// ─── Floor Utility Functions ─────────────────────────────────────────────────── + +import { GUARDIANS, FLOOR_ELEM_CYCLE } from '../constants'; + +/** + * Get the max HP for a given floor + * Uses guardian data if available, otherwise uses scaling formula + */ +export function getFloorMaxHP(floor: number): number { + if (GUARDIANS[floor]) return GUARDIANS[floor].hp; + // Improved scaling: slower early game, faster late game + const baseHP = 100; + const floorScaling = floor * 50; + const exponentialScaling = Math.pow(floor, 1.7); + return Math.floor(baseHP + floorScaling + exponentialScaling); +} + +/** + * Get the element for a given floor + * Cycles through FLOOR_ELEM_CYCLE array + */ +export function getFloorElement(floor: number): string { + return FLOOR_ELEM_CYCLE[(floor - 1) % FLOOR_ELEM_CYCLE.length]; +} diff --git a/src/lib/game/utils/formatting.ts b/src/lib/game/utils/formatting.ts new file mode 100644 index 0000000..a94d2ca --- /dev/null +++ b/src/lib/game/utils/formatting.ts @@ -0,0 +1,19 @@ +// ─── Number Formatting Functions ──────────────────────────────────────────────── + +/** + * Format a number with K, M, B suffixes for large numbers + */ +export function fmt(n: number): string { + if (!isFinite(n) || isNaN(n)) return '0'; + if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'; + if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'; + if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'; + return Math.floor(n).toString(); +} + +/** + * Format a number with a fixed number of decimal places + */ +export function fmtDec(n: number, d: number = 1): string { + return isFinite(n) ? n.toFixed(d) : '0'; +} diff --git a/src/lib/game/utils/index.ts b/src/lib/game/utils/index.ts new file mode 100644 index 0000000..cf795db --- /dev/null +++ b/src/lib/game/utils/index.ts @@ -0,0 +1,35 @@ +// ─── Game Utilities - Index ─────────────────────────────────────────────────── +// Re-exports all utility functions for backward compatibility +// This allows imports from './utils' or './utils/index' + +// Export from formatting.ts +export { fmt, fmtDec } from './formatting'; + +// Export from floor-utils.ts +export { getFloorMaxHP, getFloorElement } from './floor-utils'; + +// Export from mana-utils.ts +export { + DEFAULT_EFFECTS, + computeMaxMana, + computeElementMax, + computeRegen, + computeEffectiveRegen, + computeClickMana +} from './mana-utils'; + +// Export from combat-utils.ts +export { + getActiveEquipmentSpells, + getEffectiveSkillLevel, + getElementalBonus, + calcDamage, + calcInsight, + getMeditationBonus, + getIncursionStrength, + canAffordSpellCost, + deductSpellCost, + getDamageBreakdown, + getTotalDPS, + type DamageBreakdown +} from './combat-utils'; diff --git a/src/lib/game/utils/mana-utils.ts b/src/lib/game/utils/mana-utils.ts new file mode 100644 index 0000000..6b9f0ce --- /dev/null +++ b/src/lib/game/utils/mana-utils.ts @@ -0,0 +1,145 @@ +// ─── Mana Calculation Functions ─────────────────────────────────────────────── + +import type { GameState } from '../types'; +import { HOURS_PER_TICK, TICK_MS } from '../constants'; +import type { ComputedEffects } from '../upgrade-effects'; +import { getUnifiedEffects, type UnifiedEffects } from '../effects'; + +// ─── Default Effects Constant ─────────────────────────────────────────────────── + +// Default empty effects for when effects aren't provided +export const DEFAULT_EFFECTS: ComputedEffects = { + maxManaMultiplier: 1, + maxManaBonus: 0, + regenMultiplier: 1, + regenBonus: 0, + clickManaMultiplier: 1, + clickManaBonus: 0, + meditationEfficiency: 1, + spellCostMultiplier: 1, + conversionEfficiency: 1, + baseDamageMultiplier: 1, + baseDamageBonus: 0, + attackSpeedMultiplier: 1, + critChanceBonus: 0, + critDamageMultiplier: 1.5, + elementalDamageMultiplier: 1, + studySpeedMultiplier: 1, + studyCostMultiplier: 1, + progressRetention: 0, + instantStudyChance: 0, + freeStudyChance: 0, + elementCapMultiplier: 1, + elementCapBonus: 0, + conversionCostMultiplier: 1, + doubleCraftChance: 0, + permanentRegenBonus: 0, + specials: new Set(), + activeUpgrades: [], +}; + +// ─── Mana Computation Functions ──────────────────────────────────────────────── + +export function computeMaxMana( + state: Pick, + effects?: ComputedEffects | UnifiedEffects +): number { + const pu = state.prestigeUpgrades; + const base = + 100 + + (state.skills.manaWell || 0) * 100 + + (pu.manaWell || 0) * 500; + + // If effects not provided, compute unified effects (includes equipment) + if (!effects && state.equipmentInstances && state.equippedInstances) { + effects = getUnifiedEffects(state as any); + } + + // Apply effects if available (now includes equipment bonuses) + if (effects) { + return Math.floor((base + effects.maxManaBonus) * effects.maxManaMultiplier); + } + return base; +} + +export function computeElementMax( + state: Pick, + effects?: ComputedEffects +): number { + const pu = state.prestigeUpgrades; + const base = 10 + (state.skills.elemAttune || 0) * 50 + (pu.elementalAttune || 0) * 25; + + // Apply upgrade effects if provided + if (effects) { + return Math.floor((base + effects.elementCapBonus) * effects.elementCapMultiplier); + } + return base; +} + +export function computeRegen( + state: Pick, + effects?: ComputedEffects | UnifiedEffects +): number { + const pu = state.prestigeUpgrades; + const temporalBonus = 1 + (pu.temporalEcho || 0) * 0.1; + const base = + 2 + + (state.skills.manaFlow || 0) * 1 + + (state.skills.manaSpring || 0) * 2 + + (pu.manaFlow || 0) * 0.5; + + let regen = base * temporalBonus; + + // If effects not provided, compute unified effects (includes equipment) + if (!effects && state.equipmentInstances && state.equippedInstances) { + effects = getUnifiedEffects(state as any); + } + + // Apply effects if available (now includes equipment bonuses) + if (effects) { + regen = (regen + effects.regenBonus + effects.permanentRegenBonus) * effects.regenMultiplier; + } + + return regen; +} + +/** + * Compute regen with dynamic special effects (needs current mana, max mana, incursion) + */ +export function computeEffectiveRegen( + state: Pick, + effects?: ComputedEffects | UnifiedEffects +): number { + // Base regen from existing function + let regen = computeRegen(state, effects); + + const maxMana = computeMaxMana(state, effects); + const currentMana = state.rawMana; + const incursionStrength = state.incursionStrength || 0; + + // Apply incursion penalty + regen *= (1 - incursionStrength); + + return regen; +} + +export function computeClickMana( + state: Pick, + effects?: ComputedEffects | UnifiedEffects +): number { + const base = + 1 + + (state.skills.manaTap || 0) * 1 + + (state.skills.manaSurge || 0) * 3; + + // If effects not provided, compute unified effects (includes equipment) + if (!effects && state.equipmentInstances && state.equippedInstances) { + effects = getUnifiedEffects(state as any); + } + + // Apply effects if available (now includes equipment bonuses) + if (effects) { + return Math.floor((base + effects.clickManaBonus) * effects.clickManaMultiplier); + } + return base; +}