From 411c355a155dc2b3f06b85e975f43ed023883e9d Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Mon, 8 Jun 2026 10:30:59 +0200 Subject: [PATCH] fix: Golemancy enchantment capacity, design persistence, and UI selectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/circular-deps.txt | 11 +- docs/dependency-graph.json | 11 +- src/components/game/tabs/GolemancyTab.tsx | 36 +++++- .../tabs/golemancy/GolemDesignBuilder.tsx | 104 +++++++++++++++++- .../golemancy/GolemancyComponents.test.ts | 8 +- .../game/data/golems/golemancy-data.test.ts | 6 +- src/lib/game/data/golems/utils.ts | 5 +- src/lib/game/stores/combat-descent-actions.ts | 2 +- 8 files changed, 163 insertions(+), 20 deletions(-) diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 4b37ac3..d3a13e9 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,4 +1,11 @@ # 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) \ No newline at end of file diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 850f935..476aab4 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_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.", "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/dot-runtime.ts", "stores/golem-combat-actions.ts", + "stores/golem-combat-helpers.ts", "types.ts", "utils/index.ts" ], @@ -719,8 +720,16 @@ "constants.ts", "data/golems/index.ts", "data/golems/utils.ts", + "stores/golem-combat-helpers.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": [ "types/game.ts" ], diff --git a/src/components/game/tabs/GolemancyTab.tsx b/src/components/game/tabs/GolemancyTab.tsx index 764626c..82c3572 100644 --- a/src/components/game/tabs/GolemancyTab.tsx +++ b/src/components/game/tabs/GolemancyTab.tsx @@ -35,6 +35,7 @@ export const GolemancyTab: React.FC = () => { const [selectedEnchantmentIds, setSelectedEnchantmentIds] = useState([]); const [designName, setDesignName] = useState(''); const [selectedManaTypes, setSelectedManaTypes] = useState([]); + const [selectedSpells, setSelectedSpells] = useState([]); // 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} /> diff --git a/src/components/game/tabs/golemancy/GolemDesignBuilder.tsx b/src/components/game/tabs/golemancy/GolemDesignBuilder.tsx index e33452b..df86fcb 100644 --- a/src/components/game/tabs/golemancy/GolemDesignBuilder.tsx +++ b/src/components/game/tabs/golemancy/GolemDesignBuilder.tsx @@ -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; @@ -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 = ({ 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 = ({ 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 (
@@ -79,6 +113,34 @@ export const GolemDesignBuilder: React.FC = ({
)} /> + {/* Mana Type Selector — shown for Intermediate / Advanced / Guardian cores */} + {showManaSelector && ( +
+

+ 1b. Select Mana Types + + {selectedManaTypes.length}/{manaSlots} selected + +

+
+ {Object.entries(ELEMENTS).map(([key, elem]) => { + const isSelected = selectedManaTypes.includes(key); + const canSelect = isSelected || selectedManaTypes.length < manaSlots; + return ( + + ); + })} +
+
+ )} + (
@@ -110,6 +172,46 @@ export const GolemDesignBuilder: React.FC = ({
)} /> + {/* Spell Selector — shown when circuit has spell slots */} + {showSpellSelector && ( +
+

+ 3b. Select Spells + + {selectedSpells.length}/{spellSlots} selected + +

+ {selectedManaTypes.length === 0 ? ( +

Select mana types on the core first to see available spells.

+ ) : availableSpells.length === 0 ? ( +

No spells available for selected mana types.

+ ) : ( +
+ {availableSpells.map(spell => { + const isSelected = selectedSpells.includes(spell.name); + const canSelect = isSelected || selectedSpells.length < spellSlots; + const elemDef = ELEMENTS[spell.elem]; + return ( + + ); + })} +
+ )} +
+ )} + {selectedCore && selectedFrame && selectedCircuit && (

4. Enchantments (Optional) diff --git a/src/components/game/tabs/golemancy/GolemancyComponents.test.ts b/src/components/game/tabs/golemancy/GolemancyComponents.test.ts index 1918ca0..bab47c7 100644 --- a/src/components/game/tabs/golemancy/GolemancyComponents.test.ts +++ b/src/components/game/tabs/golemancy/GolemancyComponents.test.ts @@ -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']); diff --git a/src/lib/game/data/golems/golemancy-data.test.ts b/src/lib/game/data/golems/golemancy-data.test.ts index 9abcfa3..3f3cf92 100644 --- a/src/lib/game/data/golems/golemancy-data.test.ts +++ b/src/lib/game/data/golems/golemancy-data.test.ts @@ -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'); }); diff --git a/src/lib/game/data/golems/utils.ts b/src/lib/game/data/golems/utils.ts index 27f9fdc..18b4310 100644 --- a/src/lib/game/data/golems/utils.ts +++ b/src/lib/game/data/golems/utils.ts @@ -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, diff --git a/src/lib/game/stores/combat-descent-actions.ts b/src/lib/game/stores/combat-descent-actions.ts index 604c4ad..085f38a 100644 --- a/src/lib/game/stores/combat-descent-actions.ts +++ b/src/lib/game/stores/combat-descent-actions.ts @@ -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',