feat(golemancy): Phase 1 - Component-based construction system data definitions
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s

- Add new golem component types (Core, Frame, MindCircuit, Enchantment)
- Create 4 Core tiers, 7 Frames, 4 Mind Circuits, 8 Enchantments
- Rewrite golem utils for component-based stat computation
- Update GolemancyState with new fields (golemDesigns, golemLoadout, activeGolems)
- Update combat store, actions, and pipelines for new golem system
- Rewrite GolemancyTab with component selection UI
- Update fabricator discipline perks for new system
- Add comprehensive tests for component registries and utilities
- All files under 400 lines, all 743 tests passing
This commit is contained in:
2026-06-06 16:50:26 +02:00
parent c40e4ee940
commit 4b7aa82953
43 changed files with 2763 additions and 944 deletions
@@ -0,0 +1,83 @@
'use client';
import React from 'react';
import { useCombatStore } from '@/lib/game/stores/combatStore';
import { CORES } from '@/lib/game/data/golems';
import type { RuntimeActiveGolem } from './types';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
// ─── Active Golem Card ───────────────────────────────────────────────────────
interface ActiveGolemCardProps {
golem: RuntimeActiveGolem;
}
const ActiveGolemCard: React.FC<ActiveGolemCardProps> = React.memo(({ golem }) => {
const loadoutEntry = useCombatStore(s =>
s.golemancy.golemLoadout.find(e => e.designId === golem.designId),
);
const name = loadoutEntry?.design.name ?? golem.designId;
const core = loadoutEntry ? CORES[loadoutEntry.design.coreId] : null;
const maxMana = core?.manaCapacity ?? 100;
return (
<div className="rounded-lg border border-green-700/50 bg-green-900/10 p-3 space-y-2">
<div className="flex items-center justify-between">
<h4 className="font-semibold text-sm text-green-300">{name}</h4>
<Badge className="text-[10px] px-1.5 py-0 bg-green-800 text-green-200">
Active
</Badge>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div className="text-gray-400">
<span className="text-gray-500">Summoned Floor:</span> {golem.summonedFloor}
</div>
<div className="text-gray-400">
<span className="text-gray-500">Rooms Left:</span> {golem.roomsRemaining}
</div>
<div className="text-gray-400">
<span className="text-gray-500">Mana:</span>{' '}
<span className={golem.currentMana < maxMana * 0.2 ? 'text-red-400' : 'text-blue-400'}>
{Math.round(golem.currentMana)}/{maxMana}
</span>
</div>
<div className="text-gray-400">
<span className="text-gray-500">Atk Progress:</span>{' '}
{Math.round(golem.attackProgress * 100)}%
</div>
</div>
</div>
);
});
ActiveGolemCard.displayName = 'ActiveGolemCard';
// ─── Props ───────────────────────────────────────────────────────────────────
export interface ActiveGolemsPanelProps {
activeGolems: RuntimeActiveGolem[];
}
// ─── Component ───────────────────────────────────────────────────────────────
export const ActiveGolemsPanel: React.FC<ActiveGolemsPanelProps> = ({ activeGolems }) => {
return (
<ScrollArea className="h-[560px] rounded border border-gray-700 p-3">
{activeGolems.length === 0 ? (
<div className="text-center text-gray-500 py-8">
<p>No active golems in combat.</p>
<p className="text-xs mt-1">Enable golem designs in the Loadout and enter the spire.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{activeGolems.map(golem => (
<ActiveGolemCard key={golem.designId} golem={golem} />
))}
</div>
)}
</ScrollArea>
);
};
ActiveGolemsPanel.displayName = 'ActiveGolemsPanel';
@@ -0,0 +1,160 @@
'use client';
import React from 'react';
import {
CORES, ALL_CORES, FRAMES, ALL_FRAMES,
MIND_CIRCUITS, ALL_MIND_CIRCUITS,
GOLEM_ENCHANTMENTS, ALL_GOLEM_ENCHANTMENTS,
} from '@/lib/game/data/golems';
import type {
CoreDefinition, FrameDefinition, MindCircuitDefinition,
GolemEnchantmentDefinition, ComputedGolemStats,
} from '@/lib/game/data/golems/types';
import { ELEMENTS } from '@/lib/game/constants/elements';
import { ScrollArea } from '@/components/ui/scroll-area';
import clsx from 'clsx';
import { formatRequirement } from './golemancy-utils';
import { ComponentSelector, StatsPreview } from './GolemancySharedComponents';
export interface GolemDesignBuilderProps {
selectedCoreId: string | null;
selectedFrameId: string | null;
selectedCircuitId: string | null;
selectedEnchantmentIds: string[];
designName: string;
selectedManaTypes: string[];
unlockedCoreIds: Set<string>;
unlockedFrameIds: Set<string>;
unlockedCircuitIds: Set<string>;
computedStats: ComputedGolemStats | null;
affordability: { canAfford: boolean; missing: string };
enchantmentCapacity: number;
usedEnchantmentCapacity: number;
golemSlots: number;
enabledCount: number;
onSelectCore: (id: string) => void;
onSelectFrame: (id: string) => void;
onSelectCircuit: (id: string) => void;
onToggleEnchantment: (id: string) => void;
onDesignNameChange: (name: string) => void;
onSaveDesign: () => void;
}
export const GolemDesignBuilder: React.FC<GolemDesignBuilderProps> = ({
selectedCoreId, selectedFrameId, selectedCircuitId,
selectedEnchantmentIds, designName, selectedManaTypes,
unlockedCoreIds, unlockedFrameIds, unlockedCircuitIds,
computedStats, affordability, enchantmentCapacity, usedEnchantmentCapacity,
onSelectCore, onSelectFrame, onSelectCircuit, onToggleEnchantment,
onDesignNameChange, onSaveDesign,
}) => {
const canSaveDesign = selectedCoreId && selectedFrameId && selectedCircuitId && affordability.canAfford;
const selectedCore = selectedCoreId ? CORES[selectedCoreId] ?? null : null;
const selectedFrame = selectedFrameId ? FRAMES[selectedFrameId] ?? null : null;
const selectedCircuit = selectedCircuitId ? MIND_CIRCUITS[selectedCircuitId] ?? null : null;
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<ScrollArea className="h-[560px] rounded border border-gray-700 p-3">
<div className="space-y-4">
<div className="space-y-1">
<label className="text-xs text-gray-400">Design Name</label>
<input type="text" value={designName} onChange={e => onDesignNameChange(e.target.value)}
placeholder="Enter a name for this golem..."
className="w-full rounded bg-gray-800 border border-gray-700 px-3 py-1.5 text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500" />
</div>
<ComponentSelector label="1. Core (Power Source)" items={ALL_CORES} selectedId={selectedCoreId} unlockedIds={unlockedCoreIds} onSelect={onSelectCore}
renderItem={(core: CoreDefinition, unlocked: boolean) => (
<div>
<div className="flex items-center justify-between">
<span className="font-medium">{core.name}</span>
<span className="text-gray-500">T{core.tier}</span>
</div>
<p className="text-gray-500 mt-0.5">{core.description}</p>
<div className="flex gap-2 mt-1 text-gray-500">
<span>Cap: {core.manaCapacity}</span><span>Regen: {core.manaRegen}/h</span><span>Duration: {core.maxRoomDuration}r</span>
</div>
{!unlocked && <p className="text-red-400 mt-0.5">🔒 {formatRequirement(core.unlockRequirement)}</p>}
</div>
)} />
<ComponentSelector label="2. Frame (Combat Body)" items={ALL_FRAMES} selectedId={selectedFrameId} unlockedIds={unlockedFrameIds} onSelect={onSelectFrame}
renderItem={(frame: FrameDefinition, unlocked: boolean) => (
<div>
<div className="flex items-center justify-between">
<span className="font-medium">{frame.name}</span>
{frame.element && <span style={{ color: ELEMENTS[frame.element]?.color ?? '#888' }}>{ELEMENTS[frame.element]?.sym ?? ''} {frame.element}</span>}
</div>
<p className="text-gray-500 mt-0.5">{frame.description}</p>
<div className="flex gap-2 mt-1 text-gray-500 flex-wrap">
<span>DMG: {frame.baseDamage}</span><span>SPD: {frame.attackSpeed}/h</span>
<span>AP: {Math.round(frame.armorPierce * 100)}%</span><span>MA: {Math.round(frame.magicAffinity * 100)}%</span>
{frame.aoeTargets > 1 && <span>AoE: {frame.aoeTargets}</span>}
{frame.specialEffect !== 'none' && <span className="text-purple-400 capitalize">{frame.specialEffect}</span>}
</div>
{!unlocked && <p className="text-red-400 mt-0.5">🔒 {formatRequirement(frame.unlockRequirement)}</p>}
</div>
)} />
<ComponentSelector label="3. Mind Circuit (Behavior)" items={ALL_MIND_CIRCUITS} selectedId={selectedCircuitId} unlockedIds={unlockedCircuitIds} onSelect={onSelectCircuit}
renderItem={(circuit: MindCircuitDefinition, unlocked: boolean) => (
<div>
<div className="flex items-center justify-between">
<span className="font-medium">{circuit.name}</span>
<span className="text-gray-500">Slots: {circuit.spellSlots}</span>
</div>
<p className="text-gray-500 mt-0.5">{circuit.description}</p>
<div className="flex gap-2 mt-1 text-gray-500"><span className="capitalize">Behavior: {circuit.behavior}</span></div>
{!unlocked && <p className="text-red-400 mt-0.5">🔒 {formatRequirement(circuit.unlockRequirement)}</p>}
</div>
)} />
{selectedCore && selectedFrame && selectedCircuit && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-300">4. Enchantments (Optional)
<span className="text-gray-500 font-normal ml-2">{usedEnchantmentCapacity}/{Math.round(enchantmentCapacity)} capacity</span>
</h4>
{usedEnchantmentCapacity > enchantmentCapacity && <p className="text-xs text-red-400">Over capacity! Remove enchantments to save design.</p>}
<div className="grid grid-cols-1 gap-1.5">
{ALL_GOLEM_ENCHANTMENTS.map(enchant => {
const isSelected = selectedEnchantmentIds.includes(enchant.id);
const canAdd = !isSelected && usedEnchantmentCapacity + enchant.capacityCost <= enchantmentCapacity;
return (
<button key={enchant.id} onClick={() => onToggleEnchantment(enchant.id)} disabled={!isSelected && !canAdd}
className={clsx('text-left rounded px-3 py-2 text-xs transition-colors',
isSelected ? 'bg-purple-600/30 border border-purple-500 text-purple-200'
: canAdd ? 'bg-gray-800/60 border border-gray-700 text-gray-300 hover:bg-gray-700/60'
: 'bg-gray-900/40 border border-gray-800 text-gray-600 cursor-not-allowed')}>
<div className="flex items-center justify-between">
<span className="font-medium">{enchant.name}</span>
<span className="text-gray-500">Cost: {enchant.capacityCost}</span>
</div>
<p className="text-gray-500 mt-0.5">{enchant.description}</p>
<p className="text-gray-600 mt-0.5">Effect: {enchant.effect}</p>
</button>
);
})}
</div>
</div>
)}
<div className="pt-2 border-t border-gray-700">
<button onClick={onSaveDesign} disabled={!canSaveDesign}
className={clsx('w-full rounded px-4 py-2 text-sm font-medium transition-colors',
canSaveDesign ? 'bg-blue-600 text-white hover:bg-blue-500' : 'bg-gray-700 text-gray-500 cursor-not-allowed')}>
{canSaveDesign ? 'Save Design' : 'Select all required components'}
</button>
</div>
</div>
</ScrollArea>
<div className="rounded border border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-200 mb-3">Stats Preview</h3>
<StatsPreview stats={computedStats} canAfford={affordability} />
</div>
</div>
);
};
GolemDesignBuilder.displayName = 'GolemDesignBuilder';
@@ -0,0 +1,116 @@
'use client';
import React from 'react';
import { CORES, FRAMES, MIND_CIRCUITS, GOLEM_ENCHANTMENTS } from '@/lib/game/data/golems';
import type { GolemLoadoutEntry } from './types';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import clsx from 'clsx';
// ─── Loadout Entry Card ──────────────────────────────────────────────────────
interface LoadoutCardProps {
entry: GolemLoadoutEntry;
onToggle: (designId: string) => void;
onRemove: (designId: string) => void;
}
const LoadoutCard: React.FC<LoadoutCardProps> = React.memo(({
entry,
onToggle,
onRemove,
}) => {
const core = CORES[entry.design.coreId];
const frame = FRAMES[entry.design.frameId];
const circuit = MIND_CIRCUITS[entry.design.mindCircuitId];
const enchantments = entry.design.enchantmentIds
.map(id => GOLEM_ENCHANTMENTS[id])
.filter(Boolean);
return (
<div className={clsx(
'rounded-lg border p-3 space-y-2 transition-colors',
entry.enabled
? 'bg-gray-800/50 border-gray-600'
: 'bg-gray-900/50 border-gray-800 opacity-70',
)}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h4 className="font-semibold text-sm text-gray-100 truncate">{entry.design.name}</h4>
<p className="text-[10px] text-gray-500 mt-0.5">
{core?.name ?? '?'} + {frame?.name ?? '?'} + {circuit?.name ?? '?'}
{enchantments.length > 0 && ` + ${enchantments.length} enchantment(s)`}
</p>
</div>
<Badge className={clsx(
'text-[10px] px-1.5 py-0 shrink-0',
entry.enabled ? 'bg-green-700' : 'bg-gray-700',
)}>
{entry.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => onToggle(entry.designId)}
className={clsx(
'rounded px-2.5 py-1 text-xs font-medium transition-colors',
entry.enabled
? 'bg-red-600/80 text-white hover:bg-red-500'
: 'bg-green-600/80 text-white hover:bg-green-500',
)}
>
{entry.enabled ? 'Disable' : 'Enable'}
</button>
<button
onClick={() => onRemove(entry.designId)}
className="rounded px-2.5 py-1 text-xs font-medium bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors"
>
Remove
</button>
</div>
</div>
);
});
LoadoutCard.displayName = 'LoadoutCard';
// ─── Props ───────────────────────────────────────────────────────────────────
export interface GolemLoadoutPanelProps {
loadout: GolemLoadoutEntry[];
onToggle: (designId: string) => void;
onRemove: (designId: string) => void;
}
// ─── Component ───────────────────────────────────────────────────────────────
export const GolemLoadoutPanel: React.FC<GolemLoadoutPanelProps> = ({
loadout,
onToggle,
onRemove,
}) => {
return (
<ScrollArea className="h-[560px] rounded border border-gray-700 p-3">
{loadout.length === 0 ? (
<div className="text-center text-gray-500 py-8">
<p>No golem designs saved yet.</p>
<p className="text-xs mt-1">Use the Design Builder to create and save golem designs.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{loadout.map(entry => (
<LoadoutCard
key={entry.designId}
entry={entry}
onToggle={onToggle}
onRemove={onRemove}
/>
))}
</div>
)}
</ScrollArea>
);
};
GolemLoadoutPanel.displayName = 'GolemLoadoutPanel';
@@ -0,0 +1,271 @@
// ─── Test: computeGolemStats ──────────────────────────────────────────────────
describe('computeGolemStats', () => {
it('computes stats for a basic golem design', async () => {
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
const { computeGolemStats } = await import('@/lib/game/data/golems/utils');
const design = {
id: 'test_basic',
name: 'Test Basic',
core: CORES.basic,
frame: FRAMES.earth,
mindCircuit: MIND_CIRCUITS.simple,
enchantments: [],
selectedManaTypes: [],
selectedSpells: [],
};
const stats = computeGolemStats(design);
// Core-derived stats
expect(stats.manaCapacity).toBe(50);
expect(stats.manaRegen).toBe(0.5);
expect(stats.maxRoomDuration).toBe(3);
// Frame-derived stats
expect(stats.baseDamage).toBe(6);
expect(stats.attackSpeed).toBe(1.2);
expect(stats.armorPierce).toBe(0.05);
expect(stats.magicAffinity).toBe(0.3);
expect(stats.aoeTargets).toBe(1);
// Circuit-derived stats
expect(stats.spellSlots).toBe(0);
// Enchantment capacity = frame.magicAffinity * core.tierMultiplier
expect(stats.enchantmentCapacity).toBeCloseTo(0.3 * 1.0);
// Special effect from frame
expect(stats.specialEffect).toBe('none');
// Available mana types from core
expect(stats.availableManaTypes).toEqual(['earth']);
});
it('computes stats for an advanced golem with enchantments', async () => {
const { CORES, FRAMES, MIND_CIRCUITS, GOLEM_ENCHANTMENTS } = await import('@/lib/game/data/golems');
const { computeGolemStats } = await import('@/lib/game/data/golems/utils');
const design = {
id: 'test_advanced',
name: 'Test Advanced',
core: CORES.advanced,
frame: FRAMES.steel,
mindCircuit: MIND_CIRCUITS.advanced,
enchantments: [GOLEM_ENCHANTMENTS.sword_fire, GOLEM_ENCHANTMENTS.sword_metal],
selectedManaTypes: ['crystal', 'metal', 'fire'],
selectedSpells: [],
};
const stats = computeGolemStats(design);
// Core-derived stats
expect(stats.manaCapacity).toBe(200);
expect(stats.manaRegen).toBe(3.0);
expect(stats.maxRoomDuration).toBe(5);
// Frame-derived stats
expect(stats.baseDamage).toBe(18);
expect(stats.attackSpeed).toBe(1.6);
expect(stats.armorPierce).toBe(0.5);
expect(stats.magicAffinity).toBe(0.5);
// Circuit-derived stats
expect(stats.spellSlots).toBe(2);
// Enchantment capacity = frame.magicAffinity * core.tierMultiplier
expect(stats.enchantmentCapacity).toBeCloseTo(0.5 * 2.0);
// Selected mana types override core defaults
expect(stats.availableManaTypes).toEqual(['crystal', 'metal', 'fire']);
// Total summon cost includes all components + enchantments
expect(stats.totalSummonCost.length).toBeGreaterThan(0);
// Upkeep = core.manaRegen * 2 per hour
expect(stats.upkeepCostPerHour.length).toBe(1);
expect(stats.upkeepCostPerHour[0].amount).toBe(6.0); // 3.0 * 2
expect(stats.upkeepCostPerHour[0].element).toBe('crystal');
});
it('computes total summon cost from all components', async () => {
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
const { computeGolemStats } = await import('@/lib/game/data/golems/utils');
const design = {
id: 'test_cost',
name: 'Test Cost',
core: CORES.basic,
frame: FRAMES.earth,
mindCircuit: MIND_CIRCUITS.simple,
enchantments: [],
selectedManaTypes: [],
selectedSpells: [],
};
const stats = computeGolemStats(design);
// basic core: 10 earth, earth frame: 5 raw, simple circuit: 3 raw
const rawCosts = stats.totalSummonCost.filter(c => c.type === 'raw');
const earthCosts = stats.totalSummonCost.filter(c => c.type === 'element' && c.element === 'earth');
const totalRaw = rawCosts.reduce((sum, c) => sum + c.amount, 0);
const totalEarth = earthCosts.reduce((sum, c) => sum + c.amount, 0);
expect(totalRaw).toBe(8); // 5 + 3
expect(totalEarth).toBe(10);
});
});
// ─── Test: canAffordGolemDesign ───────────────────────────────────────────────
describe('canAffordGolemDesign', () => {
it('returns canAfford true when player has enough resources', async () => {
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
const { canAffordGolemDesign } = await import('@/lib/game/data/golems/utils');
const design = {
id: 'test_afford',
name: 'Test Afford',
core: CORES.basic,
frame: FRAMES.earth,
mindCircuit: MIND_CIRCUITS.simple,
enchantments: [],
selectedManaTypes: [],
selectedSpells: [],
};
// basic core: 10 earth, earth frame: 5 raw, simple circuit: 3 raw
const result = canAffordGolemDesign(design, 100, {
earth: { current: 50, max: 100, unlocked: true },
});
expect(result.canAfford).toBe(true);
expect(result.missing).toBe('');
});
it('returns canAfford false when raw mana is insufficient', async () => {
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
const { canAffordGolemDesign } = await import('@/lib/game/data/golems/utils');
const design = {
id: 'test_no_raw',
name: 'Test No Raw',
core: CORES.basic,
frame: FRAMES.earth,
mindCircuit: MIND_CIRCUITS.simple,
enchantments: [],
selectedManaTypes: [],
selectedSpells: [],
};
// Need 8 raw total (5 + 3), only have 3
const result = canAffordGolemDesign(design, 3, {
earth: { current: 50, max: 100, unlocked: true },
});
expect(result.canAfford).toBe(false);
expect(result.missing).toContain('raw mana');
});
it('returns canAfford false when element mana is insufficient', async () => {
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
const { canAffordGolemDesign } = await import('@/lib/game/data/golems/utils');
const design = {
id: 'test_no_elem',
name: 'Test No Elem',
core: CORES.basic,
frame: FRAMES.earth,
mindCircuit: MIND_CIRCUITS.simple,
enchantments: [],
selectedManaTypes: [],
selectedSpells: [],
};
// Need 10 earth, only have 5
const result = canAffordGolemDesign(design, 100, {
earth: { current: 5, max: 100, unlocked: true },
});
expect(result.canAfford).toBe(false);
expect(result.missing).toContain('earth');
});
it('returns canAfford false when element is not unlocked', async () => {
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
const { canAffordGolemDesign } = await import('@/lib/game/data/golems/utils');
const design = {
id: 'test_locked',
name: 'Test Locked',
core: CORES.basic,
frame: FRAMES.earth,
mindCircuit: MIND_CIRCUITS.simple,
enchantments: [],
selectedManaTypes: [],
selectedSpells: [],
};
// earth not in elements at all
const result = canAffordGolemDesign(design, 100, {});
expect(result.canAfford).toBe(false);
expect(result.missing).toContain('not unlocked');
});
it('returns canAfford false when element exists but unlocked is false', async () => {
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
const { canAffordGolemDesign } = await import('@/lib/game/data/golems/utils');
const design = {
id: 'test_unlocked_false',
name: 'Test Unlocked False',
core: CORES.basic,
frame: FRAMES.earth,
mindCircuit: MIND_CIRCUITS.simple,
enchantments: [],
selectedManaTypes: [],
selectedSpells: [],
};
const result = canAffordGolemDesign(design, 100, {
earth: { current: 50, max: 100, unlocked: false },
});
expect(result.canAfford).toBe(false);
expect(result.missing).toContain('not unlocked');
});
});
// ─── 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.golemLoadout)).toBe(true);
expect(Array.isArray(state.golemancy.activeGolems)).toBe(true);
});
});
// ─── 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);
});
});
@@ -0,0 +1,82 @@
'use client';
import React from 'react';
import type { ComputedGolemStats, CoreDefinition } from '@/lib/game/data/golems/types';
import { ELEMENTS } from '@/lib/game/constants/elements';
import clsx from 'clsx';
import { formatManaCost, formatRequirement } from './golemancy-utils';
interface ComponentSelectorProps<T> {
label: string;
items: T[];
selectedId: string | null;
unlockedIds: Set<string>;
onSelect: (id: string) => void;
renderItem: (item: T, unlocked: boolean, selected: boolean) => React.ReactNode;
}
export function ComponentSelector<T extends { id: string }>({
label, items, selectedId, unlockedIds, onSelect, renderItem,
}: ComponentSelectorProps<T>) {
return (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-300">{label}</h4>
<div className="grid grid-cols-1 gap-1.5">
{items.map(item => {
const unlocked = unlockedIds.has(item.id);
const selected = selectedId === item.id;
return (
<button key={item.id} onClick={() => unlocked && onSelect(item.id)} disabled={!unlocked}
className={clsx('text-left rounded px-3 py-2 text-xs transition-colors',
selected ? 'bg-blue-600/30 border border-blue-500 text-blue-200'
: unlocked ? 'bg-gray-800/60 border border-gray-700 text-gray-300 hover:bg-gray-700/60'
: 'bg-gray-900/40 border border-gray-800 text-gray-600 cursor-not-allowed')}>
{renderItem(item, unlocked, selected)}
</button>
);
})}
</div>
</div>
);
}
interface StatsPreviewProps {
stats: ComputedGolemStats | null;
canAfford: { canAfford: boolean; missing: string };
}
export function StatsPreview({ stats, canAfford }: StatsPreviewProps) {
if (!stats) return <div className="text-xs text-gray-500 italic">Select all required components to see stats preview.</div>;
return (
<div className="space-y-3">
<div className={clsx('text-xs font-medium px-2 py-1 rounded',
canAfford.canAfford ? 'text-green-400 bg-green-900/20' : 'text-red-400 bg-red-900/20')}>
{canAfford.canAfford ? '✓ Can afford summon cost' : `✗ Cannot afford: ${canAfford.missing}`}
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div className="text-gray-400"><span className="text-gray-500">Damage:</span> {stats.baseDamage}</div>
<div className="text-gray-400"><span className="text-gray-500">Atk Speed:</span> {stats.attackSpeed}/h</div>
<div className="text-gray-400"><span className="text-gray-500">Armor Pierce:</span> {Math.round(stats.armorPierce * 100)}%</div>
<div className="text-gray-400"><span className="text-gray-500">Magic Affinity:</span> {Math.round(stats.magicAffinity * 100)}%</div>
<div className="text-gray-400"><span className="text-gray-500">AoE Targets:</span> {stats.aoeTargets}</div>
<div className="text-gray-400"><span className="text-gray-500">Spell Slots:</span> {stats.spellSlots}</div>
<div className="text-gray-400"><span className="text-gray-500">Mana Capacity:</span> {stats.manaCapacity}</div>
<div className="text-gray-400"><span className="text-gray-500">Mana Regen:</span> {stats.manaRegen}/h</div>
<div className="text-gray-400"><span className="text-gray-500">Room Duration:</span> {stats.maxRoomDuration} rooms</div>
<div className="text-gray-400"><span className="text-gray-500">Enchant Cap:</span> {Math.round(stats.enchantmentCapacity)}</div>
{stats.specialEffect !== 'none' && (
<div className="col-span-2 text-gray-400"><span className="text-gray-500">Special:</span>{' '}<span className="text-purple-400 capitalize">{stats.specialEffect}</span></div>
)}
</div>
<div className="text-xs"><span className="text-gray-500">Summon Cost:</span>{' '}
{stats.totalSummonCost.map((c, i) => <span key={i} className="text-gray-400">{formatManaCost(c)}{i < stats.totalSummonCost.length - 1 ? ' + ' : ''}</span>)}
</div>
<div className="text-xs"><span className="text-gray-500">Upkeep:</span>{' '}
{stats.upkeepCostPerHour.map((c, i) => <span key={i} className="text-gray-400">{formatManaCost(c)}{i < stats.upkeepCostPerHour.length - 1 ? ' + ' : ''}/h</span>)}
</div>
<div className="text-xs"><span className="text-gray-500">Mana Types:</span>{' '}
{stats.availableManaTypes.map((mt, i) => <span key={mt} className="text-gray-400">{ELEMENTS[mt]?.sym ?? ''}{mt}{i < stats.availableManaTypes.length - 1 ? ', ' : ''}</span>)}
</div>
</div>
);
}
@@ -0,0 +1,160 @@
import { describe, it, expect } from 'vitest';
import { CORES, FRAMES, MIND_CIRCUITS, GOLEM_ENCHANTMENTS, ALL_CORES, ALL_FRAMES, ALL_MIND_CIRCUITS, ALL_GOLEM_ENCHANTMENTS } from '@/lib/game/data/golems';
// ─── 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: Component registries ───────────────────────────────────────────────
describe('Component registries', () => {
it('CORES has all four core definitions', () => {
expect(CORES.basic).toBeDefined();
expect(CORES.intermediate).toBeDefined();
expect(CORES.advanced).toBeDefined();
expect(CORES.guardian).toBeDefined();
});
it('FRAMES has all seven frame definitions', () => {
expect(FRAMES.earth).toBeDefined();
expect(FRAMES.sand).toBeDefined();
expect(FRAMES.frost).toBeDefined();
expect(FRAMES.crystal).toBeDefined();
expect(FRAMES.steel).toBeDefined();
expect(FRAMES.shadowglass).toBeDefined();
expect(FRAMES.crystalSteelHybrid).toBeDefined();
});
it('MIND_CIRCUITS has all four circuit definitions', () => {
expect(MIND_CIRCUITS.simple).toBeDefined();
expect(MIND_CIRCUITS.intermediate).toBeDefined();
expect(MIND_CIRCUITS.advanced).toBeDefined();
expect(MIND_CIRCUITS.guardian).toBeDefined();
});
it('GOLEM_ENCHANTMENTS has all eight enchantment definitions', () => {
expect(GOLEM_ENCHANTMENTS.sword_fire).toBeDefined();
expect(GOLEM_ENCHANTMENTS.sword_frost).toBeDefined();
expect(GOLEM_ENCHANTMENTS.sword_lightning).toBeDefined();
expect(GOLEM_ENCHANTMENTS.sword_shadow).toBeDefined();
expect(GOLEM_ENCHANTMENTS.sword_metal).toBeDefined();
expect(GOLEM_ENCHANTMENTS.sword_crystal).toBeDefined();
expect(GOLEM_ENCHANTMENTS.sword_water).toBeDefined();
expect(GOLEM_ENCHANTMENTS.sword_earth).toBeDefined();
});
it('ALL_CORES contains all core definitions', () => {
expect(ALL_CORES.length).toBe(4);
});
it('ALL_FRAMES contains all frame definitions', () => {
expect(ALL_FRAMES.length).toBe(7);
});
it('ALL_MIND_CIRCUITS contains all circuit definitions', () => {
expect(ALL_MIND_CIRCUITS.length).toBe(4);
});
it('ALL_GOLEM_ENCHANTMENTS contains all enchantment definitions', () => {
expect(ALL_GOLEM_ENCHANTMENTS.length).toBe(8);
});
it('all cores have required fields', () => {
for (const core of ALL_CORES) {
expect(core.id).toBeTruthy();
expect(core.name).toBeTruthy();
expect(core.description).toBeTruthy();
expect(core.tier).toBeGreaterThanOrEqual(1);
expect(core.tier).toBeLessThanOrEqual(4);
expect(core.manaCapacity).toBeGreaterThan(0);
expect(core.manaRegen).toBeGreaterThan(0);
expect(core.maxRoomDuration).toBeGreaterThan(0);
expect(core.summonCost.length).toBeGreaterThan(0);
expect(core.primaryManaType).toBeTruthy();
expect(core.tierMultiplier).toBeGreaterThan(0);
expect(core.unlockRequirement).toBeDefined();
expect(core.unlockRequirement.type).toBeTruthy();
}
});
it('all frames have required fields', () => {
for (const frame of ALL_FRAMES) {
expect(frame.id).toBeTruthy();
expect(frame.name).toBeTruthy();
expect(frame.description).toBeTruthy();
expect(frame.baseDamage).toBeGreaterThan(0);
expect(frame.attackSpeed).toBeGreaterThan(0);
expect(frame.armorPierce).toBeGreaterThanOrEqual(0);
expect(frame.magicAffinity).toBeGreaterThanOrEqual(0);
expect(frame.aoeTargets).toBeGreaterThanOrEqual(1);
expect(frame.summonCost.length).toBeGreaterThan(0);
expect(frame.specialEffect).toBeTruthy();
expect(frame.unlockRequirement).toBeDefined();
}
});
it('all mind circuits have required fields', () => {
for (const circuit of ALL_MIND_CIRCUITS) {
expect(circuit.id).toBeTruthy();
expect(circuit.name).toBeTruthy();
expect(circuit.description).toBeTruthy();
expect(circuit.spellSlots).toBeGreaterThanOrEqual(0);
expect(circuit.behavior).toBeTruthy();
expect(circuit.summonCost.length).toBeGreaterThan(0);
expect(circuit.unlockRequirement).toBeDefined();
}
});
it('all enchantments have required fields', () => {
for (const enchant of ALL_GOLEM_ENCHANTMENTS) {
expect(enchant.id).toBeTruthy();
expect(enchant.name).toBeTruthy();
expect(enchant.description).toBeTruthy();
expect(enchant.effect).toBeTruthy();
expect(enchant.capacityCost).toBeGreaterThan(0);
expect(enchant.summonCost.length).toBeGreaterThan(0);
}
});
it('cores span four tiers', () => {
const tiers = new Set(ALL_CORES.map(c => c.tier));
expect(tiers.size).toBe(4);
expect(tiers.has(1)).toBe(true);
expect(tiers.has(2)).toBe(true);
expect(tiers.has(3)).toBe(true);
expect(tiers.has(4)).toBe(true);
});
it('basic core is the only tier 1 core', () => {
const tier1 = ALL_CORES.filter(c => c.tier === 1);
expect(tier1.length).toBe(1);
expect(tier1[0].id).toBe('basic');
});
it('guardian core is the highest tier', () => {
const guardian = CORES.guardian;
expect(guardian).toBeDefined();
expect(guardian.tier).toBe(4);
});
});
@@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest';
import { getGolemSlots, isComponentUnlocked } from '@/lib/game/data/golems/utils';
// ─── Test: getGolemSlots ──────────────────────────────────────────────────────
describe('getGolemSlots', () => {
it('returns 0 for fabricator level < 2', () => {
expect(getGolemSlots(0)).toBe(0);
expect(getGolemSlots(1)).toBe(0);
});
it('scales with fabricator level', () => {
expect(getGolemSlots(2)).toBe(1);
expect(getGolemSlots(3)).toBe(1);
expect(getGolemSlots(4)).toBe(2);
expect(getGolemSlots(5)).toBe(2);
expect(getGolemSlots(6)).toBe(3);
expect(getGolemSlots(7)).toBe(3);
expect(getGolemSlots(8)).toBe(4);
expect(getGolemSlots(9)).toBe(4);
expect(getGolemSlots(10)).toBe(5);
});
it('caps at 5 slots for level 10+', () => {
expect(getGolemSlots(10)).toBe(5);
expect(getGolemSlots(11)).toBe(5);
expect(getGolemSlots(20)).toBe(10);
});
});
// ─── Test: isComponentUnlocked ────────────────────────────────────────────────
describe('isComponentUnlocked', () => {
it('returns true for attunement_level requirement when met', () => {
const req = { type: 'attunement_level' as const, attunement: 'fabricator', level: 2 };
const attunements = { fabricator: { active: true, level: 2 } };
expect(isComponentUnlocked(req, attunements, [], [])).toBe(true);
});
it('returns false for attunement_level when level is too low', () => {
const req = { type: 'attunement_level' as const, attunement: 'fabricator', level: 2 };
const attunements = { fabricator: { active: true, level: 1 } };
expect(isComponentUnlocked(req, attunements, [], [])).toBe(false);
});
it('returns false for attunement_level when attunement is inactive', () => {
const req = { type: 'attunement_level' as const, attunement: 'fabricator', level: 2 };
const attunements = { fabricator: { active: false, level: 5 } };
expect(isComponentUnlocked(req, attunements, [], [])).toBe(false);
});
it('returns true for mana_unlocked requirement when element is unlocked', () => {
const req = { type: 'mana_unlocked' as const, manaType: 'frost' };
expect(isComponentUnlocked(req, {}, ['earth', 'frost'], [])).toBe(true);
});
it('returns false for mana_unlocked when element is not unlocked', () => {
const req = { type: 'mana_unlocked' as const, manaType: 'frost' };
expect(isComponentUnlocked(req, {}, ['earth'], [])).toBe(false);
});
it('returns true for dual_attunement when both requirements met', () => {
const req = {
type: 'dual_attunement' as const,
attunements: ['fabricator', 'enchanter'],
levels: [4, 2],
};
const attunements = {
fabricator: { active: true, level: 4 },
enchanter: { active: true, level: 2 },
};
expect(isComponentUnlocked(req, attunements, [], [])).toBe(true);
});
it('returns false for dual_attunement when one attunement is too low', () => {
const req = {
type: 'dual_attunement' as const,
attunements: ['fabricator', 'enchanter'],
levels: [4, 2],
};
const attunements = {
fabricator: { active: true, level: 4 },
enchanter: { active: true, level: 1 },
};
expect(isComponentUnlocked(req, attunements, [], [])).toBe(false);
});
it('returns true for guardian_pact when attunements and pact are present', () => {
const req = {
type: 'guardian_pact' as const,
attunements: ['invoker', 'fabricator'],
levels: [5, 5],
};
const attunements = {
invoker: { active: true, level: 5 },
fabricator: { active: true, level: 5 },
};
expect(isComponentUnlocked(req, attunements, [], [1])).toBe(true);
});
it('returns false for guardian_pact when no pacts signed', () => {
const req = {
type: 'guardian_pact' as const,
attunements: ['invoker', 'fabricator'],
levels: [5, 5],
};
const attunements = {
invoker: { active: true, level: 5 },
fabricator: { active: true, level: 5 },
};
expect(isComponentUnlocked(req, attunements, [], [])).toBe(false);
});
it('returns false for unknown requirement type', () => {
const req = { type: 'unknown_type' as any };
expect(isComponentUnlocked(req, {}, [], [])).toBe(false);
});
});
@@ -0,0 +1,73 @@
import type {
CoreDefinition,
FrameDefinition,
MindCircuitDefinition,
GolemEnchantmentDefinition,
GolemDesign,
} from '@/lib/game/data/golems/types';
import type { GolemManaCost } from './types';
import { ELEMENTS } from '@/lib/game/constants/elements';
export function formatManaCost(cost: GolemManaCost): 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();
}
export function formatRequirement(req: CoreDefinition['unlockRequirement']): string {
switch (req.type) {
case 'attunement_level':
return `${req.attunement} level ${req.level}`;
case 'mana_unlocked': {
const elem = req.manaType ? ELEMENTS[req.manaType] : null;
return `Unlock ${elem?.sym ?? ''} ${req.manaType ?? ''}`.trim();
}
case 'dual_attunement':
return `${req.attunements?.join(' + ')} level ${req.levels?.join('/')}`;
case 'guardian_pact':
return `${req.attunements?.join(' + ')} level ${req.levels?.join('/')} + Guardian Pact`;
default:
return 'Unknown';
}
}
export function serializeDesign(
name: string,
core: CoreDefinition,
frame: FrameDefinition,
circuit: MindCircuitDefinition,
enchantments: GolemEnchantmentDefinition[],
selectedManaTypes: string[],
selectedSpells: string[],
) {
return {
id: `design_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
name,
coreId: core.id,
frameId: frame.id,
mindCircuitId: circuit.id,
enchantmentIds: enchantments.map(e => e.id),
selectedManaTypes,
selectedSpells,
};
}
export function buildGolemDesign(
core: CoreDefinition,
frame: FrameDefinition,
circuit: MindCircuitDefinition,
enchantments: GolemEnchantmentDefinition[],
selectedManaTypes: string[],
selectedSpells: string[],
): GolemDesign {
return {
id: `design_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
name: `${core.name.split(' ')[0]} ${frame.name.split(' ')[0]}`,
core,
frame,
mindCircuit: circuit,
enchantments,
selectedManaTypes,
selectedSpells,
};
}
@@ -0,0 +1,35 @@
// ─── Shared Golemancy UI Types ──────────────────────────────────────────────
import type {
CoreDefinition,
FrameDefinition,
MindCircuitDefinition,
GolemEnchantmentDefinition,
GolemDesign,
ComputedGolemStats,
GolemManaCost,
} from '@/lib/game/data/golems/types';
import type {
SerializedGolemDesign,
GolemLoadoutEntry,
RuntimeActiveGolem,
} from '@/lib/game/types/game';
export type {
CoreDefinition,
FrameDefinition,
MindCircuitDefinition,
GolemEnchantmentDefinition,
GolemDesign,
ComputedGolemStats,
GolemManaCost,
SerializedGolemDesign,
GolemLoadoutEntry,
RuntimeActiveGolem,
};
export type BuilderSection = 'builder' | 'loadout' | 'active';
export interface AttunementLookup {
[id: string]: { active: boolean; level: number };
}