fix: enforce discipline perk gating in enchantment design validation
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s

This commit is contained in:
2026-06-08 11:39:43 +02:00
parent 1e1fcdc6d4
commit 971b876537
7 changed files with 114 additions and 5 deletions
@@ -0,0 +1,101 @@
// ─── Regression Test: Discipline Perk Gating in Design Validation ─────────────
// Bug #302: validateDesignEffects() did not check unlockedEffects,
// allowing any effect to be used regardless of discipline perks.
import { describe, it, expect } from 'vitest';
import { validateDesignEffects } from '../crafting-design';
describe('validateDesignEffects — discipline perk gating', () => {
it('should reject effects not in unlockedEffects', () => {
const result = validateDesignEffects(
[{ effectId: 'spell_manaBolt', stacks: 1 }],
'basicStaff',
1,
[] // nothing unlocked
);
expect(result.valid).toBe(false);
expect(result.reason).toContain('locked');
});
it('should accept effects that are in unlockedEffects', () => {
const result = validateDesignEffects(
[{ effectId: 'spell_manaBolt', stacks: 1 }],
'basicStaff',
1,
['spell_manaBolt']
);
expect(result.valid).toBe(true);
});
it('should reject when only some effects are unlocked', () => {
const result = validateDesignEffects(
[
{ effectId: 'spell_manaBolt', stacks: 1 },
{ effectId: 'spell_fireball', stacks: 1 },
],
'basicStaff',
1,
['spell_manaBolt'] // fireball not unlocked
);
expect(result.valid).toBe(false);
expect(result.reason).toContain('spell_fireball');
});
it('should accept when all effects are unlocked', () => {
const result = validateDesignEffects(
[
{ effectId: 'spell_manaBolt', stacks: 1 },
{ effectId: 'spell_fireball', stacks: 1 },
],
'basicStaff',
1,
['spell_manaBolt', 'spell_fireball']
);
expect(result.valid).toBe(true);
});
it('should accept when unlockedEffects is empty array and effects list is empty', () => {
const result = validateDesignEffects(
[],
'basicStaff',
1,
[]
);
// Empty effects list: no effects to gate, but base validation (enchanting level, equip type) still applies
expect(result.valid).toBe(true);
});
it('should default to empty unlockedEffects when not provided (backward compat)', () => {
// Without the 4th argument, all effects should be rejected
const result = validateDesignEffects(
[{ effectId: 'spell_manaBolt', stacks: 1 }],
'basicStaff',
1
);
expect(result.valid).toBe(false);
expect(result.reason).toContain('locked');
});
it('should still reject unknown effects even when unlocked', () => {
const result = validateDesignEffects(
[{ effectId: 'nonexistent_effect', stacks: 1 }],
'basicStaff',
1,
['nonexistent_effect']
);
expect(result.valid).toBe(false);
expect(result.reason).toContain('Unknown');
});
it('should still reject effects on wrong equipment category even when unlocked', () => {
// spell_manaBolt is only allowed on 'caster', not 'body'
const result = validateDesignEffects(
[{ effectId: 'spell_manaBolt', stacks: 1 }],
'civilianShirt', // body slot, category 'body'
1,
['spell_manaBolt']
);
expect(result.valid).toBe(false);
expect(result.reason).toContain('not allowed');
});
});
@@ -24,7 +24,8 @@ export function startDesigningEnchantment(
const validation = CraftingDesign.validateDesignEffects(
effects,
equipmentTypeId,
enchantingLevel
enchantingLevel,
state.unlockedEffects
);
if (!validation.valid) return false;
+7 -1
View File
@@ -18,13 +18,16 @@ const HASTY_ENCHANTER_BONUS_MULTIPLIER = 0.25;
export function validateDesignEffects(
effects: DesignEffect[],
equipmentTypeId: string,
enchantingLevel: number
enchantingLevel: number,
unlockedEffects: string[] = []
): { valid: boolean; reason?: string } {
if (enchantingLevel < 1) return { valid: false, reason: 'Requires enchanting skill level 1' };
const equipType = EQUIPMENT_TYPES[equipmentTypeId];
if (!equipType || !equipType.category) return { valid: false, reason: 'Invalid equipment type or category' };
const unlockedSet = new Set(unlockedEffects);
for (const eff of effects) {
const effectDef = ENCHANTMENT_EFFECTS[eff.effectId];
if (!effectDef) return { valid: false, reason: `Unknown effect: ${eff.effectId}` };
@@ -32,6 +35,9 @@ export function validateDesignEffects(
return { valid: false, reason: `Effect ${eff.effectId} not allowed on ${equipType.category}` };
}
if (eff.stacks > effectDef.maxStacks) return { valid: false, reason: `Stacks exceed maximum for ${eff.effectId}` };
if (!unlockedSet.has(eff.effectId)) {
return { valid: false, reason: `Effect ${eff.effectId} is locked — unlock via discipline perks` };
}
}
return { valid: true };
+1 -1
View File
@@ -37,7 +37,7 @@ export const useCraftingStore = create<CraftingStore>()(
startDesigningEnchantment: (name, equipmentTypeId, effects) => {
const state = get(); // crafting state
const validation = CraftingDesign.validateDesignEffects(effects, equipmentTypeId, 0);
const validation = CraftingDesign.validateDesignEffects(effects, equipmentTypeId, 0, state.unlockedEffects);
if (!validation.valid) return false;
const equipType = CraftingUtils.getEquipmentType(equipmentTypeId);