Files
Mana-Loop/src/lib/game/store.ts
Z User 22dfdb5910
All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m45s
Initial commit
2026-03-28 15:00:03 +00:00

1901 lines
64 KiB
TypeScript
Executable File

// ─── Game Store ───────────────────────────────────────────────────────────────
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { GameState, GameAction, StudyTarget, SpellCost, SkillUpgradeChoice, EquipmentSlot, EquipmentInstance, EnchantmentDesign, DesignEffect, AttunementState } from './types';
import {
ELEMENTS,
GUARDIANS,
SPELLS_DEF,
SKILLS_DEF,
PRESTIGE_DEF,
FLOOR_ELEM_CYCLE,
BASE_UNLOCKED_ELEMENTS,
TICK_MS,
HOURS_PER_TICK,
MAX_DAY,
INCURSION_START_DAY,
MANA_PER_ELEMENT,
getStudySpeedMultiplier,
getStudyCostMultiplier,
ELEMENT_OPPOSITES,
EFFECT_RESEARCH_MAPPING,
BASE_UNLOCKED_EFFECTS,
ENCHANTING_UNLOCK_EFFECTS,
} from './constants';
import { computeEffects, hasSpecial, SPECIAL_EFFECTS, type ComputedEffects } from './upgrade-effects';
import {
computeAllEffects,
getUnifiedEffects,
computeEquipmentEffects,
type UnifiedEffects
} from './effects';
import { SKILL_EVOLUTION_PATHS } from './skill-evolution';
import {
createStartingEquipment,
processCraftingTick,
getSpellsFromEquipment,
type CraftingActions
} from './crafting-slice';
import { EQUIPMENT_TYPES } from './data/equipment';
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
import { ATTUNEMENTS_DEF, getTotalAttunementRegen, getAttunementConversionRate, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL } from './data/attunements';
// Default empty effects for when effects aren't provided
const DEFAULT_EFFECTS: ComputedEffects = {
maxManaMultiplier: 1,
maxManaBonus: 0,
regenMultiplier: 1,
regenBonus: 0,
clickManaMultiplier: 1,
clickManaBonus: 0,
meditationEfficiency: 1,
spellCostMultiplier: 1,
conversionEfficiency: 1,
baseDamageMultiplier: 1,
baseDamageBonus: 0,
attackSpeedMultiplier: 1,
critChanceBonus: 0,
critDamageMultiplier: 1.5,
elementalDamageMultiplier: 1,
studySpeedMultiplier: 1,
studyCostMultiplier: 1,
progressRetention: 0,
instantStudyChance: 0,
freeStudyChance: 0,
elementCapMultiplier: 1,
elementCapBonus: 0,
conversionCostMultiplier: 1,
doubleCraftChance: 0,
permanentRegenBonus: 0,
specials: new Set(),
activeUpgrades: [],
};
// ─── Helper Functions ─────────────────────────────────────────────────────────
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';
}
export function getFloorMaxHP(floor: number): number {
if (GUARDIANS[floor]) return GUARDIANS[floor].hp;
// Improved scaling: slower early game, faster late game
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 {
return FLOOR_ELEM_CYCLE[(floor - 1) % 8];
}
// ─── Computed Stats Functions ─────────────────────────────────────────────────
// Helper to get effective skill level accounting for tiers
function getEffectiveSkillLevel(
skills: Record<string, number>,
baseSkillId: string,
skillTiers: Record<string, number> = {}
): { level: number; tier: number; tierMultiplier: number } {
// Find the highest tier the player has for this base skill
const currentTier = skillTiers[baseSkillId] || 1;
// Look for the tiered skill ID (e.g., manaFlow_t2)
const tieredSkillId = currentTier > 1 ? `${baseSkillId}_t${currentTier}` : baseSkillId;
const level = skills[tieredSkillId] || skills[baseSkillId] || 0;
// Tier multiplier: each tier is 10x more powerful
const tierMultiplier = Math.pow(10, currentTier - 1);
return { level, tier: currentTier, tierMultiplier };
}
export function computeMaxMana(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: ComputedEffects | UnifiedEffects
): number {
const pu = state.prestigeUpgrades;
const base =
100 +
(state.skills.manaWell || 0) * 100 +
(pu.manaWell || 0) * 500;
// If effects not provided, compute unified effects (includes equipment)
if (!effects && state.equipmentInstances && state.equippedInstances) {
effects = getUnifiedEffects(state as any);
}
// Apply effects if available (now includes equipment bonuses)
if (effects) {
return Math.floor((base + effects.maxManaBonus) * effects.maxManaMultiplier);
}
return base;
}
export function computeElementMax(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
effects?: ComputedEffects
): number {
const pu = state.prestigeUpgrades;
const base = 10 + (state.skills.elemAttune || 0) * 50 + (pu.elementalAttune || 0) * 25;
// Apply upgrade effects if provided
if (effects) {
return Math.floor((base + effects.elementCapBonus) * effects.elementCapMultiplier);
}
return base;
}
export function computeRegen(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances' | 'attunements'>,
effects?: ComputedEffects | UnifiedEffects
): number {
const pu = state.prestigeUpgrades;
const temporalBonus = 1 + (pu.temporalEcho || 0) * 0.1;
const base =
2 +
(state.skills.manaFlow || 0) * 1 +
(state.skills.manaSpring || 0) * 2 +
(pu.manaFlow || 0) * 0.5;
let regen = base * temporalBonus;
// Add attunement raw mana regen
const attunementRegen = getTotalAttunementRegen(state.attunements || {});
regen += attunementRegen;
// If effects not provided, compute unified effects (includes equipment)
if (!effects && state.equipmentInstances && state.equippedInstances) {
effects = getUnifiedEffects(state as any);
}
// Apply effects if available (now includes equipment bonuses)
if (effects) {
regen = (regen + effects.regenBonus + effects.permanentRegenBonus) * effects.regenMultiplier;
}
return regen;
}
/**
* Compute regen with dynamic special effects (needs current mana, max mana, incursion)
*/
export function computeEffectiveRegen(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'rawMana' | 'incursionStrength' | 'skillUpgrades' | 'skillTiers'>,
effects?: ComputedEffects
): number {
// Base regen from existing function
let regen = computeRegen(state, effects);
const maxMana = computeMaxMana(state, effects);
const currentMana = state.rawMana;
const incursionStrength = state.incursionStrength || 0;
// Apply incursion penalty
regen *= (1 - incursionStrength);
return regen;
}
export function computeClickMana(
state: Pick<GameState, 'skills' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: ComputedEffects | UnifiedEffects
): number {
const base =
1 +
(state.skills.manaTap || 0) * 1 +
(state.skills.manaSurge || 0) * 3;
// If effects not provided, compute unified effects (includes equipment)
if (!effects && state.equipmentInstances && state.equippedInstances) {
effects = getUnifiedEffects(state as any);
}
// Apply effects if available (now includes equipment bonuses)
if (effects) {
return Math.floor((base + effects.clickManaBonus) * effects.clickManaMultiplier);
}
return base;
}
// Elemental damage bonus: +50% if spell element opposes floor element (super effective)
// -25% if spell element matches its own opposite (weak)
function getElementalBonus(spellElem: string, floorElem: string): number {
if (spellElem === 'raw') return 1.0; // Raw mana has no elemental bonus
if (spellElem === floorElem) return 1.25; // Same element: +25% damage
// Check for super effective first: spell is the opposite of floor
// e.g., casting water (opposite of fire) at fire floor = super effective
if (ELEMENT_OPPOSITES[floorElem] === spellElem) return 1.5; // Super effective: +50% damage
// Check for weak: spell's opposite matches floor
// e.g., casting fire (whose opposite is water) at water floor = weak
if (ELEMENT_OPPOSITES[spellElem] === floorElem) return 0.75; // Weak: -25% damage
return 1.0; // Neutral
}
export function calcDamage(
state: Pick<GameState, 'skills' | 'signedPacts'>,
spellId: string,
floorElem?: string
): number {
const sp = SPELLS_DEF[spellId];
if (!sp) return 5;
const skills = state.skills;
const baseDmg = sp.dmg + (skills.combatTrain || 0) * 5;
const pct = 1 + (skills.arcaneFury || 0) * 0.1;
// Elemental mastery bonus
const elemMasteryBonus = 1 + (skills.elementalMastery || 0) * 0.15;
// Guardian bane bonus
const guardianBonus = floorElem && GUARDIANS[Object.values(GUARDIANS).find(g => g.element === floorElem)?.hp ? 0 : 0]
? 1 + (skills.guardianBane || 0) * 0.2
: 1;
const critChance = (skills.precision || 0) * 0.05;
const pactMult = state.signedPacts.reduce(
(m, f) => m * (GUARDIANS[f]?.pact || 1),
1
);
let damage = baseDmg * pct * pactMult * elemMasteryBonus;
// Apply elemental bonus if floor element provided
if (floorElem) {
damage *= getElementalBonus(sp.elem, floorElem);
}
// Apply crit
if (Math.random() < critChance) {
damage *= 1.5;
}
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
);
}
// Meditation bonus now affects regen rate directly
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 * HOURS_PER_TICK;
// Base meditation: ramps up over 4 hours to 1.5x
let bonus = 1 + Math.min(hours / 4, 0.5);
// With Meditation Focus: up to 2.5x after 4 hours
if (hasMeditation && hours >= 4) {
bonus = 2.5;
}
// With Deep Trance: up to 3.0x after 6 hours
if (hasDeepTrance && hours >= 6) {
bonus = 3.0;
}
// With Void Meditation: up to 5.0x after 8 hours
if (hasVoidMeditation && hours >= 8) {
bonus = 5.0;
}
// Apply meditation efficiency from upgrades (Deep Wellspring, etc.)
bonus *= meditationEfficiency;
return bonus;
}
export function getIncursionStrength(day: number, hour: number): number {
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);
}
// Check if player can afford spell cost
export function canAffordSpellCost(
cost: SpellCost,
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;
}
}
// Deduct spell cost from appropriate mana pool
function deductSpellCost(
cost: SpellCost,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
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 };
}
// ─── Initial State Factory ────────────────────────────────────────────────────
function makeInitial(overrides: Partial<GameState> = {}): GameState {
const pu = overrides.prestigeUpgrades || {};
const startFloor = 1 + (pu.spireKey || 0) * 2;
const elemMax = computeElementMax({ skills: overrides.skills || {}, prestigeUpgrades: pu });
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
Object.keys(ELEMENTS).forEach((k) => {
const isUnlocked = BASE_UNLOCKED_ELEMENTS.includes(k);
let startAmount = 0;
// Start with some elemental mana if elemStart upgrade
if (isUnlocked && pu.elemStart) {
startAmount = pu.elemStart * 5;
}
elements[k] = {
current: overrides.elements?.[k]?.current ?? startAmount,
max: elemMax,
unlocked: isUnlocked,
};
});
// Starting raw mana
const startRawMana = 10 + (pu.manaWell || 0) * 500 + (pu.quickStart || 0) * 100;
// Create starting equipment (staff with mana bolt, clothes)
const startingEquipment = createStartingEquipment();
// Get spells from starting equipment
const equipmentSpells = getSpellsFromEquipment(
startingEquipment.equipmentInstances,
Object.values(startingEquipment.equippedInstances)
);
// Starting spells - now come from equipment instead of being learned directly
const startSpells: Record<string, { learned: boolean; level: number; studyProgress: number }> = {};
// Add spells from equipment
for (const spellId of equipmentSpells) {
startSpells[spellId] = { learned: true, level: 1, studyProgress: 0 };
}
// Add random starting spells from spell memory upgrade (pact spells)
if (pu.spellMemory) {
const availableSpells = Object.keys(SPELLS_DEF).filter(s => !startSpells[s]);
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 };
}
}
// Starting attunements - player begins with Enchanter
const startingAttunements: Record<string, AttunementState> = {
enchanter: { id: 'enchanter', active: true, level: 1, experience: 0 },
};
// Add any attunements from previous loops (for persistence)
if (overrides.attunements) {
Object.entries(overrides.attunements).forEach(([id, state]) => {
if (id !== 'enchanter') {
startingAttunements[id] = state;
}
});
}
// Unlock transference element for Enchanter attunement
if (elements['transference']) {
elements['transference'] = { ...elements['transference'], unlocked: true };
}
return {
day: 1,
hour: 0,
loopCount: overrides.loopCount || 0,
gameOver: false,
victory: false,
paused: false,
rawMana: startRawMana,
meditateTicks: 0,
totalManaGathered: overrides.totalManaGathered || 0,
// Attunements (class-like system)
attunements: startingAttunements,
elements: elements as Record<string, { current: number; max: number; unlocked: boolean }>,
currentFloor: startFloor,
floorHP: getFloorMaxHP(startFloor),
floorMaxHP: getFloorMaxHP(startFloor),
maxFloorReached: startFloor,
signedPacts: [],
activeSpell: 'manaBolt',
currentAction: 'meditate',
castProgress: 0,
combo: {
count: 0,
maxCombo: 0,
multiplier: 1,
elementChain: [],
decayTimer: 0,
},
spells: startSpells,
skills: overrides.skills || {},
skillProgress: {},
skillUpgrades: overrides.skillUpgrades || {},
skillTiers: overrides.skillTiers || {},
parallelStudyTarget: null,
// Achievements
achievements: {
unlocked: [],
progress: {},
},
// Stats tracking
totalSpellsCast: 0,
totalDamageDealt: 0,
totalCraftsCompleted: 0,
// New equipment system
equippedInstances: startingEquipment.equippedInstances,
equipmentInstances: startingEquipment.equipmentInstances,
enchantmentDesigns: [],
designProgress: null,
preparationProgress: null,
applicationProgress: null,
equipmentCraftingProgress: null,
unlockedEffects: [...BASE_UNLOCKED_EFFECTS], // Start with mana bolt only
equipmentSpellStates: [],
// Legacy equipment (for backward compatibility)
equipment: {
mainHand: null,
offHand: null,
head: null,
body: null,
hands: null,
accessory: null,
},
inventory: [],
blueprints: {},
// Loot inventory
lootInventory: {
materials: {},
blueprints: [],
},
schedule: [],
autoSchedule: false,
studyQueue: [],
craftQueue: [],
currentStudyTarget: null,
insight: overrides.insight || 0,
totalInsight: overrides.totalInsight || 0,
prestigeUpgrades: pu,
memorySlots: 3 + (pu.deepMemory || 0),
memories: overrides.memories || [],
incursionStrength: 0,
containmentWards: 0,
log: ['✨ The loop begins. You start with a Basic Staff (Mana Bolt) and civilian clothes. Gather your strength, mage.'],
loopInsight: 0,
};
}
// ─── Game Store ───────────────────────────────────────────────────────────────
interface GameStore extends GameState, CraftingActions {
// Actions
tick: () => void;
gatherMana: () => void;
setAction: (action: GameAction) => void;
setSpell: (spellId: string) => void;
startStudyingSkill: (skillId: string) => void;
startStudyingSpell: (spellId: string) => void;
startParallelStudySkill: (skillId: string) => void;
cancelStudy: () => void;
cancelParallelStudy: () => void;
convertMana: (element: string, amount: number) => void;
unlockElement: (element: string) => void;
craftComposite: (target: string) => void;
doPrestige: (id: string) => void;
startNewLoop: () => void;
togglePause: () => void;
resetGame: () => void;
addLog: (message: string) => void;
selectSkillUpgrade: (skillId: string, upgradeId: string) => void;
deselectSkillUpgrade: (skillId: string, upgradeId: string) => void;
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
tierUpSkill: (skillId: string) => void;
// Attunement XP and leveling
addAttunementXP: (attunementId: string, amount: number) => void;
// Debug functions
debugUnlockAttunement: (attunementId: string) => void;
debugAddElementalMana: (element: string, amount: number) => void;
debugSetTime: (day: number, hour: number) => void;
debugAddAttunementXP: (attunementId: string, amount: number) => void;
debugSetFloor: (floor: number) => void;
// Computed getters
getMaxMana: () => number;
getRegen: () => number;
getClickMana: () => number;
getDamage: (spellId: string) => number;
getMeditationMultiplier: () => number;
canCastSpell: (spellId: string) => boolean;
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => { available: SkillUpgradeChoice[]; selected: string[] };
}
export const useGameStore = create<GameStore>()(
persist(
(set, get) => ({
...makeInitial(),
getMaxMana: () => computeMaxMana(get()),
getRegen: () => computeRegen(get()),
getClickMana: () => computeClickMana(get()),
getDamage: (spellId: string) => calcDamage(get(), spellId),
getMeditationMultiplier: () => getMeditationBonus(get().meditateTicks, get().skills),
canCastSpell: (spellId: string) => {
const state = get();
const spell = SPELLS_DEF[spellId];
if (!spell || !state.spells[spellId]?.learned) return false;
return canAffordSpellCost(spell.cost, state.rawMana, state.elements);
},
addLog: (message: string) => {
set((state) => ({
log: [message, ...state.log.slice(0, 49)],
}));
},
tick: () => {
const state = get();
if (state.gameOver || state.paused) return;
// Compute unified effects (includes skill upgrades AND equipment enchantments)
const effects = getUnifiedEffects(state);
const maxMana = computeMaxMana(state, effects);
const baseRegen = computeRegen(state, effects);
// Time progression
let hour = state.hour + HOURS_PER_TICK;
let day = state.day;
if (hour >= 24) {
hour -= 24;
day += 1;
}
// Check for loop end
if (day > MAX_DAY) {
const insightGained = calcInsight(state);
set({
day,
hour,
gameOver: true,
victory: false,
loopInsight: insightGained,
log: [`⏰ The loop ends. Gained ${insightGained} Insight.`, ...state.log.slice(0, 49)],
});
return;
}
// Check for victory
if (state.maxFloorReached >= 100 && state.signedPacts.includes(100)) {
const insightGained = calcInsight(state) * 3;
set({
gameOver: true,
victory: true,
loopInsight: insightGained,
log: [`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`, ...state.log.slice(0, 49)],
});
return;
}
// Incursion
const incursionStrength = getIncursionStrength(day, hour);
// Meditation bonus tracking and regen calculation
let meditateTicks = state.meditateTicks;
let meditationMultiplier = 1;
if (state.currentAction === 'meditate') {
meditateTicks++;
meditationMultiplier = getMeditationBonus(meditateTicks, state.skills);
} else {
meditateTicks = 0;
}
// Calculate effective regen with incursion and meditation
const effectiveRegen = baseRegen * (1 - incursionStrength) * meditationMultiplier;
// Mana regeneration
let rawMana = Math.min(state.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
let totalManaGathered = state.totalManaGathered;
// Attunement mana conversion - convert raw mana to attunement's primary mana type
let elements = state.elements;
if (state.attunements) {
Object.entries(state.attunements).forEach(([attId, attState]) => {
if (!attState.active) return;
const attDef = ATTUNEMENTS_DEF[attId];
if (!attDef || !attDef.primaryManaType || attDef.conversionRate <= 0) return;
const elem = elements[attDef.primaryManaType];
if (!elem || !elem.unlocked) return;
// Get level-scaled conversion rate
const scaledConversionRate = getAttunementConversionRate(attId, attState.level || 1);
// Convert raw mana to primary type
const conversionAmount = scaledConversionRate * HOURS_PER_TICK;
const actualConversion = Math.min(conversionAmount, rawMana, elem.max - elem.current);
if (actualConversion > 0) {
rawMana -= actualConversion;
elements = {
...elements,
[attDef.primaryManaType]: {
...elem,
current: elem.current + actualConversion,
},
};
}
});
}
// Study progress
let currentStudyTarget = state.currentStudyTarget;
let skills = state.skills;
let skillProgress = state.skillProgress;
let spells = state.spells;
let log = state.log;
let unlockedEffects = state.unlockedEffects;
if (state.currentAction === 'study' && currentStudyTarget) {
const studySpeedMult = getStudySpeedMultiplier(skills);
const progressGain = HOURS_PER_TICK * studySpeedMult;
currentStudyTarget = {
...currentStudyTarget,
progress: currentStudyTarget.progress + progressGain,
};
// Check if study is complete
if (currentStudyTarget.progress >= currentStudyTarget.required) {
if (currentStudyTarget.type === 'skill') {
const skillId = currentStudyTarget.id;
const currentLevel = skills[skillId] || 0;
const newLevel = currentLevel + 1;
skills = { ...skills, [skillId]: newLevel };
skillProgress = { ...skillProgress, [skillId]: 0 };
log = [`${SKILLS_DEF[skillId]?.name} Lv.${newLevel} mastered!`, ...log.slice(0, 49)];
// Check if this skill unlocks effects (research skills)
const effectsToUnlock = EFFECT_RESEARCH_MAPPING[skillId];
if (effectsToUnlock && newLevel >= (SKILLS_DEF[skillId]?.max || 1)) {
const newEffects = effectsToUnlock.filter(e => !unlockedEffects.includes(e));
if (newEffects.length > 0) {
unlockedEffects = [...unlockedEffects, ...newEffects];
log = [`🔬 Unlocked ${newEffects.length} new enchantment effect(s)!`, ...log.slice(0, 49)];
}
}
// Special case: When enchanting skill reaches level 1, unlock mana bolt
if (skillId === 'enchanting' && newLevel >= 1) {
const enchantingEffects = ENCHANTING_UNLOCK_EFFECTS.filter(e => !unlockedEffects.includes(e));
if (enchantingEffects.length > 0) {
unlockedEffects = [...unlockedEffects, ...enchantingEffects];
log = [`✨ Enchantment design unlocked! Mana Bolt effect available.`, ...log.slice(0, 49)];
}
}
} else if (currentStudyTarget.type === 'spell') {
// Spells can no longer be studied directly - they come from equipment
// This branch is kept for backward compatibility but should not be used
const spellId = currentStudyTarget.id;
spells = { ...spells, [spellId]: { learned: true, level: 1, studyProgress: 0 } };
log = [`📖 ${SPELLS_DEF[spellId]?.name} learned!`, ...log.slice(0, 49)];
}
currentStudyTarget = null;
}
}
// Convert action - auto convert mana
if (state.currentAction === 'convert') {
const unlockedElements = Object.entries(elements)
.filter(([, e]) => e.unlocked && e.current < e.max);
if (unlockedElements.length > 0 && rawMana >= MANA_PER_ELEMENT) {
// Sort by space available (descending)
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 / MANA_PER_ELEMENT),
targetState.max - targetState.current
);
if (canConvert > 0) {
rawMana -= canConvert * MANA_PER_ELEMENT;
elements = {
...elements,
[targetId]: { ...targetState, current: targetState.current + canConvert }
};
}
}
}
// Combat - uses cast speed and spell casting
let { currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, castProgress } = state;
const floorElement = getFloorElement(currentFloor);
if (state.currentAction === 'climb') {
const spellId = state.activeSpell;
const spellDef = SPELLS_DEF[spellId];
if (spellDef) {
// Compute attack speed from quickCast skill and upgrades
const baseAttackSpeed = 1 + (state.skills.quickCast || 0) * 0.05;
const totalAttackSpeed = baseAttackSpeed * effects.attackSpeedMultiplier;
// Get spell cast speed (casts per hour, default 1)
const spellCastSpeed = spellDef.castSpeed || 1;
// Effective casts per tick = spellCastSpeed * totalAttackSpeed
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
// Accumulate cast progress
castProgress = (castProgress || 0) + progressPerTick;
// Process complete casts
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);
// 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 = (skills.spellEcho || 0) * 0.1;
if (Math.random() < echoChance) {
dmg *= 2;
log = [`✨ Spell Echo! Double damage!`, ...log.slice(0, 49)];
}
// 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);
// Reduce cast progress by 1 (one cast completed)
castProgress -= 1;
if (floorHP <= 0) {
// Floor cleared
const wasGuardian = GUARDIANS[currentFloor];
if (wasGuardian && !signedPacts.includes(currentFloor)) {
signedPacts = [...signedPacts, currentFloor];
log = [`⚔️ ${wasGuardian.name} defeated! Pact signed! (${wasGuardian.pact}x)`, ...log.slice(0, 49)];
} else if (!wasGuardian) {
if (currentFloor % 5 === 0) {
log = [`🏰 Floor ${currentFloor} cleared!`, ...log.slice(0, 49)];
}
}
currentFloor = currentFloor + 1;
if (currentFloor > 100) {
currentFloor = 100;
}
floorMaxHP = getFloorMaxHP(currentFloor);
floorHP = floorMaxHP;
maxFloorReached = Math.max(maxFloorReached, currentFloor);
// Reset cast progress on floor change
castProgress = 0;
}
}
} else {
// Not enough mana - pause casting (keep progress)
castProgress = castProgress || 0;
}
}
// Process crafting actions (design, prepare, enchant)
const craftingUpdates = processCraftingTick(
{
...state,
rawMana,
log,
currentFloor,
floorHP,
floorMaxHP,
maxFloorReached,
signedPacts,
castProgress,
incursionStrength,
currentStudyTarget,
skills,
skillProgress,
spells,
elements,
meditateTicks,
},
{ rawMana, log }
);
// Apply crafting updates
if (craftingUpdates.rawMana !== undefined) rawMana = craftingUpdates.rawMana;
if (craftingUpdates.log !== undefined) log = craftingUpdates.log;
if (craftingUpdates.currentAction !== undefined) {
set({
...craftingUpdates,
day,
hour,
rawMana,
meditateTicks,
totalManaGathered,
currentFloor,
floorHP,
floorMaxHP,
maxFloorReached,
signedPacts,
incursionStrength,
currentStudyTarget,
skills,
skillProgress,
spells,
elements,
log,
castProgress,
});
return;
}
set({
day,
hour,
rawMana,
meditateTicks,
totalManaGathered,
currentFloor,
floorHP,
floorMaxHP,
maxFloorReached,
signedPacts,
incursionStrength,
currentStudyTarget,
skills,
skillProgress,
spells,
elements,
unlockedEffects,
log,
castProgress,
...craftingUpdates,
});
},
gatherMana: () => {
const state = get();
// Compute unified effects for click mana
const effects = getUnifiedEffects(state);
let cm = computeClickMana(state, effects);
// Mana overflow bonus
const overflowBonus = 1 + (state.skills.manaOverflow || 0) * 0.25;
cm = Math.floor(cm * overflowBonus);
const max = computeMaxMana(state, effects);
set({
rawMana: Math.min(state.rawMana + cm, max),
totalManaGathered: state.totalManaGathered + cm,
});
},
setAction: (action: GameAction) => {
set((state) => ({
currentAction: action,
meditateTicks: action === 'meditate' ? state.meditateTicks : 0,
}));
},
setSpell: (spellId: string) => {
const state = get();
// Can only set learned spells
if (state.spells[spellId]?.learned) {
set({ activeSpell: spellId });
}
},
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;
}
}
// Check mana cost (with focused mind reduction)
const costMult = getStudyCostMultiplier(state.skills);
const cost = Math.floor(sk.base * (currentLevel + 1) * costMult);
if (state.rawMana < cost) return;
// Start studying
set({
rawMana: state.rawMana - cost,
currentAction: 'study',
currentStudyTarget: {
type: 'skill',
id: skillId,
progress: state.skillProgress[skillId] || 0,
required: sk.studyTime,
},
log: [`📚 Started studying ${sk.name}...`, ...state.log.slice(0, 49)],
});
},
startStudyingSpell: (spellId: string) => {
const state = get();
const sp = SPELLS_DEF[spellId];
if (!sp || state.spells[spellId]?.learned) return;
// Check mana cost (with focused mind reduction)
const costMult = getStudyCostMultiplier(state.skills);
const cost = Math.floor(sp.unlock * costMult);
if (state.rawMana < cost) return;
const studyTime = sp.studyTime || (sp.tier * 4); // Default study time based on tier
// Start studying
set({
rawMana: state.rawMana - cost,
currentAction: 'study',
currentStudyTarget: {
type: 'spell',
id: spellId,
progress: state.spells[spellId]?.studyProgress || 0,
required: studyTime,
},
spells: {
...state.spells,
[spellId]: { ...(state.spells[spellId] || { learned: false, level: 0 }), studyProgress: state.spells[spellId]?.studyProgress || 0 },
},
log: [`📚 Started studying ${sp.name}...`, ...state.log.slice(0, 49)],
});
},
cancelStudy: () => {
const state = get();
if (!state.currentStudyTarget) return;
// Knowledge retention bonus
const retentionBonus = 1 + (state.skills.knowledgeRetention || 0) * 0.2;
const savedProgress = Math.min(
state.currentStudyTarget.progress,
state.currentStudyTarget.required * retentionBonus
);
// Save progress
if (state.currentStudyTarget.type === 'skill') {
set({
currentStudyTarget: null,
currentAction: 'meditate',
skillProgress: {
...state.skillProgress,
[state.currentStudyTarget.id]: savedProgress,
},
log: [`📖 Study interrupted. Progress saved.`, ...state.log.slice(0, 49)],
});
} else if (state.currentStudyTarget.type === 'spell') {
set({
currentStudyTarget: null,
currentAction: 'meditate',
spells: {
...state.spells,
[state.currentStudyTarget.id]: {
...(state.spells[state.currentStudyTarget.id] || { learned: false, level: 0 }),
studyProgress: savedProgress,
},
},
log: [`📖 Study interrupted. Progress saved.`, ...state.log.slice(0, 49)],
});
}
},
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 },
},
log: [`${ELEMENTS[element].name} affinity unlocked!`, ...state.log.slice(0, 49)],
});
},
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 = getUnifiedEffects(state);
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,
log: [`🧪 Crafted ${outputAmount} ${ELEMENTS[target].name} mana!`, ...state.log.slice(0, 49)],
});
},
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,
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
let spellsToKeep: string[] = [];
if (state.skills.temporalMemory) {
const learnedSpells = Object.entries(state.spells)
.filter(([, s]) => s.learned)
.map(([id]) => id);
spellsToKeep = learnedSpells.slice(0, state.skills.temporalMemory);
}
const newState = makeInitial({
loopCount: state.loopCount + 1,
insight: total,
totalInsight: (state.totalInsight || 0) + insightGained,
prestigeUpgrades: state.prestigeUpgrades,
memories: state.memories,
skills: state.skills, // Keep skills through temporal memory for now
});
// Add kept spells
if (spellsToKeep.length > 0) {
spellsToKeep.forEach(spellId => {
newState.spells[spellId] = { learned: true, level: 1, studyProgress: 0 };
});
}
set(newState);
},
togglePause: () => {
set((state) => ({ paused: !state.paused }));
},
resetGame: () => {
// Clear localStorage and reset
localStorage.removeItem('mana-loop-storage');
set(makeInitial());
},
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) => ({
skillUpgrades: {
...state.skillUpgrades,
[skillId]: 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; // Max tier is 5
const nextTierSkillId = `${baseSkillId}_t${nextTier}`;
const currentLevel = state.skills[skillId] || 0;
set({
skillTiers: {
...state.skillTiers,
[baseSkillId]: nextTier,
},
skills: {
...state.skills,
[nextTierSkillId]: currentLevel, // Carry over level to new tier
[skillId]: 0, // Reset old tier
},
skillUpgrades: {
...state.skillUpgrades,
[nextTierSkillId]: [], // Start fresh with upgrades for new tier
},
log: [`🌟 ${SKILLS_DEF[baseSkillId]?.name || baseSkillId} evolved to Tier ${nextTier}!`, ...state.log.slice(0, 49)],
});
},
startParallelStudySkill: (skillId: string) => {
const state = get();
if (state.parallelStudyTarget) return; // Already have parallel study
if (!state.currentStudyTarget) return; // Need primary study
const sk = SKILLS_DEF[skillId];
if (!sk) return;
const currentLevel = state.skills[skillId] || 0;
if (currentLevel >= sk.max) return;
// Can't study same thing in parallel
if (state.currentStudyTarget.id === skillId) return;
set({
parallelStudyTarget: {
type: 'skill',
id: skillId,
progress: state.skillProgress[skillId] || 0,
required: sk.studyTime,
},
log: [`📚 Started parallel study of ${sk.name}... (50% speed)`, ...state.log.slice(0, 49)],
});
},
cancelParallelStudy: () => {
set((state) => {
if (!state.parallelStudyTarget) return state;
return {
parallelStudyTarget: null,
log: ['📖 Parallel study cancelled.', ...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 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 };
},
// ─── Crafting Actions (from crafting slice) ─────────────────────────────────
createEquipmentInstance: (typeId: string) => {
const type = EQUIPMENT_TYPES[typeId];
if (!type) return null;
const instanceId = `equip_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const instance: EquipmentInstance = {
instanceId,
typeId,
name: type.name,
enchantments: [],
usedCapacity: 0,
totalCapacity: type.baseCapacity,
rarity: 'common',
quality: 100,
};
set((state) => ({
equipmentInstances: {
...state.equipmentInstances,
[instanceId]: instance,
},
}));
return instanceId;
},
equipItem: (instanceId: string, slot: EquipmentSlot) => {
const state = get();
const instance = state.equipmentInstances[instanceId];
if (!instance) return false;
const type = EQUIPMENT_TYPES[instance.typeId];
if (!type) return false;
// Check if equipment can go in this slot
const validSlots = type.category === 'accessory'
? ['accessory1', 'accessory2']
: [type.slot];
if (!validSlots.includes(slot)) return false;
// Check if slot is occupied
const currentEquipped = state.equippedInstances[slot];
if (currentEquipped === instanceId) return true; // Already equipped here
// If this item is equipped elsewhere, unequip it first
let newEquipped = { ...state.equippedInstances };
for (const [s, id] of Object.entries(newEquipped)) {
if (id === instanceId) {
newEquipped[s as EquipmentSlot] = null;
}
}
// Equip to new slot
newEquipped[slot] = instanceId;
set(() => ({ equippedInstances: newEquipped }));
return true;
},
unequipItem: (slot: EquipmentSlot) => {
set((state) => ({
equippedInstances: {
...state.equippedInstances,
[slot]: null,
},
}));
},
deleteEquipmentInstance: (instanceId: string) => {
set((state) => {
// First unequip if equipped
let newEquipped = { ...state.equippedInstances };
for (const [slot, id] of Object.entries(newEquipped)) {
if (id === instanceId) {
newEquipped[slot as EquipmentSlot] = null;
}
}
// Remove from instances
const newInstances = { ...state.equipmentInstances };
delete newInstances[instanceId];
return {
equippedInstances: newEquipped,
equipmentInstances: newInstances,
};
});
},
startDesigningEnchantment: (name: string, equipmentTypeId: string, effects: DesignEffect[]) => {
const state = get();
// Check if player has enchanting skill
const enchantingLevel = state.skills.enchanting || 0;
if (enchantingLevel < 1) return false;
// Validate effects for equipment category
const type = EQUIPMENT_TYPES[equipmentTypeId];
if (!type) return false;
for (const eff of effects) {
const effectDef = ENCHANTMENT_EFFECTS[eff.effectId];
if (!effectDef) return false;
if (!effectDef.allowedEquipmentCategories.includes(type.category)) return false;
if (eff.stacks > effectDef.maxStacks) return false;
}
// Calculate capacity cost
const efficiencyBonus = (state.skills.efficientEnchant || 0) * 0.05;
const totalCapacityCost = effects.reduce((total, eff) =>
total + calculateEffectCapacityCost(eff.effectId, eff.stacks, efficiencyBonus), 0);
// Calculate design time
let designTime = 1;
for (const eff of effects) {
designTime += 0.5 * eff.stacks;
}
// Store pending design in designProgress
set(() => ({
currentAction: 'design',
designProgress: {
designId: `design_${Date.now()}`,
progress: 0,
required: designTime,
},
// Store design data temporarily
log: [`🔮 Designing enchantment: ${name}...`, ...state.log.slice(0, 49)],
}));
return true;
},
cancelDesign: () => {
set(() => ({
currentAction: 'meditate',
designProgress: null,
}));
},
saveDesign: (design: EnchantmentDesign) => {
set((state) => ({
enchantmentDesigns: [...state.enchantmentDesigns, design],
designProgress: null,
currentAction: 'meditate',
log: [`📜 Enchantment design "${design.name}" saved!`, ...state.log.slice(0, 49)],
}));
},
deleteDesign: (designId: string) => {
set((state) => ({
enchantmentDesigns: state.enchantmentDesigns.filter(d => d.id !== designId),
}));
},
startPreparing: (equipmentInstanceId: string) => {
const state = get();
const instance = state.equipmentInstances[equipmentInstanceId];
if (!instance) return false;
// Prep time: 2 hours base + 1 hour per 50 capacity
const prepTime = 2 + Math.floor(instance.totalCapacity / 50);
const manaCost = instance.totalCapacity * 10;
if (state.rawMana < manaCost) return false;
set(() => ({
currentAction: 'prepare',
preparationProgress: {
equipmentInstanceId,
progress: 0,
required: prepTime,
manaCostPaid: 0,
},
log: [`⚙️ Preparing ${instance.name} for enchanting...`, ...state.log.slice(0, 49)],
}));
return true;
},
cancelPreparation: () => {
set(() => ({
currentAction: 'meditate',
preparationProgress: null,
}));
},
startApplying: (equipmentInstanceId: string, designId: string) => {
const state = get();
const instance = state.equipmentInstances[equipmentInstanceId];
const design = state.enchantmentDesigns.find(d => d.id === designId);
if (!instance || !design) return false;
// Check capacity
if (instance.usedCapacity + design.totalCapacityUsed > instance.totalCapacity) {
return false;
}
// Application time: 2 hours base + 1 hour per effect stack
const applicationTime = 2 + design.effects.reduce((total, eff) => total + eff.stacks, 0);
const manaPerHour = 20 + design.effects.reduce((total, eff) => total + eff.stacks * 5, 0);
set(() => ({
currentAction: 'enchant',
applicationProgress: {
equipmentInstanceId,
designId,
progress: 0,
required: applicationTime,
manaPerHour,
paused: false,
manaSpent: 0,
},
log: [`✨ Applying "${design.name}" to ${instance.name}...`, ...state.log.slice(0, 49)],
}));
return true;
},
pauseApplication: () => {
set((state) => {
if (!state.applicationProgress) return {};
return {
applicationProgress: {
...state.applicationProgress,
paused: true,
},
};
});
},
resumeApplication: () => {
set((state) => {
if (!state.applicationProgress) return {};
return {
applicationProgress: {
...state.applicationProgress,
paused: false,
},
};
});
},
cancelApplication: () => {
set(() => ({
currentAction: 'meditate',
applicationProgress: null,
}));
},
disenchantEquipment: (instanceId: string) => {
const state = get();
const instance = state.equipmentInstances[instanceId];
if (!instance || instance.enchantments.length === 0) return;
const disenchantLevel = state.skills.disenchanting || 0;
const recoveryRate = 0.1 + disenchantLevel * 0.2; // 10% base + 20% per level
let totalRecovered = 0;
for (const ench of instance.enchantments) {
totalRecovered += Math.floor(ench.actualCost * recoveryRate);
}
set((state) => ({
rawMana: state.rawMana + totalRecovered,
equipmentInstances: {
...state.equipmentInstances,
[instanceId]: {
...instance,
enchantments: [],
usedCapacity: 0,
},
},
log: [`✨ Disenchanted ${instance.name}, recovered ${totalRecovered} mana.`, ...state.log.slice(0, 49)],
}));
},
getEquipmentSpells: () => {
const state = get();
const spells: string[] = [];
for (const instanceId of Object.values(state.equippedInstances)) {
if (!instanceId) continue;
const instance = state.equipmentInstances[instanceId];
if (!instance) continue;
for (const ench of instance.enchantments) {
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) {
spells.push(effectDef.effect.spellId);
}
}
}
return [...new Set(spells)]; // Remove duplicates
},
getEquipmentEffects: () => {
const state = get();
const effects: Record<string, number> = {};
for (const instanceId of Object.values(state.equippedInstances)) {
if (!instanceId) continue;
const instance = state.equipmentInstances[instanceId];
if (!instance) continue;
for (const ench of instance.enchantments) {
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
if (!effectDef) continue;
if (effectDef.effect.type === 'bonus' && effectDef.effect.stat && effectDef.effect.value) {
effects[effectDef.effect.stat] = (effects[effectDef.effect.stat] || 0) + effectDef.effect.value * ench.stacks;
}
}
}
return effects;
},
getAvailableCapacity: (instanceId: string) => {
const state = get();
const instance = state.equipmentInstances[instanceId];
if (!instance) return 0;
return instance.totalCapacity - instance.usedCapacity;
},
// Attunement XP and leveling
addAttunementXP: (attunementId: string, amount: number) => {
const state = get();
const attState = state.attunements[attunementId];
if (!attState?.active) return;
let newXP = attState.experience + amount;
let newLevel = attState.level;
// Check for level ups
while (newLevel < MAX_ATTUNEMENT_LEVEL) {
const xpNeeded = getAttunementXPForLevel(newLevel + 1);
if (newXP >= xpNeeded) {
newXP -= xpNeeded;
newLevel++;
} else {
break;
}
}
// Cap XP at max level
if (newLevel >= MAX_ATTUNEMENT_LEVEL) {
newXP = 0;
}
set({
attunements: {
...state.attunements,
[attunementId]: {
...attState,
level: newLevel,
experience: newXP,
},
},
log: newLevel > attState.level
? [`🌟 ${attunementId} attunement reached Level ${newLevel}!`, ...state.log.slice(0, 49)]
: state.log,
});
},
// Debug functions
debugUnlockAttunement: (attunementId: string) => {
const state = get();
const def = ATTUNEMENTS_DEF[attunementId];
if (!def) return;
set({
attunements: {
...state.attunements,
[attunementId]: {
id: attunementId,
active: true,
level: 1,
experience: 0,
},
},
// Unlock the primary mana type if applicable
elements: def.primaryManaType && state.elements[def.primaryManaType]
? {
...state.elements,
[def.primaryManaType]: {
...state.elements[def.primaryManaType],
unlocked: true,
},
}
: state.elements,
log: [`🔓 Debug: Unlocked ${def.name} attunement!`, ...state.log.slice(0, 49)],
});
},
debugAddElementalMana: (element: string, amount: number) => {
const state = get();
const elem = state.elements[element];
if (!elem?.unlocked) return;
set({
elements: {
...state.elements,
[element]: {
...elem,
current: Math.min(elem.current + amount, elem.max * 10), // Allow overflow
},
},
});
},
debugSetTime: (day: number, hour: number) => {
set({
day,
hour,
incursionStrength: getIncursionStrength(day, hour),
});
},
debugAddAttunementXP: (attunementId: string, amount: number) => {
const state = get();
const attState = state.attunements[attunementId];
if (!attState) return;
let newXP = attState.experience + amount;
let newLevel = attState.level;
while (newLevel < MAX_ATTUNEMENT_LEVEL) {
const xpNeeded = getAttunementXPForLevel(newLevel + 1);
if (newXP >= xpNeeded) {
newXP -= xpNeeded;
newLevel++;
} else {
break;
}
}
set({
attunements: {
...state.attunements,
[attunementId]: {
...attState,
level: newLevel,
experience: newXP,
},
},
});
},
debugSetFloor: (floor: number) => {
const state = get();
set({
currentFloor: floor,
floorHP: getFloorMaxHP(floor),
floorMaxHP: getFloorMaxHP(floor),
maxFloorReached: Math.max(state.maxFloorReached, floor),
});
},
}),
{
name: 'mana-loop-storage',
version: 2,
migrate: (persistedState: unknown, version: number) => {
const state = persistedState as Record<string, unknown>;
// Migration from version 0/1 to version 2 - add missing fields
if (version < 2) {
return {
...state,
castProgress: state.castProgress ?? 0,
skillUpgrades: state.skillUpgrades ?? {},
skillTiers: state.skillTiers ?? {},
parallelStudyTarget: state.parallelStudyTarget ?? null,
};
}
return state;
},
partialize: (state) => ({
day: state.day,
hour: state.hour,
loopCount: state.loopCount,
rawMana: state.rawMana,
meditateTicks: state.meditateTicks,
totalManaGathered: state.totalManaGathered,
attunements: state.attunements,
elements: state.elements,
currentFloor: state.currentFloor,
floorHP: state.floorHP,
floorMaxHP: state.floorMaxHP,
maxFloorReached: state.maxFloorReached,
signedPacts: state.signedPacts,
activeSpell: state.activeSpell,
currentAction: state.currentAction,
castProgress: state.castProgress,
combo: state.combo,
spells: state.spells,
skills: state.skills,
skillProgress: state.skillProgress,
skillUpgrades: state.skillUpgrades,
skillTiers: state.skillTiers,
currentStudyTarget: state.currentStudyTarget,
parallelStudyTarget: state.parallelStudyTarget,
achievements: state.achievements,
totalSpellsCast: state.totalSpellsCast,
totalDamageDealt: state.totalDamageDealt,
totalCraftsCompleted: state.totalCraftsCompleted,
insight: state.insight,
totalInsight: state.totalInsight,
prestigeUpgrades: state.prestigeUpgrades,
memorySlots: state.memorySlots,
memories: state.memories,
log: state.log,
// Equipment system
equippedInstances: state.equippedInstances,
equipmentInstances: state.equipmentInstances,
enchantmentDesigns: state.enchantmentDesigns,
designProgress: state.designProgress,
preparationProgress: state.preparationProgress,
applicationProgress: state.applicationProgress,
equipmentCraftingProgress: state.equipmentCraftingProgress,
unlockedEffects: state.unlockedEffects,
// Loot inventory
lootInventory: state.lootInventory,
}),
}
)
);
// ─── Game Loop Hook ───────────────────────────────────────────────────────────
export function useGameLoop() {
const tick = useGameStore((s) => s.tick);
// Use useEffect in the component that uses this hook
return {
start: () => {
const interval = setInterval(tick, TICK_MS);
return () => clearInterval(interval);
},
};
}