fix: resolve critical bugs - disciplines, debug reset, floating point, spire loop
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s

Fixes:
- Issue 193: Remove unnecessary useEffect that set activeTab when spireMode is true, and redundant setAction('climb') in SpireCombatPage
- Issue 194: Fix signed_pact prerequisite check in checkDisciplinePrerequisites by accepting signedPacts param; add 'At Limit' feedback on discipline button when concurrent limit reached
- Issue 195: Add resetDisciplines(), resetAttunements(), resetCrafting() calls to createResetGame; add resetCrafting action to crafting store
- Issue 196: Fix floating point display in ElementStatsSection (mana pools) and GameStateDebug (time); fix duplicate 'Base Regen' label in ManaStatsSection

All 917 tests pass. Files stay under 400-line limit.
This commit is contained in:
2026-05-29 14:10:04 +02:00
parent e20216bda5
commit a33e9429fe
15 changed files with 89 additions and 54 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Circular Dependencies # Circular Dependencies
Generated: 2026-05-28T19:01:33.787Z Generated: 2026-05-28T19:24:11.154Z
No circular dependencies found. ✅ No circular dependencies found. ✅
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_meta": {
"generated": "2026-05-28T19:01:32.036Z", "generated": "2026-05-28T19:24:09.393Z",
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.", "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." "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."
}, },
-6
View File
@@ -171,12 +171,6 @@ export default function ManaLoopGame() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []); // eslint-disable-line react-hooks/set-state-in-effect useEffect(() => { setMounted(true); }, []); // eslint-disable-line react-hooks/set-state-in-effect
useEffect(() => {
if (spireMode) {
setActiveTab('spells'); // eslint-disable-line react-hooks/set-state-in-effect
}
}, [spireMode]);
if (gameOver) { if (gameOver) {
return <GameOverScreen day={day} hour={hour} insightGained={loopInsight} totalInsight={insight} />; return <GameOverScreen day={day} hour={hour} insightGained={loopInsight} totalInsight={insight} />;
} }
+1 -1
View File
@@ -165,7 +165,7 @@ function TimeControlSection({ day, hour, paused, onSetDay, onTogglePause }: {
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="text-xs text-gray-400"> <div className="text-xs text-gray-400">
Current: Day {day}, Hour {hour} Current: Day {day}, Hour {Number.isFinite(hour) ? hour.toFixed(2) : '0.00'}
</div> </div>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={() => onSetDay(1)}>Day 1</Button> <Button size="sm" variant="outline" onClick={() => onSetDay(1)}>Day 1</Button>
+26 -6
View File
@@ -13,6 +13,7 @@ export interface DisciplineCardProps {
definition: DisciplineDefinition; definition: DisciplineDefinition;
xp: number; xp: number;
paused: boolean; paused: boolean;
activeIds: string[];
concurrentLimit: number; concurrentLimit: number;
isLocked: boolean; isLocked: boolean;
missingPrereqs: string[]; missingPrereqs: string[];
@@ -23,7 +24,7 @@ export interface DisciplineCardProps {
// ─── Component ──────────────────────────────────────────────────────────────── // ─── Component ────────────────────────────────────────────────────────────────
export const DisciplineCard: React.FC<DisciplineCardProps> = ({ export const DisciplineCard: React.FC<DisciplineCardProps> = ({
definition, xp, paused: isPaused, concurrentLimit, definition, xp, paused: isPaused, activeIds, concurrentLimit,
isLocked, missingPrereqs, missingSourceMana, onToggle, isLocked, missingPrereqs, missingSourceMana, onToggle,
}) => { }) => {
const { const {
@@ -41,6 +42,12 @@ export const DisciplineCard: React.FC<DisciplineCardProps> = ({
const manaColor = elementDef?.color ?? '#888888'; const manaColor = elementDef?.color ?? '#888888';
const manaIcon = elementDef?.sym ?? '✦'; const manaIcon = elementDef?.sym ?? '✦';
const manaName = elementDef?.name ?? manaType; const manaName = elementDef?.name ?? manaType;
const isActive = activeIds.includes(id);
const activeNotPaused = activeIds.filter((aid) => {
// Count how many active disciplines are not paused
return aid === id ? !isPaused : true;
}).length;
const atConcurrentLimit = !isActive && activeIds.length >= concurrentLimit;
const effectiveIsLocked = isLocked || missingSourceMana.length > 0; const effectiveIsLocked = isLocked || missingSourceMana.length > 0;
const statBonusLabel = statBonus.label; const statBonusLabel = statBonus.label;
@@ -140,9 +147,16 @@ export const DisciplineCard: React.FC<DisciplineCardProps> = ({
</div> </div>
{/* Lock Reasons */} {/* Lock Reasons */}
{effectiveIsLocked && (missingPrereqs.length > 0 || missingSourceMana.length > 0) && ( {(effectiveIsLocked || atConcurrentLimit) && (missingPrereqs.length > 0 || missingSourceMana.length > 0 || atConcurrentLimit) && (
<div className="mt-2 text-xs text-red-400"> <div className="mt-2 text-xs text-red-400">
<strong>Requires:</strong> {[...missingPrereqs, ...missingSourceMana].join(', ')} {atConcurrentLimit && <div><strong>At limit:</strong> {activeIds.length}/{concurrentLimit} disciplines active</div>}
{missingPrereqs.length > 0 && <div><strong>Requires:</strong> {missingPrereqs.join(', ')}</div>}
{missingSourceMana.length > 0 && <div><strong>Missing mana:</strong> {missingSourceMana.join(', ')}</div>}
</div>
)}
{atConcurrentLimit && missingPrereqs.length === 0 && missingSourceMana.length === 0 && (
<div className="mt-2 text-xs text-amber-400">
<strong>At limit:</strong> {activeIds.length}/{concurrentLimit} disciplines active. Gain XP to unlock more slots.
</div> </div>
)} )}
@@ -150,17 +164,23 @@ export const DisciplineCard: React.FC<DisciplineCardProps> = ({
<div className="mt-4 flex justify-end"> <div className="mt-4 flex justify-end">
<button <button
onClick={() => onToggle(id, isPaused)} onClick={() => onToggle(id, isPaused)}
disabled={effectiveIsLocked} disabled={effectiveIsLocked || atConcurrentLimit}
className={clsx( className={clsx(
'rounded px-3 py-1 text-sm font-medium', 'rounded px-3 py-1 text-sm font-medium',
effectiveIsLocked effectiveIsLocked || atConcurrentLimit
? 'bg-gray-600 text-gray-400 cursor-not-allowed' ? 'bg-gray-600 text-gray-400 cursor-not-allowed'
: isPaused : isPaused
? 'bg-yellow-600 text-white hover:bg-yellow-500' ? 'bg-yellow-600 text-white hover:bg-yellow-500'
: 'bg-blue-600 text-white hover:bg-blue-500', : 'bg-blue-600 text-white hover:bg-blue-500',
)} )}
> >
{effectiveIsLocked ? 'Locked' : isPaused ? 'Start Practicing' : 'Stop Practicing'} {effectiveIsLocked
? 'Locked'
: atConcurrentLimit
? `At Limit (${activeIds.length}/${concurrentLimit})`
: isPaused
? 'Start Practicing'
: 'Stop Practicing'}
</button> </button>
</div> </div>
</div> </div>
+5 -2
View File
@@ -41,6 +41,7 @@ const ATTUNEMENT_TABS: AttunementTab[] = [
interface CardWrapperProps { interface CardWrapperProps {
disc: DisciplineDefinition; disc: DisciplineDefinition;
disciplines: Record<string, { xp: number; paused: boolean }>; disciplines: Record<string, { xp: number; paused: boolean }>;
activeIds: string[];
concurrentLimit: number; concurrentLimit: number;
elements: ReturnType<typeof useManaStore.getState>['elements']; elements: ReturnType<typeof useManaStore.getState>['elements'];
signedPacts: ReturnType<typeof usePrestigeStore.getState>['signedPacts']; signedPacts: ReturnType<typeof usePrestigeStore.getState>['signedPacts'];
@@ -48,15 +49,16 @@ interface CardWrapperProps {
} }
const CardWrapper: React.FC<CardWrapperProps> = ({ const CardWrapper: React.FC<CardWrapperProps> = ({
disc, disciplines, concurrentLimit, elements, onToggle, disc, disciplines, activeIds, concurrentLimit, elements, signedPacts, onToggle,
}) => { }) => {
const discState = disciplines[disc.id] ?? { xp: 0, paused: true }; const discState = disciplines[disc.id] ?? { xp: 0, paused: true };
const prereqCheck = checkDisciplinePrerequisites(disc, disciplines, ALL_DISCIPLINES, elements); const prereqCheck = checkDisciplinePrerequisites(disc, disciplines, ALL_DISCIPLINES, elements, signedPacts);
return ( return (
<DisciplineCard <DisciplineCard
definition={disc} definition={disc}
xp={discState.xp} xp={discState.xp}
paused={discState.paused} paused={discState.paused}
activeIds={activeIds}
concurrentLimit={concurrentLimit} concurrentLimit={concurrentLimit}
isLocked={!prereqCheck.canProceed} isLocked={!prereqCheck.canProceed}
missingPrereqs={prereqCheck.missingPrereqs} missingPrereqs={prereqCheck.missingPrereqs}
@@ -129,6 +131,7 @@ export const DisciplinesTab: React.FC = () => {
key={disc.id} key={disc.id}
disc={disc} disc={disc}
disciplines={disciplines} disciplines={disciplines}
activeIds={activeIds}
concurrentLimit={concurrentLimit} concurrentLimit={concurrentLimit}
elements={elements} elements={elements}
signedPacts={signedPacts} signedPacts={signedPacts}
@@ -113,8 +113,7 @@ export function SpireCombatPage() {
setRoomsCleared(0); setRoomsCleared(0);
const newRoom = generateSpireFloorState(currentFloor, 0, totalRooms); const newRoom = generateSpireFloorState(currentFloor, 0, totalRooms);
setCurrentRoom(newRoom); setCurrentRoom(newRoom);
setAction('climb'); }, [currentFloor, totalRooms, setCurrentRoom]);
}, [currentFloor, totalRooms, setCurrentRoom, setAction]);
const _handleRoomCleared = () => { const _handleRoomCleared = () => {
const nextRoomIndex = roomsCleared + 1; const nextRoomIndex = roomsCleared + 1;
@@ -5,7 +5,7 @@ import { DebugName } from '@/components/game/debug/debug-context';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { FlaskConical } from 'lucide-react'; import { FlaskConical } from 'lucide-react';
import { ELEMENTS } from '@/lib/game/constants'; import { ELEMENTS } from '@/lib/game/constants';
import { usePrestigeStore, useManaStore } from '@/lib/game/stores'; import { usePrestigeStore, useManaStore, fmtDec } from '@/lib/game/stores';
import type { ElementState } from '@/lib/game/types'; import type { ElementState } from '@/lib/game/types';
interface ElementStatsSectionProps { interface ElementStatsSectionProps {
@@ -54,7 +54,7 @@ export function ElementStatsSection({ elemMax }: ElementStatsSectionProps) {
return ( return (
<div key={id} className="p-2 rounded transition-colors" style={{ border: `1px solid ${def?.color}30`, background: 'var(--bg-sunken)/50', textAlign: 'center' }}> <div key={id} className="p-2 rounded transition-colors" style={{ border: `1px solid ${def?.color}30`, background: 'var(--bg-sunken)/50', textAlign: 'center' }}>
<div className="text-lg" style={{ color: def?.color }}>{def?.sym}</div> <div className="text-lg" style={{ color: def?.color }}>{def?.sym}</div>
<div className="text-xs" style={{ color: 'var(--text-muted)' }}>{state.current}/{state.max}</div> <div className="text-xs" style={{ color: 'var(--text-muted)' }}>{fmtDec(state.current, 2)}/{fmtDec(state.max, 0)}</div>
</div> </div>
); );
})} })}
@@ -90,7 +90,7 @@ export function ManaStatsSection({ stats, elemMax }: ManaStatsSectionProps) {
<span style={{ color: 'var(--text-secondary)' }}>2/hr</span> <span style={{ color: 'var(--text-secondary)' }}>2/hr</span>
</div> </div>
<div className="flex justify-between text-sm font-semibold border-t border-[var(--border-subtle)] pt-2"> <div className="flex justify-between text-sm font-semibold border-t border-[var(--border-subtle)] pt-2">
<span style={{ color: 'var(--text-secondary)' }}>Base Regen:</span> <span style={{ color: 'var(--text-secondary)' }}>Computed Base Regen:</span>
<span style={{ color: 'var(--mana-water)' }}>{fmtDec(baseRegen, 2)}/hr</span> <span style={{ color: 'var(--mana-water)' }}>{fmtDec(baseRegen, 2)}/hr</span>
</div> </div>
{upgradeEffects.regenBonus > 0 && ( {upgradeEffects.regenBonus > 0 && (
@@ -3,6 +3,38 @@
import * as CraftingUtils from '../crafting-utils'; import * as CraftingUtils from '../crafting-utils';
import type { EquipmentInstance } from '../types'; import type { EquipmentInstance } from '../types';
import type { CraftingState } from './craftingStore.types';
/**
* Create the full default state for the crafting store.
* Used by both initial store creation and resetCrafting().
*/
export function createDefaultCraftingState(): CraftingState {
const initial = createInitialEquipmentInstances();
return {
designProgress: null,
designProgress2: null,
preparationProgress: null,
applicationProgress: null,
equipmentCraftingProgress: null,
enchantmentDesigns: [],
unlockedEffects: [],
equippedInstances: initial.equippedInstances,
equipmentInstances: initial.instances,
lootInventory: {
materials: {},
blueprints: [],
},
enchantmentSelection: {
selectedEquipmentType: null,
selectedEffects: [],
designName: '',
selectedDesign: null,
selectedEquipmentInstance: null,
},
lastError: null,
};
}
export function createInitialEquipmentInstances() { export function createInitialEquipmentInstances() {
const staffId = CraftingUtils.generateInstanceId(); const staffId = CraftingUtils.generateInstanceId();
+7 -24
View File
@@ -15,7 +15,7 @@ import * as CraftingEquipment from '../crafting-equipment';
import { equipItem as equipItemAction, unequipItem as unequipItemAction } from '../crafting-actions/equipment-actions'; import { equipItem as equipItemAction, unequipItem as unequipItemAction } from '../crafting-actions/equipment-actions';
import { ErrorCode } from '../utils/result'; import { ErrorCode } from '../utils/result';
import { createSafeStorage } from '../utils/safe-persist'; import { createSafeStorage } from '../utils/safe-persist';
import { createInitialEquipmentInstances } from './crafting-initial-state'; import { createDefaultCraftingState } from './crafting-initial-state';
import { import {
getFabricatorRecipe, getFabricatorRecipe,
deductFabricatorMana, deductFabricatorMana,
@@ -28,30 +28,9 @@ import { processEquipmentCraftingTick } from './crafting-equipment-tick';
export const useCraftingStore = create<CraftingStore>()( export const useCraftingStore = create<CraftingStore>()(
persist( persist(
(set, get) => { (set, get) => {
const initial = createInitialEquipmentInstances(); const defaultState = createDefaultCraftingState();
return { return {
// Initial state ...defaultState,
designProgress: null,
designProgress2: null,
preparationProgress: null,
applicationProgress: null,
equipmentCraftingProgress: null,
enchantmentDesigns: [],
unlockedEffects: [],
equippedInstances: initial.equippedInstances,
equipmentInstances: initial.instances,
lootInventory: {
materials: {},
blueprints: [],
},
enchantmentSelection: {
selectedEquipmentType: null,
selectedEffects: [],
designName: '',
selectedDesign: null,
selectedEquipmentInstance: null,
},
lastError: null,
// Actions // Actions
setDesignProgress: (progress) => set({ designProgress: progress }), setDesignProgress: (progress) => set({ designProgress: progress }),
@@ -369,6 +348,10 @@ export const useCraftingStore = create<CraftingStore>()(
const state = get(); const state = get();
return processEquipmentCraftingTick(state, set as unknown as (partial: Partial<CraftingState>) => void); return processEquipmentCraftingTick(state, set as unknown as (partial: Partial<CraftingState>) => void);
}, },
resetCrafting: () => {
set(createDefaultCraftingState());
},
}; };
}, },
{ {
@@ -73,6 +73,7 @@ export interface CraftingActions {
clearLastError: () => void; clearLastError: () => void;
unlockEffects: (effectIds: string[]) => void; unlockEffects: (effectIds: string[]) => void;
processEquipmentCraftingTick: () => { completed: boolean; logMessage?: string }; processEquipmentCraftingTick: () => { completed: boolean; logMessage?: string };
resetCrafting: () => void;
} }
export type CraftingStore = CraftingState & CraftingActions; export type CraftingStore = CraftingState & CraftingActions;
+1 -7
View File
@@ -95,14 +95,8 @@ export const useDisciplineStore = create<DisciplineStore>()(
if (nonPaused >= s.concurrentLimit) return s; if (nonPaused >= s.concurrentLimit) return s;
if (!canProceedDiscipline(def, existing, gameState)) return s; if (!canProceedDiscipline(def, existing, gameState)) return s;
// Invoker disciplines require at least one signed guardian pact
if (def.attunement === 'invoker') {
const signedPacts = gameState?.signedPacts || [];
if (signedPacts.length === 0) return s;
}
// Check discipline prerequisites (requires field → discipline XP or mana type unlock) // Check discipline prerequisites (requires field → discipline XP or mana type unlock)
const prereqCheck = checkDisciplinePrerequisites(def, s.disciplines, ALL_DISCIPLINES, gameState?.elements); const prereqCheck = checkDisciplinePrerequisites(def, s.disciplines, ALL_DISCIPLINES, gameState?.elements, gameState?.signedPacts);
if (!prereqCheck.canProceed) return s; if (!prereqCheck.canProceed) return s;
// For conversion disciplines: gate on having all source mana types unlocked // For conversion disciplines: gate on having all source mana types unlocked
+6
View File
@@ -4,6 +4,9 @@ import { useUIStore } from './uiStore';
import { usePrestigeStore } from './prestigeStore'; import { usePrestigeStore } from './prestigeStore';
import { useManaStore } from './manaStore'; import { useManaStore } from './manaStore';
import { useCombatStore } from './combatStore'; import { useCombatStore } from './combatStore';
import { useDisciplineStore } from './discipline-slice';
import { useAttunementStore } from './attunementStore';
import { useCraftingStore } from './craftingStore';
import { computeDisciplineEffects } from '../effects/discipline-effects'; import { computeDisciplineEffects } from '../effects/discipline-effects';
// Exact localStorage keys matching each store's persist config `name` // Exact localStorage keys matching each store's persist config `name`
@@ -32,6 +35,9 @@ export const createResetGame = (set: (state: Partial<GameCoordinatorState>) => v
usePrestigeStore.getState().resetPrestige(); usePrestigeStore.getState().resetPrestige();
useManaStore.getState().resetMana({}); useManaStore.getState().resetMana({});
useCombatStore.getState().resetCombat(startFloor); useCombatStore.getState().resetCombat(startFloor);
useDisciplineStore.getState().resetDisciplines();
useAttunementStore.getState().resetAttunements();
useCraftingStore.getState().resetCrafting();
set({ set({
...initialState, ...initialState,
+4 -1
View File
@@ -117,6 +117,7 @@ export function checkDisciplinePrerequisites(
allDisciplines: Record<string, DisciplineState>, allDisciplines: Record<string, DisciplineState>,
allDefinitions: DisciplineDefinition[], allDefinitions: DisciplineDefinition[],
elements?: Record<string, { unlocked: boolean }>, elements?: Record<string, { unlocked: boolean }>,
signedPacts?: number[],
): { canProceed: boolean; missingPrereqs: string[] } { ): { canProceed: boolean; missingPrereqs: string[] } {
if (!discipline.requires || discipline.requires.length === 0) { if (!discipline.requires || discipline.requires.length === 0) {
return { canProceed: true, missingPrereqs: [] }; return { canProceed: true, missingPrereqs: [] };
@@ -127,7 +128,9 @@ export function checkDisciplinePrerequisites(
for (const reqId of discipline.requires) { for (const reqId of discipline.requires) {
// Special case: 'signed_pact' requires at least one guardian pact // Special case: 'signed_pact' requires at least one guardian pact
if (reqId === 'signed_pact') { if (reqId === 'signed_pact') {
missingPrereqs.push('Signed guardian pact'); if (!signedPacts || signedPacts.length === 0) {
missingPrereqs.push('Signed guardian pact');
}
continue; continue;
} }