Fix mana conversion visibility and UI improvements
All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 3m9s

- Increase attunement mana conversion rates (0.2 -> 2 for Enchanter)
- Hide mana types with current < 1 in ManaDisplay and LabTab
- Only show owned equipment types when designing enchantments
This commit is contained in:
Z User
2026-03-28 06:15:14 +00:00
parent 9566f44652
commit a0595e6077
54 changed files with 14602 additions and 13 deletions

View File

@@ -29,7 +29,7 @@ export const ATTUNEMENTS_DEF: Record<string, AttunementDef> = {
color: '#1ABC9C', // Teal (transference color)
primaryManaType: 'transference',
rawManaRegen: 0.5,
conversionRate: 0.2, // Converts 0.2 raw mana to transference per hour
conversionRate: 2, // Converts 2 raw mana to transference per hour
unlocked: true, // Starting attunement
capabilities: ['enchanting', 'disenchanting'],
skillCategories: ['enchant', 'effectResearch'],
@@ -67,7 +67,7 @@ export const ATTUNEMENTS_DEF: Record<string, AttunementDef> = {
color: '#F4A261', // Earth color
primaryManaType: 'earth',
rawManaRegen: 0.4,
conversionRate: 0.25, // Converts 0.25 raw mana to earth per hour
conversionRate: 2.5, // Converts 2.5 raw mana to earth per hour
unlocked: false, // Unlocked through gameplay
unlockCondition: 'Prove your worth as a crafter',
capabilities: ['golemCrafting', 'gearCrafting', 'earthShaping'],

498
src/lib/game/data/familiars.ts Executable file
View File

@@ -0,0 +1,498 @@
// ─── Familiar Definitions ───────────────────────────────────────────────────────
// Magical companions that provide passive bonuses and active assistance
import type { FamiliarDef, FamiliarAbility } from '../types';
// ─── Familiar Abilities ─────────────────────────────────────────────────────────
const ABILITIES = {
// Combat abilities
damageBonus: (base: number, scaling: number): FamiliarAbility => ({
type: 'damageBonus',
baseValue: base,
scalingPerLevel: scaling,
desc: `+${base}% damage (+${scaling}% per level)`,
}),
critChance: (base: number, scaling: number): FamiliarAbility => ({
type: 'critChance',
baseValue: base,
scalingPerLevel: scaling,
desc: `+${base}% crit chance (+${scaling}% per level)`,
}),
castSpeed: (base: number, scaling: number): FamiliarAbility => ({
type: 'castSpeed',
baseValue: base,
scalingPerLevel: scaling,
desc: `+${base}% cast speed (+${scaling}% per level)`,
}),
elementalBonus: (base: number, scaling: number): FamiliarAbility => ({
type: 'elementalBonus',
baseValue: base,
scalingPerLevel: scaling,
desc: `+${base}% elemental damage (+${scaling}% per level)`,
}),
lifeSteal: (base: number, scaling: number): FamiliarAbility => ({
type: 'lifeSteal',
baseValue: base,
scalingPerLevel: scaling,
desc: `+${base}% life steal (+${scaling}% per level)`,
}),
thorns: (base: number, scaling: number): FamiliarAbility => ({
type: 'thorns',
baseValue: base,
scalingPerLevel: scaling,
desc: `Reflect ${base}% damage taken (+${scaling}% per level)`,
}),
// Mana abilities
manaRegen: (base: number, scaling: number): FamiliarAbility => ({
type: 'manaRegen',
baseValue: base,
scalingPerLevel: scaling,
desc: `+${base} mana regen (+${scaling} per level)`,
}),
autoGather: (base: number, scaling: number): FamiliarAbility => ({
type: 'autoGather',
baseValue: base,
scalingPerLevel: scaling,
desc: `Auto-gather ${base} mana/hour (+${scaling} per level)`,
}),
autoConvert: (base: number, scaling: number): FamiliarAbility => ({
type: 'autoConvert',
baseValue: base,
scalingPerLevel: scaling,
desc: `Auto-convert ${base} mana/hour (+${scaling} per level)`,
}),
manaShield: (base: number, scaling: number): FamiliarAbility => ({
type: 'manaShield',
baseValue: base,
scalingPerLevel: scaling,
desc: `Shield absorbs ${base} damage, costs 1 mana per ${base} damage`,
}),
// Support abilities
bonusGold: (base: number, scaling: number): FamiliarAbility => ({
type: 'bonusGold',
baseValue: base,
scalingPerLevel: scaling,
desc: `+${base}% insight gain (+${scaling}% per level)`,
}),
};
// ─── Familiar Definitions ───────────────────────────────────────────────────────
export const FAMILIARS_DEF: Record<string, FamiliarDef> = {
// === COMMON FAMILIARS (Tier 1) ===
// Mana Wisps - Basic mana helpers
manaWisp: {
id: 'manaWisp',
name: 'Mana Wisp',
desc: 'A gentle spirit of pure mana that drifts lazily through the air.',
role: 'mana',
element: 'raw',
rarity: 'common',
abilities: [
ABILITIES.manaRegen(0.5, 0.1),
],
baseStats: { power: 10, bond: 15 },
unlockCondition: { type: 'mana', value: 100 },
flavorText: 'It hums with quiet contentment, barely visible in dim light.',
},
fireSpark: {
id: 'fireSpark',
name: 'Fire Spark',
desc: 'A tiny ember given life, crackling with barely contained energy.',
role: 'combat',
element: 'fire',
rarity: 'common',
abilities: [
ABILITIES.damageBonus(2, 0.5),
],
baseStats: { power: 12, bond: 10 },
unlockCondition: { type: 'floor', value: 5 },
flavorText: 'It bounces excitedly, leaving scorch marks on everything it touches.',
},
waterDroplet: {
id: 'waterDroplet',
name: 'Water Droplet',
desc: 'A perfect sphere of living water that never seems to evaporate.',
role: 'support',
element: 'water',
rarity: 'common',
abilities: [
ABILITIES.manaRegen(0.3, 0.1),
ABILITIES.lifeSteal(1, 0.2),
],
baseStats: { power: 8, bond: 12 },
unlockCondition: { type: 'floor', value: 3 },
flavorText: 'Ripples spread across its surface with each spell you cast.',
},
earthPebble: {
id: 'earthPebble',
name: 'Earth Pebble',
desc: 'A small stone with a surprisingly friendly personality.',
role: 'guardian',
element: 'earth',
rarity: 'common',
abilities: [
ABILITIES.thorns(2, 0.5),
],
baseStats: { power: 15, bond: 8 },
unlockCondition: { type: 'floor', value: 8 },
flavorText: 'It occasionally rolls itself to a new position when bored.',
},
// === UNCOMMON FAMILIARS (Tier 2) ===
flameImp: {
id: 'flameImp',
name: 'Flame Imp',
desc: 'A mischievous fire spirit that delights in destruction.',
role: 'combat',
element: 'fire',
rarity: 'uncommon',
abilities: [
ABILITIES.damageBonus(4, 0.8),
ABILITIES.elementalBonus(3, 0.6),
],
baseStats: { power: 25, bond: 12 },
unlockCondition: { type: 'floor', value: 15 },
flavorText: 'It cackles with glee whenever you defeat an enemy.',
},
windSylph: {
id: 'windSylph',
name: 'Wind Sylph',
desc: 'An airy spirit that moves like a gentle breeze.',
role: 'support',
element: 'air',
rarity: 'uncommon',
abilities: [
ABILITIES.castSpeed(3, 0.6),
],
baseStats: { power: 20, bond: 15 },
unlockCondition: { type: 'floor', value: 12 },
flavorText: 'Its laughter sounds like wind chimes in a storm.',
},
manaSprite: {
id: 'manaSprite',
name: 'Mana Sprite',
desc: 'A more evolved mana spirit with a playful nature.',
role: 'mana',
element: 'raw',
rarity: 'uncommon',
abilities: [
ABILITIES.manaRegen(1, 0.2),
ABILITIES.autoGather(2, 0.5),
],
baseStats: { power: 18, bond: 18 },
unlockCondition: { type: 'mana', value: 1000 },
flavorText: 'It sometimes tickles your ear with invisible hands.',
},
crystalGolem: {
id: 'crystalGolem',
name: 'Crystal Golem',
desc: 'A small construct made of crystallized mana.',
role: 'guardian',
element: 'crystal',
rarity: 'uncommon',
abilities: [
ABILITIES.thorns(5, 1),
ABILITIES.manaShield(10, 2),
],
baseStats: { power: 30, bond: 10 },
unlockCondition: { type: 'floor', value: 20 },
flavorText: 'Light refracts through its body in mesmerizing patterns.',
},
// === RARE FAMILIARS (Tier 3) ===
phoenixHatchling: {
id: 'phoenixHatchling',
name: 'Phoenix Hatchling',
desc: 'A young phoenix, still learning to control its flames.',
role: 'combat',
element: 'fire',
rarity: 'rare',
abilities: [
ABILITIES.damageBonus(6, 1.2),
ABILITIES.lifeSteal(3, 0.5),
],
baseStats: { power: 40, bond: 15 },
unlockCondition: { type: 'floor', value: 30 },
flavorText: 'Tiny flames dance around its feathers as it practices flying.',
},
frostWisp: {
id: 'frostWisp',
name: 'Frost Wisp',
desc: 'A spirit of eternal winter, beautiful and deadly.',
role: 'combat',
element: 'water',
rarity: 'rare',
abilities: [
ABILITIES.elementalBonus(8, 1.5),
ABILITIES.castSpeed(4, 0.8),
],
baseStats: { power: 35, bond: 12 },
unlockCondition: { type: 'floor', value: 25 },
flavorText: 'Frost patterns appear on surfaces wherever it lingers.',
},
manaElemental: {
id: 'manaElemental',
name: 'Mana Elemental',
desc: 'A concentrated form of pure magical energy.',
role: 'mana',
element: 'raw',
rarity: 'rare',
abilities: [
ABILITIES.manaRegen(2, 0.4),
ABILITIES.autoGather(5, 1),
ABILITIES.autoConvert(2, 0.5),
],
baseStats: { power: 30, bond: 20 },
unlockCondition: { type: 'mana', value: 5000 },
flavorText: 'Reality seems to bend slightly around its fluctuating form.',
},
shieldGuardian: {
id: 'shieldGuardian',
name: 'Shield Guardian',
desc: 'A loyal protector carved from enchanted stone.',
role: 'guardian',
element: 'earth',
rarity: 'rare',
abilities: [
ABILITIES.thorns(8, 1.5),
ABILITIES.manaShield(20, 4),
],
baseStats: { power: 50, bond: 8 },
unlockCondition: { type: 'floor', value: 35 },
flavorText: 'It stands motionless for hours, then suddenly moves to block danger.',
},
// === EPIC FAMILIARS (Tier 4) ===
infernoDrake: {
id: 'infernoDrake',
name: 'Inferno Drake',
desc: 'A small dragon wreathed in eternal flames.',
role: 'combat',
element: 'fire',
rarity: 'epic',
abilities: [
ABILITIES.damageBonus(10, 2),
ABILITIES.elementalBonus(12, 2),
ABILITIES.critChance(3, 0.6),
],
baseStats: { power: 60, bond: 12 },
unlockCondition: { type: 'floor', value: 50 },
flavorText: 'Smoke occasionally drifts from its nostrils as it dreams of conquest.',
},
starlightSerpent: {
id: 'starlightSerpent',
name: 'Starlight Serpent',
desc: 'A serpentine creature formed from captured starlight.',
role: 'support',
element: 'stellar',
rarity: 'epic',
abilities: [
ABILITIES.castSpeed(8, 1.5),
ABILITIES.bonusGold(5, 1),
ABILITIES.manaRegen(1.5, 0.3),
],
baseStats: { power: 45, bond: 25 },
unlockCondition: { type: 'floor', value: 45 },
flavorText: 'It traces constellations in the air with its glowing body.',
},
voidWalker: {
id: 'voidWalker',
name: 'Void Walker',
desc: 'A being that exists partially outside normal reality.',
role: 'mana',
element: 'void',
rarity: 'epic',
abilities: [
ABILITIES.manaRegen(3, 0.6),
ABILITIES.autoGather(10, 2),
ABILITIES.manaShield(15, 3),
],
baseStats: { power: 55, bond: 15 },
unlockCondition: { type: 'floor', value: 55 },
flavorText: 'It sometimes disappears entirely, only to reappear moments later.',
},
ancientGolem: {
id: 'ancientGolem',
name: 'Ancient Golem',
desc: 'A construct from a forgotten age, still following its prime directive.',
role: 'guardian',
element: 'earth',
rarity: 'epic',
abilities: [
ABILITIES.thorns(15, 3),
ABILITIES.manaShield(30, 5),
ABILITIES.damageBonus(5, 1),
],
baseStats: { power: 80, bond: 6 },
unlockCondition: { type: 'floor', value: 60 },
flavorText: 'Ancient runes glow faintly across its weathered surface.',
},
// === LEGENDARY FAMILIARS (Tier 5) ===
primordialPhoenix: {
id: 'primordialPhoenix',
name: 'Primordial Phoenix',
desc: 'An ancient fire bird, reborn countless times through the ages.',
role: 'combat',
element: 'fire',
rarity: 'legendary',
abilities: [
ABILITIES.damageBonus(15, 3),
ABILITIES.elementalBonus(20, 4),
ABILITIES.lifeSteal(8, 1.5),
ABILITIES.critChance(5, 1),
],
baseStats: { power: 100, bond: 20 },
unlockCondition: { type: 'pact', value: 25 }, // Guardian floor 25
flavorText: 'Its eyes hold the wisdom of a thousand lifetimes.',
},
leviathanSpawn: {
id: 'leviathanSpawn',
name: 'Leviathan Spawn',
desc: 'The offspring of an ancient sea god, still growing into its power.',
role: 'mana',
element: 'water',
rarity: 'legendary',
abilities: [
ABILITIES.manaRegen(5, 1),
ABILITIES.autoGather(20, 4),
ABILITIES.autoConvert(8, 1.5),
ABILITIES.manaShield(25, 5),
],
baseStats: { power: 90, bond: 18 },
unlockCondition: { type: 'pact', value: 50 },
flavorText: 'The air around it always smells of salt and deep ocean.',
},
celestialGuardian: {
id: 'celestialGuardian',
name: 'Celestial Guardian',
desc: 'A divine protector sent by powers beyond mortal comprehension.',
role: 'guardian',
element: 'light',
rarity: 'legendary',
abilities: [
ABILITIES.thorns(25, 5),
ABILITIES.manaShield(50, 10),
ABILITIES.damageBonus(10, 2),
ABILITIES.lifeSteal(5, 1),
],
baseStats: { power: 120, bond: 12 },
unlockCondition: { type: 'pact', value: 75 },
flavorText: 'It radiates an aura of absolute protection and quiet judgment.',
},
voidEmperor: {
id: 'voidEmperor',
name: 'Void Emperor',
desc: 'A ruler from the spaces between dimensions, bound to your service.',
role: 'support',
element: 'void',
rarity: 'legendary',
abilities: [
ABILITIES.castSpeed(15, 3),
ABILITIES.bonusGold(15, 3),
ABILITIES.manaRegen(4, 0.8),
ABILITIES.critChance(8, 1.5),
],
baseStats: { power: 85, bond: 25 },
unlockCondition: { type: 'floor', value: 90 },
flavorText: 'It regards reality with the detached interest of a god.',
},
};
// ─── Helper Functions ───────────────────────────────────────────────────────────
// Get XP required for next familiar level
export function getFamiliarXpRequired(level: number): number {
// Exponential scaling: 100 * 1.5^(level-1)
return Math.floor(100 * Math.pow(1.5, level - 1));
}
// Get bond required for next bond level (1-100)
export function getBondRequired(currentBond: number): number {
// Linear scaling, every 10 bond requires more time
const bondTier = Math.floor(currentBond / 10);
return 100 + bondTier * 50; // Base 100, +50 per tier
}
// Calculate familiar's ability value at given level and ability level
export function getFamiliarAbilityValue(
ability: FamiliarAbility,
familiarLevel: number,
abilityLevel: number
): number {
// Base value + (familiar level bonus) + (ability level bonus)
const familiarBonus = Math.floor(familiarLevel / 10) * ability.scalingPerLevel;
const abilityBonus = (abilityLevel - 1) * ability.scalingPerLevel * 2;
return ability.baseValue + familiarBonus + abilityBonus;
}
// Get all familiars of a specific rarity
export function getFamiliarsByRarity(rarity: FamiliarDef['rarity']): FamiliarDef[] {
return Object.values(FAMILIARS_DEF).filter(f => f.rarity === rarity);
}
// Get all familiars of a specific role
export function getFamiliarsByRole(role: FamiliarRole): FamiliarDef[] {
return Object.values(FAMILIARS_DEF).filter(f => f.role === role);
}
// Check if player meets unlock condition for a familiar
export function canUnlockFamiliar(
familiar: FamiliarDef,
maxFloor: number,
signedPacts: number[],
totalManaGathered: number,
skillsLearned: number
): boolean {
if (!familiar.unlockCondition) return true;
const { type, value } = familiar.unlockCondition;
switch (type) {
case 'floor':
return maxFloor >= value;
case 'pact':
return signedPacts.includes(value);
case 'mana':
return totalManaGathered >= value;
case 'study':
return skillsLearned >= value;
default:
return false;
}
}
// Starting familiar (given to new players)
export const STARTING_FAMILIAR = 'manaWisp';

367
src/lib/game/familiar-slice.ts Executable file
View File

@@ -0,0 +1,367 @@
// ─── Familiar Slice ─────────────────────────────────────────────────────────────
// Actions and computations for the familiar system
import type { GameState, FamiliarInstance, FamiliarAbilityType } from './types';
import { FAMILIARS_DEF, getFamiliarXpRequired, getFamiliarAbilityValue, canUnlockFamiliar, STARTING_FAMILIAR } from './data/familiars';
import { HOURS_PER_TICK } from './constants';
// ─── Familiar Actions Interface ─────────────────────────────────────────────────
export interface FamiliarActions {
// Summoning and management
summonFamiliar: (familiarId: string) => void;
setActiveFamiliar: (instanceIndex: number, active: boolean) => void;
setFamiliarNickname: (instanceIndex: number, nickname: string) => void;
// Progression
gainFamiliarXp: (amount: number, source: 'combat' | 'gather' | 'meditate' | 'study' | 'time') => void;
upgradeFamiliarAbility: (instanceIndex: number, abilityType: FamiliarAbilityType) => void;
// Computation
getActiveFamiliarBonuses: () => FamiliarBonuses;
getAvailableFamiliars: () => string[];
}
// ─── Computed Bonuses ───────────────────────────────────────────────────────────
export interface FamiliarBonuses {
damageMultiplier: number;
manaRegenBonus: number;
autoGatherRate: number;
autoConvertRate: number;
critChanceBonus: number;
castSpeedMultiplier: number;
elementalDamageMultiplier: number;
lifeStealPercent: number;
thornsPercent: number;
insightMultiplier: number;
manaShieldAmount: number;
}
export const DEFAULT_FAMILIAR_BONUSES: FamiliarBonuses = {
damageMultiplier: 1,
manaRegenBonus: 0,
autoGatherRate: 0,
autoConvertRate: 0,
critChanceBonus: 0,
castSpeedMultiplier: 1,
elementalDamageMultiplier: 1,
lifeStealPercent: 0,
thornsPercent: 0,
insightMultiplier: 1,
manaShieldAmount: 0,
};
// ─── Familiar Slice Factory ─────────────────────────────────────────────────────
export function createFamiliarSlice(
set: (fn: (state: GameState) => Partial<GameState>) => void,
get: () => GameState
): FamiliarActions {
return {
// Summon a new familiar
summonFamiliar: (familiarId: string) => {
const state = get();
const familiarDef = FAMILIARS_DEF[familiarId];
if (!familiarDef) return;
// Check if already owned
if (state.familiars.some(f => f.familiarId === familiarId)) return;
// Check unlock condition
if (!canUnlockFamiliar(
familiarDef,
state.maxFloorReached,
state.signedPacts,
state.totalManaGathered,
Object.keys(state.skills).length
)) return;
// Create new familiar instance
const newInstance: FamiliarInstance = {
familiarId,
level: 1,
bond: 0,
experience: 0,
abilities: familiarDef.abilities.map(a => ({
type: a.type,
level: 1,
})),
active: false,
};
// Add to familiars list
set((s) => ({
familiars: [...s.familiars, newInstance],
log: [`🌟 ${familiarDef.name} has answered your call!`, ...s.log.slice(0, 49)],
}));
},
// Set a familiar as active/inactive
setActiveFamiliar: (instanceIndex: number, active: boolean) => {
const state = get();
if (instanceIndex < 0 || instanceIndex >= state.familiars.length) return;
const activeCount = state.familiars.filter(f => f.active).length;
// Check if we have slots available
if (active && activeCount >= state.activeFamiliarSlots) {
// Deactivate another familiar first
const newFamiliars = [...state.familiars];
const activeIndex = newFamiliars.findIndex(f => f.active);
if (activeIndex >= 0) {
newFamiliars[activeIndex] = { ...newFamiliars[activeIndex], active: false };
}
newFamiliars[instanceIndex] = { ...newFamiliars[instanceIndex], active };
set({ familiars: newFamiliars });
} else {
// Just toggle the familiar
const newFamiliars = [...state.familiars];
newFamiliars[instanceIndex] = { ...newFamiliars[instanceIndex], active };
set({ familiars: newFamiliars });
}
},
// Set a familiar's nickname
setFamiliarNickname: (instanceIndex: number, nickname: string) => {
const state = get();
if (instanceIndex < 0 || instanceIndex >= state.familiars.length) return;
const newFamiliars = [...state.familiars];
newFamiliars[instanceIndex] = {
...newFamiliars[instanceIndex],
nickname: nickname || undefined
};
set({ familiars: newFamiliars });
},
// Grant XP to all active familiars
gainFamiliarXp: (amount: number, _source: 'combat' | 'gather' | 'meditate' | 'study' | 'time') => {
const state = get();
if (state.familiars.length === 0) return;
const newFamiliars = [...state.familiars];
let leveled = false;
for (let i = 0; i < newFamiliars.length; i++) {
const familiar = newFamiliars[i];
if (!familiar.active) continue;
const def = FAMILIARS_DEF[familiar.familiarId];
if (!def) continue;
// Apply bond multiplier to XP gain
const bondMultiplier = 1 + (familiar.bond / 100);
const xpGain = Math.floor(amount * bondMultiplier);
let newExp = familiar.experience + xpGain;
let newLevel = familiar.level;
// Check for level ups
while (newLevel < 100 && newExp >= getFamiliarXpRequired(newLevel)) {
newExp -= getFamiliarXpRequired(newLevel);
newLevel++;
leveled = true;
}
// Gain bond passively
const newBond = Math.min(100, familiar.bond + 0.01);
newFamiliars[i] = {
...familiar,
level: newLevel,
experience: newExp,
bond: newBond,
};
}
set({
familiars: newFamiliars,
totalFamiliarXpEarned: state.totalFamiliarXpEarned + amount,
...(leveled ? { log: ['📈 Your familiar has grown stronger!', ...state.log.slice(0, 49)] } : {}),
});
},
// Upgrade a familiar's ability
upgradeFamiliarAbility: (instanceIndex: number, abilityType: FamiliarAbilityType) => {
const state = get();
if (instanceIndex < 0 || instanceIndex >= state.familiars.length) return;
const familiar = state.familiars[instanceIndex];
const def = FAMILIARS_DEF[familiar.familiarId];
if (!def) return;
// Find the ability
const abilityIndex = familiar.abilities.findIndex(a => a.type === abilityType);
if (abilityIndex < 0) return;
const ability = familiar.abilities[abilityIndex];
if (ability.level >= 10) return; // Max level
// Cost: level * 100 XP
const cost = ability.level * 100;
if (familiar.experience < cost) return;
// Upgrade
const newAbilities = [...familiar.abilities];
newAbilities[abilityIndex] = { ...ability, level: ability.level + 1 };
const newFamiliars = [...state.familiars];
newFamiliars[instanceIndex] = {
...familiar,
abilities: newAbilities,
experience: familiar.experience - cost,
};
set({ familiars: newFamiliars });
},
// Get total bonuses from active familiars
getActiveFamiliarBonuses: (): FamiliarBonuses => {
const state = get();
const bonuses = { ...DEFAULT_FAMILIAR_BONUSES };
for (const familiar of state.familiars) {
if (!familiar.active) continue;
const def = FAMILIARS_DEF[familiar.familiarId];
if (!def) continue;
// Bond multiplier: up to 50% bonus at max bond
const bondMultiplier = 1 + (familiar.bond / 200);
for (const abilityInst of familiar.abilities) {
const abilityDef = def.abilities.find(a => a.type === abilityInst.type);
if (!abilityDef) continue;
const value = getFamiliarAbilityValue(abilityDef, familiar.level, abilityInst.level) * bondMultiplier;
switch (abilityInst.type) {
case 'damageBonus':
bonuses.damageMultiplier += value / 100;
break;
case 'manaRegen':
bonuses.manaRegenBonus += value;
break;
case 'autoGather':
bonuses.autoGatherRate += value;
break;
case 'autoConvert':
bonuses.autoConvertRate += value;
break;
case 'critChance':
bonuses.critChanceBonus += value;
break;
case 'castSpeed':
bonuses.castSpeedMultiplier += value / 100;
break;
case 'elementalBonus':
bonuses.elementalDamageMultiplier += value / 100;
break;
case 'lifeSteal':
bonuses.lifeStealPercent += value;
break;
case 'thorns':
bonuses.thornsPercent += value;
break;
case 'bonusGold':
bonuses.insightMultiplier += value / 100;
break;
case 'manaShield':
bonuses.manaShieldAmount += value;
break;
}
}
}
return bonuses;
},
// Get list of available (unlocked but not owned) familiars
getAvailableFamiliars: (): string[] => {
const state = get();
const owned = new Set(state.familiars.map(f => f.familiarId));
return Object.values(FAMILIARS_DEF)
.filter(f =>
!owned.has(f.id) &&
canUnlockFamiliar(
f,
state.maxFloorReached,
state.signedPacts,
state.totalManaGathered,
Object.keys(state.skills).length
)
)
.map(f => f.id);
},
};
}
// ─── Familiar Tick Processing ───────────────────────────────────────────────────
// Process familiar-related tick effects (called from main tick)
export function processFamiliarTick(
state: Pick<GameState, 'familiars' | 'rawMana' | 'elements' | 'totalManaGathered' | 'activeFamiliarSlots'>,
familiarBonuses: FamiliarBonuses
): { rawMana: number; elements: GameState['elements']; totalManaGathered: number; gatherLog?: string } {
let rawMana = state.rawMana;
let elements = state.elements;
let totalManaGathered = state.totalManaGathered;
let gatherLog: string | undefined;
// Auto-gather from familiars
if (familiarBonuses.autoGatherRate > 0) {
const gathered = familiarBonuses.autoGatherRate * HOURS_PER_TICK;
rawMana += gathered;
totalManaGathered += gathered;
if (gathered >= 1) {
gatherLog = `✨ Familiars gathered ${Math.floor(gathered)} mana`;
}
}
// Auto-convert from familiars
if (familiarBonuses.autoConvertRate > 0) {
const convertAmount = Math.min(
familiarBonuses.autoConvertRate * HOURS_PER_TICK,
Math.floor(rawMana / 5) // 5 raw mana per element
);
if (convertAmount > 0) {
// Find unlocked elements with space
const unlockedElements = Object.entries(elements)
.filter(([, e]) => e.unlocked && e.current < e.max)
.sort((a, b) => (b[1].max - b[1].current) - (a[1].max - a[1].current));
if (unlockedElements.length > 0) {
const [targetId, targetState] = unlockedElements[0];
const canConvert = Math.min(convertAmount, targetState.max - targetState.current);
rawMana -= canConvert * 5;
elements = {
...elements,
[targetId]: { ...targetState, current: targetState.current + canConvert },
};
}
}
}
return { rawMana, elements, totalManaGathered, gatherLog };
}
// Grant starting familiar to new players
export function grantStartingFamiliar(): FamiliarInstance[] {
const starterDef = FAMILIARS_DEF[STARTING_FAMILIAR];
if (!starterDef) return [];
return [{
familiarId: STARTING_FAMILIAR,
level: 1,
bond: 0,
experience: 0,
abilities: starterDef.abilities.map(a => ({
type: a.type,
level: 1,
})),
active: true, // Start with familiar active
}];
}

View File

@@ -0,0 +1,221 @@
// ─── Derived Stats Hooks ───────────────────────────────────────────────────────
// Custom hooks for computing derived game stats from the store
import { useMemo } from 'react';
import { useGameStore } from '../store';
import { computeEffects } from '../upgrade-effects';
import {
computeMaxMana,
computeRegen,
computeClickMana,
getMeditationBonus,
getIncursionStrength,
getFloorElement,
calcDamage,
computePactMultiplier,
computePactInsightMultiplier,
getElementalBonus,
} from '../store/computed';
import { ELEMENTS, GUARDIANS, SPELLS_DEF, getStudySpeedMultiplier, getStudyCostMultiplier, HOURS_PER_TICK, TICK_MS } from '../constants';
import { hasSpecial, SPECIAL_EFFECTS } from '../upgrade-effects';
/**
* Hook for all mana-related derived stats
*/
export function useManaStats() {
const store = useGameStore();
const upgradeEffects = useMemo(
() => computeEffects(store.skillUpgrades || {}, store.skillTiers || {}),
[store.skillUpgrades, store.skillTiers]
);
const maxMana = useMemo(
() => computeMaxMana(store, upgradeEffects),
[store, upgradeEffects]
);
const baseRegen = useMemo(
() => computeRegen(store, upgradeEffects),
[store, upgradeEffects]
);
const clickMana = useMemo(
() => computeClickMana(store),
[store]
);
const meditationMultiplier = useMemo(
() => getMeditationBonus(store.meditateTicks, store.skills, upgradeEffects.meditationEfficiency),
[store.meditateTicks, store.skills, upgradeEffects.meditationEfficiency]
);
const incursionStrength = useMemo(
() => getIncursionStrength(store.day, store.hour),
[store.day, store.hour]
);
// Effective regen with incursion penalty
const effectiveRegenWithSpecials = baseRegen * (1 - incursionStrength);
// Mana Cascade bonus
const manaCascadeBonus = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_CASCADE)
? Math.floor(maxMana / 100) * 0.1
: 0;
// Final effective regen
const effectiveRegen = (effectiveRegenWithSpecials + manaCascadeBonus) * meditationMultiplier;
return {
upgradeEffects,
maxMana,
baseRegen,
clickMana,
meditationMultiplier,
incursionStrength,
effectiveRegenWithSpecials,
manaCascadeBonus,
effectiveRegen,
hasSteadyStream: hasSpecial(upgradeEffects, SPECIAL_EFFECTS.STEADY_STREAM),
hasManaTorrent: hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_TORRENT),
hasDesperateWells: hasSpecial(upgradeEffects, SPECIAL_EFFECTS.DESPERATE_WELLS),
hasManaEcho: hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_ECHO),
};
}
/**
* Hook for combat-related derived stats
*/
export function useCombatStats() {
const store = useGameStore();
const { upgradeEffects } = useManaStats();
const floorElem = useMemo(
() => getFloorElement(store.currentFloor),
[store.currentFloor]
);
const floorElemDef = useMemo(
() => ELEMENTS[floorElem],
[floorElem]
);
const isGuardianFloor = useMemo(
() => !!GUARDIANS[store.currentFloor],
[store.currentFloor]
);
const currentGuardian = useMemo(
() => GUARDIANS[store.currentFloor],
[store.currentFloor]
);
const activeSpellDef = useMemo(
() => SPELLS_DEF[store.activeSpell],
[store.activeSpell]
);
const pactMultiplier = useMemo(
() => computePactMultiplier(store),
[store]
);
const pactInsightMultiplier = useMemo(
() => computePactInsightMultiplier(store),
[store]
);
// DPS calculation
const dps = useMemo(() => {
if (!activeSpellDef) return 0;
const spellCastSpeed = activeSpellDef.castSpeed || 1;
const quickCastBonus = 1 + (store.skills.quickCast || 0) * 0.05;
const attackSpeedMult = upgradeEffects.attackSpeedMultiplier;
const totalCastSpeed = spellCastSpeed * quickCastBonus * attackSpeedMult;
const damagePerCast = calcDamage(store, store.activeSpell, floorElem);
const castsPerSecond = totalCastSpeed * HOURS_PER_TICK / (TICK_MS / 1000);
return damagePerCast * castsPerSecond;
}, [activeSpellDef, store, floorElem, upgradeEffects.attackSpeedMultiplier]);
// Damage breakdown for display
const damageBreakdown = useMemo(() => {
if (!activeSpellDef) return null;
const baseDmg = activeSpellDef.dmg;
const combatTrainBonus = (store.skills.combatTrain || 0) * 5;
const arcaneFuryMult = 1 + (store.skills.arcaneFury || 0) * 0.1;
const elemMasteryMult = 1 + (store.skills.elementalMastery || 0) * 0.15;
const guardianBaneMult = isGuardianFloor ? (1 + (store.skills.guardianBane || 0) * 0.2) : 1;
const precisionChance = (store.skills.precision || 0) * 0.05;
// Calculate elemental bonus
const elemBonus = getElementalBonus(activeSpellDef.elem, floorElem);
let elemBonusText = '';
if (activeSpellDef.elem !== 'raw' && floorElem) {
if (activeSpellDef.elem === floorElem) {
elemBonusText = '+25% same element';
} else if (elemBonus === 1.5) {
elemBonusText = '+50% super effective';
}
}
return {
base: baseDmg,
combatTrainBonus,
arcaneFuryMult,
elemMasteryMult,
guardianBaneMult,
pactMult: pactMultiplier,
precisionChance,
elemBonus,
elemBonusText,
total: calcDamage(store, store.activeSpell, floorElem),
};
}, [activeSpellDef, store, floorElem, isGuardianFloor, pactMultiplier]);
return {
floorElem,
floorElemDef,
isGuardianFloor,
currentGuardian,
activeSpellDef,
pactMultiplier,
pactInsightMultiplier,
dps,
damageBreakdown,
};
}
/**
* Hook for study-related derived stats
*/
export function useStudyStats() {
const store = useGameStore();
const studySpeedMult = useMemo(
() => getStudySpeedMultiplier(store.skills),
[store.skills]
);
const studyCostMult = useMemo(
() => getStudyCostMultiplier(store.skills),
[store.skills]
);
const upgradeEffects = useMemo(
() => computeEffects(store.skillUpgrades || {}, store.skillTiers || {}),
[store.skillUpgrades, store.skillTiers]
);
const effectiveStudySpeedMult = studySpeedMult * upgradeEffects.studySpeedMultiplier;
return {
studySpeedMult,
studyCostMult,
effectiveStudySpeedMult,
hasParallelStudy: hasSpecial(upgradeEffects, SPECIAL_EFFECTS.PARALLEL_STUDY),
};
}

