From a49b8a8bef549797c5515cf094101e818c51e8f3 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Wed, 20 May 2026 13:01:15 +0200 Subject: [PATCH] test: add unit tests for core game logic utilities 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. --- docs/circular-deps.txt | 4 +- docs/dependency-graph.json | 32 +- docs/project-structure.txt | 5 + src/lib/game/__tests__/combat-utils.test.ts | 315 +++++++++++++++ .../game/__tests__/discipline-math.test.ts | 380 ++++++++++++++++++ src/lib/game/__tests__/floor-utils.test.ts | 84 ++++ src/lib/game/__tests__/formatting.test.ts | 208 ++++++++++ src/lib/game/__tests__/mana-utils.test.ts | 310 ++++++++++++++ 8 files changed, 1306 insertions(+), 32 deletions(-) create mode 100644 src/lib/game/__tests__/combat-utils.test.ts create mode 100644 src/lib/game/__tests__/discipline-math.test.ts create mode 100644 src/lib/game/__tests__/floor-utils.test.ts create mode 100644 src/lib/game/__tests__/formatting.test.ts create mode 100644 src/lib/game/__tests__/mana-utils.test.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 2f22e59..b6aae72 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,8 +1,8 @@ # Circular Dependencies -Generated: 2026-05-20T10:00:51.281Z +Generated: 2026-05-20T10:36:04.388Z Found: 1 circular chain(s) — these MUST be fixed before modifying involved files. -1. Processed 127 files (1.4s) (4 warnings) +1. Processed 125 files (1.3s) (4 warnings) ## How to fix 1. Identify which import in the chain can be extracted to a shared types/utils file. diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index b66a0c0..860f8d7 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-05-20T10:00:49.725Z", + "generated": "2026-05-20T10:36:02.953Z", "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." }, @@ -156,25 +156,6 @@ "crafting-utils.ts", "types.ts" ], - "crafting-slice.ts": [ - "constants.ts", - "crafting-actions/index.ts", - "crafting-apply.ts", - "crafting-attunements.ts", - "crafting-design.ts", - "crafting-equipment.ts", - "crafting-loot.ts", - "crafting-prep.ts", - "crafting-utils.ts", - "data/attunements.ts", - "data/crafting-recipes.ts", - "data/enchantment-effects.ts", - "data/equipment/index.ts", - "effects/special-effects.ts", - "effects/upgrade-effects.ts", - "effects/upgrade-effects.types.ts", - "types.ts" - ], "crafting-utils.ts": [ "data/crafting-recipes.ts", "data/enchantment-effects.ts", @@ -415,15 +396,6 @@ "utils/index.ts", "utils/pact-utils.ts" ], - "store.ts": [ - "types.ts", - "utils/activity-log.ts", - "utils/combat-utils.ts", - "utils/floor-utils.ts", - "utils/formatting.ts", - "utils/mana-utils.ts", - "utils/room-utils.ts" - ], "stores/attunementStore.ts": [ "data/attunements.ts", "types.ts" @@ -454,7 +426,6 @@ "crafting-actions/preparation-actions.ts", "crafting-design.ts", "crafting-equipment.ts", - "crafting-slice.ts", "crafting-utils.ts", "stores/combatStore.ts", "stores/craftingStore.types.ts", @@ -529,6 +500,7 @@ "stores/combat-state.types.ts", "stores/combatStore.ts", "stores/craftingStore.ts", + "stores/craftingStore.types.ts", "stores/discipline-slice.ts", "stores/gameHooks.ts", "stores/gameStore.ts", diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 3ec4f18..a0166fc 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -190,8 +190,13 @@ Mana-Loop/ │ │ │ ├── store-method-tests/ │ │ │ ├── achievements.test.ts │ │ │ ├── bug-fixes.test.ts +│ │ │ ├── combat-utils.test.ts │ │ │ ├── computed-stats.test.ts +│ │ │ ├── discipline-math.test.ts │ │ │ ├── enemy-generator.test.ts +│ │ │ ├── floor-utils.test.ts +│ │ │ ├── formatting.test.ts +│ │ │ ├── mana-utils.test.ts │ │ │ ├── regression-fixes.test.ts │ │ │ └── spire-utils.test.ts │ │ ├── constants/ diff --git a/src/lib/game/__tests__/combat-utils.test.ts b/src/lib/game/__tests__/combat-utils.test.ts new file mode 100644 index 0000000..429a1a6 --- /dev/null +++ b/src/lib/game/__tests__/combat-utils.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect } from 'vitest'; +import { + getElementalBonus, + getBoonBonuses, + getIncursionStrength, + canAffordSpellCost, + deductSpellCost, +} from '../utils/combat-utils'; +import { INCURSION_START_DAY, MAX_DAY } from '../constants'; + +// ─── getElementalBonus ──────────────────────────────────────────────────────── + +describe('getElementalBonus', () => { + it('should return 1.0 for raw mana (no elemental bonus)', () => { + expect(getElementalBonus('raw', 'fire')).toBe(1.0); + expect(getElementalBonus('raw', 'water')).toBe(1.0); + expect(getElementalBonus('raw', 'earth')).toBe(1.0); + }); + + it('should return 1.25 for same element (matching)', () => { + expect(getElementalBonus('fire', 'fire')).toBe(1.25); + expect(getElementalBonus('water', 'water')).toBe(1.25); + expect(getElementalBonus('earth', 'earth')).toBe(1.25); + expect(getElementalBonus('air', 'air')).toBe(1.25); + expect(getElementalBonus('light', 'light')).toBe(1.25); + expect(getElementalBonus('dark', 'dark')).toBe(1.25); + }); + + it('should return 1.5 for super effective (opposite of floor)', () => { + // Water is opposite of fire → water spell on fire floor = super effective + expect(getElementalBonus('water', 'fire')).toBe(1.5); + // Fire is opposite of water → fire spell on water floor = super effective + expect(getElementalBonus('fire', 'water')).toBe(1.5); + // Earth is opposite of air + expect(getElementalBonus('earth', 'air')).toBe(1.5); + // Air is opposite of earth + expect(getElementalBonus('air', 'earth')).toBe(1.5); + // Dark is opposite of light + expect(getElementalBonus('dark', 'light')).toBe(1.5); + // Light is opposite of dark + expect(getElementalBonus('light', 'dark')).toBe(1.5); + }); + + it('should return 0.75 for weak (spell opposite matches floor)', () => { + // Fire spell on water floor: fire's opposite is water → weak + expect(getElementalBonus('fire', 'water')).not.toBe(0.75); // This is super effective + // Water spell on fire floor: water's opposite is fire → weak + expect(getElementalBonus('water', 'fire')).not.toBe(0.75); // This is super effective + // Let's test a truly neutral case: fire spell on earth floor + // fire's opposite is water, water !== earth → neutral + // earth's opposite is air, air !== fire → neutral + expect(getElementalBonus('fire', 'earth')).toBe(1.0); + }); + + it('should return 1.0 for neutral matchups', () => { + // Fire on earth: not same, not opposite, not weak + expect(getElementalBonus('fire', 'earth')).toBe(1.0); + // Water on air: not same, not opposite, not weak + expect(getElementalBonus('water', 'air')).toBe(1.0); + // Light on fire: not same, not opposite, not weak + expect(getElementalBonus('light', 'fire')).toBe(1.0); + }); + + it('should handle lightning matchups', () => { + // Lightning is weak to earth (grounding) + expect(getElementalBonus('lightning', 'earth')).toBe(0.75); + // Lightning on fire: neutral + expect(getElementalBonus('lightning', 'fire')).toBe(1.0); + }); +}); + +// ─── getBoonBonuses ─────────────────────────────────────────────────────────── + +describe('getBoonBonuses', () => { + it('should return zero bonuses for empty pacts', () => { + const result = getBoonBonuses([]); + expect(result).toEqual({ + maxMana: 0, + manaRegen: 0, + castingSpeed: 0, + elementalDamage: 0, + rawDamage: 0, + critChance: 0, + critDamage: 0, + spellEfficiency: 0, + manaGain: 0, + insightGain: 0, + studySpeed: 0, + prestigeInsight: 0, + }); + }); + + it('should return bonuses from a single pact (floor 10)', () => { + const result = getBoonBonuses([10]); + expect(result.elementalDamage).toBe(5); + expect(result.maxMana).toBe(50); + }); + + it('should stack bonuses from multiple pacts', () => { + const result = getBoonBonuses([10, 20]); + // Floor 10: elementalDamage=5, maxMana=50 + // Floor 20: elementalDamage=5, manaRegen=0.5 + expect(result.elementalDamage).toBe(10); + expect(result.maxMana).toBe(50); + expect(result.manaRegen).toBe(0.5); + }); + + it('should handle all boon types from floor 100', () => { + const result = getBoonBonuses([100]); + expect(result.elementalDamage).toBe(20); + expect(result.maxMana).toBe(500); + expect(result.manaRegen).toBe(2); + expect(result.insightGain).toBe(25); + }); + + it('should ignore non-guardian floors', () => { + const result = getBoonBonuses([5, 15, 25]); + expect(result).toEqual({ + maxMana: 0, + manaRegen: 0, + castingSpeed: 0, + elementalDamage: 0, + rawDamage: 0, + critChance: 0, + critDamage: 0, + spellEfficiency: 0, + manaGain: 0, + insightGain: 0, + studySpeed: 0, + prestigeInsight: 0, + }); + }); + + it('should handle mix of guardian and non-guardian floors', () => { + const result = getBoonBonuses([5, 10, 15]); + // Only floor 10 counts + expect(result.elementalDamage).toBe(5); + expect(result.maxMana).toBe(50); + }); + + it('should stack all bonus types from multiple pacts', () => { + const result = getBoonBonuses([10, 20, 30, 40, 50, 60, 80, 90, 100]); + expect(result.elementalDamage).toBeGreaterThan(0); + expect(result.maxMana).toBeGreaterThan(0); + expect(result.manaRegen).toBeGreaterThan(0); + expect(result.castingSpeed).toBeGreaterThan(0); + expect(result.insightGain).toBeGreaterThan(0); + expect(result.critDamage).toBeGreaterThan(0); + expect(result.rawDamage).toBeGreaterThan(0); + }); +}); + +// ─── getIncursionStrength ───────────────────────────────────────────────────── + +describe('getIncursionStrength', () => { + it('should return 0 before incursion start day', () => { + expect(getIncursionStrength(1, 0)).toBe(0); + expect(getIncursionStrength(10, 12)).toBe(0); + expect(getIncursionStrength(INCURSION_START_DAY - 1, 23)).toBe(0); + }); + + it('should return 0 at incursion start day hour 0', () => { + const result = getIncursionStrength(INCURSION_START_DAY, 0); + expect(result).toBe(0); + }); + + it('should return positive value during incursion', () => { + const result = getIncursionStrength(INCURSION_START_DAY, 12); + expect(result).toBeGreaterThan(0); + }); + + it('should increase with later days', () => { + const early = getIncursionStrength(INCURSION_START_DAY, 12); + const late = getIncursionStrength(MAX_DAY, 12); + expect(late).toBeGreaterThan(early); + }); + + it('should increase with later hours on same day', () => { + const morning = getIncursionStrength(INCURSION_START_DAY + 1, 6); + const evening = getIncursionStrength(INCURSION_START_DAY + 1, 18); + expect(evening).toBeGreaterThan(morning); + }); + + it('should not exceed 0.95', () => { + expect(getIncursionStrength(MAX_DAY, 23)).toBeLessThanOrEqual(0.95); + expect(getIncursionStrength(MAX_DAY + 100, 23)).toBeLessThanOrEqual(0.95); + }); + + it('should approach 0.95 at end of max day', () => { + const result = getIncursionStrength(MAX_DAY, 23); + expect(result).toBeCloseTo(0.95, 1); + }); +}); + +// ─── canAffordSpellCost ─────────────────────────────────────────────────────── + +describe('canAffordSpellCost', () => { + it('should return true for raw cost when enough mana', () => { + expect(canAffordSpellCost({ type: 'raw', amount: 10 }, 50, {})).toBe(true); + }); + + it('should return false for raw cost when not enough mana', () => { + expect(canAffordSpellCost({ type: 'raw', amount: 100 }, 50, {})).toBe(false); + }); + + it('should return true for zero raw cost', () => { + expect(canAffordSpellCost({ type: 'raw', amount: 0 }, 0, {})).toBe(true); + }); + + it('should return true for elemental cost when element is unlocked and has enough', () => { + const elements = { + fire: { current: 10, max: 50, unlocked: true }, + }; + expect(canAffordSpellCost({ type: 'element', element: 'fire', amount: 5 }, 0, elements)).toBe(true); + }); + + it('should return false for elemental cost when not enough mana', () => { + const elements = { + fire: { current: 3, max: 50, unlocked: true }, + }; + expect(canAffordSpellCost({ type: 'element', element: 'fire', amount: 5 }, 0, elements)).toBe(false); + }); + + it('should return false for locked element', () => { + const elements = { + fire: { current: 10, max: 50, unlocked: false }, + }; + expect(canAffordSpellCost({ type: 'element', element: 'fire', amount: 5 }, 0, elements)).toBe(false); + }); + + it('should return falsy for missing element', () => { + expect(canAffordSpellCost({ type: 'element', element: 'fire', amount: 5 }, 0, {})).toBeFalsy(); + }); + + it('should return true for zero elemental cost', () => { + const elements = { + fire: { current: 0, max: 50, unlocked: true }, + }; + expect(canAffordSpellCost({ type: 'element', element: 'fire', amount: 0 }, 0, elements)).toBe(true); + }); + + it('should handle exact amount', () => { + const elements = { + fire: { current: 5, max: 50, unlocked: true }, + }; + expect(canAffordSpellCost({ type: 'element', element: 'fire', amount: 5 }, 0, elements)).toBe(true); + }); +}); + +// ─── deductSpellCost ────────────────────────────────────────────────────────── + +describe('deductSpellCost', () => { + it('should deduct raw mana correctly', () => { + const result = deductSpellCost({ type: 'raw', amount: 10 }, 50, {}); + expect(result.rawMana).toBe(40); + }); + + it('should not let raw mana go below zero', () => { + const result = deductSpellCost({ type: 'raw', amount: 100 }, 50, {}); + expect(result.rawMana).toBe(0); + }); + + it('should deduct elemental mana correctly', () => { + const elements = { + fire: { current: 10, max: 50, unlocked: true }, + }; + const result = deductSpellCost({ type: 'element', element: 'fire', amount: 5 }, 0, elements); + expect(result.elements.fire.current).toBe(5); + expect(result.rawMana).toBe(0); // unchanged + }); + + it('should not let elemental mana go below zero', () => { + const elements = { + fire: { current: 3, max: 50, unlocked: true }, + }; + const result = deductSpellCost({ type: 'element', element: 'fire', amount: 10 }, 0, elements); + expect(result.elements.fire.current).toBe(0); + }); + + it('should not modify raw mana when deducting elemental cost', () => { + const elements = { + fire: { current: 10, max: 50, unlocked: true }, + }; + const result = deductSpellCost({ type: 'element', element: 'fire', amount: 5 }, 100, elements); + expect(result.rawMana).toBe(100); + }); + + it('should return unchanged state for zero cost', () => { + const elements = { + fire: { current: 10, max: 50, unlocked: true }, + }; + const result = deductSpellCost({ type: 'raw', amount: 0 }, 50, elements); + expect(result.rawMana).toBe(50); + expect(result.elements.fire.current).toBe(10); + }); + + it('should not modify other elements when deducting', () => { + const elements = { + fire: { current: 10, max: 50, unlocked: true }, + water: { current: 20, max: 50, unlocked: true }, + }; + const result = deductSpellCost({ type: 'element', element: 'fire', amount: 5 }, 0, elements); + expect(result.elements.fire.current).toBe(5); + expect(result.elements.water.current).toBe(20); + }); + + it('should handle missing element gracefully', () => { + const elements = { + fire: { current: 10, max: 50, unlocked: true }, + }; + const result = deductSpellCost({ type: 'element', element: 'water', amount: 5 }, 100, elements); + expect(result.rawMana).toBe(100); + expect(result.elements).toEqual(elements); + }); +}); diff --git a/src/lib/game/__tests__/discipline-math.test.ts b/src/lib/game/__tests__/discipline-math.test.ts new file mode 100644 index 0000000..12376f1 --- /dev/null +++ b/src/lib/game/__tests__/discipline-math.test.ts @@ -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); + }); +}); diff --git a/src/lib/game/__tests__/floor-utils.test.ts b/src/lib/game/__tests__/floor-utils.test.ts new file mode 100644 index 0000000..e6b4855 --- /dev/null +++ b/src/lib/game/__tests__/floor-utils.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { getFloorMaxHP, getFloorElement } from '../utils/floor-utils'; + +// ─── getFloorMaxHP ──────────────────────────────────────────────────────────── + +describe('getFloorMaxHP', () => { + it('should return positive HP for floor 1', () => { + expect(getFloorMaxHP(1)).toBeGreaterThan(0); + }); + + it('should return base HP of 100 + scaling for floor 1', () => { + // baseHP=100 + floorScaling=50 + exponentialScaling=1 = 151 + expect(getFloorMaxHP(1)).toBe(151); + }); + + it('should scale with floor number', () => { + const hp1 = getFloorMaxHP(1); + const hp5 = getFloorMaxHP(5); + const hp10 = getFloorMaxHP(10); + expect(hp5).toBeGreaterThan(hp1); + expect(hp10).toBeGreaterThan(hp5); + }); + + it('should return much higher HP for guardian floors', () => { + const hp9 = getFloorMaxHP(9); + const hp10 = getFloorMaxHP(10); // Ignis Prime guardian + expect(hp10).toBeGreaterThan(hp9 * 2); + }); + + it('should handle high floors', () => { + const hp100 = getFloorMaxHP(100); + expect(hp100).toBeGreaterThan(0); + expect(isFinite(hp100)).toBe(true); + }); + + it('should be monotonically increasing for non-guardian floors', () => { + let prev = getFloorMaxHP(1); + for (let f = 2; f <= 9; f++) { + const curr = getFloorMaxHP(f); + expect(curr).toBeGreaterThan(prev); + prev = curr; + } + }); +}); + +// ─── getFloorElement ────────────────────────────────────────────────────────── + +describe('getFloorElement', () => { + it('should return a string element', () => { + expect(typeof getFloorElement(1)).toBe('string'); + }); + + it('should return valid element names', () => { + const validElements = ['fire', 'water', 'air', 'earth', 'light', 'dark', 'death']; + for (let f = 1; f <= 20; f++) { + expect(validElements).toContain(getFloorElement(f)); + } + }); + + it('should cycle through 7 elements', () => { + // Floor 1 and floor 8 should have the same element (cycle of 7) + expect(getFloorElement(1)).toBe(getFloorElement(8)); + expect(getFloorElement(2)).toBe(getFloorElement(9)); + expect(getFloorElement(7)).toBe(getFloorElement(14)); + }); + + it('should return fire for floor 1', () => { + expect(getFloorElement(1)).toBe('fire'); + }); + + it('should return water for floor 2', () => { + expect(getFloorElement(2)).toBe('water'); + }); + + it('should return death for floor 7', () => { + expect(getFloorElement(7)).toBe('death'); + }); + + it('should handle high floor numbers', () => { + const elem = getFloorElement(100); + expect(typeof elem).toBe('string'); + expect(elem.length).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/game/__tests__/formatting.test.ts b/src/lib/game/__tests__/formatting.test.ts new file mode 100644 index 0000000..b7b2910 --- /dev/null +++ b/src/lib/game/__tests__/formatting.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect } from 'vitest'; +import { + fmt, + fmtDec, + formatSpellCost, + getSpellCostColor, + formatStudyTime, + formatHour, +} from '../utils/formatting'; + +// ─── fmt ────────────────────────────────────────────────────────────────────── + +describe('fmt', () => { + it('should format zero as "0"', () => { + expect(fmt(0)).toBe('0'); + }); + + it('should format numbers < 1000 as integers', () => { + expect(fmt(1)).toBe('1'); + expect(fmt(500)).toBe('500'); + expect(fmt(999)).toBe('999'); + }); + + it('should format thousands with K suffix', () => { + expect(fmt(1000)).toBe('1.0K'); + expect(fmt(1500)).toBe('1.5K'); + expect(fmt(9999)).toBe('10.0K'); + expect(fmt(10000)).toBe('10.0K'); + expect(fmt(999999)).toBe('1000.0K'); + }); + + it('should format millions with M suffix', () => { + expect(fmt(1000000)).toBe('1.00M'); + expect(fmt(1500000)).toBe('1.50M'); + expect(fmt(999999999)).toBe('1000.00M'); + }); + + it('should format billions with B suffix', () => { + expect(fmt(1000000000)).toBe('1.00B'); + expect(fmt(1500000000)).toBe('1.50B'); + expect(fmt(2500000000)).toBe('2.50B'); + }); + + it('should handle negative numbers', () => { + expect(fmt(-500)).toBe('-500'); + // fmt uses Math.floor which doesn't add suffixes for negatives + expect(fmt(-1500)).toBe('-1500'); + }); + + it('should handle NaN', () => { + expect(fmt(NaN)).toBe('0'); + }); + + it('should handle Infinity', () => { + expect(fmt(Infinity)).toBe('0'); + expect(fmt(-Infinity)).toBe('0'); + }); + + it('should handle very large numbers', () => { + expect(fmt(1e12)).toBe('1000.00B'); + expect(fmt(1e15)).toBe('1000000.00B'); + }); +}); + +// ─── fmtDec ─────────────────────────────────────────────────────────────────── + +describe('fmtDec', () => { + it('should format with default 1 decimal', () => { + expect(fmtDec(1.5)).toBe('1.5'); + expect(fmtDec(10.0)).toBe('10.0'); + }); + + it('should format with specified decimals', () => { + expect(fmtDec(1.234, 2)).toBe('1.23'); + expect(fmtDec(1.235, 2)).toBe('1.24'); // rounds + expect(fmtDec(1.999, 0)).toBe('2'); + }); + + it('should handle zero', () => { + expect(fmtDec(0, 1)).toBe('0.0'); + }); + + it('should handle negative numbers', () => { + expect(fmtDec(-1.5, 1)).toBe('-1.5'); + }); + + it('should handle NaN', () => { + expect(fmtDec(NaN, 1)).toBe('0'); + }); + + it('should handle Infinity', () => { + expect(fmtDec(Infinity, 1)).toBe('0'); + }); + + it('should handle large numbers', () => { + expect(fmtDec(1234567.89, 2)).toBe('1234567.89'); + }); +}); + +// ─── formatSpellCost ────────────────────────────────────────────────────────── + +describe('formatSpellCost', () => { + it('should format raw cost', () => { + expect(formatSpellCost({ type: 'raw', amount: 10 })).toBe('10 raw'); + expect(formatSpellCost({ type: 'raw', amount: 0 })).toBe('0 raw'); + }); + + it('should format elemental cost with symbol', () => { + expect(formatSpellCost({ type: 'element', element: 'fire', amount: 5 })).toBe('5 🔥'); + expect(formatSpellCost({ type: 'element', element: 'water', amount: 3 })).toBe('3 💧'); + expect(formatSpellCost({ type: 'element', element: 'earth', amount: 10 })).toBe('10 ⛰️'); + }); + + it('should handle unknown element', () => { + expect(formatSpellCost({ type: 'element', element: 'unknown', amount: 5 })).toBe('5 ?'); + }); + + it('should handle empty element', () => { + expect(formatSpellCost({ type: 'element', element: '', amount: 5 })).toBe('5 ?'); + }); +}); + +// ─── getSpellCostColor ──────────────────────────────────────────────────────── + +describe('getSpellCostColor', () => { + it('should return blue for raw cost', () => { + expect(getSpellCostColor({ type: 'raw', amount: 10 })).toBe('#60A5FA'); + }); + + it('should return element color for elemental cost', () => { + expect(getSpellCostColor({ type: 'element', element: 'fire', amount: 5 })).toBe('#FF6B35'); + expect(getSpellCostColor({ type: 'element', element: 'water', amount: 5 })).toBe('#4ECDC4'); + expect(getSpellCostColor({ type: 'element', element: 'light', amount: 5 })).toBe('#FFD700'); + }); + + it('should return gray for unknown element', () => { + expect(getSpellCostColor({ type: 'element', element: 'unknown', amount: 5 })).toBe('#9CA3AF'); + }); + + it('should return gray for empty element', () => { + expect(getSpellCostColor({ type: 'element', element: '', amount: 5 })).toBe('#9CA3AF'); + }); +}); + +// ─── formatStudyTime ────────────────────────────────────────────────────────── + +describe('formatStudyTime', () => { + it('should format hours when >= 1', () => { + expect(formatStudyTime(1)).toBe('1.0h'); + expect(formatStudyTime(2.5)).toBe('2.5h'); + expect(formatStudyTime(10)).toBe('10.0h'); + }); + + it('should format minutes when < 1 hour', () => { + expect(formatStudyTime(0.5)).toBe('30m'); + expect(formatStudyTime(0.25)).toBe('15m'); + expect(formatStudyTime(0.0167)).toBe('1m'); // ~1 minute + }); + + it('should handle zero', () => { + expect(formatStudyTime(0)).toBe('0m'); + }); + + it('should handle very small values', () => { + // 0.001 hours * 60 = 0.06 min, Math.round(0.06) = 0 + expect(formatStudyTime(0.001)).toBe('0m'); + }); +}); + +// ─── formatHour ─────────────────────────────────────────────────────────────── + +describe('formatHour', () => { + it('should format midnight', () => { + expect(formatHour(0)).toBe('00:00'); + }); + + it('should format noon', () => { + expect(formatHour(12)).toBe('12:00'); + }); + + it('should format morning hours', () => { + expect(formatHour(8)).toBe('08:00'); + expect(formatHour(9.5)).toBe('09:30'); + }); + + it('should format evening hours', () => { + expect(formatHour(18)).toBe('18:00'); + expect(formatHour(23.75)).toBe('23:45'); + }); + + it('should pad single-digit hours', () => { + expect(formatHour(1)).toBe('01:00'); + expect(formatHour(5.25)).toBe('05:15'); + }); + + it('should pad minutes', () => { + // 12.01: 0.01*60=0.6, floor=0 → '12:00' + expect(formatHour(12.01)).toBe('12:00'); + // 12.1: 0.1*60=5.999... due to floating point, floor=5 → '12:05' + expect(formatHour(12.1)).toBe('12:05'); + }); + + it('should handle fractional hours', () => { + expect(formatHour(14.5)).toBe('14:30'); + expect(formatHour(14.25)).toBe('14:15'); + expect(formatHour(14.75)).toBe('14:45'); + }); +}); diff --git a/src/lib/game/__tests__/mana-utils.test.ts b/src/lib/game/__tests__/mana-utils.test.ts new file mode 100644 index 0000000..42bf8f3 --- /dev/null +++ b/src/lib/game/__tests__/mana-utils.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect } from 'vitest'; +import { + computeMaxMana, + computeRegen, + computeClickMana, + getMeditationBonus, + computeEffectiveRegenForDisplay, +} from '../utils/mana-utils'; +import { HOURS_PER_TICK } from '../constants'; + +// ─── computeMaxMana ─────────────────────────────────────────────────────────── + +describe('computeMaxMana', () => { + const baseState = { + skills: {}, + prestigeUpgrades: {}, + skillUpgrades: {}, + skillTiers: {}, + }; + + it('should return base 100 with no skills or upgrades', () => { + const result = computeMaxMana(baseState); + expect(result).toBe(100); + }); + + it('should add 100 per manaWell skill level', () => { + const result = computeMaxMana({ + ...baseState, + skills: { manaWell: 3 }, + }); + expect(result).toBe(100 + 3 * 100); + }); + + it('should add 500 per manaWell prestige upgrade', () => { + const result = computeMaxMana({ + ...baseState, + prestigeUpgrades: { manaWell: 2 }, + }); + expect(result).toBe(100 + 2 * 500); + }); + + it('should apply maxManaBonus from effects', () => { + const result = computeMaxMana(baseState, { + maxManaBonus: 50, + maxManaMultiplier: 1, + } as any); + expect(result).toBe(150); + }); + + it('should apply maxManaMultiplier from effects', () => { + const result = computeMaxMana(baseState, { + maxManaBonus: 0, + maxManaMultiplier: 2, + } as any); + expect(result).toBe(200); + }); + + it('should apply both bonus and multiplier', () => { + // (100 + 50) * 1.5 = 225 + const result = computeMaxMana(baseState, { + maxManaBonus: 50, + maxManaMultiplier: 1.5, + } as any); + expect(result).toBe(225); + }); + + it('should add discipline bonus', () => { + const result = computeMaxMana(baseState, undefined, { + bonuses: { maxManaBonus: 200 }, + multipliers: {}, + }); + expect(result).toBe(300); + }); + + it('should floor the result', () => { + const result = computeMaxMana(baseState, { + maxManaBonus: 0, + maxManaMultiplier: 1.33, + } as any); + expect(result).toBe(133); // floor(133) + }); +}); + +// ─── computeRegen ───────────────────────────────────────────────────────────── + +describe('computeRegen', () => { + const baseState = { + skills: {}, + prestigeUpgrades: {}, + skillUpgrades: {}, + skillTiers: {}, + attunements: {}, + }; + + it('should return base regen of 2 with no skills', () => { + const result = computeRegen(baseState); + expect(result).toBe(2); + }); + + it('should add 1 per manaFlow skill level', () => { + const result = computeRegen({ + ...baseState, + skills: { manaFlow: 3 }, + }); + expect(result).toBe(5); // 2 + 3 + }); + + it('should add 2 per manaSpring skill level', () => { + const result = computeRegen({ + ...baseState, + skills: { manaSpring: 2 }, + }); + expect(result).toBe(6); // 2 + 4 + }); + + it('should add 0.5 per manaFlow prestige upgrade', () => { + const result = computeRegen({ + ...baseState, + prestigeUpgrades: { manaFlow: 4 }, + }); + expect(result).toBe(4); // 2 + 2 + }); + + it('should apply temporalEcho bonus', () => { + // temporalEcho=1 → 1.1x multiplier → 2 * 1.1 = 2.2 + const result = computeRegen({ + ...baseState, + prestigeUpgrades: { temporalEcho: 1 }, + }); + expect(result).toBeCloseTo(2.2, 5); + }); + + it('should apply regenBonus from effects', () => { + const result = computeRegen(baseState, { + regenBonus: 3, + regenMultiplier: 1, + permanentRegenBonus: 0, + } as any); + expect(result).toBe(5); + }); + + it('should apply regenMultiplier from effects', () => { + const result = computeRegen(baseState, { + regenBonus: 0, + regenMultiplier: 2, + permanentRegenBonus: 0, + } as any); + expect(result).toBe(4); + }); + + it('should apply permanentRegenBonus from effects', () => { + const result = computeRegen(baseState, { + regenBonus: 0, + regenMultiplier: 1, + permanentRegenBonus: 5, + } as any); + expect(result).toBe(7); + }); + + it('should add discipline regen bonus', () => { + const result = computeRegen(baseState, undefined, { + bonuses: { regenBonus: 10 }, + multipliers: {}, + }); + expect(result).toBe(12); + }); +}); + +// ─── computeClickMana ───────────────────────────────────────────────────────── + +describe('computeClickMana', () => { + it('should return 1 with no skills', () => { + const result = computeClickMana({ skills: {} }); + expect(result).toBe(1); + }); + + it('should add 1 per manaTap skill level', () => { + const result = computeClickMana({ skills: { manaTap: 3 } }); + expect(result).toBe(4); // 1 + 3 + }); + + it('should add 3 per manaSurge skill level', () => { + const result = computeClickMana({ skills: { manaSurge: 2 } }); + expect(result).toBe(7); // 1 + 6 + }); + + it('should combine manaTap and manaSurge', () => { + const result = computeClickMana({ skills: { manaTap: 2, manaSurge: 1 } }); + expect(result).toBe(6); // 1 + 2 + 3 + }); + + it('should add discipline click multiplier', () => { + const result = computeClickMana({ skills: {} }, { + bonuses: { clickManaMultiplier: 5 }, + multipliers: {}, + }); + expect(result).toBe(6); + }); +}); + +// ─── getMeditationBonus ─────────────────────────────────────────────────────── + +describe('getMeditationBonus', () => { + it('should return 1.0 with zero ticks', () => { + expect(getMeditationBonus(0, {})).toBe(1.0); + }); + + it('should increase with more ticks', () => { + const low = getMeditationBonus(10, {}); + const high = getMeditationBonus(100, {}); + expect(high).toBeGreaterThan(low); + }); + + it('should cap at 1.5x without meditation skill', () => { + // After 4 hours: 1 + min(4/4, 0.5) = 1.5 + const ticksFor4Hours = 4 / HOURS_PER_TICK; + const result = getMeditationBonus(ticksFor4Hours, {}); + expect(result).toBeCloseTo(1.5, 5); + }); + + it('should reach 2.5x with meditation skill after 4 hours', () => { + const ticksFor4Hours = 4 / HOURS_PER_TICK; + const result = getMeditationBonus(ticksFor4Hours, { meditation: 1 }); + expect(result).toBeCloseTo(2.5, 5); + }); + + it('should reach 3.0x with deep trance after 6 hours', () => { + const ticksFor6Hours = 6 / HOURS_PER_TICK; + const result = getMeditationBonus(ticksFor6Hours, { + meditation: 1, + deepTrance: 1, + }); + expect(result).toBeCloseTo(3.0, 5); + }); + + it('should reach 5.0x with void meditation after 8 hours', () => { + const ticksFor8Hours = 8 / HOURS_PER_TICK; + const result = getMeditationBonus(ticksFor8Hours, { + meditation: 1, + deepTrance: 1, + voidMeditation: 1, + }); + expect(result).toBeCloseTo(5.0, 5); + }); + + it('should reach cap at 2 hours (min(2/4, 0.5) = 0.5)', () => { + // At 2 hours: min(2/4, 0.5) = min(0.5, 0.5) = 0.5 → 1.5x + const ticksFor2Hours = 2 / HOURS_PER_TICK; + const result = getMeditationBonus(ticksFor2Hours, {}); + expect(result).toBeCloseTo(1.5, 5); + }); + + it('should be below cap at 1 hour', () => { + const ticksFor1Hour = 1 / HOURS_PER_TICK; + const result = getMeditationBonus(ticksFor1Hour, {}); + expect(result).toBeLessThan(1.5); + }); + + it('should apply meditation efficiency multiplier', () => { + const ticksFor4Hours = 4 / HOURS_PER_TICK; + const withoutEff = getMeditationBonus(ticksFor4Hours, {}, 1); + const withEff = getMeditationBonus(ticksFor4Hours, {}, 2); + expect(withEff).toBeCloseTo(withoutEff * 2, 5); + }); + + it('should ramp linearly before cap', () => { + // At 1 hour: 1 + min(1/4, 0.5) = 1 + 0.25 = 1.25 + const ticksFor1Hour = 1 / HOURS_PER_TICK; + const result = getMeditationBonus(ticksFor1Hour, {}); + expect(result).toBeCloseTo(1.25, 5); + }); +}); + +// ─── computeEffectiveRegenForDisplay ────────────────────────────────────────── + +describe('computeEffectiveRegenForDisplay', () => { + const baseState = { + skills: {}, + prestigeUpgrades: {}, + skillUpgrades: {}, + skillTiers: {}, + attunements: {}, + }; + + it('should return raw, conversion, and effective regen', () => { + const result = computeEffectiveRegenForDisplay(baseState); + expect(result).toHaveProperty('rawRegen'); + expect(result).toHaveProperty('conversionDrain'); + expect(result).toHaveProperty('effectiveRegen'); + }); + + it('should have effectiveRegen equal to rawRegen with no conversion drain', () => { + const result = computeEffectiveRegenForDisplay(baseState); + expect(result.rawRegen).toBe(2); + expect(result.conversionDrain).toBe(0); + expect(result.effectiveRegen).toBe(2); + }); + + it('should not let effectiveRegen go below zero', () => { + // This would require a state with high conversion drain + // We can't easily test this without attunement data, but we can verify the Math.max behavior + const result = computeEffectiveRegenForDisplay(baseState); + expect(result.effectiveRegen).toBeGreaterThanOrEqual(0); + }); + + it('should calculate effective as raw minus conversion', () => { + const result = computeEffectiveRegenForDisplay(baseState); + expect(result.effectiveRegen).toBe(Math.max(0, result.rawRegen - result.conversionDrain)); + }); +});