All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m55s
- Delete familiar-slice.ts, familiars.ts, and FamiliarTab.tsx - Remove familiar types from types.ts (FamiliarRole, FamiliarAbilityType, FamiliarAbility, FamiliarDef, FamiliarInstance) - Fix SpireTab spell effects to show valid types (burn, stun, pierce, multicast, buff) instead of invalid lifesteal/freeze - Remove Executioner test from store.test.ts (execute effect was previously removed) - Remove familiar fields from skills.test.ts mock state Familiars detract from the Invoker pact system. The familiar system was incomplete (missing state fields) and referenced removed ability types.
2080 lines
77 KiB
TypeScript
Executable File
2080 lines
77 KiB
TypeScript
Executable File
/**
|
|
* Unit Tests for Mana Loop Game Logic
|
|
*
|
|
* This file contains comprehensive tests for the game's core mechanics.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach } from 'bun:test';
|
|
import {
|
|
fmt,
|
|
fmtDec,
|
|
getFloorMaxHP,
|
|
getFloorElement,
|
|
computeMaxMana,
|
|
computeElementMax,
|
|
computeRegen,
|
|
computeClickMana,
|
|
calcDamage,
|
|
calcInsight,
|
|
getMeditationBonus,
|
|
getIncursionStrength,
|
|
canAffordSpellCost,
|
|
} from './store';
|
|
import {
|
|
ELEMENTS,
|
|
GUARDIANS,
|
|
SPELLS_DEF,
|
|
SKILLS_DEF,
|
|
PRESTIGE_DEF,
|
|
FLOOR_ELEM_CYCLE,
|
|
MANA_PER_ELEMENT,
|
|
MAX_DAY,
|
|
INCURSION_START_DAY,
|
|
getStudySpeedMultiplier,
|
|
getStudyCostMultiplier,
|
|
rawCost,
|
|
elemCost,
|
|
} from './constants';
|
|
import {
|
|
SKILL_EVOLUTION_PATHS,
|
|
getUpgradesForSkillAtMilestone,
|
|
getNextTierSkill,
|
|
getTierMultiplier,
|
|
generateTierSkillDef,
|
|
} from './skill-evolution';
|
|
import type { GameState, SpellCost } from './types';
|
|
|
|
// ─── Test Fixtures ───────────────────────────────────────────────────────────
|
|
|
|
function createMockState(overrides: Partial<GameState> = {}): GameState {
|
|
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
|
|
Object.keys(ELEMENTS).forEach((k) => {
|
|
elements[k] = { current: 0, max: 10, unlocked: false };
|
|
});
|
|
|
|
return {
|
|
day: 1,
|
|
hour: 0,
|
|
loopCount: 0,
|
|
gameOver: false,
|
|
victory: false,
|
|
paused: false,
|
|
rawMana: 100,
|
|
meditateTicks: 0,
|
|
totalManaGathered: 0,
|
|
elements,
|
|
currentFloor: 1,
|
|
floorHP: 100,
|
|
floorMaxHP: 100,
|
|
maxFloorReached: 1,
|
|
signedPacts: [],
|
|
activeSpell: 'manaBolt',
|
|
currentAction: 'meditate',
|
|
spells: { manaBolt: { learned: true, level: 1, studyProgress: 0 } },
|
|
skills: {},
|
|
skillProgress: {},
|
|
equipment: { mainHand: null, offHand: null, head: null, body: null, hands: null, accessory: null },
|
|
inventory: [],
|
|
blueprints: {},
|
|
schedule: [],
|
|
autoSchedule: false,
|
|
studyQueue: [],
|
|
craftQueue: [],
|
|
currentStudyTarget: null,
|
|
insight: 0,
|
|
totalInsight: 0,
|
|
prestigeUpgrades: {},
|
|
memorySlots: 3,
|
|
memories: [],
|
|
incursionStrength: 0,
|
|
containmentWards: 0,
|
|
log: [],
|
|
loopInsight: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ─── Formatting Tests ─────────────────────────────────────────────────────────
|
|
|
|
describe('Formatting Functions', () => {
|
|
describe('fmt (format number)', () => {
|
|
it('should format numbers less than 1000 as integers', () => {
|
|
expect(fmt(0)).toBe('0');
|
|
expect(fmt(1)).toBe('1');
|
|
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(999999)).toBe('1000.0K');
|
|
});
|
|
|
|
it('should format millions with M suffix', () => {
|
|
expect(fmt(1000000)).toBe('1.00M');
|
|
expect(fmt(1500000)).toBe('1.50M');
|
|
});
|
|
|
|
it('should format billions with B suffix', () => {
|
|
expect(fmt(1000000000)).toBe('1.00B');
|
|
});
|
|
|
|
it('should handle non-finite numbers', () => {
|
|
expect(fmt(Infinity)).toBe('0');
|
|
expect(fmt(NaN)).toBe('0');
|
|
expect(fmt(-Infinity)).toBe('0');
|
|
});
|
|
});
|
|
|
|
describe('fmtDec (format decimal)', () => {
|
|
it('should format numbers with specified decimal places', () => {
|
|
expect(fmtDec(1.234, 2)).toBe('1.23');
|
|
expect(fmtDec(1.5, 0)).toBe('2'); // toFixed rounds
|
|
expect(fmtDec(1.567, 1)).toBe('1.6');
|
|
});
|
|
|
|
it('should handle non-finite numbers', () => {
|
|
expect(fmtDec(Infinity, 2)).toBe('0');
|
|
expect(fmtDec(NaN, 2)).toBe('0');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Floor Tests ──────────────────────────────────────────────────────────────
|
|
|
|
describe('Floor Functions', () => {
|
|
describe('getFloorMaxHP', () => {
|
|
it('should return guardian HP for guardian floors', () => {
|
|
expect(getFloorMaxHP(10)).toBe(GUARDIANS[10].hp);
|
|
expect(getFloorMaxHP(100)).toBe(GUARDIANS[100].hp);
|
|
});
|
|
|
|
it('should scale HP for non-guardian floors', () => {
|
|
expect(getFloorMaxHP(1)).toBeGreaterThan(0);
|
|
expect(getFloorMaxHP(5)).toBeGreaterThan(getFloorMaxHP(1));
|
|
expect(getFloorMaxHP(50)).toBeGreaterThan(getFloorMaxHP(25));
|
|
});
|
|
|
|
it('should have increasing scaling', () => {
|
|
const hp1 = getFloorMaxHP(1);
|
|
const hp5 = getFloorMaxHP(5);
|
|
const hp10 = getFloorMaxHP(10); // Guardian floor
|
|
const hp50 = getFloorMaxHP(50); // Guardian floor
|
|
|
|
// HP should increase
|
|
expect(hp5).toBeGreaterThan(hp1);
|
|
expect(hp10).toBeGreaterThan(hp5);
|
|
expect(hp50).toBeGreaterThan(hp10);
|
|
|
|
// Guardian floors have much more HP
|
|
expect(hp10).toBeGreaterThan(1000);
|
|
expect(hp50).toBeGreaterThan(10000);
|
|
});
|
|
});
|
|
|
|
describe('getFloorElement', () => {
|
|
it('should cycle through elements in order', () => {
|
|
expect(getFloorElement(1)).toBe('fire');
|
|
expect(getFloorElement(2)).toBe('water');
|
|
expect(getFloorElement(3)).toBe('air');
|
|
expect(getFloorElement(4)).toBe('earth');
|
|
expect(getFloorElement(5)).toBe('light');
|
|
expect(getFloorElement(6)).toBe('dark');
|
|
expect(getFloorElement(7)).toBe('life');
|
|
expect(getFloorElement(8)).toBe('death');
|
|
});
|
|
|
|
it('should wrap around after 8 floors', () => {
|
|
expect(getFloorElement(9)).toBe('fire');
|
|
expect(getFloorElement(10)).toBe('water');
|
|
expect(getFloorElement(17)).toBe('fire');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Mana Calculation Tests ───────────────────────────────────────────────────
|
|
|
|
describe('Mana Calculation Functions', () => {
|
|
describe('computeMaxMana', () => {
|
|
it('should return base mana with no upgrades', () => {
|
|
const state = createMockState();
|
|
expect(computeMaxMana(state)).toBe(100);
|
|
});
|
|
|
|
it('should add mana from manaWell skill', () => {
|
|
const state = createMockState({ skills: { manaWell: 5 } });
|
|
expect(computeMaxMana(state)).toBe(100 + 5 * 100);
|
|
});
|
|
|
|
it('should add mana from deepReservoir skill', () => {
|
|
const state = createMockState({ skills: { deepReservoir: 3 } });
|
|
expect(computeMaxMana(state)).toBe(100 + 3 * 500);
|
|
});
|
|
|
|
it('should add mana from prestige upgrades', () => {
|
|
const state = createMockState({ prestigeUpgrades: { manaWell: 3 } });
|
|
expect(computeMaxMana(state)).toBe(100 + 3 * 500);
|
|
});
|
|
|
|
it('should stack all mana bonuses', () => {
|
|
const state = createMockState({
|
|
skills: { manaWell: 5, deepReservoir: 2 },
|
|
prestigeUpgrades: { manaWell: 2 },
|
|
});
|
|
expect(computeMaxMana(state)).toBe(100 + 5 * 100 + 2 * 500 + 2 * 500);
|
|
});
|
|
});
|
|
|
|
describe('computeElementMax', () => {
|
|
it('should return base element cap with no upgrades', () => {
|
|
const state = createMockState();
|
|
expect(computeElementMax(state)).toBe(10);
|
|
});
|
|
|
|
it('should add cap from elemAttune skill', () => {
|
|
const state = createMockState({ skills: { elemAttune: 5 } });
|
|
expect(computeElementMax(state)).toBe(10 + 5 * 50);
|
|
});
|
|
|
|
it('should add cap from prestige upgrades', () => {
|
|
const state = createMockState({ prestigeUpgrades: { elementalAttune: 3 } });
|
|
expect(computeElementMax(state)).toBe(10 + 3 * 25);
|
|
});
|
|
});
|
|
|
|
describe('computeRegen', () => {
|
|
it('should return base regen with no upgrades', () => {
|
|
const state = createMockState();
|
|
expect(computeRegen(state)).toBe(2);
|
|
});
|
|
|
|
it('should add regen from manaFlow skill', () => {
|
|
const state = createMockState({ skills: { manaFlow: 5 } });
|
|
expect(computeRegen(state)).toBe(2 + 5 * 1);
|
|
});
|
|
|
|
it('should add regen from manaSpring skill', () => {
|
|
const state = createMockState({ skills: { manaSpring: 1 } });
|
|
expect(computeRegen(state)).toBe(2 + 2);
|
|
});
|
|
|
|
it('should multiply by temporal echo prestige', () => {
|
|
const state = createMockState({ prestigeUpgrades: { temporalEcho: 2 } });
|
|
expect(computeRegen(state)).toBe(2 * 1.2);
|
|
});
|
|
});
|
|
|
|
describe('computeClickMana', () => {
|
|
it('should return base click mana with no upgrades', () => {
|
|
const state = createMockState();
|
|
expect(computeClickMana(state)).toBe(1);
|
|
});
|
|
|
|
it('should add mana from manaTap skill', () => {
|
|
const state = createMockState({ skills: { manaTap: 1 } });
|
|
expect(computeClickMana(state)).toBe(1 + 1);
|
|
});
|
|
|
|
it('should add mana from manaSurge skill', () => {
|
|
const state = createMockState({ skills: { manaSurge: 1 } });
|
|
expect(computeClickMana(state)).toBe(1 + 3);
|
|
});
|
|
|
|
it('should stack manaTap and manaSurge', () => {
|
|
const state = createMockState({ skills: { manaTap: 1, manaSurge: 1 } });
|
|
expect(computeClickMana(state)).toBe(1 + 1 + 3);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Damage Calculation Tests ─────────────────────────────────────────────────
|
|
|
|
describe('Damage Calculation', () => {
|
|
describe('calcDamage', () => {
|
|
it('should return spell base damage with no bonuses', () => {
|
|
const state = createMockState();
|
|
const dmg = calcDamage(state, 'manaBolt');
|
|
expect(dmg).toBeGreaterThanOrEqual(5); // Base damage
|
|
expect(dmg).toBeLessThanOrEqual(5 * 1.5); // No crit bonus, just base
|
|
});
|
|
|
|
it('should add damage from combatTrain skill', () => {
|
|
const state = createMockState({ skills: { combatTrain: 5 } });
|
|
const dmg = calcDamage(state, 'manaBolt');
|
|
expect(dmg).toBeGreaterThanOrEqual(5 + 5 * 5);
|
|
});
|
|
|
|
it('should multiply by arcaneFury skill', () => {
|
|
const state = createMockState({ skills: { arcaneFury: 3 } });
|
|
// Without crit: base * 1.3
|
|
const minDmg = 5 * 1.3;
|
|
const maxDmg = 5 * 1.3 * 1.5; // With crit
|
|
const dmg = calcDamage(state, 'manaBolt');
|
|
expect(dmg).toBeGreaterThanOrEqual(minDmg);
|
|
expect(dmg).toBeLessThanOrEqual(maxDmg);
|
|
});
|
|
|
|
it('should multiply by signed pacts', () => {
|
|
const state = createMockState({ signedPacts: [10] });
|
|
// Pact multiplier is 1.5 for floor 10
|
|
const dmg = calcDamage(state, 'manaBolt');
|
|
const minDmg = 5 * 1.5;
|
|
expect(dmg).toBeGreaterThanOrEqual(minDmg);
|
|
});
|
|
|
|
it('should stack multiple pacts', () => {
|
|
const state = createMockState({ signedPacts: [10, 20] });
|
|
const pactMult = GUARDIANS[10].pact * GUARDIANS[20].pact;
|
|
const dmg = calcDamage(state, 'manaBolt');
|
|
const minDmg = 5 * pactMult;
|
|
expect(dmg).toBeGreaterThanOrEqual(minDmg);
|
|
});
|
|
|
|
describe('Elemental bonuses', () => {
|
|
it('should give +25% for same element', () => {
|
|
const state = createMockState({ spells: { fireball: { learned: true, level: 1 } } });
|
|
// Floor 1 is fire element
|
|
const dmg = calcDamage(state, 'fireball', 'fire');
|
|
// Without crit: 15 * 1.25
|
|
expect(dmg).toBeGreaterThanOrEqual(15 * 1.25);
|
|
});
|
|
|
|
it('should give +50% for opposing element (super effective)', () => {
|
|
const state = createMockState({ spells: { waterJet: { learned: true, level: 1 } } });
|
|
// Water vs fire - water is the opposite of fire, so water is super effective
|
|
// ELEMENT_OPPOSITES['fire'] === 'water' -> 1.5x
|
|
const dmg = calcDamage(state, 'waterJet', 'fire');
|
|
// Base 12 * 1.5 = 18 (without crit)
|
|
expect(dmg).toBeGreaterThanOrEqual(18 * 0.5); // Can crit, so min is lower
|
|
});
|
|
|
|
it('should give +50% when attacking opposite element (fire vs water)', () => {
|
|
const state = createMockState({ spells: { fireball: { learned: true, level: 1 } } });
|
|
// Fire vs water - fire is the opposite of water, so fire is super effective
|
|
// ELEMENT_OPPOSITES['water'] === 'fire' -> 1.5x
|
|
const dmg = calcDamage(state, 'fireball', 'water');
|
|
// Base 15 * 1.5 = 22.5 (without crit)
|
|
expect(dmg).toBeGreaterThanOrEqual(22.5 * 0.5); // Can crit
|
|
});
|
|
|
|
it('should be neutral for non-opposing elements', () => {
|
|
const state = createMockState({ spells: { fireball: { learned: true, level: 1 } } });
|
|
// Fire vs air (neutral - neither same nor opposite)
|
|
const dmg = calcDamage(state, 'fireball', 'air');
|
|
expect(dmg).toBeGreaterThanOrEqual(15 * 0.5); // No bonus, but could crit
|
|
expect(dmg).toBeLessThanOrEqual(15 * 1.5);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Insight Calculation Tests ─────────────────────────────────────────────────
|
|
|
|
describe('Insight Calculation', () => {
|
|
describe('calcInsight', () => {
|
|
it('should calculate insight from floor progress', () => {
|
|
const state = createMockState({ maxFloorReached: 10 });
|
|
const insight = calcInsight(state);
|
|
expect(insight).toBe(10 * 15);
|
|
});
|
|
|
|
it('should calculate insight from mana gathered', () => {
|
|
const state = createMockState({ totalManaGathered: 5000 });
|
|
const insight = calcInsight(state);
|
|
// Formula: floor*15 + mana/500 + pacts*150
|
|
// With default maxFloorReached=1: 1*15 + 5000/500 + 0 = 15 + 10 = 25
|
|
expect(insight).toBe(25);
|
|
});
|
|
|
|
it('should calculate insight from signed pacts', () => {
|
|
const state = createMockState({ signedPacts: [10, 20] });
|
|
const insight = calcInsight(state);
|
|
// Formula: floor*15 + mana/500 + pacts*150
|
|
// With default maxFloorReached=1: 1*15 + 0 + 2*150 = 15 + 300 = 315
|
|
expect(insight).toBe(315);
|
|
});
|
|
|
|
it('should multiply by insightAmp prestige', () => {
|
|
const state = createMockState({
|
|
maxFloorReached: 10,
|
|
prestigeUpgrades: { insightAmp: 2 },
|
|
});
|
|
const insight = calcInsight(state);
|
|
expect(insight).toBe(Math.floor(10 * 15 * 1.5));
|
|
});
|
|
|
|
it('should multiply by insightHarvest skill', () => {
|
|
const state = createMockState({
|
|
maxFloorReached: 10,
|
|
skills: { insightHarvest: 3 },
|
|
});
|
|
const insight = calcInsight(state);
|
|
expect(insight).toBe(Math.floor(10 * 15 * 1.3));
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Meditation Tests ─────────────────────────────────────────────────────────
|
|
|
|
describe('Meditation Bonus', () => {
|
|
describe('getMeditationBonus', () => {
|
|
it('should start at 1x with no meditation', () => {
|
|
expect(getMeditationBonus(0, {})).toBe(1);
|
|
});
|
|
|
|
it('should ramp up over time', () => {
|
|
const bonus1hr = getMeditationBonus(25, {}); // 1 hour of ticks
|
|
expect(bonus1hr).toBeGreaterThan(1);
|
|
|
|
const bonus4hr = getMeditationBonus(100, {}); // 4 hours
|
|
expect(bonus4hr).toBeGreaterThan(bonus1hr);
|
|
});
|
|
|
|
it('should cap at 1.5x without meditation skill', () => {
|
|
const bonus = getMeditationBonus(200, {}); // 8 hours
|
|
expect(bonus).toBe(1.5);
|
|
});
|
|
|
|
it('should give 2.5x with meditation skill after 4 hours', () => {
|
|
const bonus = getMeditationBonus(100, { meditation: 1 });
|
|
expect(bonus).toBe(2.5);
|
|
});
|
|
|
|
it('should give 3.0x with deepTrance skill after 6 hours', () => {
|
|
const bonus = getMeditationBonus(150, { meditation: 1, deepTrance: 1 });
|
|
expect(bonus).toBe(3.0);
|
|
});
|
|
|
|
it('should give 5.0x with voidMeditation skill after 8 hours', () => {
|
|
const bonus = getMeditationBonus(200, { meditation: 1, deepTrance: 1, voidMeditation: 1 });
|
|
expect(bonus).toBe(5.0);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Incursion Tests ──────────────────────────────────────────────────────────
|
|
|
|
describe('Incursion Strength', () => {
|
|
describe('getIncursionStrength', () => {
|
|
it('should be 0 before incursion start day', () => {
|
|
expect(getIncursionStrength(19, 0)).toBe(0);
|
|
expect(getIncursionStrength(19, 23)).toBe(0);
|
|
});
|
|
|
|
it('should start at incursion start day', () => {
|
|
// Incursion starts at day 20, hour 0
|
|
// Formula: totalHours / maxHours * 0.95
|
|
// At day 20, hour 0: totalHours = 0, so strength = 0
|
|
// Need hour > 0 to see incursion
|
|
expect(getIncursionStrength(INCURSION_START_DAY, 1)).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should increase over time', () => {
|
|
const early = getIncursionStrength(INCURSION_START_DAY, 12);
|
|
const late = getIncursionStrength(25, 12);
|
|
expect(late).toBeGreaterThan(early);
|
|
});
|
|
|
|
it('should cap at 95%', () => {
|
|
const strength = getIncursionStrength(MAX_DAY, 23);
|
|
expect(strength).toBeLessThanOrEqual(0.95);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Spell Cost Tests ─────────────────────────────────────────────────────────
|
|
|
|
describe('Spell Cost System', () => {
|
|
describe('rawCost', () => {
|
|
it('should create a raw mana cost', () => {
|
|
const cost = rawCost(10);
|
|
expect(cost.type).toBe('raw');
|
|
expect(cost.amount).toBe(10);
|
|
expect(cost.element).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('elemCost', () => {
|
|
it('should create an elemental mana cost', () => {
|
|
const cost = elemCost('fire', 5);
|
|
expect(cost.type).toBe('element');
|
|
expect(cost.element).toBe('fire');
|
|
expect(cost.amount).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe('canAffordSpellCost', () => {
|
|
it('should allow raw mana costs when enough raw mana', () => {
|
|
const cost = rawCost(10);
|
|
const elements = { fire: { current: 0, max: 10, unlocked: true } };
|
|
expect(canAffordSpellCost(cost, 100, elements)).toBe(true);
|
|
});
|
|
|
|
it('should deny raw mana costs when not enough raw mana', () => {
|
|
const cost = rawCost(100);
|
|
const elements = { fire: { current: 0, max: 10, unlocked: true } };
|
|
expect(canAffordSpellCost(cost, 50, elements)).toBe(false);
|
|
});
|
|
|
|
it('should allow elemental costs when enough element mana', () => {
|
|
const cost = elemCost('fire', 5);
|
|
const elements = { fire: { current: 10, max: 10, unlocked: true } };
|
|
expect(canAffordSpellCost(cost, 0, elements)).toBe(true);
|
|
});
|
|
|
|
it('should deny elemental costs when element not unlocked', () => {
|
|
const cost = elemCost('fire', 5);
|
|
const elements = { fire: { current: 10, max: 10, unlocked: false } };
|
|
expect(canAffordSpellCost(cost, 100, elements)).toBe(false);
|
|
});
|
|
|
|
it('should deny elemental costs when not enough element mana', () => {
|
|
const cost = elemCost('fire', 10);
|
|
const elements = { fire: { current: 5, max: 10, unlocked: true } };
|
|
expect(canAffordSpellCost(cost, 100, elements)).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Study Speed Tests ────────────────────────────────────────────────────────
|
|
|
|
describe('Study Speed Functions', () => {
|
|
describe('getStudySpeedMultiplier', () => {
|
|
it('should return 1 with no quickLearner skill', () => {
|
|
expect(getStudySpeedMultiplier({})).toBe(1);
|
|
});
|
|
|
|
it('should increase by 10% per level', () => {
|
|
expect(getStudySpeedMultiplier({ quickLearner: 1 })).toBe(1.1);
|
|
expect(getStudySpeedMultiplier({ quickLearner: 5 })).toBe(1.5);
|
|
});
|
|
});
|
|
|
|
describe('getStudyCostMultiplier', () => {
|
|
it('should return 1 with no focusedMind skill', () => {
|
|
expect(getStudyCostMultiplier({})).toBe(1);
|
|
});
|
|
|
|
it('should decrease by 5% per level', () => {
|
|
expect(getStudyCostMultiplier({ focusedMind: 1 })).toBe(0.95);
|
|
expect(getStudyCostMultiplier({ focusedMind: 5 })).toBe(0.75);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Constants Validation Tests ───────────────────────────────────────────────
|
|
|
|
describe('Game Constants', () => {
|
|
describe('ELEMENTS', () => {
|
|
it('should have all base elements', () => {
|
|
expect(ELEMENTS.fire).toBeDefined();
|
|
expect(ELEMENTS.water).toBeDefined();
|
|
expect(ELEMENTS.air).toBeDefined();
|
|
expect(ELEMENTS.earth).toBeDefined();
|
|
expect(ELEMENTS.light).toBeDefined();
|
|
expect(ELEMENTS.dark).toBeDefined();
|
|
expect(ELEMENTS.life).toBeDefined();
|
|
expect(ELEMENTS.death).toBeDefined();
|
|
});
|
|
|
|
it('should have composite elements with recipes', () => {
|
|
expect(ELEMENTS.blood.recipe).toEqual(['life', 'water']);
|
|
expect(ELEMENTS.metal.recipe).toEqual(['fire', 'earth']);
|
|
expect(ELEMENTS.wood.recipe).toEqual(['life', 'earth']);
|
|
expect(ELEMENTS.sand.recipe).toEqual(['earth', 'water']);
|
|
});
|
|
|
|
it('should have exotic elements with 3-ingredient recipes', () => {
|
|
expect(ELEMENTS.crystal.recipe).toHaveLength(3);
|
|
expect(ELEMENTS.stellar.recipe).toHaveLength(3);
|
|
expect(ELEMENTS.void.recipe).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
describe('GUARDIANS', () => {
|
|
it('should have guardians every 10 floors', () => {
|
|
[10, 20, 30, 40, 50, 60, 70, 80, 90, 100].forEach(floor => {
|
|
expect(GUARDIANS[floor]).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should have increasing HP', () => {
|
|
let prevHP = 0;
|
|
[10, 20, 30, 40, 50, 60, 70, 80, 90, 100].forEach(floor => {
|
|
expect(GUARDIANS[floor].hp).toBeGreaterThan(prevHP);
|
|
prevHP = GUARDIANS[floor].hp;
|
|
});
|
|
});
|
|
|
|
it('should have increasing pact multipliers', () => {
|
|
let prevPact = 1;
|
|
[10, 20, 30, 40, 50, 60, 70, 80, 90, 100].forEach(floor => {
|
|
expect(GUARDIANS[floor].pact).toBeGreaterThan(prevPact);
|
|
prevPact = GUARDIANS[floor].pact;
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('SPELLS_DEF', () => {
|
|
it('should have manaBolt as a basic spell', () => {
|
|
expect(SPELLS_DEF.manaBolt).toBeDefined();
|
|
expect(SPELLS_DEF.manaBolt.tier).toBe(0);
|
|
expect(SPELLS_DEF.manaBolt.cost.type).toBe('raw');
|
|
});
|
|
|
|
it('should have spells for each base element', () => {
|
|
const elements = ['fire', 'water', 'air', 'earth', 'light', 'dark', 'life', 'death'];
|
|
elements.forEach(elem => {
|
|
const hasSpell = Object.values(SPELLS_DEF).some(s => s.elem === elem);
|
|
expect(hasSpell).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('should have increasing damage for higher tiers', () => {
|
|
const tier0Avg = Object.values(SPELLS_DEF).filter(s => s.tier === 0).reduce((a, s) => a + s.dmg, 0);
|
|
const tier1Avg = Object.values(SPELLS_DEF).filter(s => s.tier === 1).reduce((a, s) => a + s.dmg, 0);
|
|
const tier2Avg = Object.values(SPELLS_DEF).filter(s => s.tier === 2).reduce((a, s) => a + s.dmg, 0);
|
|
|
|
expect(tier1Avg).toBeGreaterThan(tier0Avg);
|
|
expect(tier2Avg).toBeGreaterThan(tier1Avg);
|
|
});
|
|
});
|
|
|
|
describe('SKILLS_DEF', () => {
|
|
it('should have skills with valid categories', () => {
|
|
const validCategories = ['mana', 'combat', 'study', 'craft', 'research', 'ascension'];
|
|
Object.values(SKILLS_DEF).forEach(skill => {
|
|
expect(validCategories).toContain(skill.cat);
|
|
});
|
|
});
|
|
|
|
it('should have reasonable study times', () => {
|
|
Object.values(SKILLS_DEF).forEach(skill => {
|
|
expect(skill.studyTime).toBeGreaterThan(0);
|
|
expect(skill.studyTime).toBeLessThanOrEqual(72);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('PRESTIGE_DEF', () => {
|
|
it('should have prestige upgrades with valid costs', () => {
|
|
Object.values(PRESTIGE_DEF).forEach(def => {
|
|
expect(def.cost).toBeGreaterThan(0);
|
|
expect(def.max).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Element Recipe Tests ─────────────────────────────────────────────────────
|
|
|
|
describe('Element Crafting Recipes', () => {
|
|
it('should have valid ingredient references', () => {
|
|
Object.entries(ELEMENTS).forEach(([id, def]) => {
|
|
if (def.recipe) {
|
|
def.recipe.forEach(ingredient => {
|
|
expect(ELEMENTS[ingredient]).toBeDefined();
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
it('should not have circular recipes', () => {
|
|
const visited = new Set<string>();
|
|
const checkCircular = (id: string, path: string[]): boolean => {
|
|
if (path.includes(id)) return true;
|
|
const def = ELEMENTS[id];
|
|
if (!def.recipe) return false;
|
|
return def.recipe.some(ing => checkCircular(ing, [...path, id]));
|
|
};
|
|
|
|
Object.keys(ELEMENTS).forEach(id => {
|
|
expect(checkCircular(id, [])).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Integration Tests ────────────────────────────────────────────────────────
|
|
|
|
describe('Integration Tests', () => {
|
|
it('should have consistent element references across all definitions', () => {
|
|
// All spell elements should exist
|
|
Object.values(SPELLS_DEF).forEach(spell => {
|
|
if (spell.elem !== 'raw') {
|
|
expect(ELEMENTS[spell.elem]).toBeDefined();
|
|
}
|
|
});
|
|
|
|
// All guardian elements should exist
|
|
Object.values(GUARDIANS).forEach(guardian => {
|
|
expect(ELEMENTS[guardian.element]).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should have balanced spell costs relative to damage', () => {
|
|
Object.values(SPELLS_DEF).forEach(spell => {
|
|
const dmgPerCost = spell.dmg / spell.cost.amount;
|
|
// Damage per mana should be reasonable (between 0.5 and 50)
|
|
expect(dmgPerCost).toBeGreaterThan(0.5);
|
|
expect(dmgPerCost).toBeLessThan(50);
|
|
});
|
|
});
|
|
|
|
it('should have balanced skill requirements', () => {
|
|
Object.entries(SKILLS_DEF).forEach(([id, skill]) => {
|
|
if (skill.req) {
|
|
Object.entries(skill.req).forEach(([reqId, level]) => {
|
|
expect(SKILLS_DEF[reqId]).toBeDefined();
|
|
expect(level).toBeGreaterThan(0);
|
|
expect(level).toBeLessThanOrEqual(SKILLS_DEF[reqId].max);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Individual Skill Tests ─────────────────────────────────────────────────────
|
|
|
|
describe('Individual Skill Tests', () => {
|
|
|
|
// ─── Mana Skills ────────────────────────────────────────────────────────────
|
|
|
|
describe('manaWell', () => {
|
|
it('should add +100 max mana per level', () => {
|
|
const state1 = createMockState({ skills: { manaWell: 1 } });
|
|
expect(computeMaxMana(state1)).toBe(100 + 100);
|
|
|
|
const state5 = createMockState({ skills: { manaWell: 5 } });
|
|
expect(computeMaxMana(state5)).toBe(100 + 500);
|
|
});
|
|
|
|
it('should stack with prestige manaWell', () => {
|
|
const state = createMockState({
|
|
skills: { manaWell: 3 },
|
|
prestigeUpgrades: { manaWell: 2 }
|
|
});
|
|
expect(computeMaxMana(state)).toBe(100 + 300 + 1000);
|
|
});
|
|
});
|
|
|
|
describe('manaFlow', () => {
|
|
it('should add +1 regen/hr per level', () => {
|
|
const state0 = createMockState();
|
|
expect(computeRegen(state0)).toBe(2);
|
|
|
|
const state3 = createMockState({ skills: { manaFlow: 3 } });
|
|
expect(computeRegen(state3)).toBe(2 + 3);
|
|
|
|
const state10 = createMockState({ skills: { manaFlow: 10 } });
|
|
expect(computeRegen(state10)).toBe(2 + 10);
|
|
});
|
|
});
|
|
|
|
describe('deepReservoir', () => {
|
|
it('should add +500 max mana per level', () => {
|
|
const state1 = createMockState({ skills: { deepReservoir: 1 } });
|
|
expect(computeMaxMana(state1)).toBe(100 + 500);
|
|
|
|
const state5 = createMockState({ skills: { deepReservoir: 5 } });
|
|
expect(computeMaxMana(state5)).toBe(100 + 2500);
|
|
});
|
|
|
|
it('should stack with manaWell', () => {
|
|
const state = createMockState({ skills: { manaWell: 5, deepReservoir: 2 } });
|
|
expect(computeMaxMana(state)).toBe(100 + 500 + 1000);
|
|
});
|
|
});
|
|
|
|
describe('elemAttune', () => {
|
|
it('should add +50 elem mana cap per level', () => {
|
|
const state0 = createMockState();
|
|
expect(computeElementMax(state0)).toBe(10);
|
|
|
|
const state3 = createMockState({ skills: { elemAttune: 3 } });
|
|
expect(computeElementMax(state3)).toBe(10 + 150);
|
|
});
|
|
});
|
|
|
|
describe('manaOverflow', () => {
|
|
it('should add +25% mana from clicks per level', () => {
|
|
// Note: manaOverflow is applied in gatherMana, not computeClickMana
|
|
// computeClickMana returns base click mana
|
|
const baseClick = computeClickMana(createMockState());
|
|
expect(baseClick).toBe(1);
|
|
|
|
// The actual bonus is applied in gatherMana:
|
|
// cm = Math.floor(cm * (1 + manaOverflow * 0.25))
|
|
// So level 1 = 1.25x, level 5 = 2.25x
|
|
const level1Bonus = 1 + 1 * 0.25;
|
|
const level5Bonus = 1 + 5 * 0.25;
|
|
|
|
expect(Math.floor(baseClick * level1Bonus)).toBe(1);
|
|
expect(Math.floor(baseClick * level5Bonus)).toBe(2);
|
|
});
|
|
});
|
|
|
|
// ─── Combat Skills ──────────────────────────────────────────────────────────
|
|
|
|
describe('combatTrain', () => {
|
|
it('should add +5 base damage per level', () => {
|
|
const state0 = createMockState();
|
|
const dmg0 = calcDamage(state0, 'manaBolt');
|
|
// Base manaBolt dmg is 5, combatTrain adds 5 per level
|
|
// But there's randomness from crit, so check minimum
|
|
expect(dmg0).toBeGreaterThanOrEqual(5);
|
|
|
|
const state5 = createMockState({ skills: { combatTrain: 5 } });
|
|
const dmg5 = calcDamage(state5, 'manaBolt');
|
|
// 5 base + 5*5 = 30 minimum (no crit)
|
|
expect(dmg5).toBeGreaterThanOrEqual(30);
|
|
});
|
|
});
|
|
|
|
describe('arcaneFury', () => {
|
|
it('should add +10% spell damage per level', () => {
|
|
const state0 = createMockState();
|
|
const dmg0 = calcDamage(state0, 'manaBolt');
|
|
|
|
const state3 = createMockState({ skills: { arcaneFury: 3 } });
|
|
const dmg3 = calcDamage(state3, 'manaBolt');
|
|
|
|
// arcaneFury gives 1 + 0.1*level = 1.3x for level 3
|
|
// Without crit, damage should be 5 * 1.3 = 6.5
|
|
expect(dmg3).toBeGreaterThanOrEqual(5 * 1.3 * 0.5); // Min (no crit)
|
|
});
|
|
});
|
|
|
|
describe('precision', () => {
|
|
it('should add +5% crit chance per level', () => {
|
|
// Precision affects crit chance in calcDamage
|
|
// This is probabilistic, so we test the formula
|
|
// critChance = skills.precision * 0.05
|
|
// Level 1 = 5%, Level 5 = 25%
|
|
|
|
// Run many samples to verify crit rate
|
|
let crits = 0;
|
|
const samples = 1000;
|
|
const state = createMockState({ skills: { precision: 2 } }); // 10% crit
|
|
|
|
for (let i = 0; i < samples; i++) {
|
|
const dmg = calcDamage(state, 'manaBolt');
|
|
if (dmg > 6) crits++; // Crit does 1.5x damage
|
|
}
|
|
|
|
// Should be around 10% with some variance
|
|
expect(crits).toBeGreaterThan(50); // At least 5%
|
|
expect(crits).toBeLessThan(200); // At most 20%
|
|
});
|
|
});
|
|
|
|
describe('quickCast', () => {
|
|
it('should be defined and have correct max', () => {
|
|
expect(SKILLS_DEF.quickCast).toBeDefined();
|
|
expect(SKILLS_DEF.quickCast.max).toBe(5);
|
|
expect(SKILLS_DEF.quickCast.desc).toContain('5% attack speed');
|
|
});
|
|
// Note: quickCast affects attack speed, which would need integration tests
|
|
});
|
|
|
|
describe('elementalMastery', () => {
|
|
it('should add +15% elemental damage bonus per level', () => {
|
|
// Test with fireball (fire spell) vs fire floor (same element)
|
|
const state0 = createMockState({ spells: { fireball: { learned: true, level: 1 } } });
|
|
const dmg0 = calcDamage(state0, 'fireball', 'fire');
|
|
|
|
const state2 = createMockState({
|
|
skills: { elementalMastery: 2 },
|
|
spells: { fireball: { learned: true, level: 1 } }
|
|
});
|
|
const dmg2 = calcDamage(state2, 'fireball', 'fire');
|
|
|
|
// elementalMastery gives 1 + 0.15*level bonus
|
|
// Level 2 = 1.3x elemental damage
|
|
expect(dmg2).toBeGreaterThan(dmg0 * 0.9);
|
|
});
|
|
});
|
|
|
|
describe('spellEcho', () => {
|
|
it('should give 10% chance to cast twice per level', () => {
|
|
expect(SKILLS_DEF.spellEcho).toBeDefined();
|
|
expect(SKILLS_DEF.spellEcho.max).toBe(3);
|
|
expect(SKILLS_DEF.spellEcho.desc).toContain('10% chance to cast twice');
|
|
});
|
|
// Note: spellEcho is probabilistic, verified in combat tick
|
|
});
|
|
|
|
// ─── Study Skills ───────────────────────────────────────────────────────────
|
|
|
|
describe('quickLearner', () => {
|
|
it('should add +10% study speed per level', () => {
|
|
expect(getStudySpeedMultiplier({ quickLearner: 0 })).toBe(1);
|
|
expect(getStudySpeedMultiplier({ quickLearner: 1 })).toBe(1.1);
|
|
expect(getStudySpeedMultiplier({ quickLearner: 5 })).toBe(1.5);
|
|
});
|
|
});
|
|
|
|
describe('focusedMind', () => {
|
|
it('should reduce study mana cost by 5% per level', () => {
|
|
expect(getStudyCostMultiplier({ focusedMind: 0 })).toBe(1);
|
|
expect(getStudyCostMultiplier({ focusedMind: 1 })).toBe(0.95);
|
|
expect(getStudyCostMultiplier({ focusedMind: 5 })).toBe(0.75);
|
|
});
|
|
|
|
it('should reduce skill study cost', () => {
|
|
// Skill base cost is 100 for manaWell
|
|
// With focusedMind 5, cost should be 100 * 0.75 = 75
|
|
const baseCost = SKILLS_DEF.manaWell.base;
|
|
const costMult5 = getStudyCostMultiplier({ focusedMind: 5 });
|
|
const reducedCost = Math.floor(baseCost * costMult5);
|
|
expect(reducedCost).toBe(75);
|
|
});
|
|
|
|
it('should reduce spell study cost', () => {
|
|
// Spell unlock cost is 100 for fireball
|
|
const baseCost = SPELLS_DEF.fireball.unlock;
|
|
const costMult5 = getStudyCostMultiplier({ focusedMind: 5 });
|
|
const reducedCost = Math.floor(baseCost * costMult5);
|
|
expect(reducedCost).toBe(75);
|
|
});
|
|
});
|
|
|
|
describe('meditation', () => {
|
|
it('should give 2.5x regen after 4 hours meditating', () => {
|
|
const bonus = getMeditationBonus(100, { meditation: 1 }); // 100 ticks = 4 hours
|
|
expect(bonus).toBe(2.5);
|
|
});
|
|
|
|
it('should not give bonus without enough time', () => {
|
|
const bonus = getMeditationBonus(50, { meditation: 1 }); // 2 hours
|
|
expect(bonus).toBeLessThan(2.5);
|
|
});
|
|
});
|
|
|
|
describe('knowledgeRetention', () => {
|
|
it('should save +20% study progress on cancel per level', () => {
|
|
expect(SKILLS_DEF.knowledgeRetention).toBeDefined();
|
|
expect(SKILLS_DEF.knowledgeRetention.max).toBe(3);
|
|
expect(SKILLS_DEF.knowledgeRetention.desc).toContain('20% study progress saved');
|
|
});
|
|
// Note: This is tested in cancelStudy integration
|
|
});
|
|
|
|
// ─── Research Skills ────────────────────────────────────────────────────────
|
|
|
|
describe('manaTap', () => {
|
|
it('should add +1 mana per click', () => {
|
|
const state0 = createMockState();
|
|
expect(computeClickMana(state0)).toBe(1);
|
|
|
|
const state1 = createMockState({ skills: { manaTap: 1 } });
|
|
expect(computeClickMana(state1)).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('manaSurge', () => {
|
|
it('should add +3 mana per click', () => {
|
|
const state = createMockState({ skills: { manaSurge: 1 } });
|
|
expect(computeClickMana(state)).toBe(1 + 3);
|
|
});
|
|
|
|
it('should stack with manaTap', () => {
|
|
const state = createMockState({ skills: { manaTap: 1, manaSurge: 1 } });
|
|
expect(computeClickMana(state)).toBe(1 + 1 + 3);
|
|
});
|
|
});
|
|
|
|
describe('manaSpring', () => {
|
|
it('should add +2 mana regen', () => {
|
|
const state = createMockState({ skills: { manaSpring: 1 } });
|
|
expect(computeRegen(state)).toBe(2 + 2);
|
|
});
|
|
});
|
|
|
|
describe('deepTrance', () => {
|
|
it('should extend meditation bonus to 6hrs for 3x', () => {
|
|
const bonus = getMeditationBonus(150, { meditation: 1, deepTrance: 1 }); // 6 hours
|
|
expect(bonus).toBe(3.0);
|
|
});
|
|
});
|
|
|
|
describe('voidMeditation', () => {
|
|
it('should extend meditation bonus to 8hrs for 5x', () => {
|
|
const bonus = getMeditationBonus(200, { meditation: 1, deepTrance: 1, voidMeditation: 1 }); // 8 hours
|
|
expect(bonus).toBe(5.0);
|
|
});
|
|
});
|
|
|
|
// ─── Ascension Skills ───────────────────────────────────────────────────────
|
|
|
|
describe('insightHarvest', () => {
|
|
it('should add +10% insight gain per level', () => {
|
|
const state0 = createMockState({ maxFloorReached: 10 });
|
|
const insight0 = calcInsight(state0);
|
|
|
|
const state3 = createMockState({ maxFloorReached: 10, skills: { insightHarvest: 3 } });
|
|
const insight3 = calcInsight(state3);
|
|
|
|
// Level 3 = 1.3x insight
|
|
expect(insight3).toBe(Math.floor(insight0 * 1.3));
|
|
});
|
|
});
|
|
|
|
describe('guardianBane', () => {
|
|
it('should add +20% damage vs guardians per level', () => {
|
|
expect(SKILLS_DEF.guardianBane).toBeDefined();
|
|
expect(SKILLS_DEF.guardianBane.max).toBe(3);
|
|
expect(SKILLS_DEF.guardianBane.desc).toContain('20% dmg vs guardians');
|
|
});
|
|
// Note: guardianBane is checked in calcDamage when floor is guardian floor
|
|
});
|
|
|
|
// ─── Crafting Skills ────────────────────────────────────────────────────────
|
|
|
|
describe('effCrafting', () => {
|
|
it('should reduce craft time by 10% per level', () => {
|
|
expect(SKILLS_DEF.effCrafting).toBeDefined();
|
|
expect(SKILLS_DEF.effCrafting.max).toBe(5);
|
|
expect(SKILLS_DEF.effCrafting.desc).toContain('10% craft time');
|
|
});
|
|
});
|
|
|
|
describe('durableConstruct', () => {
|
|
it('should add +1 max durability per level', () => {
|
|
expect(SKILLS_DEF.durableConstruct).toBeDefined();
|
|
expect(SKILLS_DEF.durableConstruct.max).toBe(5);
|
|
expect(SKILLS_DEF.durableConstruct.desc).toContain('+1 max durability');
|
|
});
|
|
});
|
|
|
|
describe('fieldRepair', () => {
|
|
it('should add +15% repair efficiency per level', () => {
|
|
expect(SKILLS_DEF.fieldRepair).toBeDefined();
|
|
expect(SKILLS_DEF.fieldRepair.max).toBe(5);
|
|
expect(SKILLS_DEF.fieldRepair.desc).toContain('15% repair efficiency');
|
|
});
|
|
});
|
|
|
|
describe('elemCrafting', () => {
|
|
it('should add +25% craft output per level', () => {
|
|
expect(SKILLS_DEF.elemCrafting).toBeDefined();
|
|
expect(SKILLS_DEF.elemCrafting.max).toBe(3);
|
|
expect(SKILLS_DEF.elemCrafting.desc).toContain('25% craft output');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Skill Requirement Tests ─────────────────────────────────────────────────
|
|
|
|
describe('Skill Requirements', () => {
|
|
it('deepReservoir should require manaWell 5', () => {
|
|
expect(SKILLS_DEF.deepReservoir.req).toEqual({ manaWell: 5 });
|
|
});
|
|
|
|
it('arcaneFury should require combatTrain 3', () => {
|
|
expect(SKILLS_DEF.arcaneFury.req).toEqual({ combatTrain: 3 });
|
|
});
|
|
|
|
it('elementalMastery should require arcaneFury 2', () => {
|
|
expect(SKILLS_DEF.elementalMastery.req).toEqual({ arcaneFury: 2 });
|
|
});
|
|
|
|
it('spellEcho should require quickCast 3', () => {
|
|
expect(SKILLS_DEF.spellEcho.req).toEqual({ quickCast: 3 });
|
|
});
|
|
|
|
it('manaSurge should require manaTap 1', () => {
|
|
expect(SKILLS_DEF.manaSurge.req).toEqual({ manaTap: 1 });
|
|
});
|
|
|
|
it('deepTrance should require meditation 1', () => {
|
|
expect(SKILLS_DEF.deepTrance.req).toEqual({ meditation: 1 });
|
|
});
|
|
|
|
it('voidMeditation should require deepTrance 1', () => {
|
|
expect(SKILLS_DEF.voidMeditation.req).toEqual({ deepTrance: 1 });
|
|
});
|
|
|
|
it('elemCrafting should require effCrafting 3', () => {
|
|
expect(SKILLS_DEF.elemCrafting.req).toEqual({ effCrafting: 3 });
|
|
});
|
|
});
|
|
|
|
// ─── Skill Evolution Tests ─────────────────────────────────────────────────────
|
|
|
|
describe('Skill Evolution System', () => {
|
|
describe('SKILL_EVOLUTION_PATHS', () => {
|
|
it('should have evolution paths for 6 base skills', () => {
|
|
expect(SKILL_EVOLUTION_PATHS.manaWell).toBeDefined();
|
|
expect(SKILL_EVOLUTION_PATHS.manaFlow).toBeDefined();
|
|
expect(SKILL_EVOLUTION_PATHS.combatTrain).toBeDefined();
|
|
expect(SKILL_EVOLUTION_PATHS.quickLearner).toBeDefined();
|
|
expect(SKILL_EVOLUTION_PATHS.focusedMind).toBeDefined();
|
|
expect(SKILL_EVOLUTION_PATHS.elemAttune).toBeDefined();
|
|
});
|
|
|
|
it('should have 5 tiers for each base skill', () => {
|
|
Object.values(SKILL_EVOLUTION_PATHS).forEach(path => {
|
|
expect(path.tiers).toHaveLength(5);
|
|
});
|
|
});
|
|
|
|
it('should have increasing multipliers for each tier', () => {
|
|
Object.values(SKILL_EVOLUTION_PATHS).forEach(path => {
|
|
let prevMult = 0;
|
|
path.tiers.forEach(tier => {
|
|
expect(tier.multiplier).toBeGreaterThan(prevMult);
|
|
prevMult = tier.multiplier;
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getUpgradesForSkillAtMilestone', () => {
|
|
it('should return 4 upgrades at each milestone', () => {
|
|
const upgradesL5 = getUpgradesForSkillAtMilestone('manaWell', 5, {});
|
|
const upgradesL10 = getUpgradesForSkillAtMilestone('manaWell', 10, {});
|
|
|
|
expect(upgradesL5).toHaveLength(4);
|
|
expect(upgradesL10).toHaveLength(4);
|
|
});
|
|
|
|
it('should return empty array for non-evolvable skills', () => {
|
|
const upgrades = getUpgradesForSkillAtMilestone('nonexistent', 5, {});
|
|
expect(upgrades).toHaveLength(0);
|
|
});
|
|
|
|
it('should have unique upgrade IDs for each milestone', () => {
|
|
const upgradesL5 = getUpgradesForSkillAtMilestone('manaWell', 5, {});
|
|
const upgradesL10 = getUpgradesForSkillAtMilestone('manaWell', 10, {});
|
|
|
|
const l5Ids = upgradesL5.map(u => u.id);
|
|
const l10Ids = upgradesL10.map(u => u.id);
|
|
|
|
// No overlap between milestones
|
|
expect(l5Ids.find(id => l10Ids.includes(id))).toBeUndefined();
|
|
});
|
|
|
|
it('should have valid effect types', () => {
|
|
Object.values(SKILL_EVOLUTION_PATHS).forEach(path => {
|
|
path.tiers.forEach(tier => {
|
|
tier.upgrades.forEach(upgrade => {
|
|
expect(['multiplier', 'bonus', 'special']).toContain(upgrade.effect.type);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getNextTierSkill', () => {
|
|
it('should return next tier for base skills', () => {
|
|
expect(getNextTierSkill('manaWell')).toBe('manaWell_t2');
|
|
expect(getNextTierSkill('manaFlow')).toBe('manaFlow_t2');
|
|
});
|
|
|
|
it('should return next tier for tier skills', () => {
|
|
expect(getNextTierSkill('manaWell_t2')).toBe('manaWell_t3');
|
|
expect(getNextTierSkill('manaWell_t3')).toBe('manaWell_t4');
|
|
expect(getNextTierSkill('manaWell_t4')).toBe('manaWell_t5');
|
|
});
|
|
|
|
it('should return null for max tier', () => {
|
|
expect(getNextTierSkill('manaWell_t5')).toBeNull();
|
|
});
|
|
|
|
it('should return null for non-evolvable skills', () => {
|
|
expect(getNextTierSkill('nonexistent')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getTierMultiplier', () => {
|
|
it('should return 1 for tier 1 skills', () => {
|
|
expect(getTierMultiplier('manaWell')).toBe(1);
|
|
expect(getTierMultiplier('manaWell_t1')).toBe(1);
|
|
});
|
|
|
|
it('should return correct multiplier for higher tiers', () => {
|
|
// Each tier is 10x more powerful so tier N level 1 = tier N-1 level 10
|
|
expect(getTierMultiplier('manaWell_t2')).toBe(10);
|
|
expect(getTierMultiplier('manaWell_t3')).toBe(100);
|
|
expect(getTierMultiplier('manaWell_t4')).toBe(1000);
|
|
expect(getTierMultiplier('manaWell_t5')).toBe(10000);
|
|
});
|
|
|
|
it('should return 1 for non-evolvable skills', () => {
|
|
expect(getTierMultiplier('nonexistent')).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('generateTierSkillDef', () => {
|
|
it('should generate valid skill definition for each tier', () => {
|
|
for (let tier = 1; tier <= 5; tier++) {
|
|
const def = generateTierSkillDef('manaWell', tier);
|
|
expect(def).toBeDefined();
|
|
expect(def?.tier).toBe(tier);
|
|
expect(def?.baseSkill).toBe('manaWell');
|
|
expect(def?.tierMultiplier).toBe(SKILL_EVOLUTION_PATHS.manaWell.tiers[tier - 1].multiplier);
|
|
}
|
|
});
|
|
|
|
it('should return null for non-evolvable base skill', () => {
|
|
expect(generateTierSkillDef('nonexistent', 1)).toBeNull();
|
|
});
|
|
|
|
it('should return null for invalid tier', () => {
|
|
expect(generateTierSkillDef('manaWell', 0)).toBeNull();
|
|
expect(generateTierSkillDef('manaWell', 6)).toBeNull();
|
|
});
|
|
|
|
it('should have correct tier names', () => {
|
|
expect(generateTierSkillDef('manaWell', 1)?.name).toBe('Mana Well');
|
|
expect(generateTierSkillDef('manaWell', 2)?.name).toBe('Deep Reservoir');
|
|
expect(generateTierSkillDef('manaWell', 3)?.name).toBe('Abyssal Pool');
|
|
expect(generateTierSkillDef('manaWell', 4)?.name).toBe('Ocean of Power');
|
|
expect(generateTierSkillDef('manaWell', 5)?.name).toBe('Infinite Reservoir');
|
|
});
|
|
|
|
it('should have scaling study time and cost', () => {
|
|
const def1 = generateTierSkillDef('manaWell', 1);
|
|
const def2 = generateTierSkillDef('manaWell', 2);
|
|
const def5 = generateTierSkillDef('manaWell', 5);
|
|
|
|
expect(def2?.studyTime).toBeGreaterThan(def1?.studyTime || 0);
|
|
expect(def5?.studyTime).toBeGreaterThan(def2?.studyTime || 0);
|
|
expect(def5?.base).toBeGreaterThan(def1?.base || 0);
|
|
});
|
|
});
|
|
|
|
describe('Upgrade Effect Validation', () => {
|
|
it('should have valid effect structures', () => {
|
|
Object.values(SKILL_EVOLUTION_PATHS).forEach(path => {
|
|
path.tiers.forEach(tier => {
|
|
tier.upgrades.forEach(upgrade => {
|
|
const eff = upgrade.effect;
|
|
|
|
if (eff.type === 'multiplier' || eff.type === 'bonus') {
|
|
expect(eff.value).toBeDefined();
|
|
expect(eff.value).toBeGreaterThan(0);
|
|
}
|
|
|
|
if (eff.type === 'special') {
|
|
expect(eff.specialId).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
it('should have descriptive names for all upgrades', () => {
|
|
Object.values(SKILL_EVOLUTION_PATHS).forEach(path => {
|
|
path.tiers.forEach(tier => {
|
|
tier.upgrades.forEach(upgrade => {
|
|
expect(upgrade.name.length).toBeGreaterThan(0);
|
|
expect(upgrade.desc.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Milestone Upgrade Choices', () => {
|
|
it('should only allow 2 upgrades per milestone', () => {
|
|
// This is enforced by canSelectUpgrade in the store
|
|
// We just verify there are 4 options available
|
|
Object.values(SKILL_EVOLUTION_PATHS).forEach(path => {
|
|
path.tiers.forEach(tier => {
|
|
const l5Upgrades = tier.upgrades.filter(u => u.milestone === 5);
|
|
const l10Upgrades = tier.upgrades.filter(u => u.milestone === 10);
|
|
|
|
expect(l5Upgrades).toHaveLength(4);
|
|
expect(l10Upgrades).toHaveLength(4);
|
|
});
|
|
});
|
|
});
|
|
|
|
it('should have unique upgrade IDs within each path', () => {
|
|
Object.values(SKILL_EVOLUTION_PATHS).forEach(path => {
|
|
const allIds: string[] = [];
|
|
path.tiers.forEach(tier => {
|
|
tier.upgrades.forEach(upgrade => {
|
|
expect(allIds).not.toContain(upgrade.id);
|
|
allIds.push(upgrade.id);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Milestone Upgrade Effect Tests ─────────────────────────────────────────────
|
|
|
|
describe('Milestone Upgrade Effects', () => {
|
|
describe('Multiplier Effect Upgrades', () => {
|
|
it('should have correct multiplier effect structure', () => {
|
|
// Get the Expanded Capacity upgrade (+25% max mana bonus)
|
|
const upgrades = getUpgradesForSkillAtMilestone('manaWell', 5, {});
|
|
const capacityUpgrade = upgrades.find(u => u.id === 'mw_t1_l5_capacity');
|
|
|
|
expect(capacityUpgrade).toBeDefined();
|
|
expect(capacityUpgrade?.effect.type).toBe('multiplier');
|
|
expect(capacityUpgrade?.effect.stat).toBe('maxMana');
|
|
expect(capacityUpgrade?.effect.value).toBe(1.25);
|
|
});
|
|
|
|
it('should have multiplier upgrades for different stats', () => {
|
|
// Mana Well: +25% max mana
|
|
const mwUpgrades = getUpgradesForSkillAtMilestone('manaWell', 5, {});
|
|
const mwMult = mwUpgrades.find(u => u.effect.type === 'multiplier');
|
|
expect(mwMult?.effect.stat).toBe('maxMana');
|
|
|
|
// Mana Flow: +25% regen speed
|
|
const mfUpgrades = getUpgradesForSkillAtMilestone('manaFlow', 5, {});
|
|
const mfMult = mfUpgrades.find(u => u.effect.type === 'multiplier');
|
|
expect(mfMult?.effect.stat).toBe('regen');
|
|
|
|
// Combat Training: +25% base damage
|
|
const ctUpgrades = getUpgradesForSkillAtMilestone('combatTrain', 5, {});
|
|
const ctMult = ctUpgrades.find(u => u.effect.type === 'multiplier');
|
|
expect(ctMult?.effect.stat).toBe('baseDamage');
|
|
|
|
// Quick Learner: +25% study speed
|
|
const qlUpgrades = getUpgradesForSkillAtMilestone('quickLearner', 5, {});
|
|
const qlMult = qlUpgrades.find(u => u.effect.type === 'multiplier');
|
|
expect(qlMult?.effect.stat).toBe('studySpeed');
|
|
});
|
|
|
|
it('should have multiplier values greater than 1 for positive effects', () => {
|
|
Object.values(SKILL_EVOLUTION_PATHS).forEach(path => {
|
|
path.tiers.forEach(tier => {
|
|
tier.upgrades.forEach(upgrade => {
|
|
if (upgrade.effect.type === 'multiplier') {
|
|
// Most multipliers should be > 1 (bonuses) or < 1 (reductions)
|
|
expect(upgrade.effect.value).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
it('should have cost reduction multipliers less than 1', () => {
|
|
// Mana Efficiency: -5% spell costs = 0.95 multiplier
|
|
const mwUpgrades = getUpgradesForSkillAtMilestone('manaWell', 10, {});
|
|
const efficiency = mwUpgrades.find(u => u.id === 'mw_t1_l10_efficiency');
|
|
|
|
expect(efficiency).toBeDefined();
|
|
expect(efficiency?.effect.type).toBe('multiplier');
|
|
expect(efficiency?.effect.value).toBeLessThan(1);
|
|
expect(efficiency?.effect.value).toBe(0.95);
|
|
});
|
|
});
|
|
|
|
describe('Bonus Effect Upgrades', () => {
|
|
it('should have correct bonus effect structure', () => {
|
|
// Get the Natural Spring upgrade (+0.5 regen per hour)
|
|
const upgrades = getUpgradesForSkillAtMilestone('manaWell', 5, {});
|
|
const regenUpgrade = upgrades.find(u => u.id === 'mw_t1_l5_regen');
|
|
|
|
expect(regenUpgrade).toBeDefined();
|
|
expect(regenUpgrade?.effect.type).toBe('bonus');
|
|
expect(regenUpgrade?.effect.stat).toBe('regen');
|
|
expect(regenUpgrade?.effect.value).toBe(0.5);
|
|
});
|
|
|
|
it('should have bonus upgrades for different stats', () => {
|
|
// Mana Well: +0.5 regen per hour
|
|
const mwUpgrades = getUpgradesForSkillAtMilestone('manaWell', 5, {});
|
|
const mwBonus = mwUpgrades.find(u => u.effect.type === 'bonus' && u.effect.stat === 'regen');
|
|
expect(mwBonus).toBeDefined();
|
|
|
|
// Mana Flow: +1 regen permanently
|
|
const mfUpgrades = getUpgradesForSkillAtMilestone('manaFlow', 10, {});
|
|
const mfBonus = mfUpgrades.find(u => u.effect.type === 'bonus' && u.effect.stat === 'permanentRegen');
|
|
expect(mfBonus).toBeDefined();
|
|
expect(mfBonus?.effect.value).toBe(1);
|
|
});
|
|
|
|
it('should have positive bonus values', () => {
|
|
Object.values(SKILL_EVOLUTION_PATHS).forEach(path => {
|
|
path.tiers.forEach(tier => {
|
|
tier.upgrades.forEach(upgrade => {
|
|
if (upgrade.effect.type === 'bonus') {
|
|
expect(upgrade.effect.value).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
it('should have tier 2+ bonus upgrades that scale appropriately', () => {
|
|
// Tier 2 Mana Well L10: +1000 max mana
|
|
const mwT2Upgrades = SKILL_EVOLUTION_PATHS.manaWell.tiers[1].upgrades;
|
|
const oceanUpgrade = mwT2Upgrades.find(u => u.id === 'mw_t2_l10_ocean');
|
|
|
|
expect(oceanUpgrade).toBeDefined();
|
|
expect(oceanUpgrade?.effect.type).toBe('bonus');
|
|
expect(oceanUpgrade?.effect.stat).toBe('maxMana');
|
|
expect(oceanUpgrade?.effect.value).toBe(1000);
|
|
});
|
|
});
|
|
|
|
describe('Special Effect Upgrades', () => {
|
|
it('should have correct special effect structure', () => {
|
|
// Get the Mana Threshold upgrade (special)
|
|
const upgrades = getUpgradesForSkillAtMilestone('manaWell', 5, {});
|
|
const thresholdUpgrade = upgrades.find(u => u.id === 'mw_t1_l5_threshold');
|
|
|
|
expect(thresholdUpgrade).toBeDefined();
|
|
expect(thresholdUpgrade?.effect.type).toBe('special');
|
|
expect(thresholdUpgrade?.effect.specialId).toBe('manaThreshold');
|
|
expect(thresholdUpgrade?.effect.specialDesc).toBeDefined();
|
|
});
|
|
|
|
it('should have unique special IDs for each special effect', () => {
|
|
const allSpecialIds: string[] = [];
|
|
|
|
Object.values(SKILL_EVOLUTION_PATHS).forEach(path => {
|
|
path.tiers.forEach(tier => {
|
|
tier.upgrades.forEach(upgrade => {
|
|
if (upgrade.effect.type === 'special') {
|
|
expect(allSpecialIds).not.toContain(upgrade.effect.specialId);
|
|
allSpecialIds.push(upgrade.effect.specialId!);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
it('should have descriptive special descriptions', () => {
|
|
Object.values(SKILL_EVOLUTION_PATHS).forEach(path => {
|
|
path.tiers.forEach(tier => {
|
|
tier.upgrades.forEach(upgrade => {
|
|
if (upgrade.effect.type === 'special') {
|
|
expect(upgrade.effect.specialDesc).toBeDefined();
|
|
expect(upgrade.effect.specialDesc!.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
it('should have special effects at both milestones', () => {
|
|
// Level 5 special
|
|
const l5Upgrades = getUpgradesForSkillAtMilestone('manaWell', 5, {});
|
|
const l5Special = l5Upgrades.find(u => u.effect.type === 'special');
|
|
expect(l5Special).toBeDefined();
|
|
expect(l5Special?.milestone).toBe(5);
|
|
|
|
// Level 10 special
|
|
const l10Upgrades = getUpgradesForSkillAtMilestone('manaWell', 10, {});
|
|
const l10Special = l10Upgrades.find(u => u.effect.type === 'special');
|
|
expect(l10Special).toBeDefined();
|
|
expect(l10Special?.milestone).toBe(10);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Upgrade Selection Tests ───────────────────────────────────────────────────
|
|
|
|
describe('Upgrade Selection System', () => {
|
|
describe('getSkillUpgradeChoices', () => {
|
|
it('should return 4 available upgrades at each milestone', () => {
|
|
const l5Choices = getUpgradesForSkillAtMilestone('manaWell', 5, {});
|
|
const l10Choices = getUpgradesForSkillAtMilestone('manaWell', 10, {});
|
|
|
|
expect(l5Choices).toHaveLength(4);
|
|
expect(l10Choices).toHaveLength(4);
|
|
});
|
|
|
|
it('should return empty array for non-evolvable skills', () => {
|
|
const upgrades = getUpgradesForSkillAtMilestone('nonexistent', 5, {});
|
|
expect(upgrades).toHaveLength(0);
|
|
});
|
|
|
|
it('should return correct upgrades for tier skills', () => {
|
|
// Tier 2 should have different upgrades than tier 1
|
|
const t1Upgrades = getUpgradesForSkillAtMilestone('manaWell', 5, { manaWell: 1 });
|
|
const t2Upgrades = getUpgradesForSkillAtMilestone('manaWell', 5, { manaWell: 2 });
|
|
|
|
// Both should return tier 1 upgrades since we're asking for base skill
|
|
// The skillTiers parameter tells us what tier the skill is currently at
|
|
expect(t1Upgrades).toHaveLength(4);
|
|
expect(t2Upgrades).toHaveLength(4);
|
|
});
|
|
|
|
it('should have upgrades with correct milestone values', () => {
|
|
const l5Upgrades = getUpgradesForSkillAtMilestone('manaWell', 5, {});
|
|
const l10Upgrades = getUpgradesForSkillAtMilestone('manaWell', 10, {});
|
|
|
|
l5Upgrades.forEach(u => expect(u.milestone).toBe(5));
|
|
l10Upgrades.forEach(u => expect(u.milestone).toBe(10));
|
|
});
|
|
});
|
|
|
|
describe('Upgrade Selection Constraints', () => {
|
|
it('should allow selecting upgrades when at milestone level', () => {
|
|
// The canSelectUpgrade function requires the skill level to be >= milestone
|
|
// This is tested by checking the logic in the store
|
|
const upgrades = getUpgradesForSkillAtMilestone('manaWell', 5, {});
|
|
expect(upgrades.length).toBe(4);
|
|
});
|
|
|
|
it('should only allow 2 upgrades per milestone', () => {
|
|
// The store enforces: selected.length >= 2 returns false
|
|
// We verify there are enough choices (4) to select 2 from
|
|
const l5Upgrades = getUpgradesForSkillAtMilestone('manaWell', 5, {});
|
|
const l10Upgrades = getUpgradesForSkillAtMilestone('manaWell', 10, {});
|
|
|
|
// Each milestone should have exactly 4 options to choose 2 from
|
|
expect(l5Upgrades.length).toBeGreaterThanOrEqual(2);
|
|
expect(l10Upgrades.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it('should have unique upgrade IDs to prevent duplicate selection', () => {
|
|
const l5Upgrades = getUpgradesForSkillAtMilestone('manaWell', 5, {});
|
|
const l10Upgrades = getUpgradesForSkillAtMilestone('manaWell', 10, {});
|
|
|
|
const l5Ids = l5Upgrades.map(u => u.id);
|
|
const l10Ids = l10Upgrades.map(u => u.id);
|
|
|
|
// No duplicate IDs within same milestone
|
|
expect(new Set(l5Ids).size).toBe(l5Ids.length);
|
|
expect(new Set(l10Ids).size).toBe(l10Ids.length);
|
|
|
|
// No overlap between milestones
|
|
const overlap = l5Ids.filter(id => l10Ids.includes(id));
|
|
expect(overlap).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('Upgrade Persistence', () => {
|
|
it('should store upgrades in skillUpgrades record', () => {
|
|
// Create a mock state with upgrades
|
|
const state = createMockState({
|
|
skills: { manaWell: 5 },
|
|
skillUpgrades: { manaWell: ['mw_t1_l5_capacity', 'mw_t1_l5_regen'] }
|
|
});
|
|
|
|
expect(state.skillUpgrades['manaWell']).toBeDefined();
|
|
expect(state.skillUpgrades['manaWell']).toHaveLength(2);
|
|
expect(state.skillUpgrades['manaWell']).toContain('mw_t1_l5_capacity');
|
|
expect(state.skillUpgrades['manaWell']).toContain('mw_t1_l5_regen');
|
|
});
|
|
|
|
it('should persist upgrades across state changes', () => {
|
|
// Simulate state changes
|
|
const state1 = createMockState({
|
|
skills: { manaWell: 5 },
|
|
skillUpgrades: { manaWell: ['mw_t1_l5_capacity'] }
|
|
});
|
|
|
|
// State changes shouldn't affect upgrades
|
|
const state2 = {
|
|
...state1,
|
|
rawMana: 500,
|
|
day: 10,
|
|
};
|
|
|
|
expect(state2.skillUpgrades['manaWell']).toEqual(['mw_t1_l5_capacity']);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Tier Up System Tests ─────────────────────────────────────────────────────
|
|
|
|
describe('Tier Up System', () => {
|
|
describe('getNextTierSkill', () => {
|
|
it('should return correct next tier for base skills', () => {
|
|
expect(getNextTierSkill('manaWell')).toBe('manaWell_t2');
|
|
expect(getNextTierSkill('manaFlow')).toBe('manaFlow_t2');
|
|
expect(getNextTierSkill('combatTrain')).toBe('combatTrain_t2');
|
|
expect(getNextTierSkill('quickLearner')).toBe('quickLearner_t2');
|
|
expect(getNextTierSkill('focusedMind')).toBe('focusedMind_t2');
|
|
expect(getNextTierSkill('elemAttune')).toBe('elemAttune_t2');
|
|
});
|
|
|
|
it('should return correct next tier for tier skills', () => {
|
|
expect(getNextTierSkill('manaWell_t2')).toBe('manaWell_t3');
|
|
expect(getNextTierSkill('manaWell_t3')).toBe('manaWell_t4');
|
|
expect(getNextTierSkill('manaWell_t4')).toBe('manaWell_t5');
|
|
});
|
|
|
|
it('should return null for max tier (tier 5)', () => {
|
|
expect(getNextTierSkill('manaWell_t5')).toBeNull();
|
|
});
|
|
|
|
it('should return null for non-evolvable skills', () => {
|
|
expect(getNextTierSkill('nonexistent')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getTierMultiplier', () => {
|
|
it('should return 1 for tier 1 skills', () => {
|
|
expect(getTierMultiplier('manaWell')).toBe(1);
|
|
expect(getTierMultiplier('manaFlow')).toBe(1);
|
|
});
|
|
|
|
it('should return increasing multipliers for higher tiers', () => {
|
|
// Each tier is 10x more powerful
|
|
expect(getTierMultiplier('manaWell_t2')).toBe(10);
|
|
expect(getTierMultiplier('manaWell_t3')).toBe(100);
|
|
expect(getTierMultiplier('manaWell_t4')).toBe(1000);
|
|
expect(getTierMultiplier('manaWell_t5')).toBe(10000);
|
|
});
|
|
|
|
it('should return consistent multipliers across skill paths', () => {
|
|
// All skills should have same tier multipliers
|
|
expect(getTierMultiplier('manaWell_t2')).toBe(getTierMultiplier('manaFlow_t2'));
|
|
expect(getTierMultiplier('manaWell_t3')).toBe(getTierMultiplier('combatTrain_t3'));
|
|
expect(getTierMultiplier('manaWell_t4')).toBe(getTierMultiplier('quickLearner_t4'));
|
|
});
|
|
});
|
|
|
|
describe('Tier Up Mechanics', () => {
|
|
it('should start new tier at level 1 after tier up', () => {
|
|
// Simulate the tier up result
|
|
const baseSkillId = 'manaWell';
|
|
const nextTierId = getNextTierSkill(baseSkillId);
|
|
|
|
expect(nextTierId).toBe('manaWell_t2');
|
|
|
|
// After tier up, the skill should be at level 1
|
|
const newSkills = { [nextTierId!]: 1 };
|
|
expect(newSkills['manaWell_t2']).toBe(1);
|
|
});
|
|
|
|
it('should carry over upgrades to new tier', () => {
|
|
// Simulate tier up with existing upgrades
|
|
const oldUpgrades = ['mw_t1_l5_capacity', 'mw_t1_l10_echo'];
|
|
const nextTierId = 'manaWell_t2';
|
|
|
|
// Upgrades should be carried over
|
|
const newSkillUpgrades = { [nextTierId]: oldUpgrades };
|
|
expect(newSkillUpgrades['manaWell_t2']).toEqual(oldUpgrades);
|
|
});
|
|
|
|
it('should update skillTiers when tiering up', () => {
|
|
// Tier up from base to tier 2
|
|
const baseSkillId = 'manaWell';
|
|
const nextTier = 2;
|
|
|
|
const newSkillTiers = { [baseSkillId]: nextTier };
|
|
expect(newSkillTiers['manaWell']).toBe(2);
|
|
});
|
|
|
|
it('should remove old skill when tiering up', () => {
|
|
// After tier up, old skill should be removed
|
|
const oldSkillId = 'manaWell';
|
|
const newSkillId = 'manaWell_t2';
|
|
|
|
const newSkills: Record<string, number> = {};
|
|
newSkills[newSkillId] = 1;
|
|
|
|
expect(newSkills[oldSkillId]).toBeUndefined();
|
|
expect(newSkills[newSkillId]).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('generateTierSkillDef', () => {
|
|
it('should generate valid skill definitions for all tiers', () => {
|
|
for (let tier = 1; tier <= 5; tier++) {
|
|
const def = generateTierSkillDef('manaWell', tier);
|
|
expect(def).toBeDefined();
|
|
expect(def?.tier).toBe(tier);
|
|
expect(def?.max).toBe(10);
|
|
}
|
|
});
|
|
|
|
it('should have correct tier names', () => {
|
|
expect(generateTierSkillDef('manaWell', 1)?.name).toBe('Mana Well');
|
|
expect(generateTierSkillDef('manaWell', 2)?.name).toBe('Deep Reservoir');
|
|
expect(generateTierSkillDef('manaWell', 3)?.name).toBe('Abyssal Pool');
|
|
expect(generateTierSkillDef('manaWell', 4)?.name).toBe('Ocean of Power');
|
|
expect(generateTierSkillDef('manaWell', 5)?.name).toBe('Infinite Reservoir');
|
|
});
|
|
|
|
it('should have scaling costs and study times', () => {
|
|
const tier1 = generateTierSkillDef('manaWell', 1);
|
|
const tier5 = generateTierSkillDef('manaWell', 5);
|
|
|
|
expect(tier5?.base).toBeGreaterThan(tier1?.base || 0);
|
|
expect(tier5?.studyTime).toBeGreaterThan(tier1?.studyTime || 0);
|
|
});
|
|
|
|
it('should return null for invalid tiers', () => {
|
|
expect(generateTierSkillDef('manaWell', 0)).toBeNull();
|
|
expect(generateTierSkillDef('manaWell', 6)).toBeNull();
|
|
expect(generateTierSkillDef('nonexistent', 1)).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Tier Effect Computation Tests ─────────────────────────────────────────────
|
|
|
|
describe('Tier Effect Computation', () => {
|
|
describe('Tiered Skill Bonuses', () => {
|
|
it('should apply tier multiplier to skill bonuses', () => {
|
|
// Tier 1 manaWell level 5 should give +500 mana
|
|
const state1 = createMockState({
|
|
skills: { manaWell: 5 },
|
|
skillTiers: {}
|
|
});
|
|
const mana1 = computeMaxMana(state1);
|
|
|
|
// Base 100 + 5 * 100 = 600
|
|
expect(mana1).toBe(600);
|
|
});
|
|
|
|
it('should have tier multiplier affect effective level', () => {
|
|
// Tier 2 skill should have 10x multiplier
|
|
const state2 = createMockState({
|
|
skills: { manaWell_t2: 5 },
|
|
skillTiers: { manaWell: 2 }
|
|
});
|
|
|
|
// The computation should use tier multiplier
|
|
// This tests that the state structure is correct
|
|
expect(state2.skillTiers['manaWell']).toBe(2);
|
|
expect(state2.skills['manaWell_t2']).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe('Tier Upgrade Access', () => {
|
|
it('should provide correct upgrades for current tier', () => {
|
|
// Tier 1 should access tier 1 upgrades
|
|
const t1Upgrades = getUpgradesForSkillAtMilestone('manaWell', 5, { manaWell: 1 });
|
|
expect(t1Upgrades[0]?.id).toContain('mw_t1');
|
|
|
|
// Tier 2 should access tier 2 upgrades
|
|
const t2Upgrades = getUpgradesForSkillAtMilestone('manaWell', 5, { manaWell: 2 });
|
|
expect(t2Upgrades[0]?.id).toContain('mw_t2');
|
|
});
|
|
|
|
it('should have more powerful upgrades at higher tiers', () => {
|
|
const t1L5 = getUpgradesForSkillAtMilestone('manaWell', 5, { manaWell: 1 });
|
|
const t2L5 = getUpgradesForSkillAtMilestone('manaWell', 5, { manaWell: 2 });
|
|
|
|
// Find multiplier upgrades
|
|
const t1Mult = t1L5.find(u => u.effect.type === 'multiplier');
|
|
const t2Mult = t2L5.find(u => u.effect.type === 'multiplier');
|
|
|
|
// Tier 2 should have stronger effects
|
|
if (t1Mult && t2Mult) {
|
|
expect(t2Mult.effect.value).toBeGreaterThanOrEqual(t1Mult.effect.value);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Upgrade Effect Application Tests ─────────────────────────────────────────────
|
|
|
|
describe('Upgrade Effect Application', () => {
|
|
it('should apply max mana multiplier upgrade to computeMaxMana', () => {
|
|
// Without upgrade
|
|
const stateNoUpgrade = createMockState({
|
|
skills: { manaWell: 5 },
|
|
skillUpgrades: {}
|
|
});
|
|
const manaNoUpgrade = computeMaxMana(stateNoUpgrade);
|
|
|
|
// With Expanded Capacity (+25% max mana)
|
|
const stateWithUpgrade = createMockState({
|
|
skills: { manaWell: 5 },
|
|
skillUpgrades: { manaWell: ['mw_t1_l5_capacity'] }
|
|
});
|
|
const manaWithUpgrade = computeMaxMana(stateWithUpgrade);
|
|
|
|
// Should be 25% more
|
|
expect(manaWithUpgrade).toBe(Math.floor(manaNoUpgrade * 1.25));
|
|
});
|
|
|
|
it('should apply max mana bonus upgrade to computeMaxMana', () => {
|
|
// Without upgrade
|
|
const stateNoUpgrade = createMockState({
|
|
skills: { manaWell: 10 },
|
|
skillUpgrades: {}
|
|
});
|
|
const manaNoUpgrade = computeMaxMana(stateNoUpgrade);
|
|
|
|
// With Ocean of Mana (+1000 max mana at tier 2 level 10)
|
|
const stateWithUpgrade = createMockState({
|
|
skills: { manaWell_t2: 10 },
|
|
skillTiers: { manaWell: 2 },
|
|
skillUpgrades: { manaWell_t2: ['mw_t2_l10_ocean'] }
|
|
});
|
|
const manaWithUpgrade = computeMaxMana(stateWithUpgrade);
|
|
|
|
// Should have bonus added
|
|
expect(manaWithUpgrade).toBeGreaterThan(manaNoUpgrade);
|
|
});
|
|
|
|
it('should apply regen multiplier upgrade to computeRegen', () => {
|
|
// Without upgrade
|
|
const stateNoUpgrade = createMockState({
|
|
skills: { manaFlow: 5 },
|
|
skillUpgrades: {}
|
|
});
|
|
const regenNoUpgrade = computeRegen(stateNoUpgrade);
|
|
|
|
// With Rapid Flow (+25% regen)
|
|
const stateWithUpgrade = createMockState({
|
|
skills: { manaFlow: 5 },
|
|
skillUpgrades: { manaFlow: ['mf_t1_l5_rapid'] }
|
|
});
|
|
const regenWithUpgrade = computeRegen(stateWithUpgrade);
|
|
|
|
// Should be 25% more
|
|
expect(regenWithUpgrade).toBe(regenNoUpgrade * 1.25);
|
|
});
|
|
|
|
it('should apply regen bonus upgrade to computeRegen', () => {
|
|
// Without upgrade - base regen only
|
|
const stateNoUpgrade = createMockState({
|
|
skills: {},
|
|
skillUpgrades: {}
|
|
});
|
|
const regenNoUpgrade = computeRegen(stateNoUpgrade);
|
|
|
|
// With Natural Spring (+0.5 regen) - from manaWell upgrades
|
|
const stateWithUpgrade = createMockState({
|
|
skills: {},
|
|
skillUpgrades: { manaWell: ['mw_t1_l5_regen'] }
|
|
});
|
|
const regenWithUpgrade = computeRegen(stateWithUpgrade);
|
|
|
|
// Should have +0.5 bonus added
|
|
expect(regenNoUpgrade).toBe(2); // Base regen is 2
|
|
expect(regenWithUpgrade).toBe(2.5); // 2 + 0.5
|
|
});
|
|
|
|
it('should apply element cap multiplier upgrade to computeElementMax', () => {
|
|
// Without upgrade
|
|
const stateNoUpgrade = createMockState({
|
|
skills: { elemAttune: 5 },
|
|
skillUpgrades: {}
|
|
});
|
|
const elemNoUpgrade = computeElementMax(stateNoUpgrade);
|
|
|
|
// With Expanded Attunement (+25% element cap)
|
|
const stateWithUpgrade = createMockState({
|
|
skills: { elemAttune: 5 },
|
|
skillUpgrades: { elemAttune: ['ea_t1_l5_expand'] }
|
|
});
|
|
const elemWithUpgrade = computeElementMax(stateWithUpgrade);
|
|
|
|
// Should be 25% more
|
|
expect(elemWithUpgrade).toBe(Math.floor(elemNoUpgrade * 1.25));
|
|
});
|
|
|
|
it('should apply permanent regen bonus from upgrade', () => {
|
|
// With Ambient Absorption (+1 permanent regen)
|
|
const stateWithUpgrade = createMockState({
|
|
skills: { manaFlow: 10 },
|
|
skillUpgrades: { manaFlow: ['mf_t1_l10_ambient'] }
|
|
});
|
|
const regenWithUpgrade = computeRegen(stateWithUpgrade);
|
|
|
|
// Without upgrade
|
|
const stateNoUpgrade = createMockState({
|
|
skills: { manaFlow: 10 },
|
|
skillUpgrades: {}
|
|
});
|
|
const regenNoUpgrade = computeRegen(stateNoUpgrade);
|
|
|
|
// Should have +1 from permanent regen bonus
|
|
expect(regenWithUpgrade).toBe(regenNoUpgrade + 1);
|
|
});
|
|
|
|
it('should stack multiple upgrades correctly', () => {
|
|
// With two upgrades: +25% max mana AND +25% max mana (from tier 2)
|
|
const stateWithUpgrades = createMockState({
|
|
skills: { manaWell: 5 },
|
|
skillUpgrades: { manaWell: ['mw_t1_l5_capacity', 'mw_t1_l5_regen'] }
|
|
});
|
|
|
|
// The +25% max mana multiplier should be applied once
|
|
// The +0.5 regen bonus should be applied
|
|
const mana = computeMaxMana(stateWithUpgrades);
|
|
const regen = computeRegen(stateWithUpgrades);
|
|
|
|
// Base mana: 100 + 5*100 = 600, with 1.25x = 750
|
|
expect(mana).toBe(750);
|
|
// Base regen: 2 + 0 = 2, with +0.5 = 2.5
|
|
expect(regen).toBe(2.5);
|
|
});
|
|
|
|
it('should apply click mana bonuses', () => {
|
|
// Without upgrades
|
|
const stateNoUpgrade = createMockState({
|
|
skills: {},
|
|
skillUpgrades: {}
|
|
});
|
|
const clickNoUpgrade = computeClickMana(stateNoUpgrade);
|
|
|
|
// Base click mana is 1
|
|
expect(clickNoUpgrade).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ─── Special Effect Tests ─────────────────────────────────────────────────────────
|
|
|
|
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from './upgrade-effects';
|
|
|
|
describe('Special Effect Application', () => {
|
|
describe('Mana Cascade', () => {
|
|
it('should add regen based on max mana', () => {
|
|
// Mana Cascade: +0.1 regen per 100 max mana
|
|
// With 1000 max mana, should add 1.0 regen
|
|
const state = createMockState({
|
|
skills: { manaFlow: 5, manaWell: 5 }, // manaWell 5 gives 500 mana, base 100 = 600
|
|
skillUpgrades: { manaFlow: ['mf_t1_l5_cascade'] }
|
|
});
|
|
|
|
const effects = computeEffects(state.skillUpgrades, state.skillTiers || {});
|
|
expect(hasSpecial(effects, SPECIAL_EFFECTS.MANA_CASCADE)).toBe(true);
|
|
|
|
// Compute max mana to check cascade calculation
|
|
const maxMana = computeMaxMana(state);
|
|
|
|
// Expected cascade bonus: floor(maxMana / 100) * 0.1
|
|
const expectedCascadeBonus = Math.floor(maxMana / 100) * 0.1;
|
|
|
|
// Base regen: 2 + manaFlow level (5) = 7
|
|
// With cascade: 7 + cascadeBonus
|
|
const baseRegen = computeRegen(state);
|
|
|
|
// The regen should be increased by cascade bonus
|
|
// Note: computeRegen doesn't include cascade - computeEffectiveRegen does
|
|
expect(baseRegen).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('Steady Stream', () => {
|
|
it('should be recognized as active when selected', () => {
|
|
const state = createMockState({
|
|
skills: { manaFlow: 5 },
|
|
skillUpgrades: { manaFlow: ['mf_t1_l5_steady'] }
|
|
});
|
|
|
|
const effects = computeEffects(state.skillUpgrades, state.skillTiers || {});
|
|
expect(hasSpecial(effects, SPECIAL_EFFECTS.STEADY_STREAM)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Mana Echo', () => {
|
|
it('should be recognized as active when selected', () => {
|
|
const state = createMockState({
|
|
skills: { manaWell: 10 },
|
|
skillUpgrades: { manaWell: ['mw_t1_l10_echo'] }
|
|
});
|
|
|
|
const effects = computeEffects(state.skillUpgrades, state.skillTiers || {});
|
|
expect(hasSpecial(effects, SPECIAL_EFFECTS.MANA_ECHO)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Combat Special Effects', () => {
|
|
it('should recognize Berserker special effect', () => {
|
|
const state = createMockState({
|
|
skills: { combatTrain: 10 },
|
|
skillUpgrades: { combatTrain: ['ct_t1_l10_berserker'] }
|
|
});
|
|
|
|
const effects = computeEffects(state.skillUpgrades, state.skillTiers || {});
|
|
expect(hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER)).toBe(true);
|
|
});
|
|
|
|
it('should recognize Adrenaline Rush special effect', () => {
|
|
const state = createMockState({
|
|
skills: { combatTrain: 10 },
|
|
skillUpgrades: { combatTrain: ['ct_t1_l10_adrenaline'] }
|
|
});
|
|
|
|
const effects = computeEffects(state.skillUpgrades, state.skillTiers || {});
|
|
expect(hasSpecial(effects, SPECIAL_EFFECTS.ADRENALINE_RUSH)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Study Special Effects', () => {
|
|
it('should recognize Mental Clarity special effect', () => {
|
|
const state = createMockState({
|
|
skills: { focusedMind: 5 },
|
|
skillUpgrades: { focusedMind: ['fm_t1_l5_clarity'] }
|
|
});
|
|
|
|
const effects = computeEffects(state.skillUpgrades, state.skillTiers || {});
|
|
expect(hasSpecial(effects, SPECIAL_EFFECTS.MENTAL_CLARITY)).toBe(true);
|
|
});
|
|
|
|
it('should recognize Study Rush special effect', () => {
|
|
const state = createMockState({
|
|
skills: { focusedMind: 10 },
|
|
skillUpgrades: { focusedMind: ['fm_t1_l10_rush'] }
|
|
});
|
|
|
|
const effects = computeEffects(state.skillUpgrades, state.skillTiers || {});
|
|
expect(hasSpecial(effects, SPECIAL_EFFECTS.STUDY_RUSH)).toBe(true);
|
|
});
|
|
|
|
it('should apply study speed multiplier from upgrades', () => {
|
|
// With Deep Focus (+25% study speed)
|
|
const state = createMockState({
|
|
skills: { quickLearner: 5 },
|
|
skillUpgrades: { quickLearner: ['ql_t1_l5_focus'] }
|
|
});
|
|
|
|
const effects = computeEffects(state.skillUpgrades, state.skillTiers || {});
|
|
expect(effects.studySpeedMultiplier).toBe(1.25);
|
|
});
|
|
});
|
|
|
|
describe('Effect Stacking', () => {
|
|
it('should track multiple special effects simultaneously', () => {
|
|
const state = createMockState({
|
|
skills: { manaFlow: 10 },
|
|
skillUpgrades: { manaFlow: ['mf_t1_l5_cascade', 'mf_t1_l5_steady', 'mf_t1_l10_ambient'] }
|
|
});
|
|
|
|
const effects = computeEffects(state.skillUpgrades, state.skillTiers || {});
|
|
expect(hasSpecial(effects, SPECIAL_EFFECTS.MANA_CASCADE)).toBe(true);
|
|
expect(hasSpecial(effects, SPECIAL_EFFECTS.STEADY_STREAM)).toBe(true);
|
|
expect(effects.permanentRegenBonus).toBe(1); // Ambient Absorption gives +1 permanent regen
|
|
});
|
|
|
|
it('should stack multipliers correctly', () => {
|
|
// Rapid Flow (+25% regen) + River of Mana (+50% regen) at tier 2
|
|
const state = createMockState({
|
|
skills: { manaFlow_t2: 5 },
|
|
skillTiers: { manaFlow: 2 },
|
|
skillUpgrades: { manaFlow_t2: ['mf_t2_l5_river'] }
|
|
});
|
|
|
|
const effects = computeEffects(state.skillUpgrades, state.skillTiers || {});
|
|
expect(effects.regenMultiplier).toBe(1.5); // River of Mana gives 1.5x
|
|
});
|
|
});
|
|
|
|
describe('Meditation Efficiency', () => {
|
|
it('should apply Deep Wellspring meditation efficiency', () => {
|
|
// Deep Wellspring: +50% meditation efficiency
|
|
const state = createMockState({
|
|
skills: { manaWell: 10 },
|
|
skillUpgrades: { manaWell: ['mw_t1_l10_meditation'] }
|
|
});
|
|
|
|
const effects = computeEffects(state.skillUpgrades, state.skillTiers || {});
|
|
expect(effects.meditationEfficiency).toBe(1.5); // +50% efficiency
|
|
});
|
|
|
|
it('should boost meditation bonus with efficiency', () => {
|
|
// Without efficiency
|
|
const baseBonus = getMeditationBonus(100, { meditation: 1 }, 1); // 4 hours with meditation skill
|
|
expect(baseBonus).toBe(2.5); // Base with meditation skill
|
|
|
|
// With Deep Wellspring (+50% efficiency)
|
|
const boostedBonus = getMeditationBonus(100, { meditation: 1 }, 1.5);
|
|
expect(boostedBonus).toBe(3.75); // 2.5 * 1.5 = 3.75
|
|
});
|
|
});
|
|
});
|
|
|
|
console.log('✅ All tests defined. Run with: bun test src/lib/game/store.test.ts');
|