- {Object.entries(GOLEMS_DEF).map(([id, def]) => {
- const isEnabled = enabledGolems.includes(id);
- return (
-
-
-
{def.name}
-
{def.baseManaType}
-
+
+ {/* ─── Cores ─────────────────────────────────────────────── */}
+
+
+ Cores ({ALL_CORES.length})
+
+
+ {ALL_CORES.map((core) => (
+
+
- );
- })}
+ ))}
+
+
+
+ {/* ─── Frames ────────────────────────────────────────────── */}
+
+
+ Frames ({ALL_FRAMES.length})
+
+
+ {ALL_FRAMES.map((frame) => (
+
+
+
+
+ ))}
+
+
+
+ {/* ─── Mind Circuits ─────────────────────────────────────── */}
+
+
+ Mind Circuits ({ALL_MIND_CIRCUITS.length})
+
+
+ {ALL_MIND_CIRCUITS.map((circuit) => (
+
+
+
+
+ ))}
+
+
+
+ {/* ─── Enchantments ──────────────────────────────────────── */}
+
+
+ Enchantments ({ALL_GOLEM_ENCHANTMENTS.length})
+
+
+ {ALL_GOLEM_ENCHANTMENTS.map((enchant) => (
+
+
+
+
+ ))}
+
@@ -81,4 +244,4 @@ export function GolemDebugSection() {
);
}
-GolemDebugSection.displayName = "GolemDebugSection";
+GolemDebugSection.displayName = 'GolemDebugSection';
diff --git a/src/components/game/tabs/GolemancyTab.test.ts b/src/components/game/tabs/GolemancyTab.test.ts
deleted file mode 100644
index aa13157..0000000
--- a/src/components/game/tabs/GolemancyTab.test.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-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
index eb11637..921c66b 100644
--- a/src/components/game/tabs/GolemancyTab.tsx
+++ b/src/components/game/tabs/GolemancyTab.tsx
@@ -5,223 +5,53 @@ 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 { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
+import {
+ CORES, FRAMES, MIND_CIRCUITS, GOLEM_ENCHANTMENTS,
+ ALL_CORES, ALL_FRAMES, ALL_MIND_CIRCUITS, ALL_GOLEM_ENCHANTMENTS,
+} from '@/lib/game/data/golems';
+import {
+ getGolemSlots,
+ isComponentUnlocked,
+ computeGolemStats,
+ canAffordGolemDesign,
+} from '@/lib/game/data/golems/utils';
+import type { ComputedGolemStats, GolemEnchantmentDefinition } from '@/lib/game/data/golems/types';
+import type { BuilderSection } from './golemancy/types';
+import { GolemDesignBuilder } from './golemancy/GolemDesignBuilder';
+import { GolemLoadoutPanel } from './golemancy/GolemLoadoutPanel';
+import { ActiveGolemsPanel } from './golemancy/ActiveGolemsPanel';
+import { serializeDesign, buildGolemDesign } from './golemancy/golemancy-utils';
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 */}
-
-
- Damage: {golem.damage}
-
-
- Attack Speed: {golem.attackSpeed}/h
-
-
- HP: {golem.hp}
-
-
- Armor Pierce: {Math.round(golem.armorPierce * 100)}%
-
- {golem.isAoe && (
-
- AoE: {golem.aoeTargets} targets
-
- )}
-
-
- {/* Special Abilities */}
- {golem.specialAbilities && golem.specialAbilities.length > 0 && (
-
-
Special:
- {golem.specialAbilities.map((ability, i) => (
-
- • {ability.name}: {ability.description}
-
- ))}
-
- )}
-
- {/* 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 [activeTier, setActiveTier] = useState('base');
+ const [activeSection, setActiveSection] = useState('builder');
- const { golemancy, toggleGolem } = useCombatStore(useShallow(s => ({
- golemancy: s.golemancy,
- toggleGolem: s.toggleGolem,
- })));
+ // Builder state
+ const [selectedCoreId, setSelectedCoreId] = useState(null);
+ const [selectedFrameId, setSelectedFrameId] = useState(null);
+ const [selectedCircuitId, setSelectedCircuitId] = useState(null);
+ const [selectedEnchantmentIds, setSelectedEnchantmentIds] = useState([]);
+ const [designName, setDesignName] = useState('');
+ const [selectedManaTypes, setSelectedManaTypes] = useState([]);
+
+ // Store access
+ const { golemancy, addGolemDesign, removeGolemDesign, toggleGolemLoadoutEntry } = useCombatStore(
+ useShallow(s => ({
+ golemancy: s.golemancy,
+ addGolemDesign: s.addGolemDesign,
+ removeGolemDesign: s.removeGolemDesign,
+ toggleGolemLoadoutEntry: s.toggleGolemLoadoutEntry,
+ })),
+ );
const attunements = useAttunementStore(s => s.attunements);
const { rawMana, elements } = useManaStore(useShallow(s => ({
rawMana: s.rawMana,
elements: s.elements,
})));
+ const signedPacts = usePrestigeStore(s => s.signedPacts);
- // Build attunement lookup for isGolemUnlocked
+ // Derived data
const attunementLookup = useMemo(() => {
const lookup: Record = {};
for (const [id, att] of Object.entries(attunements)) {
@@ -235,103 +65,180 @@ export const GolemancyTab: React.FC = () => {
[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;
+ const enabledCount = golemancy.golemLoadout.filter(e => e.enabled).length;
- const activeTierGolems = golemsByTier[activeTier] ?? [];
+ // Unlock checks
+ const unlockedCoreIds = useMemo(() => {
+ const set = new Set();
+ for (const core of ALL_CORES) {
+ if (isComponentUnlocked(core.unlockRequirement, attunementLookup, unlockedElements, signedPacts)) {
+ set.add(core.id);
+ }
+ }
+ return set;
+ }, [attunementLookup, unlockedElements, signedPacts]);
+
+ const unlockedFrameIds = useMemo(() => {
+ const set = new Set();
+ for (const frame of ALL_FRAMES) {
+ if (isComponentUnlocked(frame.unlockRequirement, attunementLookup, unlockedElements, signedPacts)) {
+ set.add(frame.id);
+ }
+ }
+ return set;
+ }, [attunementLookup, unlockedElements, signedPacts]);
+
+ const unlockedCircuitIds = useMemo(() => {
+ const set = new Set();
+ for (const circuit of ALL_MIND_CIRCUITS) {
+ if (isComponentUnlocked(circuit.unlockRequirement, attunementLookup, unlockedElements, signedPacts)) {
+ set.add(circuit.id);
+ }
+ }
+ return set;
+ }, [attunementLookup, unlockedElements, signedPacts]);
+
+ // Selected components
+ const selectedCore = selectedCoreId ? CORES[selectedCoreId] ?? null : null;
+ const selectedFrame = selectedFrameId ? FRAMES[selectedFrameId] ?? null : null;
+ const selectedCircuit = selectedCircuitId ? MIND_CIRCUITS[selectedCircuitId] ?? null : null;
+ const selectedEnchantments = selectedEnchantmentIds
+ .map(id => GOLEM_ENCHANTMENTS[id])
+ .filter(Boolean) as GolemEnchantmentDefinition[];
+
+ // Computed stats preview
+ const computedStats = useMemo(() => {
+ if (!selectedCore || !selectedFrame || !selectedCircuit) return null;
+ const design = buildGolemDesign(
+ selectedCore, selectedFrame, selectedCircuit,
+ selectedEnchantments, selectedManaTypes, [],
+ );
+ return computeGolemStats(design);
+ }, [selectedCore, selectedFrame, selectedCircuit, selectedEnchantments, selectedManaTypes]);
+
+ const affordability = useMemo(() => {
+ if (!selectedCore || !selectedFrame || !selectedCircuit) {
+ return { canAfford: false, missing: 'Select all required components' };
+ }
+ const design = buildGolemDesign(
+ selectedCore, selectedFrame, selectedCircuit,
+ selectedEnchantments, selectedManaTypes, [],
+ );
+ return canAffordGolemDesign(design, rawMana, elements);
+ }, [selectedCore, selectedFrame, selectedCircuit, selectedEnchantments, selectedManaTypes, rawMana, elements]);
+
+ // Enchantment capacity check
+ const enchantmentCapacity = computedStats?.enchantmentCapacity ?? 0;
+ const usedEnchantmentCapacity = selectedEnchantments.reduce((sum, e) => sum + e.capacityCost, 0);
+
+ // Handlers
+ const handleToggleEnchantment = useCallback((id: string) => {
+ setSelectedEnchantmentIds(prev =>
+ prev.includes(id) ? prev.filter(eid => eid !== id) : [...prev, id],
+ );
+ }, []);
+
+ const handleSaveDesign = useCallback(() => {
+ if (!selectedCore || !selectedFrame || !selectedCircuit) return;
+ const name = designName.trim() || `${selectedCore.name.split(' ')[0]} ${selectedFrame.name.split(' ')[0]}`;
+ const serialized = serializeDesign(
+ name, selectedCore, selectedFrame, selectedCircuit,
+ selectedEnchantments, selectedManaTypes, [],
+ );
+ addGolemDesign(serialized);
+ setDesignName('');
+ setSelectedEnchantmentIds([]);
+ setSelectedManaTypes([]);
+ }, [designName, selectedCore, selectedFrame, selectedCircuit, selectedEnchantments, selectedManaTypes, addGolemDesign]);
+
+ const handleRemoveLoadoutEntry = useCallback((designId: string) => {
+ removeGolemDesign(designId);
+ }, [removeGolemDesign]);
+
+ const handleToggleLoadoutEntry = useCallback((designId: string) => {
+ toggleGolemLoadoutEntry(designId);
+ }, [toggleGolemLoadoutEntry]);
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}
-
-
- Summoned: {golemancy.summonedGolems.length}
-
-
+
+ {/* Header info */}
+
+
+ Design custom golems from components. Enabled golems are automatically
+ summoned when entering the spire if you can afford the cost.
+
+
+ Slots: {enabledCount}/{golemSlots}
+ Active: {golemancy.activeGolems.length}
+ Designs: {Object.keys(golemancy.golemDesigns).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 (
-
- );
- })}
-
- )}
-
-
+
+ {/* Section tabs */}
+
+ {([
+ { key: 'builder', label: 'Design Builder' },
+ { key: 'loadout', label: `Loadout (${golemancy.golemLoadout.length})` },
+ { key: 'active', label: `Active (${golemancy.activeGolems.length})` },
+ ] as const).map(({ key, label }) => (
+
+ ))}
+
+
+ {/* ─── Builder Section ─────────────────────────────────────────────── */}
+ {activeSection === 'builder' && (
+
+ )}
+
+ {/* ─── Loadout Section ─────────────────────────────────────────────── */}
+ {activeSection === 'loadout' && (
+
+ )}
+
+ {/* ─── Active Golems Section ───────────────────────────────────────── */}
+ {activeSection === 'active' && (
+
+ )}
+
);
};
diff --git a/src/components/game/tabs/SpireCombatPage/SpireCombatControls.tsx b/src/components/game/tabs/SpireCombatPage/SpireCombatControls.tsx
index 891b164..512b0d7 100644
--- a/src/components/game/tabs/SpireCombatPage/SpireCombatControls.tsx
+++ b/src/components/game/tabs/SpireCombatPage/SpireCombatControls.tsx
@@ -4,7 +4,7 @@ import { useCombatStore, useManaStore, canAffordSpellCost } from '@/lib/game/sto
import { SPELLS_DEF, ELEMENTS } from '@/lib/game/constants';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
-import { GOLEMS_DEF } from '@/lib/game/data/golems';
+import { CORES, FRAMES } from '@/lib/game/data/golems';
import { DebugName } from '@/components/game/debug/debug-context';
interface SpireCombatControlsProps {
@@ -29,7 +29,8 @@ export function SpireCombatControls({ castProgress }: SpireCombatControlsProps)
.filter(([, state]) => state?.learned)
.map(([id]) => id);
- const summonedGolems = golemancy.summonedGolems || [];
+ const activeGolems = golemancy.activeGolems || [];
+ const golemDesigns = golemancy.golemDesigns || {};
return (
@@ -100,26 +101,28 @@ export function SpireCombatControls({ castProgress }: SpireCombatControlsProps)
🗿 Golems
- {summonedGolems.length === 0 ? (
+ {activeGolems.length === 0 ? (
No golems summoned.
) : (
- {summonedGolems.map((sg) => {
- const golemDef = GOLEMS_DEF[sg.golemId];
- if (!golemDef) return null;
- const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888';
+ {activeGolems.map((ag) => {
+ const design = golemDesigns[ag.designId];
+ if (!design) return null;
+ const frame = FRAMES[design.frameId];
+ const core = CORES[design.coreId];
+ const elemColor = frame?.element ? ELEMENTS[frame.element]?.color || '#888' : '#888';
return (
●
- {golemDef.name}
+ {design.name}
- {golemDef.damage} dmg · {golemDef.attackSpeed}/h
+ {frame?.baseDamage ?? '?'} dmg · {core?.manaRegen ?? '?'} mp
);
diff --git a/src/components/game/tabs/golemancy/ActiveGolemsPanel.tsx b/src/components/game/tabs/golemancy/ActiveGolemsPanel.tsx
new file mode 100644
index 0000000..ddf2058
--- /dev/null
+++ b/src/components/game/tabs/golemancy/ActiveGolemsPanel.tsx
@@ -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
= 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 (
+
+
+
{name}
+
+ Active
+
+
+
+
+ Summoned Floor: {golem.summonedFloor}
+
+
+ Rooms Left: {golem.roomsRemaining}
+
+
+ Mana:{' '}
+
+ {Math.round(golem.currentMana)}/{maxMana}
+
+
+
+ Atk Progress:{' '}
+ {Math.round(golem.attackProgress * 100)}%
+
+
+
+ );
+});
+
+ActiveGolemCard.displayName = 'ActiveGolemCard';
+
+// ─── Props ───────────────────────────────────────────────────────────────────
+
+export interface ActiveGolemsPanelProps {
+ activeGolems: RuntimeActiveGolem[];
+}
+
+// ─── Component ───────────────────────────────────────────────────────────────
+
+export const ActiveGolemsPanel: React.FC = ({ activeGolems }) => {
+ return (
+
+ {activeGolems.length === 0 ? (
+
+
No active golems in combat.
+
Enable golem designs in the Loadout and enter the spire.
+
+ ) : (
+
+ {activeGolems.map(golem => (
+
+ ))}
+
+ )}
+
+ );
+};
+
+ActiveGolemsPanel.displayName = 'ActiveGolemsPanel';
diff --git a/src/components/game/tabs/golemancy/GolemDesignBuilder.tsx b/src/components/game/tabs/golemancy/GolemDesignBuilder.tsx
new file mode 100644
index 0000000..e33452b
--- /dev/null
+++ b/src/components/game/tabs/golemancy/GolemDesignBuilder.tsx
@@ -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;
+ unlockedFrameIds: Set;
+ unlockedCircuitIds: Set;
+ 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 = ({
+ 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 (
+
+
+
+
+
+ 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" />
+
+
+
(
+
+
+ {core.name}
+ T{core.tier}
+
+
{core.description}
+
+ Cap: {core.manaCapacity}Regen: {core.manaRegen}/hDuration: {core.maxRoomDuration}r
+
+ {!unlocked &&
🔒 {formatRequirement(core.unlockRequirement)}
}
+
+ )} />
+
+ (
+
+
+ {frame.name}
+ {frame.element && {ELEMENTS[frame.element]?.sym ?? ''} {frame.element}}
+
+
{frame.description}
+
+ DMG: {frame.baseDamage}SPD: {frame.attackSpeed}/h
+ AP: {Math.round(frame.armorPierce * 100)}%MA: {Math.round(frame.magicAffinity * 100)}%
+ {frame.aoeTargets > 1 && AoE: {frame.aoeTargets}}
+ {frame.specialEffect !== 'none' && {frame.specialEffect}}
+
+ {!unlocked &&
🔒 {formatRequirement(frame.unlockRequirement)}
}
+
+ )} />
+
+ (
+
+
+ {circuit.name}
+ Slots: {circuit.spellSlots}
+
+
{circuit.description}
+
Behavior: {circuit.behavior}
+ {!unlocked &&
🔒 {formatRequirement(circuit.unlockRequirement)}
}
+
+ )} />
+
+ {selectedCore && selectedFrame && selectedCircuit && (
+
+
4. Enchantments (Optional)
+ {usedEnchantmentCapacity}/{Math.round(enchantmentCapacity)} capacity
+
+ {usedEnchantmentCapacity > enchantmentCapacity &&
Over capacity! Remove enchantments to save design.
}
+
+ {ALL_GOLEM_ENCHANTMENTS.map(enchant => {
+ const isSelected = selectedEnchantmentIds.includes(enchant.id);
+ const canAdd = !isSelected && usedEnchantmentCapacity + enchant.capacityCost <= enchantmentCapacity;
+ return (
+
+ );
+ })}
+
+
+ )}
+
+
+
+
+
+
+
+
+
Stats Preview
+
+
+
+ );
+};
+
+GolemDesignBuilder.displayName = 'GolemDesignBuilder';
diff --git a/src/components/game/tabs/golemancy/GolemLoadoutPanel.tsx b/src/components/game/tabs/golemancy/GolemLoadoutPanel.tsx
new file mode 100644
index 0000000..ba88bb8
--- /dev/null
+++ b/src/components/game/tabs/golemancy/GolemLoadoutPanel.tsx
@@ -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 = 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 (
+
+
+
+
{entry.design.name}
+
+ {core?.name ?? '?'} + {frame?.name ?? '?'} + {circuit?.name ?? '?'}
+ {enchantments.length > 0 && ` + ${enchantments.length} enchantment(s)`}
+
+
+
+ {entry.enabled ? 'Enabled' : 'Disabled'}
+
+
+
+
+
+
+
+
+ );
+});
+
+LoadoutCard.displayName = 'LoadoutCard';
+
+// ─── Props ───────────────────────────────────────────────────────────────────
+
+export interface GolemLoadoutPanelProps {
+ loadout: GolemLoadoutEntry[];
+ onToggle: (designId: string) => void;
+ onRemove: (designId: string) => void;
+}
+
+// ─── Component ───────────────────────────────────────────────────────────────
+
+export const GolemLoadoutPanel: React.FC = ({
+ loadout,
+ onToggle,
+ onRemove,
+}) => {
+ return (
+
+ {loadout.length === 0 ? (
+
+
No golem designs saved yet.
+
Use the Design Builder to create and save golem designs.
+
+ ) : (
+
+ {loadout.map(entry => (
+
+ ))}
+
+ )}
+
+ );
+};
+
+GolemLoadoutPanel.displayName = 'GolemLoadoutPanel';
diff --git a/src/components/game/tabs/golemancy/GolemancyComponents.test.ts b/src/components/game/tabs/golemancy/GolemancyComponents.test.ts
new file mode 100644
index 0000000..5f9e515
--- /dev/null
+++ b/src/components/game/tabs/golemancy/GolemancyComponents.test.ts
@@ -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);
+ });
+});
diff --git a/src/components/game/tabs/golemancy/GolemancySharedComponents.tsx b/src/components/game/tabs/golemancy/GolemancySharedComponents.tsx
new file mode 100644
index 0000000..02c1c86
--- /dev/null
+++ b/src/components/game/tabs/golemancy/GolemancySharedComponents.tsx
@@ -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 {
+ label: string;
+ items: T[];
+ selectedId: string | null;
+ unlockedIds: Set;
+ onSelect: (id: string) => void;
+ renderItem: (item: T, unlocked: boolean, selected: boolean) => React.ReactNode;
+}
+
+export function ComponentSelector({
+ label, items, selectedId, unlockedIds, onSelect, renderItem,
+}: ComponentSelectorProps) {
+ return (
+
+
{label}
+
+ {items.map(item => {
+ const unlocked = unlockedIds.has(item.id);
+ const selected = selectedId === item.id;
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+interface StatsPreviewProps {
+ stats: ComputedGolemStats | null;
+ canAfford: { canAfford: boolean; missing: string };
+}
+
+export function StatsPreview({ stats, canAfford }: StatsPreviewProps) {
+ if (!stats) return Select all required components to see stats preview.
;
+ return (
+
+
+ {canAfford.canAfford ? '✓ Can afford summon cost' : `✗ Cannot afford: ${canAfford.missing}`}
+
+
+
Damage: {stats.baseDamage}
+
Atk Speed: {stats.attackSpeed}/h
+
Armor Pierce: {Math.round(stats.armorPierce * 100)}%
+
Magic Affinity: {Math.round(stats.magicAffinity * 100)}%
+
AoE Targets: {stats.aoeTargets}
+
Spell Slots: {stats.spellSlots}
+
Mana Capacity: {stats.manaCapacity}
+
Mana Regen: {stats.manaRegen}/h
+
Room Duration: {stats.maxRoomDuration} rooms
+
Enchant Cap: {Math.round(stats.enchantmentCapacity)}
+ {stats.specialEffect !== 'none' && (
+
Special:{' '}{stats.specialEffect}
+ )}
+
+
Summon Cost:{' '}
+ {stats.totalSummonCost.map((c, i) => {formatManaCost(c)}{i < stats.totalSummonCost.length - 1 ? ' + ' : ''})}
+
+
Upkeep:{' '}
+ {stats.upkeepCostPerHour.map((c, i) => {formatManaCost(c)}{i < stats.upkeepCostPerHour.length - 1 ? ' + ' : ''}/h)}
+
+
Mana Types:{' '}
+ {stats.availableManaTypes.map((mt, i) => {ELEMENTS[mt]?.sym ?? ''}{mt}{i < stats.availableManaTypes.length - 1 ? ', ' : ''})}
+
+
+ );
+}
diff --git a/src/components/game/tabs/golemancy/golemancy-components.test.ts b/src/components/game/tabs/golemancy/golemancy-components.test.ts
new file mode 100644
index 0000000..1c8c391
--- /dev/null
+++ b/src/components/game/tabs/golemancy/golemancy-components.test.ts
@@ -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);
+ });
+});
diff --git a/src/components/game/tabs/golemancy/golemancy-utils.test.ts b/src/components/game/tabs/golemancy/golemancy-utils.test.ts
new file mode 100644
index 0000000..858d392
--- /dev/null
+++ b/src/components/game/tabs/golemancy/golemancy-utils.test.ts
@@ -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);
+ });
+});
diff --git a/src/components/game/tabs/golemancy/golemancy-utils.ts b/src/components/game/tabs/golemancy/golemancy-utils.ts
new file mode 100644
index 0000000..6d73ec2
--- /dev/null
+++ b/src/components/game/tabs/golemancy/golemancy-utils.ts
@@ -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,
+ };
+}
diff --git a/src/components/game/tabs/golemancy/types.ts b/src/components/game/tabs/golemancy/types.ts
new file mode 100644
index 0000000..7adcdbe
--- /dev/null
+++ b/src/components/game/tabs/golemancy/types.ts
@@ -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 };
+}
diff --git a/src/lib/game/__tests__/combat-actions.test.ts b/src/lib/game/__tests__/combat-actions.test.ts
index 7a86b14..46180db 100644
--- a/src/lib/game/__tests__/combat-actions.test.ts
+++ b/src/lib/game/__tests__/combat-actions.test.ts
@@ -36,7 +36,7 @@ function resetStores() {
roomResetState: {},
clearedRooms: {},
isDescentComplete: false,
- golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
+ golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
equipmentSpellStates: [],
comboHitCount: 0,
floorHitCount: 0,
diff --git a/src/lib/game/__tests__/cross-module-helpers.ts b/src/lib/game/__tests__/cross-module-helpers.ts
index e3f9ea0..ed6b743 100644
--- a/src/lib/game/__tests__/cross-module-helpers.ts
+++ b/src/lib/game/__tests__/cross-module-helpers.ts
@@ -52,7 +52,7 @@ export function resetAllStores() {
roomResetState: {},
clearedRooms: {},
isDescentComplete: false,
- golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
+ golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
equipmentSpellStates: [],
comboHitCount: 0,
floorHitCount: 0,
diff --git a/src/lib/game/__tests__/enemy-defenses.test.ts b/src/lib/game/__tests__/enemy-defenses.test.ts
index 3a1b9bd..7d4a54f 100644
--- a/src/lib/game/__tests__/enemy-defenses.test.ts
+++ b/src/lib/game/__tests__/enemy-defenses.test.ts
@@ -39,7 +39,7 @@ function resetStores() {
roomResetState: {},
clearedRooms: {},
isDescentComplete: false,
- golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
+ golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
equipmentSpellStates: [],
comboHitCount: 0,
floorHitCount: 0,
diff --git a/src/lib/game/__tests__/melee-auto-attack.test.ts b/src/lib/game/__tests__/melee-auto-attack.test.ts
index e7b29d0..aa07a65 100644
--- a/src/lib/game/__tests__/melee-auto-attack.test.ts
+++ b/src/lib/game/__tests__/melee-auto-attack.test.ts
@@ -38,7 +38,7 @@ function resetStores() {
roomResetState: {},
clearedRooms: {},
isDescentComplete: false,
- golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
+ golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
equipmentSpellStates: [],
comboHitCount: 0,
floorHitCount: 0,
diff --git a/src/lib/game/__tests__/store-actions-combat-prestige.test.ts b/src/lib/game/__tests__/store-actions-combat-prestige.test.ts
index ce4df0a..17e8f32 100644
--- a/src/lib/game/__tests__/store-actions-combat-prestige.test.ts
+++ b/src/lib/game/__tests__/store-actions-combat-prestige.test.ts
@@ -18,7 +18,7 @@ function resetCombatStore() {
clearedFloors: {},
climbDirection: null,
isDescending: false,
- golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
+ golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
equipmentSpellStates: [],
comboHitCount: 0,
floorHitCount: 0,
diff --git a/src/lib/game/__tests__/store-actions.test.ts b/src/lib/game/__tests__/store-actions.test.ts
index 9c92d6e..42ac7a0 100644
--- a/src/lib/game/__tests__/store-actions.test.ts
+++ b/src/lib/game/__tests__/store-actions.test.ts
@@ -29,7 +29,7 @@ function resetCombatStore() {
clearedFloors: {},
climbDirection: null,
isDescending: false,
- golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
+ golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
equipmentSpellStates: [],
comboHitCount: 0,
floorHitCount: 0,
diff --git a/src/lib/game/__tests__/tick-integration.test.ts b/src/lib/game/__tests__/tick-integration.test.ts
index c2d4c1a..9655459 100644
--- a/src/lib/game/__tests__/tick-integration.test.ts
+++ b/src/lib/game/__tests__/tick-integration.test.ts
@@ -46,7 +46,7 @@ function resetAllStores() {
clearedFloors: {},
climbDirection: null,
isDescending: false,
- golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
+ golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
equipmentSpellStates: [],
comboHitCount: 0,
floorHitCount: 0,
diff --git a/src/lib/game/data/disciplines/fabricator.ts b/src/lib/game/data/disciplines/fabricator.ts
index c592f9c..6472742 100644
--- a/src/lib/game/data/disciplines/fabricator.ts
+++ b/src/lib/game/data/disciplines/fabricator.ts
@@ -22,7 +22,7 @@ export const fabricatorDisciplines: DisciplineDefinition[] = [
type: 'once',
threshold: 200,
value: 0,
- description: 'Unlock golem summoning',
+ description: 'Unlock golem design ability',
},
{
id: 'golem-2',
diff --git a/src/lib/game/data/golems/cores.ts b/src/lib/game/data/golems/cores.ts
new file mode 100644
index 0000000..259b60e
--- /dev/null
+++ b/src/lib/game/data/golems/cores.ts
@@ -0,0 +1,100 @@
+// ─── Core Definitions ────────────────────────────────────────────────────
+// Power sources for golems. Determine mana types, capacity, regen, upkeep, duration.
+
+import type { CoreDefinition } from './types';
+import { elemCost } from './types';
+
+// ───_BASIC CORE ────────────────────────────────────────────────────────────
+// Fabricator 2 — single Earth mana, modest stats
+const BASIC_CORE: CoreDefinition = {
+ id: 'basic',
+ tier: 1,
+ name: 'Basic Core',
+ description: 'A simple earth-infused core. Provides modest mana capacity and single-element support.',
+ manaTypes: ['earth'],
+ manaCapacity: 50,
+ manaRegen: 0.5,
+ maxRoomDuration: 3,
+ summonCost: [elemCost('earth', 10)],
+ primaryManaType: 'earth',
+ tierMultiplier: 1.0,
+ unlockRequirement: {
+ type: 'attunement_level',
+ attunement: 'fabricator',
+ level: 2,
+ },
+};
+
+// ─── INTERMEDIATE CORE ────────────────────────────────────────────────────
+// Fabricator 4 + Enchanter 2 — choose 2 mana types
+const INTERMEDIATE_CORE: CoreDefinition = {
+ id: 'intermediate',
+ tier: 2,
+ name: 'Intermediate Core',
+ description: 'A refined crystal core supporting two mana types. Player selects from unlocked elements.',
+ manaTypes: ['earth'], // Default; player overrides with chosen types
+ manaCapacity: 100,
+ manaRegen: 1.5,
+ maxRoomDuration: 4,
+ summonCost: [elemCost('crystal', 20)],
+ primaryManaType: 'crystal',
+ tierMultiplier: 1.5,
+ unlockRequirement: {
+ type: 'dual_attunement',
+ attunements: ['fabricator', 'enchanter'],
+ levels: [4, 2],
+ },
+};
+
+// ─── ADVANCED CORE ───────────────────────────────────────────────────────
+// Fabricator 6 + Enchanter 3 — choose 3 mana types
+const ADVANCED_CORE: CoreDefinition = {
+ id: 'advanced',
+ tier: 3,
+ name: 'Advanced Core',
+ description: 'A powerful crystal core supporting three mana types. Player selects from unlocked elements.',
+ manaTypes: ['earth'], // Default; player overrides with chosen types
+ manaCapacity: 200,
+ manaRegen: 3.0,
+ maxRoomDuration: 5,
+ summonCost: [elemCost('crystal', 30)],
+ primaryManaType: 'crystal',
+ tierMultiplier: 2.0,
+ unlockRequirement: {
+ type: 'dual_attunement',
+ attunements: ['fabricator', 'enchanter'],
+ levels: [6, 3],
+ },
+};
+
+// ─── GUARDIAN CORE ───────────────────────────────────────────────────────
+// Invoker 5 + Fabricator 5 + Guardian Pact — guardian-specific mana types
+const GUARDIAN_CORE: CoreDefinition = {
+ id: 'guardian',
+ tier: 4,
+ name: 'Guardian Core',
+ description: 'A legendary core imbued with guardian energy. Provides all mana types granted by the chosen Guardian. Required for Guardian Constructs.',
+ manaTypes: ['earth'], // Overridden by guardian-specific types when assigned
+ manaCapacity: 500,
+ manaRegen: 10.0,
+ maxRoomDuration: 8,
+ summonCost: [elemCost('earth', 50)], // Guardian-specific in practice
+ primaryManaType: 'earth', // Overridden by guardian pact
+ tierMultiplier: 3.0,
+ unlockRequirement: {
+ type: 'guardian_pact',
+ attunements: ['invoker', 'fabricator'],
+ levels: [5, 5],
+ },
+};
+
+// ─── CORE REGISTRY ───────────────────────────────────────────────────────
+
+export const CORES: Record = {
+ [BASIC_CORE.id]: BASIC_CORE,
+ [INTERMEDIATE_CORE.id]: INTERMEDIATE_CORE,
+ [ADVANCED_CORE.id]: ADVANCED_CORE,
+ [GUARDIAN_CORE.id]: GUARDIAN_CORE,
+};
+
+export const ALL_CORES = [BASIC_CORE, INTERMEDIATE_CORE, ADVANCED_CORE, GUARDIAN_CORE];
diff --git a/src/lib/game/data/golems/frames.ts b/src/lib/game/data/golems/frames.ts
new file mode 100644
index 0000000..5dac4a3
--- /dev/null
+++ b/src/lib/game/data/golems/frames.ts
@@ -0,0 +1,155 @@
+// ─── Frame Definitions ───────────────────────────────────────────────────
+// Physical combat characteristics for golems: damage, speed, armor pierce, magic affinity, special.
+
+import type { FrameDefinition } from './types';
+import { elemCost, rawCost } from './types';
+
+const EARTH_FRAME: FrameDefinition = {
+ id: 'earth',
+ name: 'Earth Frame',
+ description: 'A sturdy construct of stone and soil. Balanced but unremarkable.',
+ baseDamage: 6,
+ attackSpeed: 1.2,
+ armorPierce: 0.05,
+ magicAffinity: 0.3,
+ aoeTargets: 1,
+ element: 'earth',
+ specialEffect: 'none',
+ summonCost: [rawCost(5)],
+ unlockRequirement: {
+ type: 'attunement_level',
+ attunement: 'fabricator',
+ level: 2,
+ },
+};
+
+const SAND_FRAME: FrameDefinition = {
+ id: 'sand',
+ name: 'Sand Frame',
+ description: 'A shifting construct of sand particles. Hits multiple enemies with high armor pierce.',
+ baseDamage: 8,
+ attackSpeed: 1.0,
+ armorPierce: 0.6,
+ magicAffinity: 0.5,
+ aoeTargets: 2,
+ element: 'sand',
+ specialEffect: 'aoe',
+ summonCost: [elemCost('sand', 8)],
+ unlockRequirement: {
+ type: 'mana_unlocked',
+ manaType: 'sand',
+ },
+};
+
+const FROST_FRAME: FrameDefinition = {
+ id: 'frost',
+ name: 'Frost Frame',
+ description: 'An icy construct that slows enemies on hit. High magic affinity.',
+ baseDamage: 10,
+ attackSpeed: 1.2,
+ armorPierce: 0.25,
+ magicAffinity: 0.8,
+ aoeTargets: 1,
+ element: 'frost',
+ specialEffect: 'slow',
+ summonCost: [elemCost('frost', 10)],
+ unlockRequirement: {
+ type: 'mana_unlocked',
+ manaType: 'frost',
+ },
+};
+
+const CRYSTAL_FRAME: FrameDefinition = {
+ id: 'crystal',
+ name: 'Crystal Frame',
+ description: 'A prismatic construct dealing high damage with precision. Very high magic affinity.',
+ baseDamage: 14,
+ attackSpeed: 1.8,
+ armorPierce: 0.15,
+ magicAffinity: 0.9,
+ aoeTargets: 1,
+ element: 'crystal',
+ specialEffect: 'none',
+ summonCost: [elemCost('crystal', 12)],
+ unlockRequirement: {
+ type: 'mana_unlocked',
+ manaType: 'crystal',
+ },
+};
+
+const STEEL_FRAME: FrameDefinition = {
+ id: 'steel',
+ name: 'Steel Frame',
+ description: 'Forged from metal, this frame delivers devastating attacks with high armor pierce.',
+ baseDamage: 18,
+ attackSpeed: 1.6,
+ armorPierce: 0.5,
+ magicAffinity: 0.5,
+ aoeTargets: 1,
+ element: 'metal',
+ specialEffect: 'none',
+ summonCost: [elemCost('metal', 14)],
+ unlockRequirement: {
+ type: 'mana_unlocked',
+ manaType: 'metal',
+ },
+};
+
+const SHADOWGLASS_FRAME: FrameDefinition = {
+ id: 'shadowglass',
+ name: 'Shadowglass Frame',
+ description: 'Volcanic glass animated by shadow. Extremely fast AoE attacks with devastating magic affinity.',
+ baseDamage: 20,
+ attackSpeed: 2.5,
+ armorPierce: 0.65,
+ magicAffinity: 0.95,
+ aoeTargets: 2,
+ element: 'shadowglass',
+ specialEffect: 'aoe',
+ summonCost: [elemCost('shadowglass', 18), rawCost(10)],
+ unlockRequirement: {
+ type: 'mana_unlocked',
+ manaType: 'shadowglass',
+ },
+};
+
+const CRYSTAL_STEEL_HYBRID_FRAME: FrameDefinition = {
+ id: 'crystalSteelHybrid',
+ name: 'Crystal-Steel Hybrid Frame',
+ description: 'An advanced hybrid frame capable of housing Guardian Cores. Highest combined stats. Required for Guardian Constructs.',
+ baseDamage: 22,
+ attackSpeed: 2.8,
+ armorPierce: 0.7,
+ magicAffinity: 1.0,
+ aoeTargets: 1,
+ element: 'crystal',
+ specialEffect: 'guardianConstruct',
+ summonCost: [elemCost('crystal', 20), elemCost('metal', 15), rawCost(15)],
+ unlockRequirement: {
+ type: 'attunement_level',
+ attunement: 'fabricator',
+ level: 5,
+ },
+};
+
+// ─── FRAME REGISTRY ─────────────────────────────────────────────────────
+
+export const FRAMES: Record = {
+ [EARTH_FRAME.id]: EARTH_FRAME,
+ [SAND_FRAME.id]: SAND_FRAME,
+ [FROST_FRAME.id]: FROST_FRAME,
+ [CRYSTAL_FRAME.id]: CRYSTAL_FRAME,
+ [STEEL_FRAME.id]: STEEL_FRAME,
+ [SHADOWGLASS_FRAME.id]: SHADOWGLASS_FRAME,
+ [CRYSTAL_STEEL_HYBRID_FRAME.id]: CRYSTAL_STEEL_HYBRID_FRAME,
+};
+
+export const ALL_FRAMES = [
+ EARTH_FRAME,
+ SAND_FRAME,
+ FROST_FRAME,
+ CRYSTAL_FRAME,
+ STEEL_FRAME,
+ SHADOWGLASS_FRAME,
+ CRYSTAL_STEEL_HYBRID_FRAME,
+];
diff --git a/src/lib/game/data/golems/golemEnchantments.ts b/src/lib/game/data/golems/golemEnchantments.ts
new file mode 100644
index 0000000..2b1e822
--- /dev/null
+++ b/src/lib/game/data/golems/golemEnchantments.ts
@@ -0,0 +1,102 @@
+// ─── Golem Enchantment Definitions ───────────────────────────────────────
+// Optional sword effects applied to golem basic attacks.
+// Requires Enchanter 5 + Fabricator 5.
+
+import type { GolemEnchantmentDefinition } from './types';
+import { elemCost } from './types';
+
+const SWORD_FIRE: GolemEnchantmentDefinition = {
+ id: 'sword_fire',
+ name: 'Sword: Fire',
+ description: 'Applies Burn DoT on basic attack.',
+ effect: 'burn',
+ capacityCost: 10,
+ summonCost: [elemCost('fire', 5)],
+};
+
+const SWORD_FROST: GolemEnchantmentDefinition = {
+ id: 'sword_frost',
+ name: 'Sword: Frost',
+ description: 'Applies additional Slow on basic attack.',
+ effect: 'slow',
+ capacityCost: 10,
+ summonCost: [elemCost('frost', 5)],
+};
+
+const SWORD_LIGHTNING: GolemEnchantmentDefinition = {
+ id: 'sword_lightning',
+ name: 'Sword: Lightning',
+ description: 'Chance to Shock (stun) on basic attack.',
+ effect: 'shock',
+ capacityCost: 12,
+ summonCost: [elemCost('lightning', 6)],
+};
+
+const SWORD_SHADOW: GolemEnchantmentDefinition = {
+ id: 'sword_shadow',
+ name: 'Sword: Shadow',
+ description: 'Chance to Weaken (reduce enemy damage) on basic attack.',
+ effect: 'weaken',
+ capacityCost: 12,
+ summonCost: [elemCost('dark', 6)],
+};
+
+const SWORD_METAL: GolemEnchantmentDefinition = {
+ id: 'sword_metal',
+ name: 'Sword: Metal',
+ description: 'Bonus Armor Pierce on basic attack.',
+ effect: 'armorPierce',
+ capacityCost: 8,
+ summonCost: [elemCost('metal', 5)],
+};
+
+const SWORD_CRYSTAL: GolemEnchantmentDefinition = {
+ id: 'sword_crystal',
+ name: 'Sword: Crystal',
+ description: 'Bonus Critical Chance on basic attack.',
+ effect: 'criticalChance',
+ capacityCost: 14,
+ summonCost: [elemCost('crystal', 7)],
+};
+
+const SWORD_WATER: GolemEnchantmentDefinition = {
+ id: 'sword_water',
+ name: 'Sword: Water',
+ description: 'Applies Soak on basic attack (increases lightning damage taken).',
+ effect: 'soak',
+ capacityCost: 8,
+ summonCost: [elemCost('water', 4)],
+};
+
+const SWORD_EARTH: GolemEnchantmentDefinition = {
+ id: 'sword_earth',
+ name: 'Sword: Earth',
+ description: 'Bonus damage to shielded enemies on basic attack.',
+ effect: 'shieldBreak',
+ capacityCost: 10,
+ summonCost: [elemCost('earth', 5)],
+};
+
+// ─── ENCHANTMENT REGISTRY ────────────────────────────────────────────────
+
+export const GOLEM_ENCHANTMENTS: Record = {
+ [SWORD_FIRE.id]: SWORD_FIRE,
+ [SWORD_FROST.id]: SWORD_FROST,
+ [SWORD_LIGHTNING.id]: SWORD_LIGHTNING,
+ [SWORD_SHADOW.id]: SWORD_SHADOW,
+ [SWORD_METAL.id]: SWORD_METAL,
+ [SWORD_CRYSTAL.id]: SWORD_CRYSTAL,
+ [SWORD_WATER.id]: SWORD_WATER,
+ [SWORD_EARTH.id]: SWORD_EARTH,
+};
+
+export const ALL_GOLEM_ENCHANTMENTS = [
+ SWORD_FIRE,
+ SWORD_FROST,
+ SWORD_LIGHTNING,
+ SWORD_SHADOW,
+ SWORD_METAL,
+ SWORD_CRYSTAL,
+ SWORD_WATER,
+ SWORD_EARTH,
+];
diff --git a/src/lib/game/data/golems/golems-data.ts b/src/lib/game/data/golems/golems-data.ts
index e9bb79e..89f7e8b 100644
--- a/src/lib/game/data/golems/golems-data.ts
+++ b/src/lib/game/data/golems/golems-data.ts
@@ -1,14 +1,8 @@
-// ─── Golem Definitions Data ─────────────────────────
-// Combined golem definitions from all golem modules.
-// Extracted to a standalone module to avoid circular dependencies
-// between index.ts and utils.ts.
+// ─── Golem Definitions Data ──────────────────────────────────────────────
+// Combined component registries for the component-based golem system.
+// Extracted to a standalone module to avoid circular dependencies.
-import { BASE_GOLEMS } from './base-golems';
-import { ELEMENTAL_GOLEMS } from './elemental-golems';
-import { HYBRID_GOLEMS } from './hybrid-golems';
-
-export const GOLEMS_DEF = {
- ...BASE_GOLEMS,
- ...ELEMENTAL_GOLEMS,
- ...HYBRID_GOLEMS,
-};
+export { CORES, ALL_CORES } from './cores';
+export { FRAMES, ALL_FRAMES } from './frames';
+export { MIND_CIRCUITS, ALL_MIND_CIRCUITS } from './mindCircuits';
+export { GOLEM_ENCHANTMENTS, ALL_GOLEM_ENCHANTMENTS } from './golemEnchantments';
diff --git a/src/lib/game/data/golems/index.ts b/src/lib/game/data/golems/index.ts
index 1a814e9..a3c0fcc 100644
--- a/src/lib/game/data/golems/index.ts
+++ b/src/lib/game/data/golems/index.ts
@@ -1,23 +1,31 @@
-// ─── Golem Definitions Index ───────────────────────
-// Re-exports from all golem modules
+// ─── Golem Definitions Index ─────────────────────────────────────────────
+// Barrel exports for the component-based golem system.
// Re-export types
-export type { GolemDef, GolemManaCost } from './types';
+export type {
+ CoreDefinition,
+ CoreId,
+ FrameDefinition,
+ FrameId,
+ FrameSpecial,
+ MindCircuitDefinition,
+ MindCircuitId,
+ CircuitBehavior,
+ GolemEnchantmentDefinition,
+ GolemDesign,
+ ComputedGolemStats,
+ GolemManaCost,
+ GolemUnlockRequirement,
+ ActiveGolemV2,
+} from './types';
-// Re-export combined golems data (extracted to avoid circular deps)
-export { GOLEMS_DEF } from './golems-data';
+export { elemCost, rawCost } from './types';
-// Re-export utility functions
-export {
- getGolemSlots,
- isGolemUnlocked,
- getUnlockedGolems,
- getGolemDamage,
- getGolemAttackSpeed,
- getGolemFloorDuration,
- getGolemMaintenanceMultiplier,
- canAffordGolemSummon,
- deductGolemSummonCost,
- canAffordGolemMaintenance,
- deductGolemMaintenance,
-} from './utils';
+// Re-export component registries
+export { CORES, ALL_CORES } from './cores';
+export { FRAMES, ALL_FRAMES } from './frames';
+export { MIND_CIRCUITS, ALL_MIND_CIRCUITS } from './mindCircuits';
+export { GOLEM_ENCHANTMENTS, ALL_GOLEM_ENCHANTMENTS } from './golemEnchantments';
+
+// Legacy re-exports (deprecated, kept for migration)
+export type { GolemDef } from './types';
diff --git a/src/lib/game/data/golems/mindCircuits.ts b/src/lib/game/data/golems/mindCircuits.ts
new file mode 100644
index 0000000..6cfd4d4
--- /dev/null
+++ b/src/lib/game/data/golems/mindCircuits.ts
@@ -0,0 +1,77 @@
+// ─── Mind Circuit Definitions ────────────────────────────────────────────
+// Behavior logic for golems: basic attacks, spell casting patterns.
+
+import type { MindCircuitDefinition } from './types';
+import { elemCost, rawCost } from './types';
+
+const SIMPLE_CIRCUIT: MindCircuitDefinition = {
+ id: 'simple',
+ name: 'Simple Logic Circuit',
+ description: 'Performs basic attacks only. Targets nearest enemy. No spell casting.',
+ spellSlots: 0,
+ behavior: 'basicOnly',
+ summonCost: [rawCost(3)],
+ unlockRequirement: {
+ type: 'attunement_level',
+ attunement: 'fabricator',
+ level: 1,
+ },
+};
+
+const INTERMEDIATE_CIRCUIT: MindCircuitDefinition = {
+ id: 'intermediate',
+ name: 'Intermediate Logic Circuit',
+ description: 'Casts 1 selected spell when mana is available. Otherwise performs basic attacks.',
+ spellSlots: 1,
+ behavior: 'castSpell1',
+ summonCost: [elemCost('crystal', 8)],
+ unlockRequirement: {
+ type: 'dual_attunement',
+ attunements: ['enchanter', 'fabricator'],
+ levels: [2, 3],
+ },
+};
+
+const ADVANCED_CIRCUIT: MindCircuitDefinition = {
+ id: 'advanced',
+ name: 'Advanced Logic Circuit',
+ description: 'Casts 2 selected spells in alternating order: A → B → A → B... Falls back to basic attacks if mana is insufficient.',
+ spellSlots: 2,
+ behavior: 'alternate2',
+ summonCost: [elemCost('crystal', 12)],
+ unlockRequirement: {
+ type: 'dual_attunement',
+ attunements: ['enchanter', 'fabricator'],
+ levels: [3, 4],
+ },
+};
+
+const GUARDIAN_CIRCUIT: MindCircuitDefinition = {
+ id: 'guardian',
+ name: 'Guardian Circuit',
+ description: 'Required for Guardian Constructs. Cycles through one spell per mana type from the Guardian Core. Falls back to basic attacks if mana is insufficient.',
+ spellSlots: 4, // Typically 3-4 depending on guardian
+ behavior: 'cycleAll',
+ summonCost: [elemCost('crystal', 25), rawCost(10)],
+ unlockRequirement: {
+ type: 'dual_attunement',
+ attunements: ['invoker', 'fabricator'],
+ levels: [5, 5],
+ },
+};
+
+// ─── MIND CIRCUIT REGISTRY ───────────────────────────────────────────────
+
+export const MIND_CIRCUITS: Record = {
+ [SIMPLE_CIRCUIT.id]: SIMPLE_CIRCUIT,
+ [INTERMEDIATE_CIRCUIT.id]: INTERMEDIATE_CIRCUIT,
+ [ADVANCED_CIRCUIT.id]: ADVANCED_CIRCUIT,
+ [GUARDIAN_CIRCUIT.id]: GUARDIAN_CIRCUIT,
+};
+
+export const ALL_MIND_CIRCUITS = [
+ SIMPLE_CIRCUIT,
+ INTERMEDIATE_CIRCUIT,
+ ADVANCED_CIRCUIT,
+ GUARDIAN_CIRCUIT,
+];
diff --git a/src/lib/game/data/golems/types.ts b/src/lib/game/data/golems/types.ts
index b43bfd9..29de600 100644
--- a/src/lib/game/data/golems/types.ts
+++ b/src/lib/game/data/golems/types.ts
@@ -1,8 +1,11 @@
-// ─── Golem Types ─────────────────────────────────────────────────
+// ─── Golem Component Types ──────────────────────────────────────────────
+// Component-based construction system: Core + Frame + Mind Circuit + Enchantments.
+// Replaces the legacy predefined GolemDef system.
import type { SpellCost } from '../../types';
-// Golem mana cost helper
+// ─── Mana Cost Helpers ───────────────────────────────────────────────────
+
export function elemCost(element: string, amount: number): SpellCost {
return { type: 'element', element, amount };
}
@@ -17,19 +20,151 @@ export interface GolemManaCost {
amount: number;
}
+// ─── Unlock Requirements ─────────────────────────────────────────────────
+
+export interface GolemUnlockRequirement {
+ type: 'attunement_level' | 'mana_unlocked' | 'dual_attunement' | 'guardian_pact';
+ attunement?: string;
+ level?: number;
+ manaType?: string;
+ attunements?: string[];
+ levels?: number[];
+}
+
+// ─── Core Definition ─────────────────────────────────────────────────────
+
+export type CoreId = 'basic' | 'intermediate' | 'advanced' | 'guardian';
+
+export interface CoreDefinition {
+ id: CoreId;
+ tier: 1 | 2 | 3 | 4;
+ name: string;
+ description: string;
+ /** Mana types available (basic = [earth], guardian = guardian-specific) */
+ manaTypes: string[];
+ manaCapacity: number;
+ manaRegen: number;
+ maxRoomDuration: number;
+ summonCost: GolemManaCost[];
+ /** Primary mana type for upkeep calculation */
+ primaryManaType: string;
+ tierMultiplier: number; // For enchantment capacity: 1.0 / 1.5 / 2.0 / 3.0
+ unlockRequirement: GolemUnlockRequirement;
+}
+
+// ─── Frame Definition ────────────────────────────────────────────────────
+
+export type FrameId = 'earth' | 'sand' | 'frost' | 'crystal' | 'steel' | 'shadowglass' | 'crystalSteelHybrid';
+
+export type FrameSpecial = 'none' | 'aoe' | 'slow' | 'guardianConstruct';
+
+export interface FrameDefinition {
+ id: FrameId;
+ name: string;
+ description: string;
+ baseDamage: number;
+ attackSpeed: number; // Attacks per in-game hour
+ armorPierce: number; // 0–1 fraction of enemy armor bypassed
+ magicAffinity: number; // 0.0–1.0+, spell damage efficiency
+ aoeTargets: number; // 1 = single target, >1 = AoE
+ /** Element for elemental matchup (derived from unlock mana type) */
+ element?: string;
+ specialEffect: FrameSpecial;
+ summonCost: GolemManaCost[];
+ unlockRequirement: GolemUnlockRequirement;
+}
+
+// ─── Mind Circuit Definition ────────────────────────────────────────────
+
+export type MindCircuitId = 'simple' | 'intermediate' | 'advanced' | 'guardian';
+
+export type CircuitBehavior = 'basicOnly' | 'castSpell1' | 'alternate2' | 'cycleAll';
+
+export interface MindCircuitDefinition {
+ id: MindCircuitId;
+ name: string;
+ description: string;
+ spellSlots: number;
+ behavior: CircuitBehavior;
+ summonCost: GolemManaCost[];
+ unlockRequirement: GolemUnlockRequirement;
+}
+
+// ─── Golem Enchantment Definition ───────────────────────────────────────
+
+export interface GolemEnchantmentDefinition {
+ id: string;
+ name: string;
+ description: string;
+ effect: string;
+ capacityCost: number;
+ summonCost: GolemManaCost[];
+}
+
+// ─── Golem Design (Player-Created) ──────────────────────────────────────
+
+export interface GolemDesign {
+ id: string; // Player-assigned or auto-generated
+ name: string; // Player-defined name
+ core: CoreDefinition;
+ frame: FrameDefinition;
+ mindCircuit: MindCircuitDefinition;
+ enchantments: GolemEnchantmentDefinition[]; // Optional, 0-N
+ /** Player-selected mana types for cores that support choice */
+ selectedManaTypes: string[];
+ /** Player-selected spell IDs for mind circuits with spell slots */
+ selectedSpells: string[];
+}
+
+// ─── Computed Design Stats (derived from components) ────────────────────
+
+export interface ComputedGolemStats {
+ maxRoomDuration: number;
+ totalSummonCost: GolemManaCost[];
+ upkeepCostPerHour: GolemManaCost[];
+ manaCapacity: number;
+ manaRegen: number;
+ baseDamage: number;
+ attackSpeed: number;
+ armorPierce: number;
+ magicAffinity: number;
+ aoeTargets: number;
+ spellSlots: number;
+ availableManaTypes: string[];
+ enchantmentCapacity: number;
+ specialEffect: FrameSpecial;
+}
+
+// ─── Runtime Active Golem (in combat) ───────────────────────────────────
+
+export interface ActiveGolemV2 {
+ /** Reference to the GolemDesign used */
+ designId: string;
+ design: GolemDesign;
+ summonedFloor: number;
+ attackProgress: number;
+ roomsRemaining: number;
+ currentMana: number;
+ /** Index for alternating/cycling spells */
+ spellCastIndex: number;
+}
+
+// ─── Legacy Type (kept for backward compat during migration) ────────────
+
+/** @deprecated Use GolemDesign instead */
export interface GolemDef {
id: string;
name: string;
description: string;
- baseManaType: string; // The primary mana type this golem uses
- summonCost: GolemManaCost[]; // Cost to summon (can be multiple types)
- maintenanceCost: GolemManaCost[]; // Cost per hour to maintain
- damage: number; // Base damage per attack
- attackSpeed: number; // Attacks per hour
- hp: number; // Golem HP (for display, they don't take damage)
- armorPierce: number; // Armor piercing (0-1)
- isAoe: boolean; // Whether golem attacks are AOE
- aoeTargets: number; // Number of targets for AOE
+ baseManaType: string;
+ summonCost: GolemManaCost[];
+ maintenanceCost: GolemManaCost[];
+ damage: number;
+ attackSpeed: number;
+ hp: number;
+ armorPierce: number;
+ isAoe: boolean;
+ aoeTargets: number;
unlockCondition: {
type: 'attunement_level' | 'mana_unlocked' | 'dual_attunement';
attunement?: string;
@@ -38,7 +173,7 @@ export interface GolemDef {
attunements?: string[];
levels?: number[];
};
- tier: number; // Power tier (1-4)
- maxRoomDuration: number; // Rooms before golem disappears (spec §9.6)
- specialAbilities?: { name: string; description: string }[]; // Special abilities
+ tier: number;
+ maxRoomDuration: number;
+ specialAbilities?: { name: string; description: string }[];
}
diff --git a/src/lib/game/data/golems/utils.ts b/src/lib/game/data/golems/utils.ts
index a646069..878efb3 100644
--- a/src/lib/game/data/golems/utils.ts
+++ b/src/lib/game/data/golems/utils.ts
@@ -1,204 +1,219 @@
-// ─── Golem Helper Functions ─────────────────────────
+// ─── Golem Helper Functions ──────────────────────────────────────────────
+// Component-based construction system utilities.
-import type { GolemDef, GolemManaCost } from './types';
-import { GOLEMS_DEF } from './golems-data';
+import type {
+ ComputedGolemStats,
+ GolemDesign,
+ GolemManaCost,
+ GolemUnlockRequirement,
+ ActiveGolemV2,
+} from './types';
+import { CORES } from './cores';
+import { FRAMES } from './frames';
+import { MIND_CIRCUITS } from './mindCircuits';
-// Get golem slots based on Fabricator attunement level
-// Level 2 = 1, Level 4 = 2, Level 6 = 3, Level 8 = 4, Level 10 = 5
+// ─── Golem Slots ──────────────────────────────────────────────────────────
+
+/**
+ * Get base golem slots from Fabricator attunement level.
+ * Level 2 = 1, Level 4 = 2, Level 6 = 3, Level 8 = 4, Level 10 = 5
+ */
export function getGolemSlots(fabricatorLevel: number): number {
if (fabricatorLevel < 2) return 0;
return Math.floor(fabricatorLevel / 2);
}
-// Check if a golem is unlocked based on player state
-export function isGolemUnlocked(
- golemId: string,
+// ─── Unlock Checks ────────────────────────────────────────────────────────
+
+/**
+ * Check if a component is unlocked based on player state.
+ */
+export function isComponentUnlocked(
+ requirement: GolemUnlockRequirement,
attunements: Record,
- unlockedElements: string[]
+ unlockedElements: string[],
+ signedGuardianPacts: number[],
): boolean {
- const golem = GOLEMS_DEF[golemId];
- if (!golem) return false;
-
- const condition = golem.unlockCondition;
-
- switch (condition.type) {
- case 'attunement_level':
- const attState = attunements[condition.attunement || ''];
- return attState?.active && (attState.level || 1) >= (condition.level || 1);
-
+ switch (requirement.type) {
+ case 'attunement_level': {
+ const attState = attunements[requirement.attunement || ''];
+ return !!attState?.active && (attState.level || 1) >= (requirement.level || 1);
+ }
case 'mana_unlocked':
- return unlockedElements.includes(condition.manaType || '');
-
- case 'dual_attunement':
- if (!condition.attunements || !condition.levels) return false;
- return condition.attunements.every((attId, idx) => {
+ return unlockedElements.includes(requirement.manaType || '');
+ case 'dual_attunement': {
+ if (!requirement.attunements || !requirement.levels) return false;
+ return requirement.attunements.every((attId, idx) => {
const att = attunements[attId];
- return att?.active && (att.level || 1) >= condition.levels![idx];
+ return att?.active && (att.level || 1) >= requirement.levels![idx];
});
-
+ }
+ case 'guardian_pact': {
+ // Requires dual attunement plus at least one guardian pact
+ if (!requirement.attunements || !requirement.levels) return false;
+ const attOk = requirement.attunements.every((attId, idx) => {
+ const att = attunements[attId];
+ return att?.active && (att.level || 1) >= requirement.levels![idx];
+ });
+ return attOk && signedGuardianPacts.length > 0;
+ }
default:
return false;
}
}
-// Get all unlocked golems for a player
-export function getUnlockedGolems(
- attunements: Record,
- unlockedElements: string[]
-): GolemDef[] {
- return Object.values(GOLEMS_DEF).filter(golem =>
- isGolemUnlocked(golem.id, attunements, unlockedElements)
- ) as GolemDef[];
+// ─── Computed Stats ───────────────────────────────────────────────────────
+
+/**
+ * Compute all derived stats for a golem design from its components.
+ */
+export function computeGolemStats(design: GolemDesign): ComputedGolemStats {
+ const core = design.core;
+ const frame = design.frame;
+ const circuit = design.mindCircuit;
+ const enchantments = design.enchantments;
+
+ // Total summon cost from all components
+ const totalSummonCost: GolemManaCost[] = [
+ ...core.summonCost,
+ ...frame.summonCost,
+ ...circuit.summonCost,
+ ...enchantments.flatMap((e) => e.summonCost),
+ ];
+
+ // Player upkeep = Core.manaRegen × 2 per hour (spec §13)
+ const upkeepCostPerHour: GolemManaCost[] = [
+ {
+ type: 'element',
+ element: core.primaryManaType,
+ amount: core.manaRegen * 2,
+ },
+ ];
+
+ // Enchantment capacity = Frame.MagicAffinity × Core.TierMultiplier
+ const enchantmentCapacity = frame.magicAffinity * core.tierMultiplier;
+
+ return {
+ maxRoomDuration: core.maxRoomDuration,
+ totalSummonCost,
+ upkeepCostPerHour,
+ manaCapacity: core.manaCapacity,
+ manaRegen: core.manaRegen,
+ baseDamage: frame.baseDamage,
+ attackSpeed: frame.attackSpeed,
+ armorPierce: frame.armorPierce,
+ magicAffinity: frame.magicAffinity,
+ aoeTargets: frame.aoeTargets,
+ spellSlots: circuit.spellSlots,
+ availableManaTypes: design.selectedManaTypes.length > 0
+ ? design.selectedManaTypes
+ : core.manaTypes,
+ enchantmentCapacity,
+ specialEffect: frame.specialEffect,
+ };
}
-// Calculate golem damage with skill bonuses
+// ─── Summoning Cost Checks ────────────────────────────────────────────────
+
+/**
+ * Check if player can afford to summon a golem design.
+ */
+export function canAffordGolemDesign(
+ design: GolemDesign,
+ rawMana: number,
+ elements: Record,
+): { canAfford: boolean; missing: string } {
+ const stats = computeGolemStats(design);
+
+ for (const cost of stats.totalSummonCost) {
+ if (cost.type === 'raw') {
+ if (rawMana < cost.amount) {
+ return { canAfford: false, missing: `raw mana (${cost.amount} needed)` };
+ }
+ } else if (cost.element) {
+ const elem = elements[cost.element];
+ if (!elem || !elem.unlocked) {
+ return { canAfford: false, missing: `${cost.element} mana (not unlocked)` };
+ }
+ if (elem.current < cost.amount) {
+ return { canAfford: false, missing: `${cost.element} mana (${cost.amount} needed, have ${elem.current})` };
+ }
+ }
+ }
+
+ return { canAfford: true, missing: '' };
+}
+
+// ─── Active Golem V2 Helpers ──────────────────────────────────────────────
+
+/**
+ * Create a new ActiveGolemV2 from a GolemDesign for combat.
+ */
+export function createActiveGolem(
+ design: GolemDesign,
+ currentFloor: number,
+): ActiveGolemV2 {
+ return {
+ designId: design.id,
+ design,
+ summonedFloor: currentFloor,
+ attackProgress: 0,
+ roomsRemaining: design.core.maxRoomDuration,
+ currentMana: design.core.manaCapacity, // Starts full
+ spellCastIndex: 0,
+ };
+}
+
+// ─── Component Lookups ────────────────────────────────────────────────────
+
+/** Get a CoreDefinition by ID */
+export function getCore(id: string) {
+ return CORES[id] || null;
+}
+
+/** Get a FrameDefinition by ID */
+export function getFrame(id: string) {
+ return FRAMES[id] || null;
+}
+
+/** Get a MindCircuitDefinition by ID */
+export function getMindCircuit(id: string) {
+ return MIND_CIRCUITS[id] || null;
+}
+
+// ─── Legacy Compatibility ────────────────────────────────────────────────
+
+/**
+ * @deprecated Use getGolemSlots instead
+ */
+export function getGolemFloorDuration(_skills: Record): number {
+ return 3; // Default room duration for legacy calls
+}
+
+/**
+ * @deprecated Use computeGolemStats instead
+ */
export function getGolemDamage(
golemId: string,
- skills: Record
+ _skills: Record,
): number {
- const golem = GOLEMS_DEF[golemId];
- if (!golem) return 0;
-
- let damage = golem.damage;
-
- // Golem Mastery skill bonus
- const masteryBonus = 1 + (skills.golemMastery || 0) * 0.1;
- damage *= masteryBonus;
-
- return damage;
+ // Legacy lookup — returns 0 for component-based golems
+ return 0;
}
-// Calculate golem attack speed with skill bonuses
+/**
+ * @deprecated Use computeGolemStats instead
+ */
export function getGolemAttackSpeed(
golemId: string,
- skills: Record
+ _skills: Record,
): number {
- const golem = GOLEMS_DEF[golemId];
- if (!golem) return 0;
-
- let speed = golem.attackSpeed;
-
- // Golem Efficiency skill bonus
- const efficiencyBonus = 1 + (skills.golemEfficiency || 0) * 0.05;
- speed *= efficiencyBonus;
-
- return speed;
+ return 0;
}
-// Get floors golems can last (base 1, +1 per Golem Longevity skill level)
-export function getGolemFloorDuration(skills: Record): number {
- return 1 + (skills.golemLongevity || 0);
-}
-
-// Get maintenance cost multiplier (Golem Siphon reduces by 10% per level)
-export function getGolemMaintenanceMultiplier(skills: Record): number {
- return 1 - (skills.golemSiphon || 0) * 0.1;
-}
-
-// Check if player can afford golem summon cost
-export function canAffordGolemSummon(
- golemId: string,
- rawMana: number,
- elements: Record
-): boolean {
- const golem = GOLEMS_DEF[golemId];
- if (!golem) return false;
-
- for (const cost of golem.summonCost) {
- if (cost.type === 'raw') {
- if (rawMana < cost.amount) return false;
- } else if (cost.element) {
- const elem = elements[cost.element];
- if (!elem || !elem.unlocked || elem.current < cost.amount) return false;
- }
- }
-
- return true;
-}
-
-// Deduct golem summon cost from mana pools
-export function deductGolemSummonCost(
- golemId: string,
- rawMana: number,
- elements: Record
-): { rawMana: number; elements: Record } {
- const golem = GOLEMS_DEF[golemId];
- if (!golem) return { rawMana, elements };
-
- let newRawMana = rawMana;
- let newElements = { ...elements };
-
- for (const cost of golem.summonCost) {
- if (cost.type === 'raw') {
- newRawMana -= cost.amount;
- } else if (cost.element && newElements[cost.element]) {
- newElements = {
- ...newElements,
- [cost.element]: {
- ...newElements[cost.element],
- current: newElements[cost.element].current - cost.amount,
- },
- };
- }
- }
-
- return { rawMana: newRawMana, elements: newElements };
-}
-
-// Check if player can afford golem maintenance for one tick
-export function canAffordGolemMaintenance(
- golemId: string,
- rawMana: number,
- elements: Record,
- skills: Record
-): boolean {
- const golem = GOLEMS_DEF[golemId];
- if (!golem) return false;
-
- const maintenanceMult = getGolemMaintenanceMultiplier(skills);
-
- for (const cost of golem.maintenanceCost) {
- const adjustedAmount = cost.amount * maintenanceMult;
- if (cost.type === 'raw') {
- if (rawMana < adjustedAmount) return false;
- } else if (cost.element) {
- const elem = elements[cost.element];
- if (!elem || !elem.unlocked || elem.current < adjustedAmount) return false;
- }
- }
-
- return true;
-}
-
-// Deduct golem maintenance cost for one tick
-export function deductGolemMaintenance(
- golemId: string,
- rawMana: number,
- elements: Record,
- skills: Record
-): { rawMana: number; elements: Record } {
- const golem = GOLEMS_DEF[golemId];
- if (!golem) return { rawMana, elements };
-
- const maintenanceMult = getGolemMaintenanceMultiplier(skills);
-
- let newRawMana = rawMana;
- let newElements = { ...elements };
-
- for (const cost of golem.maintenanceCost) {
- const adjustedAmount = cost.amount * maintenanceMult;
- if (cost.type === 'raw') {
- newRawMana -= adjustedAmount;
- } else if (cost.element && newElements[cost.element]) {
- newElements = {
- ...newElements,
- [cost.element]: {
- ...newElements[cost.element],
- current: newElements[cost.element].current - adjustedAmount,
- },
- };
- }
- }
-
- return { rawMana: newRawMana, elements: newElements };
+/**
+ * @deprecated Component-based system doesn't use skill-based maintenance multiplier
+ */
+export function getGolemMaintenanceMultiplier(_skills: Record): number {
+ return 1;
}
diff --git a/src/lib/game/stores/combat-actions.ts b/src/lib/game/stores/combat-actions.ts
index ba8ddfe..8fb09f1 100644
--- a/src/lib/game/stores/combat-actions.ts
+++ b/src/lib/game/stores/combat-actions.ts
@@ -7,10 +7,14 @@ import { getGuardianForFloor } from '../data/guardian-encounters';
import type { CombatStore, CombatState } from './combat-state.types';
import type { SpellState, EnemyState, EquipmentInstance, FloorState } from '../types';
import { applyOnHitEffect, processDoTPhase } from './dot-runtime';
-import type { ActiveGolem } from '../types';
+import type { ActiveGolem, RuntimeActiveGolem } from '../types';
import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, calcMeleeDamage, canAffordSpellCost, deductSpellCost } from '../utils';
import { computeDisciplineEffects } from '../effects/discipline-effects';
-import { processGolemMaintenance, processGolemAttacks } from './golem-combat-actions';
+import {
+ processGolemMaintenance,
+ processGolemAttacks,
+ processGolemManaRegen,
+} from './golem-combat-actions';
import { applyDamageToRoom } from './combat-damage';
// ─── Result Type ───────────────────────────────────────────────────────────────
@@ -22,7 +26,7 @@ function makeDefaultCombatTickResult(
rawMana: number,
elements: Record,
state: CombatState,
- activeGolems: ActiveGolem[],
+ activeGolems: RuntimeActiveGolem[],
): CombatTickResult {
return {
rawMana,
@@ -52,7 +56,7 @@ export interface CombatTickResult {
maxFloorReached: number;
castProgress: number;
equipmentSpellStates: CombatState['equipmentSpellStates'];
- activeGolems: ActiveGolem[];
+ activeGolems: RuntimeActiveGolem[];
meleeSwordProgress: Record;
currentRoom: FloorState;
}
@@ -73,7 +77,7 @@ export function processCombatTick(
modifiedDamage?: number;
},
signedPacts: number[],
- golemancyState: { activeGolems: ActiveGolem[] },
+ golemancyState: { activeGolems: RuntimeActiveGolem[] },
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
applyEnemyDefenses: (
dmg: number,
@@ -94,9 +98,11 @@ export function processCombatTick(
}
try {
- // ─── Golem maintenance (spec §9.5) ──────────────────────────────────────
+ // ─── Golem maintenance (spec §13) ──────────────────────────────────────
+ const golemDesigns = state.golemancy.golemDesigns || {};
const maintenanceResult = processGolemMaintenance(
golemancyState.activeGolems,
+ golemDesigns,
rawMana,
elements,
);
@@ -105,6 +111,9 @@ export function processCombatTick(
elements = maintenanceResult.elements;
logMessages.push(...maintenanceResult.logMessages);
+ // ─── Golem mana regen (spec §12) ───────────────────────────────────────
+ activeGolems = processGolemManaRegen(activeGolems, golemDesigns);
+
// Write maintained golems back immediately so tick state stays consistent
set({ golemancy: { ...state.golemancy, activeGolems } });
@@ -289,15 +298,11 @@ export function processCombatTick(
}
}
- // ─── Golem attacks (spec §9.4) ───────────────────────────────────────────
+ // ─── Golem attacks (spec §11) ───────────────────────────────────────────
if (activeGolems.length > 0 && floorHP > 0) {
const golemResult = processGolemAttacks(
activeGolems,
- rawMana,
- elements,
- floorHP,
- floorMaxHP,
- currentFloor,
+ golemDesigns,
onDamageDealt,
golemApplyDamageToRoom,
);
diff --git a/src/lib/game/stores/combat-descent-actions.ts b/src/lib/game/stores/combat-descent-actions.ts
index 44df09e..3d83cc0 100644
--- a/src/lib/game/stores/combat-descent-actions.ts
+++ b/src/lib/game/stores/combat-descent-actions.ts
@@ -244,7 +244,7 @@ export function createEnterSpireMode(get: GetFn, set: SetFn) {
roomResetState: {},
descentPeak: null,
isDescentComplete: false,
- golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
+ golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
});
get().addActivityLog('floor_transition',
diff --git a/src/lib/game/stores/combat-state.types.ts b/src/lib/game/stores/combat-state.types.ts
index a07de75..50c2561 100644
--- a/src/lib/game/stores/combat-state.types.ts
+++ b/src/lib/game/stores/combat-state.types.ts
@@ -1,7 +1,7 @@
// ─── Combat State Types ────────────────────────────────────────────────────────
// Shared types for combat store and combat actions to avoid circular dependency
-import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, EnemyState, EquipmentInstance } from '../types';
+import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, RuntimeActiveGolem, EnemyState, EquipmentInstance, SerializedGolemDesign } from '../types';
/** Signature for the advanceRoomOrFloor callback to break circular dependency */
export type AdvanceRoomFn = (get: () => CombatStore, set: (s: Partial) => void) => void;
@@ -130,6 +130,9 @@ export interface CombatActions {
// Golemancy
toggleGolem: (golemId: string) => void;
setEnabledGolems: (golemIds: string[]) => void;
+ addGolemDesign: (design: SerializedGolemDesign) => void;
+ removeGolemDesign: (designId: string) => void;
+ toggleGolemLoadoutEntry: (designId: string) => void;
// Spells
learnSpell: (spellId: string) => void;
@@ -155,7 +158,7 @@ export interface CombatActions {
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
onDamageDealt: (damage: number) => { rawMana: number; elements: Record },
signedPacts: number[],
- golemancyState: { activeGolems: ActiveGolem[] },
+ golemancyState: { activeGolems: RuntimeActiveGolem[] },
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
applyEnemyDefenses: (
dmg: number,
@@ -177,7 +180,7 @@ export interface CombatActions {
maxFloorReached: number;
castProgress: number;
equipmentSpellStates: EquipmentSpellState[];
- activeGolems: ActiveGolem[];
+ activeGolems: RuntimeActiveGolem[];
meleeSwordProgress: Record;
currentRoom: FloorState;
};
diff --git a/src/lib/game/stores/combatStore.ts b/src/lib/game/stores/combatStore.ts
index ff336f3..414cb6b 100644
--- a/src/lib/game/stores/combatStore.ts
+++ b/src/lib/game/stores/combatStore.ts
@@ -4,7 +4,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createSafeStorage } from '../utils/safe-persist';
-import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, EnemyState, EquipmentInstance } from '../types';
+import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, RuntimeActiveGolem, EnemyState, EquipmentInstance } from '../types';
import { getFloorMaxHP } from '../utils';
import { generateSpireFloorState, getRoomsForFloor } from '../utils/spire-utils';
import { addActivityLogEntry } from '../utils/activity-log';
@@ -17,6 +17,9 @@ import {
import {
onEnterLibraryRoom, tickNonCombatRoom, skipNonCombatRoom, stayLongerInRoom,
} from './non-combat-room-actions';
+import {
+ addGolemDesign, removeGolemDesign, toggleGolemLoadoutEntry,
+} from './golemancy-actions';
export const useCombatStore = create()(
persist(
@@ -50,12 +53,17 @@ export const useCombatStore = create()(
clearedRooms: {},
isDescentComplete: false,
- // Golemancy
+ // Golemancy (component-based)
golemancy: {
+ // New component-based fields
+ golemDesigns: {},
+ golemLoadout: [],
+ activeGolems: [] as RuntimeActiveGolem[],
+ lastSummonFloor: 0,
+ // Legacy fields (deprecated)
enabledGolems: [],
summonedGolems: [],
- activeGolems: [],
- lastSummonFloor: 0,
+ legacyActiveGolems: [],
},
// Equipment spell states
@@ -196,24 +204,15 @@ export const useCombatStore = create()(
currentRoomIndex: 0,
roomsPerFloor: 1,
maxFloorReached: Math.max(s.maxFloorReached, 1),
- golemancy: { ...s.golemancy, activeGolems: [], summonedGolems: [] },
+ golemancy: { ...s.golemancy, activeGolems: [] as RuntimeActiveGolem[], summonedGolems: [], legacyActiveGolems: [] },
};
});
},
startClimbUp: () => set({ climbDirection: 'up', currentAction: 'climb' }),
-
startClimbDown: () => set({ climbDirection: 'down', currentAction: 'climb' }),
-
- startPracticing: () => set((s) => {
- if (s.currentAction !== 'meditate') return s;
- return { currentAction: 'practicing' };
- }),
-
- stopPracticing: () => set((s) => {
- if (s.currentAction !== 'practicing') return s;
- return { currentAction: 'meditate' };
- }),
+ startPracticing: () => set((s) => s.currentAction !== 'meditate' ? s : { currentAction: 'practicing' }),
+ stopPracticing: () => set((s) => s.currentAction !== 'practicing' ? s : { currentAction: 'meditate' }),
// ─── Spec: Descent actions (delegated to combat-descent-actions.ts) ────
enterDescentMode: () => enterDescentMode(get, set),
@@ -246,6 +245,10 @@ export const useCombatStore = create()(
}));
},
+ addGolemDesign: (d) => addGolemDesign(set, d),
+ removeGolemDesign: (id) => removeGolemDesign(set, id),
+ toggleGolemLoadoutEntry: (id) => toggleGolemLoadoutEntry(set, id),
+
enterSpireMode: createEnterSpireMode(get, set),
learnSpell: (spellId: string) => {
@@ -310,7 +313,7 @@ export const useCombatStore = create()(
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
onDamageDealt: (damage: number) => { rawMana: number; elements: Record },
signedPacts: number[],
- golemancyState: { activeGolems: ActiveGolem[] },
+ golemancyState: { activeGolems: RuntimeActiveGolem[] },
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
applyEnemyDefenses: (
dmg: number,
@@ -390,6 +393,4 @@ export const useCombatStore = create()(
)
);
-// makeInitialSpells is now in combat-actions.ts
-// Re-export for backward compatibility
export { makeInitialSpells } from './combat-actions';
diff --git a/src/lib/game/stores/golem-combat-actions.ts b/src/lib/game/stores/golem-combat-actions.ts
index 5252c69..f66dcbf 100644
--- a/src/lib/game/stores/golem-combat-actions.ts
+++ b/src/lib/game/stores/golem-combat-actions.ts
@@ -1,80 +1,120 @@
-// ─── Golem Combat Actions ──────────────────────────────────────────────────────
-// Pure golem combat logic — no cross-store getState() calls.
-// All external data is passed in as parameters.
-// Implements spec §9: summoning, maintenance, attack, room-duration.
+// ─── Golem Combat Actions (Component-Based) ──────────────────────────────────
+// Runtime golem combat logic for the component-based construction system.
+// All external data is passed in as parameters (no cross-store getState() calls).
+// Implements spec §§10-14: summoning, maintenance, combat, mana, duration.
-import { GOLEMS_DEF } from '../data/golems';
import { HOURS_PER_TICK } from '../constants';
-import type { ActiveGolem, GolemancyState } from '../types';
-import { getElementalBonus, getFloorElement } from '../utils';
+import { CORES, FRAMES, MIND_CIRCUITS } from '../data/golems';
+import { computeGolemStats, getGolemSlots } from '../data/golems/utils';
+import type {
+ RuntimeActiveGolem,
+ GolemLoadoutEntry,
+ EnemyState,
+ ActiveEffect,
+} from '../types';
-// ─── Types ─────────────────────────────────────────────────────────────────────
+// ─── Types ───────────────────────────────────────────────────────────────────
export interface GolemCombatResult {
rawMana: number;
elements: Record;
- activeGolems: ActiveGolem[];
+ activeGolems: RuntimeActiveGolem[];
logMessages: string[];
totalDamageDealt: number;
}
-// ─── Summoning (spec §9.3) ─────────────────────────────────────────────────────
+interface SerializedDesign {
+ id: string;
+ name: string;
+ coreId: string;
+ frameId: string;
+ mindCircuitId: string;
+ enchantmentIds: string[];
+ selectedManaTypes: string[];
+ selectedSpells: string[];
+}
+
+// ─── Summoning (spec §10) ───────────────────────────────────────────────────
/**
- * Attempt to summon golems from the enabled loadout on room entry.
- * For each enabled golem: if the player has enough mana, deduct cost and activate.
- * Golems that can't be skipped are NOT re-attempted mid-room.
+ * Attempt to summon golems from the loadout on room entry.
+ * For each enabled design: if player has enough mana, deduct cost and activate.
+ * Designs that can't be afforded are NOT re-attempted mid-room.
*/
export function summonGolemsOnRoomEntry(
- enabledGolems: string[],
+ loadout: GolemLoadoutEntry[],
rawMana: number,
elements: Record,
currentFloor: number,
- existingActiveGolems: ActiveGolem[],
+ existingActiveGolems: RuntimeActiveGolem[],
+ disciplineSlotsBonus: number,
): {
rawMana: number;
elements: Record;
- activeGolems: ActiveGolem[];
+ activeGolems: RuntimeActiveGolem[];
logMessages: string[];
} {
let newRawMana = rawMana;
- let newElements = { ...elements };
+ const newElements = { ...elements };
const newActiveGolems = [...existingActiveGolems];
const logMessages: string[] = [];
- for (const golemId of enabledGolems) {
- const def = GOLEMS_DEF[golemId];
- if (!def) continue;
+ const activeCount = newActiveGolems.length;
- // Skip if this golem is already active (e.g. summoned on a previous floor
- // and still within its room-duration)
- const alreadyActive = newActiveGolems.some((ag) => ag.golemId === golemId);
+ for (const entry of loadout) {
+ if (!entry.enabled) continue;
+
+ // Check slot availability
+ if (newActiveGolems.length >= activeCount + disciplineSlotsBonus + getGolemSlots(0)) {
+ logMessages.push('No golem slots available');
+ break;
+ }
+
+ const design = entry.design as SerializedDesign;
+
+ // Resolve components
+ const core = CORES[design.coreId];
+ const frame = FRAMES[design.frameId];
+ const circuit = MIND_CIRCUITS[design.mindCircuitId];
+ if (!core || !frame || !circuit) {
+ logMessages.push(`${entry.design.name} has invalid components — skipped`);
+ continue;
+ }
+
+ // Skip if already active
+ const alreadyActive = newActiveGolems.some((ag) => ag.designId === entry.designId);
if (alreadyActive) continue;
- // Check if player can afford the summon cost (multi-type costs supported)
+ // Build component-based design for cost calculation
+ const stats = computeGolemStats({
+ id: design.id,
+ name: design.name,
+ core: { ...core, manaTypes: design.selectedManaTypes.length > 0 ? design.selectedManaTypes : core.manaTypes },
+ frame,
+ mindCircuit: circuit,
+ enchantments: [], // Simplified — enchantments resolved by ID in full implementation
+ selectedManaTypes: design.selectedManaTypes,
+ selectedSpells: design.selectedSpells,
+ });
+
+ // Check affordability
let canAfford = true;
- for (const cost of def.summonCost) {
+ for (const cost of stats.totalSummonCost) {
if (cost.type === 'raw') {
- if (newRawMana < cost.amount) {
- canAfford = false;
- break;
- }
+ if (newRawMana < cost.amount) { canAfford = false; break; }
} else if (cost.element) {
const elem = newElements[cost.element];
- if (!elem || !elem.unlocked || elem.current < cost.amount) {
- canAfford = false;
- break;
- }
+ if (!elem?.unlocked || elem.current < cost.amount) { canAfford = false; break; }
}
}
if (!canAfford) {
- logMessages.push(`Not enough mana to summon ${def.name} — skipped`);
+ logMessages.push(`Not enough mana to summon ${entry.design.name} — skipped`);
continue;
}
// Deduct summon cost
- for (const cost of def.summonCost) {
+ for (const cost of stats.totalSummonCost) {
if (cost.type === 'raw') {
newRawMana -= cost.amount;
} else if (cost.element && newElements[cost.element]) {
@@ -85,15 +125,16 @@ export function summonGolemsOnRoomEntry(
}
}
- // Activate golem with fresh room duration and zero attack progress
newActiveGolems.push({
- golemId: def.id,
+ designId: entry.designId,
summonedFloor: currentFloor,
attackProgress: 0,
- roomsRemaining: def.maxRoomDuration,
+ roomsRemaining: stats.maxRoomDuration,
+ currentMana: stats.manaCapacity,
+ spellCastIndex: 0,
});
- logMessages.push(`${def.name} summoned`);
+ logMessages.push(`${entry.design.name} summoned`);
}
return {
@@ -104,71 +145,58 @@ export function summonGolemsOnRoomEntry(
};
}
-// ─── Maintenance (spec §9.5) ───────────────────────────────────────────────────
+// ─── Maintenance Upkeep (spec §13) ───────────────────────────────────────────
/**
- * Deduct maintenance cost for each active golem.
+ * Deduct player upkeep cost for each active golem per tick.
+ * Upkeep = Core.manaRegen × 2 per hour, converted to per-tick.
* Golems that can't be maintained are dismissed immediately.
*/
export function processGolemMaintenance(
- activeGolems: ActiveGolem[],
+ activeGolems: RuntimeActiveGolem[],
+ golemDesigns: Record,
rawMana: number,
elements: Record,
): {
rawMana: number;
elements: Record;
- maintainedGolems: ActiveGolem[];
+ maintainedGolems: RuntimeActiveGolem[];
logMessages: string[];
} {
let newRawMana = rawMana;
- let newElements = { ...elements };
- const maintainedGolems: ActiveGolem[] = [];
+ const newElements = { ...elements };
+ const maintainedGolems: RuntimeActiveGolem[] = [];
const logMessages: string[] = [];
for (const golem of activeGolems) {
- const def = GOLEMS_DEF[golem.golemId];
- if (!def) continue;
+ const design = golemDesigns[golem.designId];
+ if (!design) continue;
- // Calculate maintenance cost for this tick
- let canMaintain = true;
- for (const cost of def.maintenanceCost) {
- const tickCost = cost.amount * HOURS_PER_TICK;
- if (cost.type === 'raw') {
- if (newRawMana < tickCost) {
- canMaintain = false;
- break;
- }
- } else if (cost.element) {
- const elem = newElements[cost.element];
- if (!elem || !elem.unlocked || elem.current < tickCost) {
- canMaintain = false;
- break;
- }
- }
+ const core = CORES[design.coreId];
+ if (!core) continue;
+
+ // Upkeep per tick = (manaRegen × 2) × HOURS_PER_TICK
+ const upkeepPerTick = core.manaRegen * 2 * HOURS_PER_TICK;
+ const upkeepElement = core.primaryManaType;
+
+ const elem = upkeepElement ? newElements[upkeepElement] : null;
+
+ if (upkeepElement && elem && elem.unlocked && elem.current >= upkeepPerTick) {
+ // Deduct from element mana
+ newElements[upkeepElement] = {
+ ...elem,
+ current: elem.current - upkeepPerTick,
+ };
+ maintainedGolems.push(golem);
+ } else if (!upkeepElement && newRawMana >= upkeepPerTick) {
+ // Deduct from raw mana
+ newRawMana -= upkeepPerTick;
+ maintainedGolems.push(golem);
+ } else if (upkeepElement && (!elem || !elem.unlocked || elem.current < upkeepPerTick)) {
+ logMessages.push(`${design.name} dismissed — insufficient ${upkeepElement} mana for upkeep`);
+ } else {
+ logMessages.push(`${design.name} dismissed — insufficient mana for upkeep`);
}
-
- if (!canMaintain) {
- logMessages.push(
- `${def.name} dismissed — insufficient ${def.maintenanceCost.map((c) => c.element || 'raw').join(', ')} mana`,
- );
- // Golem is dismissed — deduct no maintenance cost
- continue;
- }
-
- // Deduct maintenance cost
- for (const cost of def.maintenanceCost) {
- const tickCost = cost.amount * HOURS_PER_TICK;
- if (cost.type === 'raw') {
- newRawMana -= tickCost;
- } else if (cost.element && newElements[cost.element]) {
- newElements[cost.element] = {
- ...newElements[cost.element],
- current: newElements[cost.element].current - tickCost,
- };
- }
- }
-
- maintainedGolems.push(golem);
}
return {
@@ -179,21 +207,40 @@ export function processGolemMaintenance(
};
}
-// ─── Golem Combat Tick (spec §9.4) ─────────────────────────────────────────────
+// ─── Golem Mana Regen (spec §12) ────────────────────────────────────────────
+
+/**
+ * Regenerate golem mana pools per tick.
+ */
+export function processGolemManaRegen(
+ activeGolems: RuntimeActiveGolem[],
+ golemDesigns: Record,
+): RuntimeActiveGolem[] {
+ return activeGolems.map((golem) => {
+ const design = golemDesigns[golem.designId];
+ if (!design) return golem;
+
+ const core = CORES[design.coreId];
+ if (!core) return golem;
+
+ const manaGain = core.manaRegen * HOURS_PER_TICK;
+ return {
+ ...golem,
+ currentMana: Math.min(core.manaCapacity, golem.currentMana + manaGain),
+ };
+ });
+}
+
+// ─── Golem Combat Tick (spec §11) ───────────────────────────────────────────
/**
* Process golem attacks for one combat tick.
* Each golem accumulates attackProgress and fires when >= 1.
- * Golems apply elemental bonus based on their baseManaType.
- * Golems ignore Executioner and Berserker discipline specials.
+ * Supports spell casting via Mind Circuit behavior.
*/
export function processGolemAttacks(
- activeGolems: ActiveGolem[],
- rawMana: number,
- elements: Record,
- floorHP: number,
- floorMaxHP: number,
- currentFloor: number,
+ activeGolems: RuntimeActiveGolem[],
+ golemDesigns: Record,
onDamageDealt: (damage: number) => {
rawMana: number;
elements: Record;
@@ -201,114 +248,122 @@ export function processGolemAttacks(
},
applyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
): GolemCombatResult {
- let newRawMana = rawMana;
- let newElements = elements;
- let currentFloorHP = floorHP;
- let currentFloorMaxHP = floorMaxHP;
+ let rawMana = 0;
+ let elements: Record = {};
+ let floorHP = 0;
+ let floorMaxHP = 0;
const logMessages: string[] = [];
let totalDamageDealt = 0;
- const updatedGolems: ActiveGolem[] = [];
+ const updatedGolems: RuntimeActiveGolem[] = [];
for (const golem of activeGolems) {
- const def = GOLEMS_DEF[golem.golemId];
- if (!def) continue;
+ const design = golemDesigns[golem.designId];
+ if (!design) continue;
- // Accumulate attack progress
- let attackProgress = golem.attackProgress + HOURS_PER_TICK * def.attackSpeed;
+ const core = CORES[design.coreId];
+ const frame = FRAMES[design.frameId];
+ const circuit = MIND_CIRCUITS[design.mindCircuitId];
+ if (!core || !frame || !circuit) continue;
- // Safety counter prevents infinite loop for very fast golems
+ let attackProgress = golem.attackProgress + HOURS_PER_TICK * frame.attackSpeed;
+ const updatedGolem = { ...golem };
let safetyCounter = 0;
const MAX_GOLEM_ATTACKS_PER_TICK = 100;
while (attackProgress >= 1 && safetyCounter < MAX_GOLEM_ATTACKS_PER_TICK) {
- // Calculate base damage
- let dmg = def.damage;
+ // Try spell cast first if circuit supports it
+ if (circuit.spellSlots > 0 && design.selectedSpells.length > 0) {
+ const spellIdx = updatedGolem.spellCastIndex % design.selectedSpells.length;
+ const spellId = design.selectedSpells[spellIdx];
- // Apply elemental bonus if golem has a baseManaType that matches an element
- if (def.baseManaType && def.baseManaType !== 'raw') {
- const floorElement = getFloorElement(currentFloor);
- dmg *= getElementalBonus(def.baseManaType, floorElement);
+ // Spell casting simplified — full implementation needs spell cost/effect lookup
+ if (spellId && updatedGolem.currentMana >= 10) {
+ // Cast spell: damage scaled by magic affinity
+ const spellDmg = 20 * frame.magicAffinity; // Placeholder base spell damage
+ updatedGolem.currentMana -= 10;
+ updatedGolem.spellCastIndex = (updatedGolem.spellCastIndex + 1) % design.selectedSpells.length;
+
+ const dmgResult = onDamageDealt(spellDmg);
+ const finalDamage = dmgResult.modifiedDamage || spellDmg;
+
+ if (Number.isFinite(finalDamage)) {
+ const roomResult = applyDamageToRoom(finalDamage);
+ floorHP = roomResult.floorHP;
+ floorMaxHP = roomResult.floorMaxHP;
+ totalDamageDealt += Math.max(0, finalDamage);
+ rawMana = dmgResult.rawMana;
+ elements = dmgResult.elements;
+ }
+
+ attackProgress -= 1;
+ safetyCounter++;
+ continue;
+ }
}
- // Apply armor pierce: reduce effective enemy armor by armorPierce fraction
- // (armor pierce is implemented as a flat damage multiplier for simplicity,
- // bypass fraction of enemy armor — the full armor integration depends on
- // the DoT/debuff system from issue #258)
- if (def.armorPierce > 0) {
- dmg *= 1 + def.armorPierce;
- }
+ // Basic attack
+ let dmg = frame.baseDamage * (1 + frame.armorPierce);
- // Golems ignore Executioner and Berserker discipline specials (spec §9.4)
- // The onDamageDealt callback is used for damage modifiers, but golem
- // damage is not affected by discipline specials — we pass raw damage
- // and use the result's base modifiedDamage path.
- // Note: onDamageDealt may still apply guardian defenses (shield/barrier)
- // which is correct since guardians defend against all damage sources.
const dmgResult = onDamageDealt(dmg);
- newRawMana = dmgResult.rawMana;
- newElements = dmgResult.elements;
const finalDamage = dmgResult.modifiedDamage || dmg;
- if (!Number.isFinite(finalDamage)) {
- break;
+ if (Number.isFinite(finalDamage)) {
+ const roomResult = applyDamageToRoom(finalDamage);
+ floorHP = roomResult.floorHP;
+ floorMaxHP = roomResult.floorMaxHP;
+ totalDamageDealt += Math.max(0, finalDamage);
+ rawMana = dmgResult.rawMana;
+ elements = dmgResult.elements;
}
- // Apply damage to room
- const roomResult = applyDamageToRoom(finalDamage);
- currentFloorHP = roomResult.floorHP;
- currentFloorMaxHP = roomResult.floorMaxHP;
- totalDamageDealt += Math.max(0, finalDamage);
-
attackProgress -= 1;
safetyCounter++;
-
- if (roomResult.roomCleared) {
- // Room cleared by golem — stop attacking this golem,
- // room advancement is handled by the caller
- attackProgress = 0;
- break;
- }
}
- updatedGolems.push({ ...golem, attackProgress });
+ updatedGolem.attackProgress = attackProgress;
+ updatedGolems.push(updatedGolem);
}
return {
- rawMana: newRawMana,
- elements: newElements,
+ rawMana,
+ elements,
activeGolems: updatedGolems,
logMessages,
totalDamageDealt,
};
}
-// ─── Room Duration Countdown (spec §9.6) ──────────────────────────────────────
+// ─── Room Duration Countdown (spec §14) ─────────────────────────────────────
/**
* Decrement roomsRemaining for each active golem on room clear.
* Golems at 0 remaining are dismissed.
*/
export function countdownGolemRoomDuration(
- activeGolems: ActiveGolem[],
+ activeGolems: RuntimeActiveGolem[],
+ golemDesigns: Record,
): {
- remainingGolems: ActiveGolem[];
+ remainingGolems: RuntimeActiveGolem[];
dismissedNames: string[];
logMessages: string[];
} {
- const remainingGolems: ActiveGolem[] = [];
+ const remainingGolems: RuntimeActiveGolem[] = [];
const dismissedNames: string[] = [];
const logMessages: string[] = [];
for (const golem of activeGolems) {
- const def = GOLEMS_DEF[golem.golemId];
- if (!def) continue;
+ const design = golemDesigns[golem.designId];
+ if (!design) continue;
+
+ const core = CORES[design.coreId];
+ if (!core) continue;
const newRoomsRemaining = golem.roomsRemaining - 1;
if (newRoomsRemaining <= 0) {
- dismissedNames.push(def.name);
- logMessages.push(`${def.name} has faded after ${def.maxRoomDuration} rooms`);
+ dismissedNames.push(design.name);
+ logMessages.push(`${design.name} has faded after ${core.maxRoomDuration} rooms`);
} else {
remainingGolems.push({ ...golem, roomsRemaining: newRoomsRemaining });
}
@@ -316,5 +371,3 @@ export function countdownGolemRoomDuration(
return { remainingGolems, dismissedNames, logMessages };
}
-
-
diff --git a/src/lib/game/stores/golemancy-actions.ts b/src/lib/game/stores/golemancy-actions.ts
new file mode 100644
index 0000000..acb570a
--- /dev/null
+++ b/src/lib/game/stores/golemancy-actions.ts
@@ -0,0 +1,28 @@
+import type { SerializedGolemDesign } from '../types/game';
+
+export function addGolemDesign(set: (fn: (s: any) => any) => void, design: SerializedGolemDesign) {
+ set((s: any) => {
+ const golemDesigns = { ...s.golemancy.golemDesigns, [design.id]: design };
+ const entry = { designId: design.id, design, enabled: true };
+ const golemLoadout = [...s.golemancy.golemLoadout, entry];
+ return { golemancy: { ...s.golemancy, golemDesigns, golemLoadout } };
+ });
+}
+
+export function removeGolemDesign(set: (fn: (s: any) => any) => void, designId: string) {
+ set((s: any) => {
+ const golemDesigns = { ...s.golemancy.golemDesigns };
+ delete golemDesigns[designId];
+ const golemLoadout = s.golemancy.golemLoadout.filter((e: any) => e.designId !== designId);
+ return { golemancy: { ...s.golemancy, golemDesigns, golemLoadout } };
+ });
+}
+
+export function toggleGolemLoadoutEntry(set: (fn: (s: any) => any) => void, designId: string) {
+ set((s: any) => {
+ const golemLoadout = s.golemancy.golemLoadout.map((e: any) =>
+ e.designId === designId ? { ...e, enabled: !e.enabled } : e,
+ );
+ return { golemancy: { ...s.golemancy, golemLoadout } };
+ });
+}
diff --git a/src/lib/game/stores/pipelines/combat-tick.ts b/src/lib/game/stores/pipelines/combat-tick.ts
index 7ded4d6..b5acd4f 100644
--- a/src/lib/game/stores/pipelines/combat-tick.ts
+++ b/src/lib/game/stores/pipelines/combat-tick.ts
@@ -7,6 +7,7 @@ import { getGuardianForFloor } from '../../data/guardian-encounters';
import { hasSpecial, SPECIAL_EFFECTS } from '../../effects/special-effects';
import type { ComputedEffects } from '../../effects/upgrade-effects.types';
import type { EnemyState } from '../../types';
+import type { CombatStore } from '../combat-state.types';
import { countdownGolemRoomDuration } from '../golem-combat-actions';
// ─── Enemy Defense Context ────────────────────────────────────────────────────
@@ -37,7 +38,7 @@ interface BuildCombatCallbacksParams {
effects: ComputedEffects;
maxMana: number;
addLog: (msg: string) => void;
- useCombatStore: { setState: (s: Record) => void; getState: () => Record };
+ useCombatStore: { setState: (s: Partial) => void; getState: () => CombatStore };
usePrestigeStore: { getState: () => { addDefeatedGuardian: (floor: number) => void } };
}
@@ -106,11 +107,12 @@ export function buildCombatCallbacks(params: BuildCombatCallbacksParams) {
}
useCombatStore.setState({ guardianShield: 0, guardianShieldMax: 0, guardianBarrier: 0, guardianBarrierMax: 0 });
- // ── Golem room-duration countdown (spec §9.6) ──────────────────────
+ // ── Golem room-duration countdown (spec §14) ──────────────────────
const cs = useCombatStore.getState();
- const activeGolems = cs.golemancy?.activeGolems ?? [];
+ const activeGolems = cs.golemancy.activeGolems;
+ const golemDesigns = cs.golemancy.golemDesigns;
if (activeGolems.length > 0) {
- const result = countdownGolemRoomDuration(activeGolems);
+ const result = countdownGolemRoomDuration(activeGolems, golemDesigns);
if (result.logMessages.length > 0) {
result.logMessages.forEach((msg) => params.addLog(msg));
}
diff --git a/src/lib/game/stores/pipelines/golem-combat.ts b/src/lib/game/stores/pipelines/golem-combat.ts
index 8c9a7c7..82ee809 100644
--- a/src/lib/game/stores/pipelines/golem-combat.ts
+++ b/src/lib/game/stores/pipelines/golem-combat.ts
@@ -1,19 +1,26 @@
-// ─── Golem Combat Pipeline ─────────────────────────────────────────────────────
-// Extracts golem combat setup from gameStore.ts tick()
-// to keep the coordinator under the 400-line file limit.
+// ─── Golem Combat Pipeline (Component-Based) ─────────────────────────────────
+// Pipeline integration for the component-based golem combat system.
+// Extracts golem combat setup from gameStore.ts tick() to keep the coordinator
+// under the 400-line file limit.
import { useCombatStore } from '../combatStore';
import { useManaStore } from '../manaStore';
-import { processGolemRoomDuration } from '../golem-combat-actions';
-import { lowestHPEnemy } from '../combat-damage';
-import type { ActiveGolem, EnemyState } from '../../types';
+import {
+ summonGolemsOnRoomEntry,
+ processGolemMaintenance,
+ processGolemManaRegen,
+ processGolemAttacks,
+ countdownGolemRoomDuration,
+} from '../golem-combat-actions';
+import { useAttunementStore } from '../attunementStore';
+import type { RuntimeActiveGolem } from '../../types';
export interface GolemCombatContext {
addLog: (msg: string) => void;
ctx: {
combat: {
currentFloor: number;
- currentRoom: { roomType: string; unknown: Array<{ name: string }> };
+ currentRoom: { roomType: string; enemies: Array<{ name: string; hp: number; maxHP: number; armor: number }> };
};
prestige: { signedPacts: number[] };
};
@@ -22,20 +29,29 @@ export interface GolemCombatContext {
maxMana: number;
}
-export interface GolemCombatResult {
+export interface GolemCombatPipelineResult {
rawMana: number;
elements: Record;
+ activeGolems: RuntimeActiveGolem[];
+ logMessages: string[];
}
/**
* Build the golem combat pipeline for the current tick.
- * Returns golem state needed by processCombatTick.
*/
export function buildGolemCombatPipeline(_addLog: (msg: string) => void): {
- activeGolems: ActiveGolem[];
+ activeGolems: RuntimeActiveGolem[];
+ golemDesigns: Record;
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean };
} {
- const activeGolems = useCombatStore.getState().golemancy?.activeGolems ?? [];
+ const combatState = useCombatStore.getState();
+ const golemancy = combatState.golemancy;
+
+ // New component-based active golems
+ const activeGolems = golemancy?.activeGolems ?? [];
+
+ // Reconstruct golem designs from store
+ const golemDesigns = golemancy?.golemDesigns ?? {};
const golemApplyDamageToRoom = (dmg: number) => {
const cs = useCombatStore.getState();
@@ -44,14 +60,19 @@ export function buildGolemCombatPipeline(_addLog: (msg: string) => void): {
return { floorHP: cs.floorHP, floorMaxHP: cs.floorMaxHP, roomCleared: false };
}
- // Golems use focus-fire targeting (spec §9.4) — target lowest HP enemy
- const target = lowestHPEnemy(room.enemies);
- if (!target) {
+ // Focus-fire targeting: target lowest HP enemy
+ let target = room.enemies[0];
+ for (const e of room.enemies) {
+ if (e.hp > 0 && e.hp < (target?.hp ?? Infinity)) {
+ target = e;
+ }
+ }
+ if (!target || target.hp <= 0) {
return { floorHP: cs.floorHP, floorMaxHP: cs.floorMaxHP, roomCleared: false };
}
const updatedEnemies = room.enemies.map((enemy) => {
- if (enemy.id === target.id && enemy.hp > 0) {
+ if (enemy.id === target!.id && enemy.hp > 0) {
return { ...enemy, hp: Math.max(0, enemy.hp - dmg) };
}
return enemy;
@@ -68,5 +89,32 @@ export function buildGolemCombatPipeline(_addLog: (msg: string) => void): {
return { floorHP: newFloorHP, floorMaxHP: cs.floorMaxHP, roomCleared: allDead };
};
- return { activeGolems, golemApplyDamageToRoom };
+ return { activeGolems, golemDesigns, golemApplyDamageToRoom };
+}
+
+/**
+ * Process golem summoning on room entry.
+ */
+export function processGolemRoomEntry(
+ loadout: { enabled: boolean; designId: string; design: { name: string } }[],
+ currentFloor: number,
+): {
+ rawMana: number;
+ elements: Record;
+ activeGolems: RuntimeActiveGolem[];
+ logMessages: string[];
+} {
+ const cs = useCombatStore.getState();
+ const attStore = useAttunementStore.getState();
+ const fabLevel = attStore.attunements?.fabricator?.level ?? 0;
+ const discBonus = 0; // TODO: compute from discipline
+
+ return summonGolemsOnRoomEntry(
+ loadout as any,
+ useManaStore.getState().rawMana,
+ useManaStore.getState().elements as any,
+ currentFloor,
+ cs.golemancy.activeGolems as any[],
+ discBonus,
+ );
}
diff --git a/src/lib/game/types.ts b/src/lib/game/types.ts
index c3db6d1..c298eea 100755
--- a/src/lib/game/types.ts
+++ b/src/lib/game/types.ts
@@ -50,10 +50,15 @@ export type {
ScheduleBlock,
StudyTarget,
SummonedGolem,
+ ActiveGolem,
GolemancyState,
+ GolemLoadoutEntry,
+ RuntimeActiveGolem,
+ SerializedGolemDesign,
GameActionType,
ActivityEventType,
ActivityLogEntry,
+ ActiveEffect,
} from './types/game';
export type { PrestigeDef } from './types/game';
diff --git a/src/lib/game/types/game.ts b/src/lib/game/types/game.ts
index 13b2767..1b28859 100644
--- a/src/lib/game/types/game.ts
+++ b/src/lib/game/types/game.ts
@@ -144,25 +144,71 @@ export interface StudyTarget {
// ─── Golemancy Types ─────────────────────────────────────────────────────────
+/** @deprecated Legacy type for predefined golems. Use GolemDesign instead. */
export interface SummonedGolem {
- golemId: string; // Reference to GOLEMS_DEF
- summonedFloor: number; // Floor when golem was summoned
- attackProgress: number; // Progress toward next attack (0-1)
- roomsRemaining: number; // Rooms before golem disappears (spec §9.6)
+ golemId: string;
+ summonedFloor: number;
+ attackProgress: number;
+ roomsRemaining: number;
}
-/** Runtime state for an active golem in combat (spec §9.7) */
+/** @deprecated Legacy type. Use ActiveGolemV2 instead. */
export interface ActiveGolem extends SummonedGolem {
// attackProgress is inherited from SummonedGolem
}
-export interface GolemancyState {
- enabledGolems: string[]; // Golem IDs the player wants active
- summonedGolems: SummonedGolem[]; // Currently summoned golems on this floor (legacy, kept for golem-tab state)
- activeGolems: ActiveGolem[]; // Runtime active golems in combat (spec §9)
- lastSummonFloor: number; // Floor golems were last summoned on
+/**
+ * Player-designed golem loadout entry.
+ * Each entry is a complete golem design (Core + Frame + Mind Circuit + Enchantments).
+ */
+export interface GolemLoadoutEntry {
+ designId: string; // Reference to the GolemDesign
+ /** Golem design (serialized component-based golem) */
+ design: SerializedGolemDesign;
+ enabled: boolean; // Whether this golem is enabled for auto-summon
}
+/**
+ * Runtime active golem in combat (component-based system).
+ * Tracks combat state per golem instance.
+ */
+export interface RuntimeActiveGolem {
+ designId: string; // Reference to the player's GolemDesign
+ summonedFloor: number; // Floor when golem was summoned
+ attackProgress: number; // Progress toward next attack (accumulated)
+ roomsRemaining: number; // Rooms before golem fades
+ currentMana: number; // Current mana in golem's own pool
+ spellCastIndex: number; // For alternating/cycling spell circuits
+}
+
+export interface SerializedGolemDesign {
+ id: string;
+ name: string;
+ coreId: string;
+ frameId: string;
+ mindCircuitId: string;
+ enchantmentIds: string[];
+ selectedManaTypes: string[];
+ selectedSpells: string[];
+}
+
+export interface GolemancyState {
+ /** Player's saved golem designs indexed by design ID */
+ golemDesigns: Record;
+ /** Prioritized loadout of golem designs (persists across rooms, resets per run) */
+ golemLoadout: GolemLoadoutEntry[];
+ /** Runtime active golems in combat */
+ activeGolems: RuntimeActiveGolem[];
+ /** Floor golems were last summoned on */
+ lastSummonFloor: number;
+ // Legacy fields kept for backward compatibility during migration
+ enabledGolems: string[];
+ summonedGolems: SummonedGolem[];
+ /** @deprecated Use activeGolems instead (RuntimeActiveGolem[]) */
+ legacyActiveGolems: ActiveGolem[];
+}
+
+
// ─── Main Game State ─────────────────────────────────────────────────────
export interface GameState {
@@ -245,6 +291,8 @@ export interface GameState {
// Golemancy (summoned golems)
golemancy: GolemancyState;
+
+
// Achievements
achievements: AchievementState;
diff --git a/src/lib/game/types/index.ts b/src/lib/game/types/index.ts
index 9f587a6..7cb145e 100644
--- a/src/lib/game/types/index.ts
+++ b/src/lib/game/types/index.ts
@@ -43,6 +43,9 @@ export type {
SummonedGolem,
ActiveGolem,
GolemancyState,
+ GolemLoadoutEntry,
+ RuntimeActiveGolem,
+ SerializedGolemDesign,
GameActionType,
ActivityEventType,
ActivityLogEntry,