fix: resolve all Priority 5 CRASH/BLOCKER issues (#51-#57)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s

- #51: Fix broken import path @/types/disciplines → @/lib/game/types/disciplines
- #52: Fix canProceedDiscipline() called with wrong arguments in discipline-slice.ts
- #53: Add local useState for activeAttunement tab filtering in DisciplinesTab
- #54: Make canProceedDiscipline() defensive when gameState is undefined
- #57: Remove stale CraftingTab export from game/index.ts
- Refactored DisciplineCard to use Zustand selector subscriptions properly
- Added DisciplinePerk type import to discipline-math.ts
This commit is contained in:
2026-05-18 17:51:06 +02:00
parent 92238e4dd8
commit ff3a268358
6 changed files with 169 additions and 213 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Circular Dependencies # Circular Dependencies
Generated: 2026-05-18T13:07:38.493Z Generated: 2026-05-18T13:14:04.833Z
Found: 1 circular chain(s) — these MUST be fixed before modifying involved files. Found: 1 circular chain(s) — these MUST be fixed before modifying involved files.
1. Processed 123 files (1.2s) (29 warnings) 1. Processed 123 files (1.2s) (29 warnings)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_meta": {
"generated": "2026-05-18T13:07:36.959Z", "generated": "2026-05-18T13:14:03.496Z",
"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."
}, },
-1
View File
@@ -2,7 +2,6 @@
// Re-exports all game tab components for cleaner imports // Re-exports all game tab components for cleaner imports
// Tab components // Tab components
export { CraftingTab } from './crafting';
export { SpellsTab } from './SpellsTab'; export { SpellsTab } from './SpellsTab';
export { StatsTab } from './StatsTab'; export { StatsTab } from './StatsTab';
+155 -203
View File
@@ -1,181 +1,149 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice'; import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
import type { DisciplineDefinition } from '@/types/disciplines'; import type { DisciplineDefinition } from '@/lib/game/types/disciplines';
import { baseDisciplines } from '@/lib/game/data/disciplines/base'; import { baseDisciplines } from '@/lib/game/data/disciplines/base';
import { enchanterDisciplines } from '@/lib/game/data/disciplines/enchanter'; import { enchanterDisciplines } from '@/lib/game/data/disciplines/enchanter';
import { fabricatorDisciplines } from '@/lib/game/data/disciplines/fabricator'; import { fabricatorDisciplines } from '@/lib/game/data/disciplines/fabricator';
import { invokerDisciplines } from '@/lib/game/data/disciplines/invoker'; import { invokerDisciplines } from '@/lib/game/data/disciplines/invoker';
import { calculateStatBonus, calculateManaDrain } from '@/lib/game/utils/discipline-math'; import { calculateStatBonus, calculateManaDrain } from '@/lib/game/utils/discipline-math';
import { useRef } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
interface AttunementTab {
key: string;
label: string;
items: DisciplineDefinition[];
}
const ATTUNEMENT_TABS: AttunementTab[] = [
{ key: 'base', label: 'Base', items: baseDisciplines },
{ key: 'enchanter', label: 'Enchanter', items: enchanterDisciplines },
{ key: 'fabricator', label: 'Fabricator', items: fabricatorDisciplines },
{ key: 'invoker', label: 'Invoker', items: invokerDisciplines },
];
interface DisciplineCardProps {
id: string;
name: string;
description: string;
perkThresholds?: number[];
perkValues?: number[];
perkTypes?: string[];
statBonus: string;
baseValue: number;
drainBase: number;
difficultyFactor: number;
scalingFactor: number;
}
const DisciplineCard: React.FC<DisciplineCardProps> = ({
id,
name,
description,
perkThresholds,
perkValues,
perkTypes,
statBonus,
baseValue,
drainBase,
difficultyFactor,
scalingFactor,
}) => {
const activeIds = useDisciplineStore((s) => s.activeIds);
const concurrentLimit = useDisciplineStore((s) => s.concurrentLimit);
const currentDisc = useDisciplineStore((s) => s.disciplines[id] ?? { xp: 0, paused: true });
const displayXp = currentDisc.xp;
const progressPercent = Math.min(displayXp / Math.max(1, concurrentLimit * 100), 100);
const isPaused = currentDisc.paused;
const activeStatBonus = calculateStatBonus(baseValue, displayXp, scalingFactor);
const estimatedDrain = calculateManaDrain(drainBase, displayXp, difficultyFactor);
const unlockedPerks = perkTypes?.reduce<string[]>((acc, typ, idx) => {
const threshold = perkThresholds?.[idx];
if (threshold === undefined) return acc;
if (typ === 'once' || typ === 'infinite') {
if (displayXp >= threshold) acc.push(`${typ}-${idx}`);
} else if (typ === 'capped') {
const interval = perkValues?.[idx] ?? 1;
const tier = Math.max(0, Math.floor((displayXp - threshold) / interval) + 1);
if (tier > 0) acc.push(`${typ}-${idx}`);
}
return acc;
}, []);
const toggleAction = () => {
if (isPaused) {
useDisciplineStore.getState().activate(id);
} else {
useDisciplineStore.getState().deactivate(id);
}
};
return (
<div key={id} className="border rounded-lg p-4 shadow-sm space-y-3">
<h3 className="text-lg font-medium">{name}</h3>
<p className="text-sm text-gray-400">{description}</p>
<div className="flex items-center gap-2">
<span className="text-xs font-mono whitespace-nowrap">{Math.round(progressPercent)}%</span>
<div className="flex-1 bg-gray-200 rounded-full overflow-hidden h-3">
<div
className={`transition-all duration-300 ${activeStatBonus > 0 ? 'bg-green-500' : 'bg-red-500'}`}
style={{ width: `${Math.round(progressPercent)}%` }}
/>
</div>
</div>
<div className="text-sm text-gray-400">
<strong>Drain:</strong> {estimatedDrain.toFixed(1)} {' '}
<strong>XP:</strong> {displayXp}
</div>
<div className="mt-2 text-sm">
<strong>Stat Bonus:</strong> {activeStatBonus.toFixed(2)} on {statBonus}
</div>
<div className="mt-2">
<strong>Perks:</strong>
<ul className="mt-1 list-disc list-inside space-y-1 text-xs">
{unlockedPerks && unlockedPerks.length > 0 ? (
unlockedPerks.map((p) => (
<li key={p} className="text-green-500">{p.replace(/-([0-9]+)$/, ' $1')}</li>
))
) : (
<li className="text-gray-400">locked</li>
)}
</ul>
</div>
<div className="mt-4 flex justify-end">
<button
onClick={toggleAction}
className={clsx(
'rounded px-3 py-1 text-sm font-medium',
isPaused
? 'bg-yellow-600 text-white hover:bg-yellow-500'
: 'bg-blue-600 text-white hover:bg-blue-500',
)}
>
{isPaused ? 'Activate' : 'Pause'}
</button>
</div>
</div>
);
};
export const DisciplinesTab: React.FC = () => { export const DisciplinesTab: React.FC = () => {
const store = useDisciplineStore(); const { activeIds, concurrentLimit } = useDisciplineStore();
const { disciplines, activeIds, concurrentLimit } = store;
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [activeAttunement, setActiveAttunement] = useState<string>('base');
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
const allDisciplines: DisciplineDefinition[] = [
...baseDisciplines,
...enchanterDisciplines,
...fabricatorDisciplines,
...invokerDisciplines,
];
// Group disciplines by attunement for tab rendering
const attunementTabs: {
label: string;
items: DisciplineDefinition[];
}[] = [
{ label: 'Base', items: baseDisciplines },
{ label: 'Enchanter', items: enchanterDisciplines },
{ label: 'Fabricator', items: fabricatorDisciplines },
{ label: 'Invoker', items: invokerDisciplines },
];
// Helper to render a single discipline card
const DisciplineCard: React.FC<{
id: string;
name: string;
description: string;
xp: number;
perkThresholds?: number[];
perkValues?: number[];
perkTypes?: string[];
statBonus: string;
baseValue: number;
drainBase: number;
difficultyFactor: number;
scalingFactor: number;
}> = ({
id,
name,
description,
xp,
perkThresholds,
perkValues,
perkTypes,
statBonus,
baseValue,
drainBase,
difficultyFactor,
scalingFactor,
}) => {
if (!mounted) return null;
const state = useDisciplineStore().getState();
const currentDisc = state.disciplines[id] ?? { xp: 0, paused: true };
const isActive = activeIds.includes(id);
const canActivate = concurrentLimit > activeIds.filter(a => state.disciplines[a]?.paused !== true).length;
// Calculate displayed stats
const displayXp = currentDisc.xp;
const progressPercent = Math.min(displayXp / Math.max(1, (concurrentLimit * 100) ?? 1), 100);
const isPaused = currentDisc.paused;
const hasPendingPerk = perkThresholds?.some((t, i) => displayXp >= t && perkTypes?.[i] !== 'capped');
const activeStatBonus = calculateStatBonus(
parseInt(baseValue) || 0,
displayXp,
scalingFactor
);
// Simple visual for drain per tick
const estimatedDrain = calculateManaDrain(
drainBase,
displayXp,
difficultyFactor
);
// Determine unlocked perks
const unlockedPerks = perkTypes?.reduce<string[]>((acc, typ, idx) => {
if (typ === 'once' || typ === 'infinite') {
if (displayXp >= perkThresholds?.[idx] ?? 0) {
acc.push(`${typ}-${idx}`);
}
} else if (typ === 'capped') {
const tier = Math.max(0, Math.floor((displayXp - perkThresholds?.[idx] ?? 0) / perkValues?.[idx] ?? 1) + 1);
if (tier > 0) acc.push(`${typ}-${idx}`);
}
return acc;
}, []);
// Helper to decide button action
const toggleAction = () => {
if (isPaused) {
// Resume activate
const storeDispatch = useDisciplineStore().getState().activate as any;
storeDispatch(id);
} else {
// Pause deactivate
const storeDispatch = useDisciplineStore().getState().deactivate as any;
storeDispatch(id);
}
};
return (
<div key={id} className="border rounded-lg p-4 shadow-sm space-y-3">
<h3 className="text-lg font-medium">{name}</h3>
<p className="text-sm text-gray-400">{description}</p>
<div className="flex items-center gap-2">
<span className="text-xs font-mono whitespace-nowrap">{Math.round(progressPercent)}%</span>
<div className="flex-1 bg-gray-200 rounded-full overflow-hidden h-3">
<div
className={`bg-blue-500 transition-all duration-300 ${
activeStatBonus > 0 ? 'bg-green-500' : 'bg-red-500'
}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
<div className="text-sm text-gray-400">
<strong>Drain:</strong> {estimatedDrain.toFixed(1)}{' '}
<strong>XP:</strong> {displayXp}
</div>
{/* Bonus display */}
<div className="mt-2 text-sm">
<strong>Stat Bonus:</strong> {activeStatBonus.toFixed(2)} on {statBonus}
</div>
{/* Perks */}
<div className="mt-2">
<strong>Perks:</strong>
<ul className="mt-1 list-disc list-inside space-y-1 text-xs">
{unlockedPerks && unlockedPerks.length > 0 ? (
unlockedPerks.map((p) => (
<li key={p} className="text-green-500">{p.replace(/-([0-9]+)$/, ' $1')}</li>
))
) : (
<li className="text-gray-400">locked</li>
)}
</ul>
</div>
{/* Action button */}
<div className="mt-4 flex justify-end">
<button
onClick={toggleAction}
className={clsx(
'rounded px-3 py-1 text-sm font-medium',
isPaused
? 'bg-yellow-600 text-white hover:bg-yellow-500'
: 'bg-blue-600 text-white hover:bg-blue-500',
)}
>
{isPaused ? 'Activate' : 'Pause'}
</button>
</div>
</div>
);
};
if (!mounted) { if (!mounted) {
return ( return (
<div className="flex items-center justify-center p-8 text-gray-500"> <div className="flex items-center justify-center p-8 text-gray-500">
@@ -184,25 +152,21 @@ export const DisciplinesTab: React.FC = () => {
); );
} }
const activeTab = ATTUNEMENT_TABS.find((t) => t.key === activeAttunement);
return ( return (
<div className="mt-6"> <div className="mt-6">
{/* Tab bar */} {/* Tab bar */}
<div className="flex gap-2 mb-4"> <div className="flex gap-2 mb-4">
{attunementTabs.map((tab) => { {ATTUNEMENT_TABS.map((tab) => {
const isActiveTab = store const isActiveTab = activeAttunement === tab.key;
.getState()
.activeAttunement === tab.label.toLowerCase();
return ( return (
<button <button
key={tab.label} key={tab.key}
onClick={() => { onClick={() => setActiveAttunement(tab.key)}
// Here you could dispatch an action to switch tabs if needed
// For simplicity, we just render the tabs
console.log(`Switch to ${tab.label}`);
}}
className={clsx('rounded px-3 py-1', { className={clsx('rounded px-3 py-1', {
'bg-blue-600 text-white': tab.label === 'Base', // highlight first for demo 'bg-blue-600 text-white': isActiveTab,
'text-gray-600': tab.label !== 'Base', 'text-gray-600': !isActiveTab,
})} })}
> >
{tab.label} {tab.label}
@@ -211,36 +175,24 @@ export const DisciplinesTab: React.FC = () => {
})} })}
</div> </div>
{/* Discipline cards */} {/* Discipline cards — only render active tab */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{attunementTabs {activeTab?.items.map((disc) => (
.map((tab) => <DisciplineCard
tab.items.map((disc) => { key={disc.id}
const state = useDisciplineStore().getState(); id={disc.id}
const discState = state.disciplines[disc.id] ?? { xp: 0, paused: true }; name={disc.name}
const isActive = activeIds.includes(disc.id); description={disc.description}
const isVisible = attunementTabs.find((t) => t.label === tab.label)?.items === tab.items; perkThresholds={disc.perks?.map((p) => p.threshold)}
return isVisible && ( perkValues={disc.perks?.map((p) => p.value)}
<DisciplineCard perkTypes={disc.perks?.map((p) => p.type)}
key={disc.id} statBonus={disc.statBonus.stat}
id={disc.id} baseValue={disc.statBonus.baseValue}
name={disc.name} drainBase={disc.drainBase}
description={disc.description} difficultyFactor={disc.difficultyFactor}
xp={discState.xp} scalingFactor={disc.scalingFactor}
perkThresholds={disc.perks?.map((p) => p.threshold)} />
perkValues={disc.perks?.map((p) => p.value)} ))}
perkTypes={disc.perks?.map((p) => p.type)}
statBonus={disc.statBonus}
baseValue={disc.statBonus.baseValue?.toString() ?? '0'}
drainBase={disc.drainBase}
difficultyFactor={disc.difficultyFactor}
scalingFactor={disc.scalingFactor}
/>
);
})
)
.flat()
}
</div> </div>
{/* Summary info */} {/* Summary info */}
@@ -250,4 +202,4 @@ export const DisciplinesTab: React.FC = () => {
</div> </div>
</div> </div>
); );
}; };
+2 -1
View File
@@ -58,7 +58,8 @@ export const useDisciplineStore = create<DisciplineStore>()(
return d && !d.paused; return d && !d.paused;
}).length; }).length;
if (nonPaused >= s.concurrentLimit) return s; if (nonPaused >= s.concurrentLimit) return s;
if (!canProceedDiscipline(id, gameState)) return s; const discState = s.disciplines[id];
if (!canProceedDiscipline(def, discState, gameState)) return s;
const existing = s.disciplines[id] || { id, xp: 0, paused: false }; const existing = s.disciplines[id] || { id, xp: 0, paused: false };
return { return {
+10 -6
View File
@@ -1,7 +1,7 @@
// ─── Discipline Math Utilities ──────────────────────────────────────────────── // ─── Discipline Math Utilities ────────────────────────────────────────────────
// Continuous scaling formulas for Active Disciplines // Continuous scaling formulas for Active Disciplines
import type { DisciplineState, DisciplineDefinition } from '../types/disciplines'; import type { DisciplineState, DisciplineDefinition, DisciplinePerk } from '../types/disciplines';
/** /**
* Calculate continuous stat bonus from discipline XP * Calculate continuous stat bonus from discipline XP
@@ -64,21 +64,25 @@ export function canActivateDiscipline(
*/ */
export function canProceedDiscipline( export function canProceedDiscipline(
discipline: DisciplineDefinition, discipline: DisciplineDefinition,
disciplineState: DisciplineState, disciplineState: DisciplineState | undefined,
gameState: { elements?: Record<string, any>; rawMana?: number } gameState?: { elements?: Record<string, any>; rawMana?: number }
): boolean { ): boolean {
if (!disciplineState) return true;
if (disciplineState.paused) return false; if (disciplineState.paused) return false;
// If no game state provided, allow activation (optimistic)
if (!gameState) return true;
const drain = calculateManaDrain( const drain = calculateManaDrain(
discipline.drainBase, discipline.drainBase,
disciplineState.xp, disciplineState.xp,
discipline.difficultyFactor discipline.difficultyFactor
); );
if (discipline.manaType === 'raw') { if (discipline.manaType === 'raw') {
return (gameState.rawMana || 0) >= drain; return (gameState.rawMana || 0) >= drain;
} }
const element = gameState.elements?.[discipline.manaType]; const element = gameState.elements?.[discipline.manaType];
return element && element.current >= drain; return element && element.current >= drain;
} }