681
src/lib/game/skills.test.ts Executable file
View File

@@ -0,0 +1,681 @@
/**
* Comprehensive Skill Tests
*
* Tests each skill to verify they work exactly as their descriptions say.
*/
import { describe, it, expect } from 'bun:test';
import {
computeMaxMana,
computeElementMax,
computeRegen,
computeClickMana,
calcDamage,
calcInsight,
getMeditationBonus,
} from './store';
import {
SKILLS_DEF,
PRESTIGE_DEF,
GUARDIANS,
getStudySpeedMultiplier,
getStudyCostMultiplier,
} from './constants';
import type { GameState } from './types';
// ─── Test Helpers ───────────────────────────────────────────────────────────
function createMockState(overrides: Partial<GameState> = {}): GameState {
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
const baseElements = ['fire', 'water', 'air', 'earth', 'light', 'dark', 'life', 'death', 'mental', 'transference', 'force', 'blood', 'metal', 'wood', 'sand', 'crystal', 'stellar', 'void'];
baseElements.forEach((k) => {
elements[k] = { current: 0, max: 10, unlocked: baseElements.slice(0, 4).includes(k) };
});
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,
};
}
// ─── Mana Skills Tests ─────────────────────────────────────────────────────────
describe('Mana Skills', () => {
describe('Mana Well (+100 max mana)', () => {
it('should add 100 max mana per level', () => {
const state0 = createMockState({ skills: { manaWell: 0 } });
const state1 = createMockState({ skills: { manaWell: 1 } });
const state5 = createMockState({ skills: { manaWell: 5 } });
const state10 = createMockState({ skills: { manaWell: 10 } });
expect(computeMaxMana(state0)).toBe(100);
expect(computeMaxMana(state1)).toBe(100 + 100);
expect(computeMaxMana(state5)).toBe(100 + 500);
expect(computeMaxMana(state10)).toBe(100 + 1000);
});
it('skill definition should match description', () => {
expect(SKILLS_DEF.manaWell.desc).toBe("+100 max mana");
expect(SKILLS_DEF.manaWell.max).toBe(10);
});
});
describe('Mana Flow (+1 regen/hr)', () => {
it('should add 1 regen per hour per level', () => {
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);
});
it('skill definition should match description', () => {
expect(SKILLS_DEF.manaFlow.desc).toBe("+1 regen/hr");
expect(SKILLS_DEF.manaFlow.max).toBe(10);
});
});
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 } });
expect(computeMaxMana(state0)).toBe(100);
expect(computeMaxMana(state1)).toBe(100 + 500);
expect(computeMaxMana(state5)).toBe(100 + 2500);
});
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 });
});
});
describe('Elemental Attunement (+50 elem mana cap)', () => {
it('should add 50 element mana capacity per level', () => {
const state0 = createMockState({ skills: { elemAttune: 0 } });
const state1 = createMockState({ skills: { elemAttune: 1 } });
const state5 = createMockState({ skills: { elemAttune: 5 } });
const state10 = createMockState({ skills: { elemAttune: 10 } });
expect(computeElementMax(state0)).toBe(10);
expect(computeElementMax(state1)).toBe(10 + 50);
expect(computeElementMax(state5)).toBe(10 + 250);
expect(computeElementMax(state10)).toBe(10 + 500);
});
it('skill definition should match description', () => {
expect(SKILLS_DEF.elemAttune.desc).toBe("+50 elem mana cap");
expect(SKILLS_DEF.elemAttune.max).toBe(10);
});
});
describe('Mana Overflow (+25% mana from clicks)', () => {
it('skill definition should match description', () => {
expect(SKILLS_DEF.manaOverflow.desc).toBe("+25% mana from clicks");
expect(SKILLS_DEF.manaOverflow.max).toBe(5);
});
it('should require Mana Well 3', () => {
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 } });
const state1 = createMockState({ skills: { manaTap: 1 } });
expect(computeClickMana(state0)).toBe(1);
expect(computeClickMana(state1)).toBe(2);
});
it('skill definition should match description', () => {
expect(SKILLS_DEF.manaTap.desc).toBe("+1 mana/click");
expect(SKILLS_DEF.manaTap.max).toBe(1);
});
});
describe('Mana Surge (+3 mana/click)', () => {
it('should add 3 mana per click', () => {
const state0 = createMockState({ skills: { manaSurge: 0 } });
const state1 = createMockState({ skills: { manaSurge: 1 } });
expect(computeClickMana(state0)).toBe(1);
expect(computeClickMana(state1)).toBe(4);
});
it('should stack with Mana Tap', () => {
const state = createMockState({ skills: { manaTap: 1, manaSurge: 1 } });
expect(computeClickMana(state)).toBe(1 + 1 + 3);
});
it('should require Mana Tap 1', () => {
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);
});
it('skill definition should match description', () => {
expect(SKILLS_DEF.manaSpring.desc).toBe("+2 mana regen");
expect(SKILLS_DEF.manaSpring.max).toBe(1);
});
});
describe('Deep Trance (Extend to 6hrs for 3x)', () => {
it('skill definition should match description', () => {
expect(SKILLS_DEF.deepTrance.desc).toContain("6hrs");
expect(SKILLS_DEF.deepTrance.max).toBe(1);
});
it('should require Meditation 1', () => {
expect(SKILLS_DEF.deepTrance.req).toEqual({ meditation: 1 });
});
});
describe('Void Meditation (Extend to 8hrs for 5x)', () => {
it('skill definition should match description', () => {
expect(SKILLS_DEF.voidMeditation.desc).toContain("8hrs");
expect(SKILLS_DEF.voidMeditation.max).toBe(1);
});
it('should require Deep Trance 1', () => {
expect(SKILLS_DEF.voidMeditation.req).toEqual({ deepTrance: 1 });
});
});
});
// ─── Ascension Skills Tests ─────────────────────────────────────────────────────
describe('Ascension Skills', () => {
describe('Insight Harvest (+10% insight gain)', () => {
it('should multiply insight gain by 10% per level', () => {
const state0 = createMockState({ maxFloorReached: 10, skills: { insightHarvest: 0 } });
const state1 = createMockState({ maxFloorReached: 10, skills: { insightHarvest: 1 } });
const state5 = createMockState({ maxFloorReached: 10, skills: { insightHarvest: 5 } });
const insight0 = calcInsight(state0);
const insight1 = calcInsight(state1);
const insight5 = calcInsight(state5);
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', () => {
expect(SKILLS_DEF.insightHarvest.desc).toBe("+10% insight gain");
expect(SKILLS_DEF.insightHarvest.max).toBe(5);
});
});
describe('Temporal Memory (Keep 1 spell learned across loops)', () => {
it('skill definition should match description', () => {
expect(SKILLS_DEF.temporalMemory.desc).toBe("Keep 1 spell learned across loops");
expect(SKILLS_DEF.temporalMemory.max).toBe(3);
});
});
describe('Guardian Bane (+20% dmg vs guardians)', () => {
it('skill definition should match description', () => {
expect(SKILLS_DEF.guardianBane.desc).toBe("+20% dmg vs guardians");
expect(SKILLS_DEF.guardianBane.max).toBe(3);
});
});
});
// ─── Meditation Bonus Tests ─────────────────────────────────────────────────────
describe('Meditation Bonus', () => {
it('should start at 1x with no meditation', () => {
expect(getMeditationBonus(0, {})).toBe(1);
});
it('should ramp up over time without skills', () => {
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);
});
});
// ─── 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 });
});
it('Mana Surge should require Mana Tap 1', () => {
expect(SKILLS_DEF.manaSurge.req).toEqual({ manaTap: 1 });
});
it('Deep Trance should require Meditation 1', () => {
expect(SKILLS_DEF.deepTrance.req).toEqual({ meditation: 1 });
});
it('Void Meditation should require Deep Trance 1', () => {
expect(SKILLS_DEF.voidMeditation.req).toEqual({ deepTrance: 1 });
});
it('Elemental Crafting should require Efficient Crafting 3', () => {
expect(SKILLS_DEF.elemCrafting.req).toEqual({ effCrafting: 3 });
});
});
// ─── Study Time Tests ───────────────────────────────────────────────────────────
describe('Study Times', () => {
it('all skills should have reasonable study times', () => {
Object.entries(SKILLS_DEF).forEach(([id, skill]) => {
expect(skill.studyTime).toBeGreaterThan(0);
expect(skill.studyTime).toBeLessThanOrEqual(72);
});
});
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('ascension skills should have very long study times', () => {
const ascensionSkills = Object.entries(SKILLS_DEF).filter(([, s]) => s.cat === 'ascension');
ascensionSkills.forEach(([, skill]) => {
expect(skill.studyTime).toBeGreaterThanOrEqual(20);
});
});
});
// ─── Prestige Upgrade Tests ─────────────────────────────────────────────────────
describe('Prestige Upgrades', () => {
it('all prestige upgrades should have valid costs', () => {
Object.entries(PRESTIGE_DEF).forEach(([id, upgrade]) => {
expect(upgrade.cost).toBeGreaterThan(0);
expect(upgrade.max).toBeGreaterThan(0);
});
});
it('Mana Well prestige should add 500 starting max mana', () => {
const state0 = createMockState({ prestigeUpgrades: { manaWell: 0 } });
const state1 = createMockState({ prestigeUpgrades: { manaWell: 1 } });
const state5 = createMockState({ prestigeUpgrades: { manaWell: 5 } });
expect(computeMaxMana(state0)).toBe(100);
expect(computeMaxMana(state1)).toBe(100 + 500);
expect(computeMaxMana(state5)).toBe(100 + 2500);
});
it('Elemental Attunement prestige should add 25 element cap', () => {
const state0 = createMockState({ prestigeUpgrades: { elementalAttune: 0 } });
const state1 = createMockState({ prestigeUpgrades: { elementalAttune: 1 } });
const state10 = createMockState({ prestigeUpgrades: { elementalAttune: 10 } });
expect(computeElementMax(state0)).toBe(10);
expect(computeElementMax(state1)).toBe(10 + 25);
expect(computeElementMax(state10)).toBe(10 + 250);
});
});
// ─── Integration Tests ──────────────────────────────────────────────────────────
describe('Integration Tests', () => {
it('skill costs should scale with level', () => {
const skill = SKILLS_DEF.manaWell;
for (let level = 0; level < skill.max; level++) {
const cost = skill.base * (level + 1);
expect(cost).toBeGreaterThan(0);
}
});
it('all skills should have valid categories', () => {
const validCategories = ['mana', 'combat', 'study', 'craft', 'research', 'ascension'];
Object.values(SKILLS_DEF).forEach(skill => {
expect(validCategories).toContain(skill.cat);
});
});
it('all prerequisite skills should exist', () => {
Object.entries(SKILLS_DEF).forEach(([id, skill]) => {
if (skill.req) {
Object.keys(skill.req).forEach(reqId => {
expect(SKILLS_DEF[reqId]).toBeDefined();
});
}
});
});
it('all prerequisite levels should be within skill max', () => {
Object.entries(SKILLS_DEF).forEach(([id, skill]) => {
if (skill.req) {
Object.entries(skill.req).forEach(([reqId, reqLevel]) => {
expect(reqLevel).toBeLessThanOrEqual(SKILLS_DEF[reqId].max);
});
}
});
});
});
console.log('✅ All skill tests defined. Run with: bun test src/lib/game/skills.test.ts');

2097
src/lib/game/store.test.ts Executable file

File diff suppressed because it is too large Load Diff

164
src/lib/game/store/combatSlice.ts Executable file
View File

@@ -0,0 +1,164 @@
// ─── Combat Slice ─────────────────────────────────────────────────────────────
// Manages spire climbing, combat, and floor progression
import type { StateCreator } from 'zustand';
import type { GameState, GameAction, SpellCost } from '../types';
import { GUARDIANS, SPELLS_DEF, ELEMENTS, ELEMENT_OPPOSITES } from '../constants';
import { getFloorMaxHP, getFloorElement, calcDamage, computePactMultiplier, canAffordSpellCost, deductSpellCost } from './computed';
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '../upgrade-effects';
export interface CombatSlice {
// State
currentFloor: number;
floorHP: number;
floorMaxHP: number;
maxFloorReached: number;
activeSpell: string;
currentAction: GameAction;
castProgress: number;
// Actions
setAction: (action: GameAction) => void;
setSpell: (spellId: string) => void;
getDamage: (spellId: string) => number;
// Internal combat processing
processCombat: (deltaHours: number) => Partial<GameState>;
}
export const createCombatSlice = (
set: StateCreator<GameState>['set'],
get: () => GameState
): CombatSlice => ({
currentFloor: 1,
floorHP: getFloorMaxHP(1),
floorMaxHP: getFloorMaxHP(1),
maxFloorReached: 1,
activeSpell: 'manaBolt',
currentAction: 'meditate',
castProgress: 0,
setAction: (action: GameAction) => {
set((state) => ({
currentAction: action,
meditateTicks: action === 'meditate' ? state.meditateTicks : 0,
}));
},
setSpell: (spellId: string) => {
const state = get();
if (state.spells[spellId]?.learned) {
set({ activeSpell: spellId });
}
},
getDamage: (spellId: string) => {
const state = get();
const floorElem = getFloorElement(state.currentFloor);
return calcDamage(state, spellId, floorElem);
},
processCombat: (deltaHours: number) => {
const state = get();
if (state.currentAction !== 'climb') return {};
const spellId = state.activeSpell;
const spellDef = SPELLS_DEF[spellId];
if (!spellDef) return {};
const effects = computeEffects(state.skillUpgrades || {}, state.skillTiers || {});
const baseAttackSpeed = 1 + (state.skills.quickCast || 0) * 0.05;
const totalAttackSpeed = baseAttackSpeed * effects.attackSpeedMultiplier;
const spellCastSpeed = spellDef.castSpeed || 1;
const progressPerTick = deltaHours * spellCastSpeed * totalAttackSpeed;
let castProgress = (state.castProgress || 0) + progressPerTick;
let rawMana = state.rawMana;
let elements = state.elements;
let totalManaGathered = state.totalManaGathered;
let currentFloor = state.currentFloor;
let floorHP = state.floorHP;
let floorMaxHP = state.floorMaxHP;
let maxFloorReached = state.maxFloorReached;
let signedPacts = state.signedPacts;
let pendingPactOffer = state.pendingPactOffer;
const log = [...state.log];
const skills = state.skills;
const floorElement = getFloorElement(currentFloor);
while (castProgress >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements)) {
// Deduct cost
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
rawMana = afterCost.rawMana;
elements = afterCost.elements;
totalManaGathered += spellDef.cost.amount;
// Calculate damage
let dmg = calcDamage(state, spellId, floorElement);
dmg = dmg * effects.baseDamageMultiplier + effects.baseDamageBonus;
// Executioner: +100% damage to enemies below 25% HP
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && floorHP / floorMaxHP < 0.25) {
dmg *= 2;
}
// Berserker: +50% damage when below 50% mana
const maxMana = 100; // Would need proper max mana calculation
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
dmg *= 1.5;
}
// Spell echo - chance to cast again
const echoChance = (skills.spellEcho || 0) * 0.1;
if (Math.random() < echoChance) {
dmg *= 2;
log.unshift('✨ Spell Echo! Double damage!');
}
// Lifesteal effect
const lifestealEffect = spellDef.effects?.find(e => e.type === 'lifesteal');
if (lifestealEffect) {
const healAmount = dmg * lifestealEffect.value;
rawMana = Math.min(rawMana + healAmount, maxMana);
}
// Apply damage
floorHP = Math.max(0, floorHP - dmg);
castProgress -= 1;
if (floorHP <= 0) {
const wasGuardian = GUARDIANS[currentFloor];
if (wasGuardian && !signedPacts.includes(currentFloor)) {
pendingPactOffer = currentFloor;
log.unshift(`⚔️ ${wasGuardian.name} defeated! They offer a pact...`);
} else if (!wasGuardian) {
if (currentFloor % 5 === 0) {
log.unshift(`🏰 Floor ${currentFloor} cleared!`);
}
}
currentFloor = currentFloor + 1;
if (currentFloor > 100) currentFloor = 100;
floorMaxHP = getFloorMaxHP(currentFloor);
floorHP = floorMaxHP;
maxFloorReached = Math.max(maxFloorReached, currentFloor);
castProgress = 0;
}
}
return {
rawMana,
elements,
totalManaGathered,
currentFloor,
floorHP,
floorMaxHP,
maxFloorReached,
signedPacts,
pendingPactOffer,
castProgress,
log,
};
},
});

