// ─── Game Store (Coordinator) ───────────────────────────────────────────────── // Manages: day, hour, incursionStrength, containmentWards // Coordinates tick function across all stores import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { TICK_MS, HOURS_PER_TICK, MAX_DAY, SPELLS_DEF, GUARDIANS, ELEMENTS, BASE_UNLOCKED_ELEMENTS, getStudySpeedMultiplier } from '../constants'; import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '../upgrade-effects'; import { computeMaxMana, computeRegen, getFloorElement, getFloorMaxHP, getMeditationBonus, getIncursionStrength, calcInsight, calcDamage, deductSpellCost, } from '../utils'; import { useUIStore } from './uiStore'; import { usePrestigeStore } from './prestigeStore'; import { useManaStore } from './manaStore'; import { useSkillStore } from './skillStore'; import { useCombatStore, makeInitialSpells } from './combatStore'; import type { Memory } from '../types'; export interface GameCoordinatorState { day: number; hour: number; incursionStrength: number; containmentWards: number; initialized: boolean; } export interface GameCoordinatorStore extends GameCoordinatorState { tick: () => void; resetGame: () => void; togglePause: () => void; startNewLoop: () => void; gatherMana: () => void; initGame: () => void; } const initialState: GameCoordinatorState = { day: 1, hour: 0, incursionStrength: 0, containmentWards: 0, initialized: false, }; // Helper function for checking spell cost affordability function canAffordSpell( cost: { type: string; element?: string; amount: number }, rawMana: number, elements: Record ): boolean { if (cost.type === 'raw') { return rawMana >= cost.amount; } else if (cost.element) { const elem = elements[cost.element]; return elem && elem.unlocked && elem.current >= cost.amount; } return false; } export const useGameStore = create()( persist( (set, get) => ({ ...initialState, initGame: () => { set({ initialized: true }); }, tick: () => { const uiState = useUIStore.getState(); if (uiState.gameOver || uiState.paused) return; // Helper for logging const addLog = (msg: string) => useUIStore.getState().addLog(msg); // Get all store states const prestigeState = usePrestigeStore.getState(); const manaState = useManaStore.getState(); const skillState = useSkillStore.getState(); const combatState = useCombatStore.getState(); // Compute effects from upgrades const effects = computeEffects(skillState.skillUpgrades || {}, skillState.skillTiers || {}); const maxMana = computeMaxMana( { skills: skillState.skills, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: skillState.skillUpgrades, skillTiers: skillState.skillTiers }, effects ); const baseRegen = computeRegen( { skills: skillState.skills, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: skillState.skillUpgrades, skillTiers: skillState.skillTiers }, effects ); // Time progression let hour = get().hour + HOURS_PER_TICK; let day = get().day; if (hour >= 24) { hour -= 24; day += 1; } // Check for loop end if (day > MAX_DAY) { const insightGained = calcInsight({ maxFloorReached: combatState.maxFloorReached, totalManaGathered: manaState.totalManaGathered, signedPacts: prestigeState.signedPacts, prestigeUpgrades: prestigeState.prestigeUpgrades, skills: skillState.skills, }); addLog(`⏰ The loop ends. Gained ${insightGained} Insight.`); useUIStore.getState().setGameOver(true, false); usePrestigeStore.getState().setLoopInsight(insightGained); set({ day, hour }); return; } // Check for victory if (combatState.maxFloorReached >= 100 && prestigeState.signedPacts.includes(100)) { const insightGained = calcInsight({ maxFloorReached: combatState.maxFloorReached, totalManaGathered: manaState.totalManaGathered, signedPacts: prestigeState.signedPacts, prestigeUpgrades: prestigeState.prestigeUpgrades, skills: skillState.skills, }) * 3; addLog(`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`); useUIStore.getState().setGameOver(true, true); usePrestigeStore.getState().setLoopInsight(insightGained); return; } // Incursion const incursionStrength = getIncursionStrength(day, hour); // Meditation bonus tracking and regen calculation let meditateTicks = manaState.meditateTicks; let meditationMultiplier = 1; if (combatState.currentAction === 'meditate') { meditateTicks++; meditationMultiplier = getMeditationBonus(meditateTicks, skillState.skills, effects.meditationEfficiency); } else { meditateTicks = 0; } // Calculate effective regen with incursion and meditation const effectiveRegen = baseRegen * (1 - incursionStrength) * meditationMultiplier; // Mana regeneration let rawMana = Math.min(manaState.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana); let totalManaGathered = manaState.totalManaGathered; let elements = { ...manaState.elements }; // Study progress - handled by skillStore if (combatState.currentAction === 'study' && skillState.currentStudyTarget) { const studySpeedMult = getStudySpeedMultiplier(skillState.skills); const progressGain = HOURS_PER_TICK * studySpeedMult; const result = useSkillStore.getState().updateStudyProgress(progressGain); if (result.completed && result.target) { if (result.target.type === 'skill') { const skillId = result.target.id; const currentLevel = skillState.skills[skillId] || 0; // Update skill level useSkillStore.getState().incrementSkillLevel(skillId); useSkillStore.getState().clearPaidStudySkill(skillId); useCombatStore.getState().setAction('meditate'); addLog(`✅ ${skillId} Lv.${currentLevel + 1} mastered!`); } else if (result.target.type === 'spell') { const spellId = result.target.id; useCombatStore.getState().learnSpell(spellId); useSkillStore.getState().setCurrentStudyTarget(null); useCombatStore.getState().setAction('meditate'); addLog(`📖 ${SPELLS_DEF[spellId]?.name || spellId} learned!`); } } } // Convert action - auto convert mana if (combatState.currentAction === 'convert') { const unlockedElements = Object.entries(elements) .filter(([, e]) => e.unlocked && e.current < e.max); if (unlockedElements.length > 0 && rawMana >= 100) { 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 / 100), targetState.max - targetState.current ); if (canConvert > 0) { rawMana -= canConvert * 100; elements = { ...elements, [targetId]: { ...targetState, current: targetState.current + canConvert } }; } } } // Pact ritual progress if (prestigeState.pactRitualFloor !== null) { const guardian = GUARDIANS[prestigeState.pactRitualFloor]; if (guardian) { const pactAffinityBonus = 1 - (prestigeState.prestigeUpgrades.pactAffinity || 0) * 0.1; const requiredTime = guardian.pactTime * pactAffinityBonus; const newProgress = prestigeState.pactRitualProgress + HOURS_PER_TICK; if (newProgress >= requiredTime) { addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`); usePrestigeStore.getState().addSignedPact(prestigeState.pactRitualFloor); usePrestigeStore.getState().removeDefeatedGuardian(prestigeState.pactRitualFloor); usePrestigeStore.getState().setPactRitualFloor(null); } else { usePrestigeStore.getState().updatePactRitualProgress(newProgress); } } } // Combat let { currentFloor, floorHP, floorMaxHP, maxFloorReached, castProgress } = combatState; const floorElement = getFloorElement(currentFloor); if (combatState.currentAction === 'climb') { const spellId = combatState.activeSpell; const spellDef = SPELLS_DEF[spellId]; if (spellDef) { const baseAttackSpeed = 1 + (skillState.skills.quickCast || 0) * 0.05; const totalAttackSpeed = baseAttackSpeed * effects.attackSpeedMultiplier; const spellCastSpeed = spellDef.castSpeed || 1; const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed; castProgress = (castProgress || 0) + progressPerTick; // Process complete casts while (castProgress >= 1 && canAffordSpell(spellDef.cost, rawMana, elements)) { const afterCost = deductSpellCost(spellDef.cost, rawMana, elements); rawMana = afterCost.rawMana; elements = afterCost.elements; totalManaGathered += spellDef.cost.amount; // Calculate damage let dmg = calcDamage( { skills: skillState.skills, signedPacts: prestigeState.signedPacts }, spellId, floorElement ); // Apply upgrade damage multipliers and bonuses dmg = dmg * effects.baseDamageMultiplier + effects.baseDamageBonus; // Executioner: +100% damage to enemies below 25% HP if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && floorHP / floorMaxHP < 0.25) { dmg *= 2; } // Berserker: +50% damage when below 50% mana if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) { dmg *= 1.5; } // Spell echo - chance to cast again const echoChance = (skillState.skills.spellEcho || 0) * 0.1; if (Math.random() < echoChance) { dmg *= 2; addLog(`✨ Spell Echo! Double damage!`); } // Apply damage floorHP = Math.max(0, floorHP - dmg); castProgress -= 1; if (floorHP <= 0) { // Floor cleared const wasGuardian = GUARDIANS[currentFloor]; if (wasGuardian && !prestigeState.defeatedGuardians.includes(currentFloor) && !prestigeState.signedPacts.includes(currentFloor)) { usePrestigeStore.getState().addDefeatedGuardian(currentFloor); addLog(`⚔️ ${wasGuardian.name} defeated! Visit the Grimoire to sign a pact.`); } else if (!wasGuardian) { if (currentFloor % 5 === 0) { addLog(`🏰 Floor ${currentFloor} cleared!`); } } currentFloor = currentFloor + 1; if (currentFloor > 100) { currentFloor = 100; } floorMaxHP = getFloorMaxHP(currentFloor); floorHP = floorMaxHP; maxFloorReached = Math.max(maxFloorReached, currentFloor); castProgress = 0; useCombatStore.getState().advanceFloor(); } } } } // Update all stores with new state useManaStore.setState({ rawMana, meditateTicks, totalManaGathered, elements, }); useCombatStore.setState({ floorHP, floorMaxHP, maxFloorReached, castProgress, }); set({ day, hour, incursionStrength, }); }, gatherMana: () => { const skillState = useSkillStore.getState(); const manaState = useManaStore.getState(); const prestigeState = usePrestigeStore.getState(); // Compute click mana let cm = 1 + (skillState.skills.manaTap || 0) * 1 + (skillState.skills.manaSurge || 0) * 3; // Mana overflow bonus const overflowBonus = 1 + (skillState.skills.manaOverflow || 0) * 0.25; cm = Math.floor(cm * overflowBonus); const effects = computeEffects(skillState.skillUpgrades || {}, skillState.skillTiers || {}); const max = computeMaxMana( { skills: skillState.skills, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: skillState.skillUpgrades, skillTiers: skillState.skillTiers }, effects ); useManaStore.setState({ rawMana: Math.min(manaState.rawMana + cm, max), totalManaGathered: manaState.totalManaGathered + cm, }); }, resetGame: () => { // Clear all persisted state localStorage.removeItem('mana-loop-ui-storage'); localStorage.removeItem('mana-loop-prestige-storage'); localStorage.removeItem('mana-loop-mana-storage'); localStorage.removeItem('mana-loop-skill-storage'); localStorage.removeItem('mana-loop-combat-storage'); localStorage.removeItem('mana-loop-game-storage'); const startFloor = 1; const elemMax = 10; const elements: Record = {}; Object.keys(ELEMENTS).forEach((k) => { elements[k] = { current: 0, max: elemMax, unlocked: BASE_UNLOCKED_ELEMENTS.includes(k), }; }); useUIStore.getState().resetUI(); usePrestigeStore.getState().resetPrestige(); useManaStore.getState().resetMana({}, {}, {}, {}); useSkillStore.getState().resetSkills(); useCombatStore.getState().resetCombat(startFloor); set({ ...initialState, initialized: true, }); }, togglePause: () => { useUIStore.getState().togglePause(); }, startNewLoop: () => { const prestigeState = usePrestigeStore.getState(); const combatState = useCombatStore.getState(); const manaState = useManaStore.getState(); const skillState = useSkillStore.getState(); const insightGained = prestigeState.loopInsight || calcInsight({ maxFloorReached: combatState.maxFloorReached, totalManaGathered: manaState.totalManaGathered, signedPacts: prestigeState.signedPacts, prestigeUpgrades: prestigeState.prestigeUpgrades, skills: skillState.skills, }); const total = prestigeState.insight + insightGained; // Spell preservation is only through prestige upgrade "spellMemory" (purchased with insight) // Not through a skill - that would undermine the insight economy const pu = prestigeState.prestigeUpgrades; const startFloor = 1 + (pu.spireKey || 0) * 2; // Apply saved memories - restore skill levels, tiers, and upgrades const memories = prestigeState.memories || []; const newSkills: Record = {}; const newSkillTiers: Record = {}; const newSkillUpgrades: Record = {}; if (memories.length > 0) { for (const memory of memories) { const tieredSkillId = memory.tier > 1 ? `${memory.skillId}_t${memory.tier}` : memory.skillId; newSkills[tieredSkillId] = memory.level; if (memory.tier > 1) { newSkillTiers[memory.skillId] = memory.tier; } newSkillUpgrades[tieredSkillId] = memory.upgrades || []; } } // Reset and update all stores for new loop useUIStore.setState({ gameOver: false, victory: false, paused: false, logs: ['✨ The loop begins. You start with Mana Bolt. Gather your strength, mage.'], }); usePrestigeStore.getState().resetPrestigeForNewLoop( total, pu, prestigeState.memories, 3 + (pu.deepMemory || 0) ); usePrestigeStore.getState().incrementLoopCount(); useManaStore.getState().resetMana(pu, newSkills, newSkillUpgrades, newSkillTiers); useSkillStore.getState().resetSkills(newSkills, newSkillUpgrades, newSkillTiers); // Reset combat with starting floor and any spells from prestige upgrades const startSpells = makeInitialSpells(); if (pu.spellMemory) { const availableSpells = Object.keys(SPELLS_DEF).filter(s => s !== 'manaBolt'); 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 }; } } useCombatStore.setState({ currentFloor: startFloor, floorHP: getFloorMaxHP(startFloor), floorMaxHP: getFloorMaxHP(startFloor), maxFloorReached: startFloor, activeSpell: 'manaBolt', currentAction: 'meditate', castProgress: 0, spells: startSpells, }); set({ day: 1, hour: 0, incursionStrength: 0, containmentWards: 0, }); }, }), { name: 'mana-loop-game-storage', partialize: (state) => ({ day: state.day, hour: state.hour, incursionStrength: state.incursionStrength, containmentWards: state.containmentWards, }), } ) ); // Re-export the game loop hook for convenience export function useGameLoop() { const tick = useGameStore((s) => s.tick); return { start: () => { const interval = setInterval(tick, TICK_MS); return () => clearInterval(interval); }, }; }