diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index b21437a..4bba053 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,8 +1,8 @@ # Circular Dependencies -Generated: 2026-05-19T12:44:32.003Z +Generated: 2026-05-19T13:55:24.489Z Found: 3 circular chain(s) — these MUST be fixed before modifying involved files. -1. Processed 121 files (1.2s) (4 warnings) +1. Processed 121 files (1.3s) (4 warnings) 2. 1) data/equipment/index.ts > data/equipment/utils.ts 3. 2) data/golems/index.ts > data/golems/utils.ts diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 9507e54..e463c78 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-05-19T12:44:30.633Z", + "generated": "2026-05-19T13:55:23.066Z", "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/docs/project-structure.txt b/docs/project-structure.txt index 4297d75..5c2a7d6 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -106,6 +106,8 @@ Mana-Loop/ │ │ │ │ │ └── StudyStatsSection.tsx │ │ │ │ ├── AchievementsTab.tsx │ │ │ │ ├── ActivityLog.tsx +│ │ │ │ ├── AttunementsTab.test.ts +│ │ │ │ ├── AttunementsTab.tsx │ │ │ │ ├── DebugTab.test.ts │ │ │ │ ├── DebugTab.tsx │ │ │ │ ├── DisciplinesTab.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index 0837bf3..737ce99 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -46,6 +46,7 @@ const SpellsTab = lazy(() => import('@/components/game/tabs').then(module => ({ const StatsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.StatsTab }))); const DebugTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.DebugTab }))); const AchievementsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.AchievementsTab }))); +const AttunementsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.AttunementsTab }))); const TabLoadingFallback = () =>
Loading...
; @@ -236,6 +237,7 @@ export default function ManaLoopGame() { 📚 Disciplines 📖 Grimoire 🐛 Debug + ⚗️ Attunements 🏆 Achievements @@ -275,6 +277,14 @@ export default function ManaLoopGame() { + + attunements tab failed to load.}> + }> + + + + + achievements tab failed to load.}> }> diff --git a/src/components/game/tabs/AttunementsTab.test.ts b/src/components/game/tabs/AttunementsTab.test.ts new file mode 100644 index 0000000..50e903b --- /dev/null +++ b/src/components/game/tabs/AttunementsTab.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi } from 'vitest'; + +// ─── Test: AttunementsTab barrel export ─────────────────────────────────────── + +describe('AttunementsTab module structure', () => { + it('exports AttunementsTab from barrel index', async () => { + const mod = await import('./AttunementsTab'); + expect(mod.AttunementsTab).toBeDefined(); + expect(typeof mod.AttunementsTab).toBe('function'); + }); + + it('AttunementsTab has correct displayName', async () => { + const { AttunementsTab } = await import('./AttunementsTab'); + expect(AttunementsTab.displayName).toBe('AttunementsTab'); + }); +}); + +// ─── Test: Barrel export includes AttunementsTab ────────────────────────────── + +describe('Tab barrel export', () => { + it('includes AttunementsTab in the tabs index', async () => { + const mod = await import('@/components/game/tabs'); + expect(mod.AttunementsTab).toBeDefined(); + expect(typeof mod.AttunementsTab).toBe('function'); + }); +}); + +// ─── Test: Attunement data integrity ────────────────────────────────────────── + +describe('Attunement data', () => { + it('all attunements have required fields', async () => { + const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements'); + for (const [id, def] of Object.entries(ATTUNEMENTS_DEF)) { + expect(def.id).toBe(id); + expect(def.name).toBeTruthy(); + expect(def.desc).toBeTruthy(); + expect(def.slot).toBeTruthy(); + expect(def.icon).toBeTruthy(); + expect(def.color).toBeTruthy(); + expect(def.rawManaRegen).toBeGreaterThanOrEqual(0); + expect(def.conversionRate).toBeGreaterThanOrEqual(0); + expect(def.capabilities.length).toBeGreaterThan(0); + expect(def.skillCategories.length).toBeGreaterThan(0); + } + }); + + it('enchanter is unlocked by default', async () => { + const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements'); + expect(ATTUNEMENTS_DEF.enchanter.unlocked).toBe(true); + }); + + it('invoker and fabricator are locked by default', async () => { + const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements'); + expect(ATTUNEMENTS_DEF.invoker.unlocked).toBe(false); + expect(ATTUNEMENTS_DEF.fabricator.unlocked).toBe(false); + }); + + it('each attunement has a unique slot', async () => { + const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements'); + const slots = Object.values(ATTUNEMENTS_DEF).map((d) => d.slot); + const uniqueSlots = new Set(slots); + expect(uniqueSlots.size).toBe(slots.length); + }); +}); + +// ─── Test: XP curve ─────────────────────────────────────────────────────────── + +describe('Attunement XP curve', () => { + it('level 1 requires 0 XP', async () => { + const { getAttunementXPForLevel } = await import('@/lib/game/data/attunements'); + expect(getAttunementXPForLevel(1)).toBe(0); + }); + + it('level 2 requires 1000 XP', async () => { + const { getAttunementXPForLevel } = await import('@/lib/game/data/attunements'); + expect(getAttunementXPForLevel(2)).toBe(1000); + }); + + it('XP requirements increase with level', async () => { + const { getAttunementXPForLevel } = await import('@/lib/game/data/attunements'); + const xp2 = getAttunementXPForLevel(2); + const xp3 = getAttunementXPForLevel(3); + const xp4 = getAttunementXPForLevel(4); + expect(xp3).toBeGreaterThan(xp2); + expect(xp4).toBeGreaterThan(xp3); + }); + + it('MAX_ATTUNEMENT_LEVEL is 10', async () => { + const { MAX_ATTUNEMENT_LEVEL } = await import('@/lib/game/data/attunements'); + expect(MAX_ATTUNEMENT_LEVEL).toBe(10); + }); +}); + +// ─── Test: Attunement store interactions ────────────────────────────────────── + +describe('Attunement store interactions', () => { + it('addAttunementXP is callable', async () => { + const mockAddXP = await vi.fn(); + mockAddXP('enchanter', 100); + expect(mockAddXP).toHaveBeenCalledWith('enchanter', 100); + }); + + it('debugUnlockAttunement is callable', async () => { + const mockUnlock = await vi.fn(); + mockUnlock('invoker'); + expect(mockUnlock).toHaveBeenCalledWith('invoker'); + }); + + it('setAttunements is callable', async () => { + const mockSet = await vi.fn(); + mockSet({ enchanter: { id: 'enchanter', active: true, level: 1, experience: 0 } }); + expect(mockSet).toHaveBeenCalled(); + }); + + it('resetAttunements is callable', async () => { + const mockReset = await vi.fn(); + mockReset(); + expect(mockReset).toHaveBeenCalledTimes(1); + }); +}); + +// ─── Test: Slot name mapping ────────────────────────────────────────────────── + +describe('Attunement slot names', () => { + it('all slots used by attunements have display names', async () => { + const { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES } = await import('@/lib/game/data/attunements'); + for (const def of Object.values(ATTUNEMENTS_DEF)) { + expect(ATTUNEMENT_SLOT_NAMES[def.slot]).toBeDefined(); + expect(ATTUNEMENT_SLOT_NAMES[def.slot].length).toBeGreaterThan(0); + } + }); +}); + +// ─── Test: File size limit ──────────────────────────────────────────────────── + +describe('File size limits (400 lines max)', () => { + it('AttunementsTab.tsx is under 400 lines', async () => { + const fs = await import('fs'); + const path = await import('path'); + const filePath = path.join(__dirname, 'AttunementsTab.tsx'); + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n').length; + expect(lines).toBeLessThan(400); + }); +}); diff --git a/src/components/game/tabs/AttunementsTab.tsx b/src/components/game/tabs/AttunementsTab.tsx new file mode 100644 index 0000000..97c2813 --- /dev/null +++ b/src/components/game/tabs/AttunementsTab.tsx @@ -0,0 +1,221 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useAttunementStore } from '@/lib/game/stores'; +import { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL } from '@/lib/game/data/attunements'; +import type { AttunementDef, AttunementState } from '@/lib/game/types'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { SectionHeader } from '@/components/ui/section-header'; +import { DebugName } from '@/components/game/debug/debug-context'; +import { fmt } from '@/lib/game/stores'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function getXpForNextLevel(level: number): number { + if (level >= MAX_ATTUNEMENT_LEVEL) return 0; + return getAttunementXPForLevel(level + 1); +} + +function getXpProgress(state: AttunementState): number { + const nextXp = getXpForNextLevel(state.level); + if (nextXp <= 0) return 100; + return Math.min(100, Math.round((state.experience / nextXp) * 100)); +} + +function isAttunementUnlocked(id: string, attunements: Record): boolean { + return id in attunements; +} + +// ─── Attunement Card ───────────────────────────────────────────────────────── + +interface AttunementCardProps { + def: AttunementDef; + state?: AttunementState; +} + +function AttunementCard({ def, state }: AttunementCardProps) { + const unlocked = !!state; + const xpProgress = state ? getXpProgress(state) : 0; + const nextXp = state ? getXpForNextLevel(state.level) : 0; + + return ( + + + {/* Header */} +
+
+ {def.icon} +
+

