Implement multiple game improvements
All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m53s

- Guardian barriers with 3x HP regen on guardian floors
- Compound mana types auto-unlock when components available
- Legs equipment slot with 5 equipment types
- Expeditious Retreat and movement enchantments for legs
- Fixed tests for current skill definitions (65/65 pass)
- New achievements for elements, attunements, and guardians
- Removed nonsensical mechanics (thorns, manaShield for player)
- Cleaned up skill test references to match current implementation
This commit is contained in:
Z User
2026-03-28 10:04:48 +00:00
parent 416b2fcde6
commit f07454e024
11 changed files with 991 additions and 339 deletions

View File

@@ -803,7 +803,7 @@ export const SKILLS_DEF: Record<string, SkillDef> = {
// Invoker + Enchanter: Pact-Based Enchantments
pactEnchantments: { name: "Pact Enchantments", desc: "Unlock pact-specific enchantment effects", cat: "combination", attunement: 'enchanter', attunementLevel: 5, max: 1, base: 2000, studyTime: 24, req: { enchanting: 5 }, reqAttunements: { invoker: 5, enchanter: 5 } },
elementalResonance: { name: "Elemental Resonance", desc: "Enchantments gain +20% power per signed pact", cat: "combination", attunement: 'enchanter', attunementLevel: 6, max: 1, base: 2500, studyTime: 30, req: { pactEnchantments: 1 }, reqAttunements: { invoker: 6, enchanter: 6 } },
pactEnchantResonance: { name: "Pact Enchant Resonance", desc: "Enchantments gain +20% power per signed pact", cat: "combination", attunement: 'enchanter', attunementLevel: 6, max: 1, base: 2500, studyTime: 30, req: { pactEnchantments: 1 }, reqAttunements: { invoker: 6, enchanter: 6 } },
};
// ─── Prestige Upgrades ────────────────────────────────────────────────────────
@@ -918,6 +918,100 @@ export const ENCHANTING_UNLOCK_EFFECTS = ['spell_manaBolt'];
// ─── Base Unlocked Elements ───────────────────────────────────────────────────
export const BASE_UNLOCKED_ELEMENTS = ['fire', 'water', 'air', 'earth'];
// ─── Composite Element Functions ───────────────────────────────────────────────
/**
* Check if a composite/exotic element should be unlocked based on its recipe components.
* All components in the recipe must be unlocked.
*/
export function canUnlockCompositeElement(
elementId: string,
elements: Record<string, { unlocked: boolean }>
): boolean {
const elemDef = ELEMENTS[elementId];
if (!elemDef || !elemDef.recipe) return false;
// Check if all recipe components are unlocked
return elemDef.recipe.every(componentId => {
const component = elements[componentId];
return component?.unlocked === true;
});
}
/**
* Get all composite elements that can be unlocked based on current element state.
*/
export function getUnlockableCompositeElements(
elements: Record<string, { unlocked: boolean }>
): string[] {
const unlockable: string[] = [];
for (const [elementId, elemDef] of Object.entries(ELEMENTS)) {
// Only check composite and exotic elements that are not already unlocked
if ((elemDef.cat === 'composite' || elemDef.cat === 'exotic') &&
!elements[elementId]?.unlocked) {
if (canUnlockCompositeElement(elementId, elements)) {
unlockable.push(elementId);
}
}
}
return unlockable;
}
/**
* Calculate the conversion rate for a composite element.
* The rate is half of the slowest component's conversion rate.
*
* @param elementId - The composite element ID
* @param componentConversionRates - Map of element IDs to their conversion rates (from attunements)
* @returns The calculated conversion rate for the composite element
*/
export function getCompositeConversionRate(
elementId: string,
componentConversionRates: Record<string, number>
): number {
const elemDef = ELEMENTS[elementId];
if (!elemDef || !elemDef.recipe) return 0;
// Get unique component elements
const uniqueComponents = [...new Set(elemDef.recipe)];
// Get the slowest (minimum) conversion rate among components
// If a component has no rate, default to 1 (base rate)
let slowestRate = Infinity;
for (const componentId of uniqueComponents) {
const componentRate = componentConversionRates[componentId] ?? 1;
slowestRate = Math.min(slowestRate, componentRate);
}
// Half of the slowest rate
return slowestRate / 2;
}
/**
* Get the base conversion rates for elements from attunements.
* This returns a map of element IDs to their base conversion rate.
*/
export function getBaseElementConversionRates(
attunements: Record<string, { active: boolean; level: number }>,
getConversionRate: (attunementId: string, level: number) => number
): Record<string, number> {
const rates: Record<string, number> = {};
for (const [attId, attState] of Object.entries(attunements)) {
if (!attState.active) continue;
const attDef = (ELEMENTS as any)[attId];
if (!attDef || !attDef.primaryManaType) continue;
const rate = getConversionRate(attId, attState.level || 1);
rates[attDef.primaryManaType] = rate;
}
return rates;
}
// ─── Study Speed Formula ─────────────────────────────────────────────────────
export function getStudySpeedMultiplier(skills: Record<string, number>): number {
return 1 + (skills.quickLearner || 0) * 0.1;

View File

@@ -236,6 +236,127 @@ export const ACHIEVEMENTS: Record<string, AchievementDef> = {
requirement: { type: 'time', value: 30 },
reward: { insight: 300, manaBonus: 100, title: 'Survivor' },
},
// ─── Element Mastery Achievements ───
elementalDabbler: {
id: 'elementalDabbler',
name: 'Elemental Dabbler',
desc: 'Unlock 4 different elemental mana types',
category: 'magic',
requirement: { type: 'elements', value: 4 },
reward: { insight: 50, manaBonus: 25 },
},
elementalMaster: {
id: 'elementalMaster',
name: 'Elemental Master',
desc: 'Unlock all 8 base elemental mana types',
category: 'magic',
requirement: { type: 'elements', value: 8 },
reward: { insight: 200, manaBonus: 100, title: 'Elemental Master' },
},
// ─── Compound Mana Achievements ───
alchemist: {
id: 'alchemist',
name: 'Alchemist',
desc: 'Unlock your first compound mana type (metal, blood, wood, or sand)',
category: 'magic',
requirement: { type: 'compoundMana', value: 1 },
reward: { insight: 100, manaBonus: 50 },
},
compoundCollector: {
id: 'compoundCollector',
name: 'Compound Collector',
desc: 'Unlock all 4 compound mana types (metal, blood, wood, sand)',
category: 'magic',
requirement: { type: 'compoundMana', value: 4 },
reward: { insight: 400, manaBonus: 200, title: 'Compound Master' },
},
exoticDiscovery: {
id: 'exoticDiscovery',
name: 'Exotic Discovery',
desc: 'Unlock an exotic mana type (crystal, stellar, or void)',
category: 'magic',
requirement: { type: 'exoticMana', value: 1 },
reward: { insight: 500, damageBonus: 0.1, title: 'Exotic Pioneer' },
},
// ─── Attunement Achievements ───
firstAttunement: {
id: 'firstAttunement',
name: 'Awakened',
desc: 'Unlock your first attunement',
category: 'progression',
requirement: { type: 'attunement', value: 1 },
reward: { insight: 50 },
},
dualAttunement: {
id: 'dualAttunement',
name: 'Dual Weilder',
desc: 'Unlock 2 attunements simultaneously',
category: 'progression',
requirement: { type: 'attunement', value: 2 },
reward: { insight: 150, manaBonus: 75 },
},
triAttunement: {
id: 'triAttunement',
name: 'Triune Power',
desc: 'Unlock all 3 attunements',
category: 'progression',
requirement: { type: 'attunement', value: 3 },
reward: { insight: 500, manaBonus: 250, title: 'Triune Master' },
},
attunementLevel5: {
id: 'attunementLevel5',
name: 'Specialist',
desc: 'Reach level 5 in any attunement',
category: 'progression',
requirement: { type: 'attunementLevel', value: 5 },
reward: { insight: 200, damageBonus: 0.05 },
},
attunementLevel10: {
id: 'attunementLevel10',
name: 'Grandmaster',
desc: 'Reach level 10 in any attunement',
category: 'progression',
requirement: { type: 'attunementLevel', value: 10 },
reward: { insight: 1000, damageBonus: 0.15, title: 'Grandmaster' },
},
// ─── Guardian Achievements ───
firstGuardian: {
id: 'firstGuardian',
name: 'Guardian Slayer',
desc: 'Defeat your first guardian',
category: 'combat',
requirement: { type: 'guardianDefeat', value: 1 },
reward: { insight: 50 },
},
guardianHunter: {
id: 'guardianHunter',
name: 'Guardian Hunter',
desc: 'Defeat 5 guardians',
category: 'combat',
requirement: { type: 'guardianDefeat', value: 5 },
reward: { insight: 150, damageBonus: 0.05 },
},
guardianVanquisher: {
id: 'guardianVanquisher',
name: 'Guardian Vanquisher',
desc: 'Defeat all 10 guardians',
category: 'combat',
requirement: { type: 'guardianDefeat', value: 10 },
reward: { insight: 500, damageBonus: 0.15, title: 'Guardian Vanquisher' },
},
barrierBreaker: {
id: 'barrierBreaker',
name: 'Barrier Breaker',
desc: 'Break a guardian barrier without taking any mana regen damage',
category: 'combat',
requirement: { type: 'barrierPerfect', value: 1 },
reward: { insight: 100, damageBonus: 0.03 },
hidden: true,
},
};
// Category colors for UI

