fix: Golemancy enchantment capacity, design persistence, and UI selectors
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
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:
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user