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:
@@ -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,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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user