test: add unit tests for core game logic utilities
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m22s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m22s
Add 5 new test files covering pure utility functions: - discipline-math.test.ts (42 tests): stat bonus, mana drain, perk tiers, discipline activation/progression, unlocked perks, discipline stats - formatting.test.ts (35 tests): fmt, fmtDec, formatSpellCost, getSpellCostColor, formatStudyTime, formatHour - floor-utils.test.ts (13 tests): getFloorMaxHP, getFloorElement - combat-utils.test.ts (37 tests): getElementalBonus, getBoonBonuses, getIncursionStrength, canAffordSpellCost, deductSpellCost - mana-utils.test.ts (36 tests): computeMaxMana, computeRegen, computeClickMana, getMeditationBonus, computeEffectiveRegenForDisplay Total: 163 new tests, all passing. No existing tests broken.
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
calculateStatBonus,
|
||||
calculateManaDrain,
|
||||
calculatePerkTier,
|
||||
canActivateDiscipline,
|
||||
canProceedDiscipline,
|
||||
getUnlockedPerks,
|
||||
calculateDisciplineStats,
|
||||
} from '../utils/discipline-math';
|
||||
import type { DisciplineDefinition, DisciplineState } from '../types/disciplines';
|
||||
|
||||
// ─── Test Fixtures ────────────────────────────────────────────────────────────
|
||||
|
||||
const rawMastery: DisciplineDefinition = {
|
||||
id: 'raw-mastery',
|
||||
name: 'Raw Mana Mastery',
|
||||
attunement: 'base',
|
||||
manaType: 'raw',
|
||||
baseCost: 5,
|
||||
description: 'Learn to harness raw mana more efficiently.',
|
||||
statBonus: { stat: 'maxManaBonus', baseValue: 10 },
|
||||
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 elementalAttunement: DisciplineDefinition = {
|
||||
id: 'elemental-attunement',
|
||||
name: 'Elemental Attunement',
|
||||
attunement: 'base',
|
||||
manaType: 'fire',
|
||||
baseCost: 10,
|
||||
description: 'Begin focusing raw mana into fire.',
|
||||
statBonus: { stat: 'elementCap_fire', baseValue: 5 },
|
||||
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: 'base',
|
||||
manaType: 'raw',
|
||||
baseCost: 1,
|
||||
description: 'Test discipline with capped perk.',
|
||||
statBonus: { stat: 'testStat', baseValue: 1 },
|
||||
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(elementalAttunement, {
|
||||
elements: { fire: { unlocked: true } },
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when required element is locked', () => {
|
||||
const result = canActivateDiscipline(elementalAttunement, {
|
||||
elements: { fire: { unlocked: false } },
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return falsy when required element does not exist', () => {
|
||||
const result = canActivateDiscipline(elementalAttunement, {
|
||||
elements: {},
|
||||
});
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return falsy when elements is undefined', () => {
|
||||
const result = canActivateDiscipline(elementalAttunement, {});
|
||||
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 false when discipline is paused', () => {
|
||||
const state: DisciplineState = { id: 'raw-mastery', xp: 0, paused: true };
|
||||
const result = canProceedDiscipline(rawMastery, state, {
|
||||
rawMana: 1000,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
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 false when raw mana is insufficient', () => {
|
||||
const state: DisciplineState = { id: 'raw-mastery', xp: 10000, paused: false };
|
||||
const result = canProceedDiscipline(rawMastery, state, {
|
||||
rawMana: 0,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when element mana is sufficient', () => {
|
||||
const state: DisciplineState = { id: 'elemental-attunement', xp: 0, paused: false };
|
||||
const result = canProceedDiscipline(elementalAttunement, state, {
|
||||
elements: { fire: { current: 100, max: 100, unlocked: true } },
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when element mana is insufficient', () => {
|
||||
const state: DisciplineState = { id: 'elemental-attunement', xp: 10000, paused: false };
|
||||
const result = canProceedDiscipline(elementalAttunement, state, {
|
||||
elements: { fire: { current: 0, max: 100, unlocked: true } },
|
||||
});
|
||||
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, elementalAttunement];
|
||||
const states: DisciplineState[] = [
|
||||
{ id: 'raw-mastery', xp: 50, paused: false },
|
||||
{ id: 'elemental-attunement', 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, elementalAttunement];
|
||||
const states: DisciplineState[] = [
|
||||
{ id: 'raw-mastery', xp: 50, paused: false },
|
||||
{ id: 'elemental-attunement', 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, elementalAttunement];
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user