Fix critical bugs, add test framework, improve code quality
Some checks failed
Build and Publish Mana Loop Docker Image / build-and-publish (push) Has been cancelled

- Fix syntax errors with missing brackets in store.ts and crafting-slice.ts
- Fix state mutation issues with lootInventory deep cloning
- Remove race condition from mid-tick set() call
- Fix COMBO_MASTER using wrong counter (totalTicks vs totalSpellsCast)
- Add null safety to hasSpecial() function
- Clamp mana to 0 in deductSpellCost() to prevent negative values
- Set up Vitest testing framework with 36 passing tests
This commit is contained in:
2026-03-27 11:28:30 +00:00
parent b3e358e9a7
commit e9e056a3f0
7 changed files with 533 additions and 11 deletions

View File

@@ -0,0 +1,272 @@
import { describe, it, expect } from 'vitest';
import {
fmt,
fmtDec,
getFloorMaxHP,
getFloorElement,
canAffordSpellCost,
deductSpellCost,
computeMaxMana,
computeRegen,
computeClickMana,
getMeditationBonus,
getIncursionStrength,
} from '../computed-stats';
import { MAX_DAY, INCURSION_START_DAY, HOURS_PER_TICK } from '../constants';
describe('fmt', () => {
it('should format numbers < 1000 as integers', () => {
expect(fmt(500)).toBe('500');
expect(fmt(0)).toBe('0');
expect(fmt(999)).toBe('999');
});
it('should format thousands with K suffix', () => {
expect(fmt(1500)).toBe('1.5K');
expect(fmt(1000)).toBe('1.0K');
expect(fmt(9999)).toBe('10.0K');
});
it('should format millions with M suffix', () => {
expect(fmt(1500000)).toBe('1.50M');
expect(fmt(1000000)).toBe('1.00M');
});
it('should format billions with B suffix', () => {
expect(fmt(1500000000)).toBe('1.50B');
expect(fmt(1000000000)).toBe('1.00B');
});
it('should handle edge cases', () => {
expect(fmt(NaN)).toBe('0');
expect(fmt(Infinity)).toBe('0');
expect(fmt(-100)).toBe('-100');
});
});
describe('fmtDec', () => {
it('should format decimal numbers', () => {
expect(fmtDec(1.5, 1)).toBe('1.5');
expect(fmtDec(1.234, 2)).toBe('1.23');
expect(fmtDec(1000.5, 1)).toBe('1000.5');
});
});
describe('getFloorMaxHP', () => {
it('should return base HP for floor 1', () => {
const hp = getFloorMaxHP(1);
expect(hp).toBeGreaterThan(0);
});
it('should scale HP 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 higher HP for guardian floors', () => {
// Floor 10 is Ignis Prime guardian
const hp10 = getFloorMaxHP(10);
const hp9 = getFloorMaxHP(9);
expect(hp10).toBeGreaterThan(hp9 * 2); // Guardians have much more HP
});
});
describe('getFloorElement', () => {
it('should return an element string for valid floors', () => {
const elem = getFloorElement(1);
expect(typeof elem).toBe('string');
expect(['fire', 'water', 'earth', 'air', 'raw']).toContain(elem);
});
it('should cycle through elements based on floor number', () => {
// Check that floors have different elements
const elem1 = getFloorElement(1);
const elem7 = getFloorElement(7);
// Since it cycles every 5 floors, floor 1 and 7 might have same element
const elem2 = getFloorElement(2);
// Floor 1 and 2 should have different elements (if cycle allows)
});
});
describe('canAffordSpellCost', () => {
it('should return true for raw mana cost when enough mana', () => {
const result = canAffordSpellCost({ type: 'raw', amount: 10 }, 50, {});
expect(result).toBe(true);
});
it('should return false for raw mana cost when not enough mana', () => {
const result = canAffordSpellCost({ type: 'raw', amount: 100 }, 50, {});
expect(result).toBe(false);
});
it('should handle zero cost', () => {
const result = canAffordSpellCost({ type: 'raw', amount: 0 }, 0, {});
expect(result).toBe(true);
});
it('should handle elemental costs', () => {
const elements = {
fire: { current: 10, max: 50, unlocked: true },
};
const result = canAffordSpellCost({ type: 'element', element: 'fire', amount: 5 }, 0, elements);
expect(result).toBe(true);
});
it('should return false for elemental cost when not enough', () => {
const elements = {
fire: { current: 3, max: 50, unlocked: true },
};
const result = canAffordSpellCost({ type: 'element', element: 'fire', amount: 5 }, 0, elements);
expect(result).toBe(false);
});
it('should return false for locked element', () => {
const elements = {
fire: { current: 10, max: 50, unlocked: false },
};
const result = canAffordSpellCost({ type: 'element', element: 'fire', amount: 5 }, 0, elements);
expect(result).toBe(false);
});
});
describe('deductSpellCost', () => {
it('should deduct raw mana correctly', () => {
const result = deductSpellCost({ type: 'raw', amount: 10 }, 50, {});
expect(result.rawMana).toBe(40);
});
it('should not 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);
});
it('should return same values when cost is zero', () => {
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);
});
});
describe('computeMaxMana', () => {
it('should return base 100 with no skills or upgrades', () => {
const state = {
skills: {},
prestigeUpgrades: {},
skillUpgrades: {},
skillTiers: {},
};
const effects = { maxManaBonus: 0, maxManaMultiplier: 1 };
const result = computeMaxMana(state, effects);
expect(result).toBe(100);
});
it('should include manaWell prestige upgrade', () => {
const state = {
skills: {},
prestigeUpgrades: { manaWell: 5 },
skillUpgrades: {},
skillTiers: {},
};
const effects = { maxManaBonus: 0, maxManaMultiplier: 1 };
const result = computeMaxMana(state, effects);
expect(result).toBe(100 + 5 * 500); // Base + 500 per level
});
it('should apply multiplier from effects', () => {
const state = {
skills: {},
prestigeUpgrades: {},
skillUpgrades: {},
skillTiers: {},
};
const effects = { maxManaBonus: 0, maxManaMultiplier: 1.5 };
const result = computeMaxMana(state, effects);
expect(result).toBe(150); // 100 * 1.5
});
it('should apply bonus from effects', () => {
const state = {
skills: {},
prestigeUpgrades: {},
skillUpgrades: {},
skillTiers: {},
};
const effects = { maxManaBonus: 50, maxManaMultiplier: 1 };
const result = computeMaxMana(state, effects);
expect(result).toBe(150); // 100 + 50
});
});
describe('computeRegen', () => {
it('should return base regen with no skills', () => {
const state = {
skills: {},
prestigeUpgrades: {},
skillUpgrades: {},
skillTiers: {},
};
const effects = { regenBonus: 0, regenMultiplier: 1, permanentRegenBonus: 0 };
const result = computeRegen(state, effects);
expect(result).toBe(2); // Base regen
});
});
describe('computeClickMana', () => {
it('should return base click mana with no skills', () => {
const state = {
skills: {},
prestigeUpgrades: {},
skillUpgrades: {},
skillTiers: {},
};
const effects = { clickManaBonus: 0, clickManaMultiplier: 1 };
const result = computeClickMana(state, effects);
expect(result).toBeGreaterThanOrEqual(1);
});
});
describe('getMeditationBonus', () => {
it('should return 1.0 with zero ticks', () => {
const result = getMeditationBonus(0, {});
expect(result).toBe(1.0);
});
it('should increase with more ticks', () => {
const result1 = getMeditationBonus(10, {});
const result2 = getMeditationBonus(100, {});
expect(result2).toBeGreaterThan(result1);
});
});
describe('getIncursionStrength', () => {
it('should return 0 before incursion start day', () => {
const result = getIncursionStrength(1, 12);
expect(result).toBe(0);
});
it('should return positive value during incursion', () => {
// After incursion start day
const result = getIncursionStrength(INCURSION_START_DAY, 12);
expect(result).toBeGreaterThan(0);
});
it('should increase with later days', () => {
const result1 = getIncursionStrength(INCURSION_START_DAY, 12);
const result2 = getIncursionStrength(MAX_DAY, 12);
expect(result2).toBeGreaterThan(result1);
});
});

