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
+1 -1
View File
@@ -1,5 +1,5 @@
# Circular Dependencies # Circular Dependencies
Generated: 2026-06-08T08:59:26.002Z Generated: 2026-06-08T09:22:18.219Z
Found: 1 circular chain(s) — these MUST be fixed before modifying involved files. Found: 1 circular chain(s) — these MUST be fixed before modifying involved files.
1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts 1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_meta": {
"generated": "2026-06-08T08:59:24.090Z", "generated": "2026-06-08T09:22:16.306Z",
"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."
}, },
+1
View File
@@ -216,6 +216,7 @@ Mana-Loop/
│ │ │ │ ├── cross-module-lifecycle-consistency.test.ts │ │ │ │ ├── cross-module-lifecycle-consistency.test.ts
│ │ │ │ ├── cross-module-prestige-discipline.test.ts │ │ │ │ ├── cross-module-prestige-discipline.test.ts
│ │ │ │ ├── curse-amplification.test.ts │ │ │ │ ├── curse-amplification.test.ts
│ │ │ │ ├── design-validation-perk-gating.test.ts
│ │ │ │ ├── discipline-math.test.ts │ │ │ │ ├── discipline-math.test.ts
│ │ │ │ ├── discipline-prerequisites.test.ts │ │ │ │ ├── discipline-prerequisites.test.ts
│ │ │ │ ├── discipline-reactivate-bug.test.ts │ │ │ │ ├── discipline-reactivate-bug.test.ts
@@ -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( const validation = CraftingDesign.validateDesignEffects(
effects, effects,
equipmentTypeId, equipmentTypeId,
enchantingLevel enchantingLevel,
state.unlockedEffects
); );
if (!validation.valid) return false; if (!validation.valid) return false;
+7 -1
View File
@@ -18,13 +18,16 @@ const HASTY_ENCHANTER_BONUS_MULTIPLIER = 0.25;
export function validateDesignEffects( export function validateDesignEffects(
effects: DesignEffect[], effects: DesignEffect[],
equipmentTypeId: string, equipmentTypeId: string,
enchantingLevel: number enchantingLevel: number,
unlockedEffects: string[] = []
): { valid: boolean; reason?: string } { ): { valid: boolean; reason?: string } {
if (enchantingLevel < 1) return { valid: false, reason: 'Requires enchanting skill level 1' }; if (enchantingLevel < 1) return { valid: false, reason: 'Requires enchanting skill level 1' };
const equipType = EQUIPMENT_TYPES[equipmentTypeId]; const equipType = EQUIPMENT_TYPES[equipmentTypeId];
if (!equipType || !equipType.category) return { valid: false, reason: 'Invalid equipment type or category' }; if (!equipType || !equipType.category) return { valid: false, reason: 'Invalid equipment type or category' };
const unlockedSet = new Set(unlockedEffects);
for (const eff of effects) { for (const eff of effects) {
const effectDef = ENCHANTMENT_EFFECTS[eff.effectId]; const effectDef = ENCHANTMENT_EFFECTS[eff.effectId];
if (!effectDef) return { valid: false, reason: `Unknown effect: ${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}` }; 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 (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 }; return { valid: true };
+1 -1
View File
@@ -37,7 +37,7 @@ export const useCraftingStore = create<CraftingStore>()(
startDesigningEnchantment: (name, equipmentTypeId, effects) => { startDesigningEnchantment: (name, equipmentTypeId, effects) => {
const state = get(); // crafting state 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; if (!validation.valid) return false;
const equipType = CraftingUtils.getEquipmentType(equipmentTypeId); const equipType = CraftingUtils.getEquipmentType(equipmentTypeId);