// Game Store — coordinator, tick pipeline, time/incursion import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { HOURS_PER_TICK, MAX_DAY, EQUIPMENT_TYPES } 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 { getElementDistance } from '../utils/element-distance'; import { computeConversionRates } from '../utils/conversion-rates'; 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 } 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 { processEnchantingTicks } from './pipelines/enchanting-tick'; import { buildGolemCombatPipeline } from './pipelines/golem-combat'; import { getGuardianForFloor } from '../data/guardian-encounters'; import type { TickContext, TickWrites } from './tick-pipeline'; import type { GameCoordinatorState } from './gameStore.types'; import type { EnemyState } from '../types'; import { applyEnemyDefenses as applyEnemyDefensesFromPipeline } from './pipelines/combat-tick'; 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; } // ── Unified Conversion System ───────────────────────────────────── const { pactElementMap, grossRegen } = buildConversionParams(ctx.prestige.signedPacts, ctx.attunement.attunements); const invokerLevel = ctx.attunement.attunements.invoker?.active ? (ctx.attunement.attunements.invoker.level || 1) : 0; const conversionResult = computeConversionRates({ disciplineEffects, attunements: ctx.attunement.attunements, signedPacts: ctx.prestige.signedPacts, pactElementMap, invokerLevel, meditationMultiplier, grossRegen, rawGrossRegen: baseRegen, }); // Apply conversion results: produce element mana from conversions let rawMana = ctx.mana.rawMana; let elements = { ...ctx.mana.elements }; // Log paused conversions for (const [elem, entry] of Object.entries(conversionResult.rates)) { if (entry.paused && entry.pauseReason) { addLog(`⚠️ PAUSED: ${elem} conversion — ${entry.pauseReason}`); } } // Apply produced element mana (from active conversions) for (const [elem, entry] of Object.entries(conversionResult.rates)) { if (entry.paused || entry.finalRate <= 0 || !elements[elem]) continue; if (!elements[elem].unlocked) elements[elem] = { ...elements[elem], unlocked: true }; elements[elem] = { ...elements[elem], current: Math.min(elements[elem].max, elements[elem].current + entry.finalRate * HOURS_PER_TICK) }; } // Compute per-element net regen: produced rate - drain from being used as component const elementRegen: Record = {}; for (const [elem, entry] of Object.entries(conversionResult.rates)) { const produced = entry.finalRate; const drained = conversionResult.elementDrain[elem] || 0; elementRegen[elem] = produced - drained; } // Net raw regen = gross regen - conversion drains - incursion const netRawRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - conversionResult.totalRawDrain); const actualRegen = Math.floor(Math.min(netRawRegen * HOURS_PER_TICK, maxMana - rawMana) * 1000) / 1000; rawMana = Math.max(0, Math.min(rawMana + netRawRegen * HOURS_PER_TICK, maxMana)); let totalManaGathered = ctx.mana.totalManaGathered + Math.max(0, actualRegen); 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 dr = useDisciplineStore.getState().processTick({ rawMana, elements }); rawMana = dr.rawMana; elements = dr.elements; if (dr.autoPausedNames.length > 0) addLog('⏸️ Auto-paused (insufficient mana): ' + dr.autoPausedNames.join(', ')); rawMana = Math.min(rawMana, computeMaxMana({ prestigeUpgrades: ctx.prestige.prestigeUpgrades }, undefined, computeDisciplineEffects())); if (dr.unlockedEffects.length > 0) { useCraftingStore.getState().unlockEffects(dr.unlockedEffects); for (const effectId of dr.unlockedEffects) { addLog('Discipline insight unlocked: ' + effectId); } } if (dr.unlockedRecipes.length > 0) { useCraftingStore.getState().unlockRecipes(dr.unlockedRecipes); for (const recipeId of dr.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 if (ctx.combat.currentAction === 'climb') { const combatCbs = buildCombatCallbacks({ ctx, effects, maxMana, addLog, useCombatStore, usePrestigeStore }); const roomEnemies = ctx.combat.currentRoom?.enemies ?? []; const primaryEnemy = roomEnemies[0] ?? null; const activeEnemy = combatCbs.applyMageBarrierRecharge(primaryEnemy) ?? primaryEnemy; const defCtx = { roomType: ctx.combat.currentRoom?.roomType ?? 'combat', enemy: activeEnemy }; const golemPipeline = buildGolemCombatPipeline(addLog); const equippedSwords: Record = {}; for (const [slot, iid] of Object.entries(ctx.crafting.equippedInstances || {})) { if (!iid) continue; const inst = ctx.crafting.equipmentInstances?.[iid]; if (inst && EQUIPMENT_TYPES[inst.typeId]?.category === 'sword') equippedSwords[iid] = inst; } const cr = useCombatStore.getState().processCombatTick( rawMana, elements, maxMana, 1, combatCbs.onFloorCleared, combatCbs.makeOnDamageDealt(() => rawMana, () => elements, defCtx, addLog), ctx.prestige.signedPacts, { activeGolems: golemPipeline.activeGolems }, golemPipeline.golemApplyDamageToRoom, (dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier) => applyEnemyDefensesFromPipeline(dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier), equippedSwords, ); rawMana = cr.rawMana; elements = cr.elements; totalManaGathered += cr.totalManaGathered || 0; if (cr.logMessages) cr.logMessages.forEach(msg => addLog(msg)); writes.combat = { ...(writes.combat || {}), currentFloor: cr.currentFloor, floorHP: cr.floorHP, floorMaxHP: cr.floorMaxHP, maxFloorReached: cr.maxFloorReached, castProgress: cr.castProgress, equipmentSpellStates: cr.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: cr.activeGolems }, meleeSwordProgress: cr.meleeSwordProgress, currentRoom: cr.currentRoom }; } // Non-combat room tick (library, recovery, treasure, puzzle) if (ctx.combat.currentAction === 'climb') { const roomType = ctx.combat.currentRoom?.roomType; if (roomType === 'library' || roomType === 'recovery' || roomType === 'treasure' || roomType === 'puzzle') { if (roomType === 'recovery') { // Recovery room grants 10× regen/conversion multiplier. // Normal regen was already applied above, so we apply only // the delta (9× additional) to avoid double-counting. const boostedRegen = baseRegen * 10; const netBoostedRegen = Math.max(0, boostedRegen * (1 - incursionStrength) * meditationMultiplier - conversionResult.totalRawDrain); const regenDelta = netBoostedRegen - netRawRegen; rawMana = Math.min(rawMana + regenDelta * HOURS_PER_TICK, maxMana); for (const [elem, entry] of Object.entries(conversionResult.rates)) { if (entry.paused || entry.finalRate <= 0 || !elements[elem]) continue; if (!elements[elem].unlocked) elements[elem] = { ...elements[elem], unlocked: true }; // Normal conversion already applied above; add only the 9× delta elements[elem] = { ...elements[elem], current: Math.min(elements[elem].max, elements[elem].current + entry.finalRate * 9 * HOURS_PER_TICK) }; } } useCombatStore.getState().tickNonCombatRoom(HOURS_PER_TICK); const updatedRoom = useCombatStore.getState().currentRoom; writes.combat = { ...(writes.combat || {}), currentRoom: updatedRoom }; } } if (ctx.combat.currentAction === 'craft') { const craftingResult = useCraftingStore.getState().processEquipmentCraftingTick(); if (craftingResult.logMessage) { addLog(craftingResult.logMessage); } } // Enchanting: Design / Prepare / Application ticks const enchantingResult = processEnchantingTicks({ ctx, effects, rawMana, addLog, }); rawMana = enchantingResult.rawMana; if (enchantingResult.writes) { if (enchantingResult.writes.combat) { writes.combat = { ...(writes.combat || {}), ...enchantingResult.writes.combat }; } if (enchantingResult.writes.attunement) { writes.attunement = { ...(writes.attunement || {}), ...enchantingResult.writes.attunement }; } } // Phase 3: Write writes.game = { day, hour, incursionStrength }; writes.mana = { rawMana, meditateTicks, totalManaGathered, elements, elementRegen }; 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, }), } ) ); /** Build pact element map and gross regen for the unified conversion system */ function buildConversionParams( signedPacts: number[], attunements: Record, ): { pactElementMap: Record; grossRegen: Record } { const pactElementMap: Record = {}; for (const floor of signedPacts) { const guardian = getGuardianForFloor(floor); if (guardian?.element?.length) { pactElementMap[floor] = guardian.element[0]; } } const grossRegen: Record = {}; for (const [id, state] of Object.entries(attunements)) { if (!state.active) continue; const def = ATTUNEMENTS_DEF[id]; if (def?.primaryManaType) { grossRegen[def.primaryManaType] = (grossRegen[def.primaryManaType] || 0) + (def.conversionRate || 0); } } return { pactElementMap, grossRegen }; }