From ee893e8973dcb49b63f89653aab0d906149680c1 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Wed, 20 May 2026 19:48:40 +0200 Subject: [PATCH] =?UTF-8?q?refactor:=20tick=20pipeline=20pattern=20?= =?UTF-8?q?=E2=80=94=20read=20all=20=E2=86=92=20compute=20all=20=E2=86=92?= =?UTF-8?q?=20write=20all=20(issue=20#103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New tick-pipeline.ts: TickContext/TickWrites types + buildTickContext/applyTickWrites orchestrator - gameStore.ts tick(): refactored to 3-phase pipeline (read snapshot → compute updates → batch writes) - combat-actions.ts: accept signedPacts as parameter instead of usePrestigeStore.getState() in combat loop - combatStore.ts/combat-state.types.ts: updated processCombatTick signature for signedPacts passthrough - craftingStore.ts: removed tempState = { ...get(), rawMana } as any anti-pattern - preparation-actions.ts: accept rawMana as explicit parameter instead of GameState bag --- docs/circular-deps.txt | 4 +- docs/dependency-graph.json | 2 +- docs/project-structure.txt | 1 + .../crafting-actions/preparation-actions.ts | 21 +- src/lib/game/stores/combat-actions.ts | 68 +++++- src/lib/game/stores/combat-state.types.ts | 14 +- src/lib/game/stores/combatStore.ts | 2 + src/lib/game/stores/craftingStore.ts | 12 +- src/lib/game/stores/gameStore.ts | 200 +++++++++++------- src/lib/game/stores/tick-pipeline.ts | 102 +++++++++ 10 files changed, 317 insertions(+), 109 deletions(-) create mode 100644 src/lib/game/stores/tick-pipeline.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 6265bf2..6ce663a 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,8 +1,8 @@ # Circular Dependencies -Generated: 2026-05-20T15:46:48.123Z +Generated: 2026-05-20T16:38:29.616Z Found: 3 circular chain(s) — these MUST be fixed before modifying involved files. -1. Processed 125 files (1.5s) (3 warnings) +1. Processed 125 files (1.4s) (3 warnings) 2. 1) stores/gameStore.ts > stores/gameActions.ts 3. 2) stores/gameStore.ts > stores/gameLoopActions.ts diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 8712e9a..49ae9a1 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-05-20T15:46:46.373Z", + "generated": "2026-05-20T16:38:28.025Z", "description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.", "usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry." }, diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 0113bad..11b63c5 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -312,6 +312,7 @@ Mana-Loop/ │ │ │ ├── index.ts │ │ │ ├── manaStore.ts │ │ │ ├── prestigeStore.ts +│ │ │ ├── tick-pipeline.ts │ │ │ └── uiStore.ts │ │ ├── types/ │ │ │ ├── attunements.ts diff --git a/src/lib/game/crafting-actions/preparation-actions.ts b/src/lib/game/crafting-actions/preparation-actions.ts index c7ac2c3..f90ede6 100644 --- a/src/lib/game/crafting-actions/preparation-actions.ts +++ b/src/lib/game/crafting-actions/preparation-actions.ts @@ -1,12 +1,13 @@ // ─── Enchantment Preparation Actions ──────────────────────────────────────── -import type { GameState } from '../types'; +import type { CraftingState } from '../stores/craftingStore.types'; import * as CraftingPrep from '../crafting-prep'; export function startPreparing( equipmentInstanceId: string, - get: () => GameState, - set: (fn: (state: GameState) => Partial) => void + rawMana: number, + get: () => CraftingState, + set: (partial: Partial) => void ): boolean { const state = get(); const instance = state.equipmentInstances[equipmentInstanceId]; @@ -21,24 +22,22 @@ export function startPreparing( const costs = CraftingPrep.calculatePreparationCosts(instance.totalCapacity); - if (state.rawMana < costs.manaTotal) return false; + if (rawMana < costs.manaTotal) return false; - set(() => ({ - currentAction: 'prepare' as const, + set({ preparationProgress: CraftingPrep.initializePreparationProgress( equipmentInstanceId, instance.totalCapacity ), - })); + }); return true; } export function cancelPreparation( - set: (fn: (state: GameState) => Partial) => void + set: (partial: Partial) => void ) { - set(() => ({ - currentAction: 'meditate' as const, + set({ preparationProgress: null, - })); + }); } diff --git a/src/lib/game/stores/combat-actions.ts b/src/lib/game/stores/combat-actions.ts index fb075f7..045acbb 100644 --- a/src/lib/game/stores/combat-actions.ts +++ b/src/lib/game/stores/combat-actions.ts @@ -1,13 +1,25 @@ // ─── Combat Actions ───────────────────────────────────────────────────────────── -// Extracted combat logic from combatStore.ts +// Pure combat logic — no cross-store getState() calls. +// All external data (signedPacts, etc.) is passed in as parameters. import { SPELLS_DEF, GUARDIANS, HOURS_PER_TICK } from '../constants'; import type { CombatState } from './combat-state.types'; import type { SpellState } from '../types'; import { getFloorMaxHP, getFloorElement, calcDamage, canAffordSpellCost, deductSpellCost } from '../utils'; -import { usePrestigeStore } from './prestigeStore'; import { computeDisciplineEffects } from '../effects/discipline-effects'; -import { useDisciplineStore } from './discipline-slice'; + +export interface CombatTickResult { + rawMana: number; + elements: Record; + logMessages: string[]; + totalManaGathered: number; + currentFloor: number; + floorHP: number; + floorMaxHP: number; + maxFloorReached: number; + castProgress: number; + equipmentSpellStates: CombatState['equipmentSpellStates']; +} export function processCombatTick( get: () => CombatState, @@ -22,19 +34,42 @@ export function processCombatTick( elements: Record; modifiedDamage?: number; }, -) { + signedPacts: number[], +): CombatTickResult { const state = get(); const logMessages: string[] = []; let totalManaGathered = 0; if (state.currentAction !== 'climb') { - return { rawMana, elements, logMessages, totalManaGathered }; + return { + rawMana, + elements, + logMessages, + totalManaGathered, + currentFloor: state.currentFloor, + floorHP: state.floorHP, + floorMaxHP: state.floorMaxHP, + maxFloorReached: state.maxFloorReached, + castProgress: state.castProgress, + equipmentSpellStates: state.equipmentSpellStates, + }; } const spellId = state.activeSpell; const spellDef = SPELLS_DEF[spellId]; if (!spellDef) { - return { rawMana, elements, logMessages, totalManaGathered }; + return { + rawMana, + elements, + logMessages, + totalManaGathered, + currentFloor: state.currentFloor, + floorHP: state.floorHP, + floorMaxHP: state.floorMaxHP, + maxFloorReached: state.maxFloorReached, + castProgress: state.castProgress, + equipmentSpellStates: state.equipmentSpellStates, + }; } // Compute discipline bonuses once per tick @@ -59,7 +94,7 @@ export function processCombatTick( // Calculate base damage const floorElement = getFloorElement(currentFloor); const damage = calcDamage( - { skills: {}, signedPacts: usePrestigeStore.getState().signedPacts }, + { skills: {}, signedPacts }, spellId, floorElement, disciplineEffects, @@ -114,7 +149,7 @@ export function processCombatTick( // Calculate damage const eFloorElement = getFloorElement(currentFloor); const eDamage = calcDamage( - { skills: {}, signedPacts: usePrestigeStore.getState().signedPacts }, + { skills: {}, signedPacts }, eSpell.spellId, eFloorElement, disciplineEffects, @@ -135,16 +170,29 @@ export function processCombatTick( updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 }; } + const newMaxFloorReached = Math.max(state.maxFloorReached, currentFloor); + set({ currentFloor, floorHP, floorMaxHP: getFloorMaxHP(currentFloor), - maxFloorReached: Math.max(state.maxFloorReached, currentFloor), + maxFloorReached: newMaxFloorReached, castProgress, equipmentSpellStates: updatedEquipmentSpellStates, }); - return { rawMana, elements, logMessages, totalManaGathered }; + return { + rawMana, + elements, + logMessages, + totalManaGathered, + currentFloor, + floorHP, + floorMaxHP: getFloorMaxHP(currentFloor), + maxFloorReached: newMaxFloorReached, + castProgress, + equipmentSpellStates: updatedEquipmentSpellStates, + }; } // Helper function to create initial spells diff --git a/src/lib/game/stores/combat-state.types.ts b/src/lib/game/stores/combat-state.types.ts index 66b4991..c56b12f 100644 --- a/src/lib/game/stores/combat-state.types.ts +++ b/src/lib/game/stores/combat-state.types.ts @@ -99,7 +99,19 @@ export interface CombatState { attackSpeedMult: number, onFloorCleared: (floor: number, wasGuardian: boolean) => void, onDamageDealt: (damage: number) => { rawMana: number; elements: Record }, - ) => { rawMana: number; elements: Record; logMessages: string[]; totalManaGathered: number }; + signedPacts: number[], + ) => { + rawMana: number; + elements: Record; + logMessages: string[]; + totalManaGathered: number; + currentFloor: number; + floorHP: number; + floorMaxHP: number; + maxFloorReached: number; + castProgress: number; + equipmentSpellStates: EquipmentSpellState[]; + }; // Reset resetCombat: (startFloor: number, spellsToKeep?: string[]) => void; diff --git a/src/lib/game/stores/combatStore.ts b/src/lib/game/stores/combatStore.ts index 5fcc71f..7004641 100755 --- a/src/lib/game/stores/combatStore.ts +++ b/src/lib/game/stores/combatStore.ts @@ -223,6 +223,7 @@ export const useCombatStore = create()( attackSpeedMult: number, onFloorCleared: (floor: number, wasGuardian: boolean) => void, onDamageDealt: (damage: number) => { rawMana: number; elements: Record }, + signedPacts: number[], ) => { return processCombatTick( get, @@ -233,6 +234,7 @@ export const useCombatStore = create()( attackSpeedMult, onFloorCleared, onDamageDealt, + signedPacts, ); }, diff --git a/src/lib/game/stores/craftingStore.ts b/src/lib/game/stores/craftingStore.ts index 5d3646f..34706f8 100644 --- a/src/lib/game/stores/craftingStore.ts +++ b/src/lib/game/stores/craftingStore.ts @@ -206,15 +206,17 @@ export const useCraftingStore = create()( // Preparation actions startPreparing: (equipmentInstanceId) => { - // Get rawMana from manaStore const rawMana = useManaStore.getState().rawMana; - // Temporary state to pass to preparation action - const tempState = { ...get(), rawMana } as any; - return PreparationActions.startPreparing( + const result = PreparationActions.startPreparing( equipmentInstanceId, - () => tempState, + rawMana, + get, set ); + if (result) { + useCombatStore.setState({ currentAction: 'prepare' }); + } + return result; }, cancelPreparation: () => { diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts index 2e2b97f..42a65e9 100755 --- a/src/lib/game/stores/gameStore.ts +++ b/src/lib/game/stores/gameStore.ts @@ -1,10 +1,10 @@ // ─── Game Store (Coordinator) ───────────────────────────────────────────────── // Manages: day, hour, incursionStrength, containmentWards -// Coordinate tick function across all stores +// Orchestrates tick across all stores via a read → compute → write pipeline. import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { TICK_MS, HOURS_PER_TICK, MAX_DAY, SPELLS_DEF, GUARDIANS, getStudySpeedMultiplier } from '../constants'; +import { TICK_MS, HOURS_PER_TICK, MAX_DAY, GUARDIANS, getStudySpeedMultiplier } from '../constants'; import { hasSpecial, SPECIAL_EFFECTS } from '../effects/special-effects'; import { computeEquipmentEffects } from '../effects'; import type { ComputedEffects } from '../effects/upgrade-effects.types'; @@ -32,6 +32,8 @@ import { useDisciplineStore } from './discipline-slice'; import { ATTUNEMENTS_DEF, getAttunementConversionRate } from '../data/attunements'; import { createResetGame, createGatherMana } from './gameActions'; import { createStartNewLoop } from './gameLoopActions'; +import { buildTickContext, applyTickWrites } from './tick-pipeline'; +import type { TickContext, TickWrites } from './tick-pipeline'; export interface GameCoordinatorState { day: number; @@ -68,25 +70,30 @@ export const useGameStore = create()( }, tick: () => { - const uiState = useUIStore.getState(); - if (uiState.gameOver || uiState.paused) return; + // ── 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(), + }); - // Helper for logging - const addLog = (msg: string) => useUIStore.getState().addLog(msg); + if (ctx.ui.gameOver || ctx.ui.paused) return; - // Get all store states - const prestigeState = usePrestigeStore.getState(); - const manaState = useManaStore.getState(); - const combatState = useCombatStore.getState(); - const craftingState = useCraftingStore.getState(); - // Compute equipment specials from enchanted gear + // ── 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( - craftingState.equipmentInstances || {}, - craftingState.equippedInstances || {} + ctx.crafting.equipmentInstances || {}, + ctx.crafting.equippedInstances || {} ); - // Compute discipline specials from active discipline perks const disciplineEffects = computeDisciplineEffects(); - // Merge all specials into a single set for hasSpecial checks const allSpecials = new Set([ ...equipmentEffects.specials, ...disciplineEffects.specials, @@ -94,19 +101,19 @@ export const useGameStore = create()( const effects = { specials: allSpecials } as ComputedEffects; const maxMana = computeMaxMana( - { skills: {}, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: {}, skillTiers: {} }, + { skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {} }, undefined, disciplineEffects, ); const baseRegen = computeRegen( - { skills: {}, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, attunements: {} }, + { skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, attunements: {} }, undefined, disciplineEffects, ); // Time progression - let hour = get().hour + HOURS_PER_TICK; - let day = get().day; + let hour = ctx.game.hour + HOURS_PER_TICK; + let day = ctx.game.day; if (hour >= 24) { hour -= 24; day += 1; @@ -115,79 +122,96 @@ export const useGameStore = create()( // Check for loop end if (day > MAX_DAY) { const insightGained = calcInsight({ - maxFloorReached: combatState.maxFloorReached, - totalManaGathered: manaState.totalManaGathered, - signedPacts: prestigeState.signedPacts, - prestigeUpgrades: prestigeState.prestigeUpgrades, + maxFloorReached: ctx.combat.maxFloorReached, + totalManaGathered: ctx.mana.totalManaGathered, + signedPacts: ctx.prestige.signedPacts, + prestigeUpgrades: ctx.prestige.prestigeUpgrades, skills: {}, }, disciplineEffects); addLog(`⏰ The loop ends. Gained ${insightGained} Insight.`); - useUIStore.getState().setGameOver(true, false); - usePrestigeStore.getState().setLoopInsight(insightGained); - set({ day, hour }); + writes.ui = { ...(writes.ui || {}), gameOver: true, victory: false }; + writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained }; + writes.game = { day, hour }; + applyTickWrites(writes, { + setGame: set, + setUI: (w) => useUIStore.setState(w), + setPrestige: (w) => usePrestigeStore.setState(w), + setMana: (w) => useManaStore.setState(w), + setCombat: (w) => useCombatStore.setState(w), + setCrafting: (w) => useCraftingStore.setState(w), + setAttunement: (w) => useAttunementStore.setState(w), + setDiscipline: (w) => useDisciplineStore.setState(w), + addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)), + }); return; } // Check for victory - if (combatState.maxFloorReached >= 100 && prestigeState.signedPacts.includes(100)) { + if (ctx.combat.maxFloorReached >= 100 && ctx.prestige.signedPacts.includes(100)) { const insightGained = calcInsight({ - maxFloorReached: combatState.maxFloorReached, - totalManaGathered: manaState.totalManaGathered, - signedPacts: prestigeState.signedPacts, - prestigeUpgrades: prestigeState.prestigeUpgrades, + maxFloorReached: ctx.combat.maxFloorReached, + totalManaGathered: ctx.mana.totalManaGathered, + signedPacts: ctx.prestige.signedPacts, + prestigeUpgrades: ctx.prestige.prestigeUpgrades, skills: {}, }, disciplineEffects) * 3; addLog(`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`); - useUIStore.getState().setGameOver(true, true); - usePrestigeStore.getState().setLoopInsight(insightGained); + writes.ui = { ...(writes.ui || {}), gameOver: true, victory: true }; + writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained }; + applyTickWrites(writes, { + setGame: set, + setUI: (w) => useUIStore.setState(w), + setPrestige: (w) => usePrestigeStore.setState(w), + setMana: (w) => useManaStore.setState(w), + setCombat: (w) => useCombatStore.setState(w), + setCrafting: (w) => useCraftingStore.setState(w), + setAttunement: (w) => useAttunementStore.setState(w), + setDiscipline: (w) => useDisciplineStore.setState(w), + addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)), + }); return; } // Incursion const incursionStrength = getIncursionStrength(day, hour); - // Meditation bonus tracking and regen calculation - let meditateTicks = manaState.meditateTicks; + // Meditation bonus tracking + let meditateTicks = ctx.mana.meditateTicks; let meditationMultiplier = 1; - if (combatState.currentAction === 'meditate') { + if (ctx.combat.currentAction === 'meditate') { meditateTicks++; meditationMultiplier = getMeditationBonus(meditateTicks, {}, 1); } else { meditateTicks = 0; } - // Calculate total attunement conversion per tick (to subtract from regen) - const attunementState = useAttunementStore.getState(); + // Calculate total attunement conversion per tick let totalConversionPerTick = 0; - Object.entries(attunementState.attunements).forEach(([id, state]) => { + 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 with incursion, meditation, and attunement conversion + // Calculate effective regen const effectiveRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - totalConversionPerTick); - // Mana regeneration (now includes attunement conversion deduction) - let rawMana = Math.min(manaState.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana); - let elements = { ...manaState.elements }; + // Mana regeneration + let rawMana = Math.min(ctx.mana.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana); + let elements = { ...ctx.mana.elements }; - // Apply attunement conversion (add to primary mana types) - Object.entries(attunementState.attunements).forEach(([id, state]) => { + // 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; - - // Add to primary mana type (cost already deducted from regen) if (elements[def.primaryManaType]) { elements[def.primaryManaType].current = Math.min( elements[def.primaryManaType].max, @@ -195,10 +219,10 @@ export const useGameStore = create()( ); } }); - let totalManaGathered = manaState.totalManaGathered; + let totalManaGathered = ctx.mana.totalManaGathered; - // Convert action - delegate to manaStore - if (combatState.currentAction === 'convert') { + // Convert action — delegate to manaStore + if (ctx.combat.currentAction === 'convert') { const convertResult = useManaStore.getState().processConvertAction(rawMana); if (convertResult) { rawMana = convertResult.rawMana; @@ -207,26 +231,33 @@ export const useGameStore = create()( } // Pact ritual progress - if (prestigeState.pactRitualFloor !== null) { - const guardian = GUARDIANS[prestigeState.pactRitualFloor]; + if (ctx.prestige.pactRitualFloor !== null) { + const guardian = GUARDIANS[ctx.prestige.pactRitualFloor]; if (guardian) { - const pactAffinityBonus = 1 - (prestigeState.prestigeUpgrades.pactAffinity || 0) * 0.1; + const pactAffinityBonus = 1 - (ctx.prestige.prestigeUpgrades.pactAffinity || 0) * 0.1; const requiredTime = guardian.pactTime * pactAffinityBonus; - const newProgress = prestigeState.pactRitualProgress + HOURS_PER_TICK; + const newProgress = ctx.prestige.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); + 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 { - usePrestigeStore.getState().updatePactRitualProgress(HOURS_PER_TICK); + writes.prestige = { + ...(writes.prestige || {}), + pactRitualProgress: newProgress, + }; } } } - // Combat - delegate to combatStore - if (combatState.currentAction === 'climb') { + // Combat — delegate to combatStore + if (ctx.combat.currentAction === 'climb') { const combatResult = useCombatStore.getState().processCombatTick( rawMana, elements, @@ -240,45 +271,56 @@ export const useGameStore = create()( } }, (damage) => { - // Apply upgrade damage multipliers and bonuses let dmg = damage; - - // Executioner: +100% damage to enemies below 25% HP - if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && combatState.floorHP / combatState.floorMaxHP < 0.25) { + if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && ctx.combat.floorHP / ctx.combat.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; } - return { rawMana, elements, modifiedDamage: dmg }; - } + }, + ctx.prestige.signedPacts, ); rawMana = combatResult.rawMana; elements = combatResult.elements; totalManaGathered += combatResult.totalManaGathered || 0; - // Log any messages from combat 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, + }; } - // Update all stores with new state - useManaStore.setState({ + // ── Phase 3: Write — batch all state updates ───────────────────────── + writes.game = { day, hour, incursionStrength }; + writes.mana = { rawMana, meditateTicks, totalManaGathered, elements, - }); + }; - set({ - day, - hour, - incursionStrength, + applyTickWrites(writes, { + setGame: set, + setUI: (w) => useUIStore.setState(w), + setPrestige: (w) => usePrestigeStore.setState(w), + setMana: (w) => useManaStore.setState(w), + setCombat: (w) => useCombatStore.setState(w), + setCrafting: (w) => useCraftingStore.setState(w), + setAttunement: (w) => useAttunementStore.setState(w), + setDiscipline: (w) => useDisciplineStore.setState(w), + addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)), }); }, diff --git a/src/lib/game/stores/tick-pipeline.ts b/src/lib/game/stores/tick-pipeline.ts new file mode 100644 index 0000000..cdccddc --- /dev/null +++ b/src/lib/game/stores/tick-pipeline.ts @@ -0,0 +1,102 @@ +// ─── Tick Pipeline ───────────────────────────────────────────────────────────── +// Orchestrates the game tick as a read → compute → write pipeline. +// Eliminates cross-store getState() calls inside tick() by snapshotting all +// store state once, then batching all writes at the end. + +import type { UIState } from './uiStore'; +import type { PrestigeState } from './prestigeStore'; +import type { ManaState } from './manaStore'; +import type { CombatState } from './combat-state.types'; +import type { CraftingState } from './craftingStore.types'; +import type { AttunementStoreState } from './attunementStore'; +import type { DisciplineStoreState } from './discipline-slice'; +import type { GameCoordinatorState } from './gameStore'; + +// ─── Read-only snapshot of all store states at tick start ────────────────────── +export interface TickContext { + game: GameCoordinatorState; + ui: UIState; + prestige: PrestigeState; + mana: ManaState; + combat: CombatState; + crafting: CraftingState; + attunement: AttunementStoreState; + discipline: DisciplineStoreState; +} + +// ─── Write batches — partial state to write back to each store ───────────────── +export interface TickWrites { + game?: Partial; + ui?: Partial; + prestige?: Partial; + mana?: Partial; + combat?: Partial; + crafting?: Partial; + attunement?: Partial; + discipline?: Partial; + logs: string[]; +} + +// ─── Rehydration guard ───────────────────────────────────────────────────────── +// Zustand persist sets `_persist.rehydrated` after hydration completes. +// We check a proxy flag on each store's state object instead, since Zustand +// doesn't expose a public rehydration flag. + +/** Check if a persist store has rehydrated by looking for non-default state. */ +export function hasRehydrated(state: Record, keys: string[]): boolean { + // If any persisted key has a non-default value, the store has rehydrated. + // For our purposes, we just check that the state object is not empty. + return keys.some((k) => state[k] !== undefined); +} + +// ─── Pipeline orchestrator ───────────────────────────────────────────────────── + +/** + * Build a TickContext by snapshotting all store states. + * Call this ONCE at the start of tick(). + */ +export function buildTickContext(params: { + game: GameCoordinatorState; + ui: UIState; + prestige: PrestigeState; + mana: ManaState; + combat: CombatState; + crafting: CraftingState; + attunement: AttunementStoreState; + discipline: DisciplineStoreState; +}): TickContext { + return { ...params }; +} + +/** + * Apply all writes to their respective stores. + * Call this ONCE at the end of tick(). + */ +export function applyTickWrites( + writes: TickWrites, + setters: { + setGame: (w: Partial) => void; + setUI: (w: Partial) => void; + setPrestige: (w: Partial) => void; + setMana: (w: Partial) => void; + setCombat: (w: Partial) => void; + setCrafting: (w: Partial) => void; + setAttunement: (w: Partial) => void; + setDiscipline: (w: Partial) => void; + addLogs: (msgs: string[]) => void; + }, +): void { + const { + setGame, setUI, setPrestige, setMana, + setCombat, setCrafting, setAttunement, setDiscipline, addLogs, + } = setters; + if (writes.game) setGame(writes.game); + if (writes.ui) setUI(writes.ui); + if (writes.prestige) setPrestige(writes.prestige); + if (writes.mana) setMana(writes.mana); + if (writes.combat) setCombat(writes.combat); + if (writes.crafting) setCrafting(writes.crafting); + if (writes.attunement) setAttunement(writes.attunement); + if (writes.discipline) setDiscipline(writes.discipline); + if (writes.logs.length > 0) addLogs(writes.logs); +}