diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt
index 3a859c3..ba2b365 100644
--- a/docs/circular-deps.txt
+++ b/docs/circular-deps.txt
@@ -1,8 +1,8 @@
# Circular Dependencies
-Generated: 2026-05-19T20:26:04.052Z
+Generated: 2026-05-19T20:37:58.097Z
Found: 3 circular chain(s) — these MUST be fixed before modifying involved files.
-1. Processed 121 files (1.3s) (4 warnings)
+1. Processed 121 files (1.2s) (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 e79dbc5..dc26a7f 100644
--- a/docs/dependency-graph.json
+++ b/docs/dependency-graph.json
@@ -1,6 +1,6 @@
{
"_meta": {
- "generated": "2026-05-19T20:26:02.602Z",
+ "generated": "2026-05-19T20:37:56.730Z",
"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 43119e6..08bfea2 100644
--- a/docs/project-structure.txt
+++ b/docs/project-structure.txt
@@ -124,6 +124,8 @@ Mana-Loop/
│ │ │ │ ├── PrestigeTab.test.ts
│ │ │ │ ├── PrestigeTab.tsx
│ │ │ │ ├── SpellsTab.tsx
+│ │ │ │ ├── SpireSummaryTab.test.ts
+│ │ │ │ ├── SpireSummaryTab.tsx
│ │ │ │ ├── StatsTab.tsx
│ │ │ │ └── index.ts
│ │ │ ├── ActionButtons.tsx
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 4f017e2..e5418f1 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -51,6 +51,7 @@ const PrestigeTab = lazy(() => import('@/components/game/tabs').then(module => (
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 SpireSummaryTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.SpireSummaryTab })));
const TabLoadingFallback = () =>
Loading...
;
@@ -247,6 +248,7 @@ export default function ManaLoopGame() {
⚔️ Equipment
🗿 Golemancy
📜 Pacts
+ 🏔️ Spire
@@ -332,6 +334,14 @@ export default function ManaLoopGame() {
+
+
+ spire tab failed to load.}>
+ }>
+
+
+
+
diff --git a/src/components/game/tabs/SpireSummaryTab.test.ts b/src/components/game/tabs/SpireSummaryTab.test.ts
new file mode 100644
index 0000000..dcf1453
--- /dev/null
+++ b/src/components/game/tabs/SpireSummaryTab.test.ts
@@ -0,0 +1,107 @@
+import { describe, it, expect } from 'vitest';
+
+// ─── Test: SpireSummaryTab barrel export ───────────────────────────────────────
+
+describe('SpireSummaryTab module structure', () => {
+ it('exports SpireSummaryTab from its module', async () => {
+ const mod = await import('./SpireSummaryTab');
+ expect(mod.SpireSummaryTab).toBeDefined();
+ expect(typeof mod.SpireSummaryTab).toBe('function');
+ });
+
+ it('SpireSummaryTab has correct displayName', async () => {
+ const { SpireSummaryTab } = await import('./SpireSummaryTab');
+ expect(SpireSummaryTab.displayName).toBe('SpireSummaryTab');
+ });
+});
+
+// ─── Test: Barrel export includes SpireSummaryTab ──────────────────────────────
+
+describe('Tab barrel export', () => {
+ it('includes SpireSummaryTab in the tabs index', async () => {
+ const mod = await import('@/components/game/tabs');
+ expect(mod.SpireSummaryTab).toBeDefined();
+ expect(typeof mod.SpireSummaryTab).toBe('function');
+ });
+});
+
+// ─── Test: Guardian data ───────────────────────────────────────────────────────
+
+describe('Guardian constants', () => {
+ it('has 9 guardians defined', async () => {
+ const { GUARDIANS } = await import('@/lib/game/constants');
+ expect(Object.keys(GUARDIANS).length).toBe(9);
+ });
+
+ it('guardians are at expected floors', async () => {
+ const { GUARDIANS } = await import('@/lib/game/constants');
+ const expectedFloors = [10, 20, 30, 40, 50, 60, 80, 90, 100];
+ for (const floor of expectedFloors) {
+ expect(GUARDIANS[floor]).toBeDefined();
+ }
+ });
+
+ it('all guardians have required fields', async () => {
+ const { GUARDIANS } = await import('@/lib/game/constants');
+ for (const [, def] of Object.entries(GUARDIANS)) {
+ expect(def.name).toBeTruthy();
+ expect(def.element).toBeTruthy();
+ expect(def.hp).toBeGreaterThan(0);
+ expect(def.color).toBeTruthy();
+ expect(def.boons).toBeInstanceOf(Array);
+ expect(def.boons.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('all guardians have unique elements', async () => {
+ const { GUARDIANS } = await import('@/lib/game/constants');
+ const elements = Object.values(GUARDIANS).map((g) => g.element);
+ const uniqueElements = new Set(elements);
+ expect(uniqueElements.size).toBe(elements.length);
+ });
+});
+
+// ─── Test: Combat store spire fields ───────────────────────────────────────────
+
+describe('Combat store spire state', () => {
+ it('useCombatStore is importable', async () => {
+ const mod = await import('@/lib/game/stores');
+ expect(mod.useCombatStore).toBeDefined();
+ expect(typeof mod.useCombatStore).toBe('function');
+ });
+});
+
+// ─── Test: Floor element cycle ─────────────────────────────────────────────────
+
+describe('Floor element cycle', () => {
+ it('FLOOR_ELEM_CYCLE has 7 elements', async () => {
+ const { FLOOR_ELEM_CYCLE } = await import('@/lib/game/constants');
+ expect(FLOOR_ELEM_CYCLE.length).toBe(7);
+ });
+
+ it('element opposites define expected pairs', async () => {
+ const { ELEMENT_OPPOSITES } = await import('@/lib/game/constants');
+ // Core pairs are symmetric
+ expect(ELEMENT_OPPOSITES['fire']).toBe('water');
+ expect(ELEMENT_OPPOSITES['water']).toBe('fire');
+ expect(ELEMENT_OPPOSITES['air']).toBe('earth');
+ expect(ELEMENT_OPPOSITES['earth']).toBe('air');
+ expect(ELEMENT_OPPOSITES['light']).toBe('dark');
+ expect(ELEMENT_OPPOSITES['dark']).toBe('light');
+ // Lightning has an asymmetric opposite (grounding)
+ expect(ELEMENT_OPPOSITES['lightning']).toBe('earth');
+ });
+});
+
+// ─── Test: File size limit ─────────────────────────────────────────────────────
+
+describe('File size limits (400 lines max)', () => {
+ it('SpireSummaryTab.tsx is under 400 lines', async () => {
+ const fs = await import('fs');
+ const path = await import('path');
+ const filePath = path.join(__dirname, 'SpireSummaryTab.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/SpireSummaryTab.tsx b/src/components/game/tabs/SpireSummaryTab.tsx
new file mode 100644
index 0000000..4ef9708
--- /dev/null
+++ b/src/components/game/tabs/SpireSummaryTab.tsx
@@ -0,0 +1,356 @@
+'use client';
+
+import { useState, useEffect, useMemo } from 'react';
+import { useShallow } from 'zustand/react/shallow';
+import { useCombatStore, usePrestigeStore, fmt } from '@/lib/game/stores';
+import { GUARDIANS, ELEMENT_OPPOSITES, FLOOR_ELEM_CYCLE } from '@/lib/game/constants';
+import { Card, CardContent } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { SectionHeader } from '@/components/ui/section-header';
+import { DebugName } from '@/components/game/debug/debug-context';
+import { Mountain } from 'lucide-react';
+
+// ─── Guardian Data ────────────────────────────────────────────────────────────
+
+const GUARDIAN_FLOORS = Object.keys(GUARDIANS)
+ .map(Number)
+ .sort((a, b) => a - b);
+
+// ─── Helper: Get Counter Element ─────────────────────────────────────────────
+
+function getCounterElement(element: string): string | null {
+ return ELEMENT_OPPOSITES[element] || null;
+}
+
+function getElementColor(element: string): string {
+ const colors: Record = {
+ fire: '#FF6B35',
+ water: '#4ECDC4',
+ air: '#00D4FF',
+ earth: '#F4A261',
+ light: '#FFD700',
+ dark: '#9B59B6',
+ death: '#778CA3',
+ void: '#4A235A',
+ stellar: '#F0E68C',
+ };
+ return colors[element] || '#9CA3AF';
+}
+
+// ─── Sub-component: Floor Progress Bar ────────────────────────────────────────
+
+function FloorProgressBar({ maxFloor, clearedFloors }: { maxFloor: number; clearedFloors: Record }) {
+ const totalFloors = Math.min(maxFloor, 100);
+ const clearedSet = new Set(Object.entries(clearedFloors).filter(([, v]) => v).map(([k]) => Number(k)));
+
+ // Group floors into rows of 10 for display
+ const rows: number[][] = [];
+ for (let i = 0; i < totalFloors; i += 10) {
+ rows.push(Array.from({ length: 10 }, (_, j) => i + j + 1).filter((f) => f <= totalFloors));
+ }
+
+ return (
+
+ {rows.reverse().map((row) => (
+
+ {row.map((floor) => {
+ const isCleared = clearedSet.has(floor);
+ const isGuardian = !!GUARDIANS[floor];
+ const isCurrent = floor === maxFloor;
+
+ let bgClass = 'bg-gray-800';
+ if (isCleared) bgClass = 'bg-emerald-600/60';
+ else if (isCurrent) bgClass = 'bg-amber-600/60';
+
+ const borderClass = isGuardian
+ ? 'border-amber-500'
+ : isCurrent
+ ? 'border-amber-400'
+ : 'border-gray-700';
+
+ return (
+
+ {floor}
+
+ );
+ })}
+
+ ))}
+
+
+ );
+}
+
+// ─── Main Component ───────────────────────────────────────────────────────────
+
+export function SpireSummaryTab() {
+ const [mounted, setMounted] = useState(false);
+
+ const {
+ currentFloor,
+ maxFloorReached,
+ clearedFloors,
+ enterSpireMode,
+ } = useCombatStore(useShallow((s) => ({
+ currentFloor: s.currentFloor,
+ maxFloorReached: s.maxFloorReached,
+ clearedFloors: s.clearedFloors,
+ enterSpireMode: s.enterSpireMode,
+ })));
+
+ const { insight } = usePrestigeStore(useShallow((s) => ({
+ insight: s.insight,
+ })));
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ // Derived data
+ const defeatedGuardians = useMemo(() => {
+ return GUARDIAN_FLOORS.filter((floor) => clearedFloors[floor]);
+ }, [clearedFloors]);
+
+ const nextGuardian = useMemo(() => {
+ return GUARDIAN_FLOORS.find((floor) => !clearedFloors[floor]) || null;
+ }, [clearedFloors]);
+
+ const nextGuardianData = nextGuardian ? GUARDIANS[nextGuardian] : null;
+
+ const counterElement = nextGuardianData ? getCounterElement(nextGuardianData.element) : null;
+ const nextFloorElement = nextGuardian ? FLOOR_ELEM_CYCLE[(nextGuardian - 1) % FLOOR_ELEM_CYCLE.length] : null;
+
+ const totalFloorsCleared = useMemo(() => {
+ return Object.values(clearedFloors).filter(Boolean).length;
+ }, [clearedFloors]);
+
+ if (!mounted) {
+ return (
+
+ Loading spire data…
+
+ );
+ }
+
+ return (
+
+
+ {/* ── Top Stats Row ─────────────────────────────────────────────── */}
+
+
+
+
+
{maxFloorReached}
+
Max Floor Reached
+
+
+
{totalFloorsCleared}
+
Floors Cleared
+
+
+
{defeatedGuardians.length}
+
Guardians Defeated
+
+
+
{fmt(insight)}
+
Insight Earned
+
+
+
+
+
+ {/* ── Climb the Spire Button ────────────────────────────────────── */}
+
+
+
+
+ {/* ── Next Guardian + Preparation ───────────────────────────────── */}
+ {nextGuardianData && nextGuardian && (
+
+
+
+
+
+ {nextGuardian}
+
+
+
{nextGuardianData.name}
+
+
+ {nextGuardianData.element}
+
+ HP: {fmt(nextGuardianData.hp)}
+ {nextGuardianData.armor && (
+
+ Armor: {Math.round(nextGuardianData.armor * 100)}%
+
+ )}
+
+
+
+
+ {/* Preparation recommendations */}
+
+
Recommended Preparation:
+
+ {counterElement && (
+
+ ⚡
+
+ Use {counterElement} spells for super effective damage (+50%)
+
+
+ )}
+ {nextFloorElement && (
+
+ 🔄
+
+ Floor element: {nextFloorElement}
+
+
+ )}
+ {nextGuardianData.armor && nextGuardianData.armor > 0.15 && (
+
+ 🛡️
+ High armor — consider armor-piercing or raw damage spells
+
+ )}
+
+ 💡
+ Ensure mana pools are full before attempting
+
+
+
+
+
+ )}
+
+ {/* ── All Guardians List ────────────────────────────────────────── */}
+
+
+
+
+ {GUARDIAN_FLOORS.map((floor) => {
+ const guardian = GUARDIANS[floor];
+ const isDefeated = clearedFloors[floor];
+
+ return (
+
+
+
+ {floor}
+
+
+
+ {guardian.name}
+
+
+
+ {guardian.element}
+
+ HP: {fmt(guardian.hp)}
+
+
+
+
+ {isDefeated ? (
+
+ ✓ Defeated
+
+ ) : (
+
+ Undefeated
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+ {/* ── Floor Progress Map ────────────────────────────────────────── */}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+SpireSummaryTab.displayName = 'SpireSummaryTab';
diff --git a/src/components/game/tabs/index.ts b/src/components/game/tabs/index.ts
index e870f5b..7d745ba 100644
--- a/src/components/game/tabs/index.ts
+++ b/src/components/game/tabs/index.ts
@@ -11,3 +11,4 @@ export { PrestigeTab } from './PrestigeTab';
export { EquipmentTab } from './EquipmentTab';
export { GolemancyTab } from './GolemancyTab';
export { GuardianPactsTab } from './GuardianPactsTab';
+export { SpireSummaryTab } from './SpireSummaryTab';