fix: multiple bug fixes - infinite loop crash, enchant tick handlers, discipline crash, Plasma symbol, desync
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m24s

- #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
This commit is contained in:
2026-06-01 12:57:52 +02:00
parent 2539559edc
commit 7dd9ad5b92
11 changed files with 213 additions and 21 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Circular Dependencies
Generated: 2026-06-01T07:54:15.106Z
Generated: 2026-06-01T09:05:01.898Z
No circular dependencies found. ✅
+1 -1
View File
@@ -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."
},
+1
View File
@@ -329,6 +329,7 @@ Mana-Loop/
│ │ │ ├── stores/
│ │ │ │ ├── pipelines/
│ │ │ │ │ ├── combat-tick.ts
│ │ │ │ │ ├── enchanting-tick.ts
│ │ │ │ │ ├── equipment-crafting.ts
│ │ │ │ │ └── pact-ritual.ts
│ │ │ │ ├── attunementStore.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))
);
}
+1 -1
View File
@@ -43,7 +43,7 @@ export const ELEMENTS: Record<string, ElementDef> = {
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.
-9
View File
@@ -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,
+2 -1
View File
@@ -19,7 +19,7 @@ export const useCombatStore = create<CombatStore>()(
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<CombatStore>()(
currentRoom: generateFloorState(1),
castProgress: 0,
clearedFloors: {},
maxFloorReached: 0,
});
},
+5 -1
View File
@@ -127,13 +127,17 @@ export const useCraftingStore = create<CraftingStore>()(
// 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<CraftingState>) => void,
currentAction
);
if (result) {
useCombatStore.setState({ currentAction: 'enchant' });
}
return result;
},
pauseApplication: () => {
+9 -6
View File
@@ -77,9 +77,12 @@ export const useDisciplineStore = create<DisciplineStore>()(
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<DisciplineStore>()(
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<DisciplineStore>()(
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<DisciplineStore>()(
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<DisciplineStore>()(
);
// 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?.();
}
+15
View File
@@ -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<GameCoordinatorStore>()(
}
}
// 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 = {
@@ -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<TickWrites> } {
const { ctx, effects, rawMana: initialRawMana, addLog } = params;
let rawMana = initialRawMana;
const writes: Partial<TickWrites> = {};
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 };
}