diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt
index e435034..3a859c3 100644
--- a/docs/circular-deps.txt
+++ b/docs/circular-deps.txt
@@ -1,5 +1,5 @@
# Circular Dependencies
-Generated: 2026-05-19T20:04:31.355Z
+Generated: 2026-05-19T20:26:04.052Z
Found: 3 circular chain(s) — these MUST be fixed before modifying involved files.
1. Processed 121 files (1.3s) (4 warnings)
diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json
index 4f54073..e79dbc5 100644
--- a/docs/dependency-graph.json
+++ b/docs/dependency-graph.json
@@ -1,6 +1,6 @@
{
"_meta": {
- "generated": "2026-05-19T20:04:29.897Z",
+ "generated": "2026-05-19T20:26:02.602Z",
"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 0c1ba03..43119e6 100644
--- a/docs/project-structure.txt
+++ b/docs/project-structure.txt
@@ -119,6 +119,8 @@ Mana-Loop/
│ │ │ │ ├── EquipmentTab.tsx
│ │ │ │ ├── GolemancyTab.test.ts
│ │ │ │ ├── GolemancyTab.tsx
+│ │ │ │ ├── GuardianPactsTab.test.ts
+│ │ │ │ ├── GuardianPactsTab.tsx
│ │ │ │ ├── PrestigeTab.test.ts
│ │ │ │ ├── PrestigeTab.tsx
│ │ │ │ ├── SpellsTab.tsx
diff --git a/src/app/page.tsx b/src/app/page.tsx
index d5a53bd..4f017e2 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -50,6 +50,7 @@ const AttunementsTab = lazy(() => import('@/components/game/tabs').then(module =
const PrestigeTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.PrestigeTab })));
const EquipmentTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.EquipmentTab })));
const GolemancyTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.GolemancyTab })));
+const GuardianPactsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.GuardianPactsTab })));
const TabLoadingFallback = () =>
Loading...
;
@@ -245,6 +246,7 @@ export default function ManaLoopGame() {
✨ Prestige
⚔️ Equipment
🗿 Golemancy
+ 📜 Pacts
@@ -322,6 +324,14 @@ export default function ManaLoopGame() {
+
+
+ pacts tab failed to load.}>
+ }>
+
+
+
+
diff --git a/src/components/game/tabs/GuardianPactsTab.test.ts b/src/components/game/tabs/GuardianPactsTab.test.ts
new file mode 100644
index 0000000..1f38076
--- /dev/null
+++ b/src/components/game/tabs/GuardianPactsTab.test.ts
@@ -0,0 +1,132 @@
+import { describe, it, expect } from 'vitest';
+
+// ─── Test: GuardianPactsTab barrel export ──────────────────────────────────────
+
+describe('GuardianPactsTab module structure', () => {
+ it('exports GuardianPactsTab from barrel index', async () => {
+ const mod = await import('./GuardianPactsTab');
+ expect(mod.GuardianPactsTab).toBeDefined();
+ expect(typeof mod.GuardianPactsTab).toBe('function');
+ });
+
+ it('GuardianPactsTab has correct displayName', async () => {
+ const { GuardianPactsTab } = await import('./GuardianPactsTab');
+ expect(GuardianPactsTab.displayName).toBe('GuardianPactsTab');
+ });
+});
+
+// ─── Test: Barrel export includes GuardianPactsTab ─────────────────────────────
+
+describe('Tab barrel export', () => {
+ it('includes GuardianPactsTab in the tabs index', async () => {
+ const mod = await import('@/components/game/tabs');
+ expect(mod.GuardianPactsTab).toBeDefined();
+ expect(typeof mod.GuardianPactsTab).toBe('function');
+ });
+});
+
+// ─── Test: Guardian data integrity ─────────────────────────────────────────────
+
+describe('Guardian data', () => {
+ it('all guardians have required fields', async () => {
+ const { GUARDIANS } = await import('@/lib/game/constants/guardians');
+ for (const [floor, def] of Object.entries(GUARDIANS)) {
+ expect(def.name).toBeTruthy();
+ expect(def.element).toBeTruthy();
+ expect(def.hp).toBeGreaterThan(0);
+ expect(def.power).toBeGreaterThan(0);
+ expect(def.boons.length).toBeGreaterThan(0);
+ expect(def.pactCost).toBeGreaterThan(0);
+ expect(def.pactTime).toBeGreaterThan(0);
+ expect(def.uniquePerk).toBeTruthy();
+ expect(def.signingCost).toBeTruthy();
+ expect(def.signingCost.mana).toBeGreaterThan(0);
+ expect(def.signingCost.time).toBeGreaterThan(0);
+ expect(def.unlocksMana.length).toBeGreaterThan(0);
+ expect(def.damageMultiplier).toBeGreaterThan(0);
+ expect(def.insightMultiplier).toBeGreaterThan(0);
+ }
+ });
+
+ it('guardians are defined at expected floors', async () => {
+ const { GUARDIANS } = await import('@/lib/game/constants/guardians');
+ const expectedFloors = [10, 20, 30, 40, 50, 60, 80, 90, 100];
+ for (const floor of expectedFloors) {
+ expect(GUARDIANS[floor]).toBeDefined();
+ }
+ });
+
+ it('guardian boons have valid types', async () => {
+ const validBoonTypes = [
+ 'maxMana', 'manaRegen', 'castingSpeed', 'elementalDamage', 'rawDamage',
+ 'critChance', 'critDamage', 'spellEfficiency', 'manaGain', 'insightGain',
+ 'studySpeed', 'prestigeInsight',
+ ];
+ const { GUARDIANS } = await import('@/lib/game/constants/guardians');
+ for (const def of Object.values(GUARDIANS)) {
+ for (const boon of def.boons) {
+ expect(validBoonTypes).toContain(boon.type);
+ expect(boon.value).toBeGreaterThan(0);
+ expect(boon.desc).toBeTruthy();
+ }
+ }
+ });
+});
+
+// ─── Test: Prestige store pact state ───────────────────────────────────────────
+
+describe('Prestige store pact state', () => {
+ it('has correct initial pact state shape', async () => {
+ const { usePrestigeStore } = await import('@/lib/game/stores/prestigeStore');
+ const state = usePrestigeStore.getState();
+ expect(Array.isArray(state.defeatedGuardians)).toBe(true);
+ expect(Array.isArray(state.signedPacts)).toBe(true);
+ expect(typeof state.pactSlots).toBe('number');
+ expect(state.pactSlots).toBeGreaterThanOrEqual(1);
+ expect(state.pactRitualFloor).toBeNull();
+ expect(state.pactRitualProgress).toBe(0);
+ });
+
+ it('startPactRitual is a function', async () => {
+ const { usePrestigeStore } = await import('@/lib/game/stores/prestigeStore');
+ const state = usePrestigeStore.getState();
+ expect(typeof state.startPactRitual).toBe('function');
+ });
+
+ it('cancelPactRitual is a function', async () => {
+ const { usePrestigeStore } = await import('@/lib/game/stores/prestigeStore');
+ const state = usePrestigeStore.getState();
+ expect(typeof state.cancelPactRitual).toBe('function');
+ });
+
+ it('completePactRitual is a function', async () => {
+ const { usePrestigeStore } = await import('@/lib/game/stores/prestigeStore');
+ const state = usePrestigeStore.getState();
+ expect(typeof state.completePactRitual).toBe('function');
+ });
+
+ it('defeatGuardian is a function', async () => {
+ const { usePrestigeStore } = await import('@/lib/game/stores/prestigeStore');
+ const state = usePrestigeStore.getState();
+ expect(typeof state.defeatGuardian).toBe('function');
+ });
+
+ it('removePact is a function', async () => {
+ const { usePrestigeStore } = await import('@/lib/game/stores/prestigeStore');
+ const state = usePrestigeStore.getState();
+ expect(typeof state.removePact).toBe('function');
+ });
+});
+
+// ─── Test: File size limit ─────────────────────────────────────────────────────
+
+describe('File size limits (400 lines max)', () => {
+ it('GuardianPactsTab.tsx is under 400 lines', async () => {
+ const fs = await import('fs');
+ const path = await import('path');
+ const filePath = path.join(__dirname, 'GuardianPactsTab.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/GuardianPactsTab.tsx b/src/components/game/tabs/GuardianPactsTab.tsx
new file mode 100644
index 0000000..b0b7e11
--- /dev/null
+++ b/src/components/game/tabs/GuardianPactsTab.tsx
@@ -0,0 +1,391 @@
+'use client';
+
+import React, { useState, useEffect, useMemo, useCallback } from 'react';
+import { useShallow } from 'zustand/react/shallow';
+import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
+import { useManaStore } from '@/lib/game/stores/manaStore';
+import { useUIStore } from '@/lib/game/stores/uiStore';
+import { GUARDIANS, ELEMENTS } from '@/lib/game/constants';
+import type { GuardianDef, GuardianBoon } from '@/lib/game/types';
+import { DebugName } from '@/components/game/debug/debug-context';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Shield, Swords, Clock, Sparkles, Check, Lock, ChevronRight } from 'lucide-react';
+import clsx from 'clsx';
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+type GuardianStatus = 'undefeated' | 'defeated' | 'signed';
+
+function getGuardianStatus(floor: number, defeated: number[], signed: number[]): GuardianStatus {
+ if (signed.includes(floor)) return 'signed';
+ if (defeated.includes(floor)) return 'defeated';
+ return 'undefeated';
+}
+
+function formatHours(hours: number): string {
+ return `${hours}h`;
+}
+
+// ─── Guardian Card ───────────────────────────────────────────────────────────
+
+interface GuardianCardProps {
+ floor: number;
+ guardian: GuardianDef;
+ status: GuardianStatus;
+ canAfford: boolean;
+ hasSlot: boolean;
+ isRitualActive: boolean;
+ ritualProgress: number;
+ onStartRitual: (floor: number) => void;
+}
+
+const GuardianCard: React.FC = React.memo(({
+ floor,
+ guardian,
+ status,
+ canAfford,
+ hasSlot,
+ isRitualActive,
+ ritualProgress,
+ onStartRitual,
+}) => {
+ const elemDef = ELEMENTS[guardian.element];
+ const elemColor = elemDef?.color ?? '#888';
+ const elemSym = elemDef?.sym ?? '';
+
+ const statusConfig: Record = {
+ undefeated: { label: 'Undefeated', color: 'text-gray-400', bg: 'bg-gray-800/50' },
+ defeated: { label: 'Pact Available', color: 'text-amber-400', bg: 'bg-amber-900/20' },
+ signed: { label: 'Pact Signed', color: 'text-green-400', bg: 'bg-green-900/20' },
+ };
+
+ const sc = statusConfig[status];
+ const ritualTime = guardian.pactTime;
+ const ritualComplete = ritualProgress >= ritualTime;
+
+ return (
+
+
+
+
+
+ {elemSym}
+ {guardian.name}
+
+
Floor {floor} · {elemDef?.name ?? guardian.element}
+
+
+ {sc.label}
+
+
+
+
+
+ {/* Stats */}
+
+
+
+ HP: {guardian.hp.toLocaleString()}
+
+
+
+ PWR: {guardian.power.toLocaleString()}
+
+
+
+ ARM: {Math.round((guardian.armor ?? 0) * 100)}%
+
+
+
+ {/* Boons */}
+
+
+ Boons
+
+
+ {guardian.boons.map((boon: GuardianBoon, i: number) => (
+
+ {boon.desc}
+
+ ))}
+
+
+
+ {/* Unique Perk */}
+
+ Perk: {guardian.uniquePerk}
+
+
+ {/* Pact Cost */}
+
+
+
+ {formatHours(guardian.pactTime)}
+
+
+ Cost: {guardian.pactCost.toLocaleString()} mana
+
+
+
+ {/* Ritual Progress */}
+ {isRitualActive && (
+
+
+ Ritual in progress…
+ {ritualProgress}/{ritualTime}h
+
+
+ {ritualComplete && (
+
+ Ritual complete — pact will be signed on next tick
+
+ )}
+
+ )}
+
+ {/* Action Button */}
+ {status === 'defeated' && !isRitualActive && (
+
+ )}
+
+
+ );
+});
+
+GuardianCard.displayName = 'GuardianCard';
+
+// ─── Floor Tier Groups ──────────────────────────────────────────────────────
+
+interface FloorTier {
+ label: string;
+ floors: number[];
+}
+
+function groupFloorsByTier(floors: number[]): FloorTier[] {
+ const tiers: FloorTier[] = [
+ { label: 'Early Spire (10–40)', floors: [] },
+ { label: 'Mid Spire (50–60)', floors: [] },
+ { label: 'Late Spire (80–100)', floors: [] },
+ ];
+ for (const f of floors) {
+ if (f <= 40) tiers[0].floors.push(f);
+ else if (f <= 60) tiers[1].floors.push(f);
+ else tiers[2].floors.push(f);
+ }
+ return tiers.filter(t => t.floors.length > 0);
+}
+
+// ─── Main Tab ────────────────────────────────────────────────────────────────
+
+export const GuardianPactsTab: React.FC = () => {
+ const [mounted, setMounted] = useState(false);
+ const [activeTier, setActiveTier] = useState('all');
+
+ const {
+ defeatedGuardians,
+ signedPacts,
+ pactSlots,
+ pactRitualFloor,
+ pactRitualProgress,
+ startPactRitual,
+ } = usePrestigeStore(useShallow(s => ({
+ defeatedGuardians: s.defeatedGuardians,
+ signedPacts: s.signedPacts,
+ pactSlots: s.pactSlots,
+ pactRitualFloor: s.pactRitualFloor,
+ pactRitualProgress: s.pactRitualProgress,
+ startPactRitual: s.startPactRitual,
+ })));
+
+ const rawMana = useManaStore(s => s.rawMana);
+ const addLog = useUIStore(s => s.addLog);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const guardianFloors = useMemo(
+ () => Object.keys(GUARDIANS).map(Number).sort((a, b) => a - b),
+ [],
+ );
+
+ const tiers = useMemo(() => groupFloorsByTier(guardianFloors), [guardianFloors]);
+
+ const filteredFloors = useMemo(() => {
+ if (activeTier === 'all') return guardianFloors;
+ const tier = tiers.find(t => t.label === activeTier);
+ return tier ? tier.floors : guardianFloors;
+ }, [activeTier, guardianFloors, tiers]);
+
+ const handleStartRitual = useCallback((floor: number) => {
+ const guardian = GUARDIANS[floor];
+ if (!guardian) return;
+
+ const success = startPactRitual(floor, rawMana);
+ if (success) {
+ addLog(`📜 Began pact ritual with ${guardian.name}…`);
+ } else {
+ addLog(`⚠️ Cannot start pact ritual with ${guardian.name}.`);
+ }
+ }, [startPactRitual, rawMana, addLog]);
+
+ // Cumulative boon summary from signed pacts
+ const cumulativeBoons = useMemo(() => {
+ const boonMap: Record = {};
+ for (const floor of signedPacts) {
+ const guardian = GUARDIANS[floor];
+ if (!guardian) continue;
+ for (const boon of guardian.boons) {
+ boonMap[boon.type] = (boonMap[boon.type] || 0) + boon.value;
+ }
+ }
+ return boonMap;
+ }, [signedPacts]);
+
+ if (!mounted) {
+ return (
+
+ Loading guardian pacts…
+
+ );
+ }
+
+ return (
+
+
+ {/* Header Summary */}
+
+
+
+
+
+ Pact Slots:
+ {signedPacts.length} / {pactSlots}
+
+
+
+ Signed:
+ {signedPacts.length}
+
+
+
+ Defeated:
+ {defeatedGuardians.length}
+
+
+
+ {/* Cumulative Boons */}
+ {signedPacts.length > 0 && (
+
+
Active Boon Effects:
+
+ {Object.entries(cumulativeBoons).map(([type, value]) => (
+
+ {type}: +{value}
+
+ ))}
+
+
+ )}
+
+
+
+ {/* Tier Filter */}
+
+
+ {tiers.map((tier) => (
+
+ ))}
+
+
+ {/* Guardian Cards */}
+
+
+ {filteredFloors.map((floor) => {
+ const guardian = GUARDIANS[floor];
+ if (!guardian) return null;
+ const status = getGuardianStatus(floor, defeatedGuardians, signedPacts);
+ const isRitualActive = pactRitualFloor === floor;
+ const hasSlot = signedPacts.length < pactSlots;
+ const canAfford = rawMana >= guardian.pactCost;
+
+ return (
+
+ );
+ })}
+
+
+
+
+ );
+};
+
+GuardianPactsTab.displayName = 'GuardianPactsTab';
diff --git a/src/components/game/tabs/index.ts b/src/components/game/tabs/index.ts
index c788694..e870f5b 100644
--- a/src/components/game/tabs/index.ts
+++ b/src/components/game/tabs/index.ts
@@ -10,3 +10,4 @@ export { AttunementsTab } from './AttunementsTab';
export { PrestigeTab } from './PrestigeTab';
export { EquipmentTab } from './EquipmentTab';
export { GolemancyTab } from './GolemancyTab';
+export { GuardianPactsTab } from './GuardianPactsTab';