From a33e9429fe8f47ba4f5cbd07cab75147cacb900b Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Fri, 29 May 2026 14:10:04 +0200 Subject: [PATCH] fix: resolve critical bugs - disciplines, debug reset, floating point, spire loop 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. --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 2 +- src/app/page.tsx | 6 ---- src/components/game/debug/GameStateDebug.tsx | 2 +- src/components/game/tabs/DisciplineCard.tsx | 32 +++++++++++++++---- src/components/game/tabs/DisciplinesTab.tsx | 7 ++-- .../tabs/SpireCombatPage/SpireCombatPage.tsx | 3 +- .../tabs/StatsTab/ElementStatsSection.tsx | 4 +-- .../game/tabs/StatsTab/ManaStatsSection.tsx | 2 +- src/lib/game/stores/crafting-initial-state.ts | 32 +++++++++++++++++++ src/lib/game/stores/craftingStore.ts | 31 ++++-------------- src/lib/game/stores/craftingStore.types.ts | 1 + src/lib/game/stores/discipline-slice.ts | 8 +---- src/lib/game/stores/gameActions.ts | 6 ++++ src/lib/game/utils/discipline-math.ts | 5 ++- 15 files changed, 89 insertions(+), 54 deletions(-) diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 273b465..befb730 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,4 +1,4 @@ # Circular Dependencies -Generated: 2026-05-28T19:01:33.787Z +Generated: 2026-05-28T19:24:11.154Z No circular dependencies found. ✅ diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index ef02588..86e7b2b 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_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.", "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/src/app/page.tsx b/src/app/page.tsx index d524175..4be3002 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -171,12 +171,6 @@ export default function ManaLoopGame() { const [mounted, setMounted] = useState(false); 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) { return ; } diff --git a/src/components/game/debug/GameStateDebug.tsx b/src/components/game/debug/GameStateDebug.tsx index 70827e4..9fb4a08 100644 --- a/src/components/game/debug/GameStateDebug.tsx +++ b/src/components/game/debug/GameStateDebug.tsx @@ -165,7 +165,7 @@ function TimeControlSection({ day, hour, paused, onSetDay, onTogglePause }: {
- Current: Day {day}, Hour {hour} + Current: Day {day}, Hour {Number.isFinite(hour) ? hour.toFixed(2) : '0.00'}
diff --git a/src/components/game/tabs/DisciplineCard.tsx b/src/components/game/tabs/DisciplineCard.tsx index 19160ab..e5caa6a 100644 --- a/src/components/game/tabs/DisciplineCard.tsx +++ b/src/components/game/tabs/DisciplineCard.tsx @@ -13,6 +13,7 @@ export interface DisciplineCardProps { definition: DisciplineDefinition; xp: number; paused: boolean; + activeIds: string[]; concurrentLimit: number; isLocked: boolean; missingPrereqs: string[]; @@ -23,7 +24,7 @@ export interface DisciplineCardProps { // ─── Component ──────────────────────────────────────────────────────────────── export const DisciplineCard: React.FC = ({ - definition, xp, paused: isPaused, concurrentLimit, + definition, xp, paused: isPaused, activeIds, concurrentLimit, isLocked, missingPrereqs, missingSourceMana, onToggle, }) => { const { @@ -41,6 +42,12 @@ export const DisciplineCard: React.FC = ({ const manaColor = elementDef?.color ?? '#888888'; const manaIcon = elementDef?.sym ?? '✦'; 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 statBonusLabel = statBonus.label; @@ -140,9 +147,16 @@ export const DisciplineCard: React.FC = ({
{/* Lock Reasons */} - {effectiveIsLocked && (missingPrereqs.length > 0 || missingSourceMana.length > 0) && ( + {(effectiveIsLocked || atConcurrentLimit) && (missingPrereqs.length > 0 || missingSourceMana.length > 0 || atConcurrentLimit) && (
- Requires: {[...missingPrereqs, ...missingSourceMana].join(', ')} + {atConcurrentLimit &&
At limit: {activeIds.length}/{concurrentLimit} disciplines active
} + {missingPrereqs.length > 0 &&
Requires: {missingPrereqs.join(', ')}
} + {missingSourceMana.length > 0 &&
Missing mana: {missingSourceMana.join(', ')}
} +
+ )} + {atConcurrentLimit && missingPrereqs.length === 0 && missingSourceMana.length === 0 && ( +
+ At limit: {activeIds.length}/{concurrentLimit} disciplines active. Gain XP to unlock more slots.
)} @@ -150,17 +164,23 @@ export const DisciplineCard: React.FC = ({
diff --git a/src/components/game/tabs/DisciplinesTab.tsx b/src/components/game/tabs/DisciplinesTab.tsx index 00eee79..4927a7d 100644 --- a/src/components/game/tabs/DisciplinesTab.tsx +++ b/src/components/game/tabs/DisciplinesTab.tsx @@ -41,6 +41,7 @@ const ATTUNEMENT_TABS: AttunementTab[] = [ interface CardWrapperProps { disc: DisciplineDefinition; disciplines: Record; + activeIds: string[]; concurrentLimit: number; elements: ReturnType['elements']; signedPacts: ReturnType['signedPacts']; @@ -48,15 +49,16 @@ interface CardWrapperProps { } const CardWrapper: React.FC = ({ - disc, disciplines, concurrentLimit, elements, onToggle, + disc, disciplines, activeIds, concurrentLimit, elements, signedPacts, onToggle, }) => { 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 ( { key={disc.id} disc={disc} disciplines={disciplines} + activeIds={activeIds} concurrentLimit={concurrentLimit} elements={elements} signedPacts={signedPacts} diff --git a/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx b/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx index 6efb128..00a54d4 100644 --- a/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx +++ b/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx @@ -113,8 +113,7 @@ export function SpireCombatPage() { setRoomsCleared(0); const newRoom = generateSpireFloorState(currentFloor, 0, totalRooms); setCurrentRoom(newRoom); - setAction('climb'); - }, [currentFloor, totalRooms, setCurrentRoom, setAction]); + }, [currentFloor, totalRooms, setCurrentRoom]); const _handleRoomCleared = () => { const nextRoomIndex = roomsCleared + 1; diff --git a/src/components/game/tabs/StatsTab/ElementStatsSection.tsx b/src/components/game/tabs/StatsTab/ElementStatsSection.tsx index 9fa0a86..1c867fa 100644 --- a/src/components/game/tabs/StatsTab/ElementStatsSection.tsx +++ b/src/components/game/tabs/StatsTab/ElementStatsSection.tsx @@ -5,7 +5,7 @@ import { DebugName } from '@/components/game/debug/debug-context'; import { Separator } from '@/components/ui/separator'; import { FlaskConical } from 'lucide-react'; 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'; interface ElementStatsSectionProps { @@ -54,7 +54,7 @@ export function ElementStatsSection({ elemMax }: ElementStatsSectionProps) { return (
{def?.sym}
-
{state.current}/{state.max}
+
{fmtDec(state.current, 2)}/{fmtDec(state.max, 0)}
); })} diff --git a/src/components/game/tabs/StatsTab/ManaStatsSection.tsx b/src/components/game/tabs/StatsTab/ManaStatsSection.tsx index 7d130ea..f8737f9 100644 --- a/src/components/game/tabs/StatsTab/ManaStatsSection.tsx +++ b/src/components/game/tabs/StatsTab/ManaStatsSection.tsx @@ -90,7 +90,7 @@ export function ManaStatsSection({ stats, elemMax }: ManaStatsSectionProps) { 2/hr
- Base Regen: + Computed Base Regen: {fmtDec(baseRegen, 2)}/hr
{upgradeEffects.regenBonus > 0 && ( diff --git a/src/lib/game/stores/crafting-initial-state.ts b/src/lib/game/stores/crafting-initial-state.ts index 0d946d8..1942e4a 100644 --- a/src/lib/game/stores/crafting-initial-state.ts +++ b/src/lib/game/stores/crafting-initial-state.ts @@ -3,6 +3,38 @@ import * as CraftingUtils from '../crafting-utils'; 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() { const staffId = CraftingUtils.generateInstanceId(); diff --git a/src/lib/game/stores/craftingStore.ts b/src/lib/game/stores/craftingStore.ts index 4fa1e05..2fef9fc 100644 --- a/src/lib/game/stores/craftingStore.ts +++ b/src/lib/game/stores/craftingStore.ts @@ -15,7 +15,7 @@ import * as CraftingEquipment from '../crafting-equipment'; import { equipItem as equipItemAction, unequipItem as unequipItemAction } from '../crafting-actions/equipment-actions'; import { ErrorCode } from '../utils/result'; import { createSafeStorage } from '../utils/safe-persist'; -import { createInitialEquipmentInstances } from './crafting-initial-state'; +import { createDefaultCraftingState } from './crafting-initial-state'; import { getFabricatorRecipe, deductFabricatorMana, @@ -28,30 +28,9 @@ import { processEquipmentCraftingTick } from './crafting-equipment-tick'; export const useCraftingStore = create()( persist( (set, get) => { - const initial = createInitialEquipmentInstances(); + const defaultState = createDefaultCraftingState(); return { - // Initial state - 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, + ...defaultState, // Actions setDesignProgress: (progress) => set({ designProgress: progress }), @@ -369,6 +348,10 @@ export const useCraftingStore = create()( const state = get(); return processEquipmentCraftingTick(state, set as unknown as (partial: Partial) => void); }, + + resetCrafting: () => { + set(createDefaultCraftingState()); + }, }; }, { diff --git a/src/lib/game/stores/craftingStore.types.ts b/src/lib/game/stores/craftingStore.types.ts index c55bfc0..8e9569e 100644 --- a/src/lib/game/stores/craftingStore.types.ts +++ b/src/lib/game/stores/craftingStore.types.ts @@ -73,6 +73,7 @@ export interface CraftingActions { clearLastError: () => void; unlockEffects: (effectIds: string[]) => void; processEquipmentCraftingTick: () => { completed: boolean; logMessage?: string }; + resetCrafting: () => void; } export type CraftingStore = CraftingState & CraftingActions; diff --git a/src/lib/game/stores/discipline-slice.ts b/src/lib/game/stores/discipline-slice.ts index 3cf4e7f..685acd7 100644 --- a/src/lib/game/stores/discipline-slice.ts +++ b/src/lib/game/stores/discipline-slice.ts @@ -95,14 +95,8 @@ export const useDisciplineStore = create()( if (nonPaused >= s.concurrentLimit) 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) - 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; // For conversion disciplines: gate on having all source mana types unlocked diff --git a/src/lib/game/stores/gameActions.ts b/src/lib/game/stores/gameActions.ts index 13e09bb..fb4d9e4 100644 --- a/src/lib/game/stores/gameActions.ts +++ b/src/lib/game/stores/gameActions.ts @@ -4,6 +4,9 @@ import { useUIStore } from './uiStore'; import { usePrestigeStore } from './prestigeStore'; import { useManaStore } from './manaStore'; import { useCombatStore } from './combatStore'; +import { useDisciplineStore } from './discipline-slice'; +import { useAttunementStore } from './attunementStore'; +import { useCraftingStore } from './craftingStore'; import { computeDisciplineEffects } from '../effects/discipline-effects'; // Exact localStorage keys matching each store's persist config `name` @@ -32,6 +35,9 @@ export const createResetGame = (set: (state: Partial) => v usePrestigeStore.getState().resetPrestige(); useManaStore.getState().resetMana({}); useCombatStore.getState().resetCombat(startFloor); + useDisciplineStore.getState().resetDisciplines(); + useAttunementStore.getState().resetAttunements(); + useCraftingStore.getState().resetCrafting(); set({ ...initialState, diff --git a/src/lib/game/utils/discipline-math.ts b/src/lib/game/utils/discipline-math.ts index 92bead7..ef9de35 100644 --- a/src/lib/game/utils/discipline-math.ts +++ b/src/lib/game/utils/discipline-math.ts @@ -117,6 +117,7 @@ export function checkDisciplinePrerequisites( allDisciplines: Record, allDefinitions: DisciplineDefinition[], elements?: Record, + signedPacts?: number[], ): { canProceed: boolean; missingPrereqs: string[] } { if (!discipline.requires || discipline.requires.length === 0) { return { canProceed: true, missingPrereqs: [] }; @@ -127,7 +128,9 @@ export function checkDisciplinePrerequisites( for (const reqId of discipline.requires) { // Special case: 'signed_pact' requires at least one guardian pact if (reqId === 'signed_pact') { - missingPrereqs.push('Signed guardian pact'); + if (!signedPacts || signedPacts.length === 0) { + missingPrereqs.push('Signed guardian pact'); + } continue; }