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';