feat(golemancy): Phase 1 - Component-based construction system data definitions
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
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:
@@ -2,30 +2,130 @@
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Bug, Wand2 } from 'lucide-react';
|
||||
import { Bug, Cpu, Box, CircuitBoard, Sparkles } from 'lucide-react';
|
||||
import { useCombatStore } from '@/lib/game/stores';
|
||||
import { GOLEMS_DEF } from '@/lib/game/data/golems';
|
||||
import {
|
||||
ALL_CORES,
|
||||
ALL_FRAMES,
|
||||
ALL_MIND_CIRCUITS,
|
||||
ALL_GOLEM_ENCHANTMENTS,
|
||||
type CoreDefinition,
|
||||
type FrameDefinition,
|
||||
type MindCircuitDefinition,
|
||||
type GolemEnchantmentDefinition,
|
||||
} from '@/lib/game/data/golems';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
|
||||
function formatCost(cost: { type: string; element?: string; amount: number }): string {
|
||||
if (cost.type === 'raw') return `${cost.amount} raw`;
|
||||
return `${cost.amount} ${cost.element ?? 'unknown'}`;
|
||||
}
|
||||
|
||||
function formatUnlock(req: {
|
||||
type: string;
|
||||
attunement?: string;
|
||||
level?: number;
|
||||
manaType?: string;
|
||||
attunements?: string[];
|
||||
levels?: number[];
|
||||
}): string {
|
||||
switch (req.type) {
|
||||
case 'attunement_level':
|
||||
return `${req.attunement} Lv${req.level}`;
|
||||
case 'mana_unlocked':
|
||||
return `Unlock ${req.manaType} mana`;
|
||||
case 'dual_attunement':
|
||||
return `${req.attunements?.[0]} Lv${req.levels?.[0]} + ${req.attunements?.[1]} Lv${req.levels?.[1]}`;
|
||||
case 'guardian_pact':
|
||||
return `Guardian: ${req.attunements?.[0]} Lv${req.levels?.[0]} + ${req.attunements?.[1]} Lv${req.levels?.[1]}`;
|
||||
default:
|
||||
return req.type;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCosts(costs: { type: string; element?: string; amount: number }[]): string {
|
||||
return costs.map(formatCost).join(', ');
|
||||
}
|
||||
|
||||
function CoreCard({ core }: { core: CoreDefinition }) {
|
||||
return (
|
||||
<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() {
|
||||
const golemancy = useCombatStore((s) => s.golemancy);
|
||||
const setEnabledGolems = useCombatStore((s) => s.setEnabledGolems);
|
||||
const enabledGolems = useCombatStore((s) => s.golemancy?.enabledGolems) ?? [];
|
||||
|
||||
const enabledGolems = golemancy?.enabledGolems || [];
|
||||
const allComponentIds = [
|
||||
...ALL_CORES.map((c) => c.id),
|
||||
...ALL_FRAMES.map((f) => f.id),
|
||||
...ALL_MIND_CIRCUITS.map((m) => m.id),
|
||||
...ALL_GOLEM_ENCHANTMENTS.map((e) => e.id),
|
||||
];
|
||||
|
||||
const handleEnableAll = () => {
|
||||
setEnabledGolems(Object.keys(GOLEMS_DEF));
|
||||
};
|
||||
const handleEnableAll = () => setEnabledGolems(allComponentIds);
|
||||
|
||||
const handleDisableAll = () => {
|
||||
setEnabledGolems([]);
|
||||
};
|
||||
const handleDisableAll = () => setEnabledGolems([]);
|
||||
|
||||
const handleToggleGolem = (golemId: string) => {
|
||||
if (enabledGolems.includes(golemId)) {
|
||||
setEnabledGolems(enabledGolems.filter(id => id !== golemId));
|
||||
const handleToggleComponent = (id: string) => {
|
||||
if (enabledGolems.includes(id)) {
|
||||
setEnabledGolems(enabledGolems.filter((x) => x !== id));
|
||||
} else {
|
||||
setEnabledGolems([...enabledGolems, golemId]);
|
||||
setEnabledGolems([...enabledGolems, id]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,45 +135,108 @@ export function GolemDebugSection() {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-orange-400 text-sm flex items-center gap-2">
|
||||
<Bug className="w-4 h-4" />
|
||||
Golem Debug
|
||||
Golem Debug — Component System
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button size="sm" variant="outline" onClick={handleEnableAll}>
|
||||
<Wand2 className="w-3 h-3 mr-1" /> Enable All Golems
|
||||
Enable All Components
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleDisableAll}>
|
||||
Disable All Golems
|
||||
Disable All
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
Enabled: {enabledGolems.length} / {Object.keys(GOLEMS_DEF).length}
|
||||
Enabled: {enabledGolems.length} / {allComponentIds.length}
|
||||
</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]) => {
|
||||
const isEnabled = enabledGolems.includes(id);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`p-2 rounded border flex items-center justify-between ${
|
||||
isEnabled ? 'border-orange-600/50 bg-orange-900/20' : 'border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{def.name}</div>
|
||||
<div className="text-xs text-gray-400">{def.baseManaType}</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Cores ─────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-amber-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
||||
<Cpu className="w-3.5 h-3.5" /> Cores ({ALL_CORES.length})
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-52 overflow-y-auto">
|
||||
{ALL_CORES.map((core) => (
|
||||
<div key={core.id} className="relative">
|
||||
<CoreCard core={core} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isEnabled ? 'default' : 'outline'}
|
||||
onClick={() => handleToggleGolem(id)}
|
||||
variant={enabledGolems.includes(core.id) ? 'default' : 'outline'}
|
||||
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>
|
||||
</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>
|
||||
</CardContent>
|
||||
</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);
|
||||
});
|
||||
});
|
||||
@@ -5,223 +5,53 @@ import { useShallow } from 'zustand/react/shallow';
|
||||
import { useCombatStore } from '@/lib/game/stores/combatStore';
|
||||
import { useAttunementStore } from '@/lib/game/stores/attunementStore';
|
||||
import { useManaStore } from '@/lib/game/stores/manaStore';
|
||||
import { GOLEMS_DEF, isGolemUnlocked, canAffordGolemSummon, getGolemSlots } from '@/lib/game/data/golems';
|
||||
import type { GolemDef } from '@/lib/game/data/golems';
|
||||
import { ELEMENTS } from '@/lib/game/constants/elements';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
|
||||
import {
|
||||
CORES, FRAMES, MIND_CIRCUITS, GOLEM_ENCHANTMENTS,
|
||||
ALL_CORES, ALL_FRAMES, ALL_MIND_CIRCUITS, ALL_GOLEM_ENCHANTMENTS,
|
||||
} from '@/lib/game/data/golems';
|
||||
import {
|
||||
getGolemSlots,
|
||||
isComponentUnlocked,
|
||||
computeGolemStats,
|
||||
canAffordGolemDesign,
|
||||
} from '@/lib/game/data/golems/utils';
|
||||
import type { ComputedGolemStats, GolemEnchantmentDefinition } from '@/lib/game/data/golems/types';
|
||||
import type { BuilderSection } from './golemancy/types';
|
||||
import { GolemDesignBuilder } from './golemancy/GolemDesignBuilder';
|
||||
import { GolemLoadoutPanel } from './golemancy/GolemLoadoutPanel';
|
||||
import { ActiveGolemsPanel } from './golemancy/ActiveGolemsPanel';
|
||||
import { serializeDesign, buildGolemDesign } from './golemancy/golemancy-utils';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// ─── Tier configuration ──────────────────────────────────────────────────────
|
||||
|
||||
interface TierConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
tier: number;
|
||||
}
|
||||
|
||||
const TIERS: TierConfig[] = [
|
||||
{ key: 'base', label: 'Base', tier: 1 },
|
||||
{ key: 'elemental', label: 'Elemental', tier: 2 },
|
||||
{ key: 'hybrid', label: 'Hybrid', tier: 3 },
|
||||
];
|
||||
|
||||
function getTierLabel(tier: number): string {
|
||||
if (tier <= 1) return 'Base';
|
||||
if (tier <= 2) return 'Elemental';
|
||||
return 'Hybrid';
|
||||
}
|
||||
|
||||
function getTierColor(tier: number): string {
|
||||
if (tier <= 1) return 'bg-gray-600';
|
||||
if (tier <= 2) return 'bg-blue-600';
|
||||
if (tier <= 3) return 'bg-purple-600';
|
||||
return 'bg-amber-500';
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatCost(cost: GolemDef['summonCost'][number]): string {
|
||||
if (cost.type === 'raw') return `${cost.amount} raw`;
|
||||
const elem = cost.element ? ELEMENTS[cost.element] : null;
|
||||
return `${cost.amount} ${elem?.sym ?? ''} ${cost.element ?? ''}`.trim();
|
||||
}
|
||||
|
||||
function formatUnlockCondition(golem: GolemDef): string {
|
||||
const cond = golem.unlockCondition;
|
||||
switch (cond.type) {
|
||||
case 'attunement_level':
|
||||
return `${cond.attunement} level ${cond.level}`;
|
||||
case 'mana_unlocked': {
|
||||
const elem = cond.manaType ? ELEMENTS[cond.manaType] : null;
|
||||
return `Unlock ${elem?.sym ?? ''} ${cond.manaType ?? ''}`.trim();
|
||||
}
|
||||
case 'dual_attunement':
|
||||
return `${cond.attunements?.join(' + ')} level ${cond.levels?.join('/')}`;
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Golem Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface GolemCardProps {
|
||||
golem: GolemDef;
|
||||
unlocked: boolean;
|
||||
enabled: boolean;
|
||||
summoned: boolean;
|
||||
canAfford: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
const GolemCard: React.FC<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 = () => {
|
||||
const [activeTier, setActiveTier] = useState<string>('base');
|
||||
const [activeSection, setActiveSection] = useState<BuilderSection>('builder');
|
||||
|
||||
const { golemancy, toggleGolem } = useCombatStore(useShallow(s => ({
|
||||
golemancy: s.golemancy,
|
||||
toggleGolem: s.toggleGolem,
|
||||
})));
|
||||
// Builder state
|
||||
const [selectedCoreId, setSelectedCoreId] = useState<string | null>(null);
|
||||
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 { rawMana, elements } = useManaStore(useShallow(s => ({
|
||||
rawMana: s.rawMana,
|
||||
elements: s.elements,
|
||||
})));
|
||||
const signedPacts = usePrestigeStore(s => s.signedPacts);
|
||||
|
||||
// Build attunement lookup for isGolemUnlocked
|
||||
// Derived data
|
||||
const attunementLookup = useMemo(() => {
|
||||
const lookup: Record<string, { active: boolean; level: number }> = {};
|
||||
for (const [id, att] of Object.entries(attunements)) {
|
||||
@@ -235,103 +65,180 @@ export const GolemancyTab: React.FC = () => {
|
||||
[elements],
|
||||
);
|
||||
|
||||
// Group golems by tier
|
||||
const golemsByTier = useMemo(() => {
|
||||
const groups: Record<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 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 (
|
||||
<DebugName name="GolemancyTab">
|
||||
<div className="space-y-4">
|
||||
{/* Header info */}
|
||||
<div className="text-sm text-gray-400 space-y-1">
|
||||
<p>
|
||||
Configure your golem loadout. Enabled golems are automatically summoned
|
||||
when entering the spire if you can afford the cost.
|
||||
</p>
|
||||
<div className="flex gap-4 text-xs">
|
||||
<span>
|
||||
Slots: {enabledCount}/{golemSlots}
|
||||
</span>
|
||||
<span>
|
||||
Summoned: {golemancy.summonedGolems.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{/* Header info */}
|
||||
<div className="text-sm text-gray-400 space-y-1">
|
||||
<p>
|
||||
Design custom golems from components. Enabled golems are automatically
|
||||
summoned when entering the spire if you can afford the cost.
|
||||
</p>
|
||||
<div className="flex gap-4 text-xs">
|
||||
<span>Slots: {enabledCount}/{golemSlots}</span>
|
||||
<span>Active: {golemancy.activeGolems.length}</span>
|
||||
<span>Designs: {Object.keys(golemancy.golemDesigns).length}</span>
|
||||
</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>
|
||||
</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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { GOLEMS_DEF } from '@/lib/game/data/golems';
|
||||
import { CORES, FRAMES } from '@/lib/game/data/golems';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
|
||||
interface SpireCombatControlsProps {
|
||||
@@ -29,7 +29,8 @@ export function SpireCombatControls({ castProgress }: SpireCombatControlsProps)
|
||||
.filter(([, state]) => state?.learned)
|
||||
.map(([id]) => id);
|
||||
|
||||
const summonedGolems = golemancy.summonedGolems || [];
|
||||
const activeGolems = golemancy.activeGolems || [];
|
||||
const golemDesigns = golemancy.golemDesigns || {};
|
||||
|
||||
return (
|
||||
<DebugName name="SpireCombatControls">
|
||||
@@ -100,26 +101,28 @@ export function SpireCombatControls({ castProgress }: SpireCombatControlsProps)
|
||||
<CardTitle className="text-sm text-blue-400">🗿 Golems</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{summonedGolems.length === 0 ? (
|
||||
{activeGolems.length === 0 ? (
|
||||
<div className="text-xs text-gray-500 italic">No golems summoned.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{summonedGolems.map((sg) => {
|
||||
const golemDef = GOLEMS_DEF[sg.golemId];
|
||||
if (!golemDef) return null;
|
||||
const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888';
|
||||
{activeGolems.map((ag) => {
|
||||
const design = golemDesigns[ag.designId];
|
||||
if (!design) return null;
|
||||
const frame = FRAMES[design.frameId];
|
||||
const core = CORES[design.coreId];
|
||||
const elemColor = frame?.element ? ELEMENTS[frame.element]?.color || '#888' : '#888';
|
||||
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<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 className="text-xs text-gray-500">
|
||||
{golemDef.damage} dmg · {golemDef.attackSpeed}/h
|
||||
{frame?.baseDamage ?? '?'} dmg · {core?.manaRegen ?? '?'} mp
|
||||
</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 };
|
||||
}
|
||||
Reference in New Issue
Block a user