322
src/lib/game/store/computed.ts Executable file
View File

@@ -0,0 +1,322 @@
// ─── Computed Stats Functions ─────────────────────────────────────────────────
import type { GameState } from '../types';
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF, PRESTIGE_DEF, ELEMENT_OPPOSITES } from '../constants';
import { computeEffects } from '../upgrade-effects';
import { getTierMultiplier } from '../skill-evolution';
// Helper to get effective skill level accounting for tiers
export function getEffectiveSkillLevel(
skills: Record<string, number>,
baseSkillId: string,
skillTiers: Record<string, number> = {}
): { level: number; tier: number; tierMultiplier: number } {
const currentTier = skillTiers[baseSkillId] || 1;
const tieredSkillId = currentTier > 1 ? `${baseSkillId}_t${currentTier}` : baseSkillId;
const level = skills[tieredSkillId] || skills[baseSkillId] || 0;
const tierMultiplier = Math.pow(10, currentTier - 1);
return { level, tier: currentTier, tierMultiplier };
}
export function computeMaxMana(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
effects?: ReturnType<typeof computeEffects>
): number {
const pu = state.prestigeUpgrades;
const skillTiers = state.skillTiers || {};
const skillUpgrades = state.skillUpgrades || {};
const manaWellLevel = getEffectiveSkillLevel(state.skills, 'manaWell', skillTiers);
const base =
100 +
manaWellLevel.level * 100 * manaWellLevel.tierMultiplier +
(pu.manaWell || 0) * 500;
const computedEffects = effects ?? computeEffects(skillUpgrades, skillTiers);
return Math.floor((base + computedEffects.maxManaBonus) * computedEffects.maxManaMultiplier);
}
export function computeElementMax(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
effects?: ReturnType<typeof computeEffects>
): number {
const pu = state.prestigeUpgrades;
const skillTiers = state.skillTiers || {};
const skillUpgrades = state.skillUpgrades || {};
const elemAttuneLevel = getEffectiveSkillLevel(state.skills, 'elemAttune', skillTiers);
const base = 10 + elemAttuneLevel.level * 50 * elemAttuneLevel.tierMultiplier + (pu.elementalAttune || 0) * 25;
const computedEffects = effects ?? computeEffects(skillUpgrades, skillTiers);
return Math.floor((base + computedEffects.elementCapBonus) * computedEffects.elementCapMultiplier);
}
export function computeRegen(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
effects?: ReturnType<typeof computeEffects>
): number {
const pu = state.prestigeUpgrades;
const skillTiers = state.skillTiers || {};
const skillUpgrades = state.skillUpgrades || {};
const temporalBonus = 1 + (pu.temporalEcho || 0) * 0.1;
const manaFlowLevel = getEffectiveSkillLevel(state.skills, 'manaFlow', skillTiers);
const manaSpringLevel = getEffectiveSkillLevel(state.skills, 'manaSpring', skillTiers);
const base =
2 +
manaFlowLevel.level * 1 * manaFlowLevel.tierMultiplier +
manaSpringLevel.level * 2 * manaSpringLevel.tierMultiplier +
(pu.manaFlow || 0) * 0.5;
let regen = base * temporalBonus;
const computedEffects = effects ?? computeEffects(skillUpgrades, skillTiers);
regen = (regen + computedEffects.regenBonus + computedEffects.permanentRegenBonus) * computedEffects.regenMultiplier;
return regen;
}
export function computeClickMana(
state: Pick<GameState, 'skills' | 'skillUpgrades' | 'skillTiers'>,
effects?: ReturnType<typeof computeEffects>
): number {
const skillTiers = state.skillTiers || {};
const skillUpgrades = state.skillUpgrades || {};
const manaTapLevel = getEffectiveSkillLevel(state.skills, 'manaTap', skillTiers);
const manaSurgeLevel = getEffectiveSkillLevel(state.skills, 'manaSurge', skillTiers);
const base =
1 +
manaTapLevel.level * 1 * manaTapLevel.tierMultiplier +
manaSurgeLevel.level * 3 * manaSurgeLevel.tierMultiplier;
const computedEffects = effects ?? computeEffects(skillUpgrades, skillTiers);
return Math.floor((base + computedEffects.clickManaBonus) * computedEffects.clickManaMultiplier);
}
// Elemental damage bonus
export function getElementalBonus(spellElem: string, floorElem: string): number {
if (spellElem === 'raw') return 1.0;
if (spellElem === floorElem) return 1.25; // Same element: +25% damage
if (ELEMENT_OPPOSITES[floorElem] === spellElem) return 1.5; // Super effective
if (ELEMENT_OPPOSITES[spellElem] === floorElem) return 0.75; // Weak
return 1.0;
}
// Compute the pact multiplier with interference/synergy system
export function computePactMultiplier(
state: Pick<GameState, 'signedPacts' | 'pactInterferenceMitigation' | 'signedPactDetails'>
): number {
const { signedPacts, pactInterferenceMitigation = 0 } = state;
if (signedPacts.length === 0) return 1.0;
let baseMult = 1.0;
for (const floor of signedPacts) {
const guardian = GUARDIANS[floor];
if (guardian) {
baseMult *= guardian.damageMultiplier;
}
}
if (signedPacts.length === 1) return baseMult;
const numAdditionalPacts = signedPacts.length - 1;
const basePenalty = 0.5 * numAdditionalPacts;
const mitigationReduction = Math.min(pactInterferenceMitigation, 5) * 0.1;
const effectivePenalty = Math.max(0, basePenalty - mitigationReduction);
if (pactInterferenceMitigation >= 5) {
const synergyBonus = (pactInterferenceMitigation - 5) * 0.1;
return baseMult * (1 + synergyBonus);
}
return baseMult * (1 - effectivePenalty);
}
// Compute the insight multiplier from signed pacts
export function computePactInsightMultiplier(
state: Pick<GameState, 'signedPacts' | 'pactInterferenceMitigation'>
): number {
const { signedPacts, pactInterferenceMitigation = 0 } = state;
if (signedPacts.length === 0) return 1.0;
let mult = 1.0;
for (const floor of signedPacts) {
const guardian = GUARDIANS[floor];
if (guardian) {
mult *= guardian.insightMultiplier;
}
}
if (signedPacts.length > 1) {
const numAdditionalPacts = signedPacts.length - 1;
const basePenalty = 0.5 * numAdditionalPacts;
const mitigationReduction = Math.min(pactInterferenceMitigation, 5) * 0.1;
const effectivePenalty = Math.max(0, basePenalty - mitigationReduction);
if (pactInterferenceMitigation >= 5) {
const synergyBonus = (pactInterferenceMitigation - 5) * 0.1;
return mult * (1 + synergyBonus);
}
return mult * (1 - effectivePenalty);
}
return mult;
}
export function calcDamage(
state: Pick<GameState, 'skills' | 'signedPacts' | 'pactInterferenceMitigation' | 'signedPactDetails' | 'skillUpgrades' | 'skillTiers'>,
spellId: string,
floorElem?: string,
effects?: ReturnType<typeof computeEffects>
): number {
const sp = SPELLS_DEF[spellId];
if (!sp) return 5;
const skillTiers = state.skillTiers || {};
const skillUpgrades = state.skillUpgrades || {};
const computedEffects = effects ?? computeEffects(skillUpgrades, skillTiers);
// Get effective skill levels with tier multipliers
const combatTrainLevel = getEffectiveSkillLevel(state.skills, 'combatTrain', skillTiers);
const arcaneFuryLevel = getEffectiveSkillLevel(state.skills, 'arcaneFury', skillTiers);
const elemMasteryLevel = getEffectiveSkillLevel(state.skills, 'elementalMastery', skillTiers);
const guardianBaneLevel = getEffectiveSkillLevel(state.skills, 'guardianBane', skillTiers);
const precisionLevel = getEffectiveSkillLevel(state.skills, 'precision', skillTiers);
// Base damage from spell + combat training
const baseDmg = sp.dmg + combatTrainLevel.level * 5 * combatTrainLevel.tierMultiplier;
// Spell damage multiplier from arcane fury
const pct = 1 + arcaneFuryLevel.level * 0.1 * arcaneFuryLevel.tierMultiplier;
// Elemental mastery bonus
const elemMasteryBonus = 1 + elemMasteryLevel.level * 0.15 * elemMasteryLevel.tierMultiplier;
// Guardian bane bonus (only for guardian floors)
const guardianBonus = floorElem && Object.values(GUARDIANS).find(g => g.element === floorElem)
? 1 + guardianBaneLevel.level * 0.2 * guardianBaneLevel.tierMultiplier
: 1;
// Crit chance from precision
const skillCritChance = precisionLevel.level * 0.05 * precisionLevel.tierMultiplier;
const totalCritChance = skillCritChance + computedEffects.critChanceBonus;
// Pact multiplier
const pactMult = computePactMultiplier(state);
// Calculate base damage
let damage = baseDmg * pct * pactMult * elemMasteryBonus * guardianBonus;
// Apply upgrade effects: base damage multiplier and bonus
damage = damage * computedEffects.baseDamageMultiplier + computedEffects.baseDamageBonus;
// Apply elemental damage multiplier from upgrades
damage *= computedEffects.elementalDamageMultiplier;
// Apply elemental bonus for floor
if (floorElem) {
damage *= getElementalBonus(sp.elem, floorElem);
}
// Apply critical hit
if (Math.random() < totalCritChance) {
damage *= computedEffects.critDamageMultiplier;
}
return damage;
}
export function calcInsight(state: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'prestigeUpgrades' | 'skills'>): number {
const pu = state.prestigeUpgrades;
const skillBonus = 1 + (state.skills.insightHarvest || 0) * 0.1;
const mult = (1 + (pu.insightAmp || 0) * 0.25) * skillBonus;
return Math.floor(
(state.maxFloorReached * 15 + state.totalManaGathered / 500 + state.signedPacts.length * 150) * mult
);
}
export function getMeditationBonus(meditateTicks: number, skills: Record<string, number>, meditationEfficiency: number = 1): number {
const hasMeditation = skills.meditation === 1;
const hasDeepTrance = skills.deepTrance === 1;
const hasVoidMeditation = skills.voidMeditation === 1;
const hours = meditateTicks * 0.04; // HOURS_PER_TICK
let bonus = 1 + Math.min(hours / 4, 0.5);
if (hasMeditation && hours >= 4) {
bonus = 2.5;
}
if (hasDeepTrance && hours >= 6) {
bonus = 3.0;
}
if (hasVoidMeditation && hours >= 8) {
bonus = 5.0;
}
bonus *= meditationEfficiency;
return bonus;
}
export function getIncursionStrength(day: number, hour: number): number {
const INCURSION_START_DAY = 20;
const MAX_DAY = 30;
if (day < INCURSION_START_DAY) return 0;
const totalHours = (day - INCURSION_START_DAY) * 24 + hour;
const maxHours = (MAX_DAY - INCURSION_START_DAY) * 24;
return Math.min(0.95, (totalHours / maxHours) * 0.95);
}
export function getFloorMaxHP(floor: number): number {
if (GUARDIANS[floor]) return GUARDIANS[floor].hp;
const baseHP = 100;
const floorScaling = floor * 50;
const exponentialScaling = Math.pow(floor, 1.7);
return Math.floor(baseHP + floorScaling + exponentialScaling);
}
export function getFloorElement(floor: number): string {
const FLOOR_ELEM_CYCLE = ["fire", "water", "air", "earth", "light", "dark", "life", "death"];
return FLOOR_ELEM_CYCLE[(floor - 1) % 8];
}
// Formatting utilities
export function fmt(n: number): string {
if (!isFinite(n) || isNaN(n)) return '0';
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
return Math.floor(n).toString();
}
export function fmtDec(n: number, d: number = 1): string {
return isFinite(n) ? n.toFixed(d) : '0';
}
// Check if player can afford spell cost
export function canAffordSpellCost(
cost: { type: 'raw' | 'element'; element?: string; amount: number },
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>
): boolean {
if (cost.type === 'raw') {
return rawMana >= cost.amount;
} else {
const elem = elements[cost.element || ''];
return elem && elem.unlocked && elem.current >= cost.amount;
}
}

View File

@@ -0,0 +1,636 @@
// ─── Crafting Store Slice ────────────────────────────────────────────────────────
// Handles equipment, enchantments, and crafting progress
import type {
EquipmentInstance,
AppliedEnchantment,
EnchantmentDesign,
DesignEffect,
DesignProgress,
PreparationProgress,
ApplicationProgress,
EquipmentSpellState
} from '../types';
import {
EQUIPMENT_TYPES,
EQUIPMENT_SLOTS,
type EquipmentSlot,
type EquipmentTypeDef,
getEquipmentType,
calculateRarity
} from '../data/equipment';
import {
ENCHANTMENT_EFFECTS,
getEnchantmentEffect,
canApplyEffect,
calculateEffectCapacityCost,
type EnchantmentEffectDef
} from '../data/enchantment-effects';
import { SPELLS_DEF } from '../constants';
import type { StateCreator } from 'zustand';
// ─── Helper Functions ────────────────────────────────────────────────────────────
let instanceIdCounter = 0;
function generateInstanceId(): string {
return `equip_${Date.now()}_${++instanceIdCounter}`;
}
let designIdCounter = 0;
function generateDesignId(): string {
return `design_${Date.now()}_${++designIdCounter}`;
}
// Calculate efficiency bonus from skills
function getEnchantEfficiencyBonus(skills: Record<string, number>): number {
const enchantingLevel = skills.enchanting || 0;
const efficientEnchantLevel = skills.efficientEnchant || 0;
// 2% per enchanting level + 5% per efficient enchant level
return (enchantingLevel * 0.02) + (efficientEnchantLevel * 0.05);
}
// Calculate design time based on effects
function calculateDesignTime(effects: DesignEffect[]): number {
const totalCapacity = effects.reduce((sum, e) => sum + e.capacityCost, 0);
return Math.max(1, Math.floor(totalCapacity / 10)); // Hours
}
// Calculate preparation time for equipment
function calculatePreparationTime(equipmentType: string): number {
const typeDef = getEquipmentType(equipmentType);
if (!typeDef) return 1;
return Math.max(1, Math.floor(typeDef.baseCapacity / 5)); // Hours
}
// Calculate preparation mana cost
function calculatePreparationManaCost(equipmentType: string): number {
const typeDef = getEquipmentType(equipmentType);
if (!typeDef) return 50;
return typeDef.baseCapacity * 5;
}
// Calculate application time based on effects
function calculateApplicationTime(effects: DesignEffect[], skills: Record<string, number>): number {
const totalCapacity = effects.reduce((sum, e) => sum + e.capacityCost, 0);
const speedBonus = 1 + (skills.enchantSpeed || 0) * 0.1;
return Math.max(4, Math.floor(totalCapacity / 20 * 24 / speedBonus)); // Hours (days * 24)
}
// Calculate mana per hour for application
function calculateApplicationManaPerHour(effects: DesignEffect[]): number {
const totalCapacity = effects.reduce((sum, e) => sum + e.capacityCost, 0);
return Math.max(1, Math.floor(totalCapacity * 0.5));
}
// Create a new equipment instance
export function createEquipmentInstance(typeId: string, name?: string): EquipmentInstance {
const typeDef = getEquipmentType(typeId);
if (!typeDef) {
throw new Error(`Unknown equipment type: ${typeId}`);
}
return {
instanceId: generateInstanceId(),
typeId,
name: name || typeDef.name,
enchantments: [],
usedCapacity: 0,
totalCapacity: typeDef.baseCapacity,
rarity: 'common',
quality: 100, // Full quality for new items
};
}
// Get spells from equipment
export function getSpellsFromEquipment(equipment: EquipmentInstance): string[] {
const spells: string[] = [];
for (const ench of equipment.enchantments) {
const effectDef = getEnchantmentEffect(ench.effectId);
if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) {
spells.push(effectDef.effect.spellId);
}
}
return spells;
}
// Compute total effects from equipment
export function computeEquipmentEffects(equipment: EquipmentInstance[]): Record<string, number> {
const effects: Record<string, number> = {};
const multipliers: Record<string, number> = {};
const specials: Set<string> = new Set();
for (const equip of equipment) {
for (const ench of equip.enchantments) {
const effectDef = getEnchantmentEffect(ench.effectId);
if (!effectDef) continue;
const value = (effectDef.effect.value || 0) * ench.stacks;
if (effectDef.effect.type === 'bonus' && effectDef.effect.stat) {
effects[effectDef.effect.stat] = (effects[effectDef.effect.stat] || 0) + value;
} else if (effectDef.effect.type === 'multiplier' && effectDef.effect.stat) {
multipliers[effectDef.effect.stat] = (multipliers[effectDef.effect.stat] || 1) * Math.pow(value, ench.stacks);
} else if (effectDef.effect.type === 'special' && effectDef.effect.specialId) {
specials.add(effectDef.effect.specialId);
}
}
}
// Apply multipliers to bonus effects
for (const [stat, mult] of Object.entries(multipliers)) {
effects[`${stat}_multiplier`] = mult;
}
// Add special effect flags
for (const special of specials) {
effects[`special_${special}`] = 1;
}
return effects;
}
// ─── Store Interface ─────────────────────────────────────────────────────────────
export interface CraftingState {
// Equipment instances
equippedInstances: Record<string, string | null>; // slot -> instanceId
equipmentInstances: Record<string, EquipmentInstance>; // instanceId -> instance
// Enchantment designs
enchantmentDesigns: EnchantmentDesign[];
// Crafting progress
designProgress: DesignProgress | null;
preparationProgress: PreparationProgress | null;
applicationProgress: ApplicationProgress | null;
// Equipment spell states
equipmentSpellStates: EquipmentSpellState[];
}
export interface CraftingActions {
// Equipment management
createEquipment: (typeId: string, slot?: EquipmentSlot) => EquipmentInstance;
equipInstance: (instanceId: string, slot: EquipmentSlot) => void;
unequipSlot: (slot: EquipmentSlot) => void;
deleteInstance: (instanceId: string) => void;
// Enchantment design
startDesign: (name: string, equipmentType: string, effects: DesignEffect[]) => void;
cancelDesign: () => void;
deleteDesign: (designId: string) => void;
// Equipment preparation
startPreparation: (instanceId: string) => void;
cancelPreparation: () => void;
// Enchantment application
startApplication: (instanceId: string, designId: string) => void;
pauseApplication: () => void;
resumeApplication: () => void;
cancelApplication: () => void;
// Tick processing
processDesignTick: (hours: number) => void;
processPreparationTick: (hours: number, manaAvailable: number) => number; // Returns mana used
processApplicationTick: (hours: number, manaAvailable: number) => number; // Returns mana used
// Getters
getEquippedInstance: (slot: EquipmentSlot) => EquipmentInstance | null;
getAllEquipped: () => EquipmentInstance[];
getAvailableSpells: () => string[];
getEquipmentEffects: () => Record<string, number>;
}
export type CraftingStore = CraftingState & CraftingActions;
// ─── Initial State ──────────────────────────────────────────────────────────────
export const initialCraftingState: CraftingState = {
equippedInstances: {
mainHand: null,
offHand: null,
head: null,
body: null,
hands: null,
feet: null,
accessory1: null,
accessory2: null,
},
equipmentInstances: {},
enchantmentDesigns: [],
designProgress: null,
preparationProgress: null,
applicationProgress: null,
equipmentSpellStates: [],
};
// ─── Store Slice Creator ────────────────────────────────────────────────────────
export const createCraftingSlice: StateCreator<CraftingStore, [], [], CraftingStore> = (set, get) => ({
...initialCraftingState,
// Equipment management
createEquipment: (typeId: string, slot?: EquipmentSlot) => {
const instance = createEquipmentInstance(typeId);
set((state) => ({
equipmentInstances: {
...state.equipmentInstances,
[instance.instanceId]: instance,
},
}));
// Auto-equip if slot provided
if (slot) {
get().equipInstance(instance.instanceId, slot);
}
return instance;
},
equipInstance: (instanceId: string, slot: EquipmentSlot) => {
const instance = get().equipmentInstances[instanceId];
if (!instance) return;
const typeDef = getEquipmentType(instance.typeId);
if (!typeDef) return;
// Check if equipment can go in this slot
if (typeDef.slot !== slot) {
// For accessories, both accessory1 and accessory2 are valid
if (typeDef.category !== 'accessory' || (slot !== 'accessory1' && slot !== 'accessory2')) {
return;
}
}
set((state) => ({
equippedInstances: {
...state.equippedInstances,
[slot]: instanceId,
},
}));
},
unequipSlot: (slot: EquipmentSlot) => {
set((state) => ({
equippedInstances: {
...state.equippedInstances,
[slot]: null,
},
}));
},
deleteInstance: (instanceId: string) => {
set((state) => {
const newInstanceMap = { ...state.equipmentInstances };
delete newInstanceMap[instanceId];
// Remove from equipped slots
const newEquipped = { ...state.equippedInstances };
for (const slot of EQUIPMENT_SLOTS) {
if (newEquipped[slot] === instanceId) {
newEquipped[slot] = null;
}
}
return {
equipmentInstances: newInstanceMap,
equippedInstances: newEquipped,
};
});
},
// Enchantment design
startDesign: (name: string, equipmentType: string, effects: DesignEffect[]) => {
const totalCapacity = effects.reduce((sum, e) => sum + e.capacityCost, 0);
const designTime = calculateDesignTime(effects);
const design: EnchantmentDesign = {
id: generateDesignId(),
name,
equipmentType,
effects,
totalCapacityUsed: totalCapacity,
designTime,
created: Date.now(),
};
set((state) => ({
enchantmentDesigns: [...state.enchantmentDesigns, design],
designProgress: {
designId: design.id,
progress: 0,
required: designTime,
},
}));
},
cancelDesign: () => {
const progress = get().designProgress;
if (!progress) return;
set((state) => ({
designProgress: null,
enchantmentDesigns: state.enchantmentDesigns.filter(d => d.id !== progress.designId),
}));
},
deleteDesign: (designId: string) => {
set((state) => ({
enchantmentDesigns: state.enchantmentDesigns.filter(d => d.id !== designId),
}));
},
// Equipment preparation
startPreparation: (instanceId: string) => {
const instance = get().equipmentInstances[instanceId];
if (!instance) return;
const prepTime = calculatePreparationTime(instance.typeId);
const manaCost = calculatePreparationManaCost(instance.typeId);
set({
preparationProgress: {
equipmentInstanceId: instanceId,
progress: 0,
required: prepTime,
manaCostPaid: 0,
},
});
},
cancelPreparation: () => {
set({ preparationProgress: null });
},
// Enchantment application
startApplication: (instanceId: string, designId: string) => {
const instance = get().equipmentInstances[instanceId];
const design = get().enchantmentDesigns.find(d => d.id === designId);
if (!instance || !design) return;
const appTime = calculateApplicationTime(design.effects, {}); // TODO: pass skills
const manaPerHour = calculateApplicationManaPerHour(design.effects);
set({
applicationProgress: {
equipmentInstanceId: instanceId,
designId,
progress: 0,
required: appTime,
manaPerHour,
paused: false,
manaSpent: 0,
},
});
},
pauseApplication: () => {
const progress = get().applicationProgress;
if (!progress) return;
set({
applicationProgress: { ...progress, paused: true },
});
},
resumeApplication: () => {
const progress = get().applicationProgress;
if (!progress) return;
set({
applicationProgress: { ...progress, paused: false },
});
},
cancelApplication: () => {
set({ applicationProgress: null });
},
// Tick processing
processDesignTick: (hours: number) => {
const progress = get().designProgress;
if (!progress) return;
const newProgress = progress.progress + hours;
if (newProgress >= progress.required) {
// Design complete
set({ designProgress: null });
} else {
set({
designProgress: { ...progress, progress: newProgress },
});
}
},
processPreparationTick: (hours: number, manaAvailable: number) => {
const progress = get().preparationProgress;
if (!progress) return 0;
const instance = get().equipmentInstances[progress.equipmentInstanceId];
if (!instance) {
set({ preparationProgress: null });
return 0;
}
const totalManaCost = calculatePreparationManaCost(instance.typeId);
const remainingManaCost = totalManaCost - progress.manaCostPaid;
const manaToPay = Math.min(manaAvailable, remainingManaCost);
if (manaToPay < remainingManaCost) {
// Not enough mana, just pay what we can
set({
preparationProgress: {
...progress,
manaCostPaid: progress.manaCostPaid + manaToPay,
},
});
return manaToPay;
}
// Pay remaining mana and progress
const newProgress = progress.progress + hours;
if (newProgress >= progress.required) {
// Preparation complete - clear enchantments
set((state) => ({
preparationProgress: null,
equipmentInstances: {
...state.equipmentInstances,
[instance.instanceId]: {
...instance,
enchantments: [],
usedCapacity: 0,
rarity: 'common',
},
},
}));
} else {
set({
preparationProgress: {
...progress,
progress: newProgress,
manaCostPaid: progress.manaCostPaid + manaToPay,
},
});
}
return manaToPay;
},
processApplicationTick: (hours: number, manaAvailable: number) => {
const progress = get().applicationProgress;
if (!progress || progress.paused) return 0;
const design = get().enchantmentDesigns.find(d => d.id === progress.designId);
const instance = get().equipmentInstances[progress.equipmentInstanceId];
if (!design || !instance) {
set({ applicationProgress: null });
return 0;
}
const manaNeeded = progress.manaPerHour * hours;
const manaToUse = Math.min(manaAvailable, manaNeeded);
if (manaToUse < manaNeeded) {
// Not enough mana - pause and save progress
set({
applicationProgress: {
...progress,
manaSpent: progress.manaSpent + manaToUse,
},
});
return manaToUse;
}
const newProgress = progress.progress + hours;
if (newProgress >= progress.required) {
// Application complete - apply enchantments
const efficiencyBonus = 0; // TODO: get from skills
const newEnchantments: AppliedEnchantment[] = design.effects.map(e => ({
effectId: e.effectId,
stacks: e.stacks,
actualCost: calculateEffectCapacityCost(e.effectId, e.stacks, efficiencyBonus),
}));
const totalUsedCapacity = newEnchantments.reduce((sum, e) => sum + e.actualCost, 0);
set((state) => ({
applicationProgress: null,
equipmentInstances: {
...state.equipmentInstances,
[instance.instanceId]: {
...instance,
enchantments: newEnchantments,
usedCapacity: totalUsedCapacity,
rarity: calculateRarity(newEnchantments),
},
},
}));
} else {
set({
applicationProgress: {
...progress,
progress: newProgress,
manaSpent: progress.manaSpent + manaToUse,
},
});
}
return manaToUse;
},
// Getters
getEquippedInstance: (slot: EquipmentSlot) => {
const state = get();
const instanceId = state.equippedInstances[slot];
if (!instanceId) return null;
return state.equipmentInstances[instanceId] || null;
},
getAllEquipped: () => {
const state = get();
const equipped: EquipmentInstance[] = [];
for (const slot of EQUIPMENT_SLOTS) {
const instanceId = state.equippedInstances[slot];
if (instanceId && state.equipmentInstances[instanceId]) {
equipped.push(state.equipmentInstances[instanceId]);
}
}
return equipped;
},
getAvailableSpells: () => {
const equipped = get().getAllEquipped();
const spells: string[] = [];
for (const equip of equipped) {
spells.push(...getSpellsFromEquipment(equip));
}
return spells;
},
getEquipmentEffects: () => {
return computeEquipmentEffects(get().getAllEquipped());
},
});
// ─── Starting Equipment Factory ────────────────────────────────────────────────
export function createStartingEquipment(): {
equippedInstances: Record<string, string | null>;
equipmentInstances: Record<string, EquipmentInstance>;
} {
const instances: EquipmentInstance[] = [];
// Create starting equipment
const basicStaff = createEquipmentInstance('basicStaff');
basicStaff.enchantments = [{
effectId: 'spell_manaBolt',
stacks: 1,
actualCost: 50, // Fills the staff completely
}];
basicStaff.usedCapacity = 50;
basicStaff.rarity = 'uncommon';
instances.push(basicStaff);
const civilianShirt = createEquipmentInstance('civilianShirt');
instances.push(civilianShirt);
const civilianGloves = createEquipmentInstance('civilianGloves');
instances.push(civilianGloves);
const civilianShoes = createEquipmentInstance('civilianShoes');
instances.push(civilianShoes);
// Build instance map
const equipmentInstances: Record<string, EquipmentInstance> = {};
for (const inst of instances) {
equipmentInstances[inst.instanceId] = inst;
}
// Build equipped map
const equippedInstances: Record<string, string | null> = {
mainHand: basicStaff.instanceId,
offHand: null,
head: null,
body: civilianShirt.instanceId,
hands: civilianGloves.instanceId,
feet: civilianShoes.instanceId,
accessory1: null,
accessory2: null,
};
return { equippedInstances, equipmentInstances };
}

