fix: Golemancy enchantment capacity, design persistence, and UI selectors
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s

- Fix enchantment capacity formula: multiply magicAffinity by 100 (spec treats it as percentage)
- Fix enterSpireMode preserving golemDesigns (only reset loadout/activeGolems per spec §9)
- Add mana type selector UI for Intermediate/Advanced/Guardian cores
- Add spell selector UI for circuits with spell slots

Fixes #310, #311, #312
This commit is contained in:
2026-06-08 10:30:59 +02:00
parent 1e99a57496
commit 411c355a15
8 changed files with 163 additions and 20 deletions
@@ -11,6 +11,8 @@ import type {
GolemEnchantmentDefinition, ComputedGolemStats,
} from '@/lib/game/data/golems/types';
import { ELEMENTS } from '@/lib/game/constants/elements';
import { SPELLS_DEF } from '@/lib/game/constants/spells';
import type { SpellDef } from '@/lib/game/types/spells';
import { ScrollArea } from '@/components/ui/scroll-area';
import clsx from 'clsx';
import { formatRequirement } from './golemancy-utils';
@@ -21,6 +23,7 @@ export interface GolemDesignBuilderProps {
selectedFrameId: string | null;
selectedCircuitId: string | null;
selectedEnchantmentIds: string[];
selectedSpells: string[];
designName: string;
selectedManaTypes: string[];
unlockedCoreIds: Set<string>;
@@ -36,16 +39,30 @@ export interface GolemDesignBuilderProps {
onSelectFrame: (id: string) => void;
onSelectCircuit: (id: string) => void;
onToggleEnchantment: (id: string) => void;
onToggleSpell: (id: string) => void;
onToggleManaType: (type: string) => void;
onDesignNameChange: (name: string) => void;
onSaveDesign: () => void;
}
/** How many mana types a core tier allows the player to pick */
function coreManaTypeSlots(core: CoreDefinition | null): number {
if (!core) return 0;
switch (core.id) {
case 'intermediate': return 2;
case 'advanced': return 3;
case 'guardian': return 4;
default: return 0; // basic = fixed earth
}
}
export const GolemDesignBuilder: React.FC<GolemDesignBuilderProps> = ({
selectedCoreId, selectedFrameId, selectedCircuitId,
selectedEnchantmentIds, designName, selectedManaTypes,
selectedEnchantmentIds, selectedSpells, designName, selectedManaTypes,
unlockedCoreIds, unlockedFrameIds, unlockedCircuitIds,
computedStats, affordability, enchantmentCapacity, usedEnchantmentCapacity,
onSelectCore, onSelectFrame, onSelectCircuit, onToggleEnchantment,
onToggleSpell, onToggleManaType,
onDesignNameChange, onSaveDesign,
}) => {
const canSaveDesign = selectedCoreId && selectedFrameId && selectedCircuitId && affordability.canAfford;
@@ -53,6 +70,23 @@ export const GolemDesignBuilder: React.FC<GolemDesignBuilderProps> = ({
const selectedFrame = selectedFrameId ? FRAMES[selectedFrameId] ?? null : null;
const selectedCircuit = selectedCircuitId ? MIND_CIRCUITS[selectedCircuitId] ?? null : null;
const manaSlots = coreManaTypeSlots(selectedCore);
const showManaSelector = manaSlots > 0;
const showSpellSelector = (selectedCircuit?.spellSlots ?? 0) > 0;
const spellSlots = selectedCircuit?.spellSlots ?? 0;
// Filter spells to only those whose element matches selected mana types
const availableSpells = React.useMemo(() => {
if (!showSpellSelector || selectedManaTypes.length === 0) return [];
const spells: SpellDef[] = [];
for (const spell of Object.values(SPELLS_DEF)) {
if (selectedManaTypes.includes(spell.elem)) {
spells.push(spell);
}
}
return spells;
}, [showSpellSelector, selectedManaTypes]);
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<ScrollArea className="h-[560px] rounded border border-gray-700 p-3">
@@ -79,6 +113,34 @@ export const GolemDesignBuilder: React.FC<GolemDesignBuilderProps> = ({
</div>
)} />
{/* Mana Type Selector — shown for Intermediate / Advanced / Guardian cores */}
{showManaSelector && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-300">
1b. Select Mana Types
<span className="text-gray-500 font-normal ml-2">
{selectedManaTypes.length}/{manaSlots} selected
</span>
</h4>
<div className="grid grid-cols-2 gap-1.5">
{Object.entries(ELEMENTS).map(([key, elem]) => {
const isSelected = selectedManaTypes.includes(key);
const canSelect = isSelected || selectedManaTypes.length < manaSlots;
return (
<button key={key} onClick={() => onToggleManaType(key)} disabled={!canSelect}
className={clsx('text-left rounded px-3 py-1.5 text-xs transition-colors flex items-center gap-2',
isSelected ? 'bg-blue-600/30 border border-blue-500 text-blue-200'
: canSelect ? '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')}>
<span>{elem.sym}</span>
<span>{elem.name}</span>
</button>
);
})}
</div>
</div>
)}
<ComponentSelector label="2. Frame (Combat Body)" items={ALL_FRAMES} selectedId={selectedFrameId} unlockedIds={unlockedFrameIds} onSelect={onSelectFrame}
renderItem={(frame: FrameDefinition, unlocked: boolean) => (
<div>
@@ -110,6 +172,46 @@ export const GolemDesignBuilder: React.FC<GolemDesignBuilderProps> = ({
</div>
)} />
{/* Spell Selector — shown when circuit has spell slots */}
{showSpellSelector && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-300">
3b. Select Spells
<span className="text-gray-500 font-normal ml-2">
{selectedSpells.length}/{spellSlots} selected
</span>
</h4>
{selectedManaTypes.length === 0 ? (
<p className="text-xs text-yellow-400">Select mana types on the core first to see available spells.</p>
) : availableSpells.length === 0 ? (
<p className="text-xs text-gray-500">No spells available for selected mana types.</p>
) : (
<div className="grid grid-cols-1 gap-1.5">
{availableSpells.map(spell => {
const isSelected = selectedSpells.includes(spell.name);
const canSelect = isSelected || selectedSpells.length < spellSlots;
const elemDef = ELEMENTS[spell.elem];
return (
<button key={spell.name} onClick={() => onToggleSpell(spell.name)} disabled={!canSelect}
className={clsx('text-left rounded px-3 py-2 text-xs transition-colors',
isSelected ? 'bg-green-600/30 border border-green-500 text-green-200'
: canSelect ? '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">{spell.name}</span>
<span className="text-gray-500">
{elemDef?.sym} {spell.elem} · {spell.dmg} dmg · {spell.cost.amount} {spell.cost.type === 'raw' ? 'raw' : spell.cost.element}
</span>
</div>
{spell.desc && <p className="text-gray-500 mt-0.5">{spell.desc}</p>}
</button>
);
})}
</div>
)}
</div>
)}
{selectedCore && selectedFrame && selectedCircuit && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-300">4. Enchantments (Optional)
@@ -33,8 +33,8 @@ describe('computeGolemStats', () => {
// Circuit-derived stats
expect(stats.spellSlots).toBe(0);
// Enchantment capacity = frame.magicAffinity * core.tierMultiplier
expect(stats.enchantmentCapacity).toBeCloseTo(0.3 * 1.0);
// Enchantment capacity = Math.round(frame.magicAffinity * 100) * core.tierMultiplier
expect(stats.enchantmentCapacity).toBeCloseTo(Math.round(0.3 * 100) * 1.0);
// Special effect from frame
expect(stats.specialEffect).toBe('none');
@@ -74,8 +74,8 @@ describe('computeGolemStats', () => {
// Circuit-derived stats
expect(stats.spellSlots).toBe(2);
// Enchantment capacity = frame.magicAffinity * core.tierMultiplier
expect(stats.enchantmentCapacity).toBeCloseTo(0.5 * 2.0);
// Enchantment capacity = Math.round(frame.magicAffinity * 100) * core.tierMultiplier
expect(stats.enchantmentCapacity).toBeCloseTo(Math.round(0.5 * 100) * 2.0);
// Selected mana types override core defaults
expect(stats.availableManaTypes).toEqual(['crystal', 'metal', 'fire']);