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
+30 -6
View File
@@ -35,6 +35,7 @@ export const GolemancyTab: React.FC = () => {
const [selectedEnchantmentIds, setSelectedEnchantmentIds] = useState<string[]>([]);
const [designName, setDesignName] = useState('');
const [selectedManaTypes, setSelectedManaTypes] = useState<string[]>([]);
const [selectedSpells, setSelectedSpells] = useState<string[]>([]);
// Store access
const { golemancy, addGolemDesign, removeGolemDesign, toggleGolemLoadoutEntry } = useCombatStore(
@@ -117,10 +118,10 @@ export const GolemancyTab: React.FC = () => {
if (!selectedCore || !selectedFrame || !selectedCircuit) return null;
const design = buildGolemDesign(
selectedCore, selectedFrame, selectedCircuit,
selectedEnchantments, selectedManaTypes, [],
selectedEnchantments, selectedManaTypes, selectedSpells,
);
return computeGolemStats(design);
}, [selectedCore, selectedFrame, selectedCircuit, selectedEnchantments, selectedManaTypes]);
}, [selectedCore, selectedFrame, selectedCircuit, selectedEnchantments, selectedManaTypes, selectedSpells]);
const affordability = useMemo(() => {
if (!selectedCore || !selectedFrame || !selectedCircuit) {
@@ -128,10 +129,10 @@ export const GolemancyTab: React.FC = () => {
}
const design = buildGolemDesign(
selectedCore, selectedFrame, selectedCircuit,
selectedEnchantments, selectedManaTypes, [],
selectedEnchantments, selectedManaTypes, selectedSpells,
);
return canAffordGolemDesign(design, rawMana, elements);
}, [selectedCore, selectedFrame, selectedCircuit, selectedEnchantments, selectedManaTypes, rawMana, elements]);
}, [selectedCore, selectedFrame, selectedCircuit, selectedEnchantments, selectedManaTypes, selectedSpells, rawMana, elements]);
// Enchantment capacity check
const enchantmentCapacity = computedStats?.enchantmentCapacity ?? 0;
@@ -144,18 +145,38 @@ export const GolemancyTab: React.FC = () => {
);
}, []);
const handleToggleManaType = useCallback((type: string) => {
setSelectedManaTypes(prev => {
if (prev.includes(type)) return prev.filter(t => t !== type);
const core = selectedCoreId ? CORES[selectedCoreId] : null;
const slots = core?.id === 'intermediate' ? 2 : core?.id === 'advanced' ? 3 : core?.id === 'guardian' ? 4 : 0;
if (prev.length >= slots) return prev;
return [...prev, type];
});
}, [selectedCoreId]);
const handleToggleSpell = useCallback((spellName: string) => {
setSelectedSpells(prev => {
if (prev.includes(spellName)) return prev.filter(s => s !== spellName);
const slots = selectedCircuit?.spellSlots ?? 0;
if (prev.length >= slots) return prev;
return [...prev, spellName];
});
}, [selectedCircuit]);
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, [],
selectedEnchantments, selectedManaTypes, selectedSpells,
);
addGolemDesign(serialized);
setDesignName('');
setSelectedEnchantmentIds([]);
setSelectedManaTypes([]);
}, [designName, selectedCore, selectedFrame, selectedCircuit, selectedEnchantments, selectedManaTypes, addGolemDesign]);
setSelectedSpells([]);
}, [designName, selectedCore, selectedFrame, selectedCircuit, selectedEnchantments, selectedManaTypes, selectedSpells, addGolemDesign]);
const handleRemoveLoadoutEntry = useCallback((designId: string) => {
removeGolemDesign(designId);
@@ -209,6 +230,7 @@ export const GolemancyTab: React.FC = () => {
selectedFrameId={selectedFrameId}
selectedCircuitId={selectedCircuitId}
selectedEnchantmentIds={selectedEnchantmentIds}
selectedSpells={selectedSpells}
designName={designName}
selectedManaTypes={selectedManaTypes}
unlockedCoreIds={unlockedCoreIds}
@@ -224,6 +246,8 @@ export const GolemancyTab: React.FC = () => {
onSelectFrame={setSelectedFrameId}
onSelectCircuit={setSelectedCircuitId}
onToggleEnchantment={handleToggleEnchantment}
onToggleSpell={handleToggleSpell}
onToggleManaType={handleToggleManaType}
onDesignNameChange={setDesignName}
onSaveDesign={handleSaveDesign}
/>
@@ -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']);
@@ -136,7 +136,7 @@ describe('Computed stats', () => {
expect(stats.magicAffinity).toBe(0.3);
expect(stats.aoeTargets).toBe(1);
expect(stats.spellSlots).toBe(0);
expect(stats.enchantmentCapacity).toBeCloseTo(0.3);
expect(stats.enchantmentCapacity).toBeCloseTo(30); // Earth Frame 30% × Basic Core 1.0
expect(stats.specialEffect).toBe('none');
expect(stats.availableManaTypes).toEqual(['earth']);
});
@@ -153,7 +153,7 @@ describe('Computed stats', () => {
expect(stats.armorPierce).toBe(0.5);
expect(stats.magicAffinity).toBe(0.5);
expect(stats.spellSlots).toBe(2);
expect(stats.enchantmentCapacity).toBeCloseTo(1.0);
expect(stats.enchantmentCapacity).toBeCloseTo(100); // Steel Frame 50% × Advanced Core 2.0
});
it('guardian crystalSteelHybrid golem with guardian circuit', () => {
@@ -168,7 +168,7 @@ describe('Computed stats', () => {
expect(stats.armorPierce).toBe(0.7);
expect(stats.magicAffinity).toBe(1.0);
expect(stats.spellSlots).toBe(4);
expect(stats.enchantmentCapacity).toBeCloseTo(3.0);
expect(stats.enchantmentCapacity).toBeCloseTo(300); // Crystal-Steel Hybrid 100% × Guardian Core 3.0
expect(stats.specialEffect).toBe('guardianConstruct');
});
+3 -2
View File
@@ -90,8 +90,9 @@ export function computeGolemStats(design: GolemDesign): ComputedGolemStats {
},
];
// Enchantment capacity = Frame.MagicAffinity × Core.TierMultiplier
const enchantmentCapacity = frame.magicAffinity * core.tierMultiplier;
// Enchantment capacity = Frame.MagicAffinity(%) × Core.TierMultiplier
// magicAffinity is stored as decimal (0.3 = 30%) per spec §5.1, so multiply by 100
const enchantmentCapacity = Math.round(frame.magicAffinity * 100) * core.tierMultiplier;
return {
maxRoomDuration: core.maxRoomDuration,
@@ -253,7 +253,7 @@ export function createEnterSpireMode(get: GetFn, set: SetFn) {
roomResetState: {},
descentPeak: null,
isDescentComplete: false,
golemancy: { activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [] },
golemancy: { activeGolems: [], lastSummonFloor: 0, golemDesigns: s.golemancy?.golemDesigns ?? {}, golemLoadout: [] },
});
get().addActivityLog('floor_transition',