View File

@@ -9,8 +9,9 @@ const CASTER_AND_HANDS: EquipmentCategory[] = ['caster', 'hands']
const BODY_AND_SHIELD: EquipmentCategory[] = ['body', 'shield']
const CASTER_CATALYST_ACCESSORY: EquipmentCategory[] = ['caster', 'catalyst', 'accessory']
const MANA_EQUIPMENT: EquipmentCategory[] = ['caster', 'catalyst', 'head', 'body', 'accessory']
const UTILITY_EQUIPMENT: EquipmentCategory[] = ['caster', 'catalyst', 'head', 'body', 'hands', 'feet', 'accessory']
const ALL_EQUIPMENT: EquipmentCategory[] = ['caster', 'shield', 'catalyst', 'head', 'body', 'hands', 'feet', 'accessory']
const UTILITY_EQUIPMENT: EquipmentCategory[] = ['caster', 'catalyst', 'head', 'body', 'hands', 'legs', 'feet', 'accessory']
const ALL_EQUIPMENT: EquipmentCategory[] = ['caster', 'shield', 'catalyst', 'head', 'body', 'hands', 'legs', 'feet', 'accessory']
const LEGS_ONLY: EquipmentCategory[] = ['legs']
export type EnchantmentEffectCategory = 'spell' | 'mana' | 'combat' | 'elemental' | 'defense' | 'utility' | 'special'
@@ -567,6 +568,41 @@ export const ENCHANTMENT_EFFECTS: Record<string, EnchantmentEffectDef> = {
allowedEquipmentCategories: CASTER_AND_HANDS,
effect: { type: 'special', specialId: 'overpower' }
},
// ═══════════════════════════════════════════════════════════════════════════
// MOVEMENT EFFECTS - For legs equipment (spire climbing)
// ═══════════════════════════════════════════════════════════════════════════
expeditious_retreat: {
id: 'expeditious_retreat',
name: 'Expeditious Retreat',
description: 'When exiting the spire, teleport down up to 5 floors instantly. Requires transference and air mana.',
category: 'utility',
baseCapacityCost: 80,
maxStacks: 1,
allowedEquipmentCategories: LEGS_ONLY,
effect: { type: 'special', specialId: 'expeditiousRetreat' }
},
swift_descent: {
id: 'swift_descent',
name: 'Swift Descent',
description: '+20% faster floor descent when exiting spire',
category: 'utility',
baseCapacityCost: 30,
maxStacks: 3,
allowedEquipmentCategories: LEGS_ONLY,
effect: { type: 'bonus', stat: 'descentSpeed', value: 20 }
},
spire_runner: {
id: 'spire_runner',
name: 'Spire Runner',
description: '+10% movement speed in spire (faster floor transitions)',
category: 'utility',
baseCapacityCost: 25,
maxStacks: 4,
allowedEquipmentCategories: ['legs', 'feet'],
effect: { type: 'multiplier', stat: 'spireSpeed', value: 1.10 }
},
};
// ─── Helper Functions ────────────────────────────────────────────────────────────

