test: add unit tests for core game logic utilities
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:
2026-05-20 13:01:15 +02:00
parent cba42e01ff
commit a49b8a8bef
8 changed files with 1306 additions and 32 deletions
@@ -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);
});
});