// Game Store — coordinator, tick pipeline, time/incursion import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { HOURS_PER_TICK, MAX_DAY } from '../constants'; 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 { mergePerElementCapBonuses } from '../utils/element-cap-bonus'; import { processPactRitual } from './pipelines/pact-ritual'; import { buildCombatCallbacks } from './pipelines/combat-tick'; 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: () => { useDisciplineStore.getState().setPracticingCallbacks({ onStartPracticing: () => useCombatStore.getState().startPracticing(), onStopPracticing: () => useCombatStore.getState().stopPracticing(), }); set({ initialized: true }); }, tick: () => { try { 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; // 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)), }; const writes: TickWrites = { logs: [] }; const addLog = (msg: string) => writes.logs.push(msg); const steadyHandLevel = ctx.prestige.prestigeUpgrades.steadyHand || 0; const enchantmentPowerMultiplier = 1 + steadyHandLevel * 0.15; const equipmentEffects = computeEquipmentEffects( ctx.crafting.equipmentInstances || {}, ctx.crafting.equippedInstances || {}, enchantmentPowerMultiplier, ); const disciplineEffects = computeDisciplineEffects(); const allSpecials = new Set([ ...equipmentEffects.specials, ...disciplineEffects.specials, ]); const effects = { specials: allSpecials } as ComputedEffects; const maxMana = computeMaxMana( { prestigeUpgrades: ctx.prestige.prestigeUpgrades }, undefined, disciplineEffects, ); const baseRegen = computeRegen( { prestigeUpgrades: ctx.prestige.prestigeUpgrades, attunements: {} }, undefined, disciplineEffects, ) * (1 + (disciplineEffects.multipliers.regenMultiplier || 0)); let hour = ctx.game.hour + HOURS_PER_TICK; let day = ctx.game.day; if (hour >= 24) { hour -= 24; day += 1; } const insightParams = { maxFloorReached: ctx.combat.maxFloorReached, totalManaGathered: ctx.mana.totalManaGathered, signedPacts: ctx.prestige.signedPacts, prestigeUpgrades: ctx.prestige.prestigeUpgrades, }; 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; } 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; } const incursionStrength = getIncursionStrength(day, hour); let meditateTicks = ctx.mana.meditateTicks; let meditationMultiplier = 1; if (ctx.combat.currentAction === 'meditate') { meditateTicks++; meditationMultiplier = getMeditationBonus(meditateTicks, 1, disciplineEffects.meditationCapBonus); } else { meditateTicks = 0; } let totalConversionPerTick = 0; let rawManaDelta = 0; let elements = { ...ctx.mana.elements }; 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; totalConversionPerTick += conversionThisTick; // Deduct raw mana to pay for the conversion — without this, attunements produce free element mana rawManaDelta -= conversionThisTick; if (elements[def.primaryManaType]) { if (!elements[def.primaryManaType].unlocked) { elements[def.primaryManaType] = { ...elements[def.primaryManaType], unlocked: true }; } elements[def.primaryManaType].current = Math.min( elements[def.primaryManaType].max, elements[def.primaryManaType].current + conversionThisTick, ); } }); const effectiveRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - totalConversionPerTick); const rawAfterConversion = ctx.mana.rawMana + rawManaDelta; const regenFromMeditation = Math.max(0, effectiveRegen * HOURS_PER_TICK); const roomLeft = Math.max(0, maxMana - Math.max(0, rawAfterConversion)); // Only count regen that actually fits below the cap (fix #224) const actualRegenAdded = Math.floor(Math.min(regenFromMeditation, roomLeft) * 1000) / 1000; let rawMana = Math.max(0, Math.min(rawAfterConversion + effectiveRegen * HOURS_PER_TICK, maxMana)); let totalManaGathered = ctx.mana.totalManaGathered + Math.max(0, actualRegenAdded); if (ctx.combat.currentAction === 'convert') { const convertResult = useManaStore.getState().processConvertAction(rawMana); if (convertResult) { rawMana = convertResult.rawMana; elements = convertResult.elements; } } const pactResult = processPactRitual( ctx.prestige.pactRitualFloor, ctx.prestige.pactRitualProgress, ctx.prestige.signedPacts, ctx.prestige.defeatedGuardians, ctx.prestige.prestigeUpgrades.pactAffinity || 0, disciplineEffects.bonuses.pactAffinityBonus || 0, ); if (pactResult.writes) { writes.prestige = { ...(writes.prestige || {}), ...pactResult.writes }; } pactResult.logs.forEach(l => addLog(l)); const disciplineResult = useDisciplineStore.getState().processTick({ rawMana, elements, }); rawMana = disciplineResult.rawMana; elements = disciplineResult.elements; for (const [targetElem, conv] of Object.entries(disciplineEffects.conversions)) { const conversionAmount = conv.rate * HOURS_PER_TICK; 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; // Re-check against actual remaining mana to prevent negative values // when multiple disciplines share the same source for (const srcType of conv.sourceManaTypes) { if (srcType === 'raw' && rawMana < conversionAmount) { canConvert = false; break; } if (srcType !== 'raw' && elements[srcType] && elements[srcType].current < conversionAmount) { canConvert = false; break; } } if (!canConvert) continue; for (const srcType of conv.sourceManaTypes) { if (srcType === 'raw') { rawMana -= conversionAmount; } else if (elements[srcType]) { elements[srcType] = { ...elements[srcType], current: Math.max(0, elements[srcType].current - conversionAmount) }; } } if (elements[targetElem]) { elements[targetElem] = { ...elements[targetElem], current: Math.min(elements[targetElem].max, elements[targetElem].current + conversionAmount), }; } } if (disciplineResult.unlockedEffects.length > 0) { useCraftingStore.getState().unlockEffects(disciplineResult.unlockedEffects); for (const effectId of disciplineResult.unlockedEffects) { addLog('Discipline insight unlocked: ' + effectId); } } if (disciplineResult.unlockedRecipes.length > 0) { useCraftingStore.getState().unlockRecipes(disciplineResult.unlockedRecipes); for (const recipeId of disciplineResult.unlockedRecipes) { addLog('Fabricator recipe unlocked: ' + recipeId); } } const perElementCapBonuses = mergePerElementCapBonuses( disciplineEffects.bonuses, equipmentEffects.bonuses, ); useManaStore.getState().computeElementMaxWithBonuses(perElementCapBonuses); const manaStateAfter = useManaStore.getState(); for (const [ek, es] of Object.entries(manaStateAfter.elements)) { if (elements[ek]) { elements[ek] = { ...elements[ek], max: es.max, baseMax: es.baseMax }; } } // Combat — delegate to combatStore if (ctx.combat.currentAction === 'climb') { const combatCbs = buildCombatCallbacks({ ctx, effects, maxMana, addLog, useCombatStore, usePrestigeStore, }); const combatResult = useCombatStore.getState().processCombatTick( rawMana, elements, maxMana, 1, combatCbs.onFloorCleared, combatCbs.makeOnDamageDealt(() => rawMana, () => elements), 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, }; } if (ctx.combat.currentAction === 'craft') { const craftingResult = useCraftingStore.getState().processEquipmentCraftingTick(); if (craftingResult.logMessage) { addLog(craftingResult.logMessage); } } // Phase 3: Write writes.game = { day, hour, incursionStrength }; writes.mana = { rawMana, meditateTicks, totalManaGathered, elements, }; applyTickWrites(writes, storeSetters); } catch (error: unknown) { 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', version: 1, partialize: (state) => ({ day: state.day, hour: state.hour, incursionStrength: state.incursionStrength, containmentWards: state.containmentWards, }), } ) );