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
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:
@@ -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,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."
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user