diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 05f256c..1d1c868 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,4 +1,4 @@ # Circular Dependencies -Generated: 2026-06-05T12:07:36.291Z +Generated: 2026-06-05T13:36:31.575Z No circular dependencies found. ✅ diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 8c53965..8eadece 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-06-05T12:07:34.394Z", + "generated": "2026-06-05T13:36:29.562Z", "description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.", "usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry." }, diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 689dd26..9877197 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -118,6 +118,16 @@ Mana-Loop/ │ │ │ │ │ ├── ManaStatsSection.tsx │ │ │ │ │ ├── PactStatusSection.tsx │ │ │ │ │ └── StudyStatsSection.tsx +│ │ │ │ ├── golemancy/ +│ │ │ │ │ ├── ActiveGolemsPanel.tsx +│ │ │ │ │ ├── GolemDesignBuilder.tsx +│ │ │ │ │ ├── GolemLoadoutPanel.tsx +│ │ │ │ │ ├── GolemancyComponents.test.ts +│ │ │ │ │ ├── GolemancySharedComponents.tsx +│ │ │ │ │ ├── golemancy-components.test.ts +│ │ │ │ │ ├── golemancy-utils.test.ts +│ │ │ │ │ ├── golemancy-utils.ts +│ │ │ │ │ └── types.ts │ │ │ │ ├── AchievementsTab.tsx │ │ │ │ ├── ActivityLog.tsx │ │ │ │ ├── AttunementsTab.test.ts @@ -131,7 +141,6 @@ Mana-Loop/ │ │ │ │ ├── ElementalSubtab.tsx │ │ │ │ ├── EquipmentTab.test.ts │ │ │ │ ├── EquipmentTab.tsx -│ │ │ │ ├── GolemancyTab.test.ts │ │ │ │ ├── GolemancyTab.tsx │ │ │ │ ├── GuardianPactsTab.test.ts │ │ │ │ ├── GuardianPactsTab.tsx @@ -319,10 +328,14 @@ Mana-Loop/ │ │ │ │ │ └── utils.ts │ │ │ │ ├── golems/ │ │ │ │ │ ├── base-golems.ts +│ │ │ │ │ ├── cores.ts │ │ │ │ │ ├── elemental-golems.ts +│ │ │ │ │ ├── frames.ts +│ │ │ │ │ ├── golemEnchantments.ts │ │ │ │ │ ├── golems-data.ts │ │ │ │ │ ├── hybrid-golems.ts │ │ │ │ │ ├── index.ts +│ │ │ │ │ ├── mindCircuits.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── achievements.ts @@ -373,6 +386,7 @@ Mana-Loop/ │ │ │ │ ├── gameStore.ts │ │ │ │ ├── gameStore.types.ts │ │ │ │ ├── golem-combat-actions.ts +│ │ │ │ ├── golemancy-actions.ts │ │ │ │ ├── index.ts │ │ │ │ ├── manaStore.ts │ │ │ │ ├── non-combat-room-actions.ts diff --git a/src/components/game/tabs/DebugTab/GolemDebugSection.tsx b/src/components/game/tabs/DebugTab/GolemDebugSection.tsx index 99d9b13..e795fb0 100644 --- a/src/components/game/tabs/DebugTab/GolemDebugSection.tsx +++ b/src/components/game/tabs/DebugTab/GolemDebugSection.tsx @@ -2,30 +2,130 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Bug, Wand2 } from 'lucide-react'; +import { Bug, Cpu, Box, CircuitBoard, Sparkles } from 'lucide-react'; import { useCombatStore } from '@/lib/game/stores'; -import { GOLEMS_DEF } from '@/lib/game/data/golems'; +import { + ALL_CORES, + ALL_FRAMES, + ALL_MIND_CIRCUITS, + ALL_GOLEM_ENCHANTMENTS, + type CoreDefinition, + type FrameDefinition, + type MindCircuitDefinition, + type GolemEnchantmentDefinition, +} from '@/lib/game/data/golems'; import { DebugName } from '@/components/game/debug/debug-context'; +function formatCost(cost: { type: string; element?: string; amount: number }): string { + if (cost.type === 'raw') return `${cost.amount} raw`; + return `${cost.amount} ${cost.element ?? 'unknown'}`; +} + +function formatUnlock(req: { + type: string; + attunement?: string; + level?: number; + manaType?: string; + attunements?: string[]; + levels?: number[]; +}): string { + switch (req.type) { + case 'attunement_level': + return `${req.attunement} Lv${req.level}`; + case 'mana_unlocked': + return `Unlock ${req.manaType} mana`; + case 'dual_attunement': + return `${req.attunements?.[0]} Lv${req.levels?.[0]} + ${req.attunements?.[1]} Lv${req.levels?.[1]}`; + case 'guardian_pact': + return `Guardian: ${req.attunements?.[0]} Lv${req.levels?.[0]} + ${req.attunements?.[1]} Lv${req.levels?.[1]}`; + default: + return req.type; + } +} + +function formatCosts(costs: { type: string; element?: string; amount: number }[]): string { + return costs.map(formatCost).join(', '); +} + +function CoreCard({ core }: { core: CoreDefinition }) { + return ( +
+
{core.name}
+
{core.description}
+
+
Tier {core.tier} · Primary: {core.primaryManaType}
+
Capacity: {core.manaCapacity} · Regen: {core.manaRegen}/h · Duration: {core.maxRoomDuration} rooms
+
Types: {core.manaTypes.join(', ')}
+
Cost: {formatCosts(core.summonCost)}
+
Unlock: {formatUnlock(core.unlockRequirement)}
+
+
+ ); +} + +function FrameCard({ frame }: { frame: FrameDefinition }) { + return ( +
+
{frame.name}
+
{frame.description}
+
+
Element: {frame.element} · Special: {frame.specialEffect}
+
DMG: {frame.baseDamage} · SPD: {frame.attackSpeed} · AoE: {frame.aoeTargets}
+
ArmorPierce: {(frame.armorPierce * 100).toFixed(0)}% · MagicAffinity: {(frame.magicAffinity * 100).toFixed(0)}%
+
Cost: {formatCosts(frame.summonCost)}
+
Unlock: {formatUnlock(frame.unlockRequirement)}
+
+
+ ); +} + +function MindCircuitCard({ circuit }: { circuit: MindCircuitDefinition }) { + return ( +
+
{circuit.name}
+
{circuit.description}
+
+
Behavior: {circuit.behavior} · Spell Slots: {circuit.spellSlots}
+
Cost: {formatCosts(circuit.summonCost)}
+
Unlock: {formatUnlock(circuit.unlockRequirement)}
+
+
+ ); +} + +function EnchantmentCard({ enchant }: { enchant: GolemEnchantmentDefinition }) { + return ( +
+
{enchant.name}
+
{enchant.description}
+
+
Effect: {enchant.effect} · Capacity Cost: {enchant.capacityCost}
+
Cost: {formatCosts(enchant.summonCost)}
+
+
+ ); +} + export function GolemDebugSection() { - const golemancy = useCombatStore((s) => s.golemancy); const setEnabledGolems = useCombatStore((s) => s.setEnabledGolems); + const enabledGolems = useCombatStore((s) => s.golemancy?.enabledGolems) ?? []; - const enabledGolems = golemancy?.enabledGolems || []; + const allComponentIds = [ + ...ALL_CORES.map((c) => c.id), + ...ALL_FRAMES.map((f) => f.id), + ...ALL_MIND_CIRCUITS.map((m) => m.id), + ...ALL_GOLEM_ENCHANTMENTS.map((e) => e.id), + ]; - const handleEnableAll = () => { - setEnabledGolems(Object.keys(GOLEMS_DEF)); - }; + const handleEnableAll = () => setEnabledGolems(allComponentIds); - const handleDisableAll = () => { - setEnabledGolems([]); - }; + const handleDisableAll = () => setEnabledGolems([]); - const handleToggleGolem = (golemId: string) => { - if (enabledGolems.includes(golemId)) { - setEnabledGolems(enabledGolems.filter(id => id !== golemId)); + const handleToggleComponent = (id: string) => { + if (enabledGolems.includes(id)) { + setEnabledGolems(enabledGolems.filter((x) => x !== id)); } else { - setEnabledGolems([...enabledGolems, golemId]); + setEnabledGolems([...enabledGolems, id]); } }; @@ -35,45 +135,108 @@ export function GolemDebugSection() { - Golem Debug + Golem Debug — Component System - +
- Enabled: {enabledGolems.length} / {Object.keys(GOLEMS_DEF).length} + Enabled: {enabledGolems.length} / {allComponentIds.length}
-
- {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,