// ─── Game Store ─────────────────────────────────────────────────────────────── import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { GameState, GameAction, StudyTarget, SkillUpgradeChoice, EquipmentSlot, EnchantmentDesign, DesignEffect, LootInventory } from './types'; import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF, PRESTIGE_DEF, FLOOR_ELEM_CYCLE, BASE_UNLOCKED_ELEMENTS, TICK_MS, HOURS_PER_TICK, MAX_DAY, INCURSION_START_DAY, MANA_PER_ELEMENT, getStudySpeedMultiplier, ELEMENT_OPPOSITES, EFFECT_RESEARCH_MAPPING, BASE_UNLOCKED_EFFECTS, ENCHANTING_UNLOCK_EFFECTS, } from './constants'; import { hasSpecial, SPECIAL_EFFECTS, computeDynamicRegen } from './upgrade-effects'; import { getUnifiedEffects } from './effects'; import { SKILL_EVOLUTION_PATHS } from './skill-evolution'; import { createStartingEquipment, processCraftingTick, getSpellsFromEquipment, type CraftingActions } from './crafting-slice'; import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects'; import { createFamiliarSlice, processFamiliarTick, grantStartingFamiliar, type FamiliarActions, type FamiliarBonuses, DEFAULT_FAMILIAR_BONUSES, } from './familiar-slice'; import { createNavigationSlice, type NavigationActions, } from './navigation-slice'; import { createStudySlice, type StudyActions, } from './study-slice'; import { rollLootDrops, LOOT_DROPS } from './data/loot-drops'; import { CRAFTING_RECIPES, canCraftRecipe } from './data/crafting-recipes'; import { EQUIPMENT_TYPES } from './data/equipment'; import type { EquipmentInstance } from './types'; // Import computed stats and utility functions from computed-stats.ts import { DEFAULT_EFFECTS, fmt, fmtDec, getFloorMaxHP, getFloorElement, getActiveEquipmentSpells, getEffectiveSkillLevel, computeMaxMana, computeElementMax, computeRegen, computeEffectiveRegen, computeClickMana, calcDamage, calcInsight, getMeditationBonus, getIncursionStrength, canAffordSpellCost, deductSpellCost, getTotalDPS, getDamageBreakdown, } from './computed-stats'; // Re-export formatting functions and computed stats for backward compatibility export { fmt, fmtDec, getFloorElement, computeMaxMana, computeRegen, computeClickMana, calcDamage, getMeditationBonus, getIncursionStrength, canAffordSpellCost, getFloorMaxHP, getActiveEquipmentSpells, getTotalDPS, getDamageBreakdown, }; // ─── Local Helper Functions ──────────────────────────────────────────────────── // Elemental damage bonus: +50% if spell element opposes floor element (super effective) // -25% if spell element matches its own opposite (weak) 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 } // ─── Initial State Factory ──────────────────────────────────────────────────── function makeInitial(overrides: Partial = {}): GameState { const pu = overrides.prestigeUpgrades || {}; const startFloor = 1 + (pu.spireKey || 0) * 2; const elemMax = computeElementMax({ skills: overrides.skills || {}, prestigeUpgrades: pu, skillUpgrades: overrides.skillUpgrades || {}, skillTiers: overrides.skillTiers || {} }); const elements: Record = {}; Object.keys(ELEMENTS).forEach((k) => { const isUnlocked = BASE_UNLOCKED_ELEMENTS.includes(k); let startAmount = 0; // Start with some elemental mana if elemStart upgrade if (isUnlocked && pu.elemStart) { startAmount = pu.elemStart * 5; } elements[k] = { current: overrides.elements?.[k]?.current ?? startAmount, max: elemMax, unlocked: isUnlocked, }; }); // Starting raw mana const startRawMana = 10 + (pu.manaWell || 0) * 500 + (pu.quickStart || 0) * 100; // Create starting equipment (staff with mana bolt, clothes) const startingEquipment = createStartingEquipment(); // Get spells from starting equipment const equipmentSpells = getSpellsFromEquipment( startingEquipment.equipmentInstances, Object.values(startingEquipment.equippedInstances) ); // Starting spells - now come from equipment instead of being learned directly const startSpells: Record = {}; // Add spells from equipment for (const spellId of equipmentSpells) { startSpells[spellId] = { learned: true, level: 1, studyProgress: 0 }; } // Add random starting spells from spell memory upgrade (pact spells) if (pu.spellMemory) { const availableSpells = Object.keys(SPELLS_DEF).filter(s => !startSpells[s]); const shuffled = availableSpells.sort(() => Math.random() - 0.5); for (let i = 0; i < Math.min(pu.spellMemory, shuffled.length); i++) { startSpells[shuffled[i]] = { learned: true, level: 1, studyProgress: 0 }; } } return { day: 1, hour: 0, loopCount: overrides.loopCount || 0, gameOver: false, victory: false, paused: false, rawMana: startRawMana, meditateTicks: 0, totalManaGathered: overrides.totalManaGathered || 0, elements: elements as Record, currentFloor: startFloor, floorHP: getFloorMaxHP(startFloor), floorMaxHP: getFloorMaxHP(startFloor), maxFloorReached: startFloor, signedPacts: [], activeSpell: 'manaBolt', currentAction: 'meditate', castProgress: 0, // Floor Navigation climbDirection: 'up', clearedFloors: {}, lastClearedFloor: null, spells: startSpells, skills: overrides.skills || {}, skillProgress: {}, skillUpgrades: overrides.skillUpgrades || {}, skillTiers: overrides.skillTiers || {}, parallelStudyTarget: null, studyStartedAt: null, consecutiveStudyHours: 0, lastStudyCost: 0, // New equipment system equippedInstances: startingEquipment.equippedInstances, equipmentInstances: startingEquipment.equipmentInstances, enchantmentDesigns: [], designProgress: null, preparationProgress: null, applicationProgress: null, unlockedEffects: [...BASE_UNLOCKED_EFFECTS], // Start with mana bolt only equipmentSpellStates: [], // Legacy equipment (for backward compatibility) equipment: { mainHand: null, offHand: null, head: null, body: null, hands: null, accessory: null, }, inventory: [], blueprints: {}, schedule: [], autoSchedule: false, studyQueue: [], craftQueue: [], currentStudyTarget: null, insight: overrides.insight || 0, totalInsight: overrides.totalInsight || 0, prestigeUpgrades: pu, memorySlots: 3 + (pu.deepMemory || 0), memories: overrides.memories || [], incursionStrength: 0, containmentWards: 0, // Combo System combo: { count: 0, multiplier: 1, lastCastTime: 0, decayTimer: 0, maxCombo: 0, elementChain: [], }, totalTicks: 0, consecutiveHits: 0, // Loot System lootInventory: { materials: {}, essence: {}, blueprints: [], }, lootDropsToday: 0, // Equipment Crafting Progress equipmentCraftingProgress: null, // Achievements achievements: { unlocked: [], progress: {}, }, totalDamageDealt: 0, totalSpellsCast: 0, totalCraftsCompleted: 0, // Familiars familiars: grantStartingFamiliar(), activeFamiliarSlots: 1, familiarSummonProgress: 0, totalFamiliarXpEarned: 0, log: ['✨ The loop begins. You start with a Basic Staff (Mana Bolt) and civilian clothes. A friendly Mana Wisp floats nearby. Gather your strength, mage.'], loopInsight: 0, }; } // ─── Game Store ─────────────────────────────────────────────────────────────── interface GameStore extends GameState, CraftingActions, FamiliarActions, NavigationActions, StudyActions { // Actions tick: () => void; gatherMana: () => void; setAction: (action: GameAction) => void; setSpell: (spellId: string) => void; convertMana: (element: string, amount: number) => void; unlockElement: (element: string) => void; craftComposite: (target: string) => void; doPrestige: (id: string) => void; startNewLoop: () => void; togglePause: () => void; resetGame: () => void; addLog: (message: string) => void; selectSkillUpgrade: (skillId: string, upgradeId: string) => void; deselectSkillUpgrade: (skillId: string, upgradeId: string) => void; commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void; tierUpSkill: (skillId: string) => void; // Inventory Management updateLootInventory: (inventory: LootInventory) => void; // Computed getters getMaxMana: () => number; getRegen: () => number; getClickMana: () => number; getDamage: (spellId: string) => number; getMeditationMultiplier: () => number; canCastSpell: (spellId: string) => boolean; getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => { available: SkillUpgradeChoice[]; selected: string[] }; } export const useGameStore = create()( persist( (set, get) => ({ ...makeInitial(), ...createFamiliarSlice(set, get), ...createNavigationSlice(set, get), ...createStudySlice(set, get), getMaxMana: () => computeMaxMana(get()), getRegen: () => computeRegen(get()), getClickMana: () => computeClickMana(get()), getDamage: (spellId: string) => calcDamage(get(), spellId), getMeditationMultiplier: () => getMeditationBonus(get().meditateTicks, get().skills), canCastSpell: (spellId: string) => { const state = get(); const spell = SPELLS_DEF[spellId]; if (!spell || !state.spells[spellId]?.learned) return false; return canAffordSpellCost(spell.cost, state.rawMana, state.elements); }, addLog: (message: string) => { set((state) => ({ log: [message, ...state.log.slice(0, 49)], })); }, tick: () => { const state = get(); if (state.gameOver || state.paused) return; // Compute unified effects (includes skill upgrades AND equipment enchantments) const effects = getUnifiedEffects(state); // Compute familiar bonuses const familiarBonuses = state.familiars.length > 0 ? (() => { const slice = createFamiliarSlice(set, get); return slice.getActiveFamiliarBonuses(); })() : DEFAULT_FAMILIAR_BONUSES; const maxMana = computeMaxMana(state, effects); const baseRegen = computeRegen(state, effects) + familiarBonuses.manaRegenBonus; // Time progression let hour = state.hour + HOURS_PER_TICK; let day = state.day; if (hour >= 24) { hour -= 24; day += 1; } // Check for loop end if (day > MAX_DAY) { const insightGained = calcInsight(state); set({ day, hour, gameOver: true, victory: false, loopInsight: insightGained, log: [`⏰ The loop ends. Gained ${insightGained} Insight.`, ...state.log.slice(0, 49)], }); return; } // Check for victory if (state.maxFloorReached >= 100 && state.signedPacts.includes(100)) { const insightGained = calcInsight(state) * 3; set({ gameOver: true, victory: true, loopInsight: insightGained, log: [`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`, ...state.log.slice(0, 49)], }); return; } // Incursion const incursionStrength = getIncursionStrength(day, hour); // Meditation bonus tracking and regen calculation let meditateTicks = state.meditateTicks; let meditationMultiplier = 1; if (state.currentAction === 'meditate') { meditateTicks++; meditationMultiplier = getMeditationBonus(meditateTicks, state.skills); } else { meditateTicks = 0; } // Calculate effective regen with dynamic special effects // computeDynamicRegen handles: Mana Cascade, Mana Torrent, Desperate Wells, Steady Stream let effectiveRegen = computeDynamicRegen( effects, baseRegen, maxMana, state.rawMana, incursionStrength ) * meditationMultiplier; // Mana regeneration let rawMana = Math.min(state.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana); let totalManaGathered = state.totalManaGathered; // Familiar auto-gather and auto-convert let elements = state.elements; if (familiarBonuses.autoGatherRate > 0 || familiarBonuses.autoConvertRate > 0) { const familiarUpdates = processFamiliarTick( { rawMana, elements, totalManaGathered, familiars: state.familiars, activeFamiliarSlots: state.activeFamiliarSlots }, familiarBonuses ); rawMana = Math.min(familiarUpdates.rawMana, maxMana); elements = familiarUpdates.elements; totalManaGathered = familiarUpdates.totalManaGathered; } // Study progress let currentStudyTarget = state.currentStudyTarget; let skills = state.skills; let skillProgress = state.skillProgress; let spells = state.spells; let log = state.log; let unlockedEffects = state.unlockedEffects; if (state.currentAction === 'study' && currentStudyTarget) { const studySpeedMult = getStudySpeedMultiplier(skills); const progressGain = HOURS_PER_TICK * studySpeedMult; currentStudyTarget = { ...currentStudyTarget, progress: currentStudyTarget.progress + progressGain, }; // Check if study is complete if (currentStudyTarget.progress >= currentStudyTarget.required) { if (currentStudyTarget.type === 'skill') { const skillId = currentStudyTarget.id; const currentLevel = skills[skillId] || 0; const newLevel = currentLevel + 1; skills = { ...skills, [skillId]: newLevel }; skillProgress = { ...skillProgress, [skillId]: 0 }; log = [`✅ ${SKILLS_DEF[skillId]?.name} Lv.${newLevel} mastered!`, ...log.slice(0, 49)]; // Check if this skill unlocks effects (research skills) const effectsToUnlock = EFFECT_RESEARCH_MAPPING[skillId]; if (effectsToUnlock && newLevel >= (SKILLS_DEF[skillId]?.max || 1)) { const newEffects = effectsToUnlock.filter(e => !unlockedEffects.includes(e)); if (newEffects.length > 0) { unlockedEffects = [...unlockedEffects, ...newEffects]; log = [`🔬 Unlocked ${newEffects.length} new enchantment effect(s)!`, ...log.slice(0, 49)]; } } // Special case: When enchanting skill reaches level 1, unlock mana bolt if (skillId === 'enchanting' && newLevel >= 1) { const enchantingEffects = ENCHANTING_UNLOCK_EFFECTS.filter(e => !unlockedEffects.includes(e)); if (enchantingEffects.length > 0) { unlockedEffects = [...unlockedEffects, ...enchantingEffects]; log = [`✨ Enchantment design unlocked! Mana Bolt effect available.`, ...log.slice(0, 49)]; } } } else if (currentStudyTarget.type === 'spell') { // Spells can no longer be studied directly - they come from equipment // This branch is kept for backward compatibility but should not be used const spellId = currentStudyTarget.id; spells = { ...spells, [spellId]: { learned: true, level: 1, studyProgress: 0 } }; log = [`📖 ${SPELLS_DEF[spellId]?.name} learned!`, ...log.slice(0, 49)]; } currentStudyTarget = null; } } // Convert action - auto convert mana if (state.currentAction === 'convert') { const unlockedElements = Object.entries(elements) .filter(([, e]) => e.unlocked && e.current < e.max); if (unlockedElements.length > 0 && rawMana >= MANA_PER_ELEMENT) { // Sort by space available (descending) unlockedElements.sort((a, b) => (b[1].max - b[1].current) - (a[1].max - a[1].current)); const [targetId, targetState] = unlockedElements[0]; const canConvert = Math.min( Math.floor(rawMana / MANA_PER_ELEMENT), targetState.max - targetState.current ); if (canConvert > 0) { rawMana -= canConvert * MANA_PER_ELEMENT; elements = { ...elements, [targetId]: { ...targetState, current: targetState.current + canConvert } }; } } } // Combat - MULTI-SPELL casting from all equipped weapons let { currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, equipmentSpellStates, combo, totalTicks, lootInventory, achievements, totalDamageDealt, totalSpellsCast } = state; const floorElement = getFloorElement(currentFloor); // Increment total ticks const newTotalTicks = totalTicks + 1; // Combo decay - decay combo when not climbing or when decay timer expires let newCombo = { ...combo }; if (state.currentAction !== 'climb') { // Rapidly decay combo when not climbing newCombo.count = Math.max(0, newCombo.count - 5); newCombo.multiplier = 1 + newCombo.count * 0.02; } else if (newCombo.count > 0) { // Slow decay while climbing but not casting newCombo.decayTimer--; if (newCombo.decayTimer <= 0) { newCombo.count = Math.max(0, newCombo.count - 2); newCombo.multiplier = 1 + newCombo.count * 0.02; newCombo.decayTimer = 10; } } if (state.currentAction === 'climb') { // Get all spells from equipped caster weapons const activeSpells = getActiveEquipmentSpells(state.equippedInstances, state.equipmentInstances); // Initialize spell states if needed if (!equipmentSpellStates) { equipmentSpellStates = []; } // Ensure we have state for all active spells for (const { spellId, equipmentId } of activeSpells) { if (!equipmentSpellStates.find(s => s.spellId === spellId && s.sourceEquipment === equipmentId)) { equipmentSpellStates.push({ spellId, sourceEquipment: equipmentId, castProgress: 0, }); } } // Remove states for spells that are no longer equipped equipmentSpellStates = equipmentSpellStates.filter(es => activeSpells.some(as => as.spellId === es.spellId && as.equipmentId === es.sourceEquipment) ); // Compute attack speed from quickCast skill and upgrades const baseAttackSpeed = 1 + (state.skills.quickCast || 0) * 0.05; const totalAttackSpeed = baseAttackSpeed * effects.attackSpeedMultiplier * familiarBonuses.castSpeedMultiplier; // Process each active spell for (const { spellId, equipmentId } of activeSpells) { const spellDef = SPELLS_DEF[spellId]; if (!spellDef) continue; // Get or create spell state let spellState = equipmentSpellStates.find(s => s.spellId === spellId && s.sourceEquipment === equipmentId); if (!spellState) continue; // Get spell cast speed (casts per hour, default 1) const spellCastSpeed = spellDef.castSpeed || 1; // Effective casts per tick = spellCastSpeed * totalAttackSpeed const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed; // Accumulate cast progress spellState = { ...spellState, castProgress: spellState.castProgress + progressPerTick }; // Process complete casts while (spellState.castProgress >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements)) { // Deduct cost const afterCost = deductSpellCost(spellDef.cost, rawMana, elements); rawMana = afterCost.rawMana; elements = afterCost.elements; totalManaGathered += spellDef.cost.amount; // Increment spell cast counter totalSpellsCast++; // ─── Combo System ─── // Build combo on each cast newCombo.count = Math.min(100, newCombo.count + 1); newCombo.lastCastTime = newTotalTicks; newCombo.decayTimer = 10; // Reset decay timer newCombo.maxCombo = Math.max(newCombo.maxCombo, newCombo.count); // Track element chain const spellElement = spellDef.elem; newCombo.elementChain = [...newCombo.elementChain.slice(-2), spellElement]; // Calculate combo multiplier let comboMult = 1 + newCombo.count * 0.02; // +2% per combo // Element chain bonus: +25% if last 3 spells were different elements const uniqueElements = new Set(newCombo.elementChain); if (newCombo.elementChain.length === 3 && uniqueElements.size === 3) { comboMult += 0.25; // Log elemental chain occasionally if (newCombo.count % 10 === 0) { log = [`🌈 Elemental Chain! (${newCombo.elementChain.join(' → ')})`, ...log.slice(0, 49)]; } } newCombo.multiplier = Math.min(3.0, comboMult); // Calculate damage let dmg = calcDamage(state, spellId, floorElement); // Apply combo multiplier FIRST dmg *= newCombo.multiplier; // Apply upgrade damage multipliers and bonuses dmg = dmg * effects.baseDamageMultiplier + effects.baseDamageBonus; // Overpower: +50% damage when mana above 80% if (hasSpecial(effects, SPECIAL_EFFECTS.OVERPOWER) && rawMana >= maxMana * 0.8) { dmg *= 1.5; } // Berserker: +50% damage when below 50% mana if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) { dmg *= 1.5; } // Familiar bonuses dmg *= familiarBonuses.damageMultiplier; dmg *= familiarBonuses.elementalDamageMultiplier; // Familiar crit chance bonus if (Math.random() < familiarBonuses.critChanceBonus / 100) { dmg *= 1.5; } // Spell echo - chance to cast again const echoChance = (skills.spellEcho || 0) * 0.1; if (Math.random() < echoChance) { dmg *= 2; log = [`✨ Spell Echo! ${spellDef.name} deals double damage!`, ...log.slice(0, 49)]; } // Lifesteal effect const lifestealEffect = spellDef.effects?.find(e => e.type === 'lifesteal'); if (lifestealEffect) { const healAmount = dmg * lifestealEffect.value; rawMana = Math.min(rawMana + healAmount, maxMana); } // Familiar lifesteal if (familiarBonuses.lifeStealPercent > 0) { const healAmount = dmg * (familiarBonuses.lifeStealPercent / 100); rawMana = Math.min(rawMana + healAmount, maxMana); } // Track total damage for achievements totalDamageDealt += dmg; // Apply damage floorHP = Math.max(0, floorHP - dmg); // Reduce cast progress by 1 (one cast completed) spellState = { ...spellState, castProgress: spellState.castProgress - 1 }; if (floorHP <= 0) { // Floor cleared const wasGuardian = GUARDIANS[currentFloor]; const clearedFloors = state.clearedFloors; const climbDirection = state.climbDirection; // Mark this floor as cleared (needs respawn if we leave and return) clearedFloors[currentFloor] = true; const lastClearedFloor = currentFloor; // ─── Loot Drop System ─── const lootDrops = rollLootDrops(currentFloor, !!wasGuardian, 0); for (const { drop, amount } of lootDrops) { if (drop.type === 'material') { lootInventory.materials[drop.id] = (lootInventory.materials[drop.id] || 0) + amount; log = [`💎 Found: ${drop.name}!`, ...log.slice(0, 49)]; } else if (drop.type === 'essence' && drop.id) { // Extract element from essence drop id (e.g., 'fireEssenceDrop' -> 'fire') const element = drop.id.replace('EssenceDrop', ''); if (elements[element]) { const gain = Math.min(amount, elements[element].max - elements[element].current); elements[element] = { ...elements[element], current: elements[element].current + gain }; log = [`✨ Gained ${gain} ${element} essence!`, ...log.slice(0, 49)]; } } else if (drop.type === 'gold') { rawMana += amount; log = [`💫 Gained ${amount} mana from ${drop.name}!`, ...log.slice(0, 49)]; } else if (drop.type === 'blueprint') { if (!lootInventory.blueprints.includes(drop.id)) { lootInventory.blueprints.push(drop.id); log = [`📜 Discovered: ${drop.name}!`, ...log.slice(0, 49)]; } } } if (wasGuardian && !signedPacts.includes(currentFloor)) { signedPacts = [...signedPacts, currentFloor]; log = [`⚔️ ${wasGuardian.name} defeated! Pact signed! (${wasGuardian.pact}x)`, ...log.slice(0, 49)]; } else if (!wasGuardian) { if (currentFloor % 5 === 0) { log = [`🏰 Floor ${currentFloor} cleared!`, ...log.slice(0, 49)]; } } // Move to next floor based on direction const nextFloor = climbDirection === 'up' ? Math.min(currentFloor + 1, 100) : Math.max(currentFloor - 1, 1); currentFloor = nextFloor; floorMaxHP = getFloorMaxHP(currentFloor); // Check if this floor was previously cleared (has enemies respawned?) // Floors respawn when you leave them and come back const floorWasCleared = clearedFloors[currentFloor]; if (floorWasCleared) { // Floor has respawned - reset it but mark as uncleared delete clearedFloors[currentFloor]; } floorHP = floorMaxHP; maxFloorReached = Math.max(maxFloorReached, currentFloor); // Reset combo on floor change (partial reset - keep 50%) newCombo.count = Math.floor(newCombo.count * 0.5); newCombo.multiplier = 1 + newCombo.count * 0.02; newCombo.elementChain = []; // Reset ALL spell progress on floor change equipmentSpellStates = equipmentSpellStates.map(s => ({ ...s, castProgress: 0 })); spellState = { ...spellState, castProgress: 0 }; // Update clearedFloors in the state set((s) => ({ ...s, clearedFloors, lastClearedFloor })); break; // Exit the while loop - new floor } } // Update the spell state in the array equipmentSpellStates = equipmentSpellStates.map(s => (s.spellId === spellId && s.sourceEquipment === equipmentId) ? spellState : s ); } } // Update combo state combo = newCombo; // Process crafting actions (design, prepare, enchant) const craftingUpdates = processCraftingTick( { ...state, rawMana, log, currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, equipmentSpellStates, incursionStrength, currentStudyTarget, skills, skillProgress, spells, elements, meditateTicks, }, { rawMana, log } ); // Apply crafting updates if (craftingUpdates.rawMana !== undefined) rawMana = craftingUpdates.rawMana; if (craftingUpdates.log !== undefined) log = craftingUpdates.log; if (craftingUpdates.currentAction !== undefined) { set({ ...craftingUpdates, day, hour, rawMana, meditateTicks, totalManaGathered, currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, incursionStrength, currentStudyTarget, skills, skillProgress, spells, elements, log, equipmentSpellStates, combo, totalTicks: newTotalTicks, lootInventory, achievements, totalDamageDealt, totalSpellsCast, }); return; } // Grant XP to active familiars based on activity let familiars = state.familiars; if (familiars.some(f => f.active)) { let xpGain = 0; let xpSource: 'combat' | 'gather' | 'meditate' | 'study' | 'time' = 'time'; if (state.currentAction === 'climb') { xpGain = 2 * HOURS_PER_TICK; // 2 XP per hour in combat xpSource = 'combat'; } else if (state.currentAction === 'meditate') { xpGain = 1 * HOURS_PER_TICK; xpSource = 'meditate'; } else if (state.currentAction === 'study') { xpGain = 1.5 * HOURS_PER_TICK; xpSource = 'study'; } else { xpGain = 0.5 * HOURS_PER_TICK; // Passive XP } // Update familiar XP and bond familiars = familiars.map(f => { if (!f.active) return f; const bondMultiplier = 1 + (f.bond / 100); const xpGained = Math.floor(xpGain * bondMultiplier); const newXp = f.experience + xpGained; const newBond = Math.min(100, f.bond + 0.02); // Slow bond gain return { ...f, experience: newXp, bond: newBond }; }); } set({ day, hour, rawMana, meditateTicks, totalManaGathered, currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, incursionStrength, currentStudyTarget, skills, skillProgress, spells, elements, unlockedEffects, log, equipmentSpellStates, combo, totalTicks: newTotalTicks, lootInventory, achievements, totalDamageDealt, totalSpellsCast, familiars, ...craftingUpdates, }); }, gatherMana: () => { const state = get(); // Compute unified effects for click mana const effects = getUnifiedEffects(state); let cm = computeClickMana(state, effects); // Mana overflow bonus const overflowBonus = 1 + (state.skills.manaOverflow || 0) * 0.25; cm = Math.floor(cm * overflowBonus); // MANA_ECHO: 10% chance to gain double mana from clicks let echoTriggered = false; if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_ECHO) && Math.random() < 0.1) { cm *= 2; echoTriggered = true; } const max = computeMaxMana(state, effects); const newRawMana = Math.min(state.rawMana + cm, max); if (echoTriggered) { set({ rawMana: newRawMana, totalManaGathered: state.totalManaGathered + cm, log: [`✨ Mana Echo! Gained ${cm} mana (doubled)!`, ...state.log.slice(0, 49)], }); } else { set({ rawMana: newRawMana, totalManaGathered: state.totalManaGathered + cm, }); } }, setAction: (action: GameAction) => { set((state) => ({ currentAction: action, meditateTicks: action === 'meditate' ? state.meditateTicks : 0, })); }, setSpell: (spellId: string) => { const state = get(); // Can only set learned spells if (state.spells[spellId]?.learned) { set({ activeSpell: spellId }); } }, convertMana: (element: string, amount: number = 1) => { const state = get(); const e = state.elements[element]; if (!e?.unlocked) return; const cost = MANA_PER_ELEMENT * amount; if (state.rawMana < cost) return; if (e.current >= e.max) return; const canConvert = Math.min( amount, Math.floor(state.rawMana / MANA_PER_ELEMENT), e.max - e.current ); set({ rawMana: state.rawMana - canConvert * MANA_PER_ELEMENT, elements: { ...state.elements, [element]: { ...e, current: e.current + canConvert }, }, }); }, unlockElement: (element: string) => { const state = get(); if (state.elements[element]?.unlocked) return; const cost = 500; if (state.rawMana < cost) return; set({ rawMana: state.rawMana - cost, elements: { ...state.elements, [element]: { ...state.elements[element], unlocked: true }, }, log: [`✨ ${ELEMENTS[element].name} affinity unlocked!`, ...state.log.slice(0, 49)], }); }, craftComposite: (target: string) => { const state = get(); const edef = ELEMENTS[target]; if (!edef?.recipe) return; const recipe = edef.recipe; const costs: Record = {}; recipe.forEach((r) => { costs[r] = (costs[r] || 0) + 1; }); // Check ingredients for (const [r, amt] of Object.entries(costs)) { if ((state.elements[r]?.current || 0) < amt) return; } const newElems = { ...state.elements }; for (const [r, amt] of Object.entries(costs)) { newElems[r] = { ...newElems[r], current: newElems[r].current - amt }; } // Elemental crafting bonus const craftBonus = 1 + (state.skills.elemCrafting || 0) * 0.25; const outputAmount = Math.floor(craftBonus); const effects = getUnifiedEffects(state); const elemMax = computeElementMax(state, effects); newElems[target] = { ...(newElems[target] || { current: 0, max: elemMax, unlocked: false }), current: (newElems[target]?.current || 0) + outputAmount, max: elemMax, unlocked: true, }; set({ elements: newElems, log: [`🧪 Crafted ${outputAmount} ${ELEMENTS[target].name} mana!`, ...state.log.slice(0, 49)], }); }, doPrestige: (id: string) => { const state = get(); const pd = PRESTIGE_DEF[id]; if (!pd) return; const lvl = state.prestigeUpgrades[id] || 0; if (lvl >= pd.max || state.insight < pd.cost) return; const newPU = { ...state.prestigeUpgrades, [id]: lvl + 1 }; set({ insight: state.insight - pd.cost, prestigeUpgrades: newPU, memorySlots: id === 'deepMemory' ? state.memorySlots + 1 : state.memorySlots, log: [`⭐ ${pd.name} upgraded to Lv.${lvl + 1}!`, ...state.log.slice(0, 49)], }); }, startNewLoop: () => { const state = get(); const insightGained = state.loopInsight || calcInsight(state); const total = state.insight + insightGained; // Check for EMERGENCY_RESERVE before creating new state const effects = getUnifiedEffects(state); const maxMana = computeMaxMana(state, effects); const hasEmergencyReserve = hasSpecial(effects, SPECIAL_EFFECTS.EMERGENCY_RESERVE); // Keep some spells through temporal memory let spellsToKeep: string[] = []; if (state.skills.temporalMemory) { const learnedSpells = Object.entries(state.spells) .filter(([, s]) => s.learned) .map(([id]) => id); spellsToKeep = learnedSpells.slice(0, state.skills.temporalMemory); } const newState = makeInitial({ loopCount: state.loopCount + 1, insight: total, totalInsight: (state.totalInsight || 0) + insightGained, prestigeUpgrades: state.prestigeUpgrades, memories: state.memories, skills: state.skills, // Keep skills through temporal memory for now }); // Add kept spells if (spellsToKeep.length > 0) { spellsToKeep.forEach(spellId => { newState.spells[spellId] = { learned: true, level: 1, studyProgress: 0 }; }); } // EMERGENCY_RESERVE: Keep 10% of max mana when starting new loop if (hasEmergencyReserve) { const reserveMana = Math.floor(maxMana * 0.1); newState.rawMana = reserveMana; newState.log = [`💫 Emergency Reserve preserved ${reserveMana} mana!`, ...newState.log.slice(0, 49)]; } set(newState); }, togglePause: () => { set((state) => ({ paused: !state.paused })); }, resetGame: () => { // Clear localStorage and reset localStorage.removeItem('mana-loop-storage'); set(makeInitial()); }, selectSkillUpgrade: (skillId: string, upgradeId: string) => { set((state) => { const current = state.skillUpgrades?.[skillId] || []; if (current.includes(upgradeId)) return state; if (current.length >= 2) return state; // Max 2 upgrades per milestone return { skillUpgrades: { ...state.skillUpgrades, [skillId]: [...current, upgradeId], }, }; }); }, deselectSkillUpgrade: (skillId: string, upgradeId: string) => { set((state) => { const current = state.skillUpgrades?.[skillId] || []; return { skillUpgrades: { ...state.skillUpgrades, [skillId]: current.filter(id => id !== upgradeId), }, }; }); }, commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => { set((state) => ({ skillUpgrades: { ...state.skillUpgrades, [skillId]: upgradeIds, }, })); }, tierUpSkill: (skillId: string) => { const state = get(); const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId; const currentTier = state.skillTiers?.[baseSkillId] || 1; const nextTier = currentTier + 1; if (nextTier > 5) return; // Max tier is 5 const nextTierSkillId = `${baseSkillId}_t${nextTier}`; const currentLevel = state.skills[skillId] || 0; set({ skillTiers: { ...state.skillTiers, [baseSkillId]: nextTier, }, skills: { ...state.skills, [nextTierSkillId]: currentLevel, // Carry over level to new tier [skillId]: 0, // Reset old tier }, skillUpgrades: { ...state.skillUpgrades, [nextTierSkillId]: [], // Start fresh with upgrades for new tier }, log: [`🌟 ${SKILLS_DEF[baseSkillId]?.name || baseSkillId} evolved to Tier ${nextTier}!`, ...state.log.slice(0, 49)], }); }, getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => { const state = get(); const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId; const tier = state.skillTiers?.[baseSkillId] || 1; const path = SKILL_EVOLUTION_PATHS[baseSkillId]; if (!path) return { available: [], selected: [] }; const tierDef = path.tiers.find(t => t.tier === tier); if (!tierDef) return { available: [], selected: [] }; const available = tierDef.upgrades.filter(u => u.milestone === milestone); const selected = state.skillUpgrades?.[skillId]?.filter(id => available.some(u => u.id === id) ) || []; return { available, selected }; }, // ─── Crafting Actions (from crafting slice) ───────────────────────────────── createEquipmentInstance: (typeId: string) => { const type = EQUIPMENT_TYPES[typeId]; if (!type) return null; const instanceId = `equip_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const instance: EquipmentInstance = { instanceId, typeId, name: type.name, enchantments: [], usedCapacity: 0, totalCapacity: type.baseCapacity, rarity: 'common', quality: 100, }; set((state) => ({ equipmentInstances: { ...state.equipmentInstances, [instanceId]: instance, }, })); return instanceId; }, equipItem: (instanceId: string, slot: EquipmentSlot) => { const state = get(); const instance = state.equipmentInstances[instanceId]; if (!instance) return false; const type = EQUIPMENT_TYPES[instance.typeId]; if (!type) return false; // Check if equipment can go in this slot const validSlots = type.category === 'accessory' ? ['accessory1', 'accessory2'] : [type.slot]; if (!validSlots.includes(slot)) return false; // Check if slot is occupied const currentEquipped = state.equippedInstances[slot]; if (currentEquipped === instanceId) return true; // Already equipped here // If this item is equipped elsewhere, unequip it first let newEquipped = { ...state.equippedInstances }; for (const [s, id] of Object.entries(newEquipped)) { if (id === instanceId) { newEquipped[s as EquipmentSlot] = null; } } // Equip to new slot newEquipped[slot] = instanceId; set(() => ({ equippedInstances: newEquipped })); return true; }, unequipItem: (slot: EquipmentSlot) => { set((state) => ({ equippedInstances: { ...state.equippedInstances, [slot]: null, }, })); }, deleteEquipmentInstance: (instanceId: string) => { set((state) => { // First unequip if equipped let newEquipped = { ...state.equippedInstances }; for (const [slot, id] of Object.entries(newEquipped)) { if (id === instanceId) { newEquipped[slot as EquipmentSlot] = null; } } // Remove from instances const newInstances = { ...state.equipmentInstances }; delete newInstances[instanceId]; return { equippedInstances: newEquipped, equipmentInstances: newInstances, }; }); }, updateLootInventory: (inventory: LootInventory) => { set({ lootInventory: inventory }); }, startDesigningEnchantment: (name: string, equipmentTypeId: string, effects: DesignEffect[]) => { const state = get(); // Check if player has enchanting skill const enchantingLevel = state.skills.enchanting || 0; if (enchantingLevel < 1) return false; // Validate effects for equipment category const type = EQUIPMENT_TYPES[equipmentTypeId]; if (!type) return false; for (const eff of effects) { const effectDef = ENCHANTMENT_EFFECTS[eff.effectId]; if (!effectDef) return false; if (!effectDef.allowedEquipmentCategories.includes(type.category)) return false; if (eff.stacks > effectDef.maxStacks) return false; } // Calculate capacity cost const efficiencyBonus = (state.skills.efficientEnchant || 0) * 0.05; const totalCapacityCost = effects.reduce((total, eff) => total + calculateEffectCapacityCost(eff.effectId, eff.stacks, efficiencyBonus), 0); // Calculate design time let designTime = 1; for (const eff of effects) { designTime += 0.5 * eff.stacks; } // Store pending design in designProgress set(() => ({ currentAction: 'design', designProgress: { designId: `design_${Date.now()}`, progress: 0, required: designTime, }, // Store design data temporarily log: [`🔮 Designing enchantment: ${name}...`, ...state.log.slice(0, 49)], })); return true; }, cancelDesign: () => { set(() => ({ currentAction: 'meditate', designProgress: null, })); }, saveDesign: (design: EnchantmentDesign) => { set((state) => ({ enchantmentDesigns: [...state.enchantmentDesigns, design], designProgress: null, currentAction: 'meditate', log: [`📜 Enchantment design "${design.name}" saved!`, ...state.log.slice(0, 49)], })); }, deleteDesign: (designId: string) => { set((state) => ({ enchantmentDesigns: state.enchantmentDesigns.filter(d => d.id !== designId), })); }, startPreparing: (equipmentInstanceId: string) => { const state = get(); const instance = state.equipmentInstances[equipmentInstanceId]; if (!instance) return false; // Prep time: 2 hours base + 1 hour per 50 capacity const prepTime = 2 + Math.floor(instance.totalCapacity / 50); const manaCost = instance.totalCapacity * 10; if (state.rawMana < manaCost) return false; set(() => ({ currentAction: 'prepare', preparationProgress: { equipmentInstanceId, progress: 0, required: prepTime, manaCostPaid: 0, }, log: [`⚙️ Preparing ${instance.name} for enchanting...`, ...state.log.slice(0, 49)], })); return true; }, cancelPreparation: () => { set(() => ({ currentAction: 'meditate', preparationProgress: null, })); }, startApplying: (equipmentInstanceId: string, designId: string) => { const state = get(); const instance = state.equipmentInstances[equipmentInstanceId]; const design = state.enchantmentDesigns.find(d => d.id === designId); if (!instance || !design) return false; // Check capacity if (instance.usedCapacity + design.totalCapacityUsed > instance.totalCapacity) { return false; } // Application time: 2 hours base + 1 hour per effect stack const applicationTime = 2 + design.effects.reduce((total, eff) => total + eff.stacks, 0); const manaPerHour = 20 + design.effects.reduce((total, eff) => total + eff.stacks * 5, 0); set(() => ({ currentAction: 'enchant', applicationProgress: { equipmentInstanceId, designId, progress: 0, required: applicationTime, manaPerHour, paused: false, manaSpent: 0, }, log: [`✨ Applying "${design.name}" to ${instance.name}...`, ...state.log.slice(0, 49)], })); return true; }, pauseApplication: () => { set((state) => { if (!state.applicationProgress) return {}; return { applicationProgress: { ...state.applicationProgress, paused: true, }, }; }); }, resumeApplication: () => { set((state) => { if (!state.applicationProgress) return {}; return { applicationProgress: { ...state.applicationProgress, paused: false, }, }; }); }, cancelApplication: () => { set(() => ({ currentAction: 'meditate', applicationProgress: null, })); }, disenchantEquipment: (instanceId: string) => { const state = get(); const instance = state.equipmentInstances[instanceId]; if (!instance || instance.enchantments.length === 0) return; const disenchantLevel = state.skills.disenchanting || 0; const recoveryRate = 0.1 + disenchantLevel * 0.2; // 10% base + 20% per level let totalRecovered = 0; for (const ench of instance.enchantments) { totalRecovered += Math.floor(ench.actualCost * recoveryRate); } set((state) => ({ rawMana: state.rawMana + totalRecovered, equipmentInstances: { ...state.equipmentInstances, [instanceId]: { ...instance, enchantments: [], usedCapacity: 0, }, }, log: [`✨ Disenchanted ${instance.name}, recovered ${totalRecovered} mana.`, ...state.log.slice(0, 49)], })); }, getEquipmentSpells: () => { const state = get(); const spells: string[] = []; for (const instanceId of Object.values(state.equippedInstances)) { if (!instanceId) continue; const instance = state.equipmentInstances[instanceId]; if (!instance) continue; for (const ench of instance.enchantments) { const effectDef = ENCHANTMENT_EFFECTS[ench.effectId]; if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) { spells.push(effectDef.effect.spellId); } } } return [...new Set(spells)]; // Remove duplicates }, getEquipmentEffects: () => { const state = get(); const effects: Record = {}; for (const instanceId of Object.values(state.equippedInstances)) { if (!instanceId) continue; const instance = state.equipmentInstances[instanceId]; if (!instance) continue; for (const ench of instance.enchantments) { const effectDef = ENCHANTMENT_EFFECTS[ench.effectId]; if (!effectDef) continue; if (effectDef.effect.type === 'bonus' && effectDef.effect.stat && effectDef.effect.value) { effects[effectDef.effect.stat] = (effects[effectDef.effect.stat] || 0) + effectDef.effect.value * ench.stacks; } } } return effects; }, getAvailableCapacity: (instanceId: string) => { const state = get(); const instance = state.equipmentInstances[instanceId]; if (!instance) return 0; return instance.totalCapacity - instance.usedCapacity; }, // ─── Equipment Crafting (from blueprints) ─────────────────────────────────── startCraftingEquipment: (blueprintId: string) => { const state = get(); const recipe = CRAFTING_RECIPES[blueprintId]; if (!recipe) return false; // Check if player has the blueprint if (!state.lootInventory.blueprints.includes(blueprintId)) return false; // Check materials and mana const { canCraft } = canCraftRecipe( recipe, state.lootInventory.materials, state.rawMana ); if (!canCraft) return false; // Deduct materials const newMaterials = { ...state.lootInventory.materials }; for (const [matId, amount] of Object.entries(recipe.materials)) { newMaterials[matId] = (newMaterials[matId] || 0) - amount; if (newMaterials[matId] <= 0) { delete newMaterials[matId]; } } // Start crafting progress set((state) => ({ lootInventory: { ...state.lootInventory, materials: newMaterials, }, rawMana: state.rawMana - recipe.manaCost, currentAction: 'craft', equipmentCraftingProgress: { blueprintId, equipmentTypeId: recipe.equipmentTypeId, progress: 0, required: recipe.craftTime, manaSpent: recipe.manaCost, }, log: [`🔨 Started crafting ${recipe.name}...`, ...state.log.slice(0, 49)], })); return true; }, cancelEquipmentCrafting: () => { set((state) => { const progress = state.equipmentCraftingProgress; if (!progress) return {}; const recipe = CRAFTING_RECIPES[progress.blueprintId]; if (!recipe) return { currentAction: 'meditate', equipmentCraftingProgress: null }; // Refund 50% of mana const manaRefund = Math.floor(progress.manaSpent * 0.5); return { currentAction: 'meditate', equipmentCraftingProgress: null, rawMana: state.rawMana + manaRefund, log: [`🚫 Crafting cancelled. Refunded ${manaRefund} mana.`, ...state.log.slice(0, 49)], }; }); }, deleteMaterial: (materialId: string, amount: number) => { set((state) => { const currentAmount = state.lootInventory.materials[materialId] || 0; const newAmount = Math.max(0, currentAmount - amount); const newMaterials = { ...state.lootInventory.materials }; if (newAmount <= 0) { delete newMaterials[materialId]; } else { newMaterials[materialId] = newAmount; } const dropName = LOOT_DROPS[materialId]?.name || materialId; return { lootInventory: { ...state.lootInventory, materials: newMaterials, }, log: [`🗑️ Deleted ${amount}x ${dropName}.`, ...state.log.slice(0, 49)], }; }); }, }), { name: 'mana-loop-storage', version: 2, migrate: (persistedState: unknown, version: number) => { const state = persistedState as Record; // Migration from version 0/1 to version 2 - add missing fields if (version < 2) { return { ...state, castProgress: state.castProgress ?? 0, skillUpgrades: state.skillUpgrades ?? {}, skillTiers: state.skillTiers ?? {}, parallelStudyTarget: state.parallelStudyTarget ?? null, }; } return state; }, partialize: (state) => ({ day: state.day, hour: state.hour, loopCount: state.loopCount, rawMana: state.rawMana, meditateTicks: state.meditateTicks, totalManaGathered: state.totalManaGathered, elements: state.elements, currentFloor: state.currentFloor, floorHP: state.floorHP, floorMaxHP: state.floorMaxHP, maxFloorReached: state.maxFloorReached, signedPacts: state.signedPacts, activeSpell: state.activeSpell, currentAction: state.currentAction, castProgress: state.castProgress, climbDirection: state.climbDirection, clearedFloors: state.clearedFloors, lastClearedFloor: state.lastClearedFloor, spells: state.spells, skills: state.skills, skillProgress: state.skillProgress, skillUpgrades: state.skillUpgrades, skillTiers: state.skillTiers, currentStudyTarget: state.currentStudyTarget, parallelStudyTarget: state.parallelStudyTarget, insight: state.insight, totalInsight: state.totalInsight, prestigeUpgrades: state.prestigeUpgrades, memorySlots: state.memorySlots, memories: state.memories, log: state.log, // Equipment system equippedInstances: state.equippedInstances, equipmentInstances: state.equipmentInstances, enchantmentDesigns: state.enchantmentDesigns, designProgress: state.designProgress, preparationProgress: state.preparationProgress, applicationProgress: state.applicationProgress, // Loot system lootInventory: state.lootInventory, lootDropsToday: state.lootDropsToday, // Achievements achievements: state.achievements, totalDamageDealt: state.totalDamageDealt, totalSpellsCast: state.totalSpellsCast, totalCraftsCompleted: state.totalCraftsCompleted, // Familiars familiars: state.familiars, activeFamiliarSlots: state.activeFamiliarSlots, familiarSummonProgress: state.familiarSummonProgress, totalFamiliarXpEarned: state.totalFamiliarXpEarned, }), } ) ); // ─── Game Loop Hook ─────────────────────────────────────────────────────────── export function useGameLoop() { const tick = useGameStore((s) => s.tick); // Use useEffect in the component that uses this hook return { start: () => { const interval = setInterval(tick, TICK_MS); return () => clearInterval(interval); }, }; }