9
src/lib/game/store/index.ts Executable file
View File

@@ -0,0 +1,9 @@
// ─── Store Module Exports ─────────────────────────────────────────────────────
// Re-exports from main store and adds new computed utilities
// This allows gradual migration while keeping existing functionality
// Re-export everything from the main store
export * from '../store';
// Export new computed utilities
export * from './computed';

197
src/lib/game/store/manaSlice.ts Executable file
View File

@@ -0,0 +1,197 @@
// ─── Mana Slice ───────────────────────────────────────────────────────────────
// Manages raw mana, elements, and meditation
import type { StateCreator } from 'zustand';
import type { GameState, ElementState, SpellCost } from '../types';
import { ELEMENTS, MANA_PER_ELEMENT, BASE_UNLOCKED_ELEMENTS } from '../constants';
import { computeMaxMana, computeElementMax, computeClickMana, canAffordSpellCost, getMeditationBonus } from './computed';
import { computeEffects } from '../upgrade-effects';
export interface ManaSlice {
// State
rawMana: number;
totalManaGathered: number;
meditateTicks: number;
elements: Record<string, ElementState>;
// Actions
gatherMana: () => void;
convertMana: (element: string, amount: number) => void;
unlockElement: (element: string) => void;
craftComposite: (target: string) => void;
// Computed getters
getMaxMana: () => number;
getRegen: () => number;
getClickMana: () => number;
getMeditationMultiplier: () => number;
}
export const createManaSlice = (
set: StateCreator<GameState>['set'],
get: () => GameState
): ManaSlice => ({
rawMana: 10,
totalManaGathered: 0,
meditateTicks: 0,
elements: (() => {
const elems: Record<string, ElementState> = {};
const pu = get().prestigeUpgrades;
const elemMax = computeElementMax(get());
Object.keys(ELEMENTS).forEach((k) => {
const isUnlocked = BASE_UNLOCKED_ELEMENTS.includes(k);
let startAmount = 0;
if (isUnlocked && pu.elemStart) {
startAmount = pu.elemStart * 5;
}
elems[k] = {
current: startAmount,
max: elemMax,
unlocked: isUnlocked,
};
});
return elems;
})(),
gatherMana: () => {
const state = get();
let cm = computeClickMana(state);
// Mana overflow bonus
const overflowBonus = 1 + (state.skills.manaOverflow || 0) * 0.25;
cm = Math.floor(cm * overflowBonus);
const effects = computeEffects(state.skillUpgrades || {}, state.skillTiers || {});
const max = computeMaxMana(state, effects);
// Mana Echo: 10% chance to gain double mana from clicks
const hasManaEcho = effects.specials?.has('MANA_ECHO') ?? false;
if (hasManaEcho && Math.random() < 0.1) {
cm *= 2;
}
set({
rawMana: Math.min(state.rawMana + cm, max),
totalManaGathered: state.totalManaGathered + cm,
});
},
convertMana: (element: string, amount: number = 1) => {
const state = get();
const e = state.elements[element];
if (!e?.unlocked) return;
const cost = MANA_PER_ELEMENT * amount;
if (state.rawMana < cost) return;
if (e.current >= e.max) return;
const canConvert = Math.min(
amount,
Math.floor(state.rawMana / MANA_PER_ELEMENT),
e.max - e.current
);
set({
rawMana: state.rawMana - canConvert * MANA_PER_ELEMENT,
elements: {
...state.elements,
[element]: { ...e, current: e.current + canConvert },
},
});
},
unlockElement: (element: string) => {
const state = get();
if (state.elements[element]?.unlocked) return;
const cost = 500;
if (state.rawMana < cost) return;
set({
rawMana: state.rawMana - cost,
elements: {
...state.elements,
[element]: { ...state.elements[element], unlocked: true },
},
});
},
craftComposite: (target: string) => {
const state = get();
const edef = ELEMENTS[target];
if (!edef?.recipe) return;
const recipe = edef.recipe;
const costs: Record<string, number> = {};
recipe.forEach((r) => {
costs[r] = (costs[r] || 0) + 1;
});
// Check ingredients
for (const [r, amt] of Object.entries(costs)) {
if ((state.elements[r]?.current || 0) < amt) return;
}
const newElems = { ...state.elements };
for (const [r, amt] of Object.entries(costs)) {
newElems[r] = { ...newElems[r], current: newElems[r].current - amt };
}
// Elemental crafting bonus
const craftBonus = 1 + (state.skills.elemCrafting || 0) * 0.25;
const outputAmount = Math.floor(craftBonus);
const effects = computeEffects(state.skillUpgrades || {}, state.skillTiers || {});
const elemMax = computeElementMax(state, effects);
newElems[target] = {
...(newElems[target] || { current: 0, max: elemMax, unlocked: false }),
current: (newElems[target]?.current || 0) + outputAmount,
max: elemMax,
unlocked: true,
};
set({
elements: newElems,
});
},
getMaxMana: () => computeMaxMana(get()),
getRegen: () => {
const state = get();
const effects = computeEffects(state.skillUpgrades || {}, state.skillTiers || {});
// This would need proper regen calculation
return 2;
},
getClickMana: () => computeClickMana(get()),
getMeditationMultiplier: () => {
const state = get();
const effects = computeEffects(state.skillUpgrades || {}, state.skillTiers || {});
return getMeditationBonus(state.meditateTicks, state.skills, effects.meditationEfficiency);
},
});
// Helper function to deduct spell cost
export function deductSpellCost(
cost: SpellCost,
rawMana: number,
elements: Record<string, ElementState>
): { rawMana: number; elements: Record<string, ElementState> } {
const newElements = { ...elements };
if (cost.type === 'raw') {
return { rawMana: rawMana - cost.amount, elements: newElements };
} else if (cost.element && newElements[cost.element]) {
newElements[cost.element] = {
...newElements[cost.element],
current: newElements[cost.element].current - cost.amount,
};
return { rawMana, elements: newElements };
}
return { rawMana, elements: newElements };
}
export { canAffordSpellCost };

180
src/lib/game/store/pactSlice.ts Executable file
View File

@@ -0,0 +1,180 @@
// ─── Pact Slice ───────────────────────────────────────────────────────────────
// Manages guardian pacts, signing, and mana unlocking
import type { StateCreator } from 'zustand';
import type { GameState } from '../types';
import { GUARDIANS, ELEMENTS } from '../constants';
import { computePactMultiplier, computePactInsightMultiplier } from './computed';
export interface PactSlice {
// State
signedPacts: number[];
pendingPactOffer: number | null;
maxPacts: number;
pactSigningProgress: {
floor: number;
progress: number;
required: number;
manaCost: number;
} | null;
signedPactDetails: Record<number, {
floor: number;
guardianId: string;
signedAt: { day: number; hour: number };
skillLevels: Record<string, number>;
}>;
pactInterferenceMitigation: number;
pactSynergyUnlocked: boolean;
// Actions
acceptPact: (floor: number) => void;
declinePact: (floor: number) => void;
// Computed getters
getPactMultiplier: () => number;
getPactInsightMultiplier: () => number;
}
export const createPactSlice = (
set: StateCreator<GameState>['set'],
get: () => GameState
): PactSlice => ({
signedPacts: [],
pendingPactOffer: null,
maxPacts: 1,
pactSigningProgress: null,
signedPactDetails: {},
pactInterferenceMitigation: 0,
pactSynergyUnlocked: false,
acceptPact: (floor: number) => {
const state = get();
const guardian = GUARDIANS[floor];
if (!guardian || state.signedPacts.includes(floor)) return;
const maxPacts = 1 + (state.prestigeUpgrades.pactCapacity || 0);
if (state.signedPacts.length >= maxPacts) {
set({
log: [`⚠️ Cannot sign more pacts! Maximum: ${maxPacts}.`, ...state.log.slice(0, 49)],
});
return;
}
const baseCost = guardian.signingCost.mana;
const discount = Math.min((state.prestigeUpgrades.pactDiscount || 0) * 0.1, 0.5);
const manaCost = Math.floor(baseCost * (1 - discount));
if (state.rawMana < manaCost) {
set({
log: [`⚠️ Need ${manaCost} mana to sign pact with ${guardian.name}!`, ...state.log.slice(0, 49)],
});
return;
}
const baseTime = guardian.signingCost.time;
const haste = Math.min((state.prestigeUpgrades.pactHaste || 0) * 0.1, 0.5);
const signingTime = Math.max(1, baseTime * (1 - haste));
set({
rawMana: state.rawMana - manaCost,
pactSigningProgress: {
floor,
progress: 0,
required: signingTime,
manaCost,
},
pendingPactOffer: null,
currentAction: 'study',
log: [`📜 Beginning pact signing with ${guardian.name}... (${signingTime}h, ${manaCost} mana)`, ...state.log.slice(0, 49)],
});
},
declinePact: (floor: number) => {
const state = get();
const guardian = GUARDIANS[floor];
if (!guardian) return;
set({
pendingPactOffer: null,
log: [`🚫 Declined pact with ${guardian.name}.`, ...state.log.slice(0, 49)],
});
},
getPactMultiplier: () => computePactMultiplier(get()),
getPactInsightMultiplier: () => computePactInsightMultiplier(get()),
});
// Process pact signing progress (called during tick)
export function processPactSigning(state: GameState, deltaHours: number): Partial<GameState> {
if (!state.pactSigningProgress) return {};
const progress = state.pactSigningProgress.progress + deltaHours;
const log = [...state.log];
if (progress >= state.pactSigningProgress.required) {
const floor = state.pactSigningProgress.floor;
const guardian = GUARDIANS[floor];
if (!guardian || state.signedPacts.includes(floor)) {
return { pactSigningProgress: null };
}
const signedPacts = [...state.signedPacts, floor];
const signedPactDetails = {
...state.signedPactDetails,
[floor]: {
floor,
guardianId: guardian.element,
signedAt: { day: state.day, hour: state.hour },
skillLevels: {},
},
};
// Unlock mana types
let elements = { ...state.elements };
for (const elemId of guardian.unlocksMana) {
if (elements[elemId]) {
elements = {
...elements,
[elemId]: { ...elements[elemId], unlocked: true },
};
}
}
// Check for compound element unlocks
const unlockedSet = new Set(
Object.entries(elements)
.filter(([, e]) => e.unlocked)
.map(([id]) => id)
);
for (const [elemId, elemDef] of Object.entries(ELEMENTS)) {
if (elemDef.recipe && !elements[elemId]?.unlocked) {
const canUnlock = elemDef.recipe.every(comp => unlockedSet.has(comp));
if (canUnlock) {
elements = {
...elements,
[elemId]: { ...elements[elemId], unlocked: true },
};
log.unshift(`🔮 ${elemDef.name} mana unlocked through component synergy!`);
}
}
}
log.unshift(`📜 Pact with ${guardian.name} signed! ${guardian.unlocksMana.map(e => ELEMENTS[e]?.name || e).join(', ')} mana unlocked!`);
return {
signedPacts,
signedPactDetails,
elements,
pactSigningProgress: null,
log,
};
}
return {
pactSigningProgress: {
...state.pactSigningProgress,
progress,
},
};
}

View File

@@ -0,0 +1,140 @@
// ─── Prestige Slice ───────────────────────────────────────────────────────────
// Manages insight, prestige upgrades, and loop resources
import type { StateCreator } from 'zustand';
import type { GameState } from '../types';
import { PRESTIGE_DEF } from '../constants';
export interface PrestigeSlice {
// State
insight: number;
totalInsight: number;
prestigeUpgrades: Record<string, number>;
loopInsight: number;
memorySlots: number;
memories: string[];
// Actions
doPrestige: (id: string) => void;
startNewLoop: () => void;
}
export const createPrestigeSlice = (
set: StateCreator<GameState>['set'],
get: () => GameState
): PrestigeSlice => ({
insight: 0,
totalInsight: 0,
prestigeUpgrades: {},
loopInsight: 0,
memorySlots: 3,
memories: [],
doPrestige: (id: string) => {
const state = get();
const pd = PRESTIGE_DEF[id];
if (!pd) return;
const lvl = state.prestigeUpgrades[id] || 0;
if (lvl >= pd.max || state.insight < pd.cost) return;
const newPU = { ...state.prestigeUpgrades, [id]: lvl + 1 };
set({
insight: state.insight - pd.cost,
prestigeUpgrades: newPU,
memorySlots: id === 'deepMemory' ? state.memorySlots + 1 : state.memorySlots,
maxPacts: id === 'pactCapacity' ? state.maxPacts + 1 : state.maxPacts,
pactInterferenceMitigation: id === 'pactInterference' ? (state.pactInterferenceMitigation || 0) + 1 : state.pactInterferenceMitigation,
log: [`${pd.name} upgraded to Lv.${lvl + 1}!`, ...state.log.slice(0, 49)],
});
},
startNewLoop: () => {
const state = get();
const insightGained = state.loopInsight || calcInsight(state);
const total = state.insight + insightGained;
// Keep some spells through temporal memory
const spellsToKeep: string[] = [];
if (state.skills.temporalMemory) {
const learnedSpells = Object.entries(state.spells)
.filter(([, s]) => s.learned)
.map(([id]) => id);
spellsToKeep.push(...learnedSpells.slice(0, state.skills.temporalMemory));
}
// Reset to initial state with insight carried over
const pu = state.prestigeUpgrades;
const startFloor = 1 + (pu.spireKey || 0) * 2;
const startRawMana = 10 + (pu.manaWell || 0) * 500 + (pu.quickStart || 0) * 100;
// Reset elements
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
Object.keys(ELEMENTS).forEach((k) => {
elements[k] = {
current: 0,
max: 10 + (pu.elementalAttune || 0) * 25,
unlocked: false,
};
});
// Reset spells
const spells: Record<string, { learned: boolean; level: number; studyProgress: number }> = {
manaBolt: { learned: true, level: 1, studyProgress: 0 },
};
spellsToKeep.forEach(spellId => {
spells[spellId] = { learned: true, level: 1, studyProgress: 0 };
});
// Add random starting spells from spell memory upgrade
if (pu.spellMemory) {
const availableSpells = Object.keys(SPELLS_DEF).filter(s => s !== 'manaBolt' && !spellsToKeep.includes(s));
const shuffled = availableSpells.sort(() => Math.random() - 0.5);
for (let i = 0; i < Math.min(pu.spellMemory, shuffled.length); i++) {
spells[shuffled[i]] = { learned: true, level: 1, studyProgress: 0 };
}
}
set({
day: 1,
hour: 0,
gameOver: false,
victory: false,
loopCount: state.loopCount + 1,
rawMana: startRawMana,
totalManaGathered: 0,
meditateTicks: 0,
elements,
currentFloor: startFloor,
floorHP: getFloorMaxHP(startFloor),
floorMaxHP: getFloorMaxHP(startFloor),
maxFloorReached: startFloor,
signedPacts: [],
pendingPactOffer: null,
pactSigningProgress: null,
signedPactDetails: {},
activeSpell: 'manaBolt',
currentAction: 'meditate',
castProgress: 0,
spells,
skills: {},
skillProgress: {},
skillUpgrades: {},
skillTiers: {},
currentStudyTarget: null,
parallelStudyTarget: null,
insight: total,
totalInsight: (state.totalInsight || 0) + insightGained,
loopInsight: 0,
maxPacts: 1 + (pu.pactCapacity || 0),
pactInterferenceMitigation: pu.pactInterference || 0,
memorySlots: 3 + (pu.deepMemory || 0),
log: ['✨ A new loop begins. Your insight grows...', '✨ The loop begins. You start with Mana Bolt.'],
});
},
});
// Need to import these
import { ELEMENTS, SPELLS_DEF } from '../constants';
import { getFloorMaxHP, calcInsight } from './computed';

346
src/lib/game/store/skillSlice.ts Executable file
View File

