fix: enforce discipline perk gating in enchantment design validation
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
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user