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
+9 -2
View File
@@ -1,4 +1,11 @@
# Circular Dependencies # Circular Dependencies
Generated: 2026-06-07T21:16:10.397Z Generated: 2026-06-08T08:12:33.305Z
Found: 1 circular chain(s) — these MUST be fixed before modifying involved files.
No circular dependencies found. ✅ 1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts
## How to fix
1. Identify which import in the chain can be extracted to a shared types/utils file.
2. Move the shared type or function there.
3. Both files import from the new shared module instead of each other.
4. Run: bunx madge --circular src/lib/game (should return clean)
+10 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_meta": {
"generated": "2026-06-07T21:16:08.368Z", "generated": "2026-06-08T08:12:31.358Z",
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.", "description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry." "usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
}, },
@@ -550,6 +550,7 @@
"stores/combat-state.types.ts", "stores/combat-state.types.ts",
"stores/dot-runtime.ts", "stores/dot-runtime.ts",
"stores/golem-combat-actions.ts", "stores/golem-combat-actions.ts",
"stores/golem-combat-helpers.ts",
"types.ts", "types.ts",
"utils/index.ts" "utils/index.ts"
], ],
@@ -719,8 +720,16 @@
"constants.ts", "constants.ts",
"data/golems/index.ts", "data/golems/index.ts",
"data/golems/utils.ts", "data/golems/utils.ts",
"stores/golem-combat-helpers.ts",
"types.ts" "types.ts"
], ],
"stores/golem-combat-helpers.ts": [
"data/golems/index.ts",
"stores/combat-state.types.ts",
"stores/golem-combat-actions.ts",
"types.ts",
"utils/index.ts"
],
"stores/golemancy-actions.ts": [ "stores/golemancy-actions.ts": [
"types/game.ts" "types/game.ts"
], ],
+30 -6
View File
@@ -35,6 +35,7 @@ export const GolemancyTab: React.FC = () => {
const [selectedEnchantmentIds, setSelectedEnchantmentIds] = useState<string[]>([]); const [selectedEnchantmentIds, setSelectedEnchantmentIds] = useState<string[]>([]);
const [designName, setDesignName] = useState(''); const [designName, setDesignName] = useState('');
const [selectedManaTypes, setSelectedManaTypes] = useState<string[]>([]); const [selectedManaTypes, setSelectedManaTypes] = useState<string[]>([]);
const [selectedSpells, setSelectedSpells] = useState<string[]>([]);
// Store access // Store access
const { golemancy, addGolemDesign, removeGolemDesign, toggleGolemLoadoutEntry } = useCombatStore( const { golemancy, addGolemDesign, removeGolemDesign, toggleGolemLoadoutEntry } = useCombatStore(
@@ -117,10 +118,10 @@ export const GolemancyTab: React.FC = () => {
if (!selectedCore || !selectedFrame || !selectedCircuit) return null; if (!selectedCore || !selectedFrame || !selectedCircuit) return null;
const design = buildGolemDesign( const design = buildGolemDesign(
selectedCore, selectedFrame, selectedCircuit, selectedCore, selectedFrame, selectedCircuit,
selectedEnchantments, selectedManaTypes, [], selectedEnchantments, selectedManaTypes, selectedSpells,
); );
return computeGolemStats(design); return computeGolemStats(design);
}, [selectedCore, selectedFrame, selectedCircuit, selectedEnchantments, selectedManaTypes]); }, [selectedCore, selectedFrame, selectedCircuit, selectedEnchantments, selectedManaTypes, selectedSpells]);
const affordability = useMemo(() => { const affordability = useMemo(() => {
if (!selectedCore || !selectedFrame || !selectedCircuit) { if (!selectedCore || !selectedFrame || !selectedCircuit) {
@@ -128,10 +129,10 @@ export const GolemancyTab: React.FC = () => {
} }
const design = buildGolemDesign( const design = buildGolemDesign(
selectedCore, selectedFrame, selectedCircuit, selectedCore, selectedFrame, selectedCircuit,
selectedEnchantments, selectedManaTypes, [], selectedEnchantments, selectedManaTypes, selectedSpells,
); );
return canAffordGolemDesign(design, rawMana, elements); return canAffordGolemDesign(design, rawMana, elements);
}, [selectedCore, selectedFrame, selectedCircuit, selectedEnchantments, selectedManaTypes, rawMana, elements]); }, [selectedCore, selectedFrame, selectedCircuit, selectedEnchantments, selectedManaTypes, selectedSpells, rawMana, elements]);
// Enchantment capacity check // Enchantment capacity check
const enchantmentCapacity = computedStats?.enchantmentCapacity ?? 0; 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(() => { const handleSaveDesign = useCallback(() => {
if (!selectedCore || !selectedFrame || !selectedCircuit) return; if (!selectedCore || !selectedFrame || !selectedCircuit) return;
const name = designName.trim() || `${selectedCore.name.split(' ')[0]} ${selectedFrame.name.split(' ')[0]}`; const name = designName.trim() || `${selectedCore.name.split(' ')[0]} ${selectedFrame.name.split(' ')[0]}`;
const serialized = serializeDesign( const serialized = serializeDesign(
name, selectedCore, selectedFrame, selectedCircuit, name, selectedCore, selectedFrame, selectedCircuit,
selectedEnchantments, selectedManaTypes, [], selectedEnchantments, selectedManaTypes, selectedSpells,
); );
addGolemDesign(serialized); addGolemDesign(serialized);
setDesignName(''); setDesignName('');
setSelectedEnchantmentIds([]); setSelectedEnchantmentIds([]);
setSelectedManaTypes([]); setSelectedManaTypes([]);
}, [designName, selectedCore, selectedFrame, selectedCircuit, selectedEnchantments, selectedManaTypes, addGolemDesign]); setSelectedSpells([]);
}, [designName, selectedCore, selectedFrame, selectedCircuit, selectedEnchantments, selectedManaTypes, selectedSpells, addGolemDesign]);
const handleRemoveLoadoutEntry = useCallback((designId: string) => { const handleRemoveLoadoutEntry = useCallback((designId: string) => {
removeGolemDesign(designId); removeGolemDesign(designId);
@@ -209,6 +230,7 @@ export const GolemancyTab: React.FC = () => {
selectedFrameId={selectedFrameId} selectedFrameId={selectedFrameId}
selectedCircuitId={selectedCircuitId} selectedCircuitId={selectedCircuitId}
selectedEnchantmentIds={selectedEnchantmentIds} selectedEnchantmentIds={selectedEnchantmentIds}
selectedSpells={selectedSpells}
designName={designName} designName={designName}
selectedManaTypes={selectedManaTypes} selectedManaTypes={selectedManaTypes}
unlockedCoreIds={unlockedCoreIds} unlockedCoreIds={unlockedCoreIds}
@@ -224,6 +246,8 @@ export const GolemancyTab: React.FC = () => {
onSelectFrame={setSelectedFrameId} onSelectFrame={setSelectedFrameId}
onSelectCircuit={setSelectedCircuitId} onSelectCircuit={setSelectedCircuitId}
onToggleEnchantment={handleToggleEnchantment} onToggleEnchantment={handleToggleEnchantment}
onToggleSpell={handleToggleSpell}
onToggleManaType={handleToggleManaType}
onDesignNameChange={setDesignName} onDesignNameChange={setDesignName}
onSaveDesign={handleSaveDesign} onSaveDesign={handleSaveDesign}
/> />
@@ -11,6 +11,8 @@ import type {
GolemEnchantmentDefinition, ComputedGolemStats, GolemEnchantmentDefinition, ComputedGolemStats,
} from '@/lib/game/data/golems/types'; } from '@/lib/game/data/golems/types';
import { ELEMENTS } from '@/lib/game/constants/elements'; 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 { ScrollArea } from '@/components/ui/scroll-area';
import clsx from 'clsx'; import clsx from 'clsx';
import { formatRequirement } from './golemancy-utils'; import { formatRequirement } from './golemancy-utils';
@@ -21,6 +23,7 @@ export interface GolemDesignBuilderProps {
selectedFrameId: string | null; selectedFrameId: string | null;
selectedCircuitId: string | null; selectedCircuitId: string | null;
selectedEnchantmentIds: string[]; selectedEnchantmentIds: string[];
selectedSpells: string[];
designName: string; designName: string;
selectedManaTypes: string[]; selectedManaTypes: string[];
unlockedCoreIds: Set<string>; unlockedCoreIds: Set<string>;
@@ -36,16 +39,30 @@ export interface GolemDesignBuilderProps {
onSelectFrame: (id: string) => void; onSelectFrame: (id: string) => void;
onSelectCircuit: (id: string) => void; onSelectCircuit: (id: string) => void;
onToggleEnchantment: (id: string) => void; onToggleEnchantment: (id: string) => void;
onToggleSpell: (id: string) => void;
onToggleManaType: (type: string) => void;
onDesignNameChange: (name: string) => void; onDesignNameChange: (name: string) => void;
onSaveDesign: () => 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> = ({ export const GolemDesignBuilder: React.FC<GolemDesignBuilderProps> = ({
selectedCoreId, selectedFrameId, selectedCircuitId, selectedCoreId, selectedFrameId, selectedCircuitId,
selectedEnchantmentIds, designName, selectedManaTypes, selectedEnchantmentIds, selectedSpells, designName, selectedManaTypes,
unlockedCoreIds, unlockedFrameIds, unlockedCircuitIds, unlockedCoreIds, unlockedFrameIds, unlockedCircuitIds,
computedStats, affordability, enchantmentCapacity, usedEnchantmentCapacity, computedStats, affordability, enchantmentCapacity, usedEnchantmentCapacity,
onSelectCore, onSelectFrame, onSelectCircuit, onToggleEnchantment, onSelectCore, onSelectFrame, onSelectCircuit, onToggleEnchantment,
onToggleSpell, onToggleManaType,
onDesignNameChange, onSaveDesign, onDesignNameChange, onSaveDesign,
}) => { }) => {
const canSaveDesign = selectedCoreId && selectedFrameId && selectedCircuitId && affordability.canAfford; 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 selectedFrame = selectedFrameId ? FRAMES[selectedFrameId] ?? null : null;
const selectedCircuit = selectedCircuitId ? MIND_CIRCUITS[selectedCircuitId] ?? 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 ( return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<ScrollArea className="h-[560px] rounded border border-gray-700 p-3"> <ScrollArea className="h-[560px] rounded border border-gray-700 p-3">
@@ -79,6 +113,34 @@ export const GolemDesignBuilder: React.FC<GolemDesignBuilderProps> = ({
</div> </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} <ComponentSelector label="2. Frame (Combat Body)" items={ALL_FRAMES} selectedId={selectedFrameId} unlockedIds={unlockedFrameIds} onSelect={onSelectFrame}
renderItem={(frame: FrameDefinition, unlocked: boolean) => ( renderItem={(frame: FrameDefinition, unlocked: boolean) => (
<div> <div>
@@ -110,6 +172,46 @@ export const GolemDesignBuilder: React.FC<GolemDesignBuilderProps> = ({
</div> </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 && ( {selectedCore && selectedFrame && selectedCircuit && (
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-300">4. Enchantments (Optional) <h4 className="text-sm font-semibold text-gray-300">4. Enchantments (Optional)
@@ -33,8 +33,8 @@ describe('computeGolemStats', () => {
// Circuit-derived stats // Circuit-derived stats
expect(stats.spellSlots).toBe(0); expect(stats.spellSlots).toBe(0);
// Enchantment capacity = frame.magicAffinity * core.tierMultiplier // Enchantment capacity = Math.round(frame.magicAffinity * 100) * core.tierMultiplier
expect(stats.enchantmentCapacity).toBeCloseTo(0.3 * 1.0); expect(stats.enchantmentCapacity).toBeCloseTo(Math.round(0.3 * 100) * 1.0);
// Special effect from frame // Special effect from frame
expect(stats.specialEffect).toBe('none'); expect(stats.specialEffect).toBe('none');
@@ -74,8 +74,8 @@ describe('computeGolemStats', () => {
// Circuit-derived stats // Circuit-derived stats
expect(stats.spellSlots).toBe(2); expect(stats.spellSlots).toBe(2);
// Enchantment capacity = frame.magicAffinity * core.tierMultiplier // Enchantment capacity = Math.round(frame.magicAffinity * 100) * core.tierMultiplier
expect(stats.enchantmentCapacity).toBeCloseTo(0.5 * 2.0); expect(stats.enchantmentCapacity).toBeCloseTo(Math.round(0.5 * 100) * 2.0);
// Selected mana types override core defaults // Selected mana types override core defaults
expect(stats.availableManaTypes).toEqual(['crystal', 'metal', 'fire']); expect(stats.availableManaTypes).toEqual(['crystal', 'metal', 'fire']);
@@ -136,7 +136,7 @@ describe('Computed stats', () => {
expect(stats.magicAffinity).toBe(0.3); expect(stats.magicAffinity).toBe(0.3);
expect(stats.aoeTargets).toBe(1); expect(stats.aoeTargets).toBe(1);
expect(stats.spellSlots).toBe(0); 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.specialEffect).toBe('none');
expect(stats.availableManaTypes).toEqual(['earth']); expect(stats.availableManaTypes).toEqual(['earth']);
}); });
@@ -153,7 +153,7 @@ describe('Computed stats', () => {
expect(stats.armorPierce).toBe(0.5); expect(stats.armorPierce).toBe(0.5);
expect(stats.magicAffinity).toBe(0.5); expect(stats.magicAffinity).toBe(0.5);
expect(stats.spellSlots).toBe(2); 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', () => { it('guardian crystalSteelHybrid golem with guardian circuit', () => {
@@ -168,7 +168,7 @@ describe('Computed stats', () => {
expect(stats.armorPierce).toBe(0.7); expect(stats.armorPierce).toBe(0.7);
expect(stats.magicAffinity).toBe(1.0); expect(stats.magicAffinity).toBe(1.0);
expect(stats.spellSlots).toBe(4); 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'); 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 // Enchantment capacity = Frame.MagicAffinity(%) × Core.TierMultiplier
const enchantmentCapacity = 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 { return {
maxRoomDuration: core.maxRoomDuration, maxRoomDuration: core.maxRoomDuration,
@@ -253,7 +253,7 @@ export function createEnterSpireMode(get: GetFn, set: SetFn) {
roomResetState: {}, roomResetState: {},
descentPeak: null, descentPeak: null,
isDescentComplete: false, isDescentComplete: false,
golemancy: { activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [] }, golemancy: { activeGolems: [], lastSummonFloor: 0, golemDesigns: s.golemancy?.golemDesigns ?? {}, golemLoadout: [] },
}); });
get().addActivityLog('floor_transition', get().addActivityLog('floor_transition',