@@ -0,0 +1,346 @@
// ─── Skill Slice ──────────────────────────────────────────────────────────────
// Manages skills, studying, and skill progress
import type { StateCreator } from 'zustand';
import type { GameState, StudyTarget, SkillUpgradeChoice } from '../types';
import { SKILLS_DEF, SPELLS_DEF, getStudySpeedMultiplier, getStudyCostMultiplier } from '../constants';
import { getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier } from '../skill-evolution';
import { computeEffects } from '../upgrade-effects';
export interface SkillSlice {
// State
skills: Record<string, number>;
skillProgress: Record<string, number>;
skillUpgrades: Record<string, string[]>;
skillTiers: Record<string, number>;
currentStudyTarget: StudyTarget | null;
parallelStudyTarget: StudyTarget | null;
// Actions
startStudyingSkill: (skillId: string) => void;
startStudyingSpell: (spellId: string) => void;
startParallelStudySkill: (skillId: string) => void;
cancelStudy: () => void;
cancelParallelStudy: () => void;
selectSkillUpgrade: (skillId: string, upgradeId: string) => void;
deselectSkillUpgrade: (skillId: string, upgradeId: string) => void;
commitSkillUpgrades: (skillId: string, upgradeIds: string[], milestone: 5 | 10) => void;
tierUpSkill: (skillId: string) => void;
// Getters
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => { available: SkillUpgradeChoice[]; selected: string[] };
}
export const createSkillSlice = (
set: StateCreator<GameState>['set'],
get: () => GameState
): SkillSlice => ({
skills: {},
skillProgress: {},
skillUpgrades: {},
skillTiers: {},
currentStudyTarget: null,
parallelStudyTarget: null,
startStudyingSkill: (skillId: string) => {
const state = get();
const sk = SKILLS_DEF[skillId];
if (!sk) return;
const currentLevel = state.skills[skillId] || 0;
if (currentLevel >= sk.max) return;
// Check prerequisites
if (sk.req) {
for (const [r, rl] of Object.entries(sk.req)) {
if ((state.skills[r] || 0) < rl) return;
}
}
const costMult = getStudyCostMultiplier(state.skills);
const totalCost = Math.floor(sk.base * (currentLevel + 1) * costMult);
const manaCostPerHour = totalCost / sk.studyTime;
set({
currentAction: 'study',
currentStudyTarget: {
type: 'skill',
id: skillId,
progress: state.skillProgress[skillId] || 0,
required: sk.studyTime,
manaCostPerHour,
},
log: [`📚 Started studying ${sk.name}... (${Math.floor(manaCostPerHour)} mana/hour)`, ...state.log.slice(0, 49)],
});
},
startStudyingSpell: (spellId: string) => {
const state = get();
const sp = SPELLS_DEF[spellId];
if (!sp || state.spells[spellId]?.learned) return;
const costMult = getStudyCostMultiplier(state.skills);
const totalCost = Math.floor(sp.unlock * costMult);
const studyTime = sp.studyTime || (sp.tier * 4);
const manaCostPerHour = totalCost / studyTime;
set({
currentAction: 'study',
currentStudyTarget: {
type: 'spell',
id: spellId,
progress: state.spells[spellId]?.studyProgress || 0,
required: studyTime,
manaCostPerHour,
},
spells: {
...state.spells,
[spellId]: {
...(state.spells[spellId] || { learned: false, level: 0 }),
studyProgress: state.spells[spellId]?.studyProgress || 0,
},
},
log: [`📚 Started studying ${sp.name}... (${Math.floor(manaCostPerHour)} mana/hour)`, ...state.log.slice(0, 49)],
});
},
startParallelStudySkill: (skillId: string) => {
const state = get();
if (state.parallelStudyTarget) return;
if (!state.currentStudyTarget) return;
const sk = SKILLS_DEF[skillId];
if (!sk) return;
const currentLevel = state.skills[skillId] || 0;
if (currentLevel >= sk.max) return;
if (state.currentStudyTarget.id === skillId) return;
set({
parallelStudyTarget: {
type: 'skill',
id: skillId,
progress: state.skillProgress[skillId] || 0,
required: sk.studyTime,
manaCostPerHour: 0, // Parallel study doesn't cost extra
},
log: [`📚 Started parallel study of ${sk.name}... (50% speed)`, ...state.log.slice(0, 49)],
});
},
cancelStudy: () => {
const state = get();
if (!state.currentStudyTarget) return;
const savedProgress = state.currentStudyTarget.progress;
const log = ['📖 Study paused. Progress saved.', ...state.log.slice(0, 49)];
if (state.currentStudyTarget.type === 'skill') {
set({
currentStudyTarget: null,
currentAction: 'meditate',
skillProgress: {
...state.skillProgress,
[state.currentStudyTarget.id]: savedProgress,
},
log,
});
} else {
set({
currentStudyTarget: null,
currentAction: 'meditate',
spells: {
...state.spells,
[state.currentStudyTarget.id]: {
...(state.spells[state.currentStudyTarget.id] || { learned: false, level: 0 }),
studyProgress: savedProgress,
},
},
log,
});
}
},
cancelParallelStudy: () => {
set((state) => {
if (!state.parallelStudyTarget) return state;
return {
parallelStudyTarget: null,
log: ['📖 Parallel study cancelled.', ...state.log.slice(0, 49)],
};
});
},
selectSkillUpgrade: (skillId: string, upgradeId: string) => {
set((state) => {
const current = state.skillUpgrades?.[skillId] || [];
if (current.includes(upgradeId)) return state;
if (current.length >= 2) return state;
return {
skillUpgrades: {
...state.skillUpgrades,
[skillId]: [...current, upgradeId],
},
};
});
},
deselectSkillUpgrade: (skillId: string, upgradeId: string) => {
set((state) => {
const current = state.skillUpgrades?.[skillId] || [];
return {
skillUpgrades: {
...state.skillUpgrades,
[skillId]: current.filter(id => id !== upgradeId),
},
};
});
},
commitSkillUpgrades: (skillId: string, upgradeIds: string[], milestone: 5 | 10) => {
set((state) => {
const existingUpgrades = state.skillUpgrades?.[skillId] || [];
const otherMilestoneUpgrades = existingUpgrades.filter(
id => milestone === 5 ? id.includes('_l10') : id.includes('_l5')
);
return {
skillUpgrades: {
...state.skillUpgrades,
[skillId]: [...otherMilestoneUpgrades, ...upgradeIds],
},
};
});
},
tierUpSkill: (skillId: string) => {
const state = get();
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
const currentTier = state.skillTiers?.[baseSkillId] || 1;
const nextTier = currentTier + 1;
if (nextTier > 5) return;
const nextTierSkillId = `${baseSkillId}_t${nextTier}`;
set({
skillTiers: {
...state.skillTiers,
[baseSkillId]: nextTier,
},
skills: {
...state.skills,
[nextTierSkillId]: 0,
[skillId]: 0,
},
skillProgress: {
...state.skillProgress,
[skillId]: 0,
[nextTierSkillId]: 0,
},
skillUpgrades: {
...state.skillUpgrades,
[nextTierSkillId]: [],
[skillId]: [],
},
log: [`🌟 ${SKILLS_DEF[baseSkillId]?.name || baseSkillId} evolved to Tier ${nextTier}!`, ...state.log.slice(0, 49)],
});
},
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => {
const state = get();
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
const tier = state.skillTiers?.[baseSkillId] || 1;
const available = getUpgradesForSkillAtMilestone(skillId, milestone, state.skillTiers || {});
const selected = (state.skillUpgrades?.[skillId] || []).filter(id =>
available.some(u => u.id === id)
);
return { available, selected };
},
});
// Process study progress (called during tick)
export function processStudy(state: GameState, deltaHours: number): Partial<GameState> {
if (state.currentAction !== 'study' || !state.currentStudyTarget) return {};
const target = state.currentStudyTarget;
const studySpeedMult = getStudySpeedMultiplier(state.skills);
const progressGain = deltaHours * studySpeedMult;
const manaCost = progressGain * target.manaCostPerHour;
let rawMana = state.rawMana;
let totalManaGathered = state.totalManaGathered;
let skills = state.skills;
let skillProgress = state.skillProgress;
let spells = state.spells;
const log = [...state.log];
if (rawMana >= manaCost) {
rawMana -= manaCost;
totalManaGathered += manaCost;
const newProgress = target.progress + progressGain;
if (newProgress >= target.required) {
// Study complete
if (target.type === 'skill') {
const skillId = target.id;
const currentLevel = skills[skillId] || 0;
skills = { ...skills, [skillId]: currentLevel + 1 };
skillProgress = { ...skillProgress, [skillId]: 0 };
log.unshift(`${SKILLS_DEF[skillId]?.name} Lv.${currentLevel + 1} mastered!`);
} else if (target.type === 'spell') {
const spellId = target.id;
spells = {
...spells,
[spellId]: { learned: true, level: 1, studyProgress: 0 },
};
log.unshift(`📖 ${SPELLS_DEF[spellId]?.name} learned!`);
}
return {
rawMana,
totalManaGathered,
skills,
skillProgress,
spells,
currentStudyTarget: null,
currentAction: 'meditate',
log,
};
}
return {
rawMana,
totalManaGathered,
currentStudyTarget: { ...target, progress: newProgress },
};
}
// Not enough mana
log.unshift('⚠️ Not enough mana to continue studying. Progress saved.');
if (target.type === 'skill') {
return {
currentStudyTarget: null,
currentAction: 'meditate',
skillProgress: { ...skillProgress, [target.id]: target.progress },
log,
};
} else {
return {
currentStudyTarget: null,
currentAction: 'meditate',
spells: {
...spells,
[target.id]: {
...(spells[target.id] || { learned: false, level: 0 }),
studyProgress: target.progress,
},
},
log,
};
}
}

88
src/lib/game/store/timeSlice.ts Executable file
View File

@@ -0,0 +1,88 @@
// ─── Time Slice ───────────────────────────────────────────────────────────────
// Manages game time, loops, and game state
import type { StateCreator } from 'zustand';
import type { GameState } from '../types';
import { MAX_DAY } from '../constants';
import { calcInsight } from './computed';
export interface TimeSlice {
// State
day: number;
hour: number;
loopCount: number;
gameOver: boolean;
victory: boolean;
paused: boolean;
incursionStrength: number;
loopInsight: number;
log: string[];
// Actions
togglePause: () => void;
resetGame: () => void;
startNewLoop: () => void;
addLog: (message: string) => void;
}
export const createTimeSlice = (
set: StateCreator<GameState>['set'],
get: () => GameState,
initialOverrides?: Partial<GameState>
): TimeSlice => ({
day: 1,
hour: 0,
loopCount: initialOverrides?.loopCount || 0,
gameOver: false,
victory: false,
paused: false,
incursionStrength: 0,
loopInsight: 0,
log: ['✨ The loop begins. You start with Mana Bolt. Gather your strength, mage.'],
togglePause: () => {
set((state) => ({ paused: !state.paused }));
},
resetGame: () => {
if (typeof window !== 'undefined') {
localStorage.removeItem('mana-loop-storage');
}
// Reset to initial state
window.location.reload();
},
startNewLoop: () => {
const state = get();
const insightGained = state.loopInsight || calcInsight(state);
const total = state.insight + insightGained;
// Keep some spells through temporal memory
const spellsToKeep: string[] = [];
if (state.skills.temporalMemory) {
const learnedSpells = Object.entries(state.spells)
.filter(([, s]) => s.learned)
.map(([id]) => id);
spellsToKeep.push(...learnedSpells.slice(0, state.skills.temporalMemory));
}
// This will be handled by the main store reset
set({
day: 1,
hour: 0,
gameOver: false,
victory: false,
loopCount: state.loopCount + 1,
insight: total,
totalInsight: (state.totalInsight || 0) + insightGained,
loopInsight: 0,
log: ['✨ A new loop begins. Your insight grows...'],
});
},
addLog: (message: string) => {
set((state) => ({
log: [message, ...state.log.slice(0, 49)],
}));
},
});

494
src/lib/game/stores.test.ts Executable file
View File

@@ -0,0 +1,494 @@
/**
* Tests for the split store architecture
*
* Tests each store in isolation and integration between stores
*/
import { describe, it, expect, beforeEach } from 'bun:test';
import {
useManaStore,
useSkillStore,
usePrestigeStore,
useCombatStore,
useUIStore,
fmt,
fmtDec,
computeMaxMana,
computeElementMax,
computeRegen,
computeClickMana,
calcDamage,
calcInsight,
getMeditationBonus,
getIncursionStrength,
canAffordSpellCost,
} from './stores';
import {
ELEMENTS,
GUARDIANS,
SPELLS_DEF,
SKILLS_DEF,
PRESTIGE_DEF,
getStudySpeedMultiplier,
getStudyCostMultiplier,
HOURS_PER_TICK,
} from './constants';
import type { GameState } from './types';
// ─── Test Fixtures ───────────────────────────────────────────────────────────
// Reset all stores before each test
beforeEach(() => {
useManaStore.setState({
rawMana: 10,
meditateTicks: 0,
totalManaGathered: 0,
elements: (() => {
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
Object.keys(ELEMENTS).forEach((k) => {
elements[k] = { current: 0, max: 10, unlocked: ['fire', 'water', 'air', 'earth'].includes(k) };
});
return elements;
})(),
});
useSkillStore.setState({
skills: {},
skillProgress: {},
skillUpgrades: {},
skillTiers: {},
paidStudySkills: {},
currentStudyTarget: null,
parallelStudyTarget: null,
});
usePrestigeStore.setState({
loopCount: 0,
insight: 0,
totalInsight: 0,
loopInsight: 0,
prestigeUpgrades: {},
memorySlots: 3,
pactSlots: 1,
memories: [],
defeatedGuardians: [],
signedPacts: [],
pactRitualFloor: null,
pactRitualProgress: 0,
});
useCombatStore.setState({
currentFloor: 1,
floorHP: 151,
floorMaxHP: 151,
maxFloorReached: 1,
activeSpell: 'manaBolt',
currentAction: 'meditate',
castProgress: 0,
spells: { manaBolt: { learned: true, level: 1, studyProgress: 0 } },
});
useUIStore.setState({
logs: [],
paused: false,
gameOver: false,
victory: false,
});
});
// ─── Mana Store Tests ─────────────────────────────────────────────────────────
describe('ManaStore', () => {
describe('initial state', () => {
it('should have correct initial values', () => {
const state = useManaStore.getState();
expect(state.rawMana).toBe(10);
expect(state.meditateTicks).toBe(0);
expect(state.totalManaGathered).toBe(0);
});
it('should have base elements unlocked', () => {
const state = useManaStore.getState();
expect(state.elements.fire.unlocked).toBe(true);
expect(state.elements.water.unlocked).toBe(true);
expect(state.elements.air.unlocked).toBe(true);
expect(state.elements.earth.unlocked).toBe(true);
expect(state.elements.light.unlocked).toBe(false);
});
});
describe('raw mana operations', () => {
it('should add raw mana', () => {
useManaStore.getState().addRawMana(50, 100);
expect(useManaStore.getState().rawMana).toBe(60);
});
it('should cap at max mana', () => {
useManaStore.getState().addRawMana(200, 100);
expect(useManaStore.getState().rawMana).toBe(100);
});
it('should spend raw mana', () => {
const result = useManaStore.getState().spendRawMana(5);
expect(result).toBe(true);
expect(useManaStore.getState().rawMana).toBe(5);
});
it('should fail to spend more than available', () => {
const result = useManaStore.getState().spendRawMana(50);
expect(result).toBe(false);
expect(useManaStore.getState().rawMana).toBe(10);
});
});
describe('element operations', () => {
it('should convert raw mana to element', () => {
useManaStore.getState().addRawMana(90, 1000); // Have 100 mana
const result = useManaStore.getState().convertMana('fire', 1);
expect(result).toBe(true);
expect(useManaStore.getState().elements.fire.current).toBe(1);
});
it('should unlock new element', () => {
useManaStore.getState().addRawMana(490, 1000); // Have 500 mana
const result = useManaStore.getState().unlockElement('light', 500);
expect(result).toBe(true);
expect(useManaStore.getState().elements.light.unlocked).toBe(true);
});
});
});
// ─── Skill Store Tests ───────────────────────────────────────────────────────
describe('SkillStore', () => {
describe('study skill', () => {
it('should start studying a skill', () => {
useManaStore.getState().addRawMana(90, 1000); // Have 100 mana
const result = useSkillStore.getState().startStudyingSkill('manaWell', 100);
expect(result.started).toBe(true);
expect(result.cost).toBe(100);
expect(useSkillStore.getState().currentStudyTarget).not.toBeNull();
expect(useSkillStore.getState().currentStudyTarget?.type).toBe('skill');
expect(useSkillStore.getState().currentStudyTarget?.id).toBe('manaWell');
});
it('should not start studying without enough mana', () => {
const result = useSkillStore.getState().startStudyingSkill('manaWell', 50);
expect(result.started).toBe(false);
expect(result.cost).toBe(100);
expect(useSkillStore.getState().currentStudyTarget).toBeNull();
});
it('should track paid study skills', () => {
useManaStore.getState().addRawMana(90, 1000);
useSkillStore.getState().startStudyingSkill('manaWell', 100);
expect(useSkillStore.getState().paidStudySkills['manaWell']).toBe(0);
});
it('should resume studying for free after payment', () => {
useManaStore.getState().addRawMana(90, 1000);
// First study attempt
const result1 = useSkillStore.getState().startStudyingSkill('manaWell', 100);
expect(result1.cost).toBe(100);
// Cancel study (simulated)
useSkillStore.getState().cancelStudy(0);
// Resume should be free
const result2 = useSkillStore.getState().startStudyingSkill('manaWell', 0);
expect(result2.started).toBe(true);
expect(result2.cost).toBe(0);
});
});
describe('update study progress', () => {
it('should update study progress', () => {
useManaStore.getState().addRawMana(90, 1000);
useSkillStore.getState().startStudyingSkill('manaWell', 100);
const result = useSkillStore.getState().updateStudyProgress(1);
expect(result.completed).toBe(false);
expect(useSkillStore.getState().currentStudyTarget?.progress).toBe(1);
});
it('should complete study when progress reaches required', () => {
useManaStore.getState().addRawMana(90, 1000);
useSkillStore.getState().startStudyingSkill('manaWell', 100);
// manaWell requires 4 hours
const result = useSkillStore.getState().updateStudyProgress(4);
expect(result.completed).toBe(true);
expect(result.target?.id).toBe('manaWell');
expect(useSkillStore.getState().currentStudyTarget).toBeNull();
});
it('should apply study speed multiplier', () => {
useManaStore.getState().addRawMana(90, 1000);
useSkillStore.getState().setSkillLevel('quickLearner', 5); // 50% faster
useSkillStore.getState().startStudyingSkill('manaWell', 100);
// The caller should calculate progress with speed multiplier
const speedMult = getStudySpeedMultiplier(useSkillStore.getState().skills);
const result = useSkillStore.getState().updateStudyProgress(3 * speedMult); // 3 * 1.5 = 4.5
expect(result.completed).toBe(true);
});
});
describe('skill level operations', () => {
it('should set skill level', () => {
useSkillStore.getState().setSkillLevel('manaWell', 5);
expect(useSkillStore.getState().skills['manaWell']).toBe(5);
});
it('should increment skill level', () => {
useSkillStore.getState().setSkillLevel('manaWell', 5);
useSkillStore.getState().incrementSkillLevel('manaWell');
expect(useSkillStore.getState().skills['manaWell']).toBe(6);
});
});
describe('prerequisites', () => {
it('should not start studying without prerequisites', () => {
useManaStore.getState().addRawMana(990, 1000);
// deepReservoir requires manaWell 5
const result = useSkillStore.getState().startStudyingSkill('deepReservoir', 1000);
expect(result.started).toBe(false);
});
it('should start studying with prerequisites met', () => {
useManaStore.getState().addRawMana(990, 1000);
useSkillStore.getState().setSkillLevel('manaWell', 5);
const result = useSkillStore.getState().startStudyingSkill('deepReservoir', 1000);
expect(result.started).toBe(true);
});
});
});
// ─── Prestige Store Tests ────────────────────────────────────────────────────
describe('PrestigeStore', () => {
describe('prestige upgrades', () => {
it('should purchase prestige upgrade', () => {
usePrestigeStore.getState().startNewLoop(1000); // Add 1000 insight
const result = usePrestigeStore.getState().doPrestige('manaWell');
expect(result).toBe(true);
expect(usePrestigeStore.getState().prestigeUpgrades['manaWell']).toBe(1);
expect(usePrestigeStore.getState().insight).toBe(500); // 1000 - 500 cost
});
it('should not purchase without enough insight', () => {
usePrestigeStore.getState().startNewLoop(100);
const result = usePrestigeStore.getState().doPrestige('manaWell');
expect(result).toBe(false);
});
});
describe('loop management', () => {
it('should increment loop count', () => {
usePrestigeStore.getState().incrementLoopCount();
expect(usePrestigeStore.getState().loopCount).toBe(1);
usePrestigeStore.getState().incrementLoopCount();
expect(usePrestigeStore.getState().loopCount).toBe(2);
});
it('should reset for new loop', () => {
usePrestigeStore.getState().startNewLoop(1000);
usePrestigeStore.getState().doPrestige('manaWell');
usePrestigeStore.getState().resetPrestigeForNewLoop(
500, // total insight
{ manaWell: 1 }, // prestige upgrades
[], // memories
3 // memory slots
);
expect(usePrestigeStore.getState().insight).toBe(500);
expect(usePrestigeStore.getState().prestigeUpgrades['manaWell']).toBe(1);
expect(usePrestigeStore.getState().defeatedGuardians).toEqual([]);
expect(usePrestigeStore.getState().signedPacts).toEqual([]);
});
});
describe('guardian pacts', () => {
it('should add signed pact', () => {
usePrestigeStore.getState().addSignedPact(10);
expect(usePrestigeStore.getState().signedPacts).toContain(10);
});
it('should add defeated guardian', () => {
usePrestigeStore.getState().addDefeatedGuardian(10);
expect(usePrestigeStore.getState().defeatedGuardians).toContain(10);
});
it('should not add same guardian twice', () => {
usePrestigeStore.getState().addDefeatedGuardian(10);
usePrestigeStore.getState().addDefeatedGuardian(10);
expect(usePrestigeStore.getState().defeatedGuardians.length).toBe(1);
});
});
});
// ─── Combat Store Tests ───────────────────────────────────────────────────────
describe('CombatStore', () => {
describe('floor operations', () => {
it('should advance floor', () => {
useCombatStore.getState().advanceFloor();
expect(useCombatStore.getState().currentFloor).toBe(2);
expect(useCombatStore.getState().maxFloorReached).toBe(2);
});
it('should cap at floor 100', () => {
useCombatStore.setState({ currentFloor: 100 });
useCombatStore.getState().advanceFloor();
expect(useCombatStore.getState().currentFloor).toBe(100);
});
});
describe('action management', () => {
it('should set action', () => {
useCombatStore.getState().setAction('climb');
expect(useCombatStore.getState().currentAction).toBe('climb');
useCombatStore.getState().setAction('study');
expect(useCombatStore.getState().currentAction).toBe('study');
});
});
describe('spell management', () => {
it('should set active spell', () => {
useCombatStore.getState().learnSpell('fireball');
useCombatStore.getState().setSpell('fireball');
expect(useCombatStore.getState().activeSpell).toBe('fireball');
});
it('should learn spell', () => {
useCombatStore.getState().learnSpell('fireball');
expect(useCombatStore.getState().spells['fireball']?.learned).toBe(true);
});
});
});
// ─── UI Store Tests ───────────────────────────────────────────────────────────
describe('UIStore', () => {
describe('log management', () => {
it('should add log message', () => {
useUIStore.getState().addLog('Test message');
expect(useUIStore.getState().logs).toContain('Test message');
});
it('should clear logs', () => {
useUIStore.getState().addLog('Test message');
useUIStore.getState().clearLogs();
expect(useUIStore.getState().logs.length).toBe(0);
});
});
describe('pause management', () => {
it('should toggle pause', () => {
expect(useUIStore.getState().paused).toBe(false);
useUIStore.getState().togglePause();
expect(useUIStore.getState().paused).toBe(true);
useUIStore.getState().togglePause();
expect(useUIStore.getState().paused).toBe(false);
});
it('should set pause state', () => {
useUIStore.getState().setPaused(true);
expect(useUIStore.getState().paused).toBe(true);
});
});
describe('game over state', () => {
it('should set game over', () => {
useUIStore.getState().setGameOver(true);
expect(useUIStore.getState().gameOver).toBe(true);
expect(useUIStore.getState().victory).toBe(false);
});
it('should set victory', () => {
useUIStore.getState().setGameOver(true, true);
expect(useUIStore.getState().gameOver).toBe(true);
expect(useUIStore.getState().victory).toBe(true);
});
});
});
// ─── Integration Tests ─────────────────────────────────────────────────────────
describe('Store Integration', () => {
describe('skill study flow', () => {
it('should complete full study flow', () => {
// Setup: give enough mana
useManaStore.getState().addRawMana(90, 1000);
// Start studying
const startResult = useSkillStore.getState().startStudyingSkill('manaWell', 100);
expect(startResult.started).toBe(true);
expect(startResult.cost).toBe(100);
// Deduct mana (simulating UI behavior)
if (startResult.cost > 0) {
useManaStore.getState().spendRawMana(startResult.cost);
}
// Set action to study
useCombatStore.getState().setAction('study');
expect(useCombatStore.getState().currentAction).toBe('study');
// Update progress until complete
const result = useSkillStore.getState().updateStudyProgress(4);
expect(result.completed).toBe(true);
// Level up skill
useSkillStore.getState().setSkillLevel('manaWell', 1);
expect(useSkillStore.getState().skills['manaWell']).toBe(1);
});
});
describe('mana and prestige interaction', () => {
it('should apply prestige mana bonus', () => {
// Get prestige upgrade
usePrestigeStore.getState().startNewLoop(1000);
usePrestigeStore.getState().doPrestige('manaWell');
// Check that prestige upgrade is recorded
expect(usePrestigeStore.getState().prestigeUpgrades['manaWell']).toBe(1);
// Mana well prestige gives +500 max mana per level
const state = {
skills: {},
prestigeUpgrades: usePrestigeStore.getState().prestigeUpgrades,
skillUpgrades: {},
skillTiers: {},
};
const maxMana = computeMaxMana(state);
expect(maxMana).toBe(100 + 500); // Base 100 + 500 from prestige
});
});
});
console.log('✅ All store tests defined. Run with: bun test src/lib/game/stores.test.ts');

View File

