3ad919a047
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m17s
DISC-2: Removed old pool-drain model from discipline-slice.ts processTick() - Disciplines no longer drain rawMana or element pools - canProceedDiscipline() no longer checks mana sufficiency - Removed auto-pause on insufficient mana from processTick() - Removed drain display and auto-paused message from DisciplineCard.tsx - Removed auto-paused log from gameStore.ts tick() DISC-4: Audited crafting pipeline — no composite crafting logic remains - craftComposite already removed from manaStore.ts (comment only) - No other composite crafting references found DISC-5: Added collapsible formula reference to Conversion Stats section - Shows unified formula, multipliers, cost formulas, and constraints DISC-6: Added per-element net regen summary line - Shows 'Net Fire Regen: +0.50/hr − 0.15/hr = +0.35/hr' per element DISC-7: Created dedicated 'Conversion Stats' section in Stats tab - Renamed from 'Conversion Breakdown' to dedicated section header DISC-8: Added detailed per-element regen breakdown to ManaDisplay - Each element card now expandable to show produced rate and downstream drains - New ElementRegenBreakdown type and elementRegenBreakdown prop Tests: Updated 4 test files to reflect new no-drain behavior - All 1090 tests pass
390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|