{def.name}

+

+ {ATTUNEMENT_SLOT_NAMES[def.slot] ?? def.slot} +

+
+
+ {unlocked ? ( + + Lv.{state.level} + + ) : ( + + Locked + + )} +
+ + {/* Description */} +

{def.desc}

+ + {/* XP Progress (unlocked only) */} + {unlocked && state && ( +
+
+ XP Progress + + {fmt(state.experience)} / {fmt(nextXp)} + +
+ + {state.level >= MAX_ATTUNEMENT_LEVEL && ( +

Maximum level reached

+ )} +
+ )} + + {/* Unlock condition (locked only) */} + {!unlocked && def.unlockCondition && ( +
+ 🔒 {def.unlockCondition} +
+ )} + + {/* Details grid */} +
+
+ Mana Type +

+ {def.primaryManaType ?? 'None (pact-based)'} +

+
+
+ Raw Regen +

+{def.rawManaRegen}/hr

+
+ {def.conversionRate > 0 && ( +
+ Conversion +

{def.conversionRate}/hr

+
+ )} +
+ Status +

+ {state?.active ? 'Active' : unlocked ? 'Inactive' : 'Locked'} +

+
+
+ + {/* Capabilities */} +
+ Capabilities +
+ {def.capabilities.map((cap) => ( + + {cap} + + ))} +
+
+ + {/* Skill Categories */} +
+ Skill Categories +
+ {def.skillCategories.map((cat) => ( + + {cat} + + ))} +
+
+
+
+ ); +} + +// ─── Main Component ────────────────────────────────────────────────────────── + +export function AttunementsTab() { + const attunements = useAttunementStore((s) => s.attunements); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const allDefs = Object.values(ATTUNEMENTS_DEF); + const unlockedCount = allDefs.filter((d) => isAttunementUnlocked(d.id, attunements)).length; + + if (!mounted) { + return ( +
+ Loading attunements… +
+ ); + } + + return ( + +
+ {/* Summary header */} + + +
+
+

Attunements

+

+ Class-like abilities tied to body locations +

+
+
+
+ {unlockedCount} + + /{allDefs.length} + +
+

Unlocked

+
+
+
+
+ + {/* Attunement cards */} + +
+ {allDefs.map((def) => ( + + ))} +
+
+
+
+ ); +} + +AttunementsTab.displayName = 'AttunementsTab'; diff --git a/src/components/game/tabs/index.ts b/src/components/game/tabs/index.ts index aed8750..8d0730d 100644 --- a/src/components/game/tabs/index.ts +++ b/src/components/game/tabs/index.ts @@ -6,3 +6,4 @@ export { SpellsTab } from './SpellsTab'; export { StatsTab } from './StatsTab'; export { DebugTab } from './DebugTab'; export { AchievementsTab } from './AchievementsTab'; +export { AttunementsTab } from './AttunementsTab';