From 971b876537d049a6618d255a6b9bd3cff8e60aac Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Mon, 8 Jun 2026 11:39:43 +0200 Subject: [PATCH] fix: enforce discipline perk gating in enchantment design validation --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 2 +- docs/project-structure.txt | 1 + .../design-validation-perk-gating.test.ts | 101 ++++++++++++++++++ .../game/crafting-actions/design-actions.ts | 3 +- src/lib/game/crafting-design.ts | 8 +- src/lib/game/stores/craftingStore.ts | 2 +- 7 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 src/lib/game/__tests__/design-validation-perk-gating.test.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 6754282..566d2ce 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,5 +1,5 @@ # 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. 1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 9909fd5..81a49d1 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_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.", "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." }, diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 89397ab..27a9887 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -216,6 +216,7 @@ Mana-Loop/ │ │ │ │ ├── cross-module-lifecycle-consistency.test.ts │ │ │ │ ├── cross-module-prestige-discipline.test.ts │ │ │ │ ├── curse-amplification.test.ts +│ │ │ │ ├── design-validation-perk-gating.test.ts │ │ │ │ ├── discipline-math.test.ts │ │ │ │ ├── discipline-prerequisites.test.ts │ │ │ │ ├── discipline-reactivate-bug.test.ts diff --git a/src/lib/game/__tests__/design-validation-perk-gating.test.ts b/src/lib/game/__tests__/design-validation-perk-gating.test.ts new file mode 100644 index 0000000..cff4fdb --- /dev/null +++ b/src/lib/game/__tests__/design-validation-perk-gating.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'); + }); +}); diff --git a/src/lib/game/crafting-actions/design-actions.ts b/src/lib/game/crafting-actions/design-actions.ts index 4fb2227..e008c3d 100644 --- a/src/lib/game/crafting-actions/design-actions.ts +++ b/src/lib/game/crafting-actions/design-actions.ts @@ -24,7 +24,8 @@ export function startDesigningEnchantment( const validation = CraftingDesign.validateDesignEffects( effects, equipmentTypeId, - enchantingLevel + enchantingLevel, + state.unlockedEffects ); if (!validation.valid) return false; diff --git a/src/lib/game/crafting-design.ts b/src/lib/game/crafting-design.ts index a507c1c..0f2f28c 100644 --- a/src/lib/game/crafting-design.ts +++ b/src/lib/game/crafting-design.ts @@ -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 }; diff --git a/src/lib/game/stores/craftingStore.ts b/src/lib/game/stores/craftingStore.ts index 4ce9888..82ef3d0 100644 --- a/src/lib/game/stores/craftingStore.ts +++ b/src/lib/game/stores/craftingStore.ts @@ -37,7 +37,7 @@ export const useCraftingStore = create()( 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);