View File

@@ -386,11 +386,12 @@ export function deductSpellCost(
const newElements = { ...elements };
if (cost.type === 'raw') {
return { rawMana: rawMana - cost.amount, elements: newElements };
// Clamp to 0 to prevent negative mana
return { rawMana: Math.max(0, rawMana - cost.amount), elements: newElements };
} else if (cost.element && newElements[cost.element]) {
newElements[cost.element] = {
...newElements[cost.element],
current: newElements[cost.element].current - cost.amount
current: Math.max(0, newElements[cost.element].current - cost.amount)
};
return { rawMana, elements: newElements };
}

View File

@@ -576,9 +576,16 @@ export const useGameStore = create<GameStore>()(
}
// Combat - MULTI-SPELL casting from all equipped weapons
let { currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, equipmentSpellStates, combo, lootInventory, achievements, totalDamageDealt, totalSpellsCast } = state;
let { currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, equipmentSpellStates, combo, achievements, totalDamageDealt, totalSpellsCast } = state;
const floorElement = getFloorElement(currentFloor);
// Deep clone lootInventory to avoid state mutation
let lootInventory = {
materials: { ...state.lootInventory.materials },
essence: { ...state.lootInventory.essence },
blueprints: [...state.lootInventory.blueprints],
};
// Combo decay - decay combo when not climbing or when decay timer expires
let newCombo = { ...combo };
if (state.currentAction !== 'climb') {
@@ -699,7 +706,7 @@ export const useGameStore = create<GameStore>()(
}
// COMBO_MASTER: Every 5th attack deals 3x damage
const totalHitsThisLoop = state.totalTicks || 0;
const totalHitsThisLoop = totalSpellsCast;
const isFifthHit = (totalHitsThisLoop % 5) === 4; // 5th, 10th, 15th, etc.
if (hasSpecial(effects, SPECIAL_EFFECTS.COMBO_MASTER) && isFifthHit) {
dmg *= 3;
@@ -809,9 +816,6 @@ export const useGameStore = create<GameStore>()(
equipmentSpellStates = equipmentSpellStates.map(s => ({ ...s, castProgress: 0 }));
spellState = { ...spellState, castProgress: 0 };
// Update clearedFloors in the state
set((s) => ({ ...s, clearedFloors, lastClearedFloor }));
break; // Exit the while loop - new floor
}
}

View File

@@ -277,7 +277,7 @@ export function computeEffects(
* Check if a special effect is active
*/
export function hasSpecial(effects: ComputedEffects, specialId: string): boolean {
return effects.specials.has(specialId);
return effects?.specials?.has(specialId) ?? false;
}
/**