diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt
index 1242411..e435034 100644
--- a/docs/circular-deps.txt
+++ b/docs/circular-deps.txt
@@ -1,8 +1,8 @@
# Circular Dependencies
-Generated: 2026-05-19T18:19:35.896Z
+Generated: 2026-05-19T20:04:31.355Z
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 dd23d7e..4f54073 100644
--- a/docs/dependency-graph.json
+++ b/docs/dependency-graph.json
@@ -1,6 +1,6 @@
{
"_meta": {
- "generated": "2026-05-19T18:19:34.562Z",
+ "generated": "2026-05-19T20:04:29.897Z",
"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 11b38db..0c1ba03 100644
--- a/docs/project-structure.txt
+++ b/docs/project-structure.txt
@@ -117,6 +117,8 @@ Mana-Loop/
│ │ │ │ ├── DisciplinesTab.tsx
│ │ │ │ ├── EquipmentTab.test.ts
│ │ │ │ ├── EquipmentTab.tsx
+│ │ │ │ ├── GolemancyTab.test.ts
+│ │ │ │ ├── GolemancyTab.tsx
│ │ │ │ ├── PrestigeTab.test.ts
│ │ │ │ ├── PrestigeTab.tsx
│ │ │ │ ├── SpellsTab.tsx
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 28f132a..d5a53bd 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -49,6 +49,7 @@ const AchievementsTab = lazy(() => import('@/components/game/tabs').then(module
const AttunementsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.AttunementsTab })));
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 TabLoadingFallback = () =>
Loading...
;
@@ -243,6 +244,7 @@ export default function ManaLoopGame() {
🏆 Achievements
✨ Prestige
⚔️ Equipment
+ 🗿 Golemancy
@@ -312,6 +314,14 @@ export default function ManaLoopGame() {
+
+
+ golemancy tab failed to load.}>
+ }>
+
+
+
+
diff --git a/src/components/game/tabs/GolemancyTab.test.ts b/src/components/game/tabs/GolemancyTab.test.ts
new file mode 100644
index 0000000..aa13157
--- /dev/null
+++ b/src/components/game/tabs/GolemancyTab.test.ts
@@ -0,0 +1,148 @@
+import { describe, it, expect } from 'vitest';
+
+// ─── Test: GolemancyTab barrel export ─────────────────────────────────────────
+
+describe('GolemancyTab module structure', () => {
+ it('exports GolemancyTab from barrel index', async () => {
+ const mod = await import('./GolemancyTab');
+ expect(mod.GolemancyTab).toBeDefined();
+ expect(typeof mod.GolemancyTab).toBe('function');
+ });
+
+ it('GolemancyTab has correct displayName', async () => {
+ const { GolemancyTab } = await import('./GolemancyTab');
+ expect(GolemancyTab.displayName).toBe('GolemancyTab');
+ });
+});
+
+// ─── Test: Barrel export includes GolemancyTab ────────────────────────────────
+
+describe('Tab barrel export', () => {
+ it('includes GolemancyTab in the tabs index', async () => {
+ const mod = await import('@/components/game/tabs');
+ expect(mod.GolemancyTab).toBeDefined();
+ expect(typeof mod.GolemancyTab).toBe('function');
+ });
+});
+
+// ─── Test: Golem data integrity ───────────────────────────────────────────────
+
+describe('Golem data', () => {
+ it('all golems have required fields', async () => {
+ const { GOLEMS_DEF } = await import('@/lib/game/data/golems');
+ for (const [id, def] of Object.entries(GOLEMS_DEF)) {
+ expect(def.id).toBe(id);
+ expect(def.name).toBeTruthy();
+ expect(def.description).toBeTruthy();
+ expect(def.baseManaType).toBeTruthy();
+ expect(def.summonCost.length).toBeGreaterThan(0);
+ expect(def.maintenanceCost.length).toBeGreaterThan(0);
+ expect(def.damage).toBeGreaterThan(0);
+ expect(def.attackSpeed).toBeGreaterThan(0);
+ expect(def.hp).toBeGreaterThan(0);
+ expect(def.armorPierce).toBeGreaterThanOrEqual(0);
+ expect(def.tier).toBeGreaterThanOrEqual(1);
+ expect(def.unlockCondition).toBeTruthy();
+ }
+ });
+
+ it('has golems across multiple tiers', async () => {
+ const { GOLEMS_DEF } = await import('@/lib/game/data/golems');
+ const tiers = new Set(Object.values(GOLEMS_DEF).map(g => g.tier));
+ expect(tiers.size).toBeGreaterThanOrEqual(3);
+ });
+
+ it('earthGolem is the only base tier golem', async () => {
+ const { GOLEMS_DEF } = await import('@/lib/game/data/golems');
+ const baseGolems = Object.values(GOLEMS_DEF).filter(g => g.tier === 1);
+ expect(baseGolems.length).toBe(1);
+ expect(baseGolems[0].id).toBe('earthGolem');
+ });
+
+ it('voidstoneGolem is the highest tier', async () => {
+ const { GOLEMS_DEF } = await import('@/lib/game/data/golems');
+ const voidstone = GOLEMS_DEF.voidstoneGolem;
+ expect(voidstone).toBeDefined();
+ expect(voidstone.tier).toBe(4);
+ });
+});
+
+// ─── Test: Golem utility functions ────────────────────────────────────────────
+
+describe('Golem utility functions', () => {
+ it('getGolemSlots returns 0 for fabricator level < 2', async () => {
+ const { getGolemSlots } = await import('@/lib/game/data/golems');
+ expect(getGolemSlots(0)).toBe(0);
+ expect(getGolemSlots(1)).toBe(0);
+ });
+
+ it('getGolemSlots scales with fabricator level', async () => {
+ const { getGolemSlots } = await import('@/lib/game/data/golems');
+ expect(getGolemSlots(2)).toBe(1);
+ expect(getGolemSlots(4)).toBe(2);
+ expect(getGolemSlots(10)).toBe(5);
+ });
+
+ it('isGolemUnlocked returns false for unknown golem', async () => {
+ const { isGolemUnlocked } = await import('@/lib/game/data/golems');
+ expect(isGolemUnlocked('nonexistent', {}, [])).toBe(false);
+ });
+
+ it('isGolemUnlocked checks attunement level', async () => {
+ const { isGolemUnlocked } = await import('@/lib/game/data/golems');
+ expect(isGolemUnlocked('earthGolem', { fabricator: { active: true, level: 1 } }, [])).toBe(false);
+ expect(isGolemUnlocked('earthGolem', { fabricator: { active: true, level: 2 } }, [])).toBe(true);
+ });
+
+ it('isGolemUnlocked checks mana unlocked', async () => {
+ const { isGolemUnlocked } = await import('@/lib/game/data/golems');
+ expect(isGolemUnlocked('steelGolem', {}, [])).toBe(false);
+ expect(isGolemUnlocked('steelGolem', {}, ['metal'])).toBe(true);
+ });
+
+ it('canAffordGolemSummon returns false for unknown golem', async () => {
+ const { canAffordGolemSummon } = await import('@/lib/game/data/golems');
+ expect(canAffordGolemSummon('nonexistent', 100, {})).toBe(false);
+ });
+
+ it('canAffordGolemSummon checks raw mana cost', async () => {
+ const { canAffordGolemSummon } = await import('@/lib/game/data/golems');
+ // earthGolem costs 10 earth
+ const elements = { earth: { current: 5, max: 100, unlocked: true } };
+ expect(canAffordGolemSummon('earthGolem', 0, elements)).toBe(false);
+ elements.earth.current = 10;
+ expect(canAffordGolemSummon('earthGolem', 0, elements)).toBe(true);
+ });
+});
+
+// ─── Test: Combat store golemancy state ───────────────────────────────────────
+
+describe('Combat store golemancy', () => {
+ it('toggleGolem is a function', async () => {
+ const { useCombatStore } = await import('@/lib/game/stores/combatStore');
+ const state = useCombatStore.getState();
+ expect(typeof state.toggleGolem).toBe('function');
+ });
+
+ it('golemancy state has correct shape', async () => {
+ const { useCombatStore } = await import('@/lib/game/stores/combatStore');
+ const state = useCombatStore.getState();
+ expect(state.golemancy).toBeDefined();
+ expect(Array.isArray(state.golemancy.enabledGolems)).toBe(true);
+ expect(Array.isArray(state.golemancy.summonedGolems)).toBe(true);
+ expect(typeof state.golemancy.lastSummonFloor).toBe('number');
+ });
+});
+
+// ─── Test: File size limit ────────────────────────────────────────────────────
+
+describe('File size limits (400 lines max)', () => {
+ it('GolemancyTab.tsx is under 400 lines', async () => {
+ const fs = await import('fs');
+ const path = await import('path');
+ const filePath = path.join(__dirname, 'GolemancyTab.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/GolemancyTab.tsx b/src/components/game/tabs/GolemancyTab.tsx
new file mode 100644
index 0000000..38f9fcc
--- /dev/null
+++ b/src/components/game/tabs/GolemancyTab.tsx
@@ -0,0 +1,339 @@
+'use client';
+
+import React, { useState, useEffect, useCallback, useMemo } from 'react';
+import { useShallow } from 'zustand/react/shallow';
+import { useCombatStore } from '@/lib/game/stores/combatStore';
+import { useAttunementStore } from '@/lib/game/stores/attunementStore';
+import { useManaStore } from '@/lib/game/stores/manaStore';
+import { GOLEMS_DEF, isGolemUnlocked, canAffordGolemSummon, getGolemSlots } from '@/lib/game/data/golems';
+import type { GolemDef } from '@/lib/game/data/golems';
+import { ELEMENTS } from '@/lib/game/constants/elements';
+import { DebugName } from '@/components/game/debug/debug-context';
+import { Badge } from '@/components/ui/badge';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import clsx from 'clsx';
+
+// ─── Tier configuration ──────────────────────────────────────────────────────
+
+interface TierConfig {
+ key: string;
+ label: string;
+ tier: number;
+}
+
+const TIERS: TierConfig[] = [
+ { key: 'base', label: 'Base', tier: 1 },
+ { key: 'elemental', label: 'Elemental', tier: 2 },
+ { key: 'hybrid', label: 'Hybrid', tier: 3 },
+];
+
+function getTierLabel(tier: number): string {
+ if (tier <= 1) return 'Base';
+ if (tier <= 2) return 'Elemental';
+ return 'Hybrid';
+}
+
+function getTierColor(tier: number): string {
+ if (tier <= 1) return 'bg-gray-600';
+ if (tier <= 2) return 'bg-blue-600';
+ if (tier <= 3) return 'bg-purple-600';
+ return 'bg-amber-500';
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function formatCost(cost: GolemDef['summonCost'][number]): string {
+ if (cost.type === 'raw') return `${cost.amount} raw`;
+ const elem = cost.element ? ELEMENTS[cost.element] : null;
+ return `${cost.amount} ${elem?.sym ?? ''} ${cost.element ?? ''}`.trim();
+}
+
+function formatUnlockCondition(golem: GolemDef): string {
+ const cond = golem.unlockCondition;
+ switch (cond.type) {
+ case 'attunement_level':
+ return `${cond.attunement} level ${cond.level}`;
+ case 'mana_unlocked': {
+ const elem = cond.manaType ? ELEMENTS[cond.manaType] : null;
+ return `Unlock ${elem?.sym ?? ''} ${cond.manaType ?? ''}`.trim();
+ }
+ case 'dual_attunement':
+ return `${cond.attunements?.join(' + ')} level ${cond.levels?.join('/')}`;
+ default:
+ return 'Unknown';
+ }
+}
+
+// ─── Golem Card ──────────────────────────────────────────────────────────────
+
+interface GolemCardProps {
+ golem: GolemDef;
+ unlocked: boolean;
+ enabled: boolean;
+ summoned: boolean;
+ canAfford: boolean;
+ onToggle: (id: string) => void;
+}
+
+const GolemCard: React.FC = React.memo(({
+ golem,
+ unlocked,
+ enabled,
+ summoned,
+ canAfford,
+ onToggle,
+}) => {
+ const elemColor = ELEMENTS[golem.baseManaType]?.color ?? '#888';
+ const elemSym = ELEMENTS[golem.baseManaType]?.sym ?? '';
+
+ return (
+
+ {/* Header */}
+
+
+
{golem.name}
+
{golem.description}
+
+
+
+ {elemSym}
+
+
+ T{golem.tier}
+
+
+
+
+ {/* Stats grid */}
+
+
+ DMG: {golem.damage}
+
+
+ SPD: {golem.attackSpeed}/h
+
+
+ HP: {golem.hp}
+
+
+ AP: {Math.round(golem.armorPierce * 100)}%
+
+ {golem.isAoe && (
+
+ AoE: {golem.aoeTargets} targets
+
+ )}
+
+
+ {/* Costs */}
+
+
+ Summon:{' '}
+ {golem.summonCost.map((c, i) => (
+ {formatCost(c)}{i < golem.summonCost.length - 1 ? ' + ' : ''}
+ ))}
+
+
+ Upkeep:{' '}
+ {golem.maintenanceCost.map((c, i) => (
+ {formatCost(c)}{i < golem.maintenanceCost.length - 1 ? ' + ' : ''}
+ ))}/tick
+
+
+
+ {/* Unlock requirement */}
+ {!unlocked && (
+
+ 🔒 Requires: {formatUnlockCondition(golem)}
+
+ )}
+
+ {/* Status + toggle */}
+
+
+ {summoned ? (
+ ● Summoned
+ ) : enabled ? (
+ ○ Queued
+ ) : (
+ — Idle
+ )}
+
+
+
+
+ );
+});
+
+GolemCard.displayName = 'GolemCard';
+
+// ─── Main Tab ────────────────────────────────────────────────────────────────
+
+export const GolemancyTab: React.FC = () => {
+ const [mounted, setMounted] = useState(false);
+ const [activeTier, setActiveTier] = useState('base');
+
+ const { golemancy, toggleGolem } = useCombatStore(useShallow(s => ({
+ golemancy: s.golemancy,
+ toggleGolem: s.toggleGolem,
+ })));
+ const attunements = useAttunementStore(s => s.attunements);
+ const { rawMana, elements } = useManaStore(useShallow(s => ({
+ rawMana: s.rawMana,
+ elements: s.elements,
+ })));
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ // Build attunement lookup for isGolemUnlocked
+ const attunementLookup = useMemo(() => {
+ const lookup: Record = {};
+ for (const [id, att] of Object.entries(attunements)) {
+ lookup[id] = { active: att.active, level: att.level };
+ }
+ return lookup;
+ }, [attunements]);
+
+ const unlockedElements = useMemo(
+ () => Object.entries(elements).filter(([, e]) => e.unlocked).map(([k]) => k),
+ [elements],
+ );
+
+ // Group golems by tier
+ const golemsByTier = useMemo(() => {
+ const groups: Record = { base: [], elemental: [], hybrid: [] };
+ for (const golem of Object.values(GOLEMS_DEF)) {
+ const label = getTierLabel(golem.tier);
+ const key = label.toLowerCase();
+ if (groups[key]) {
+ groups[key].push(golem);
+ } else {
+ // tier 4 golems go into hybrid
+ groups.hybrid.push(golem);
+ }
+ }
+ return groups;
+ }, []);
+
+ const handleToggle = useCallback((id: string) => {
+ toggleGolem(id);
+ }, [toggleGolem]);
+
+ // Golem slot info
+ const fabricatorLevel = attunements.fabricator?.level ?? 0;
+ const golemSlots = getGolemSlots(fabricatorLevel);
+ const enabledCount = golemancy.enabledGolems.length;
+
+ if (!mounted) {
+ return (
+
+ Loading golemancy…
+
+ );
+ }
+
+ const activeTierGolems = golemsByTier[activeTier] ?? [];
+
+ return (
+
+
+ {/* Header info */}
+
+
+ Configure your golem loadout. Enabled golems are automatically summoned
+ when entering the spire if you can afford the cost.
+
+
+
+ Slots: {enabledCount}/{golemSlots > 0 ? golemSlots : '—'}
+
+
+ Summoned: {golemancy.summonedGolems.length}
+
+
+
+
+ {/* Tier tabs */}
+
+ {TIERS.map((tier) => {
+ const count = golemsByTier[tier.key]?.length ?? 0;
+ return (
+
+ );
+ })}
+
+
+ {/* Golem cards */}
+
+ {activeTierGolems.length === 0 ? (
+
+ No golems in this tier.
+
+ ) : (
+
+ {activeTierGolems.map((golem) => {
+ const unlocked = isGolemUnlocked(golem.id, attunementLookup, unlockedElements);
+ const enabled = golemancy.enabledGolems.includes(golem.id);
+ const summoned = golemancy.summonedGolems.some(g => g.golemId === golem.id);
+ const canAfford = canAffordGolemSummon(golem.id, rawMana, elements);
+ return (
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+};
+
+GolemancyTab.displayName = 'GolemancyTab';
diff --git a/src/components/game/tabs/index.ts b/src/components/game/tabs/index.ts
index 65209ce..c788694 100644
--- a/src/components/game/tabs/index.ts
+++ b/src/components/game/tabs/index.ts
@@ -9,3 +9,4 @@ export { AchievementsTab } from './AchievementsTab';
export { AttunementsTab } from './AttunementsTab';
export { PrestigeTab } from './PrestigeTab';
export { EquipmentTab } from './EquipmentTab';
+export { GolemancyTab } from './GolemancyTab';