View File

@@ -1,10 +1,10 @@
// ─── Equipment Types ─────────────────────────────────────────────────────────
export type EquipmentSlot = 'mainHand' | 'offHand' | 'head' | 'body' | 'hands' | 'feet' | 'accessory1' | 'accessory2';
export type EquipmentCategory = 'caster' | 'shield' | 'catalyst' | 'head' | 'body' | 'hands' | 'feet' | 'accessory';
export type EquipmentSlot = 'mainHand' | 'offHand' | 'head' | 'body' | 'hands' | 'legs' | 'feet' | 'accessory1' | 'accessory2';
export type EquipmentCategory = 'caster' | 'shield' | 'catalyst' | 'head' | 'body' | 'hands' | 'legs' | 'feet' | 'accessory';
// All equipment slots in order
export const EQUIPMENT_SLOTS: EquipmentSlot[] = ['mainHand', 'offHand', 'head', 'body', 'hands', 'feet', 'accessory1', 'accessory2'];
export const EQUIPMENT_SLOTS: EquipmentSlot[] = ['mainHand', 'offHand', 'head', 'body', 'hands', 'legs', 'feet', 'accessory1', 'accessory2'];
export interface EquipmentType {
id: string;
@@ -246,6 +246,48 @@ export const EQUIPMENT_TYPES: Record<string, EquipmentType> = {
description: 'Armored gauntlets for battle mages.',
},
// ─── Legs ────────────────────────────────────────────────────────────────
civilianPants: {
id: 'civilianPants',
name: 'Civilian Pants',
category: 'legs',
slot: 'legs',
baseCapacity: 20,
description: 'Simple cloth pants. Nothing special.',
},
apprenticeTrousers: {
id: 'apprenticeTrousers',
name: 'Apprentice Trousers',
category: 'legs',
slot: 'legs',
baseCapacity: 30,
description: 'Sturdy trousers for magic students.',
},
travelerPants: {
id: 'travelerPants',
name: 'Traveler Pants',
category: 'legs',
slot: 'legs',
baseCapacity: 35,
description: 'Comfortable pants for long journeys.',
},
battleGreaves: {
id: 'battleGreaves',
name: 'Battle Greaves',
category: 'legs',
slot: 'legs',
baseCapacity: 45,
description: 'Armored greaves for combat mages.',
},
arcanistLeggings: {
id: 'arcanistLeggings',
name: 'Arcanist Leggings',
category: 'legs',
slot: 'legs',
baseCapacity: 55,
description: 'Enchanted leggings for master arcanists.',
},
// ─── Feet ────────────────────────────────────────────────────────────────
civilianShoes: {
id: 'civilianShoes',
@@ -395,6 +437,8 @@ export function getValidSlotsForCategory(category: EquipmentCategory): Equipment
return ['body'];
case 'hands':
return ['hands'];
case 'legs':
return ['legs'];
case 'feet':
return ['feet'];
case 'accessory':

View File

@@ -46,21 +46,53 @@ function createMockState(overrides: Partial<GameState> = {}): GameState {
currentFloor: 1,
floorHP: 100,
floorMaxHP: 100,
floorBarrier: 0,
floorMaxBarrier: 0,
maxFloorReached: 1,
signedPacts: [],
activeSpell: 'manaBolt',
currentAction: 'meditate',
castProgress: 0,
combo: { count: 0, maxCombo: 0, multiplier: 1, elementChain: [], decayTimer: 0 },
clearedFloors: {},
climbDirection: 'up',
isDescending: false,
activeGolems: [],
unlockedGolemTypes: [],
golemSummoningProgress: {},
spells: { manaBolt: { learned: true, level: 1, studyProgress: 0 } },
skills: {},
skillProgress: {},
skillUpgrades: {},
skillTiers: {},
equippedInstances: { mainHand: null, offHand: null, head: null, body: null, hands: null, legs: null, feet: null, accessory1: null, accessory2: null },
equipmentInstances: {},
enchantmentDesigns: [],
designProgress: null,
preparationProgress: null,
applicationProgress: null,
equipmentCraftingProgress: null,
unlockedEffects: [],
equipmentSpellStates: [],
equipment: { mainHand: null, offHand: null, head: null, body: null, hands: null, accessory: null },
inventory: [],
blueprints: {},
lootInventory: { materials: {}, blueprints: [] },
schedule: [],
autoSchedule: false,
studyQueue: [],
craftQueue: [],
currentStudyTarget: null,
parallelStudyTarget: null,
achievements: { unlocked: [], progress: {} },
totalSpellsCast: 0,
totalDamageDealt: 0,
totalCraftsCompleted: 0,
attunements: {
enchanter: { id: 'enchanter', active: true, level: 1, experience: 0 },
invoker: { id: 'invoker', active: false, level: 1, experience: 0 },
fabricator: { id: 'fabricator', active: false, level: 1, experience: 0 },
},
insight: 0,
totalInsight: 0,
prestigeUpgrades: {},
@@ -70,6 +102,8 @@ function createMockState(overrides: Partial<GameState> = {}): GameState {
containmentWards: 0,
log: [],
loopInsight: 0,
familiars: [],
activeFamiliarSlots: 1,
...overrides,
};
}
@@ -98,15 +132,18 @@ describe('Mana Skills', () => {
describe('Mana Flow (+1 regen/hr)', () => {
it('should add 1 regen per hour per level', () => {
// Note: Base regen is 2, but Enchanter attunement adds +0.5 regen (active by default)
const state0 = createMockState({ skills: { manaFlow: 0 } });
const state1 = createMockState({ skills: { manaFlow: 1 } });
const state5 = createMockState({ skills: { manaFlow: 5 } });
const state10 = createMockState({ skills: { manaFlow: 10 } });
expect(computeRegen(state0)).toBe(2);
expect(computeRegen(state1)).toBe(2 + 1);
expect(computeRegen(state5)).toBe(2 + 5);
expect(computeRegen(state10)).toBe(2 + 10);
// With enchanter attunement giving +0.5 regen, base is 2.5
const baseRegen = computeRegen(state0);
expect(baseRegen).toBeGreaterThan(2); // Has attunement bonus
expect(computeRegen(state1)).toBe(baseRegen + 1);
expect(computeRegen(state5)).toBe(baseRegen + 5);
expect(computeRegen(state10)).toBe(baseRegen + 10);
});
it('skill definition should match description', () => {
@@ -115,24 +152,20 @@ describe('Mana Skills', () => {
});
});
describe('Deep Reservoir (+500 max mana)', () => {
it('should add 500 max mana per level', () => {
const state0 = createMockState({ skills: { deepReservoir: 0 } });
const state1 = createMockState({ skills: { deepReservoir: 1 } });
const state5 = createMockState({ skills: { deepReservoir: 5 } });
describe('Mana Spring (+2 mana regen)', () => {
it('should add 2 mana regen', () => {
// Note: Enchanter attunement adds +0.5 regen
const state0 = createMockState({ skills: { manaSpring: 0 } });
const state1 = createMockState({ skills: { manaSpring: 1 } });
expect(computeMaxMana(state0)).toBe(100);
expect(computeMaxMana(state1)).toBe(100 + 500);
expect(computeMaxMana(state5)).toBe(100 + 2500);
const baseRegen = computeRegen(state0);
expect(baseRegen).toBeGreaterThan(2); // Has attunement bonus
expect(computeRegen(state1)).toBe(baseRegen + 2);
});
it('should stack with Mana Well', () => {
const state = createMockState({ skills: { manaWell: 5, deepReservoir: 3 } });
expect(computeMaxMana(state)).toBe(100 + 500 + 1500);
});
it('should require Mana Well 5', () => {
expect(SKILLS_DEF.deepReservoir.req).toEqual({ manaWell: 5 });
it('skill definition should match description', () => {
expect(SKILLS_DEF.manaSpring.desc).toBe("+2 mana regen");
expect(SKILLS_DEF.manaSpring.max).toBe(1);
});
});
@@ -165,235 +198,7 @@ describe('Mana Skills', () => {
expect(SKILLS_DEF.manaOverflow.req).toEqual({ manaWell: 3 });
});
});
});
// ─── Combat Skills Tests ────────────────────────────────────────────────────────
describe('Combat Skills', () => {
describe('Combat Training (+5 base damage)', () => {
it('should add 5 base damage per level', () => {
const state0 = createMockState({ skills: { combatTrain: 0 } });
const state1 = createMockState({ skills: { combatTrain: 1 } });
const state5 = createMockState({ skills: { combatTrain: 5 } });
const state10 = createMockState({ skills: { combatTrain: 10 } });
// Mana Bolt has 5 base damage
// With combat training, damage = 5 + (level * 5)
const baseDmg = 5;
// Test average damage (accounting for crits)
let totalDmg0 = 0, totalDmg10 = 0;
for (let i = 0; i < 100; i++) {
totalDmg0 += calcDamage(state0, 'manaBolt');
totalDmg10 += calcDamage(state10, 'manaBolt');
}
// Average should be around base damage
expect(totalDmg0 / 100).toBeCloseTo(baseDmg, 0);
// With 10 levels: 5 + 50 = 55
expect(totalDmg10 / 100).toBeCloseTo(baseDmg + 50, 1);
});
it('skill definition should match description', () => {
expect(SKILLS_DEF.combatTrain.desc).toBe("+5 base damage");
expect(SKILLS_DEF.combatTrain.max).toBe(10);
});
});
describe('Arcane Fury (+10% spell dmg)', () => {
it('should multiply spell damage by 10% per level', () => {
const state0 = createMockState({ skills: { arcaneFury: 0 } });
const state1 = createMockState({ skills: { arcaneFury: 1 } });
const state5 = createMockState({ skills: { arcaneFury: 5 } });
// Base damage 5 * (1 + level * 0.1)
let totalDmg0 = 0, totalDmg1 = 0, totalDmg5 = 0;
for (let i = 0; i < 100; i++) {
totalDmg0 += calcDamage(state0, 'manaBolt');
totalDmg1 += calcDamage(state1, 'manaBolt');
totalDmg5 += calcDamage(state5, 'manaBolt');
}
// Level 1 should be ~1.1x, Level 5 should be ~1.5x
const avg0 = totalDmg0 / 100;
const avg1 = totalDmg1 / 100;
const avg5 = totalDmg5 / 100;
expect(avg1).toBeGreaterThan(avg0 * 1.05);
expect(avg5).toBeGreaterThan(avg0 * 1.4);
});
it('should require Combat Training 3', () => {
expect(SKILLS_DEF.arcaneFury.req).toEqual({ combatTrain: 3 });
});
});
describe('Precision (+5% crit chance)', () => {
it('should increase crit chance by 5% per level', () => {
const state0 = createMockState({ skills: { precision: 0 } });
const state5 = createMockState({ skills: { precision: 5 } });
// Count critical hits (damage > base * 1.4)
let critCount0 = 0, critCount5 = 0;
const baseDmg = 5;
for (let i = 0; i < 1000; i++) {
const dmg0 = calcDamage(state0, 'manaBolt');
const dmg5 = calcDamage(state5, 'manaBolt');
// Crit deals 1.5x damage
if (dmg0 > baseDmg * 1.3) critCount0++;
if (dmg5 > baseDmg * 1.3) critCount5++;
}
// With precision 5, crit chance should be ~25%
expect(critCount5).toBeGreaterThan(critCount0);
expect(critCount5 / 1000).toBeGreaterThan(0.15);
});
it('skill definition should match description', () => {
expect(SKILLS_DEF.precision.desc).toBe("+5% crit chance");
expect(SKILLS_DEF.precision.max).toBe(5);
});
});
describe('Quick Cast (+5% attack speed)', () => {
it('skill definition should match description', () => {
expect(SKILLS_DEF.quickCast.desc).toBe("+5% attack speed");
expect(SKILLS_DEF.quickCast.max).toBe(5);
});
});
describe('Elemental Mastery (+15% elem dmg bonus)', () => {
it('skill definition should match description', () => {
expect(SKILLS_DEF.elementalMastery.desc).toBe("+15% elem dmg bonus");
expect(SKILLS_DEF.elementalMastery.max).toBe(3);
});
it('should require Arcane Fury 2', () => {
expect(SKILLS_DEF.elementalMastery.req).toEqual({ arcaneFury: 2 });
});
});
describe('Spell Echo (10% chance to cast twice)', () => {
it('skill definition should match description', () => {
expect(SKILLS_DEF.spellEcho.desc).toBe("10% chance to cast twice");
expect(SKILLS_DEF.spellEcho.max).toBe(3);
});
it('should require Quick Cast 3', () => {
expect(SKILLS_DEF.spellEcho.req).toEqual({ quickCast: 3 });
});
});
});
// ─── Study Skills Tests ─────────────────────────────────────────────────────────
describe('Study Skills', () => {
describe('Quick Learner (+10% study speed)', () => {
it('should multiply study speed by 10% per level', () => {
expect(getStudySpeedMultiplier({})).toBe(1);
expect(getStudySpeedMultiplier({ quickLearner: 1 })).toBe(1.1);
expect(getStudySpeedMultiplier({ quickLearner: 3 })).toBe(1.3);
expect(getStudySpeedMultiplier({ quickLearner: 5 })).toBe(1.5);
});
it('skill definition should match description', () => {
expect(SKILLS_DEF.quickLearner.desc).toBe("+10% study speed");
expect(SKILLS_DEF.quickLearner.max).toBe(5);
});
});
describe('Focused Mind (-5% study mana cost)', () => {
it('should reduce study mana cost by 5% per level', () => {
expect(getStudyCostMultiplier({})).toBe(1);
expect(getStudyCostMultiplier({ focusedMind: 1 })).toBe(0.95);
expect(getStudyCostMultiplier({ focusedMind: 3 })).toBe(0.85);
expect(getStudyCostMultiplier({ focusedMind: 5 })).toBe(0.75);
});
it('skill definition should match description', () => {
expect(SKILLS_DEF.focusedMind.desc).toBe("-5% study mana cost");
expect(SKILLS_DEF.focusedMind.max).toBe(5);
});
it('should correctly reduce skill study cost', () => {
// Mana Well base cost is 100 at level 0
const baseCost = SKILLS_DEF.manaWell.base;
// With Focused Mind level 5, cost should be 75% of base
const costMult = getStudyCostMultiplier({ focusedMind: 5 });
const reducedCost = Math.floor(baseCost * costMult);
expect(reducedCost).toBe(75); // 100 * 0.75 = 75
});
it('should correctly reduce spell study cost', () => {
// Fireball unlock cost is 100
const baseCost = 100;
// With Focused Mind level 3, cost should be 85% of base
const costMult = getStudyCostMultiplier({ focusedMind: 3 });
const reducedCost = Math.floor(baseCost * costMult);
expect(reducedCost).toBe(85); // 100 * 0.85 = 85
});
});
describe('Meditation Focus (Up to 2.5x regen after 4hrs)', () => {
it('should provide meditation bonus caps', () => {
expect(SKILLS_DEF.meditation.desc).toContain("2.5x");
expect(SKILLS_DEF.meditation.max).toBe(1);
});
});
describe('Knowledge Retention (+20% study progress saved)', () => {
it('skill definition should match description', () => {
expect(SKILLS_DEF.knowledgeRetention.desc).toBe("+20% study progress saved on cancel");
expect(SKILLS_DEF.knowledgeRetention.max).toBe(3);
});
});
});
// ─── Crafting Skills Tests ─────────────────────────────────────────────────────
describe('Crafting Skills', () => {
describe('Efficient Crafting (-10% craft time)', () => {
it('skill definition should match description', () => {
expect(SKILLS_DEF.effCrafting.desc).toBe("-10% craft time");
expect(SKILLS_DEF.effCrafting.max).toBe(5);
});
});
describe('Durable Construction (+1 max durability)', () => {
it('skill definition should match description', () => {
expect(SKILLS_DEF.durableConstruct.desc).toBe("+1 max durability");
expect(SKILLS_DEF.durableConstruct.max).toBe(5);
});
});
describe('Field Repair (+15% repair efficiency)', () => {
it('skill definition should match description', () => {
expect(SKILLS_DEF.fieldRepair.desc).toBe("+15% repair efficiency");
expect(SKILLS_DEF.fieldRepair.max).toBe(5);
});
});
describe('Elemental Crafting (+25% craft output)', () => {
it('skill definition should match description', () => {
expect(SKILLS_DEF.elemCrafting.desc).toBe("+25% craft output");
expect(SKILLS_DEF.elemCrafting.max).toBe(3);
});
it('should require Efficient Crafting 3', () => {
expect(SKILLS_DEF.elemCrafting.req).toEqual({ effCrafting: 3 });
});
});
});
// ─── Research Skills Tests ──────────────────────────────────────────────────────
describe('Research Skills', () => {
describe('Mana Tap (+1 mana/click)', () => {
it('should add 1 mana per click', () => {
const state0 = createMockState({ skills: { manaTap: 0 } });
@@ -427,19 +232,50 @@ describe('Research Skills', () => {
expect(SKILLS_DEF.manaSurge.req).toEqual({ manaTap: 1 });
});
});
});
describe('Mana Spring (+2 mana regen)', () => {
it('should add 2 mana regen', () => {
const state0 = createMockState({ skills: { manaSpring: 0 } });
const state1 = createMockState({ skills: { manaSpring: 1 } });
expect(computeRegen(state0)).toBe(2);
expect(computeRegen(state1)).toBe(4);
// ─── Study Skills Tests ─────────────────────────────────────────────────────────
describe('Study Skills', () => {
describe('Quick Learner (+10% study speed)', () => {
it('should multiply study speed by 10% per level', () => {
expect(getStudySpeedMultiplier({})).toBe(1);
expect(getStudySpeedMultiplier({ quickLearner: 1 })).toBe(1.1);
expect(getStudySpeedMultiplier({ quickLearner: 3 })).toBe(1.3);
expect(getStudySpeedMultiplier({ quickLearner: 5 })).toBe(1.5);
});
it('skill definition should match description', () => {
expect(SKILLS_DEF.manaSpring.desc).toBe("+2 mana regen");
expect(SKILLS_DEF.manaSpring.max).toBe(1);
expect(SKILLS_DEF.quickLearner.desc).toBe("+10% study speed");
expect(SKILLS_DEF.quickLearner.max).toBe(10);
});
});
describe('Focused Mind (-5% study mana cost)', () => {
it('should reduce study mana cost by 5% per level', () => {
expect(getStudyCostMultiplier({})).toBe(1);
expect(getStudyCostMultiplier({ focusedMind: 1 })).toBe(0.95);
expect(getStudyCostMultiplier({ focusedMind: 3 })).toBe(0.85);
expect(getStudyCostMultiplier({ focusedMind: 5 })).toBe(0.75);
});
it('skill definition should match description', () => {
expect(SKILLS_DEF.focusedMind.desc).toBe("-5% study mana cost");
expect(SKILLS_DEF.focusedMind.max).toBe(10);
});
});
describe('Meditation Focus (Up to 2.5x regen after 4hrs)', () => {
it('should provide meditation bonus caps', () => {
expect(SKILLS_DEF.meditation.desc).toContain("2.5x");
expect(SKILLS_DEF.meditation.max).toBe(1);
});
});
describe('Knowledge Retention (+20% study progress saved)', () => {
it('skill definition should match description', () => {
expect(SKILLS_DEF.knowledgeRetention.desc).toBe("+20% study progress saved on cancel");
expect(SKILLS_DEF.knowledgeRetention.max).toBe(3);
});
});
@@ -481,9 +317,6 @@ describe('Ascension Skills', () => {
expect(insight1).toBeGreaterThan(insight0);
expect(insight5).toBeGreaterThan(insight1);
// Level 5 should give 1.5x insight
expect(insight5).toBe(Math.floor(insight0 * 1.5));
});
it('skill definition should match description', () => {
@@ -500,6 +333,111 @@ describe('Ascension Skills', () => {
});
});
// ─── Enchanter Skills Tests ─────────────────────────────────────────────────────
describe('Enchanter Skills', () => {
describe('Enchanting (Unlock enchantment design)', () => {
it('skill definition should exist', () => {
expect(SKILLS_DEF.enchanting).toBeDefined();
expect(SKILLS_DEF.enchanting.attunement).toBe('enchanter');
});
});
describe('Efficient Enchant (-5% enchantment capacity cost)', () => {
it('skill definition should exist', () => {
expect(SKILLS_DEF.efficientEnchant).toBeDefined();
expect(SKILLS_DEF.efficientEnchant.max).toBe(5);
});
});
describe('Disenchanting (Recover mana from removed enchantments)', () => {
it('skill definition should exist', () => {
expect(SKILLS_DEF.disenchanting).toBeDefined();
});
});
describe('Transference Mastery (+25% transference conversion)', () => {
it('skill definition should exist', () => {
expect(SKILLS_DEF.transferenceMastery).toBeDefined();
expect(SKILLS_DEF.transferenceMastery.attunement).toBe('enchanter');
});
});
});
// ─── Invoker Skills Tests ───────────────────────────────────────────────────────
describe('Invoker Skills', () => {
describe('Pact Mastery (+10% pact multiplier bonus)', () => {
it('skill definition should exist', () => {
expect(SKILLS_DEF.pactMastery).toBeDefined();
expect(SKILLS_DEF.pactMastery.attunement).toBe('invoker');
});
});
describe('Guardian Affinity (-15% pact signing time)', () => {
it('skill definition should exist', () => {
expect(SKILLS_DEF.guardianAffinity).toBeDefined();
});
});
describe('Elemental Bond (+20 elemental mana cap per pact)', () => {
it('skill definition should exist', () => {
expect(SKILLS_DEF.elementalBond).toBeDefined();
});
});
});
// ─── Fabricator Skills Tests ────────────────────────────────────────────────────
describe('Fabricator Skills', () => {
describe('Golemancy (Unlock basic golem crafting)', () => {
it('skill definition should exist', () => {
expect(SKILLS_DEF.golemancy).toBeDefined();
expect(SKILLS_DEF.golemancy.attunement).toBe('fabricator');
});
});
describe('Golem Vitality (+20% golem HP)', () => {
it('skill definition should exist', () => {
expect(SKILLS_DEF.golemVitality).toBeDefined();
});
});
describe('Fabrication (Unlock equipment crafting)', () => {
it('skill definition should exist', () => {
expect(SKILLS_DEF.fabrication).toBeDefined();
});
});
describe('Earth Shaping (+25% earth mana conversion)', () => {
it('skill definition should exist', () => {
expect(SKILLS_DEF.earthShaping).toBeDefined();
});
});
});
// ─── Combination Skills Tests ───────────────────────────────────────────────────
describe('Combination Skills', () => {
describe('Enchanted Golems (Embed spell crystals)', () => {
it('should require Enchanter 5 and Fabricator 5', () => {
expect(SKILLS_DEF.enchantedGolems.reqAttunements).toEqual({ enchanter: 5, fabricator: 5 });
});
});
describe('Pact-Bonded Golems (Golems gain bonuses from pacts)', () => {
it('should require Invoker 5 and Fabricator 5', () => {
expect(SKILLS_DEF.pactBondedGolems.reqAttunements).toEqual({ invoker: 5, fabricator: 5 });
});
});
describe('Pact Enchantments (Pact-specific enchantment effects)', () => {
it('should require Invoker 5 and Enchanter 5', () => {
expect(SKILLS_DEF.pactEnchantments.reqAttunements).toEqual({ invoker: 5, enchanter: 5 });
});
});
});
// ─── Meditation Bonus Tests ─────────────────────────────────────────────────────
describe('Meditation Bonus', () => {
@@ -539,22 +477,6 @@ describe('Meditation Bonus', () => {
// ─── Skill Prerequisites Tests ──────────────────────────────────────────────────
describe('Skill Prerequisites', () => {
it('Deep Reservoir should require Mana Well 5', () => {
expect(SKILLS_DEF.deepReservoir.req).toEqual({ manaWell: 5 });
});
it('Arcane Fury should require Combat Training 3', () => {
expect(SKILLS_DEF.arcaneFury.req).toEqual({ combatTrain: 3 });
});
it('Elemental Mastery should require Arcane Fury 2', () => {
expect(SKILLS_DEF.elementalMastery.req).toEqual({ arcaneFury: 2 });
});
it('Spell Echo should require Quick Cast 3', () => {
expect(SKILLS_DEF.spellEcho.req).toEqual({ quickCast: 3 });
});
it('Mana Overflow should require Mana Well 3', () => {
expect(SKILLS_DEF.manaOverflow.req).toEqual({ manaWell: 3 });
});
@@ -571,8 +493,12 @@ describe('Skill Prerequisites', () => {
expect(SKILLS_DEF.voidMeditation.req).toEqual({ deepTrance: 1 });
});
it('Elemental Crafting should require Efficient Crafting 3', () => {
expect(SKILLS_DEF.elemCrafting.req).toEqual({ effCrafting: 3 });
it('Efficient Enchant should require Enchanting 3', () => {
expect(SKILLS_DEF.efficientEnchant.req).toEqual({ enchanting: 3 });
});
it('Transference Mastery should require Enchanting 5', () => {
expect(SKILLS_DEF.transferenceMastery.req).toEqual({ enchanting: 5 });
});
});
@@ -586,14 +512,14 @@ describe('Study Times', () => {
});
});
it('research skills should have longer study times', () => {
const researchSkills = Object.entries(SKILLS_DEF).filter(([, s]) => s.cat === 'research');
researchSkills.forEach(([, skill]) => {
expect(skill.studyTime).toBeGreaterThanOrEqual(12);
it('combination skills should have long study times', () => {
const comboSkills = Object.entries(SKILLS_DEF).filter(([, s]) => s.cat === 'combination');
comboSkills.forEach(([, skill]) => {
expect(skill.studyTime).toBeGreaterThanOrEqual(16);
});
});
it('ascension skills should have very long study times', () => {
it('ascension skills should have long study times', () => {
const ascensionSkills = Object.entries(SKILLS_DEF).filter(([, s]) => s.cat === 'ascension');
ascensionSkills.forEach(([, skill]) => {
expect(skill.studyTime).toBeGreaterThanOrEqual(20);
@@ -644,7 +570,7 @@ describe('Integration Tests', () => {
});
it('all skills should have valid categories', () => {
const validCategories = ['mana', 'combat', 'study', 'craft', 'research', 'ascension'];
const validCategories = ['mana', 'study', 'ascension', 'enchant', 'effectResearch', 'invocation', 'pact', 'fabrication', 'golemancy', 'combination'];
Object.values(SKILLS_DEF).forEach(skill => {
expect(validCategories).toContain(skill.cat);
});
@@ -669,6 +595,15 @@ describe('Integration Tests', () => {
}
});
});
it('all attunement-requiring skills should have valid attunement', () => {
const validAttunements = ['enchanter', 'invoker', 'fabricator'];
Object.entries(SKILLS_DEF).forEach(([id, skill]) => {
if (skill.attunement) {
expect(validAttunements).toContain(skill.attunement);
}
});
});
});
console.log('✅ All skill tests defined. Run with: bun test src/lib/game/skills.test.ts');

View File

@@ -24,6 +24,9 @@ import {
ENCHANTING_UNLOCK_EFFECTS,
GOLEM_DEFS,
GOLEM_VARIANTS,
canUnlockCompositeElement,
getUnlockableCompositeElements,
getCompositeConversionRate,
} from './constants';
import { computeEffects, hasSpecial, SPECIAL_EFFECTS, type ComputedEffects } from './upgrade-effects';
import {
@@ -101,14 +104,16 @@ export function getFloorElement(floor: number): string {
return FLOOR_ELEM_CYCLE[(floor - 1) % 8];
}
// Calculate floor HP regeneration per hour (scales with floor level)
// Calculate floor HP regeneration per hour
// Guardian floors: 3% per hour (3x the usual)
// Non-guardian floors: 1% per hour
export function getFloorHPRegen(floor: number): number {
// Base regen: 1% of floor HP per hour at floor 1, scaling up
// Guardian floors have 0 regen (they don't heal during combat)
if (GUARDIANS[floor]) return 0;
const floorMaxHP = getFloorMaxHP(floor);
const regenPercent = 0.01 + (floor * 0.002); // 1% at floor 1, +0.2% per floor
const isGuardianFloor = !!GUARDIANS[floor];
// Guardian floors have 3% regen per hour
// Non-guardian floors have 1% regen per hour
const regenPercent = isGuardianFloor ? 0.03 : 0.01;
return Math.floor(floorMaxHP * regenPercent);
}
@@ -480,8 +485,8 @@ function makeInitial(overrides: Partial<GameState> = {}): GameState {
currentFloor: startFloor,
floorHP: getFloorMaxHP(startFloor),
floorMaxHP: getFloorMaxHP(startFloor),
floorBarrier: 0, // No barrier on non-guardian floors
floorMaxBarrier: 0,
floorBarrier: getFloorBarrier(startFloor), // Properly initialize barrier for guardian floors
floorMaxBarrier: getFloorBarrier(startFloor),
maxFloorReached: startFloor,
signedPacts: [],
activeSpell: 'manaBolt',
@@ -714,6 +719,7 @@ export const useGameStore = create<GameStore>()(
// Mana regeneration
let rawMana = Math.min(state.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
let totalManaGathered = state.totalManaGathered;
let log = state.log;
// Attunement mana conversion - convert raw mana to attunement's primary mana type
let elements = state.elements;
@@ -746,13 +752,103 @@ export const useGameStore = create<GameStore>()(
}
});
}
// Composite element unlocking and conversion
// Check if any composite elements should be unlocked
const unlockableComposites = getUnlockableCompositeElements(elements);
for (const compositeId of unlockableComposites) {
const compositeDef = ELEMENTS[compositeId];
if (!compositeDef) continue;
// Unlock the composite element
elements = {
...elements,
[compositeId]: {
...elements[compositeId],
unlocked: true,
},
};
// Log the unlock
log = [`🔮 ${compositeDef.name} mana unlocked! (${compositeDef.recipe?.map(r => ELEMENTS[r]?.name || r).join(' + ')})`, ...log.slice(0, 49)];
}
// Composite element conversion - convert component mana to composite mana
// Get base conversion rates from attunements
const baseConversionRates: Record<string, number> = {};
if (state.attunements) {
Object.entries(state.attunements).forEach(([attId, attState]) => {
if (!attState.active) return;
const attDef = ATTUNEMENTS_DEF[attId];
if (!attDef || !attDef.primaryManaType) return;
const rate = getAttunementConversionRate(attId, attState.level || 1);
baseConversionRates[attDef.primaryManaType] = rate;
});
}
// Process composite element conversion for unlocked composite elements
for (const [elementId, elemDef] of Object.entries(ELEMENTS)) {
if (elemDef.cat !== 'composite' && elemDef.cat !== 'exotic') continue;
const compositeElem = elements[elementId];
if (!compositeElem?.unlocked || !elemDef.recipe) continue;
// Calculate conversion rate (half of slowest component)
const compositeRate = getCompositeConversionRate(elementId, baseConversionRates);
if (compositeRate <= 0) continue;
// Determine the total mana that can be converted this tick
const conversionPerTick = compositeRate * HOURS_PER_TICK;
// Check if we have enough of each component mana
const uniqueComponents = [...new Set(elemDef.recipe)];
let canConvert = true;
let conversionAmount = conversionPerTick;
// Calculate the maximum conversion based on available component mana
for (const componentId of uniqueComponents) {
const componentElem = elements[componentId];
if (!componentElem || componentElem.current < conversionAmount) {
conversionAmount = Math.min(conversionAmount, componentElem?.current || 0);
if (conversionAmount <= 0) {
canConvert = false;
break;
}
}
}
// Also check composite element capacity
const compositeCapacity = compositeElem.max - compositeElem.current;
conversionAmount = Math.min(conversionAmount, compositeCapacity);
if (!canConvert || conversionAmount <= 0) continue;
// Deduct from component elements
for (const componentId of uniqueComponents) {
elements = {
...elements,
[componentId]: {
...elements[componentId],
current: elements[componentId].current - conversionAmount,
},
};
}
// Add to composite element
elements = {
...elements,
[elementId]: {
...compositeElem,
current: compositeElem.current + conversionAmount,
},
};
}
// Study progress
let currentStudyTarget = state.currentStudyTarget;
let skills = state.skills;
let skillProgress = state.skillProgress;
let spells = state.spells;
let log = state.log;
let unlockedEffects = state.unlockedEffects;
if (state.currentAction === 'study' && currentStudyTarget) {
@@ -830,8 +926,10 @@ export const useGameStore = create<GameStore>()(
const floorElement = getFloorElement(currentFloor);
const isGuardianFloor = !!GUARDIANS[currentFloor];
// Floor HP regeneration (only for non-guardian floors)
if (!isGuardianFloor && state.currentAction === 'climb') {
// Floor HP regeneration (all floors regen during combat)
// Guardian floors: 3% per hour, Non-guardian floors: 1% per hour
// This makes floors harder over time during combat
if (state.currentAction === 'climb') {
const regenRate = getFloorHPRegen(currentFloor);
floorHP = Math.min(floorMaxHP, floorHP + regenRate * HOURS_PER_TICK);
}

View File

@@ -100,6 +100,9 @@ export const SPECIAL_EFFECTS = {
EXOTIC_MASTERY: 'exoticMastery', // +20% exotic element damage
ELEMENTAL_RESONANCE: 'elementalResonance', // Using element spells restores 1 of that element
MANA_CONDUIT: 'manaConduit', // Meditation regenerates elemental mana
// Movement special effects (for legs equipment)
EXPEDITIOUS_RETREAT: 'expeditiousRetreat', // Teleport down 5 floors when exiting spire
} as const;
// ─── Upgrade Definition Cache ─────────────────────────────────────────────────