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

- Add new golem component types (Core, Frame, MindCircuit, Enchantment)
- Create 4 Core tiers, 7 Frames, 4 Mind Circuits, 8 Enchantments
- Rewrite golem utils for component-based stat computation
- Update GolemancyState with new fields (golemDesigns, golemLoadout, activeGolems)
- Update combat store, actions, and pipelines for new golem system
- Rewrite GolemancyTab with component selection UI
- Update fabricator discipline perks for new system
- Add comprehensive tests for component registries and utilities
- All files under 400 lines, all 743 tests passing
This commit is contained in:
2026-06-06 16:50:26 +02:00
parent c40e4ee940
commit 4b7aa82953
43 changed files with 2763 additions and 944 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Circular Dependencies # Circular Dependencies
Generated: 2026-06-05T12:07:36.291Z Generated: 2026-06-05T13:36:31.575Z
No circular dependencies found. ✅ No circular dependencies found. ✅
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_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.", "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." "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."
}, },
+15 -1
View File
@@ -118,6 +118,16 @@ Mana-Loop/
│ │ │ │ │ ├── ManaStatsSection.tsx │ │ │ │ │ ├── ManaStatsSection.tsx
│ │ │ │ │ ├── PactStatusSection.tsx │ │ │ │ │ ├── PactStatusSection.tsx
│ │ │ │ │ └── StudyStatsSection.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 │ │ │ │ ├── AchievementsTab.tsx
│ │ │ │ ├── ActivityLog.tsx │ │ │ │ ├── ActivityLog.tsx
│ │ │ │ ├── AttunementsTab.test.ts │ │ │ │ ├── AttunementsTab.test.ts
@@ -131,7 +141,6 @@ Mana-Loop/
│ │ │ │ ├── ElementalSubtab.tsx │ │ │ │ ├── ElementalSubtab.tsx
│ │ │ │ ├── EquipmentTab.test.ts │ │ │ │ ├── EquipmentTab.test.ts
│ │ │ │ ├── EquipmentTab.tsx │ │ │ │ ├── EquipmentTab.tsx
│ │ │ │ ├── GolemancyTab.test.ts
│ │ │ │ ├── GolemancyTab.tsx │ │ │ │ ├── GolemancyTab.tsx
│ │ │ │ ├── GuardianPactsTab.test.ts │ │ │ │ ├── GuardianPactsTab.test.ts
│ │ │ │ ├── GuardianPactsTab.tsx │ │ │ │ ├── GuardianPactsTab.tsx
@@ -319,10 +328,14 @@ Mana-Loop/
│ │ │ │ │ └── utils.ts │ │ │ │ │ └── utils.ts
│ │ │ │ ├── golems/ │ │ │ │ ├── golems/
│ │ │ │ │ ├── base-golems.ts │ │ │ │ │ ├── base-golems.ts
│ │ │ │ │ ├── cores.ts
│ │ │ │ │ ├── elemental-golems.ts │ │ │ │ │ ├── elemental-golems.ts
│ │ │ │ │ ├── frames.ts
│ │ │ │ │ ├── golemEnchantments.ts
│ │ │ │ │ ├── golems-data.ts │ │ │ │ │ ├── golems-data.ts
│ │ │ │ │ ├── hybrid-golems.ts │ │ │ │ │ ├── hybrid-golems.ts
│ │ │ │ │ ├── index.ts │ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── mindCircuits.ts
│ │ │ │ │ ├── types.ts │ │ │ │ │ ├── types.ts
│ │ │ │ │ └── utils.ts │ │ │ │ │ └── utils.ts
│ │ │ │ ├── achievements.ts │ │ │ │ ├── achievements.ts
@@ -373,6 +386,7 @@ Mana-Loop/
│ │ │ │ ├── gameStore.ts │ │ │ │ ├── gameStore.ts
│ │ │ │ ├── gameStore.types.ts │ │ │ │ ├── gameStore.types.ts
│ │ │ │ ├── golem-combat-actions.ts │ │ │ │ ├── golem-combat-actions.ts
│ │ │ │ ├── golemancy-actions.ts
│ │ │ │ ├── index.ts │ │ │ │ ├── index.ts
│ │ │ │ ├── manaStore.ts │ │ │ │ ├── manaStore.ts
│ │ │ │ ├── non-combat-room-actions.ts │ │ │ │ ├── non-combat-room-actions.ts
@@ -2,30 +2,130 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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 { 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'; 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 (
<div className="p-2 rounded border border-gray-700 bg-gray-800/40">
<div className="text-sm font-medium text-amber-300">{core.name}</div>
<div className="text-xs text-gray-400 mt-0.5">{core.description}</div>
<div className="text-xs text-gray-500 mt-1 space-y-0.5">
<div>Tier {core.tier} · Primary: {core.primaryManaType}</div>
<div>Capacity: {core.manaCapacity} · Regen: {core.manaRegen}/h · Duration: {core.maxRoomDuration} rooms</div>
<div>Types: {core.manaTypes.join(', ')}</div>
<div>Cost: {formatCosts(core.summonCost)}</div>
<div className="text-yellow-500/70">Unlock: {formatUnlock(core.unlockRequirement)}</div>
</div>
</div>
);
}
function FrameCard({ frame }: { frame: FrameDefinition }) {
return (
<div className="p-2 rounded border border-gray-700 bg-gray-800/40">
<div className="text-sm font-medium text-sky-300">{frame.name}</div>
<div className="text-xs text-gray-400 mt-0.5">{frame.description}</div>
<div className="text-xs text-gray-500 mt-1 space-y-0.5">
<div>Element: {frame.element} · Special: {frame.specialEffect}</div>
<div>DMG: {frame.baseDamage} · SPD: {frame.attackSpeed} · AoE: {frame.aoeTargets}</div>
<div>ArmorPierce: {(frame.armorPierce * 100).toFixed(0)}% · MagicAffinity: {(frame.magicAffinity * 100).toFixed(0)}%</div>
<div>Cost: {formatCosts(frame.summonCost)}</div>
<div className="text-yellow-500/70">Unlock: {formatUnlock(frame.unlockRequirement)}</div>
</div>
</div>
);
}
function MindCircuitCard({ circuit }: { circuit: MindCircuitDefinition }) {
return (
<div className="p-2 rounded border border-gray-700 bg-gray-800/40">
<div className="text-sm font-medium text-violet-300">{circuit.name}</div>
<div className="text-xs text-gray-400 mt-0.5">{circuit.description}</div>
<div className="text-xs text-gray-500 mt-1 space-y-0.5">
<div>Behavior: {circuit.behavior} · Spell Slots: {circuit.spellSlots}</div>
<div>Cost: {formatCosts(circuit.summonCost)}</div>
<div className="text-yellow-500/70">Unlock: {formatUnlock(circuit.unlockRequirement)}</div>
</div>
</div>
);
}
function EnchantmentCard({ enchant }: { enchant: GolemEnchantmentDefinition }) {
return (
<div className="p-2 rounded border border-gray-700 bg-gray-800/40">
<div className="text-sm font-medium text-emerald-300">{enchant.name}</div>
<div className="text-xs text-gray-400 mt-0.5">{enchant.description}</div>
<div className="text-xs text-gray-500 mt-1 space-y-0.5">
<div>Effect: {enchant.effect} · Capacity Cost: {enchant.capacityCost}</div>
<div>Cost: {formatCosts(enchant.summonCost)}</div>
</div>
</div>
);
}
export function GolemDebugSection() { export function GolemDebugSection() {
const golemancy = useCombatStore((s) => s.golemancy);
const setEnabledGolems = useCombatStore((s) => s.setEnabledGolems); 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 = () => { const handleEnableAll = () => setEnabledGolems(allComponentIds);
setEnabledGolems(Object.keys(GOLEMS_DEF));
};
const handleDisableAll = () => { const handleDisableAll = () => setEnabledGolems([]);
setEnabledGolems([]);
};
const handleToggleGolem = (golemId: string) => { const handleToggleComponent = (id: string) => {
if (enabledGolems.includes(golemId)) { if (enabledGolems.includes(id)) {
setEnabledGolems(enabledGolems.filter(id => id !== golemId)); setEnabledGolems(enabledGolems.filter((x) => x !== id));
} else { } else {
setEnabledGolems([...enabledGolems, golemId]); setEnabledGolems([...enabledGolems, id]);
} }
}; };
@@ -35,45 +135,108 @@ export function GolemDebugSection() {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-orange-400 text-sm flex items-center gap-2"> <CardTitle className="text-orange-400 text-sm flex items-center gap-2">
<Bug className="w-4 h-4" /> <Bug className="w-4 h-4" />
Golem Debug Golem Debug Component System
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-4">
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={handleEnableAll}> <Button size="sm" variant="outline" onClick={handleEnableAll}>
<Wand2 className="w-3 h-3 mr-1" /> Enable All Golems Enable All Components
</Button> </Button>
<Button size="sm" variant="outline" onClick={handleDisableAll}> <Button size="sm" variant="outline" onClick={handleDisableAll}>
Disable All Golems Disable All
</Button> </Button>
</div> </div>
<div className="text-xs text-gray-400"> <div className="text-xs text-gray-400">
Enabled: {enabledGolems.length} / {Object.keys(GOLEMS_DEF).length} Enabled: {enabledGolems.length} / {allComponentIds.length}
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
{Object.entries(GOLEMS_DEF).map(([id, def]) => { {/* ─── Cores ─────────────────────────────────────────────── */}
const isEnabled = enabledGolems.includes(id); <div>
return ( <div className="text-xs font-semibold text-amber-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
<div <Cpu className="w-3.5 h-3.5" /> Cores ({ALL_CORES.length})
key={id} </div>
className={`p-2 rounded border flex items-center justify-between ${ <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-52 overflow-y-auto">
isEnabled ? 'border-orange-600/50 bg-orange-900/20' : 'border-gray-700' {ALL_CORES.map((core) => (
}`} <div key={core.id} className="relative">
> <CoreCard core={core} />
<div>
<div className="text-sm font-medium">{def.name}</div>
<div className="text-xs text-gray-400">{def.baseManaType}</div>
</div>
<Button <Button
size="sm" size="sm"
variant={isEnabled ? 'default' : 'outline'} variant={enabledGolems.includes(core.id) ? 'default' : 'outline'}
onClick={() => handleToggleGolem(id)} className="absolute top-1 right-1 h-6 px-2 text-[10px]"
onClick={() => handleToggleComponent(core.id)}
> >
{isEnabled ? 'On' : 'Off'} {enabledGolems.includes(core.id) ? 'On' : 'Off'}
</Button> </Button>
</div> </div>
); ))}
})} </div>
</div>
{/* ─── Frames ────────────────────────────────────────────── */}
<div>
<div className="text-xs font-semibold text-sky-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Box className="w-3.5 h-3.5" /> Frames ({ALL_FRAMES.length})
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-64 overflow-y-auto">
{ALL_FRAMES.map((frame) => (
<div key={frame.id} className="relative">
<FrameCard frame={frame} />
<Button
size="sm"
variant={enabledGolems.includes(frame.id) ? 'default' : 'outline'}
className="absolute top-1 right-1 h-6 px-2 text-[10px]"
onClick={() => handleToggleComponent(frame.id)}
>
{enabledGolems.includes(frame.id) ? 'On' : 'Off'}
</Button>
</div>
))}
</div>
</div>
{/* ─── Mind Circuits ─────────────────────────────────────── */}
<div>
<div className="text-xs font-semibold text-violet-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
<CircuitBoard className="w-3.5 h-3.5" /> Mind Circuits ({ALL_MIND_CIRCUITS.length})
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-44 overflow-y-auto">
{ALL_MIND_CIRCUITS.map((circuit) => (
<div key={circuit.id} className="relative">
<MindCircuitCard circuit={circuit} />
<Button
size="sm"
variant={enabledGolems.includes(circuit.id) ? 'default' : 'outline'}
className="absolute top-1 right-1 h-6 px-2 text-[10px]"
onClick={() => handleToggleComponent(circuit.id)}
>
{enabledGolems.includes(circuit.id) ? 'On' : 'Off'}
</Button>
</div>
))}
</div>
</div>
{/* ─── Enchantments ──────────────────────────────────────── */}
<div>
<div className="text-xs font-semibold text-emerald-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5" /> Enchantments ({ALL_GOLEM_ENCHANTMENTS.length})
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-52 overflow-y-auto">
{ALL_GOLEM_ENCHANTMENTS.map((enchant) => (
<div key={enchant.id} className="relative">
<EnchantmentCard enchant={enchant} />
<Button
size="sm"
variant={enabledGolems.includes(enchant.id) ? 'default' : 'outline'}
className="absolute top-1 right-1 h-6 px-2 text-[10px]"
onClick={() => handleToggleComponent(enchant.id)}
>
{enabledGolems.includes(enchant.id) ? 'On' : 'Off'}
</Button>
</div>
))}
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -81,4 +244,4 @@ export function GolemDebugSection() {
); );
} }
GolemDebugSection.displayName = "GolemDebugSection"; GolemDebugSection.displayName = 'GolemDebugSection';
@@ -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);
});
});
+204 -297
View File
@@ -5,223 +5,53 @@ import { useShallow } from 'zustand/react/shallow';
import { useCombatStore } from '@/lib/game/stores/combatStore'; import { useCombatStore } from '@/lib/game/stores/combatStore';
import { useAttunementStore } from '@/lib/game/stores/attunementStore'; import { useAttunementStore } from '@/lib/game/stores/attunementStore';
import { useManaStore } from '@/lib/game/stores/manaStore'; import { useManaStore } from '@/lib/game/stores/manaStore';
import { GOLEMS_DEF, isGolemUnlocked, canAffordGolemSummon, getGolemSlots } from '@/lib/game/data/golems'; import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
import type { GolemDef } from '@/lib/game/data/golems'; import {
import { ELEMENTS } from '@/lib/game/constants/elements'; CORES, FRAMES, MIND_CIRCUITS, GOLEM_ENCHANTMENTS,
import { DebugName } from '@/components/game/debug/debug-context'; ALL_CORES, ALL_FRAMES, ALL_MIND_CIRCUITS, ALL_GOLEM_ENCHANTMENTS,
import { Badge } from '@/components/ui/badge'; } from '@/lib/game/data/golems';
import { ScrollArea } from '@/components/ui/scroll-area'; 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'; 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<GolemCardProps> = React.memo(({
golem,
unlocked,
enabled,
summoned,
canAfford,
onToggle,
}) => {
const elemColor = ELEMENTS[golem.baseManaType]?.color ?? '#888';
const elemSym = ELEMENTS[golem.baseManaType]?.sym ?? '';
return (
<div
className={clsx(
'rounded-lg border p-4 space-y-3 transition-colors',
unlocked
? 'bg-gray-800/50 border-gray-600'
: 'bg-gray-900/50 border-gray-800 opacity-60',
summoned && 'ring-1 ring-green-500/50',
)}
>
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="font-semibold text-gray-100 truncate">{golem.name}</h3>
<p className="text-xs text-gray-400 mt-0.5">{golem.description}</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span
className="text-xs"
style={{ color: elemColor }}
title={golem.baseManaType}
>
{elemSym}
</span>
<Badge className={clsx('text-[10px] px-1.5 py-0', getTierColor(golem.tier))}>
T{golem.tier}
</Badge>
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div className="text-gray-400">
<span className="text-gray-500">Damage:</span> {golem.damage}
</div>
<div className="text-gray-400">
<span className="text-gray-500">Attack Speed:</span> {golem.attackSpeed}/h
</div>
<div className="text-gray-400">
<span className="text-gray-500">HP:</span> {golem.hp}
</div>
<div className="text-gray-400">
<span className="text-gray-500">Armor Pierce:</span> {Math.round(golem.armorPierce * 100)}%
</div>
{golem.isAoe && (
<div className="col-span-2 text-gray-400">
<span className="text-gray-500">AoE:</span> {golem.aoeTargets} targets
</div>
)}
</div>
{/* Special Abilities */}
{golem.specialAbilities && golem.specialAbilities.length > 0 && (
<div className="text-xs space-y-0.5">
<span className="text-gray-500">Special:</span>
{golem.specialAbilities.map((ability, i) => (
<div key={i} className="text-gray-400 pl-2">
{ability.name}: {ability.description}
</div>
))}
</div>
)}
{/* Costs */}
<div className="text-xs space-y-0.5">
<div className="text-gray-400">
<span className="text-gray-500">Summon:</span>{' '}
{golem.summonCost.map((c, i) => (
<span key={i}>{formatCost(c)}{i < golem.summonCost.length - 1 ? ' + ' : ''}</span>
))}
</div>
<div className="text-gray-400">
<span className="text-gray-500">Upkeep:</span>{' '}
{golem.maintenanceCost.map((c, i) => (
<span key={i}>{formatCost(c)}{i < golem.maintenanceCost.length - 1 ? ' + ' : ''}</span>
))}/tick
</div>
</div>
{/* Unlock requirement */}
{!unlocked && (
<div className="text-xs text-red-400">
🔒 Requires: {formatUnlockCondition(golem)}
</div>
)}
{/* Status + toggle */}
<div className="flex items-center justify-between pt-1">
<div className="text-xs">
{summoned ? (
<span className="text-green-400"> Summoned</span>
) : enabled ? (
<span className="text-yellow-400"> Queued</span>
) : (
<span className="text-gray-500"> Idle</span>
)}
</div>
<button
onClick={() => onToggle(golem.id)}
disabled={!unlocked}
className={clsx(
'rounded px-3 py-1 text-xs font-medium transition-colors',
!unlocked
? 'bg-gray-700 text-gray-500 cursor-not-allowed'
: enabled
? 'bg-red-600/80 text-white hover:bg-red-500'
: canAfford
? 'bg-green-600/80 text-white hover:bg-green-500'
: 'bg-blue-600/80 text-white hover:bg-blue-500',
)}
>
{!unlocked ? 'Locked' : enabled ? 'Disable' : 'Enable'}
</button>
</div>
</div>
);
});
GolemCard.displayName = 'GolemCard';
// ─── Main Tab ────────────────────────────────────────────────────────────────
export const GolemancyTab: React.FC = () => { export const GolemancyTab: React.FC = () => {
const [activeTier, setActiveTier] = useState<string>('base'); const [activeSection, setActiveSection] = useState<BuilderSection>('builder');
const { golemancy, toggleGolem } = useCombatStore(useShallow(s => ({ // Builder state
golemancy: s.golemancy, const [selectedCoreId, setSelectedCoreId] = useState<string | null>(null);
toggleGolem: s.toggleGolem, const [selectedFrameId, setSelectedFrameId] = useState<string | null>(null);
}))); const [selectedCircuitId, setSelectedCircuitId] = useState<string | null>(null);
const [selectedEnchantmentIds, setSelectedEnchantmentIds] = useState<string[]>([]);
const [designName, setDesignName] = useState('');
const [selectedManaTypes, setSelectedManaTypes] = useState<string[]>([]);
// 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 attunements = useAttunementStore(s => s.attunements);
const { rawMana, elements } = useManaStore(useShallow(s => ({ const { rawMana, elements } = useManaStore(useShallow(s => ({
rawMana: s.rawMana, rawMana: s.rawMana,
elements: s.elements, elements: s.elements,
}))); })));
const signedPacts = usePrestigeStore(s => s.signedPacts);
// Build attunement lookup for isGolemUnlocked // Derived data
const attunementLookup = useMemo(() => { const attunementLookup = useMemo(() => {
const lookup: Record<string, { active: boolean; level: number }> = {}; const lookup: Record<string, { active: boolean; level: number }> = {};
for (const [id, att] of Object.entries(attunements)) { for (const [id, att] of Object.entries(attunements)) {
@@ -235,103 +65,180 @@ export const GolemancyTab: React.FC = () => {
[elements], [elements],
); );
// Group golems by tier
const golemsByTier = useMemo(() => {
const groups: Record<string, GolemDef[]> = { 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 fabricatorLevel = attunements.fabricator?.level ?? 0;
const golemSlots = getGolemSlots(fabricatorLevel); 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<string>();
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<string>();
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<string>();
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<ComputedGolemStats | null>(() => {
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 ( return (
<DebugName name="GolemancyTab"> <div className="space-y-4">
<div className="space-y-4"> {/* Header info */}
{/* Header info */} <div className="text-sm text-gray-400 space-y-1">
<div className="text-sm text-gray-400 space-y-1"> <p>
<p> Design custom golems from components. Enabled golems are automatically
Configure your golem loadout. Enabled golems are automatically summoned summoned when entering the spire if you can afford the cost.
when entering the spire if you can afford the cost. </p>
</p> <div className="flex gap-4 text-xs">
<div className="flex gap-4 text-xs"> <span>Slots: {enabledCount}/{golemSlots}</span>
<span> <span>Active: {golemancy.activeGolems.length}</span>
Slots: {enabledCount}/{golemSlots} <span>Designs: {Object.keys(golemancy.golemDesigns).length}</span>
</span>
<span>
Summoned: {golemancy.summonedGolems.length}
</span>
</div>
</div> </div>
{/* Tier tabs */}
<div className="flex gap-2">
{TIERS.map((tier) => {
const count = golemsByTier[tier.key]?.length ?? 0;
return (
<button
key={tier.key}
onClick={() => setActiveTier(tier.key)}
className={clsx(
'rounded px-3 py-1 text-xs font-medium transition-colors',
activeTier === tier.key
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:text-gray-200',
)}
>
{tier.label} ({count})
</button>
);
})}
</div>
{/* Golem cards */}
<ScrollArea className="h-[500px] rounded border border-gray-700 p-3">
{activeTierGolems.length === 0 ? (
<div className="text-center text-gray-500 py-8">
No golems in this tier.
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{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 (
<GolemCard
key={golem.id}
golem={golem}
unlocked={unlocked}
enabled={enabled}
summoned={summoned}
canAfford={canAfford}
onToggle={handleToggle}
/>
);
})}
</div>
)}
</ScrollArea>
</div> </div>
</DebugName>
{/* Section tabs */}
<div className="flex gap-2">
{([
{ key: 'builder', label: 'Design Builder' },
{ key: 'loadout', label: `Loadout (${golemancy.golemLoadout.length})` },
{ key: 'active', label: `Active (${golemancy.activeGolems.length})` },
] as const).map(({ key, label }) => (
<button
key={key}
onClick={() => setActiveSection(key)}
className={clsx(
'rounded px-3 py-1 text-xs font-medium transition-colors',
activeSection === key
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:text-gray-200',
)}
>
{label}
</button>
))}
</div>
{/* ─── Builder Section ─────────────────────────────────────────────── */}
{activeSection === 'builder' && (
<GolemDesignBuilder
selectedCoreId={selectedCoreId}
selectedFrameId={selectedFrameId}
selectedCircuitId={selectedCircuitId}
selectedEnchantmentIds={selectedEnchantmentIds}
designName={designName}
selectedManaTypes={selectedManaTypes}
unlockedCoreIds={unlockedCoreIds}
unlockedFrameIds={unlockedFrameIds}
unlockedCircuitIds={unlockedCircuitIds}
computedStats={computedStats}
affordability={affordability}
enchantmentCapacity={enchantmentCapacity}
usedEnchantmentCapacity={usedEnchantmentCapacity}
golemSlots={golemSlots}
enabledCount={enabledCount}
onSelectCore={setSelectedCoreId}
onSelectFrame={setSelectedFrameId}
onSelectCircuit={setSelectedCircuitId}
onToggleEnchantment={handleToggleEnchantment}
onDesignNameChange={setDesignName}
onSaveDesign={handleSaveDesign}
/>
)}
{/* ─── Loadout Section ─────────────────────────────────────────────── */}
{activeSection === 'loadout' && (
<GolemLoadoutPanel
loadout={golemancy.golemLoadout}
onToggle={handleToggleLoadoutEntry}
onRemove={handleRemoveLoadoutEntry}
/>
)}
{/* ─── Active Golems Section ───────────────────────────────────────── */}
{activeSection === 'active' && (
<ActiveGolemsPanel activeGolems={golemancy.activeGolems} />
)}
</div>
); );
}; };
@@ -4,7 +4,7 @@ import { useCombatStore, useManaStore, canAffordSpellCost } from '@/lib/game/sto
import { SPELLS_DEF, ELEMENTS } from '@/lib/game/constants'; import { SPELLS_DEF, ELEMENTS } from '@/lib/game/constants';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress'; 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'; import { DebugName } from '@/components/game/debug/debug-context';
interface SpireCombatControlsProps { interface SpireCombatControlsProps {
@@ -29,7 +29,8 @@ export function SpireCombatControls({ castProgress }: SpireCombatControlsProps)
.filter(([, state]) => state?.learned) .filter(([, state]) => state?.learned)
.map(([id]) => id); .map(([id]) => id);
const summonedGolems = golemancy.summonedGolems || []; const activeGolems = golemancy.activeGolems || [];
const golemDesigns = golemancy.golemDesigns || {};
return ( return (
<DebugName name="SpireCombatControls"> <DebugName name="SpireCombatControls">
@@ -100,26 +101,28 @@ export function SpireCombatControls({ castProgress }: SpireCombatControlsProps)
<CardTitle className="text-sm text-blue-400">🗿 Golems</CardTitle> <CardTitle className="text-sm text-blue-400">🗿 Golems</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{summonedGolems.length === 0 ? ( {activeGolems.length === 0 ? (
<div className="text-xs text-gray-500 italic">No golems summoned.</div> <div className="text-xs text-gray-500 italic">No golems summoned.</div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{summonedGolems.map((sg) => { {activeGolems.map((ag) => {
const golemDef = GOLEMS_DEF[sg.golemId]; const design = golemDesigns[ag.designId];
if (!golemDef) return null; if (!design) return null;
const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888'; const frame = FRAMES[design.frameId];
const core = CORES[design.coreId];
const elemColor = frame?.element ? ELEMENTS[frame.element]?.color || '#888' : '#888';
return ( return (
<div <div
key={sg.golemId} key={ag.designId + ag.summonedFloor}
className="flex items-center justify-between p-2 bg-gray-800/50 rounded border border-gray-700/50" className="flex items-center justify-between p-2 bg-gray-800/50 rounded border border-gray-700/50"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span style={{ color: elemColor }}></span> <span style={{ color: elemColor }}></span>
<span className="text-xs text-gray-200">{golemDef.name}</span> <span className="text-xs text-gray-200">{design.name}</span>
</div> </div>
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
{golemDef.damage} dmg · {golemDef.attackSpeed}/h {frame?.baseDamage ?? '?'} dmg · {core?.manaRegen ?? '?'} mp
</div> </div>
</div> </div>
); );
@@ -0,0 +1,83 @@
'use client';
import React from 'react';
import { useCombatStore } from '@/lib/game/stores/combatStore';
import { CORES } from '@/lib/game/data/golems';
import type { RuntimeActiveGolem } from './types';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
// ─── Active Golem Card ───────────────────────────────────────────────────────
interface ActiveGolemCardProps {
golem: RuntimeActiveGolem;
}
const ActiveGolemCard: React.FC<ActiveGolemCardProps> = React.memo(({ golem }) => {
const loadoutEntry = useCombatStore(s =>
s.golemancy.golemLoadout.find(e => e.designId === golem.designId),
);
const name = loadoutEntry?.design.name ?? golem.designId;
const core = loadoutEntry ? CORES[loadoutEntry.design.coreId] : null;
const maxMana = core?.manaCapacity ?? 100;
return (
<div className="rounded-lg border border-green-700/50 bg-green-900/10 p-3 space-y-2">
<div className="flex items-center justify-between">
<h4 className="font-semibold text-sm text-green-300">{name}</h4>
<Badge className="text-[10px] px-1.5 py-0 bg-green-800 text-green-200">
Active
</Badge>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div className="text-gray-400">
<span className="text-gray-500">Summoned Floor:</span> {golem.summonedFloor}
</div>
<div className="text-gray-400">
<span className="text-gray-500">Rooms Left:</span> {golem.roomsRemaining}
</div>
<div className="text-gray-400">
<span className="text-gray-500">Mana:</span>{' '}
<span className={golem.currentMana < maxMana * 0.2 ? 'text-red-400' : 'text-blue-400'}>
{Math.round(golem.currentMana)}/{maxMana}
</span>
</div>
<div className="text-gray-400">
<span className="text-gray-500">Atk Progress:</span>{' '}
{Math.round(golem.attackProgress * 100)}%
</div>
</div>
</div>
);
});
ActiveGolemCard.displayName = 'ActiveGolemCard';
// ─── Props ───────────────────────────────────────────────────────────────────
export interface ActiveGolemsPanelProps {
activeGolems: RuntimeActiveGolem[];
}
// ─── Component ───────────────────────────────────────────────────────────────
export const ActiveGolemsPanel: React.FC<ActiveGolemsPanelProps> = ({ activeGolems }) => {
return (
<ScrollArea className="h-[560px] rounded border border-gray-700 p-3">
{activeGolems.length === 0 ? (
<div className="text-center text-gray-500 py-8">
<p>No active golems in combat.</p>
<p className="text-xs mt-1">Enable golem designs in the Loadout and enter the spire.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{activeGolems.map(golem => (
<ActiveGolemCard key={golem.designId} golem={golem} />
))}
</div>
)}
</ScrollArea>
);
};
ActiveGolemsPanel.displayName = 'ActiveGolemsPanel';
@@ -0,0 +1,160 @@
'use client';
import React from 'react';
import {
CORES, ALL_CORES, FRAMES, ALL_FRAMES,
MIND_CIRCUITS, ALL_MIND_CIRCUITS,
GOLEM_ENCHANTMENTS, ALL_GOLEM_ENCHANTMENTS,
} from '@/lib/game/data/golems';
import type {
CoreDefinition, FrameDefinition, MindCircuitDefinition,
GolemEnchantmentDefinition, ComputedGolemStats,
} from '@/lib/game/data/golems/types';
import { ELEMENTS } from '@/lib/game/constants/elements';
import { ScrollArea } from '@/components/ui/scroll-area';
import clsx from 'clsx';
import { formatRequirement } from './golemancy-utils';
import { ComponentSelector, StatsPreview } from './GolemancySharedComponents';
export interface GolemDesignBuilderProps {
selectedCoreId: string | null;
selectedFrameId: string | null;
selectedCircuitId: string | null;
selectedEnchantmentIds: string[];
designName: string;
selectedManaTypes: string[];
unlockedCoreIds: Set<string>;
unlockedFrameIds: Set<string>;
unlockedCircuitIds: Set<string>;
computedStats: ComputedGolemStats | null;
affordability: { canAfford: boolean; missing: string };
enchantmentCapacity: number;
usedEnchantmentCapacity: number;
golemSlots: number;
enabledCount: number;
onSelectCore: (id: string) => void;
onSelectFrame: (id: string) => void;
onSelectCircuit: (id: string) => void;
onToggleEnchantment: (id: string) => void;
onDesignNameChange: (name: string) => void;
onSaveDesign: () => void;
}
export const GolemDesignBuilder: React.FC<GolemDesignBuilderProps> = ({
selectedCoreId, selectedFrameId, selectedCircuitId,
selectedEnchantmentIds, designName, selectedManaTypes,
unlockedCoreIds, unlockedFrameIds, unlockedCircuitIds,
computedStats, affordability, enchantmentCapacity, usedEnchantmentCapacity,
onSelectCore, onSelectFrame, onSelectCircuit, onToggleEnchantment,
onDesignNameChange, onSaveDesign,
}) => {
const canSaveDesign = selectedCoreId && selectedFrameId && selectedCircuitId && affordability.canAfford;
const selectedCore = selectedCoreId ? CORES[selectedCoreId] ?? null : null;
const selectedFrame = selectedFrameId ? FRAMES[selectedFrameId] ?? null : null;
const selectedCircuit = selectedCircuitId ? MIND_CIRCUITS[selectedCircuitId] ?? null : null;
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<ScrollArea className="h-[560px] rounded border border-gray-700 p-3">
<div className="space-y-4">
<div className="space-y-1">
<label className="text-xs text-gray-400">Design Name</label>
<input type="text" value={designName} onChange={e => onDesignNameChange(e.target.value)}
placeholder="Enter a name for this golem..."
className="w-full rounded bg-gray-800 border border-gray-700 px-3 py-1.5 text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500" />
</div>
<ComponentSelector label="1. Core (Power Source)" items={ALL_CORES} selectedId={selectedCoreId} unlockedIds={unlockedCoreIds} onSelect={onSelectCore}
renderItem={(core: CoreDefinition, unlocked: boolean) => (
<div>
<div className="flex items-center justify-between">
<span className="font-medium">{core.name}</span>
<span className="text-gray-500">T{core.tier}</span>
</div>
<p className="text-gray-500 mt-0.5">{core.description}</p>
<div className="flex gap-2 mt-1 text-gray-500">
<span>Cap: {core.manaCapacity}</span><span>Regen: {core.manaRegen}/h</span><span>Duration: {core.maxRoomDuration}r</span>
</div>
{!unlocked && <p className="text-red-400 mt-0.5">🔒 {formatRequirement(core.unlockRequirement)}</p>}
</div>
)} />
<ComponentSelector label="2. Frame (Combat Body)" items={ALL_FRAMES} selectedId={selectedFrameId} unlockedIds={unlockedFrameIds} onSelect={onSelectFrame}
renderItem={(frame: FrameDefinition, unlocked: boolean) => (
<div>
<div className="flex items-center justify-between">
<span className="font-medium">{frame.name}</span>
{frame.element && <span style={{ color: ELEMENTS[frame.element]?.color ?? '#888' }}>{ELEMENTS[frame.element]?.sym ?? ''} {frame.element}</span>}
</div>
<p className="text-gray-500 mt-0.5">{frame.description}</p>
<div className="flex gap-2 mt-1 text-gray-500 flex-wrap">
<span>DMG: {frame.baseDamage}</span><span>SPD: {frame.attackSpeed}/h</span>
<span>AP: {Math.round(frame.armorPierce * 100)}%</span><span>MA: {Math.round(frame.magicAffinity * 100)}%</span>
{frame.aoeTargets > 1 && <span>AoE: {frame.aoeTargets}</span>}
{frame.specialEffect !== 'none' && <span className="text-purple-400 capitalize">{frame.specialEffect}</span>}
</div>
{!unlocked && <p className="text-red-400 mt-0.5">🔒 {formatRequirement(frame.unlockRequirement)}</p>}
</div>
)} />
<ComponentSelector label="3. Mind Circuit (Behavior)" items={ALL_MIND_CIRCUITS} selectedId={selectedCircuitId} unlockedIds={unlockedCircuitIds} onSelect={onSelectCircuit}
renderItem={(circuit: MindCircuitDefinition, unlocked: boolean) => (
<div>
<div className="flex items-center justify-between">
<span className="font-medium">{circuit.name}</span>
<span className="text-gray-500">Slots: {circuit.spellSlots}</span>
</div>
<p className="text-gray-500 mt-0.5">{circuit.description}</p>
<div className="flex gap-2 mt-1 text-gray-500"><span className="capitalize">Behavior: {circuit.behavior}</span></div>
{!unlocked && <p className="text-red-400 mt-0.5">🔒 {formatRequirement(circuit.unlockRequirement)}</p>}
</div>
)} />
{selectedCore && selectedFrame && selectedCircuit && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-300">4. Enchantments (Optional)
<span className="text-gray-500 font-normal ml-2">{usedEnchantmentCapacity}/{Math.round(enchantmentCapacity)} capacity</span>
</h4>
{usedEnchantmentCapacity > enchantmentCapacity && <p className="text-xs text-red-400">Over capacity! Remove enchantments to save design.</p>}
<div className="grid grid-cols-1 gap-1.5">
{ALL_GOLEM_ENCHANTMENTS.map(enchant => {
const isSelected = selectedEnchantmentIds.includes(enchant.id);
const canAdd = !isSelected && usedEnchantmentCapacity + enchant.capacityCost <= enchantmentCapacity;
return (
<button key={enchant.id} onClick={() => onToggleEnchantment(enchant.id)} disabled={!isSelected && !canAdd}
className={clsx('text-left rounded px-3 py-2 text-xs transition-colors',
isSelected ? 'bg-purple-600/30 border border-purple-500 text-purple-200'
: canAdd ? 'bg-gray-800/60 border border-gray-700 text-gray-300 hover:bg-gray-700/60'
: 'bg-gray-900/40 border border-gray-800 text-gray-600 cursor-not-allowed')}>
<div className="flex items-center justify-between">
<span className="font-medium">{enchant.name}</span>
<span className="text-gray-500">Cost: {enchant.capacityCost}</span>
</div>
<p className="text-gray-500 mt-0.5">{enchant.description}</p>
<p className="text-gray-600 mt-0.5">Effect: {enchant.effect}</p>
</button>
);
})}
</div>
</div>
)}
<div className="pt-2 border-t border-gray-700">
<button onClick={onSaveDesign} disabled={!canSaveDesign}
className={clsx('w-full rounded px-4 py-2 text-sm font-medium transition-colors',
canSaveDesign ? 'bg-blue-600 text-white hover:bg-blue-500' : 'bg-gray-700 text-gray-500 cursor-not-allowed')}>
{canSaveDesign ? 'Save Design' : 'Select all required components'}
</button>
</div>
</div>
</ScrollArea>
<div className="rounded border border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-200 mb-3">Stats Preview</h3>
<StatsPreview stats={computedStats} canAfford={affordability} />
</div>
</div>
);
};
GolemDesignBuilder.displayName = 'GolemDesignBuilder';
@@ -0,0 +1,116 @@
'use client';
import React from 'react';
import { CORES, FRAMES, MIND_CIRCUITS, GOLEM_ENCHANTMENTS } from '@/lib/game/data/golems';
import type { GolemLoadoutEntry } from './types';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import clsx from 'clsx';
// ─── Loadout Entry Card ──────────────────────────────────────────────────────
interface LoadoutCardProps {
entry: GolemLoadoutEntry;
onToggle: (designId: string) => void;
onRemove: (designId: string) => void;
}
const LoadoutCard: React.FC<LoadoutCardProps> = React.memo(({
entry,
onToggle,
onRemove,
}) => {
const core = CORES[entry.design.coreId];
const frame = FRAMES[entry.design.frameId];
const circuit = MIND_CIRCUITS[entry.design.mindCircuitId];
const enchantments = entry.design.enchantmentIds
.map(id => GOLEM_ENCHANTMENTS[id])
.filter(Boolean);
return (
<div className={clsx(
'rounded-lg border p-3 space-y-2 transition-colors',
entry.enabled
? 'bg-gray-800/50 border-gray-600'
: 'bg-gray-900/50 border-gray-800 opacity-70',
)}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h4 className="font-semibold text-sm text-gray-100 truncate">{entry.design.name}</h4>
<p className="text-[10px] text-gray-500 mt-0.5">
{core?.name ?? '?'} + {frame?.name ?? '?'} + {circuit?.name ?? '?'}
{enchantments.length > 0 && ` + ${enchantments.length} enchantment(s)`}
</p>
</div>
<Badge className={clsx(
'text-[10px] px-1.5 py-0 shrink-0',
entry.enabled ? 'bg-green-700' : 'bg-gray-700',
)}>
{entry.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => onToggle(entry.designId)}
className={clsx(
'rounded px-2.5 py-1 text-xs font-medium transition-colors',
entry.enabled
? 'bg-red-600/80 text-white hover:bg-red-500'
: 'bg-green-600/80 text-white hover:bg-green-500',
)}
>
{entry.enabled ? 'Disable' : 'Enable'}
</button>
<button
onClick={() => onRemove(entry.designId)}
className="rounded px-2.5 py-1 text-xs font-medium bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors"
>
Remove
</button>
</div>
</div>
);
});
LoadoutCard.displayName = 'LoadoutCard';
// ─── Props ───────────────────────────────────────────────────────────────────
export interface GolemLoadoutPanelProps {
loadout: GolemLoadoutEntry[];
onToggle: (designId: string) => void;
onRemove: (designId: string) => void;
}
// ─── Component ───────────────────────────────────────────────────────────────
export const GolemLoadoutPanel: React.FC<GolemLoadoutPanelProps> = ({
loadout,
onToggle,
onRemove,
}) => {
return (
<ScrollArea className="h-[560px] rounded border border-gray-700 p-3">
{loadout.length === 0 ? (
<div className="text-center text-gray-500 py-8">
<p>No golem designs saved yet.</p>
<p className="text-xs mt-1">Use the Design Builder to create and save golem designs.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{loadout.map(entry => (
<LoadoutCard
key={entry.designId}
entry={entry}
onToggle={onToggle}
onRemove={onRemove}
/>
))}
</div>
)}
</ScrollArea>
);
};
GolemLoadoutPanel.displayName = 'GolemLoadoutPanel';
@@ -0,0 +1,271 @@
// ─── Test: computeGolemStats ──────────────────────────────────────────────────
describe('computeGolemStats', () => {
it('computes stats for a basic golem design', async () => {
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
const { computeGolemStats } = await import('@/lib/game/data/golems/utils');
const design = {
id: 'test_basic',
name: 'Test Basic',
core: CORES.basic,
frame: FRAMES.earth,
mindCircuit: MIND_CIRCUITS.simple,
enchantments: [],
selectedManaTypes: [],
selectedSpells: [],
};
const stats = computeGolemStats(design);
// Core-derived stats
expect(stats.manaCapacity).toBe(50);
expect(stats.manaRegen).toBe(0.5);
expect(stats.maxRoomDuration).toBe(3);
// Frame-derived stats
expect(stats.baseDamage).toBe(6);
expect(stats.attackSpeed).toBe(1.2);
expect(stats.armorPierce).toBe(0.05);
expect(stats.magicAffinity).toBe(0.3);
expect(stats.aoeTargets).toBe(1);
// Circuit-derived stats
expect(stats.spellSlots).toBe(0);
// Enchantment capacity = frame.magicAffinity * core.tierMultiplier
expect(stats.enchantmentCapacity).toBeCloseTo(0.3 * 1.0);
// Special effect from frame
expect(stats.specialEffect).toBe('none');
// Available mana types from core
expect(stats.availableManaTypes).toEqual(['earth']);
});
it('computes stats for an advanced golem with enchantments', async () => {
const { CORES, FRAMES, MIND_CIRCUITS, GOLEM_ENCHANTMENTS } = await import('@/lib/game/data/golems');
const { computeGolemStats } = await import('@/lib/game/data/golems/utils');
const design = {
id: 'test_advanced',
name: 'Test Advanced',
core: CORES.advanced,
frame: FRAMES.steel,
mindCircuit: MIND_CIRCUITS.advanced,
enchantments: [GOLEM_ENCHANTMENTS.sword_fire, GOLEM_ENCHANTMENTS.sword_metal],
selectedManaTypes: ['crystal', 'metal', 'fire'],
selectedSpells: [],
};
const stats = computeGolemStats(design);
// Core-derived stats
expect(stats.manaCapacity).toBe(200);
expect(stats.manaRegen).toBe(3.0);
expect(stats.maxRoomDuration).toBe(5);
// Frame-derived stats
expect(stats.baseDamage).toBe(18);
expect(stats.attackSpeed).toBe(1.6);
expect(stats.armorPierce).toBe(0.5);
expect(stats.magicAffinity).toBe(0.5);
// Circuit-derived stats
expect(stats.spellSlots).toBe(2);
// Enchantment capacity = frame.magicAffinity * core.tierMultiplier
expect(stats.enchantmentCapacity).toBeCloseTo(0.5 * 2.0);
// Selected mana types override core defaults
expect(stats.availableManaTypes).toEqual(['crystal', 'metal', 'fire']);
// Total summon cost includes all components + enchantments
expect(stats.totalSummonCost.length).toBeGreaterThan(0);
// Upkeep = core.manaRegen * 2 per hour
expect(stats.upkeepCostPerHour.length).toBe(1);
expect(stats.upkeepCostPerHour[0].amount).toBe(6.0); // 3.0 * 2
expect(stats.upkeepCostPerHour[0].element).toBe('crystal');
});
it('computes total summon cost from all components', async () => {
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
const { computeGolemStats } = await import('@/lib/game/data/golems/utils');
const design = {
id: 'test_cost',
name: 'Test Cost',
core: CORES.basic,
frame: FRAMES.earth,
mindCircuit: MIND_CIRCUITS.simple,
enchantments: [],
selectedManaTypes: [],
selectedSpells: [],
};
const stats = computeGolemStats(design);
// basic core: 10 earth, earth frame: 5 raw, simple circuit: 3 raw
const rawCosts = stats.totalSummonCost.filter(c => c.type === 'raw');
const earthCosts = stats.totalSummonCost.filter(c => c.type === 'element' && c.element === 'earth');
const totalRaw = rawCosts.reduce((sum, c) => sum + c.amount, 0);
const totalEarth = earthCosts.reduce((sum, c) => sum + c.amount, 0);
expect(totalRaw).toBe(8); // 5 + 3
expect(totalEarth).toBe(10);
});
});
// ─── Test: canAffordGolemDesign ───────────────────────────────────────────────
describe('canAffordGolemDesign', () => {
it('returns canAfford true when player has enough resources', async () => {
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
const { canAffordGolemDesign } = await import('@/lib/game/data/golems/utils');
const design = {
id: 'test_afford',
name: 'Test Afford',
core: CORES.basic,
frame: FRAMES.earth,
mindCircuit: MIND_CIRCUITS.simple,
enchantments: [],
selectedManaTypes: [],
selectedSpells: [],
};
// basic core: 10 earth, earth frame: 5 raw, simple circuit: 3 raw
const result = canAffordGolemDesign(design, 100, {
earth: { current: 50, max: 100, unlocked: true },
});
expect(result.canAfford).toBe(true);
expect(result.missing).toBe('');
});
it('returns canAfford false when raw mana is insufficient', async () => {
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
const { canAffordGolemDesign } = await import('@/lib/game/data/golems/utils');
const design = {
id: 'test_no_raw',
name: 'Test No Raw',
core: CORES.basic,
frame: FRAMES.earth,
mindCircuit: MIND_CIRCUITS.simple,
enchantments: [],
selectedManaTypes: [],
selectedSpells: [],
};
// Need 8 raw total (5 + 3), only have 3
const result = canAffordGolemDesign(design, 3, {
earth: { current: 50, max: 100, unlocked: true },
});
expect(result.canAfford).toBe(false);
expect(result.missing).toContain('raw mana');
});
it('returns canAfford false when element mana is insufficient', async () => {
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
const { canAffordGolemDesign } = await import('@/lib/game/data/golems/utils');
const design = {
id: 'test_no_elem',
name: 'Test No Elem',
core: CORES.basic,
frame: FRAMES.earth,
mindCircuit: MIND_CIRCUITS.simple,
enchantments: [],
selectedManaTypes: [],
selectedSpells: [],
};
// Need 10 earth, only have 5
const result = canAffordGolemDesign(design, 100, {
earth: { current: 5, max: 100, unlocked: true },
});
expect(result.canAfford).toBe(false);
expect(result.missing).toContain('earth');
});
it('returns canAfford false when element is not unlocked', async () => {
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
const { canAffordGolemDesign } = await import('@/lib/game/data/golems/utils');
const design = {
id: 'test_locked',
name: 'Test Locked',
core: CORES.basic,
frame: FRAMES.earth,
mindCircuit: MIND_CIRCUITS.simple,
enchantments: [],
selectedManaTypes: [],
selectedSpells: [],
};
// earth not in elements at all
const result = canAffordGolemDesign(design, 100, {});
expect(result.canAfford).toBe(false);
expect(result.missing).toContain('not unlocked');
});
it('returns canAfford false when element exists but unlocked is false', async () => {
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
const { canAffordGolemDesign } = await import('@/lib/game/data/golems/utils');
const design = {
id: 'test_unlocked_false',
name: 'Test Unlocked False',
core: CORES.basic,
frame: FRAMES.earth,
mindCircuit: MIND_CIRCUITS.simple,
enchantments: [],
selectedManaTypes: [],
selectedSpells: [],
};
const result = canAffordGolemDesign(design, 100, {
earth: { current: 50, max: 100, unlocked: false },
});
expect(result.canAfford).toBe(false);
expect(result.missing).toContain('not unlocked');
});
});
// ─── Test: Combat store golemancy state ───────────────────────────────────────
describe('Combat store golemancy', () => {
it('toggleGolem is a function', async () => {
const { useCombatStore } = await import('@/lib/game/stores/combatStore');
const state = useCombatStore.getState();
expect(typeof state.toggleGolem).toBe('function');
});
it('golemancy state has correct shape', async () => {
const { useCombatStore } = await import('@/lib/game/stores/combatStore');
const state = useCombatStore.getState();
expect(state.golemancy).toBeDefined();
expect(Array.isArray(state.golemancy.golemLoadout)).toBe(true);
expect(Array.isArray(state.golemancy.activeGolems)).toBe(true);
});
});
// ─── Test: File size limit ────────────────────────────────────────────────────
describe('File size limits (400 lines max)', () => {
it('GolemancyTab.tsx is under 400 lines', async () => {
const fs = await import('fs');
const path = await import('path');
const filePath = path.join(__dirname, '..', 'GolemancyTab.tsx');
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n').length;
expect(lines).toBeLessThan(400);
});
});
@@ -0,0 +1,82 @@
'use client';
import React from 'react';
import type { ComputedGolemStats, CoreDefinition } from '@/lib/game/data/golems/types';
import { ELEMENTS } from '@/lib/game/constants/elements';
import clsx from 'clsx';
import { formatManaCost, formatRequirement } from './golemancy-utils';
interface ComponentSelectorProps<T> {
label: string;
items: T[];
selectedId: string | null;
unlockedIds: Set<string>;
onSelect: (id: string) => void;
renderItem: (item: T, unlocked: boolean, selected: boolean) => React.ReactNode;
}
export function ComponentSelector<T extends { id: string }>({
label, items, selectedId, unlockedIds, onSelect, renderItem,
}: ComponentSelectorProps<T>) {
return (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-300">{label}</h4>
<div className="grid grid-cols-1 gap-1.5">
{items.map(item => {
const unlocked = unlockedIds.has(item.id);
const selected = selectedId === item.id;
return (
<button key={item.id} onClick={() => unlocked && onSelect(item.id)} disabled={!unlocked}
className={clsx('text-left rounded px-3 py-2 text-xs transition-colors',
selected ? 'bg-blue-600/30 border border-blue-500 text-blue-200'
: unlocked ? 'bg-gray-800/60 border border-gray-700 text-gray-300 hover:bg-gray-700/60'
: 'bg-gray-900/40 border border-gray-800 text-gray-600 cursor-not-allowed')}>
{renderItem(item, unlocked, selected)}
</button>
);
})}
</div>
</div>
);
}
interface StatsPreviewProps {
stats: ComputedGolemStats | null;
canAfford: { canAfford: boolean; missing: string };
}
export function StatsPreview({ stats, canAfford }: StatsPreviewProps) {
if (!stats) return <div className="text-xs text-gray-500 italic">Select all required components to see stats preview.</div>;
return (
<div className="space-y-3">
<div className={clsx('text-xs font-medium px-2 py-1 rounded',
canAfford.canAfford ? 'text-green-400 bg-green-900/20' : 'text-red-400 bg-red-900/20')}>
{canAfford.canAfford ? '✓ Can afford summon cost' : `✗ Cannot afford: ${canAfford.missing}`}
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div className="text-gray-400"><span className="text-gray-500">Damage:</span> {stats.baseDamage}</div>
<div className="text-gray-400"><span className="text-gray-500">Atk Speed:</span> {stats.attackSpeed}/h</div>
<div className="text-gray-400"><span className="text-gray-500">Armor Pierce:</span> {Math.round(stats.armorPierce * 100)}%</div>
<div className="text-gray-400"><span className="text-gray-500">Magic Affinity:</span> {Math.round(stats.magicAffinity * 100)}%</div>
<div className="text-gray-400"><span className="text-gray-500">AoE Targets:</span> {stats.aoeTargets}</div>
<div className="text-gray-400"><span className="text-gray-500">Spell Slots:</span> {stats.spellSlots}</div>
<div className="text-gray-400"><span className="text-gray-500">Mana Capacity:</span> {stats.manaCapacity}</div>
<div className="text-gray-400"><span className="text-gray-500">Mana Regen:</span> {stats.manaRegen}/h</div>
<div className="text-gray-400"><span className="text-gray-500">Room Duration:</span> {stats.maxRoomDuration} rooms</div>
<div className="text-gray-400"><span className="text-gray-500">Enchant Cap:</span> {Math.round(stats.enchantmentCapacity)}</div>
{stats.specialEffect !== 'none' && (
<div className="col-span-2 text-gray-400"><span className="text-gray-500">Special:</span>{' '}<span className="text-purple-400 capitalize">{stats.specialEffect}</span></div>
)}
</div>
<div className="text-xs"><span className="text-gray-500">Summon Cost:</span>{' '}
{stats.totalSummonCost.map((c, i) => <span key={i} className="text-gray-400">{formatManaCost(c)}{i < stats.totalSummonCost.length - 1 ? ' + ' : ''}</span>)}
</div>
<div className="text-xs"><span className="text-gray-500">Upkeep:</span>{' '}
{stats.upkeepCostPerHour.map((c, i) => <span key={i} className="text-gray-400">{formatManaCost(c)}{i < stats.upkeepCostPerHour.length - 1 ? ' + ' : ''}/h</span>)}
</div>
<div className="text-xs"><span className="text-gray-500">Mana Types:</span>{' '}
{stats.availableManaTypes.map((mt, i) => <span key={mt} className="text-gray-400">{ELEMENTS[mt]?.sym ?? ''}{mt}{i < stats.availableManaTypes.length - 1 ? ', ' : ''}</span>)}
</div>
</div>
);
}
@@ -0,0 +1,160 @@
import { describe, it, expect } from 'vitest';
import { CORES, FRAMES, MIND_CIRCUITS, GOLEM_ENCHANTMENTS, ALL_CORES, ALL_FRAMES, ALL_MIND_CIRCUITS, ALL_GOLEM_ENCHANTMENTS } from '@/lib/game/data/golems';
// ─── Test: GolemancyTab barrel export ─────────────────────────────────────────
describe('GolemancyTab module structure', () => {
it('exports GolemancyTab from barrel index', async () => {
const mod = await import('../GolemancyTab');
expect(mod.GolemancyTab).toBeDefined();
expect(typeof mod.GolemancyTab).toBe('function');
});
it('GolemancyTab has correct displayName', async () => {
const { GolemancyTab } = await import('../GolemancyTab');
expect(GolemancyTab.displayName).toBe('GolemancyTab');
});
});
// ─── Test: Barrel export includes GolemancyTab ────────────────────────────────
describe('Tab barrel export', () => {
it('includes GolemancyTab in the tabs index', async () => {
const mod = await import('@/components/game/tabs');
expect(mod.GolemancyTab).toBeDefined();
expect(typeof mod.GolemancyTab).toBe('function');
});
});
// ─── Test: Component registries ───────────────────────────────────────────────
describe('Component registries', () => {
it('CORES has all four core definitions', () => {
expect(CORES.basic).toBeDefined();
expect(CORES.intermediate).toBeDefined();
expect(CORES.advanced).toBeDefined();
expect(CORES.guardian).toBeDefined();
});
it('FRAMES has all seven frame definitions', () => {
expect(FRAMES.earth).toBeDefined();
expect(FRAMES.sand).toBeDefined();
expect(FRAMES.frost).toBeDefined();
expect(FRAMES.crystal).toBeDefined();
expect(FRAMES.steel).toBeDefined();
expect(FRAMES.shadowglass).toBeDefined();
expect(FRAMES.crystalSteelHybrid).toBeDefined();
});
it('MIND_CIRCUITS has all four circuit definitions', () => {
expect(MIND_CIRCUITS.simple).toBeDefined();
expect(MIND_CIRCUITS.intermediate).toBeDefined();
expect(MIND_CIRCUITS.advanced).toBeDefined();
expect(MIND_CIRCUITS.guardian).toBeDefined();
});
it('GOLEM_ENCHANTMENTS has all eight enchantment definitions', () => {
expect(GOLEM_ENCHANTMENTS.sword_fire).toBeDefined();
expect(GOLEM_ENCHANTMENTS.sword_frost).toBeDefined();
expect(GOLEM_ENCHANTMENTS.sword_lightning).toBeDefined();
expect(GOLEM_ENCHANTMENTS.sword_shadow).toBeDefined();
expect(GOLEM_ENCHANTMENTS.sword_metal).toBeDefined();
expect(GOLEM_ENCHANTMENTS.sword_crystal).toBeDefined();
expect(GOLEM_ENCHANTMENTS.sword_water).toBeDefined();
expect(GOLEM_ENCHANTMENTS.sword_earth).toBeDefined();
});
it('ALL_CORES contains all core definitions', () => {
expect(ALL_CORES.length).toBe(4);
});
it('ALL_FRAMES contains all frame definitions', () => {
expect(ALL_FRAMES.length).toBe(7);
});
it('ALL_MIND_CIRCUITS contains all circuit definitions', () => {
expect(ALL_MIND_CIRCUITS.length).toBe(4);
});
it('ALL_GOLEM_ENCHANTMENTS contains all enchantment definitions', () => {
expect(ALL_GOLEM_ENCHANTMENTS.length).toBe(8);
});
it('all cores have required fields', () => {
for (const core of ALL_CORES) {
expect(core.id).toBeTruthy();
expect(core.name).toBeTruthy();
expect(core.description).toBeTruthy();
expect(core.tier).toBeGreaterThanOrEqual(1);
expect(core.tier).toBeLessThanOrEqual(4);
expect(core.manaCapacity).toBeGreaterThan(0);
expect(core.manaRegen).toBeGreaterThan(0);
expect(core.maxRoomDuration).toBeGreaterThan(0);
expect(core.summonCost.length).toBeGreaterThan(0);
expect(core.primaryManaType).toBeTruthy();
expect(core.tierMultiplier).toBeGreaterThan(0);
expect(core.unlockRequirement).toBeDefined();
expect(core.unlockRequirement.type).toBeTruthy();
}
});
it('all frames have required fields', () => {
for (const frame of ALL_FRAMES) {
expect(frame.id).toBeTruthy();
expect(frame.name).toBeTruthy();
expect(frame.description).toBeTruthy();
expect(frame.baseDamage).toBeGreaterThan(0);
expect(frame.attackSpeed).toBeGreaterThan(0);
expect(frame.armorPierce).toBeGreaterThanOrEqual(0);
expect(frame.magicAffinity).toBeGreaterThanOrEqual(0);
expect(frame.aoeTargets).toBeGreaterThanOrEqual(1);
expect(frame.summonCost.length).toBeGreaterThan(0);
expect(frame.specialEffect).toBeTruthy();
expect(frame.unlockRequirement).toBeDefined();
}
});
it('all mind circuits have required fields', () => {
for (const circuit of ALL_MIND_CIRCUITS) {
expect(circuit.id).toBeTruthy();
expect(circuit.name).toBeTruthy();
expect(circuit.description).toBeTruthy();
expect(circuit.spellSlots).toBeGreaterThanOrEqual(0);
expect(circuit.behavior).toBeTruthy();
expect(circuit.summonCost.length).toBeGreaterThan(0);
expect(circuit.unlockRequirement).toBeDefined();
}
});
it('all enchantments have required fields', () => {
for (const enchant of ALL_GOLEM_ENCHANTMENTS) {
expect(enchant.id).toBeTruthy();
expect(enchant.name).toBeTruthy();
expect(enchant.description).toBeTruthy();
expect(enchant.effect).toBeTruthy();
expect(enchant.capacityCost).toBeGreaterThan(0);
expect(enchant.summonCost.length).toBeGreaterThan(0);
}
});
it('cores span four tiers', () => {
const tiers = new Set(ALL_CORES.map(c => c.tier));
expect(tiers.size).toBe(4);
expect(tiers.has(1)).toBe(true);
expect(tiers.has(2)).toBe(true);
expect(tiers.has(3)).toBe(true);
expect(tiers.has(4)).toBe(true);
});
it('basic core is the only tier 1 core', () => {
const tier1 = ALL_CORES.filter(c => c.tier === 1);
expect(tier1.length).toBe(1);
expect(tier1[0].id).toBe('basic');
});
it('guardian core is the highest tier', () => {
const guardian = CORES.guardian;
expect(guardian).toBeDefined();
expect(guardian.tier).toBe(4);
});
});
@@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest';
import { getGolemSlots, isComponentUnlocked } from '@/lib/game/data/golems/utils';
// ─── Test: getGolemSlots ──────────────────────────────────────────────────────
describe('getGolemSlots', () => {
it('returns 0 for fabricator level < 2', () => {
expect(getGolemSlots(0)).toBe(0);
expect(getGolemSlots(1)).toBe(0);
});
it('scales with fabricator level', () => {
expect(getGolemSlots(2)).toBe(1);
expect(getGolemSlots(3)).toBe(1);
expect(getGolemSlots(4)).toBe(2);
expect(getGolemSlots(5)).toBe(2);
expect(getGolemSlots(6)).toBe(3);
expect(getGolemSlots(7)).toBe(3);
expect(getGolemSlots(8)).toBe(4);
expect(getGolemSlots(9)).toBe(4);
expect(getGolemSlots(10)).toBe(5);
});
it('caps at 5 slots for level 10+', () => {
expect(getGolemSlots(10)).toBe(5);
expect(getGolemSlots(11)).toBe(5);
expect(getGolemSlots(20)).toBe(10);
});
});
// ─── Test: isComponentUnlocked ────────────────────────────────────────────────
describe('isComponentUnlocked', () => {
it('returns true for attunement_level requirement when met', () => {
const req = { type: 'attunement_level' as const, attunement: 'fabricator', level: 2 };
const attunements = { fabricator: { active: true, level: 2 } };
expect(isComponentUnlocked(req, attunements, [], [])).toBe(true);
});
it('returns false for attunement_level when level is too low', () => {
const req = { type: 'attunement_level' as const, attunement: 'fabricator', level: 2 };
const attunements = { fabricator: { active: true, level: 1 } };
expect(isComponentUnlocked(req, attunements, [], [])).toBe(false);
});
it('returns false for attunement_level when attunement is inactive', () => {
const req = { type: 'attunement_level' as const, attunement: 'fabricator', level: 2 };
const attunements = { fabricator: { active: false, level: 5 } };
expect(isComponentUnlocked(req, attunements, [], [])).toBe(false);
});
it('returns true for mana_unlocked requirement when element is unlocked', () => {
const req = { type: 'mana_unlocked' as const, manaType: 'frost' };
expect(isComponentUnlocked(req, {}, ['earth', 'frost'], [])).toBe(true);
});
it('returns false for mana_unlocked when element is not unlocked', () => {
const req = { type: 'mana_unlocked' as const, manaType: 'frost' };
expect(isComponentUnlocked(req, {}, ['earth'], [])).toBe(false);
});
it('returns true for dual_attunement when both requirements met', () => {
const req = {
type: 'dual_attunement' as const,
attunements: ['fabricator', 'enchanter'],
levels: [4, 2],
};
const attunements = {
fabricator: { active: true, level: 4 },
enchanter: { active: true, level: 2 },
};
expect(isComponentUnlocked(req, attunements, [], [])).toBe(true);
});
it('returns false for dual_attunement when one attunement is too low', () => {
const req = {
type: 'dual_attunement' as const,
attunements: ['fabricator', 'enchanter'],
levels: [4, 2],
};
const attunements = {
fabricator: { active: true, level: 4 },
enchanter: { active: true, level: 1 },
};
expect(isComponentUnlocked(req, attunements, [], [])).toBe(false);
});
it('returns true for guardian_pact when attunements and pact are present', () => {
const req = {
type: 'guardian_pact' as const,
attunements: ['invoker', 'fabricator'],
levels: [5, 5],
};
const attunements = {
invoker: { active: true, level: 5 },
fabricator: { active: true, level: 5 },
};
expect(isComponentUnlocked(req, attunements, [], [1])).toBe(true);
});
it('returns false for guardian_pact when no pacts signed', () => {
const req = {
type: 'guardian_pact' as const,
attunements: ['invoker', 'fabricator'],
levels: [5, 5],
};
const attunements = {
invoker: { active: true, level: 5 },
fabricator: { active: true, level: 5 },
};
expect(isComponentUnlocked(req, attunements, [], [])).toBe(false);
});
it('returns false for unknown requirement type', () => {
const req = { type: 'unknown_type' as any };
expect(isComponentUnlocked(req, {}, [], [])).toBe(false);
});
});
@@ -0,0 +1,73 @@
import type {
CoreDefinition,
FrameDefinition,
MindCircuitDefinition,
GolemEnchantmentDefinition,
GolemDesign,
} from '@/lib/game/data/golems/types';
import type { GolemManaCost } from './types';
import { ELEMENTS } from '@/lib/game/constants/elements';
export function formatManaCost(cost: GolemManaCost): string {
if (cost.type === 'raw') return `${cost.amount} raw`;
const elem = cost.element ? ELEMENTS[cost.element] : null;
return `${cost.amount} ${elem?.sym ?? ''} ${cost.element ?? ''}`.trim();
}
export function formatRequirement(req: CoreDefinition['unlockRequirement']): string {
switch (req.type) {
case 'attunement_level':
return `${req.attunement} level ${req.level}`;
case 'mana_unlocked': {
const elem = req.manaType ? ELEMENTS[req.manaType] : null;
return `Unlock ${elem?.sym ?? ''} ${req.manaType ?? ''}`.trim();
}
case 'dual_attunement':
return `${req.attunements?.join(' + ')} level ${req.levels?.join('/')}`;
case 'guardian_pact':
return `${req.attunements?.join(' + ')} level ${req.levels?.join('/')} + Guardian Pact`;
default:
return 'Unknown';
}
}
export function serializeDesign(
name: string,
core: CoreDefinition,
frame: FrameDefinition,
circuit: MindCircuitDefinition,
enchantments: GolemEnchantmentDefinition[],
selectedManaTypes: string[],
selectedSpells: string[],
) {
return {
id: `design_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
name,
coreId: core.id,
frameId: frame.id,
mindCircuitId: circuit.id,
enchantmentIds: enchantments.map(e => e.id),
selectedManaTypes,
selectedSpells,
};
}
export function buildGolemDesign(
core: CoreDefinition,
frame: FrameDefinition,
circuit: MindCircuitDefinition,
enchantments: GolemEnchantmentDefinition[],
selectedManaTypes: string[],
selectedSpells: string[],
): GolemDesign {
return {
id: `design_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
name: `${core.name.split(' ')[0]} ${frame.name.split(' ')[0]}`,
core,
frame,
mindCircuit: circuit,
enchantments,
selectedManaTypes,
selectedSpells,
};
}
@@ -0,0 +1,35 @@
// ─── Shared Golemancy UI Types ──────────────────────────────────────────────
import type {
CoreDefinition,
FrameDefinition,
MindCircuitDefinition,
GolemEnchantmentDefinition,
GolemDesign,
ComputedGolemStats,
GolemManaCost,
} from '@/lib/game/data/golems/types';
import type {
SerializedGolemDesign,
GolemLoadoutEntry,
RuntimeActiveGolem,
} from '@/lib/game/types/game';
export type {
CoreDefinition,
FrameDefinition,
MindCircuitDefinition,
GolemEnchantmentDefinition,
GolemDesign,
ComputedGolemStats,
GolemManaCost,
SerializedGolemDesign,
GolemLoadoutEntry,
RuntimeActiveGolem,
};
export type BuilderSection = 'builder' | 'loadout' | 'active';
export interface AttunementLookup {
[id: string]: { active: boolean; level: number };
}
@@ -36,7 +36,7 @@ function resetStores() {
roomResetState: {}, roomResetState: {},
clearedRooms: {}, clearedRooms: {},
isDescentComplete: false, isDescentComplete: false,
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 }, golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
equipmentSpellStates: [], equipmentSpellStates: [],
comboHitCount: 0, comboHitCount: 0,
floorHitCount: 0, floorHitCount: 0,
@@ -52,7 +52,7 @@ export function resetAllStores() {
roomResetState: {}, roomResetState: {},
clearedRooms: {}, clearedRooms: {},
isDescentComplete: false, isDescentComplete: false,
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 }, golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
equipmentSpellStates: [], equipmentSpellStates: [],
comboHitCount: 0, comboHitCount: 0,
floorHitCount: 0, floorHitCount: 0,
@@ -39,7 +39,7 @@ function resetStores() {
roomResetState: {}, roomResetState: {},
clearedRooms: {}, clearedRooms: {},
isDescentComplete: false, isDescentComplete: false,
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 }, golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
equipmentSpellStates: [], equipmentSpellStates: [],
comboHitCount: 0, comboHitCount: 0,
floorHitCount: 0, floorHitCount: 0,
@@ -38,7 +38,7 @@ function resetStores() {
roomResetState: {}, roomResetState: {},
clearedRooms: {}, clearedRooms: {},
isDescentComplete: false, isDescentComplete: false,
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 }, golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
equipmentSpellStates: [], equipmentSpellStates: [],
comboHitCount: 0, comboHitCount: 0,
floorHitCount: 0, floorHitCount: 0,
@@ -18,7 +18,7 @@ function resetCombatStore() {
clearedFloors: {}, clearedFloors: {},
climbDirection: null, climbDirection: null,
isDescending: false, isDescending: false,
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 }, golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
equipmentSpellStates: [], equipmentSpellStates: [],
comboHitCount: 0, comboHitCount: 0,
floorHitCount: 0, floorHitCount: 0,
+1 -1
View File
@@ -29,7 +29,7 @@ function resetCombatStore() {
clearedFloors: {}, clearedFloors: {},
climbDirection: null, climbDirection: null,
isDescending: false, isDescending: false,
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 }, golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
equipmentSpellStates: [], equipmentSpellStates: [],
comboHitCount: 0, comboHitCount: 0,
floorHitCount: 0, floorHitCount: 0,
@@ -46,7 +46,7 @@ function resetAllStores() {
clearedFloors: {}, clearedFloors: {},
climbDirection: null, climbDirection: null,
isDescending: false, isDescending: false,
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 }, golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
equipmentSpellStates: [], equipmentSpellStates: [],
comboHitCount: 0, comboHitCount: 0,
floorHitCount: 0, floorHitCount: 0,
+1 -1
View File
@@ -22,7 +22,7 @@ export const fabricatorDisciplines: DisciplineDefinition[] = [
type: 'once', type: 'once',
threshold: 200, threshold: 200,
value: 0, value: 0,
description: 'Unlock golem summoning', description: 'Unlock golem design ability',
}, },
{ {
id: 'golem-2', id: 'golem-2',
+100
View File
@@ -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<string, CoreDefinition> = {
[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];
+155
View File
@@ -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<string, FrameDefinition> = {
[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,
];
@@ -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<string, GolemEnchantmentDefinition> = {
[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,
];
+7 -13
View File
@@ -1,14 +1,8 @@
// ─── Golem Definitions Data ───────────────────────── // ─── Golem Definitions Data ──────────────────────────────────────────────
// Combined golem definitions from all golem modules. // Combined component registries for the component-based golem system.
// Extracted to a standalone module to avoid circular dependencies // Extracted to a standalone module to avoid circular dependencies.
// between index.ts and utils.ts.
import { BASE_GOLEMS } from './base-golems'; export { CORES, ALL_CORES } from './cores';
import { ELEMENTAL_GOLEMS } from './elemental-golems'; export { FRAMES, ALL_FRAMES } from './frames';
import { HYBRID_GOLEMS } from './hybrid-golems'; export { MIND_CIRCUITS, ALL_MIND_CIRCUITS } from './mindCircuits';
export { GOLEM_ENCHANTMENTS, ALL_GOLEM_ENCHANTMENTS } from './golemEnchantments';
export const GOLEMS_DEF = {
...BASE_GOLEMS,
...ELEMENTAL_GOLEMS,
...HYBRID_GOLEMS,
};
+27 -19
View File
@@ -1,23 +1,31 @@
// ─── Golem Definitions Index ─────────────────────── // ─── Golem Definitions Index ─────────────────────────────────────────────
// Re-exports from all golem modules // Barrel exports for the component-based golem system.
// Re-export types // 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 { elemCost, rawCost } from './types';
export { GOLEMS_DEF } from './golems-data';
// Re-export utility functions // Re-export component registries
export { export { CORES, ALL_CORES } from './cores';
getGolemSlots, export { FRAMES, ALL_FRAMES } from './frames';
isGolemUnlocked, export { MIND_CIRCUITS, ALL_MIND_CIRCUITS } from './mindCircuits';
getUnlockedGolems, export { GOLEM_ENCHANTMENTS, ALL_GOLEM_ENCHANTMENTS } from './golemEnchantments';
getGolemDamage,
getGolemAttackSpeed, // Legacy re-exports (deprecated, kept for migration)
getGolemFloorDuration, export type { GolemDef } from './types';
getGolemMaintenanceMultiplier,
canAffordGolemSummon,
deductGolemSummonCost,
canAffordGolemMaintenance,
deductGolemMaintenance,
} from './utils';
+77
View File
@@ -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<string, MindCircuitDefinition> = {
[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,
];
+149 -14
View File
@@ -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'; import type { SpellCost } from '../../types';
// Golem mana cost helper // ─── Mana Cost Helpers ───────────────────────────────────────────────────
export function elemCost(element: string, amount: number): SpellCost { export function elemCost(element: string, amount: number): SpellCost {
return { type: 'element', element, amount }; return { type: 'element', element, amount };
} }
@@ -17,19 +20,151 @@ export interface GolemManaCost {
amount: number; 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; // 01 fraction of enemy armor bypassed
magicAffinity: number; // 0.01.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 { export interface GolemDef {
id: string; id: string;
name: string; name: string;
description: string; description: string;
baseManaType: string; // The primary mana type this golem uses baseManaType: string;
summonCost: GolemManaCost[]; // Cost to summon (can be multiple types) summonCost: GolemManaCost[];
maintenanceCost: GolemManaCost[]; // Cost per hour to maintain maintenanceCost: GolemManaCost[];
damage: number; // Base damage per attack damage: number;
attackSpeed: number; // Attacks per hour attackSpeed: number;
hp: number; // Golem HP (for display, they don't take damage) hp: number;
armorPierce: number; // Armor piercing (0-1) armorPierce: number;
isAoe: boolean; // Whether golem attacks are AOE isAoe: boolean;
aoeTargets: number; // Number of targets for AOE aoeTargets: number;
unlockCondition: { unlockCondition: {
type: 'attunement_level' | 'mana_unlocked' | 'dual_attunement'; type: 'attunement_level' | 'mana_unlocked' | 'dual_attunement';
attunement?: string; attunement?: string;
@@ -38,7 +173,7 @@ export interface GolemDef {
attunements?: string[]; attunements?: string[];
levels?: number[]; levels?: number[];
}; };
tier: number; // Power tier (1-4) tier: number;
maxRoomDuration: number; // Rooms before golem disappears (spec §9.6) maxRoomDuration: number;
specialAbilities?: { name: string; description: string }[]; // Special abilities specialAbilities?: { name: string; description: string }[];
} }
+189 -174
View File
@@ -1,204 +1,219 @@
// ─── Golem Helper Functions ───────────────────────── // ─── Golem Helper Functions ──────────────────────────────────────────────
// Component-based construction system utilities.
import type { GolemDef, GolemManaCost } from './types'; import type {
import { GOLEMS_DEF } from './golems-data'; 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 // ─── Golem Slots ──────────────────────────────────────────────────────────
// Level 2 = 1, Level 4 = 2, Level 6 = 3, Level 8 = 4, Level 10 = 5
/**
* 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 { export function getGolemSlots(fabricatorLevel: number): number {
if (fabricatorLevel < 2) return 0; if (fabricatorLevel < 2) return 0;
return Math.floor(fabricatorLevel / 2); return Math.floor(fabricatorLevel / 2);
} }
// Check if a golem is unlocked based on player state // ─── Unlock Checks ────────────────────────────────────────────────────────
export function isGolemUnlocked(
golemId: string, /**
* Check if a component is unlocked based on player state.
*/
export function isComponentUnlocked(
requirement: GolemUnlockRequirement,
attunements: Record<string, { active: boolean; level: number }>, attunements: Record<string, { active: boolean; level: number }>,
unlockedElements: string[] unlockedElements: string[],
signedGuardianPacts: number[],
): boolean { ): boolean {
const golem = GOLEMS_DEF[golemId]; switch (requirement.type) {
if (!golem) return false; case 'attunement_level': {
const attState = attunements[requirement.attunement || ''];
const condition = golem.unlockCondition; return !!attState?.active && (attState.level || 1) >= (requirement.level || 1);
}
switch (condition.type) {
case 'attunement_level':
const attState = attunements[condition.attunement || ''];
return attState?.active && (attState.level || 1) >= (condition.level || 1);
case 'mana_unlocked': case 'mana_unlocked':
return unlockedElements.includes(condition.manaType || ''); return unlockedElements.includes(requirement.manaType || '');
case 'dual_attunement': {
case 'dual_attunement': if (!requirement.attunements || !requirement.levels) return false;
if (!condition.attunements || !condition.levels) return false; return requirement.attunements.every((attId, idx) => {
return condition.attunements.every((attId, idx) => {
const att = attunements[attId]; 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: default:
return false; return false;
} }
} }
// Get all unlocked golems for a player // ─── Computed Stats ───────────────────────────────────────────────────────
export function getUnlockedGolems(
attunements: Record<string, { active: boolean; level: number }>, /**
unlockedElements: string[] * Compute all derived stats for a golem design from its components.
): GolemDef[] { */
return Object.values(GOLEMS_DEF).filter(golem => export function computeGolemStats(design: GolemDesign): ComputedGolemStats {
isGolemUnlocked(golem.id, attunements, unlockedElements) const core = design.core;
) as GolemDef[]; 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<string, { current: number; max: number; unlocked: boolean }>,
): { 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<string, number>): number {
return 3; // Default room duration for legacy calls
}
/**
* @deprecated Use computeGolemStats instead
*/
export function getGolemDamage( export function getGolemDamage(
golemId: string, golemId: string,
skills: Record<string, number> _skills: Record<string, number>,
): number { ): number {
const golem = GOLEMS_DEF[golemId]; // Legacy lookup — returns 0 for component-based golems
if (!golem) return 0; return 0;
let damage = golem.damage;
// Golem Mastery skill bonus
const masteryBonus = 1 + (skills.golemMastery || 0) * 0.1;
damage *= masteryBonus;
return damage;
} }
// Calculate golem attack speed with skill bonuses /**
* @deprecated Use computeGolemStats instead
*/
export function getGolemAttackSpeed( export function getGolemAttackSpeed(
golemId: string, golemId: string,
skills: Record<string, number> _skills: Record<string, number>,
): number { ): number {
const golem = GOLEMS_DEF[golemId]; return 0;
if (!golem) return 0;
let speed = golem.attackSpeed;
// Golem Efficiency skill bonus
const efficiencyBonus = 1 + (skills.golemEfficiency || 0) * 0.05;
speed *= efficiencyBonus;
return speed;
} }
// Get floors golems can last (base 1, +1 per Golem Longevity skill level) /**
export function getGolemFloorDuration(skills: Record<string, number>): number { * @deprecated Component-based system doesn't use skill-based maintenance multiplier
return 1 + (skills.golemLongevity || 0); */
} export function getGolemMaintenanceMultiplier(_skills: Record<string, number>): number {
return 1;
// Get maintenance cost multiplier (Golem Siphon reduces by 10% per level)
export function getGolemMaintenanceMultiplier(skills: Record<string, number>): 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<string, { current: number; max: number; unlocked: boolean }>
): 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<string, { current: number; max: number; unlocked: boolean }>
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
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<string, { current: number; max: number; unlocked: boolean }>,
skills: Record<string, number>
): 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<string, { current: number; max: number; unlocked: boolean }>,
skills: Record<string, number>
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
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 };
} }
+17 -12
View File
@@ -7,10 +7,14 @@ import { getGuardianForFloor } from '../data/guardian-encounters';
import type { CombatStore, CombatState } from './combat-state.types'; import type { CombatStore, CombatState } from './combat-state.types';
import type { SpellState, EnemyState, EquipmentInstance, FloorState } from '../types'; import type { SpellState, EnemyState, EquipmentInstance, FloorState } from '../types';
import { applyOnHitEffect, processDoTPhase } from './dot-runtime'; 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 { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, calcMeleeDamage, canAffordSpellCost, deductSpellCost } from '../utils';
import { computeDisciplineEffects } from '../effects/discipline-effects'; 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'; import { applyDamageToRoom } from './combat-damage';
// ─── Result Type ─────────────────────────────────────────────────────────────── // ─── Result Type ───────────────────────────────────────────────────────────────
@@ -22,7 +26,7 @@ function makeDefaultCombatTickResult(
rawMana: number, rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>, elements: Record<string, { current: number; max: number; unlocked: boolean }>,
state: CombatState, state: CombatState,
activeGolems: ActiveGolem[], activeGolems: RuntimeActiveGolem[],
): CombatTickResult { ): CombatTickResult {
return { return {
rawMana, rawMana,
@@ -52,7 +56,7 @@ export interface CombatTickResult {
maxFloorReached: number; maxFloorReached: number;
castProgress: number; castProgress: number;
equipmentSpellStates: CombatState['equipmentSpellStates']; equipmentSpellStates: CombatState['equipmentSpellStates'];
activeGolems: ActiveGolem[]; activeGolems: RuntimeActiveGolem[];
meleeSwordProgress: Record<string, number>; meleeSwordProgress: Record<string, number>;
currentRoom: FloorState; currentRoom: FloorState;
} }
@@ -73,7 +77,7 @@ export function processCombatTick(
modifiedDamage?: number; modifiedDamage?: number;
}, },
signedPacts: number[], signedPacts: number[],
golemancyState: { activeGolems: ActiveGolem[] }, golemancyState: { activeGolems: RuntimeActiveGolem[] },
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean }, golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
applyEnemyDefenses: ( applyEnemyDefenses: (
dmg: number, dmg: number,
@@ -94,9 +98,11 @@ export function processCombatTick(
} }
try { try {
// ─── Golem maintenance (spec §9.5) ────────────────────────────────────── // ─── Golem maintenance (spec §13) ──────────────────────────────────────
const golemDesigns = state.golemancy.golemDesigns || {};
const maintenanceResult = processGolemMaintenance( const maintenanceResult = processGolemMaintenance(
golemancyState.activeGolems, golemancyState.activeGolems,
golemDesigns,
rawMana, rawMana,
elements, elements,
); );
@@ -105,6 +111,9 @@ export function processCombatTick(
elements = maintenanceResult.elements; elements = maintenanceResult.elements;
logMessages.push(...maintenanceResult.logMessages); logMessages.push(...maintenanceResult.logMessages);
// ─── Golem mana regen (spec §12) ───────────────────────────────────────
activeGolems = processGolemManaRegen(activeGolems, golemDesigns);
// Write maintained golems back immediately so tick state stays consistent // Write maintained golems back immediately so tick state stays consistent
set({ golemancy: { ...state.golemancy, activeGolems } }); 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) { if (activeGolems.length > 0 && floorHP > 0) {
const golemResult = processGolemAttacks( const golemResult = processGolemAttacks(
activeGolems, activeGolems,
rawMana, golemDesigns,
elements,
floorHP,
floorMaxHP,
currentFloor,
onDamageDealt, onDamageDealt,
golemApplyDamageToRoom, golemApplyDamageToRoom,
); );
@@ -244,7 +244,7 @@ export function createEnterSpireMode(get: GetFn, set: SetFn) {
roomResetState: {}, roomResetState: {},
descentPeak: null, descentPeak: null,
isDescentComplete: false, isDescentComplete: false,
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 }, golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
}); });
get().addActivityLog('floor_transition', get().addActivityLog('floor_transition',
+6 -3
View File
@@ -1,7 +1,7 @@
// ─── Combat State Types ──────────────────────────────────────────────────────── // ─── Combat State Types ────────────────────────────────────────────────────────
// Shared types for combat store and combat actions to avoid circular dependency // 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 */ /** Signature for the advanceRoomOrFloor callback to break circular dependency */
export type AdvanceRoomFn = (get: () => CombatStore, set: (s: Partial<CombatState>) => void) => void; export type AdvanceRoomFn = (get: () => CombatStore, set: (s: Partial<CombatState>) => void) => void;
@@ -130,6 +130,9 @@ export interface CombatActions {
// Golemancy // Golemancy
toggleGolem: (golemId: string) => void; toggleGolem: (golemId: string) => void;
setEnabledGolems: (golemIds: string[]) => void; setEnabledGolems: (golemIds: string[]) => void;
addGolemDesign: (design: SerializedGolemDesign) => void;
removeGolemDesign: (designId: string) => void;
toggleGolemLoadoutEntry: (designId: string) => void;
// Spells // Spells
learnSpell: (spellId: string) => void; learnSpell: (spellId: string) => void;
@@ -155,7 +158,7 @@ export interface CombatActions {
onFloorCleared: (floor: number, wasGuardian: boolean) => void, onFloorCleared: (floor: number, wasGuardian: boolean) => void,
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> }, onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
signedPacts: number[], signedPacts: number[],
golemancyState: { activeGolems: ActiveGolem[] }, golemancyState: { activeGolems: RuntimeActiveGolem[] },
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean }, golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
applyEnemyDefenses: ( applyEnemyDefenses: (
dmg: number, dmg: number,
@@ -177,7 +180,7 @@ export interface CombatActions {
maxFloorReached: number; maxFloorReached: number;
castProgress: number; castProgress: number;
equipmentSpellStates: EquipmentSpellState[]; equipmentSpellStates: EquipmentSpellState[];
activeGolems: ActiveGolem[]; activeGolems: RuntimeActiveGolem[];
meleeSwordProgress: Record<string, number>; meleeSwordProgress: Record<string, number>;
currentRoom: FloorState; currentRoom: FloorState;
}; };
+20 -19
View File
@@ -4,7 +4,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { createSafeStorage } from '../utils/safe-persist'; 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 { getFloorMaxHP } from '../utils';
import { generateSpireFloorState, getRoomsForFloor } from '../utils/spire-utils'; import { generateSpireFloorState, getRoomsForFloor } from '../utils/spire-utils';
import { addActivityLogEntry } from '../utils/activity-log'; import { addActivityLogEntry } from '../utils/activity-log';
@@ -17,6 +17,9 @@ import {
import { import {
onEnterLibraryRoom, tickNonCombatRoom, skipNonCombatRoom, stayLongerInRoom, onEnterLibraryRoom, tickNonCombatRoom, skipNonCombatRoom, stayLongerInRoom,
} from './non-combat-room-actions'; } from './non-combat-room-actions';
import {
addGolemDesign, removeGolemDesign, toggleGolemLoadoutEntry,
} from './golemancy-actions';
export const useCombatStore = create<CombatStore>()( export const useCombatStore = create<CombatStore>()(
persist( persist(
@@ -50,12 +53,17 @@ export const useCombatStore = create<CombatStore>()(
clearedRooms: {}, clearedRooms: {},
isDescentComplete: false, isDescentComplete: false,
// Golemancy // Golemancy (component-based)
golemancy: { golemancy: {
// New component-based fields
golemDesigns: {},
golemLoadout: [],
activeGolems: [] as RuntimeActiveGolem[],
lastSummonFloor: 0,
// Legacy fields (deprecated)
enabledGolems: [], enabledGolems: [],
summonedGolems: [], summonedGolems: [],
activeGolems: [], legacyActiveGolems: [],
lastSummonFloor: 0,
}, },
// Equipment spell states // Equipment spell states
@@ -196,24 +204,15 @@ export const useCombatStore = create<CombatStore>()(
currentRoomIndex: 0, currentRoomIndex: 0,
roomsPerFloor: 1, roomsPerFloor: 1,
maxFloorReached: Math.max(s.maxFloorReached, 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' }), startClimbUp: () => set({ climbDirection: 'up', currentAction: 'climb' }),
startClimbDown: () => set({ climbDirection: 'down', currentAction: 'climb' }), startClimbDown: () => set({ climbDirection: 'down', currentAction: 'climb' }),
startPracticing: () => set((s) => s.currentAction !== 'meditate' ? s : { currentAction: 'practicing' }),
startPracticing: () => set((s) => { stopPracticing: () => set((s) => s.currentAction !== 'practicing' ? s : { currentAction: 'meditate' }),
if (s.currentAction !== 'meditate') return s;
return { currentAction: 'practicing' };
}),
stopPracticing: () => set((s) => {
if (s.currentAction !== 'practicing') return s;
return { currentAction: 'meditate' };
}),
// ─── Spec: Descent actions (delegated to combat-descent-actions.ts) ──── // ─── Spec: Descent actions (delegated to combat-descent-actions.ts) ────
enterDescentMode: () => enterDescentMode(get, set), enterDescentMode: () => enterDescentMode(get, set),
@@ -246,6 +245,10 @@ export const useCombatStore = create<CombatStore>()(
})); }));
}, },
addGolemDesign: (d) => addGolemDesign(set, d),
removeGolemDesign: (id) => removeGolemDesign(set, id),
toggleGolemLoadoutEntry: (id) => toggleGolemLoadoutEntry(set, id),
enterSpireMode: createEnterSpireMode(get, set), enterSpireMode: createEnterSpireMode(get, set),
learnSpell: (spellId: string) => { learnSpell: (spellId: string) => {
@@ -310,7 +313,7 @@ export const useCombatStore = create<CombatStore>()(
onFloorCleared: (floor: number, wasGuardian: boolean) => void, onFloorCleared: (floor: number, wasGuardian: boolean) => void,
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> }, onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
signedPacts: number[], signedPacts: number[],
golemancyState: { activeGolems: ActiveGolem[] }, golemancyState: { activeGolems: RuntimeActiveGolem[] },
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean }, golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
applyEnemyDefenses: ( applyEnemyDefenses: (
dmg: number, dmg: number,
@@ -390,6 +393,4 @@ export const useCombatStore = create<CombatStore>()(
) )
); );
// makeInitialSpells is now in combat-actions.ts
// Re-export for backward compatibility
export { makeInitialSpells } from './combat-actions'; export { makeInitialSpells } from './combat-actions';
+207 -154
View File
@@ -1,80 +1,120 @@
// ─── Golem Combat Actions ────────────────────────────────────────────────────── // ─── Golem Combat Actions (Component-Based) ──────────────────────────────────
// Pure golem combat logic — no cross-store getState() calls. // Runtime golem combat logic for the component-based construction system.
// All external data is passed in as parameters. // All external data is passed in as parameters (no cross-store getState() calls).
// Implements spec §9: summoning, maintenance, attack, room-duration. // Implements spec §§10-14: summoning, maintenance, combat, mana, duration.
import { GOLEMS_DEF } from '../data/golems';
import { HOURS_PER_TICK } from '../constants'; import { HOURS_PER_TICK } from '../constants';
import type { ActiveGolem, GolemancyState } from '../types'; import { CORES, FRAMES, MIND_CIRCUITS } from '../data/golems';
import { getElementalBonus, getFloorElement } from '../utils'; import { computeGolemStats, getGolemSlots } from '../data/golems/utils';
import type {
RuntimeActiveGolem,
GolemLoadoutEntry,
EnemyState,
ActiveEffect,
} from '../types';
// ─── Types ───────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
export interface GolemCombatResult { export interface GolemCombatResult {
rawMana: number; rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>; elements: Record<string, { current: number; max: number; unlocked: boolean }>;
activeGolems: ActiveGolem[]; activeGolems: RuntimeActiveGolem[];
logMessages: string[]; logMessages: string[];
totalDamageDealt: number; 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. * Attempt to summon golems from the loadout on room entry.
* For each enabled golem: if the player has enough mana, deduct cost and activate. * For each enabled design: if player has enough mana, deduct cost and activate.
* Golems that can't be skipped are NOT re-attempted mid-room. * Designs that can't be afforded are NOT re-attempted mid-room.
*/ */
export function summonGolemsOnRoomEntry( export function summonGolemsOnRoomEntry(
enabledGolems: string[], loadout: GolemLoadoutEntry[],
rawMana: number, rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>, elements: Record<string, { current: number; max: number; unlocked: boolean }>,
currentFloor: number, currentFloor: number,
existingActiveGolems: ActiveGolem[], existingActiveGolems: RuntimeActiveGolem[],
disciplineSlotsBonus: number,
): { ): {
rawMana: number; rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>; elements: Record<string, { current: number; max: number; unlocked: boolean }>;
activeGolems: ActiveGolem[]; activeGolems: RuntimeActiveGolem[];
logMessages: string[]; logMessages: string[];
} { } {
let newRawMana = rawMana; let newRawMana = rawMana;
let newElements = { ...elements }; const newElements = { ...elements };
const newActiveGolems = [...existingActiveGolems]; const newActiveGolems = [...existingActiveGolems];
const logMessages: string[] = []; const logMessages: string[] = [];
for (const golemId of enabledGolems) { const activeCount = newActiveGolems.length;
const def = GOLEMS_DEF[golemId];
if (!def) continue;
// Skip if this golem is already active (e.g. summoned on a previous floor for (const entry of loadout) {
// and still within its room-duration) if (!entry.enabled) continue;
const alreadyActive = newActiveGolems.some((ag) => ag.golemId === golemId);
// 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; 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; let canAfford = true;
for (const cost of def.summonCost) { for (const cost of stats.totalSummonCost) {
if (cost.type === 'raw') { if (cost.type === 'raw') {
if (newRawMana < cost.amount) { if (newRawMana < cost.amount) { canAfford = false; break; }
canAfford = false;
break;
}
} else if (cost.element) { } else if (cost.element) {
const elem = newElements[cost.element]; const elem = newElements[cost.element];
if (!elem || !elem.unlocked || elem.current < cost.amount) { if (!elem?.unlocked || elem.current < cost.amount) { canAfford = false; break; }
canAfford = false;
break;
}
} }
} }
if (!canAfford) { if (!canAfford) {
logMessages.push(`Not enough mana to summon ${def.name} — skipped`); logMessages.push(`Not enough mana to summon ${entry.design.name} — skipped`);
continue; continue;
} }
// Deduct summon cost // Deduct summon cost
for (const cost of def.summonCost) { for (const cost of stats.totalSummonCost) {
if (cost.type === 'raw') { if (cost.type === 'raw') {
newRawMana -= cost.amount; newRawMana -= cost.amount;
} else if (cost.element && newElements[cost.element]) { } 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({ newActiveGolems.push({
golemId: def.id, designId: entry.designId,
summonedFloor: currentFloor, summonedFloor: currentFloor,
attackProgress: 0, 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 { 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. * Golems that can't be maintained are dismissed immediately.
*/ */
export function processGolemMaintenance( export function processGolemMaintenance(
activeGolems: ActiveGolem[], activeGolems: RuntimeActiveGolem[],
golemDesigns: Record<string, SerializedDesign>,
rawMana: number, rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>, elements: Record<string, { current: number; max: number; unlocked: boolean }>,
): { ): {
rawMana: number; rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>; elements: Record<string, { current: number; max: number; unlocked: boolean }>;
maintainedGolems: ActiveGolem[]; maintainedGolems: RuntimeActiveGolem[];
logMessages: string[]; logMessages: string[];
} { } {
let newRawMana = rawMana; let newRawMana = rawMana;
let newElements = { ...elements }; const newElements = { ...elements };
const maintainedGolems: ActiveGolem[] = []; const maintainedGolems: RuntimeActiveGolem[] = [];
const logMessages: string[] = []; const logMessages: string[] = [];
for (const golem of activeGolems) { for (const golem of activeGolems) {
const def = GOLEMS_DEF[golem.golemId]; const design = golemDesigns[golem.designId];
if (!def) continue; if (!design) continue;
// Calculate maintenance cost for this tick const core = CORES[design.coreId];
let canMaintain = true; if (!core) continue;
for (const cost of def.maintenanceCost) {
const tickCost = cost.amount * HOURS_PER_TICK; // Upkeep per tick = (manaRegen × 2) × HOURS_PER_TICK
if (cost.type === 'raw') { const upkeepPerTick = core.manaRegen * 2 * HOURS_PER_TICK;
if (newRawMana < tickCost) { const upkeepElement = core.primaryManaType;
canMaintain = false;
break; const elem = upkeepElement ? newElements[upkeepElement] : null;
}
} else if (cost.element) { if (upkeepElement && elem && elem.unlocked && elem.current >= upkeepPerTick) {
const elem = newElements[cost.element]; // Deduct from element mana
if (!elem || !elem.unlocked || elem.current < tickCost) { newElements[upkeepElement] = {
canMaintain = false; ...elem,
break; 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 { 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<string, SerializedDesign>,
): 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. * Process golem attacks for one combat tick.
* Each golem accumulates attackProgress and fires when >= 1. * Each golem accumulates attackProgress and fires when >= 1.
* Golems apply elemental bonus based on their baseManaType. * Supports spell casting via Mind Circuit behavior.
* Golems ignore Executioner and Berserker discipline specials.
*/ */
export function processGolemAttacks( export function processGolemAttacks(
activeGolems: ActiveGolem[], activeGolems: RuntimeActiveGolem[],
rawMana: number, golemDesigns: Record<string, SerializedDesign>,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
floorHP: number,
floorMaxHP: number,
currentFloor: number,
onDamageDealt: (damage: number) => { onDamageDealt: (damage: number) => {
rawMana: number; rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>; elements: Record<string, { current: number; max: number; unlocked: boolean }>;
@@ -201,114 +248,122 @@ export function processGolemAttacks(
}, },
applyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean }, applyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
): GolemCombatResult { ): GolemCombatResult {
let newRawMana = rawMana; let rawMana = 0;
let newElements = elements; let elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
let currentFloorHP = floorHP; let floorHP = 0;
let currentFloorMaxHP = floorMaxHP; let floorMaxHP = 0;
const logMessages: string[] = []; const logMessages: string[] = [];
let totalDamageDealt = 0; let totalDamageDealt = 0;
const updatedGolems: ActiveGolem[] = []; const updatedGolems: RuntimeActiveGolem[] = [];
for (const golem of activeGolems) { for (const golem of activeGolems) {
const def = GOLEMS_DEF[golem.golemId]; const design = golemDesigns[golem.designId];
if (!def) continue; if (!design) continue;
// Accumulate attack progress const core = CORES[design.coreId];
let attackProgress = golem.attackProgress + HOURS_PER_TICK * def.attackSpeed; 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; let safetyCounter = 0;
const MAX_GOLEM_ATTACKS_PER_TICK = 100; const MAX_GOLEM_ATTACKS_PER_TICK = 100;
while (attackProgress >= 1 && safetyCounter < MAX_GOLEM_ATTACKS_PER_TICK) { while (attackProgress >= 1 && safetyCounter < MAX_GOLEM_ATTACKS_PER_TICK) {
// Calculate base damage // Try spell cast first if circuit supports it
let dmg = def.damage; 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 // Spell casting simplified — full implementation needs spell cost/effect lookup
if (def.baseManaType && def.baseManaType !== 'raw') { if (spellId && updatedGolem.currentMana >= 10) {
const floorElement = getFloorElement(currentFloor); // Cast spell: damage scaled by magic affinity
dmg *= getElementalBonus(def.baseManaType, floorElement); 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 // Basic attack
// (armor pierce is implemented as a flat damage multiplier for simplicity, let dmg = frame.baseDamage * (1 + frame.armorPierce);
// 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;
}
// 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); const dmgResult = onDamageDealt(dmg);
newRawMana = dmgResult.rawMana;
newElements = dmgResult.elements;
const finalDamage = dmgResult.modifiedDamage || dmg; const finalDamage = dmgResult.modifiedDamage || dmg;
if (!Number.isFinite(finalDamage)) { if (Number.isFinite(finalDamage)) {
break; 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; attackProgress -= 1;
safetyCounter++; 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 { return {
rawMana: newRawMana, rawMana,
elements: newElements, elements,
activeGolems: updatedGolems, activeGolems: updatedGolems,
logMessages, logMessages,
totalDamageDealt, totalDamageDealt,
}; };
} }
// ─── Room Duration Countdown (spec §9.6) ───────────────────────────────────── // ─── Room Duration Countdown (spec §14) ─────────────────────────────────────
/** /**
* Decrement roomsRemaining for each active golem on room clear. * Decrement roomsRemaining for each active golem on room clear.
* Golems at 0 remaining are dismissed. * Golems at 0 remaining are dismissed.
*/ */
export function countdownGolemRoomDuration( export function countdownGolemRoomDuration(
activeGolems: ActiveGolem[], activeGolems: RuntimeActiveGolem[],
golemDesigns: Record<string, SerializedDesign>,
): { ): {
remainingGolems: ActiveGolem[]; remainingGolems: RuntimeActiveGolem[];
dismissedNames: string[]; dismissedNames: string[];
logMessages: string[]; logMessages: string[];
} { } {
const remainingGolems: ActiveGolem[] = []; const remainingGolems: RuntimeActiveGolem[] = [];
const dismissedNames: string[] = []; const dismissedNames: string[] = [];
const logMessages: string[] = []; const logMessages: string[] = [];
for (const golem of activeGolems) { for (const golem of activeGolems) {
const def = GOLEMS_DEF[golem.golemId]; const design = golemDesigns[golem.designId];
if (!def) continue; if (!design) continue;
const core = CORES[design.coreId];
if (!core) continue;
const newRoomsRemaining = golem.roomsRemaining - 1; const newRoomsRemaining = golem.roomsRemaining - 1;
if (newRoomsRemaining <= 0) { if (newRoomsRemaining <= 0) {
dismissedNames.push(def.name); dismissedNames.push(design.name);
logMessages.push(`${def.name} has faded after ${def.maxRoomDuration} rooms`); logMessages.push(`${design.name} has faded after ${core.maxRoomDuration} rooms`);
} else { } else {
remainingGolems.push({ ...golem, roomsRemaining: newRoomsRemaining }); remainingGolems.push({ ...golem, roomsRemaining: newRoomsRemaining });
} }
@@ -316,5 +371,3 @@ export function countdownGolemRoomDuration(
return { remainingGolems, dismissedNames, logMessages }; return { remainingGolems, dismissedNames, logMessages };
} }
+28
View File
@@ -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 } };
});
}
+6 -4
View File
@@ -7,6 +7,7 @@ import { getGuardianForFloor } from '../../data/guardian-encounters';
import { hasSpecial, SPECIAL_EFFECTS } from '../../effects/special-effects'; import { hasSpecial, SPECIAL_EFFECTS } from '../../effects/special-effects';
import type { ComputedEffects } from '../../effects/upgrade-effects.types'; import type { ComputedEffects } from '../../effects/upgrade-effects.types';
import type { EnemyState } from '../../types'; import type { EnemyState } from '../../types';
import type { CombatStore } from '../combat-state.types';
import { countdownGolemRoomDuration } from '../golem-combat-actions'; import { countdownGolemRoomDuration } from '../golem-combat-actions';
// ─── Enemy Defense Context ──────────────────────────────────────────────────── // ─── Enemy Defense Context ────────────────────────────────────────────────────
@@ -37,7 +38,7 @@ interface BuildCombatCallbacksParams {
effects: ComputedEffects; effects: ComputedEffects;
maxMana: number; maxMana: number;
addLog: (msg: string) => void; addLog: (msg: string) => void;
useCombatStore: { setState: (s: Record<string, unknown>) => void; getState: () => Record<string, unknown> }; useCombatStore: { setState: (s: Partial<CombatStore>) => void; getState: () => CombatStore };
usePrestigeStore: { getState: () => { addDefeatedGuardian: (floor: number) => void } }; 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 }); 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 cs = useCombatStore.getState();
const activeGolems = cs.golemancy?.activeGolems ?? []; const activeGolems = cs.golemancy.activeGolems;
const golemDesigns = cs.golemancy.golemDesigns;
if (activeGolems.length > 0) { if (activeGolems.length > 0) {
const result = countdownGolemRoomDuration(activeGolems); const result = countdownGolemRoomDuration(activeGolems, golemDesigns);
if (result.logMessages.length > 0) { if (result.logMessages.length > 0) {
result.logMessages.forEach((msg) => params.addLog(msg)); result.logMessages.forEach((msg) => params.addLog(msg));
} }
+64 -16
View File
@@ -1,19 +1,26 @@
// ─── Golem Combat Pipeline ───────────────────────────────────────────────────── // ─── Golem Combat Pipeline (Component-Based) ─────────────────────────────────
// Extracts golem combat setup from gameStore.ts tick() // Pipeline integration for the component-based golem combat system.
// to keep the coordinator under the 400-line file limit. // Extracts golem combat setup from gameStore.ts tick() to keep the coordinator
// under the 400-line file limit.
import { useCombatStore } from '../combatStore'; import { useCombatStore } from '../combatStore';
import { useManaStore } from '../manaStore'; import { useManaStore } from '../manaStore';
import { processGolemRoomDuration } from '../golem-combat-actions'; import {
import { lowestHPEnemy } from '../combat-damage'; summonGolemsOnRoomEntry,
import type { ActiveGolem, EnemyState } from '../../types'; processGolemMaintenance,
processGolemManaRegen,
processGolemAttacks,
countdownGolemRoomDuration,
} from '../golem-combat-actions';
import { useAttunementStore } from '../attunementStore';
import type { RuntimeActiveGolem } from '../../types';
export interface GolemCombatContext { export interface GolemCombatContext {
addLog: (msg: string) => void; addLog: (msg: string) => void;
ctx: { ctx: {
combat: { combat: {
currentFloor: number; 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[] }; prestige: { signedPacts: number[] };
}; };
@@ -22,20 +29,29 @@ export interface GolemCombatContext {
maxMana: number; maxMana: number;
} }
export interface GolemCombatResult { export interface GolemCombatPipelineResult {
rawMana: number; rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>; elements: Record<string, { current: number; max: number; unlocked: boolean }>;
activeGolems: RuntimeActiveGolem[];
logMessages: string[];
} }
/** /**
* Build the golem combat pipeline for the current tick. * Build the golem combat pipeline for the current tick.
* Returns golem state needed by processCombatTick.
*/ */
export function buildGolemCombatPipeline(_addLog: (msg: string) => void): { export function buildGolemCombatPipeline(_addLog: (msg: string) => void): {
activeGolems: ActiveGolem[]; activeGolems: RuntimeActiveGolem[];
golemDesigns: Record<string, { id: string; name: string; coreId: string; frameId: string; mindCircuitId: string; enchantmentIds: string[]; selectedManaTypes: string[]; selectedSpells: string[] }>;
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean }; 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 golemApplyDamageToRoom = (dmg: number) => {
const cs = useCombatStore.getState(); const cs = useCombatStore.getState();
@@ -44,14 +60,19 @@ export function buildGolemCombatPipeline(_addLog: (msg: string) => void): {
return { floorHP: cs.floorHP, floorMaxHP: cs.floorMaxHP, roomCleared: false }; return { floorHP: cs.floorHP, floorMaxHP: cs.floorMaxHP, roomCleared: false };
} }
// Golems use focus-fire targeting (spec §9.4) — target lowest HP enemy // Focus-fire targeting: target lowest HP enemy
const target = lowestHPEnemy(room.enemies); let target = room.enemies[0];
if (!target) { 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 }; return { floorHP: cs.floorHP, floorMaxHP: cs.floorMaxHP, roomCleared: false };
} }
const updatedEnemies = room.enemies.map((enemy) => { 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, hp: Math.max(0, enemy.hp - dmg) };
} }
return enemy; return enemy;
@@ -68,5 +89,32 @@ export function buildGolemCombatPipeline(_addLog: (msg: string) => void): {
return { floorHP: newFloorHP, floorMaxHP: cs.floorMaxHP, roomCleared: allDead }; 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<string, { current: number; max: number; unlocked: boolean }>;
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,
);
} }
+5
View File
@@ -50,10 +50,15 @@ export type {
ScheduleBlock, ScheduleBlock,
StudyTarget, StudyTarget,
SummonedGolem, SummonedGolem,
ActiveGolem,
GolemancyState, GolemancyState,
GolemLoadoutEntry,
RuntimeActiveGolem,
SerializedGolemDesign,
GameActionType, GameActionType,
ActivityEventType, ActivityEventType,
ActivityLogEntry, ActivityLogEntry,
ActiveEffect,
} from './types/game'; } from './types/game';
export type { PrestigeDef } from './types/game'; export type { PrestigeDef } from './types/game';
+58 -10
View File
@@ -144,25 +144,71 @@ export interface StudyTarget {
// ─── Golemancy Types ───────────────────────────────────────────────────────── // ─── Golemancy Types ─────────────────────────────────────────────────────────
/** @deprecated Legacy type for predefined golems. Use GolemDesign instead. */
export interface SummonedGolem { export interface SummonedGolem {
golemId: string; // Reference to GOLEMS_DEF golemId: string;
summonedFloor: number; // Floor when golem was summoned summonedFloor: number;
attackProgress: number; // Progress toward next attack (0-1) attackProgress: number;
roomsRemaining: number; // Rooms before golem disappears (spec §9.6) roomsRemaining: number;
} }
/** Runtime state for an active golem in combat (spec §9.7) */ /** @deprecated Legacy type. Use ActiveGolemV2 instead. */
export interface ActiveGolem extends SummonedGolem { export interface ActiveGolem extends SummonedGolem {
// attackProgress is inherited from SummonedGolem // attackProgress is inherited from SummonedGolem
} }
export interface GolemancyState { /**
enabledGolems: string[]; // Golem IDs the player wants active * Player-designed golem loadout entry.
summonedGolems: SummonedGolem[]; // Currently summoned golems on this floor (legacy, kept for golem-tab state) * Each entry is a complete golem design (Core + Frame + Mind Circuit + Enchantments).
activeGolems: ActiveGolem[]; // Runtime active golems in combat (spec §9) */
lastSummonFloor: number; // Floor golems were last summoned on 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<string, SerializedGolemDesign>;
/** 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 ───────────────────────────────────────────────────── // ─── Main Game State ─────────────────────────────────────────────────────
export interface GameState { export interface GameState {
@@ -245,6 +291,8 @@ export interface GameState {
// Golemancy (summoned golems) // Golemancy (summoned golems)
golemancy: GolemancyState; golemancy: GolemancyState;
// Achievements // Achievements
achievements: AchievementState; achievements: AchievementState;
+3
View File
@@ -43,6 +43,9 @@ export type {
SummonedGolem, SummonedGolem,
ActiveGolem, ActiveGolem,
GolemancyState, GolemancyState,
GolemLoadoutEntry,
RuntimeActiveGolem,
SerializedGolemDesign,
GameActionType, GameActionType,
ActivityEventType, ActivityEventType,
ActivityLogEntry, ActivityLogEntry,