@@ -0,0 +1,583 @@
/**
* Store Method Tests
*
* Tests for individual store methods: skillStore, manaStore, combatStore, prestigeStore
*/
import { describe, it, expect, beforeEach } from 'bun:test';
import { useSkillStore } from '../skillStore';
import { useManaStore } from '../manaStore';
import { useCombatStore } from '../combatStore';
import { usePrestigeStore } from '../prestigeStore';
import { useUIStore } from '../uiStore';
import { SKILLS_DEF, SPELLS_DEF, GUARDIANS, BASE_UNLOCKED_ELEMENTS, ELEMENTS } from '../../constants';
// Reset stores before each test
beforeEach(() => {
// Reset all stores to initial state
useSkillStore.getState().resetSkills();
useManaStore.getState().resetMana({}, {}, {}, {});
usePrestigeStore.getState().resetPrestige();
useUIStore.getState().resetUI();
useCombatStore.getState().resetCombat(1);
});
// ─── Skill Store Tests ─────────────────────────────────────────────────────────
describe('SkillStore', () => {
describe('startStudyingSkill', () => {
it('should start studying a skill when have enough mana', () => {
const skillStore = useSkillStore.getState();
const result = skillStore.startStudyingSkill('manaWell', 100);
expect(result.started).toBe(true);
expect(result.cost).toBe(100); // base cost for level 1
const newState = useSkillStore.getState();
expect(newState.currentStudyTarget).not.toBeNull();
expect(newState.currentStudyTarget?.type).toBe('skill');
expect(newState.currentStudyTarget?.id).toBe('manaWell');
});
it('should not start studying when not enough mana', () => {
const skillStore = useSkillStore.getState();
const result = skillStore.startStudyingSkill('manaWell', 50);
expect(result.started).toBe(false);
const newState = useSkillStore.getState();
expect(newState.currentStudyTarget).toBeNull();
});
it('should not start studying skill at max level', () => {
// Set skill to max level
useSkillStore.setState({ skills: { manaWell: SKILLS_DEF.manaWell.max } });
const skillStore = useSkillStore.getState();
const result = skillStore.startStudyingSkill('manaWell', 1000);
expect(result.started).toBe(false);
});
it('should not start studying without prerequisites', () => {
const skillStore = useSkillStore.getState();
// deepReservoir requires manaWell level 5
const result = skillStore.startStudyingSkill('deepReservoir', 1000);
expect(result.started).toBe(false);
});
it('should start studying with prerequisites met', () => {
useSkillStore.setState({ skills: { manaWell: 5 } });
const skillStore = useSkillStore.getState();
const result = skillStore.startStudyingSkill('deepReservoir', 1000);
expect(result.started).toBe(true);
});
it('should be free to resume if already paid', () => {
// First, start studying (which marks as paid)
const skillStore = useSkillStore.getState();
skillStore.startStudyingSkill('manaWell', 100);
// Cancel study
skillStore.cancelStudy(0);
// Resume should be free
const newState = useSkillStore.getState();
const result = newState.startStudyingSkill('manaWell', 0);
expect(result.started).toBe(true);
expect(result.cost).toBe(0);
});
});
describe('updateStudyProgress', () => {
it('should progress study target', () => {
// Start studying
useSkillStore.getState().startStudyingSkill('manaWell', 100);
// Update progress
const result = useSkillStore.getState().updateStudyProgress(1);
expect(result.completed).toBe(false);
const state = useSkillStore.getState();
expect(state.currentStudyTarget?.progress).toBe(1);
});
it('should complete study when progress reaches required', () => {
// Start studying manaWell (4 hours study time)
useSkillStore.getState().startStudyingSkill('manaWell', 100);
// Update with enough progress
const result = useSkillStore.getState().updateStudyProgress(4);
expect(result.completed).toBe(true);
const state = useSkillStore.getState();
expect(state.currentStudyTarget).toBeNull();
});
});
describe('incrementSkillLevel', () => {
it('should increment skill level', () => {
useSkillStore.setState({ skills: { manaWell: 0 } });
useSkillStore.getState().incrementSkillLevel('manaWell');
const state = useSkillStore.getState();
expect(state.skills.manaWell).toBe(1);
});
it('should clear skill progress', () => {
useSkillStore.setState({
skills: { manaWell: 0 },
skillProgress: { manaWell: 2 }
});
useSkillStore.getState().incrementSkillLevel('manaWell');
const state = useSkillStore.getState();
expect(state.skillProgress.manaWell).toBe(0);
});
});
describe('cancelStudy', () => {
it('should clear study target', () => {
useSkillStore.getState().startStudyingSkill('manaWell', 100);
useSkillStore.getState().cancelStudy(0);
const state = useSkillStore.getState();
expect(state.currentStudyTarget).toBeNull();
});
it('should save progress with retention bonus', () => {
useSkillStore.getState().startStudyingSkill('manaWell', 100);
useSkillStore.getState().updateStudyProgress(2); // 2 hours progress
// Cancel with 50% retention bonus
// Retention bonus limits how much of the *required* time can be saved
// Required = 4 hours, so 50% = 2 hours max
// Progress = 2 hours, so we save all of it (within limit)
useSkillStore.getState().cancelStudy(0.5);
const state = useSkillStore.getState();
// Saved progress should be min(progress, required * retentionBonus) = min(2, 4*0.5) = min(2, 2) = 2
expect(state.skillProgress.manaWell).toBe(2);
});
it('should limit saved progress to retention bonus cap', () => {
useSkillStore.getState().startStudyingSkill('manaWell', 100);
useSkillStore.getState().updateStudyProgress(3); // 3 hours progress (out of 4 required)
// Cancel with 50% retention bonus
// Cap is 4 * 0.5 = 2 hours, progress is 3, so we save 2 (the cap)
useSkillStore.getState().cancelStudy(0.5);
const state = useSkillStore.getState();
expect(state.skillProgress.manaWell).toBe(2);
});
});
});
// ─── Mana Store Tests ──────────────────────────────────────────────────────────
describe('ManaStore', () => {
describe('initial state', () => {
it('should have base elements unlocked', () => {
const state = useManaStore.getState();
expect(state.elements.fire.unlocked).toBe(true);
expect(state.elements.water.unlocked).toBe(true);
expect(state.elements.air.unlocked).toBe(true);
expect(state.elements.earth.unlocked).toBe(true);
});
it('should have exotic elements locked', () => {
const state = useManaStore.getState();
expect(state.elements.void.unlocked).toBe(false);
expect(state.elements.stellar.unlocked).toBe(false);
});
});
describe('convertMana', () => {
it('should convert raw mana to elemental', () => {
useManaStore.setState({ rawMana: 200 });
const result = useManaStore.getState().convertMana('fire', 1);
expect(result).toBe(true);
const state = useManaStore.getState();
expect(state.rawMana).toBe(100);
expect(state.elements.fire.current).toBe(1);
});
it('should not convert when not enough raw mana', () => {
useManaStore.setState({ rawMana: 50 });
const result = useManaStore.getState().convertMana('fire', 1);
expect(result).toBe(false);
});
it('should not convert when element at max', () => {
useManaStore.setState({
rawMana: 500,
elements: {
...useManaStore.getState().elements,
fire: { current: 10, max: 10, unlocked: true }
}
});
const result = useManaStore.getState().convertMana('fire', 1);
expect(result).toBe(false);
});
it('should not convert to locked element', () => {
useManaStore.setState({ rawMana: 500 });
const result = useManaStore.getState().convertMana('void', 1);
expect(result).toBe(false);
});
});
describe('unlockElement', () => {
it('should unlock element when have enough mana', () => {
useManaStore.setState({ rawMana: 500 });
const result = useManaStore.getState().unlockElement('light', 500);
expect(result).toBe(true);
const state = useManaStore.getState();
expect(state.elements.light.unlocked).toBe(true);
});
it('should not unlock when not enough mana', () => {
useManaStore.setState({ rawMana: 100 });
const result = useManaStore.getState().unlockElement('light', 500);
expect(result).toBe(false);
});
});
describe('craftComposite', () => {
it('should craft composite element with correct ingredients', () => {
// Set up ingredients for blood (life + water)
useManaStore.setState({
elements: {
...useManaStore.getState().elements,
life: { current: 5, max: 10, unlocked: true },
water: { current: 5, max: 10, unlocked: true },
}
});
const result = useManaStore.getState().craftComposite('blood', ['life', 'water']);
expect(result).toBe(true);
const state = useManaStore.getState();
expect(state.elements.life.current).toBe(4);
expect(state.elements.water.current).toBe(4);
expect(state.elements.blood.current).toBe(1);
expect(state.elements.blood.unlocked).toBe(true);
});
it('should not craft without ingredients', () => {
useManaStore.setState({
elements: {
...useManaStore.getState().elements,
life: { current: 0, max: 10, unlocked: true },
water: { current: 0, max: 10, unlocked: true },
}
});
const result = useManaStore.getState().craftComposite('blood', ['life', 'water']);
expect(result).toBe(false);
});
});
});
// ─── Combat Store Tests ────────────────────────────────────────────────────────
describe('CombatStore', () => {
describe('initial state', () => {
it('should start with manaBolt learned', () => {
const state = useCombatStore.getState();
expect(state.spells.manaBolt.learned).toBe(true);
});
it('should start at floor 1', () => {
const state = useCombatStore.getState();
expect(state.currentFloor).toBe(1);
});
});
describe('setAction', () => {
it('should change current action', () => {
useCombatStore.getState().setAction('climb');
const state = useCombatStore.getState();
expect(state.currentAction).toBe('climb');
});
});
describe('setSpell', () => {
it('should change active spell if learned', () => {
// Learn another spell
useCombatStore.getState().learnSpell('fireball');
useCombatStore.getState().setSpell('fireball');
const state = useCombatStore.getState();
expect(state.activeSpell).toBe('fireball');
});
it('should not change to unlearned spell', () => {
useCombatStore.getState().setSpell('fireball');
const state = useCombatStore.getState();
expect(state.activeSpell).toBe('manaBolt'); // Still manaBolt
});
});
describe('learnSpell', () => {
it('should add spell to learned spells', () => {
useCombatStore.getState().learnSpell('fireball');
const state = useCombatStore.getState();
expect(state.spells.fireball.learned).toBe(true);
});
});
describe('advanceFloor', () => {
it('should increment floor', () => {
useCombatStore.getState().advanceFloor();
const state = useCombatStore.getState();
expect(state.currentFloor).toBe(2);
});
it('should not exceed floor 100', () => {
useCombatStore.setState({ currentFloor: 100 });
useCombatStore.getState().advanceFloor();
const state = useCombatStore.getState();
expect(state.currentFloor).toBe(100);
});
it('should update maxFloorReached', () => {
useCombatStore.setState({ maxFloorReached: 1 });
useCombatStore.getState().advanceFloor();
const state = useCombatStore.getState();
expect(state.maxFloorReached).toBe(2);
});
});
});
// ─── Prestige Store Tests ──────────────────────────────────────────────────────
describe('PrestigeStore', () => {
describe('initial state', () => {
it('should start with 0 insight', () => {
const state = usePrestigeStore.getState();
expect(state.insight).toBe(0);
});
it('should start with 3 memory slots', () => {
const state = usePrestigeStore.getState();
expect(state.memorySlots).toBe(3);
});
it('should start with 1 pact slot', () => {
const state = usePrestigeStore.getState();
expect(state.pactSlots).toBe(1);
});
});
describe('doPrestige', () => {
it('should deduct insight and add upgrade', () => {
usePrestigeStore.setState({ insight: 1000 });
usePrestigeStore.getState().doPrestige('manaWell');
const state = usePrestigeStore.getState();
expect(state.prestigeUpgrades.manaWell).toBe(1);
expect(state.insight).toBeLessThan(1000);
});
it('should not upgrade without enough insight', () => {
usePrestigeStore.setState({ insight: 100 });
usePrestigeStore.getState().doPrestige('manaWell');
const state = usePrestigeStore.getState();
expect(state.prestigeUpgrades.manaWell).toBeUndefined();
});
});
describe('addMemory', () => {
it('should add memory within slot limit', () => {
const memory = { skillId: 'manaWell', level: 5, tier: 1, upgrades: [] };
usePrestigeStore.getState().addMemory(memory);
const state = usePrestigeStore.getState();
expect(state.memories.length).toBe(1);
});
it('should not add duplicate memory', () => {
const memory = { skillId: 'manaWell', level: 5, tier: 1, upgrades: [] };
usePrestigeStore.getState().addMemory(memory);
usePrestigeStore.getState().addMemory(memory);
const state = usePrestigeStore.getState();
expect(state.memories.length).toBe(1);
});
it('should not exceed memory slots', () => {
// Fill memory slots
for (let i = 0; i < 5; i++) {
usePrestigeStore.getState().addMemory({
skillId: `skill${i}`,
level: 5,
tier: 1,
upgrades: []
});
}
const state = usePrestigeStore.getState();
expect(state.memories.length).toBe(3); // Default 3 slots
});
});
describe('startPactRitual', () => {
it('should start ritual for defeated guardian', () => {
usePrestigeStore.setState({
defeatedGuardians: [10],
signedPacts: []
});
useManaStore.setState({ rawMana: 1000 });
const result = usePrestigeStore.getState().startPactRitual(10, 1000);
expect(result).toBe(true);
const state = usePrestigeStore.getState();
expect(state.pactRitualFloor).toBe(10);
});
it('should not start ritual for undefeated guardian', () => {
usePrestigeStore.setState({
defeatedGuardians: [],
signedPacts: []
});
useManaStore.setState({ rawMana: 1000 });
const result = usePrestigeStore.getState().startPactRitual(10, 1000);
expect(result).toBe(false);
});
it('should not start ritual without enough mana', () => {
usePrestigeStore.setState({
defeatedGuardians: [10],
signedPacts: []
});
useManaStore.setState({ rawMana: 100 });
const result = usePrestigeStore.getState().startPactRitual(10, 100);
expect(result).toBe(false);
});
});
describe('addSignedPact', () => {
it('should add pact to signed list', () => {
usePrestigeStore.getState().addSignedPact(10);
const state = usePrestigeStore.getState();
expect(state.signedPacts).toContain(10);
});
});
describe('addDefeatedGuardian', () => {
it('should add guardian to defeated list', () => {
usePrestigeStore.getState().addDefeatedGuardian(10);
const state = usePrestigeStore.getState();
expect(state.defeatedGuardians).toContain(10);
});
});
});
// ─── UI Store Tests ────────────────────────────────────────────────────────────
describe('UIStore', () => {
describe('addLog', () => {
it('should add message to logs', () => {
useUIStore.getState().addLog('Test message');
const state = useUIStore.getState();
expect(state.logs[0]).toBe('Test message');
});
it('should limit log size', () => {
for (let i = 0; i < 100; i++) {
useUIStore.getState().addLog(`Message ${i}`);
}
const state = useUIStore.getState();
expect(state.logs.length).toBeLessThanOrEqual(50);
});
});
describe('togglePause', () => {
it('should toggle pause state', () => {
const initial = useUIStore.getState().paused;
useUIStore.getState().togglePause();
expect(useUIStore.getState().paused).toBe(!initial);
useUIStore.getState().togglePause();
expect(useUIStore.getState().paused).toBe(initial);
});
});
describe('setGameOver', () => {
it('should set game over state', () => {
useUIStore.getState().setGameOver(true, false);
const state = useUIStore.getState();
expect(state.gameOver).toBe(true);
expect(state.victory).toBe(false);
});
it('should set victory state', () => {
useUIStore.getState().setGameOver(true, true);
const state = useUIStore.getState();
expect(state.gameOver).toBe(true);
expect(state.victory).toBe(true);
});
});
});
console.log('✅ All store method tests defined.');

View File

@@ -0,0 +1,468 @@
/**
* Comprehensive Store Tests
*
* Tests for the split store architecture after refactoring.
* Each store is tested individually and for cross-store communication.
*/
import { describe, it, expect, beforeEach } from 'bun:test';
import {
fmt,
fmtDec,
getFloorMaxHP,
getFloorElement,
computeMaxMana,
computeElementMax,
computeRegen,
computeClickMana,
calcDamage,
calcInsight,
getMeditationBonus,
getIncursionStrength,
canAffordSpellCost,
deductSpellCost,
getBoonBonuses,
} from '../../utils';
import {
ELEMENTS,
GUARDIANS,
SPELLS_DEF,
SKILLS_DEF,
PRESTIGE_DEF,
MAX_DAY,
INCURSION_START_DAY,
getStudySpeedMultiplier,
getStudyCostMultiplier,
rawCost,
elemCost,
BASE_UNLOCKED_ELEMENTS,
} from '../../constants';
import type { GameState } 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: BASE_UNLOCKED_ELEMENTS.includes(k) };
});
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: {},
skillUpgrades: {},
skillTiers: {},
paidStudySkills: {},
equipment: { mainHand: null, offHand: null, head: null, body: null, hands: null, accessory: null },
inventory: [],
blueprints: {},
schedule: [],
autoSchedule: false,
studyQueue: [],
craftQueue: [],
currentStudyTarget: null,
parallelStudyTarget: null,
insight: 0,
totalInsight: 0,
prestigeUpgrades: {},
memorySlots: 3,
pactSlots: 1,
memories: [],
incursionStrength: 0,
containmentWards: 0,
defeatedGuardians: [],
pactRitualFloor: null,
pactRitualProgress: 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');
});
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');
});
});
describe('fmtDec (format decimal)', () => {
it('should format numbers with specified decimal places', () => {
expect(fmtDec(1.234, 2)).toBe('1.23');
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));
});
});
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');
});
it('should wrap around after 8 floors', () => {
expect(getFloorElement(9)).toBe('fire');
expect(getFloorElement(10)).toBe('water');
});
});
});
// ─── 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);
});
});
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);
});
});
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);
});
});
});
// ─── 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);
});
it('should add damage from combatTrain skill', () => {
const state = createMockState({ skills: { combatTrain: 5 } });
const dmg = calcDamage(state, 'manaBolt');
expect(dmg).toBeGreaterThanOrEqual(5 + 5 * 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 signed pacts', () => {
const state = createMockState({ signedPacts: [10, 20] });
const insight = calcInsight(state);
expect(insight).toBe(315); // 1*15 + 0 + 2*150 = 15 + 300 = 315
});
});
});
// ─── Meditation Tests ─────────────────────────────────────────────────────────
describe('Meditation Bonus', () => {
describe('getMeditationBonus', () => {
it('should start at 1x with no meditation', () => {
expect(getMeditationBonus(0, {})).toBe(1);
});
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);
});
it('should start at incursion start day', () => {
expect(getIncursionStrength(INCURSION_START_DAY, 1)).toBeGreaterThan(0);
});
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);
});
});
describe('elemCost', () => {
it('should create an elemental mana cost', () => {
const cost = elemCost('fire', 5);
expect(cost.type).toBe('element');
expect(cost.element).toBe('fire');
});
});
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);
});
});
describe('deductSpellCost', () => {
it('should deduct raw mana', () => {
const cost = rawCost(10);
const elements = { fire: { current: 0, max: 10, unlocked: true } };
const result = deductSpellCost(cost, 100, elements);
expect(result.rawMana).toBe(90);
});
});
});
// ─── Boon Bonus Tests ─────────────────────────────────────────────────────────
describe('Boon Bonuses', () => {
describe('getBoonBonuses', () => {
it('should return zeros with no pacts', () => {
const bonuses = getBoonBonuses([]);
expect(bonuses.maxMana).toBe(0);
expect(bonuses.manaRegen).toBe(0);
});
it('should accumulate bonuses from multiple pacts', () => {
const bonuses = getBoonBonuses([10, 20]);
expect(bonuses.maxMana).toBe(100); // From floor 10
expect(bonuses.manaRegen).toBe(2); // From floor 20
});
});
});
// ─── 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);
});
});
});
// ─── Guardian Tests ───────────────────────────────────────────────────────────
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;
});
});
});
// ─── Skill Definition Tests ───────────────────────────────────────────────────
describe('Skill Definitions', () => {
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);
});
});
});
// ─── Prestige Upgrade Tests ───────────────────────────────────────────────────
describe('Prestige Upgrades', () => {
it('should have prestige upgrades with valid costs', () => {
Object.values(PRESTIGE_DEF).forEach(def => {
expect(def.cost).toBeGreaterThan(0);
expect(def.max).toBeGreaterThan(0);
});
});
});
// ─── Spell Definition Tests ───────────────────────────────────────────────────
describe('Spell Definitions', () => {
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 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);
expect(tier1Avg).toBeGreaterThan(tier0Avg);
});
});
console.log('✅ All store tests defined.');

View File

@@ -0,0 +1,275 @@
// ─── Combat Store ─────────────────────────────────────────────────────────────
// Handles floors, spells, guardians, combat, and casting
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { SPELLS_DEF, GUARDIANS, HOURS_PER_TICK } from '../constants';
import type { GameAction, SpellState } from '../types';
import { getFloorMaxHP, getFloorElement, calcDamage } from '../utils';
import { usePrestigeStore } from './prestigeStore';
export interface CombatState {
// Floor state
currentFloor: number;
floorHP: number;
floorMaxHP: number;
maxFloorReached: number;
// Action state
activeSpell: string;
currentAction: GameAction;
castProgress: number;
// Spells
spells: Record<string, SpellState>;
// Actions
setCurrentFloor: (floor: number) => void;
advanceFloor: () => void;
setFloorHP: (hp: number) => void;
setMaxFloorReached: (floor: number) => void;
setAction: (action: GameAction) => void;
setSpell: (spellId: string) => void;
setCastProgress: (progress: number) => void;
// Spells
learnSpell: (spellId: string) => void;
setSpellState: (spellId: string, state: Partial<SpellState>) => void;
// Combat tick
processCombatTick: (
skills: Record<string, number>,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
maxMana: number,
attackSpeedMult: number,
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }>; logMessages: string[] };
// Reset
resetCombat: (startFloor: number, spellsToKeep?: string[]) => void;
}
export const useCombatStore = create<CombatState>()(
persist(
(set, get) => ({
currentFloor: 1,
floorHP: getFloorMaxHP(1),
floorMaxHP: getFloorMaxHP(1),
maxFloorReached: 1,
activeSpell: 'manaBolt',
currentAction: 'meditate',
castProgress: 0,
spells: {
manaBolt: { learned: true, level: 1, studyProgress: 0 },
},
setCurrentFloor: (floor: number) => {
set({
currentFloor: floor,
floorHP: getFloorMaxHP(floor),
floorMaxHP: getFloorMaxHP(floor),
});
},
advanceFloor: () => {
set((state) => {
const newFloor = Math.min(state.currentFloor + 1, 100);
return {
currentFloor: newFloor,
floorHP: getFloorMaxHP(newFloor),
floorMaxHP: getFloorMaxHP(newFloor),
maxFloorReached: Math.max(state.maxFloorReached, newFloor),
castProgress: 0,
};
});
},
setFloorHP: (hp: number) => {
set({ floorHP: Math.max(0, hp) });
},
setMaxFloorReached: (floor: number) => {
set((state) => ({
maxFloorReached: Math.max(state.maxFloorReached, floor),
}));
},
setAction: (action: GameAction) => {
set({ currentAction: action });
},
setSpell: (spellId: string) => {
const state = get();
if (state.spells[spellId]?.learned) {
set({ activeSpell: spellId });
}
},
setCastProgress: (progress: number) => {
set({ castProgress: progress });
},
learnSpell: (spellId: string) => {
set((state) => ({
spells: {
...state.spells,
[spellId]: { learned: true, level: 1, studyProgress: 0 },
},
}));
},
setSpellState: (spellId: string, spellState: Partial<SpellState>) => {
set((state) => ({
spells: {
...state.spells,
[spellId]: { ...(state.spells[spellId] || { learned: false, level: 0, studyProgress: 0 }), ...spellState },
},
}));
},
processCombatTick: (
skills: Record<string, number>,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
maxMana: number,
attackSpeedMult: number,
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
) => {
const state = get();
const logMessages: string[] = [];
if (state.currentAction !== 'climb') {
return { rawMana, elements, logMessages };
}
const spellId = state.activeSpell;
const spellDef = SPELLS_DEF[spellId];
if (!spellDef) {
return { rawMana, elements, logMessages };
}
// Calculate cast speed
const baseAttackSpeed = 1 + (skills.quickCast || 0) * 0.05;
const totalAttackSpeed = baseAttackSpeed * attackSpeedMult;
const spellCastSpeed = spellDef.castSpeed || 1;
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
let castProgress = (state.castProgress || 0) + progressPerTick;
let floorHP = state.floorHP;
let currentFloor = state.currentFloor;
let floorMaxHP = state.floorMaxHP;
// Process complete casts
while (castProgress >= 1) {
// Check if we can afford the spell
const cost = spellDef.cost;
let canCast = false;
if (cost.type === 'raw') {
canCast = rawMana >= cost.amount;
if (canCast) rawMana -= cost.amount;
} else if (cost.element) {
const elem = elements[cost.element];
canCast = elem && elem.unlocked && elem.current >= cost.amount;
if (canCast) {
elements = {
...elements,
[cost.element]: { ...elem, current: elem.current - cost.amount },
};
}
}
if (!canCast) break;
// Calculate damage
const floorElement = getFloorElement(currentFloor);
const damage = calcDamage(
{ skills, signedPacts: usePrestigeStore.getState().signedPacts },
spellId,
floorElement
);
// Apply damage
floorHP = Math.max(0, floorHP - damage);
castProgress -= 1;
// Handle lifesteal
const lifestealEffect = spellDef.effects?.find(e => e.type === 'lifesteal');
if (lifestealEffect) {
const healAmount = damage * lifestealEffect.value;
rawMana = Math.min(rawMana + healAmount, maxMana);
}
// Check if floor is cleared
if (floorHP <= 0) {
const wasGuardian = GUARDIANS[currentFloor];
onFloorCleared(currentFloor, !!wasGuardian);
currentFloor = Math.min(currentFloor + 1, 100);
floorMaxHP = getFloorMaxHP(currentFloor);
floorHP = floorMaxHP;
castProgress = 0;
if (wasGuardian) {
logMessages.push(`⚔️ ${wasGuardian.name} defeated!`);
} else if (currentFloor % 5 === 0) {
logMessages.push(`🏰 Floor ${currentFloor - 1} cleared!`);
}
}
}
set({
currentFloor,
floorHP,
floorMaxHP,
maxFloorReached: Math.max(state.maxFloorReached, currentFloor),
castProgress,
});
return { rawMana, elements, logMessages };
},
resetCombat: (startFloor: number, spellsToKeep: string[] = []) => {
const startSpells = makeInitialSpells(spellsToKeep);
set({
currentFloor: startFloor,
floorHP: getFloorMaxHP(startFloor),
floorMaxHP: getFloorMaxHP(startFloor),
maxFloorReached: startFloor,
activeSpell: 'manaBolt',
currentAction: 'meditate',
castProgress: 0,
spells: startSpells,
});
},
}),
{
name: 'mana-loop-combat',
partialize: (state) => ({
currentFloor: state.currentFloor,
maxFloorReached: state.maxFloorReached,
spells: state.spells,
activeSpell: state.activeSpell,
}),
}
)
);
// Helper function to create initial spells
export function makeInitialSpells(spellsToKeep: string[] = []): Record<string, SpellState> {
const startSpells: Record<string, SpellState> = {
manaBolt: { learned: true, level: 1, studyProgress: 0 },
};
// Add kept spells
for (const spellId of spellsToKeep) {
startSpells[spellId] = { learned: true, level: 1, studyProgress: 0 };
}
return startSpells;
}

525
src/lib/game/stores/gameStore.ts Executable file
View File

