// ─── Game Store (Coordinator) ───────────────────────────────────────────────── // Manages: day, hour, incursionStrength, containmentWards // Orchestrates tick across all stores via a read → compute → write pipeline. import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { HOURS_PER_TICK, MAX_DAY } from '../constants'; import { getGuardianForFloor } from '../data/guardian-encounters'; import { hasSpecial, SPECIAL_EFFECTS } from '../effects/special-effects'; import { computeEquipmentEffects } from '../effects'; import type { ComputedEffects } from '../effects/upgrade-effects.types'; import { computeDisciplineEffects } from '../effects/discipline-effects'; import { computeMaxMana, computeRegen, getMeditationBonus, getIncursionStrength, calcInsight } from '../utils'; import { useUIStore } from './uiStore'; import { usePrestigeStore } from './prestigeStore'; import { useManaStore } from './manaStore'; import { useCombatStore } from './combatStore'; import { useAttunementStore } from './attunementStore'; import { useCraftingStore } from './craftingStore'; import { useDisciplineStore } from './discipline-slice'; import { ATTUNEMENTS_DEF, getAttunementConversionRate } from '../data/attunements'; import { createResetGame, createGatherMana } from './gameActions'; import { createSafeStorage } from '../utils/safe-persist'; import { createStartNewLoop } from './gameLoopActions'; import { buildTickContext, applyTickWrites } from './tick-pipeline'; import type { TickContext, TickWrites } from './tick-pipeline'; import type { GameCoordinatorState } from './gameStore.types'; export interface GameCoordinatorStore extends GameCoordinatorState { tick: () => void; resetGame: () => void; togglePause: () => void; startNewLoop: () => void; initGame: () => void; gatherMana: () => void; } const initialState: GameCoordinatorState = { day: 1, hour: 0, incursionStrength: 0, containmentWards: 0, initialized: false, }; export const useGameStore = create()( persist( (set, get) => ({ ...initialState, initGame: () => { // Wire discipline store ↔ combat store callbacks (breaks circular dependency) useDisciplineStore.getState().setPracticingCallbacks({ onStartPracticing: () => useCombatStore.getState().startPracticing(), onStopPracticing: () => useCombatStore.getState().stopPracticing(), }); set({ initialized: true }); }, tick: () => { try { // ── Phase 1: Read — snapshot all store states once ────────────────── const ctx = buildTickContext({ game: get(), ui: useUIStore.getState(), prestige: usePrestigeStore.getState(), mana: useManaStore.getState(), combat: useCombatStore.getState(), crafting: useCraftingStore.getState(), attunement: useAttunementStore.getState(), discipline: useDisciplineStore.getState(), }); if (ctx.ui.gameOver || ctx.ui.paused) return; // Shared setters object — used by every applyTickWrites call below // eslint-disable-next-line @typescript-eslint/no-explicit-any const storeSetters = { setGame: set, setUI: (w: any) => useUIStore.setState(w), setPrestige: (w: any) => usePrestigeStore.setState(w), setMana: (w: any) => useManaStore.setState(w), setCombat: (w: any) => useCombatStore.setState(w), setCrafting: (w: any) => useCraftingStore.setState(w), setAttunement: (w: any) => useAttunementStore.setState(w), setDiscipline: (w: any) => useDisciplineStore.setState(w), addLogs: (msgs: string[]) => msgs.forEach((m) => useUIStore.getState().addLog(m)), }; // ── Phase 2: Compute — derive all updates ─────────────────────────── const writes: TickWrites = { logs: [] }; const addLog = (msg: string) => writes.logs.push(msg); // Compute equipment and discipline effects const equipmentEffects = computeEquipmentEffects( ctx.crafting.equipmentInstances || {}, ctx.crafting.equippedInstances || {} ); const disciplineEffects = computeDisciplineEffects(); const allSpecials = new Set([ ...equipmentEffects.specials, ...disciplineEffects.specials, ]); const effects = { specials: allSpecials } as ComputedEffects; const maxMana = computeMaxMana( { skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {} }, undefined, disciplineEffects, ); const baseRegen = computeRegen( { skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, attunements: {} }, undefined, disciplineEffects, ) * (1 + (disciplineEffects.multipliers.regenMultiplier || 0)); // Time progression let hour = ctx.game.hour + HOURS_PER_TICK; let day = ctx.game.day; if (hour >= 24) { hour -= 24; day += 1; } // Shared insight params — reused for both loop-end and victory const insightParams = { maxFloorReached: ctx.combat.maxFloorReached, totalManaGathered: ctx.mana.totalManaGathered, signedPacts: ctx.prestige.signedPacts, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skills: {} as Record, }; // Check for loop end if (day > MAX_DAY) { const insightGained = calcInsight(insightParams, disciplineEffects); addLog(`⏰ The loop ends. Gained ${insightGained} Insight.`); writes.ui = { ...(writes.ui || {}), gameOver: true, victory: false }; writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained }; writes.game = { day, hour }; applyTickWrites(writes, storeSetters); return; } // Check for victory (3× insight multiplier) if (ctx.combat.maxFloorReached >= 100 && ctx.prestige.signedPacts.includes(100)) { const insightGained = calcInsight(insightParams, disciplineEffects) * 3; addLog(`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`); writes.ui = { ...(writes.ui || {}), gameOver: true, victory: true }; writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained }; applyTickWrites(writes, storeSetters); return; } // Incursion const incursionStrength = getIncursionStrength(day, hour); // Meditation bonus tracking let meditateTicks = ctx.mana.meditateTicks; let meditationMultiplier = 1; if (ctx.combat.currentAction === 'meditate') { meditateTicks++; meditationMultiplier = getMeditationBonus(meditateTicks, {}, 1, disciplineEffects.meditationCapBonus); } else { meditateTicks = 0; } // Calculate total attunement conversion per tick let totalConversionPerTick = 0; Object.entries(ctx.attunement.attunements).forEach(([id, state]) => { if (!state.active) return; const def = ATTUNEMENTS_DEF[id]; if (!def || def.conversionRate <= 0 || !def.primaryManaType) return; const scaledRate = getAttunementConversionRate(id, state.level || 1); totalConversionPerTick += scaledRate * HOURS_PER_TICK; }); // Calculate effective regen const effectiveRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - totalConversionPerTick); // Mana regeneration let rawMana = Math.min(ctx.mana.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana); let elements = { ...ctx.mana.elements }; // Apply attunement conversion Object.entries(ctx.attunement.attunements).forEach(([id, state]) => { if (!state.active) return; const def = ATTUNEMENTS_DEF[id]; if (!def || def.conversionRate <= 0 || !def.primaryManaType) return; const scaledRate = getAttunementConversionRate(id, state.level || 1); const conversionThisTick = scaledRate * HOURS_PER_TICK; if (elements[def.primaryManaType]) { elements[def.primaryManaType].current = Math.min( elements[def.primaryManaType].max, elements[def.primaryManaType].current + conversionThisTick ); } }); let totalManaGathered = ctx.mana.totalManaGathered; // Convert action — delegate to manaStore if (ctx.combat.currentAction === 'convert') { const convertResult = useManaStore.getState().processConvertAction(rawMana); if (convertResult) { rawMana = convertResult.rawMana; elements = convertResult.elements; } } // Pact ritual progress if (ctx.prestige.pactRitualFloor !== null) { const guardian = getGuardianForFloor(ctx.prestige.pactRitualFloor); if (guardian) { const pactAffinity = Math.min(0.9, (ctx.prestige.prestigeUpgrades.pactAffinity || 0) * 0.1 + (disciplineEffects.bonuses.pactAffinityBonus || 0)); const requiredTime = guardian.pactTime * (1 - pactAffinity); if (ctx.prestige.pactRitualProgress + HOURS_PER_TICK >= requiredTime) { addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`); // Unlock mana types granted by this guardian const manaStore = useManaStore.getState(); for (const manaType of guardian.unlocksMana || []) { const result = manaStore.unlockElement(manaType, 0); if (result.success) { addLog(`✨ ${manaType.charAt(0).toUpperCase() + manaType.slice(1)} mana unlocked!`); } } writes.prestige = { ...(writes.prestige || {}), signedPacts: [...ctx.prestige.signedPacts, ctx.prestige.pactRitualFloor], defeatedGuardians: ctx.prestige.defeatedGuardians.filter(f => f !== ctx.prestige.pactRitualFloor), pactRitualFloor: null, pactRitualProgress: 0, }; } else { writes.prestige = { ...(writes.prestige || {}), pactRitualProgress: ctx.prestige.pactRitualProgress + HOURS_PER_TICK, }; } } } // Discipline tick — process active disciplines (XP accrual + mana drain) const disciplineResult = useDisciplineStore.getState().processTick({ rawMana, elements, }); rawMana = disciplineResult.rawMana; elements = disciplineResult.elements; // Apply discipline conversions: drain source mana, add to target element for (const [targetElem, conv] of Object.entries(disciplineEffects.conversions)) { const conversionAmount = conv.rate * HOURS_PER_TICK; // Check that all source mana types are available (unlocked and have enough) let canConvert = true; for (const srcType of conv.sourceManaTypes) { if (srcType === 'raw') { if (rawMana < conversionAmount) { canConvert = false; break; } } else if (!elements[srcType] || !elements[srcType].unlocked || elements[srcType].current < conversionAmount) { canConvert = false; break; } } if (!canConvert) continue; // Drain source mana types for (const srcType of conv.sourceManaTypes) { if (srcType === 'raw') { rawMana -= conversionAmount; } else if (elements[srcType]) { elements[srcType] = { ...elements[srcType], current: elements[srcType].current - conversionAmount, }; } } // Add to target element if (elements[targetElem]) { elements[targetElem] = { ...elements[targetElem], current: Math.min( elements[targetElem].max, elements[targetElem].current + conversionAmount, ), }; } } // Unlock enchantment effects from newly unlocked discipline perks if (disciplineResult.unlockedEffects.length > 0) { useCraftingStore.getState().unlockEffects(disciplineResult.unlockedEffects); for (const effectId of disciplineResult.unlockedEffects) { addLog(`✨ Discipline insight unlocked: ${effectId}`); } } // Combat — delegate to combatStore if (ctx.combat.currentAction === 'climb') { const combatResult = useCombatStore.getState().processCombatTick( rawMana, elements, maxMana, 1, (floor, wasGuardian) => { if (wasGuardian) { const defeatedGuardian = getGuardianForFloor(floor); addLog(`\u2694\ufe0f ${defeatedGuardian?.name || 'Guardian'} defeated! Visit the Grimoire to sign a pact.`); } else if (floor % 5 === 0) { addLog(`🏰 Floor ${floor} cleared!`); } }, (damage) => { let dmg = damage; if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && ctx.combat.floorHP / ctx.combat.floorMaxHP < 0.25) { dmg *= 2; } if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) { dmg *= 1.5; } return { rawMana, elements, modifiedDamage: dmg }; }, ctx.prestige.signedPacts, ); rawMana = combatResult.rawMana; elements = combatResult.elements; totalManaGathered += combatResult.totalManaGathered || 0; if (combatResult.logMessages) { combatResult.logMessages.forEach(msg => addLog(msg)); } writes.combat = { ...(writes.combat || {}), currentFloor: combatResult.currentFloor, floorHP: combatResult.floorHP, floorMaxHP: combatResult.floorMaxHP, maxFloorReached: combatResult.maxFloorReached, castProgress: combatResult.castProgress, equipmentSpellStates: combatResult.equipmentSpellStates, }; } // ── Phase 3: Write — batch all state updates ───────────────────────── writes.game = { day, hour, incursionStrength }; writes.mana = { rawMana, meditateTicks, totalManaGathered, elements, }; applyTickWrites(writes, storeSetters); } catch (error: unknown) { // Log error to UI store if available, otherwise console error try { const msg = error instanceof Error ? error.message : String(error); useUIStore.getState().addLog(`⚠️ Tick error: ${msg}`); } catch { console.error('Tick error:', error); } } }, resetGame: createResetGame(set, initialState), togglePause: () => { useUIStore.getState().togglePause(); }, startNewLoop: createStartNewLoop(set), gatherMana: createGatherMana(), }), { storage: createSafeStorage(), name: 'mana-loop-game-storage', partialize: (state) => ({ day: state.day, hour: state.hour, incursionStrength: state.incursionStrength, containmentWards: state.containmentWards, }), } ) );