Some checks failed
Build and Publish Mana Loop Docker Image / build-and-publish (push) Has been cancelled
Major changes: - Created docs/skills.md with comprehensive skill system documentation - Rewrote skill-evolution.ts with new upgrade tree structure: - Upgrades organized in branching paths with prerequisites - Each choice can lead to upgraded versions at future milestones - Support for upgrade children and requirement chains - Added getBaseSkillId and generateTierSkillDef helper functions - Fixed getFloorElement to use FLOOR_ELEM_CYCLE.length - Updated test files to match current skill definitions - Removed tests for non-existent skills Skill system now supports: - Levels 1-10 for most skills, level 5 caps for specialized, level 1 for research - Tier up system: Tier N Level 1 = Tier N-1 Level 10 in power - Milestone upgrades at levels 5 and 10 with branching upgrade trees - Attunement requirements for skill access and tier up - Study costs and time for leveling
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');
|