@@ -0,0 +1,525 @@
// ─── Game Store (Coordinator) ─────────────────────────────────────────────────
// Manages: day, hour, incursionStrength, containmentWards
// Coordinates tick function across all stores
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { TICK_MS, HOURS_PER_TICK, MAX_DAY, SPELLS_DEF, GUARDIANS, ELEMENTS, BASE_UNLOCKED_ELEMENTS, getStudySpeedMultiplier } from '../constants';
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '../upgrade-effects';
import {
computeMaxMana,
computeRegen,
getFloorElement,
getFloorMaxHP,
getMeditationBonus,
getIncursionStrength,
calcInsight,
calcDamage,
deductSpellCost,
} from '../utils';
import { useUIStore } from './uiStore';
import { usePrestigeStore } from './prestigeStore';
import { useManaStore } from './manaStore';
import { useSkillStore } from './skillStore';
import { useCombatStore, makeInitialSpells } from './combatStore';
import type { Memory } from '../types';
export interface GameCoordinatorState {
day: number;
hour: number;
incursionStrength: number;
containmentWards: number;
initialized: boolean;
}
export interface GameCoordinatorStore extends GameCoordinatorState {
tick: () => void;
resetGame: () => void;
togglePause: () => void;
startNewLoop: () => void;
gatherMana: () => void;
initGame: () => void;
}
const initialState: GameCoordinatorState = {
day: 1,
hour: 0,
incursionStrength: 0,
containmentWards: 0,
initialized: false,
};
// Helper function for checking spell cost affordability
function canAffordSpell(
cost: { type: string; element?: string; amount: number },
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>
): boolean {
if (cost.type === 'raw') {
return rawMana >= cost.amount;
} else if (cost.element) {
const elem = elements[cost.element];
return elem && elem.unlocked && elem.current >= cost.amount;
}
return false;
}
export const useGameStore = create<GameCoordinatorStore>()(
persist(
(set, get) => ({
...initialState,
initGame: () => {
set({ initialized: true });
},
tick: () => {
const uiState = useUIStore.getState();
if (uiState.gameOver || uiState.paused) return;
// Helper for logging
const addLog = (msg: string) => useUIStore.getState().addLog(msg);
// Get all store states
const prestigeState = usePrestigeStore.getState();
const manaState = useManaStore.getState();
const skillState = useSkillStore.getState();
const combatState = useCombatStore.getState();
// Compute effects from upgrades
const effects = computeEffects(skillState.skillUpgrades || {}, skillState.skillTiers || {});
const maxMana = computeMaxMana(
{ skills: skillState.skills, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: skillState.skillUpgrades, skillTiers: skillState.skillTiers },
effects
);
const baseRegen = computeRegen(
{ skills: skillState.skills, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: skillState.skillUpgrades, skillTiers: skillState.skillTiers },
effects
);
// Time progression
let hour = get().hour + HOURS_PER_TICK;
let day = get().day;
if (hour >= 24) {
hour -= 24;
day += 1;
}
// Check for loop end
if (day > MAX_DAY) {
const insightGained = calcInsight({
maxFloorReached: combatState.maxFloorReached,
totalManaGathered: manaState.totalManaGathered,
signedPacts: prestigeState.signedPacts,
prestigeUpgrades: prestigeState.prestigeUpgrades,
skills: skillState.skills,
});
addLog(`⏰ The loop ends. Gained ${insightGained} Insight.`);
useUIStore.getState().setGameOver(true, false);
usePrestigeStore.getState().setLoopInsight(insightGained);
set({ day, hour });
return;
}
// Check for victory
if (combatState.maxFloorReached >= 100 && prestigeState.signedPacts.includes(100)) {
const insightGained = calcInsight({
maxFloorReached: combatState.maxFloorReached,
totalManaGathered: manaState.totalManaGathered,
signedPacts: prestigeState.signedPacts,
prestigeUpgrades: prestigeState.prestigeUpgrades,
skills: skillState.skills,
}) * 3;
addLog(`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`);
useUIStore.getState().setGameOver(true, true);
usePrestigeStore.getState().setLoopInsight(insightGained);
return;
}
// Incursion
const incursionStrength = getIncursionStrength(day, hour);
// Meditation bonus tracking and regen calculation
let meditateTicks = manaState.meditateTicks;
let meditationMultiplier = 1;
if (combatState.currentAction === 'meditate') {
meditateTicks++;
meditationMultiplier = getMeditationBonus(meditateTicks, skillState.skills, effects.meditationEfficiency);
} else {
meditateTicks = 0;
}
// Calculate effective regen with incursion and meditation
const effectiveRegen = baseRegen * (1 - incursionStrength) * meditationMultiplier;
// Mana regeneration
let rawMana = Math.min(manaState.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
let totalManaGathered = manaState.totalManaGathered;
let elements = { ...manaState.elements };
// Study progress - handled by skillStore
if (combatState.currentAction === 'study' && skillState.currentStudyTarget) {
const studySpeedMult = getStudySpeedMultiplier(skillState.skills);
const progressGain = HOURS_PER_TICK * studySpeedMult;
const result = useSkillStore.getState().updateStudyProgress(progressGain);
if (result.completed && result.target) {
if (result.target.type === 'skill') {
const skillId = result.target.id;
const currentLevel = skillState.skills[skillId] || 0;
// Update skill level
useSkillStore.getState().incrementSkillLevel(skillId);
useSkillStore.getState().clearPaidStudySkill(skillId);
useCombatStore.getState().setAction('meditate');
addLog(`${skillId} Lv.${currentLevel + 1} mastered!`);
} else if (result.target.type === 'spell') {
const spellId = result.target.id;
useCombatStore.getState().learnSpell(spellId);
useSkillStore.getState().setCurrentStudyTarget(null);
useCombatStore.getState().setAction('meditate');
addLog(`📖 ${SPELLS_DEF[spellId]?.name || spellId} learned!`);
}
}
}
// Convert action - auto convert mana
if (combatState.currentAction === 'convert') {
const unlockedElements = Object.entries(elements)
.filter(([, e]) => e.unlocked && e.current < e.max);
if (unlockedElements.length > 0 && rawMana >= 100) {
unlockedElements.sort((a, b) => (b[1].max - b[1].current) - (a[1].max - a[1].current));
const [targetId, targetState] = unlockedElements[0];
const canConvert = Math.min(
Math.floor(rawMana / 100),
targetState.max - targetState.current
);
if (canConvert > 0) {
rawMana -= canConvert * 100;
elements = {
...elements,
[targetId]: { ...targetState, current: targetState.current + canConvert }
};
}
}
}
// Pact ritual progress
if (prestigeState.pactRitualFloor !== null) {
const guardian = GUARDIANS[prestigeState.pactRitualFloor];
if (guardian) {
const pactAffinityBonus = 1 - (prestigeState.prestigeUpgrades.pactAffinity || 0) * 0.1;
const requiredTime = guardian.pactTime * pactAffinityBonus;
const newProgress = prestigeState.pactRitualProgress + HOURS_PER_TICK;
if (newProgress >= requiredTime) {
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
usePrestigeStore.getState().addSignedPact(prestigeState.pactRitualFloor);
usePrestigeStore.getState().removeDefeatedGuardian(prestigeState.pactRitualFloor);
usePrestigeStore.getState().setPactRitualFloor(null);
} else {
usePrestigeStore.getState().updatePactRitualProgress(newProgress);
}
}
}
// Combat
let { currentFloor, floorHP, floorMaxHP, maxFloorReached, castProgress } = combatState;
const floorElement = getFloorElement(currentFloor);
if (combatState.currentAction === 'climb') {
const spellId = combatState.activeSpell;
const spellDef = SPELLS_DEF[spellId];
if (spellDef) {
const baseAttackSpeed = 1 + (skillState.skills.quickCast || 0) * 0.05;
const totalAttackSpeed = baseAttackSpeed * effects.attackSpeedMultiplier;
const spellCastSpeed = spellDef.castSpeed || 1;
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
castProgress = (castProgress || 0) + progressPerTick;
// Process complete casts
while (castProgress >= 1 && canAffordSpell(spellDef.cost, rawMana, elements)) {
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
rawMana = afterCost.rawMana;
elements = afterCost.elements;
totalManaGathered += spellDef.cost.amount;
// Calculate damage
let dmg = calcDamage(
{ skills: skillState.skills, signedPacts: prestigeState.signedPacts },
spellId,
floorElement
);
// Apply upgrade damage multipliers and bonuses
dmg = dmg * effects.baseDamageMultiplier + effects.baseDamageBonus;
// Executioner: +100% damage to enemies below 25% HP
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && floorHP / floorMaxHP < 0.25) {
dmg *= 2;
}
// Berserker: +50% damage when below 50% mana
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
dmg *= 1.5;
}
// Spell echo - chance to cast again
const echoChance = (skillState.skills.spellEcho || 0) * 0.1;
if (Math.random() < echoChance) {
dmg *= 2;
addLog(`✨ Spell Echo! Double damage!`);
}
// Lifesteal effect
const lifestealEffect = spellDef.effects?.find(e => e.type === 'lifesteal');
if (lifestealEffect) {
const healAmount = dmg * lifestealEffect.value;
rawMana = Math.min(rawMana + healAmount, maxMana);
}
// Apply damage
floorHP = Math.max(0, floorHP - dmg);
castProgress -= 1;
if (floorHP <= 0) {
// Floor cleared
const wasGuardian = GUARDIANS[currentFloor];
if (wasGuardian && !prestigeState.defeatedGuardians.includes(currentFloor) && !prestigeState.signedPacts.includes(currentFloor)) {
usePrestigeStore.getState().addDefeatedGuardian(currentFloor);
addLog(`⚔️ ${wasGuardian.name} defeated! Visit the Grimoire to sign a pact.`);
} else if (!wasGuardian) {
if (currentFloor % 5 === 0) {
addLog(`🏰 Floor ${currentFloor} cleared!`);
}
}
currentFloor = currentFloor + 1;
if (currentFloor > 100) {
currentFloor = 100;
}
floorMaxHP = getFloorMaxHP(currentFloor);
floorHP = floorMaxHP;
maxFloorReached = Math.max(maxFloorReached, currentFloor);
castProgress = 0;
useCombatStore.getState().advanceFloor();
}
}
}
}
// Update all stores with new state
useManaStore.setState({
rawMana,
meditateTicks,
totalManaGathered,
elements,
});
useCombatStore.setState({
floorHP,
floorMaxHP,
maxFloorReached,
castProgress,
});
set({
day,
hour,
incursionStrength,
});
},
gatherMana: () => {
const skillState = useSkillStore.getState();
const manaState = useManaStore.getState();
const prestigeState = usePrestigeStore.getState();
// Compute click mana
let cm = 1 +
(skillState.skills.manaTap || 0) * 1 +
(skillState.skills.manaSurge || 0) * 3;
// Mana overflow bonus
const overflowBonus = 1 + (skillState.skills.manaOverflow || 0) * 0.25;
cm = Math.floor(cm * overflowBonus);
const effects = computeEffects(skillState.skillUpgrades || {}, skillState.skillTiers || {});
const max = computeMaxMana(
{ skills: skillState.skills, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: skillState.skillUpgrades, skillTiers: skillState.skillTiers },
effects
);
useManaStore.setState({
rawMana: Math.min(manaState.rawMana + cm, max),
totalManaGathered: manaState.totalManaGathered + cm,
});
},
resetGame: () => {
// Clear all persisted state
localStorage.removeItem('mana-loop-ui-storage');
localStorage.removeItem('mana-loop-prestige-storage');
localStorage.removeItem('mana-loop-mana-storage');
localStorage.removeItem('mana-loop-skill-storage');
localStorage.removeItem('mana-loop-combat-storage');
localStorage.removeItem('mana-loop-game-storage');
const startFloor = 1;
const elemMax = 10;
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
Object.keys(ELEMENTS).forEach((k) => {
elements[k] = {
current: 0,
max: elemMax,
unlocked: BASE_UNLOCKED_ELEMENTS.includes(k),
};
});
useUIStore.getState().resetUI();
usePrestigeStore.getState().resetPrestige();
useManaStore.getState().resetMana({}, {}, {}, {});
useSkillStore.getState().resetSkills();
useCombatStore.getState().resetCombat(startFloor);
set({
...initialState,
initialized: true,
});
},
togglePause: () => {
useUIStore.getState().togglePause();
},
startNewLoop: () => {
const prestigeState = usePrestigeStore.getState();
const combatState = useCombatStore.getState();
const manaState = useManaStore.getState();
const skillState = useSkillStore.getState();
const insightGained = prestigeState.loopInsight || calcInsight({
maxFloorReached: combatState.maxFloorReached,
totalManaGathered: manaState.totalManaGathered,
signedPacts: prestigeState.signedPacts,
prestigeUpgrades: prestigeState.prestigeUpgrades,
skills: skillState.skills,
});
const total = prestigeState.insight + insightGained;
// Keep some spells through temporal memory
let spellsToKeep: string[] = [];
if (skillState.skills.temporalMemory) {
const learnedSpells = Object.entries(combatState.spells)
.filter(([, s]) => s.learned)
.map(([id]) => id);
spellsToKeep = learnedSpells.slice(0, skillState.skills.temporalMemory);
}
const pu = prestigeState.prestigeUpgrades;
const startFloor = 1 + (pu.spireKey || 0) * 2;
// Apply saved memories - restore skill levels, tiers, and upgrades
const memories = prestigeState.memories || [];
const newSkills: Record<string, number> = {};
const newSkillTiers: Record<string, number> = {};
const newSkillUpgrades: Record<string, string[]> = {};
if (memories.length > 0) {
for (const memory of memories) {
const tieredSkillId = memory.tier > 1 ? `${memory.skillId}_t${memory.tier}` : memory.skillId;
newSkills[tieredSkillId] = memory.level;
if (memory.tier > 1) {
newSkillTiers[memory.skillId] = memory.tier;
}
newSkillUpgrades[tieredSkillId] = memory.upgrades || [];
}
}
// Reset and update all stores for new loop
useUIStore.setState({
gameOver: false,
victory: false,
paused: false,
logs: ['✨ The loop begins. You start with Mana Bolt. Gather your strength, mage.'],
});
usePrestigeStore.getState().resetPrestigeForNewLoop(
total,
pu,
prestigeState.memories,
3 + (pu.deepMemory || 0)
);
usePrestigeStore.getState().incrementLoopCount();
useManaStore.getState().resetMana(pu, newSkills, newSkillUpgrades, newSkillTiers);
useSkillStore.getState().resetSkills(newSkills, newSkillUpgrades, newSkillTiers);
// Reset combat with starting floor and any kept spells
const startSpells = makeInitialSpells();
if (pu.spellMemory) {
const availableSpells = Object.keys(SPELLS_DEF).filter(s => s !== 'manaBolt');
const shuffled = availableSpells.sort(() => Math.random() - 0.5);
for (let i = 0; i < Math.min(pu.spellMemory, shuffled.length); i++) {
startSpells[shuffled[i]] = { learned: true, level: 1, studyProgress: 0 };
}
}
spellsToKeep.forEach(spellId => {
startSpells[spellId] = { learned: true, level: 1, studyProgress: 0 };
});
useCombatStore.setState({
currentFloor: startFloor,
floorHP: getFloorMaxHP(startFloor),
floorMaxHP: getFloorMaxHP(startFloor),
maxFloorReached: startFloor,
activeSpell: 'manaBolt',
currentAction: 'meditate',
castProgress: 0,
spells: startSpells,
});
set({
day: 1,
hour: 0,
incursionStrength: 0,
containmentWards: 0,
});
},
}),
{
name: 'mana-loop-game-storage',
partialize: (state) => ({
day: state.day,
hour: state.hour,
incursionStrength: state.incursionStrength,
containmentWards: state.containmentWards,
}),
}
)
);
// Re-export the game loop hook for convenience
export function useGameLoop() {
const tick = useGameStore((s) => s.tick);
return {
start: () => {
const interval = setInterval(tick, TICK_MS);
return () => clearInterval(interval);
},
};
}

563
src/lib/game/stores/index.test.ts Executable file
View File

@@ -0,0 +1,563 @@
/**
* Comprehensive Store Tests
*
* Tests the split store architecture to ensure all stores work correctly together.
*/
import { describe, it, expect, beforeEach } from 'bun:test';
import {
computeMaxMana,
computeElementMax,
computeRegen,
computeClickMana,
calcDamage,
calcInsight,
getMeditationBonus,
getFloorMaxHP,
getFloorElement,
getIncursionStrength,
canAffordSpellCost,
fmt,
fmtDec,
} from './index';
import {
ELEMENTS,
GUARDIANS,
SPELLS_DEF,
SKILLS_DEF,
PRESTIGE_DEF,
getStudySpeedMultiplier,
getStudyCostMultiplier,
HOURS_PER_TICK,
MAX_DAY,
INCURSION_START_DAY,
} from '../constants';
import type { GameState, SkillUpgradeChoice } from '../types';
// ─── Test Helpers ───────────────────────────────────────────────────────────
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: ['fire', 'water', 'air', 'earth'].includes(k) };
});
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,
defeatedGuardians: [],
signedPacts: [],
pactSlots: 1,
pactRitualFloor: null,
pactRitualProgress: 0,
activeSpell: 'manaBolt',
currentAction: 'meditate',
castProgress: 0,
spells: { manaBolt: { learned: true, level: 1, studyProgress: 0 } },
skills: {},
skillProgress: {},
skillUpgrades: {},
skillTiers: {},
paidStudySkills: {},
currentStudyTarget: null,
parallelStudyTarget: null,
insight: 0,
totalInsight: 0,
prestigeUpgrades: {},
memorySlots: 3,
memories: [],
incursionStrength: 0,
containmentWards: 0,
log: [],
loopInsight: 0,
equipment: { mainHand: null, offHand: null, head: null, body: null, hands: null, accessory: null },
inventory: [],
blueprints: {},
schedule: [],
autoSchedule: false,
studyQueue: [],
craftQueue: [],
...overrides,
};
}
// ─── Utility Function Tests ─────────────────────────────────────────────────
describe('Utility Functions', () => {
describe('fmt', () => {
it('should format small numbers', () => {
expect(fmt(0)).toBe('0');
expect(fmt(1)).toBe('1');
expect(fmt(999)).toBe('999');
});
it('should format thousands', () => {
expect(fmt(1000)).toBe('1.0K');
expect(fmt(1500)).toBe('1.5K');
});
it('should format millions', () => {
expect(fmt(1000000)).toBe('1.00M');
expect(fmt(1500000)).toBe('1.50M');
});
it('should format billions', () => {
expect(fmt(1000000000)).toBe('1.00B');
});
});
describe('fmtDec', () => {
it('should format decimals', () => {
expect(fmtDec(1.234, 2)).toBe('1.23');
expect(fmtDec(1.5, 1)).toBe('1.5');
});
});
});
// ─── Mana Calculation Tests ───────────────────────────────────────────────
describe('Mana Calculations', () => {
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('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);
});
});
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);
});
});
});
// ─── Combat Calculation Tests ─────────────────────────────────────────────
describe('Combat Calculations', () => {
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 (can be higher with crit)
});
it('should add damage from combatTrain skill', () => {
const state = createMockState({ skills: { combatTrain: 5 } });
const dmg = calcDamage(state, 'manaBolt');
expect(dmg).toBeGreaterThanOrEqual(5 + 5 * 5); // 5 base + 25 from skill
});
it('should multiply by arcaneFury skill', () => {
const state = createMockState({ skills: { arcaneFury: 3 } });
const dmg = calcDamage(state, 'manaBolt');
// 5 * 1.3 = 6.5 minimum (without crit)
expect(dmg).toBeGreaterThanOrEqual(5 * 1.3 * 0.8);
});
it('should have elemental bonuses', () => {
const state = createMockState({
spells: {
manaBolt: { learned: true, level: 1 },
fireball: { learned: true, level: 1 },
waterJet: { learned: true, level: 1 },
}
});
// Test elemental bonus by comparing same spell vs different elements
// Fireball vs fire floor (same element, +25%) vs vs air floor (neutral)
let fireVsFire = 0, fireVsAir = 0;
for (let i = 0; i < 100; i++) {
fireVsFire += calcDamage(state, 'fireball', 'fire');
fireVsAir += calcDamage(state, 'fireball', 'air');
}
const sameAvg = fireVsFire / 100;
const neutralAvg = fireVsAir / 100;
// Same element should do more damage
expect(sameAvg).toBeGreaterThan(neutralAvg * 1.1);
});
});
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));
});
});
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');
});
it('should wrap around after 8 floors', () => {
expect(getFloorElement(9)).toBe('fire');
expect(getFloorElement(10)).toBe('water');
});
});
});
// ─── 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);
expect(getStudySpeedMultiplier({ quickLearner: 10 })).toBe(2.0);
});
});
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);
expect(getStudyCostMultiplier({ focusedMind: 10 })).toBe(0.5);
});
});
});
// ─── 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);
});
});
});
// ─── Insight Tests ────────────────────────────────────────────────────────
describe('Insight Calculations', () => {
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);
// 1*15 + 5000/500 + 0 = 25
expect(insight).toBe(25);
});
it('should calculate insight from signed pacts', () => {
const state = createMockState({ signedPacts: [10, 20] });
const insight = calcInsight(state);
// 1*15 + 0 + 2*150 = 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));
});
});
});
// ─── 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', () => {
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('canAffordSpellCost', () => {
it('should allow raw mana costs when enough raw mana', () => {
const cost = { type: 'raw' as const, amount: 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 = { type: 'raw' as const, amount: 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 = { type: 'element' as const, element: 'fire', amount: 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 = { type: 'element' as const, element: 'fire', amount: 5 };
const elements = { fire: { current: 10, max: 10, unlocked: false } };
expect(canAffordSpellCost(cost, 100, elements)).toBe(false);
});
});
});
// ─── Skill Definition Tests ──────────────────────────────────────────────
describe('Skill Definitions', () => {
it('all skills should have valid categories', () => {
const validCategories = ['mana', 'combat', 'study', 'craft', 'research', 'ascension'];
Object.values(SKILLS_DEF).forEach(skill => {
expect(validCategories).toContain(skill.cat);
});
});
it('all skills should have reasonable study times', () => {
Object.values(SKILLS_DEF).forEach(skill => {
expect(skill.studyTime).toBeGreaterThan(0);
expect(skill.studyTime).toBeLessThanOrEqual(72);
});
});
it('all prerequisite skills should exist', () => {
Object.entries(SKILLS_DEF).forEach(([id, skill]) => {
if (skill.req) {
Object.keys(skill.req).forEach(reqId => {
expect(SKILLS_DEF[reqId]).toBeDefined();
});
}
});
});
it('all prerequisite levels should be within skill max', () => {
Object.entries(SKILLS_DEF).forEach(([id, skill]) => {
if (skill.req) {
Object.entries(skill.req).forEach(([reqId, reqLevel]) => {
expect(reqLevel).toBeLessThanOrEqual(SKILLS_DEF[reqId].max);
});
}
});
});
});
// ─── Prestige Upgrade Tests ──────────────────────────────────────────────
describe('Prestige Upgrades', () => {
it('all prestige upgrades should have valid costs', () => {
Object.entries(PRESTIGE_DEF).forEach(([id, upgrade]) => {
expect(upgrade.cost).toBeGreaterThan(0);
expect(upgrade.max).toBeGreaterThan(0);
});
});
it('Mana Well prestige should add 500 starting max mana', () => {
const state0 = createMockState({ prestigeUpgrades: { manaWell: 0 } });
const state1 = createMockState({ prestigeUpgrades: { manaWell: 1 } });
const state5 = createMockState({ prestigeUpgrades: { manaWell: 5 } });
expect(computeMaxMana(state0)).toBe(100);
expect(computeMaxMana(state1)).toBe(100 + 500);
expect(computeMaxMana(state5)).toBe(100 + 2500);
});
it('Elemental Attunement prestige should add 25 element cap', () => {
const state0 = createMockState({ prestigeUpgrades: { elementalAttune: 0 } });
const state1 = createMockState({ prestigeUpgrades: { elementalAttune: 1 } });
const state10 = createMockState({ prestigeUpgrades: { elementalAttune: 10 } });
expect(computeElementMax(state0)).toBe(10);
expect(computeElementMax(state1)).toBe(10 + 25);
expect(computeElementMax(state10)).toBe(10 + 250);
});
});
// ─── Guardian Tests ──────────────────────────────────────────────────────
describe('Guardian Definitions', () => {
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 boons defined', () => {
Object.values(GUARDIANS).forEach(guardian => {
expect(guardian.boons).toBeDefined();
expect(guardian.boons.length).toBeGreaterThan(0);
});
});
it('should have pact costs defined', () => {
Object.values(GUARDIANS).forEach(guardian => {
expect(guardian.pactCost).toBeGreaterThan(0);
expect(guardian.pactTime).toBeGreaterThan(0);
});
});
});
console.log('✅ All store tests defined.');

42
src/lib/game/stores/index.ts Executable file
View File

