From 7dd9ad5b9282b916b7acdf7b2459b38d0e64a4f1 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Mon, 1 Jun 2026 12:57:52 +0200 Subject: [PATCH] fix: multiple bug fixes - infinite loop crash, enchant tick handlers, discipline crash, Plasma symbol, desync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #236: Fix Climb the Spire React #185 infinite loop - removed redundant set() in processCombatTick that caused double Zustand writes per tick - #235: Add enchanting design/prepare/apply tick handlers - extracted to pipelines/enchanting-tick.ts - #235: Fix startApplying not setting currentAction to 'enchant' - #243: Guard discipline store against undefined activeIds/processedPerks from corrupted persisted state - #245: Change Plasma symbol from ⚡ (conflicts with Lightning) to 🔴 - #241: Fix combat store maxFloorReached desync - initialize to 0, reset on exitSpireMode - #239: Fix EffectSelector not rendering when unlockedEffects is empty (fresh game) - Created pipelines/enchanting-tick.ts to keep gameStore.ts under 400 lines --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 2 +- docs/project-structure.txt | 1 + .../crafting/EnchantmentDesigner/utils.ts | 2 +- src/lib/game/constants/elements.ts | 2 +- src/lib/game/stores/combat-actions.ts | 9 - src/lib/game/stores/combatStore.ts | 3 +- src/lib/game/stores/craftingStore.ts | 6 +- src/lib/game/stores/discipline-slice.ts | 15 +- src/lib/game/stores/gameStore.ts | 15 ++ .../game/stores/pipelines/enchanting-tick.ts | 177 ++++++++++++++++++ 11 files changed, 213 insertions(+), 21 deletions(-) create mode 100644 src/lib/game/stores/pipelines/enchanting-tick.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index ba4f025..d900ce6 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,4 +1,4 @@ # Circular Dependencies -Generated: 2026-06-01T07:54:15.106Z +Generated: 2026-06-01T09:05:01.898Z No circular dependencies found. ✅ diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 4faaa07..1ec15bc 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-06-01T07:54:13.158Z", + "generated": "2026-06-01T09:04:59.927Z", "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 b5b5a3c..34a6a2e 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -329,6 +329,7 @@ Mana-Loop/ │ │ │ ├── stores/ │ │ │ │ ├── pipelines/ │ │ │ │ │ ├── combat-tick.ts +│ │ │ │ │ ├── enchanting-tick.ts │ │ │ │ │ ├── equipment-crafting.ts │ │ │ │ │ └── pact-ritual.ts │ │ │ │ ├── attunementStore.ts diff --git a/src/components/game/crafting/EnchantmentDesigner/utils.ts b/src/components/game/crafting/EnchantmentDesigner/utils.ts index 3a0573c..c29a1c3 100644 --- a/src/components/game/crafting/EnchantmentDesigner/utils.ts +++ b/src/components/game/crafting/EnchantmentDesigner/utils.ts @@ -18,7 +18,7 @@ export function getAvailableEffects( return Object.values(ENCHANTMENT_EFFECTS).filter( effect => effect.allowedEquipmentCategories.includes(type.category) && - unlockedEffects.includes(effect.id) + (unlockedEffects.length === 0 || unlockedEffects.includes(effect.id)) ); } diff --git a/src/lib/game/constants/elements.ts b/src/lib/game/constants/elements.ts index 701bd22..ce5361c 100644 --- a/src/lib/game/constants/elements.ts +++ b/src/lib/game/constants/elements.ts @@ -43,7 +43,7 @@ export const ELEMENTS: Record = { void: { name: "Void", sym: "🕳️", color: "#4A235A", glow: "#4A235A40", cat: "exotic", recipe: ["dark", "dark", "death"] }, soul: { name: "Soul", sym: "💫", color: "#E8D5F5", glow: "#E8D5F540", cat: "exotic", recipe: ["light", "dark", "transference"] }, time: { name: "Time", sym: "⏱️", color: "#C5B99A", glow: "#C5B99A40", cat: "exotic", recipe: ["soul", "sand", "transference"] }, - plasma: { name: "Plasma", sym: "⚡", color: "#FF6B9D", glow: "#FF6B9D40", cat: "exotic", recipe: ["lightning", "fire", "transference"] }, + plasma: { name: "Plasma", sym: "🔴", color: "#FF6B9D", glow: "#FF6B9D40", cat: "exotic", recipe: ["lightning", "fire", "transference"] }, }; // NOTE: Life, Blood, Wood, Mental, and Force mana types have been removed. diff --git a/src/lib/game/stores/combat-actions.ts b/src/lib/game/stores/combat-actions.ts index 4b02c64..12704fb 100644 --- a/src/lib/game/stores/combat-actions.ts +++ b/src/lib/game/stores/combat-actions.ts @@ -216,15 +216,6 @@ export function processCombatTick( const newMaxFloorReached = Math.max(state.maxFloorReached, currentFloor); - set({ - currentFloor, - floorHP, - floorMaxHP: getFloorMaxHP(currentFloor), - maxFloorReached: newMaxFloorReached, - castProgress, - equipmentSpellStates: updatedEquipmentSpellStates, - }); - return { rawMana, elements, diff --git a/src/lib/game/stores/combatStore.ts b/src/lib/game/stores/combatStore.ts index 74b6e0d..ea0cf88 100644 --- a/src/lib/game/stores/combatStore.ts +++ b/src/lib/game/stores/combatStore.ts @@ -19,7 +19,7 @@ export const useCombatStore = create()( currentFloor: 1, floorHP: getFloorMaxHP(1), floorMaxHP: getFloorMaxHP(1), - maxFloorReached: 1, + maxFloorReached: 0, activeSpell: 'manaBolt', currentAction: 'meditate', castProgress: 0, @@ -164,6 +164,7 @@ export const useCombatStore = create()( currentRoom: generateFloorState(1), castProgress: 0, clearedFloors: {}, + maxFloorReached: 0, }); }, diff --git a/src/lib/game/stores/craftingStore.ts b/src/lib/game/stores/craftingStore.ts index 769c35c..4ce9888 100644 --- a/src/lib/game/stores/craftingStore.ts +++ b/src/lib/game/stores/craftingStore.ts @@ -127,13 +127,17 @@ export const useCraftingStore = create()( // Enchantment application actions startApplying: (equipmentInstanceId, designId) => { const currentAction = useCombatStore.getState().currentAction; - return ApplicationActions.startApplying( + const result = ApplicationActions.startApplying( equipmentInstanceId, designId, get, set as unknown as (partial: Partial) => void, currentAction ); + if (result) { + useCombatStore.setState({ currentAction: 'enchant' }); + } + return result; }, pauseApplication: () => { diff --git a/src/lib/game/stores/discipline-slice.ts b/src/lib/game/stores/discipline-slice.ts index 3ac5a39..c92815d 100644 --- a/src/lib/game/stores/discipline-slice.ts +++ b/src/lib/game/stores/discipline-slice.ts @@ -77,9 +77,12 @@ export const useDisciplineStore = create()( const def = DISCIPLINE_MAP[id]; if (!def) return s; + // Guard against corrupted persisted state + const activeIds = s.activeIds ?? []; + // Allow re-activation if discipline exists but is paused const existing = s.disciplines[id]; - if (s.activeIds.includes(id)) { + if (activeIds.includes(id)) { // If already active and paused, un-pause it if (existing?.paused) { return { @@ -89,7 +92,7 @@ export const useDisciplineStore = create()( return s; } - const nonPaused = s.activeIds.filter((aid) => { + const nonPaused = activeIds.filter((aid) => { const d = s.disciplines[aid]; return d && !d.paused; }).length; @@ -117,7 +120,7 @@ export const useDisciplineStore = create()( get().practicingCallbacks?.onStartPracticing?.(); return { disciplines: { ...s.disciplines, [id]: { ...discState, paused: false } }, - activeIds: [...s.activeIds, id], + activeIds: [...activeIds, id], }; }); }, @@ -160,10 +163,10 @@ export const useDisciplineStore = create()( const newDisciplines = { ...s.disciplines }; const newUnlockedEffects: string[] = []; const newUnlockedRecipes: string[] = []; - const newProcessedPerks = [...s.processedPerks]; + const newProcessedPerks = [...(s.processedPerks ?? [])]; const drainedIds: string[] = []; - for (const id of s.activeIds) { + for (const id of s.activeIds ?? []) { const disc = newDisciplines[id]; if (!disc) continue; if (disc.paused) continue; @@ -245,7 +248,7 @@ export const useDisciplineStore = create()( ); // Remove mana-drained disciplines from activeIds so onStopPracticing fires - const newActiveIds = s.activeIds.filter((aid) => !drainedIds.includes(aid)); + const newActiveIds = (s.activeIds ?? []).filter((aid) => !drainedIds.includes(aid)); if (newActiveIds.length === 0 && s.activeIds.length > 0) { get().practicingCallbacks?.onStopPracticing?.(); } diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts index da19a75..9588346 100644 --- a/src/lib/game/stores/gameStore.ts +++ b/src/lib/game/stores/gameStore.ts @@ -22,6 +22,7 @@ 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 type { TickContext, TickWrites } from './tick-pipeline'; import type { GameCoordinatorState } from './gameStore.types'; @@ -311,6 +312,20 @@ export const useGameStore = create()( } } + // 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 = { diff --git a/src/lib/game/stores/pipelines/enchanting-tick.ts b/src/lib/game/stores/pipelines/enchanting-tick.ts new file mode 100644 index 0000000..14d72be --- /dev/null +++ b/src/lib/game/stores/pipelines/enchanting-tick.ts @@ -0,0 +1,177 @@ +// ─── Enchanting Tick Handlers ───────────────────────────────────────────────── +// Design → Prepare → Application tick processing for the enchanting pipeline. +// Extracted from gameStore.ts to keep the coordinator under the 400-line limit. + +import { HOURS_PER_TICK } from '../../constants'; +import type { ComputedEffects } from '../../effects/upgrade-effects.types'; +import { calculateDesignProgress, createCompletedDesignFromProgress } from '../../crafting-design'; +import { calculatePreparationTick, completePreparation } from '../../crafting-prep'; +import { calculateApplicationTick, applyEnchantments, updateEnchanterAttunement } from '../../crafting-apply'; +import { useCraftingStore } from '../craftingStore'; +import type { TickContext, TickWrites } from '../tick-pipeline'; + +interface EnchantingTickParams { + ctx: TickContext; + effects: ComputedEffects; + rawMana: number; + addLog: (msg: string) => void; +} + +export function processEnchantingTicks( + params: EnchantingTickParams, +): { rawMana: number; writes: Partial } { + const { ctx, effects, rawMana: initialRawMana, addLog } = params; + let rawMana = initialRawMana; + const writes: Partial = {}; + const currentAction = ctx.combat.currentAction; + + // ── Phase 1: Design ────────────────────────────────────────────────────── + if (currentAction === 'design') { + const designProgress = ctx.crafting.designProgress; + const designProgress2 = ctx.crafting.designProgress2; + if (!designProgress && !designProgress2) { + writes.combat = { ...(writes.combat || {}), currentAction: 'meditate' }; + } else { + const activeProgress = designProgress || designProgress2!; + const isRepeatDesign = ctx.crafting.enchantmentDesigns.some( + (d) => d.name === activeProgress.name, + ); + const designResult = calculateDesignProgress( + activeProgress.progress, + activeProgress.required, + effects, + isRepeatDesign, + ); + if (designResult.isComplete) { + const completedDesign = createCompletedDesignFromProgress( + { + designId: activeProgress.designId, + name: activeProgress.name, + equipmentType: activeProgress.equipmentType, + effects: activeProgress.effects, + required: activeProgress.required, + }, + 0, + ); + useCraftingStore.getState().saveDesign(completedDesign); + addLog('Design "' + completedDesign.name + '" completed!'); + writes.combat = { ...(writes.combat || {}), currentAction: 'meditate' }; + } else { + if (designProgress) { + useCraftingStore.getState().setDesignProgress({ + ...designProgress, + progress: designResult.progress, + }); + } else if (designProgress2) { + useCraftingStore.getState().setDesignProgress2({ + ...designProgress2, + progress: designResult.progress, + }); + } + } + } + } + + // ── Phase 2: Preparation ───────────────────────────────────────────────── + if (currentAction === 'prepare') { + const prepProgress = ctx.crafting.preparationProgress; + if (!prepProgress) { + writes.combat = { ...(writes.combat || {}), currentAction: 'meditate' }; + } else { + const instance = ctx.crafting.equipmentInstances[prepProgress.equipmentInstanceId]; + if (!instance) { + useCraftingStore.getState().setPreparationProgress(null); + writes.combat = { ...(writes.combat || {}), currentAction: 'meditate' }; + addLog('Preparation failed: equipment not found.'); + } else { + const manaPerTick = (instance.totalCapacity * 10) / (2 + instance.totalCapacity / 50) * HOURS_PER_TICK; + const prepResult = calculatePreparationTick( + prepProgress.progress, + prepProgress.required, + prepProgress.manaCostPaid, + manaPerTick, + ); + if (prepResult.manaConsumed > 0) { + rawMana = Math.max(0, rawMana - prepResult.manaConsumed); + } + if (prepResult.isComplete) { + const completionResult = completePreparation(instance); + useCraftingStore.setState((s) => ({ + equipmentInstances: { + ...s.equipmentInstances, + [prepProgress.equipmentInstanceId]: completionResult.updatedInstance, + }, + preparationProgress: null, + })); + writes.combat = { ...(writes.combat || {}), currentAction: 'meditate' }; + addLog(completionResult.logMessage); + } else { + useCraftingStore.getState().setPreparationProgress({ + ...prepProgress, + progress: prepResult.progress, + manaCostPaid: prepResult.manaCostPaid, + }); + } + } + } + } + + // ── Phase 3: Application ───────────────────────────────────────────────── + if (currentAction === 'enchant') { + const appProgress = ctx.crafting.applicationProgress; + if (!appProgress || appProgress.paused) { + if (!appProgress) { + writes.combat = { ...(writes.combat || {}), currentAction: 'meditate' }; + } + } else { + const instance = ctx.crafting.equipmentInstances[appProgress.equipmentInstanceId]; + const design = ctx.crafting.enchantmentDesigns.find((d) => d.id === appProgress.designId); + if (!instance || !design) { + useCraftingStore.getState().setApplicationProgress(null); + writes.combat = { ...(writes.combat || {}), currentAction: 'meditate' }; + addLog('Enchantment failed: equipment or design not found.'); + } else { + const totalStacks = design.effects.reduce((s, e) => s + e.stacks, 0); + const manaPerTick = (20 + 5 * totalStacks) * HOURS_PER_TICK; + const appResult = calculateApplicationTick( + appProgress.progress, + appProgress.required, + appProgress.manaSpent, + manaPerTick, + effects, + ); + if (appResult.manaConsumed > 0) { + rawMana = Math.max(0, rawMana - appResult.manaConsumed); + } + if (appResult.isComplete) { + const applyResult = applyEnchantments(instance, design, effects); + useCraftingStore.setState((s) => ({ + equipmentInstances: { + ...s.equipmentInstances, + [appProgress.equipmentInstanceId]: applyResult.updatedInstance, + }, + applicationProgress: null, + })); + const updatedAttunements = updateEnchanterAttunement( + ctx.attunement.attunements, + applyResult.xpGained, + ); + writes.attunement = { ...(writes.attunement || {}), attunements: updatedAttunements }; + writes.combat = { ...(writes.combat || {}), currentAction: 'meditate' }; + addLog(applyResult.logMessage); + if (appResult.triggeredFreeEnchant) { + addLog('Free enchantment triggered! No mana consumed this tick.'); + } + } else { + useCraftingStore.getState().setApplicationProgress({ + ...appProgress, + progress: appResult.progress, + manaSpent: appResult.manaSpent, + }); + } + } + } + } + + return { rawMana, writes }; +}