import { describe, it, expect } from 'vitest'; import { calculateStatBonus, calculateManaDrain, calculatePerkTier, canActivateDiscipline, canProceedDiscipline, getUnlockedPerks, calculateDisciplineStats, } from '../utils/discipline-math'; import { DisciplinesAttunementType } from '../types/disciplines'; import type { DisciplineDefinition, DisciplineState } from '../types/disciplines'; // ─── Test Fixtures ──────────────────────────────────────────────────────────── const rawMastery: DisciplineDefinition = { id: 'raw-mastery', name: 'Raw Mana Mastery', attunement: DisciplinesAttunementType.BASE, manaType: 'raw', baseCost: 5, description: 'Learn to harness raw mana more efficiently.', statBonus: { stat: 'maxManaBonus', baseValue: 10, label: 'Max Mana Bonus' }, difficultyFactor: 100, scalingFactor: 50, drainBase: 1, perks: [ { id: 'raw-mastery-1', type: 'once', threshold: 100, value: 0, description: '+50 Max Mana', }, { id: 'raw-mastery-2', type: 'infinite', threshold: 500, value: 100, description: 'Every 100 XP: +25 Max Mana', }, ], }; const attuneFire: DisciplineDefinition = { id: 'attune-fire', name: 'Fire Attunement', attunement: DisciplinesAttunementType.BASE, manaType: 'fire', baseCost: 10, description: 'Begin focusing raw mana into fire.', statBonus: { stat: 'elementCap_fire', baseValue: 5, label: 'Fire Element Cap' }, difficultyFactor: 150, scalingFactor: 75, drainBase: 2, perks: [ { id: 'elem-attunement-1', type: 'once', threshold: 200, value: 0, description: '+10 Fire Capacity', }, ], }; const cappedPerkDiscipline: DisciplineDefinition = { id: 'capped-test', name: 'Capped Perk Test', attunement: DisciplinesAttunementType.BASE, manaType: 'raw', baseCost: 1, description: 'Test discipline with capped perk.', statBonus: { stat: 'testStat', baseValue: 1, label: 'Test Stat' }, difficultyFactor: 100, scalingFactor: 100, drainBase: 1, perks: [ { id: 'capped-1', type: 'capped', threshold: 100, value: 50, description: 'Every 50 XP after 100: +1 tier', }, ], }; // ─── calculateStatBonus ─────────────────────────────────────────────────────── describe('calculateStatBonus', () => { it('should return 0 when xp is 0', () => { expect(calculateStatBonus(10, 0, 50)).toBe(0); }); it('should return 0 when xp is negative', () => { expect(calculateStatBonus(10, -10, 50)).toBe(0); }); it('should return baseValue when xp equals scalingFactor', () => { // ratio = 50/50 = 1, 1^0.65 = 1, bonus = 10 * 1 = 10 const result = calculateStatBonus(10, 50, 50); expect(result).toBeCloseTo(10, 5); }); it('should scale sub-linearly with xp', () => { const low = calculateStatBonus(10, 50, 50); const high = calculateStatBonus(10, 200, 50); // 4x xp should yield less than 4x bonus due to power 0.65 expect(high).toBeGreaterThan(low); expect(high).toBeLessThan(low * 4); }); it('should handle large xp values', () => { const result = calculateStatBonus(10, 10000, 50); expect(result).toBeGreaterThan(0); expect(isFinite(result)).toBe(true); }); it('should handle very small scaling factor', () => { const result = calculateStatBonus(10, 10, 1); expect(result).toBeGreaterThan(0); expect(isFinite(result)).toBe(true); }); }); // ─── calculateManaDrain ─────────────────────────────────────────────────────── describe('calculateManaDrain', () => { it('should return baseDrain when xp is 0', () => { expect(calculateManaDrain(1, 0, 100)).toBe(1); }); it('should return baseDrain when xp is negative', () => { expect(calculateManaDrain(1, -10, 100)).toBe(1); }); it('should increase drain with xp', () => { const base = calculateManaDrain(1, 0, 100); const withXp = calculateManaDrain(1, 100, 100); expect(withXp).toBeGreaterThan(base); }); it('should scale sub-linearly (power 0.4)', () => { const low = calculateManaDrain(1, 100, 100); const high = calculateManaDrain(1, 10000, 100); // 100x xp should yield less than 100x drain increase expect(high).toBeGreaterThan(low); expect(high - 1).toBeLessThan((low - 1) * 100); }); it('should handle large xp values', () => { const result = calculateManaDrain(1, 100000, 100); expect(result).toBeGreaterThan(1); expect(isFinite(result)).toBe(true); }); }); // ─── calculatePerkTier ──────────────────────────────────────────────────────── describe('calculatePerkTier', () => { it('should return 0 when xp is below threshold', () => { expect(calculatePerkTier(50, 100, 50)).toBe(0); }); it('should return 0 when xp equals threshold', () => { // excess = 0, floor(0/50) + 1 = 1, but xp < threshold is false // Actually: xp=100, threshold=100, excess=0, floor(0/50)+1 = 1 expect(calculatePerkTier(100, 100, 50)).toBe(1); }); it('should return 1 at threshold', () => { expect(calculatePerkTier(100, 100, 50)).toBe(1); }); it('should return 2 after one interval past threshold', () => { // xp=150, threshold=100, excess=50, floor(50/50)+1 = 2 expect(calculatePerkTier(150, 100, 50)).toBe(2); }); it('should return 0 for xp just below threshold', () => { expect(calculatePerkTier(99, 100, 50)).toBe(0); }); it('should handle zero xp', () => { expect(calculatePerkTier(0, 100, 50)).toBe(0); }); }); // ─── canActivateDiscipline ──────────────────────────────────────────────────── describe('canActivateDiscipline', () => { it('should return true for raw mana discipline regardless of elements', () => { const result = canActivateDiscipline(rawMastery, { elements: {} }); expect(result).toBe(true); }); it('should return true when required element is unlocked', () => { const result = canActivateDiscipline(attuneFire, { elements: { fire: { unlocked: true } }, }); expect(result).toBe(true); }); it('should return false when required element is locked', () => { const result = canActivateDiscipline(attuneFire, { elements: { fire: { unlocked: false } }, }); expect(result).toBe(false); }); it('should return falsy when required element does not exist', () => { const result = canActivateDiscipline(attuneFire, { elements: {}, }); expect(result).toBeFalsy(); }); it('should return falsy when elements is undefined', () => { const result = canActivateDiscipline(attuneFire, {}); expect(result).toBeFalsy(); }); }); // ─── canProceedDiscipline ───────────────────────────────────────────────────── describe('canProceedDiscipline', () => { it('should return true when no state provided (optimistic)', () => { const result = canProceedDiscipline(rawMastery, undefined, undefined); expect(result).toBe(true); }); it('should return true when discipline is paused (paused state is handled by activate, not canProceedDiscipline)', () => { const state: DisciplineState = { id: 'raw-mastery', xp: 0, paused: true }; const result = canProceedDiscipline(rawMastery, state, { rawMana: 1000, }); expect(result).toBe(true); }); it('should return true when raw mana is sufficient', () => { const state: DisciplineState = { id: 'raw-mastery', xp: 0, paused: false }; const result = canProceedDiscipline(rawMastery, state, { rawMana: 100, }); expect(result).toBe(true); }); it('should return true when raw mana is insufficient (no drain model)', () => { const state: DisciplineState = { id: 'raw-mastery', xp: 10000, paused: false }; const result = canProceedDiscipline(rawMastery, state, { rawMana: 0, }); expect(result).toBe(true); }); it('should return true when element mana is sufficient', () => { const state: DisciplineState = { id: 'attune-fire', xp: 0, paused: false }; const result = canProceedDiscipline(attuneFire, state, { elements: { fire: { current: 100, max: 100, unlocked: true } }, }); expect(result).toBe(true); }); it('should return true when element exists (no drain model)', () => { const state: DisciplineState = { id: 'attune-fire', xp: 10000, paused: false }; const result = canProceedDiscipline(attuneFire, state, { elements: { fire: { current: 0, max: 100, unlocked: true } }, }); expect(result).toBe(true); }); it('should return false when element does not exist in game state', () => { const state: DisciplineState = { id: 'attune-fire', xp: 10000, paused: false }; const result = canProceedDiscipline(attuneFire, state, { elements: {}, }); expect(result).toBe(false); }); it('should return true when no game state provided (optimistic)', () => { const state: DisciplineState = { id: 'raw-mastery', xp: 100, paused: false }; const result = canProceedDiscipline(rawMastery, state, undefined); expect(result).toBe(true); }); }); // ─── getUnlockedPerks ───────────────────────────────────────────────────────── describe('getUnlockedPerks', () => { it('should return empty array when no perks are unlocked', () => { const result = getUnlockedPerks(rawMastery, 0); expect(result).toEqual([]); }); it('should return once perk when threshold is met', () => { const result = getUnlockedPerks(rawMastery, 100); expect(result.length).toBe(1); expect(result[0].id).toBe('raw-mastery-1'); }); it('should return both once and infinite perks when both thresholds are met', () => { const result = getUnlockedPerks(rawMastery, 500); expect(result.length).toBe(2); expect(result.map(p => p.id)).toContain('raw-mastery-1'); expect(result.map(p => p.id)).toContain('raw-mastery-2'); }); it('should return all perks at high xp', () => { const result = getUnlockedPerks(rawMastery, 10000); expect(result.length).toBe(2); }); it('should return capped perk when threshold is met', () => { const result = getUnlockedPerks(cappedPerkDiscipline, 100); expect(result.length).toBe(1); expect(result[0].id).toBe('capped-1'); }); it('should return empty for xp just below threshold', () => { const result = getUnlockedPerks(rawMastery, 99); expect(result).toEqual([]); }); }); // ─── calculateDisciplineStats ───────────────────────────────────────────────── describe('calculateDisciplineStats', () => { it('should return empty stats for empty arrays', () => { const result = calculateDisciplineStats([], []); expect(result).toEqual({}); }); it('should return empty stats when all disciplines are paused', () => { const states: DisciplineState[] = [ { id: 'raw-mastery', xp: 1000, paused: true }, ]; const result = calculateDisciplineStats([rawMastery], states); expect(result).toEqual({}); }); it('should calculate stats for active discipline', () => { const states: DisciplineState[] = [ { id: 'raw-mastery', xp: 50, paused: false }, ]; const result = calculateDisciplineStats([rawMastery], states); expect(result.maxManaBonus).toBeGreaterThan(0); }); it('should sum stats from multiple active disciplines', () => { const disciplines = [rawMastery, attuneFire]; const states: DisciplineState[] = [ { id: 'raw-mastery', xp: 50, paused: false }, { id: 'attune-fire', xp: 75, paused: false }, ]; const result = calculateDisciplineStats(disciplines, states); expect(result.maxManaBonus).toBeGreaterThan(0); expect(result.elementCap_fire).toBeGreaterThan(0); }); it('should skip paused disciplines', () => { const disciplines = [rawMastery, attuneFire]; const states: DisciplineState[] = [ { id: 'raw-mastery', xp: 50, paused: false }, { id: 'attune-fire', xp: 75, paused: true }, ]; const result = calculateDisciplineStats(disciplines, states); expect(result.maxManaBonus).toBeGreaterThan(0); expect(result.elementCap_fire).toBeUndefined(); }); it('should handle missing state for a discipline', () => { const disciplines = [rawMastery, attuneFire]; const states: DisciplineState[] = [ { id: 'raw-mastery', xp: 50, paused: false }, ]; const result = calculateDisciplineStats(disciplines, states); expect(result.maxManaBonus).toBeGreaterThan(0); expect(result.elementCap_fire).toBeUndefined(); }); it('should return zero stat bonus for zero xp', () => { const states: DisciplineState[] = [ { id: 'raw-mastery', xp: 0, paused: false }, ]; const result = calculateDisciplineStats([rawMastery], states); expect(result.maxManaBonus).toBe(0); }); });