@@ -0,0 +1,42 @@
// ─── Store Index ──────────────────────────────────────────────────────────────
// Exports all stores and re-exports commonly used utilities
// Stores
export { useUIStore } from './uiStore';
export type { UIState } from './uiStore';
export { usePrestigeStore } from './prestigeStore';
export type { PrestigeState } from './prestigeStore';
export { useManaStore, makeInitialElements } from './manaStore';
export type { ManaState } from './manaStore';
export { useSkillStore } from './skillStore';
export type { SkillState } from './skillStore';
export { useCombatStore, makeInitialSpells } from './combatStore';
export type { CombatState } from './combatStore';
export { useGameStore, useGameLoop } from './gameStore';
export type { GameCoordinatorState, GameCoordinatorStore } from './gameStore';
// Re-export utilities from utils.ts
export {
fmt,
fmtDec,
getFloorMaxHP,
getFloorElement,
computeMaxMana,
computeElementMax,
computeRegen,
computeEffectiveRegen,
computeClickMana,
getElementalBonus,
getBoonBonuses,
calcDamage,
calcInsight,
getMeditationBonus,
getIncursionStrength,
canAffordSpellCost,
deductSpellCost,
} from '../utils';

264
src/lib/game/stores/manaStore.ts Executable file
View File

@@ -0,0 +1,264 @@
// ─── Mana Store ───────────────────────────────────────────────────────────────
// Handles raw mana, elements, meditation, and mana regeneration
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { ELEMENTS, MANA_PER_ELEMENT, BASE_UNLOCKED_ELEMENTS } from '../constants';
import type { ElementState } from '../types';
export interface ManaState {
rawMana: number;
meditateTicks: number;
totalManaGathered: number;
elements: Record<string, ElementState>;
// Actions
setRawMana: (amount: number) => void;
addRawMana: (amount: number, maxMana: number) => void;
spendRawMana: (amount: number) => boolean;
gatherMana: (amount: number, maxMana: number) => void;
// Meditation
setMeditateTicks: (ticks: number) => void;
incrementMeditateTicks: () => void;
resetMeditateTicks: () => void;
// Elements
convertMana: (element: string, amount: number) => boolean;
unlockElement: (element: string, cost: number) => boolean;
addElementMana: (element: string, amount: number, max: number) => void;
spendElementMana: (element: string, amount: number) => boolean;
setElementMax: (max: number) => void;
craftComposite: (target: string, recipe: string[]) => boolean;
// Reset
resetMana: (
prestigeUpgrades: Record<string, number>,
skills?: Record<string, number>,
skillUpgrades?: Record<string, string[]>,
skillTiers?: Record<string, number>
) => void;
}
export const useManaStore = create<ManaState>()(
persist(
(set, get) => ({
rawMana: 10,
meditateTicks: 0,
totalManaGathered: 0,
elements: Object.fromEntries(
Object.keys(ELEMENTS).map(k => [
k,
{
current: 0,
max: 10,
unlocked: BASE_UNLOCKED_ELEMENTS.includes(k),
}
])
) as Record<string, ElementState>,
setRawMana: (amount: number) => {
set({ rawMana: Math.max(0, amount) });
},
addRawMana: (amount: number, maxMana: number) => {
set((state) => ({
rawMana: Math.min(state.rawMana + amount, maxMana),
totalManaGathered: state.totalManaGathered + amount,
}));
},
spendRawMana: (amount: number) => {
const state = get();
if (state.rawMana < amount) return false;
set({ rawMana: state.rawMana - amount });
return true;
},
gatherMana: (amount: number, maxMana: number) => {
set((state) => ({
rawMana: Math.min(state.rawMana + amount, maxMana),
totalManaGathered: state.totalManaGathered + amount,
}));
},
setMeditateTicks: (ticks: number) => {
set({ meditateTicks: ticks });
},
incrementMeditateTicks: () => {
set((state) => ({ meditateTicks: state.meditateTicks + 1 }));
},
resetMeditateTicks: () => {
set({ meditateTicks: 0 });
},
convertMana: (element: string, amount: number) => {
const state = get();
const elem = state.elements[element];
if (!elem?.unlocked) return false;
const cost = MANA_PER_ELEMENT * amount;
if (state.rawMana < cost) return false;
if (elem.current >= elem.max) return false;
const canConvert = Math.min(
amount,
Math.floor(state.rawMana / MANA_PER_ELEMENT),
elem.max - elem.current
);
if (canConvert <= 0) return false;
set({
rawMana: state.rawMana - canConvert * MANA_PER_ELEMENT,
elements: {
...state.elements,
[element]: { ...elem, current: elem.current + canConvert },
},
});
return true;
},
unlockElement: (element: string, cost: number) => {
const state = get();
if (state.elements[element]?.unlocked) return false;
if (state.rawMana < cost) return false;
set({
rawMana: state.rawMana - cost,
elements: {
...state.elements,
[element]: { ...state.elements[element], unlocked: true },
},
});
return true;
},
addElementMana: (element: string, amount: number, max: number) => {
set((state) => {
const elem = state.elements[element];
if (!elem) return state;
return {
elements: {
...state.elements,
[element]: {
...elem,
current: Math.min(elem.current + amount, max),
},
},
};
});
},
spendElementMana: (element: string, amount: number) => {
const state = get();
const elem = state.elements[element];
if (!elem || elem.current < amount) return false;
set({
elements: {
...state.elements,
[element]: { ...elem, current: elem.current - amount },
},
});
return true;
},
setElementMax: (max: number) => {
set((state) => ({
elements: Object.fromEntries(
Object.entries(state.elements).map(([k, v]) => [k, { ...v, max }])
) as Record<string, ElementState>,
}));
},
craftComposite: (target: string, recipe: string[]) => {
const state = get();
// Count required ingredients
const costs: Record<string, number> = {};
recipe.forEach(r => {
costs[r] = (costs[r] || 0) + 1;
});
// Check if we have all ingredients
for (const [r, amt] of Object.entries(costs)) {
if ((state.elements[r]?.current || 0) < amt) return false;
}
// Deduct ingredients
const newElems = { ...state.elements };
for (const [r, amt] of Object.entries(costs)) {
newElems[r] = {
...newElems[r],
current: newElems[r].current - amt,
};
}
// Add crafted element
const targetElem = newElems[target];
newElems[target] = {
...(targetElem || { current: 0, max: 10, unlocked: false }),
current: (targetElem?.current || 0) + 1,
unlocked: true,
};
set({ elements: newElems });
return true;
},
resetMana: (
prestigeUpgrades: Record<string, number>,
skills: Record<string, number> = {},
skillUpgrades: Record<string, string[]> = {},
skillTiers: Record<string, number> = {}
) => {
const elementMax = 10 + (prestigeUpgrades.elemMax || 0) * 5;
const startingMana = 10 + (prestigeUpgrades.manaStart || 0) * 10;
const elements = makeInitialElements(elementMax, prestigeUpgrades);
set({
rawMana: startingMana,
meditateTicks: 0,
totalManaGathered: 0,
elements,
});
},
}),
{
name: 'mana-loop-mana',
partialize: (state) => ({
rawMana: state.rawMana,
totalManaGathered: state.totalManaGathered,
elements: state.elements,
}),
}
)
);
// Helper function to create initial elements
export function makeInitialElements(
elementMax: number,
prestigeUpgrades: Record<string, number> = {}
): Record<string, ElementState> {
const elemStart = (prestigeUpgrades.elemStart || 0) * 5;
const elements: Record<string, ElementState> = {};
Object.keys(ELEMENTS).forEach(k => {
const isUnlocked = BASE_UNLOCKED_ELEMENTS.includes(k);
elements[k] = {
current: isUnlocked ? elemStart : 0,
max: elementMax,
unlocked: isUnlocked,
};
});
return elements;
}

View File

@@ -0,0 +1,266 @@
// ─── Prestige Store ───────────────────────────────────────────────────────────
// Handles insight, prestige upgrades, memories, loops, pacts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Memory } from '../types';
import { GUARDIANS, PRESTIGE_DEF } from '../constants';
export interface PrestigeState {
// Loop counter
loopCount: number;
// Insight
insight: number;
totalInsight: number;
loopInsight: number; // Insight earned at end of current loop
// Prestige upgrades
prestigeUpgrades: Record<string, number>;
memorySlots: number;
pactSlots: number;
// Memories (skills preserved across loops)
memories: Memory[];
// Guardian pacts
defeatedGuardians: number[];
signedPacts: number[];
pactRitualFloor: number | null;
pactRitualProgress: number;
// Actions
doPrestige: (id: string) => void;
addMemory: (memory: Memory) => void;
removeMemory: (skillId: string) => void;
clearMemories: () => void;
startPactRitual: (floor: number, rawMana: number) => boolean;
cancelPactRitual: () => void;
completePactRitual: (addLog: (msg: string) => void) => void;
updatePactRitualProgress: (hours: number) => void;
removePact: (floor: number) => void;
defeatGuardian: (floor: number) => void;
// Methods called by gameStore
addSignedPact: (floor: number) => void;
removeDefeatedGuardian: (floor: number) => void;
setPactRitualFloor: (floor: number | null) => void;
addDefeatedGuardian: (floor: number) => void;
incrementLoopCount: () => void;
resetPrestigeForNewLoop: (
totalInsight: number,
prestigeUpgrades: Record<string, number>,
memories: Memory[],
memorySlots: number
) => void;
// Loop management
startNewLoop: (insightGained: number) => void;
setLoopInsight: (insight: number) => void;
// Reset
resetPrestige: () => void;
}
const initialState = {
loopCount: 0,
insight: 0,
totalInsight: 0,
loopInsight: 0,
prestigeUpgrades: {} as Record<string, number>,
memorySlots: 3,
pactSlots: 1,
memories: [] as Memory[],
defeatedGuardians: [] as number[],
signedPacts: [] as number[],
pactRitualFloor: null as number | null,
pactRitualProgress: 0,
};
export const usePrestigeStore = create<PrestigeState>()(
persist(
(set, get) => ({
...initialState,
doPrestige: (id: string) => {
const state = get();
const pd = PRESTIGE_DEF[id];
if (!pd) return false;
const lvl = state.prestigeUpgrades[id] || 0;
if (lvl >= pd.max || state.insight < pd.cost) return false;
const newPU = { ...state.prestigeUpgrades, [id]: lvl + 1 };
set({
insight: state.insight - pd.cost,
prestigeUpgrades: newPU,
memorySlots: id === 'deepMemory' ? state.memorySlots + 1 : state.memorySlots,
pactSlots: id === 'pactBinding' ? state.pactSlots + 1 : state.pactSlots,
});
return true;
},
addMemory: (memory: Memory) => {
const state = get();
if (state.memories.length >= state.memorySlots) return;
if (state.memories.some(m => m.skillId === memory.skillId)) return;
set({ memories: [...state.memories, memory] });
},
removeMemory: (skillId: string) => {
set((state) => ({
memories: state.memories.filter(m => m.skillId !== skillId),
}));
},
clearMemories: () => {
set({ memories: [] });
},
startPactRitual: (floor: number, rawMana: number) => {
const state = get();
const guardian = GUARDIANS[floor];
if (!guardian) return false;
if (!state.defeatedGuardians.includes(floor)) return false;
if (state.signedPacts.includes(floor)) return false;
if (state.signedPacts.length >= state.pactSlots) return false;
if (rawMana < guardian.pactCost) return false;
if (state.pactRitualFloor !== null) return false;
set({
pactRitualFloor: floor,
pactRitualProgress: 0,
});
return true;
},
cancelPactRitual: () => {
set({
pactRitualFloor: null,
pactRitualProgress: 0,
});
},
completePactRitual: (addLog: (msg: string) => void) => {
const state = get();
if (state.pactRitualFloor === null) return;
const guardian = GUARDIANS[state.pactRitualFloor];
if (!guardian) return;
set({
signedPacts: [...state.signedPacts, state.pactRitualFloor],
defeatedGuardians: state.defeatedGuardians.filter(f => f !== state.pactRitualFloor),
pactRitualFloor: null,
pactRitualProgress: 0,
});
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
},
updatePactRitualProgress: (hours: number) => {
set((state) => ({
pactRitualProgress: state.pactRitualProgress + hours,
}));
},
removePact: (floor: number) => {
set((state) => ({
signedPacts: state.signedPacts.filter(f => f !== floor),
}));
},
defeatGuardian: (floor: number) => {
const state = get();
if (state.defeatedGuardians.includes(floor) || state.signedPacts.includes(floor)) return;
set({
defeatedGuardians: [...state.defeatedGuardians, floor],
});
},
addSignedPact: (floor: number) => {
const state = get();
if (state.signedPacts.includes(floor)) return;
set({ signedPacts: [...state.signedPacts, floor] });
},
removeDefeatedGuardian: (floor: number) => {
set((state) => ({
defeatedGuardians: state.defeatedGuardians.filter(f => f !== floor),
}));
},
setPactRitualFloor: (floor: number | null) => {
set({ pactRitualFloor: floor, pactRitualProgress: 0 });
},
addDefeatedGuardian: (floor: number) => {
const state = get();
if (state.defeatedGuardians.includes(floor) || state.signedPacts.includes(floor)) return;
set({ defeatedGuardians: [...state.defeatedGuardians, floor] });
},
incrementLoopCount: () => {
set((state) => ({ loopCount: state.loopCount + 1 }));
},
resetPrestigeForNewLoop: (
totalInsight: number,
prestigeUpgrades: Record<string, number>,
memories: Memory[],
memorySlots: number
) => {
set({
insight: totalInsight,
prestigeUpgrades,
memories,
memorySlots,
// Reset loop-specific state
defeatedGuardians: [],
signedPacts: [],
pactRitualFloor: null,
pactRitualProgress: 0,
loopInsight: 0,
});
},
startNewLoop: (insightGained: number) => {
const state = get();
set({
loopCount: state.loopCount + 1,
insight: state.insight + insightGained,
totalInsight: state.totalInsight + insightGained,
loopInsight: 0,
// Reset loop-specific state
defeatedGuardians: [],
signedPacts: [],
pactRitualFloor: null,
pactRitualProgress: 0,
});
},
setLoopInsight: (insight: number) => {
set({ loopInsight: insight });
},
resetPrestige: () => {
set(initialState);
},
}),
{
name: 'mana-loop-prestige',
partialize: (state) => ({
loopCount: state.loopCount,
insight: state.insight,
totalInsight: state.totalInsight,
prestigeUpgrades: state.prestigeUpgrades,
memorySlots: state.memorySlots,
pactSlots: state.pactSlots,
memories: state.memories,
}),
}
)
);

332
src/lib/game/stores/skillStore.ts Executable file
View File

@@ -0,0 +1,332 @@
// ─── Skill Store ──────────────────────────────────────────────────────────────
// Handles skills, upgrades, tiers, and study progress
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { SKILLS_DEF, getStudySpeedMultiplier, getStudyCostMultiplier } from '../constants';
import type { StudyTarget, SkillUpgradeChoice } from '../types';
import { SKILL_EVOLUTION_PATHS, getBaseSkillId } from '../skill-evolution';
export interface SkillState {
// Skills
skills: Record<string, number>;
skillProgress: Record<string, number>; // Saved study progress for skills
skillUpgrades: Record<string, string[]>; // Selected upgrade IDs per skill
skillTiers: Record<string, number>; // Current tier for each base skill
paidStudySkills: Record<string, number>; // skillId -> level that was paid for
// Study
currentStudyTarget: StudyTarget | null;
parallelStudyTarget: StudyTarget | null;
// Actions - Skills
setSkillLevel: (skillId: string, level: number) => void;
incrementSkillLevel: (skillId: string) => void;
clearPaidStudySkill: (skillId: string) => void;
setPaidStudySkill: (skillId: string, level: number) => void;
// Actions - Study
startStudyingSkill: (skillId: string, rawMana: number) => { started: boolean; cost: number };
startStudyingSpell: (spellId: string, rawMana: number, studyTime: number) => { started: boolean; cost: number };
updateStudyProgress: (progressGain: number) => { completed: boolean; target: StudyTarget | null };
cancelStudy: (retentionBonus: number) => void;
setStudyTarget: (target: StudyTarget | null) => void;
setCurrentStudyTarget: (target: StudyTarget | null) => void;
// Actions - Upgrades
selectSkillUpgrade: (skillId: string, upgradeId: string) => void;
deselectSkillUpgrade: (skillId: string, upgradeId: string) => void;
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
tierUpSkill: (skillId: string) => void;
// Computed
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => { available: SkillUpgradeChoice[]; selected: string[] };
// Reset
resetSkills: (
skills?: Record<string, number>,
skillUpgrades?: Record<string, string[]>,
skillTiers?: Record<string, number>
) => void;
}
export const useSkillStore = create<SkillState>()(
persist(
(set, get) => ({
skills: {},
skillProgress: {},
skillUpgrades: {},
skillTiers: {},
paidStudySkills: {},
currentStudyTarget: null,
parallelStudyTarget: null,
setSkillLevel: (skillId: string, level: number) => {
set((state) => ({
skills: { ...state.skills, [skillId]: level },
}));
},
incrementSkillLevel: (skillId: string) => {
set((state) => ({
skills: { ...state.skills, [skillId]: (state.skills[skillId] || 0) + 1 },
skillProgress: { ...state.skillProgress, [skillId]: 0 },
}));
},
clearPaidStudySkill: (skillId: string) => {
set((state) => {
const { [skillId]: _, ...remaining } = state.paidStudySkills;
return { paidStudySkills: remaining };
});
},
setPaidStudySkill: (skillId: string, level: number) => {
set((state) => ({
paidStudySkills: { ...state.paidStudySkills, [skillId]: level },
}));
},
startStudyingSkill: (skillId: string, rawMana: number) => {
const state = get();
const sk = SKILLS_DEF[skillId];
if (!sk) return { started: false, cost: 0 };
const currentLevel = state.skills[skillId] || 0;
if (currentLevel >= sk.max) return { started: false, cost: 0 };
// Check prerequisites
if (sk.req) {
for (const [r, rl] of Object.entries(sk.req)) {
if ((state.skills[r] || 0) < rl) return { started: false, cost: 0 };
}
}
// Check if already paid for this level
const paidForLevel = state.paidStudySkills[skillId];
const isAlreadyPaid = paidForLevel === currentLevel;
// Calculate cost
const costMult = getStudyCostMultiplier(state.skills);
const cost = Math.floor(sk.base * (currentLevel + 1) * costMult);
if (!isAlreadyPaid && rawMana < cost) return { started: false, cost };
// Get saved progress
const savedProgress = state.skillProgress[skillId] || 0;
// Mark as paid (this is done here so resume works for free)
const newPaidSkills = isAlreadyPaid
? state.paidStudySkills
: { ...state.paidStudySkills, [skillId]: currentLevel };
// Start studying
set({
paidStudySkills: newPaidSkills,
currentStudyTarget: {
type: 'skill',
id: skillId,
progress: savedProgress,
required: sk.studyTime,
},
});
return { started: true, cost: isAlreadyPaid ? 0 : cost };
},
startStudyingSpell: (spellId: string, rawMana: number, studyTime: number) => {
const state = get();
// Start studying the spell
set({
currentStudyTarget: {
type: 'spell',
id: spellId,
progress: 0,
required: studyTime,
},
});
// Spell study has no mana cost upfront - cost is paid via study time
return { started: true, cost: 0 };
},
updateStudyProgress: (progressGain: number) => {
const state = get();
if (!state.currentStudyTarget) return { completed: false, target: null };
const newProgress = state.currentStudyTarget.progress + progressGain;
const completed = newProgress >= state.currentStudyTarget.required;
const newTarget = completed ? null : {
...state.currentStudyTarget,
progress: newProgress,
};
set({ currentStudyTarget: newTarget });
return {
completed,
target: completed ? state.currentStudyTarget : null
};
},
cancelStudy: (retentionBonus: number) => {
const state = get();
if (!state.currentStudyTarget) return;
// Save progress with retention bonus
const savedProgress = Math.min(
state.currentStudyTarget.progress,
state.currentStudyTarget.required * retentionBonus
);
if (state.currentStudyTarget.type === 'skill') {
set({
currentStudyTarget: null,
skillProgress: {
...state.skillProgress,
[state.currentStudyTarget.id]: savedProgress,
},
});
} else {
set({ currentStudyTarget: null });
}
},
setStudyTarget: (target: StudyTarget | null) => {
set({ currentStudyTarget: target });
},
setCurrentStudyTarget: (target: StudyTarget | null) => {
set({ currentStudyTarget: target });
},
selectSkillUpgrade: (skillId: string, upgradeId: string) => {
set((state) => {
const current = state.skillUpgrades?.[skillId] || [];
if (current.includes(upgradeId)) return state;
if (current.length >= 2) return state; // Max 2 upgrades per milestone
return {
skillUpgrades: {
...state.skillUpgrades,
[skillId]: [...current, upgradeId],
},
};
});
},
deselectSkillUpgrade: (skillId: string, upgradeId: string) => {
set((state) => {
const current = state.skillUpgrades?.[skillId] || [];
return {
skillUpgrades: {
...state.skillUpgrades,
[skillId]: current.filter(id => id !== upgradeId),
},
};
});
},
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => {
set((state) => {
// Determine which milestone we're committing
const isL5 = upgradeIds.some(id => id.includes('_l5'));
const isL10 = upgradeIds.some(id => id.includes('_l10'));
const existingUpgrades = state.skillUpgrades?.[skillId] || [];
let preservedUpgrades: string[];
if (isL5) {
preservedUpgrades = existingUpgrades.filter(id => id.includes('_l10'));
} else if (isL10) {
preservedUpgrades = existingUpgrades.filter(id => id.includes('_l5'));
} else {
preservedUpgrades = [];
}
const mergedUpgrades = [...preservedUpgrades, ...upgradeIds];
return {
skillUpgrades: {
...state.skillUpgrades,
[skillId]: mergedUpgrades,
},
};
});
},
tierUpSkill: (skillId: string) => {
const state = get();
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
const currentTier = state.skillTiers?.[baseSkillId] || 1;
const nextTier = currentTier + 1;
if (nextTier > 5) return;
const nextTierSkillId = `${baseSkillId}_t${nextTier}`;
const currentLevel = state.skills[skillId] || 0;
set({
skillTiers: {
...state.skillTiers,
[baseSkillId]: nextTier,
},
skills: {
...state.skills,
[nextTierSkillId]: currentLevel,
[skillId]: 0,
},
skillUpgrades: {
...state.skillUpgrades,
[nextTierSkillId]: [],
},
});
},
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => {
const state = get();
const baseSkillId = getBaseSkillId(skillId);
const tier = state.skillTiers?.[baseSkillId] || 1;
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
if (!path) return { available: [], selected: [] };
const tierDef = path.tiers.find(t => t.tier === tier);
if (!tierDef) return { available: [], selected: [] };
const available = tierDef.upgrades.filter(u => u.milestone === milestone);
const selected = state.skillUpgrades?.[skillId]?.filter(id =>
available.some(u => u.id === id)
) || [];
return { available, selected };
},
resetSkills: (
skills: Record<string, number> = {},
skillUpgrades: Record<string, string[]> = {},
skillTiers: Record<string, number> = {}
) => {
set({
skills,
skillProgress: {},
skillUpgrades,
skillTiers,
paidStudySkills: {},
currentStudyTarget: null,
parallelStudyTarget: null,
});
},
}),
{
name: 'mana-loop-skills',
partialize: (state) => ({
skills: state.skills,
skillProgress: state.skillProgress,
skillUpgrades: state.skillUpgrades,
skillTiers: state.skillTiers,
}),
}
)
);

74
src/lib/game/stores/uiStore.ts Executable file
View File

@@ -0,0 +1,74 @@
// ─── UI Store ────────────────────────────────────────────────────────────────
// Handles logs, pause state, and UI-specific state
import { create } from 'zustand';
export interface LogEntry {
message: string;
timestamp: number;
}
export interface UIState {
logs: string[];
paused: boolean;
gameOver: boolean;
victory: boolean;
// Actions
addLog: (message: string) => void;
clearLogs: () => void;
togglePause: () => void;
setPaused: (paused: boolean) => void;
setGameOver: (gameOver: boolean, victory?: boolean) => void;
reset: () => void;
resetUI: () => void;
}
const MAX_LOGS = 50;
export const useUIStore = create<UIState>((set) => ({
logs: ['✨ The loop begins. You start with Mana Bolt. Gather your strength, mage.'],
paused: false,
gameOver: false,
victory: false,
addLog: (message: string) => {
set((state) => ({
logs: [message, ...state.logs.slice(0, MAX_LOGS - 1)],
}));
},
clearLogs: () => {
set({ logs: [] });
},
togglePause: () => {
set((state) => ({ paused: !state.paused }));
},
setPaused: (paused: boolean) => {
set({ paused });
},
setGameOver: (gameOver: boolean, victory: boolean = false) => {
set({ gameOver, victory });
},
reset: () => {
set({
logs: ['✨ The loop begins. You start with Mana Bolt. Gather your strength, mage.'],
paused: false,
gameOver: false,
victory: false,
});
},
resetUI: () => {
set({
logs: ['✨ The loop begins. You start with Mana Bolt. Gather your strength, mage.'],
paused: false,
gameOver: false,
victory: false,
});
},
}));