Files
Mana-Loop/src/lib/game/store.ts
zhipu 3ce0bea13f
Some checks failed
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m13s
feat: Implement study special effects
- MENTAL_CLARITY: +10% study speed when mana > 75%
- STUDY_RUSH: First hour of study is 2x speed
- STUDY_MOMENTUM: +5% study speed per consecutive hour (max +50%)
- KNOWLEDGE_ECHO: 10% chance for instant study progress
- STUDY_REFUND: 25% mana back on study completion
- Add tracking for studyStartedAt and consecutiveStudyHours
- Reset tracking when study completes or stops
2026-03-26 13:38:55 +00:00

1781 lines
63 KiB
TypeScript
Executable File

// ─── Game Store ───────────────────────────────────────────────────────────────
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { GameState, GameAction, StudyTarget, SkillUpgradeChoice, EquipmentSlot, EnchantmentDesign, DesignEffect, LootInventory } 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,
ELEMENT_OPPOSITES,
EFFECT_RESEARCH_MAPPING,
BASE_UNLOCKED_EFFECTS,
ENCHANTING_UNLOCK_EFFECTS,
} from './constants';
import { hasSpecial, SPECIAL_EFFECTS, computeDynamicRegen } from './upgrade-effects';
import { getUnifiedEffects } from './effects';
import { SKILL_EVOLUTION_PATHS } from './skill-evolution';
import {
createStartingEquipment,
processCraftingTick,
getSpellsFromEquipment,
type CraftingActions
} from './crafting-slice';
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
import {
createFamiliarSlice,
processFamiliarTick,
grantStartingFamiliar,
type FamiliarActions,
type FamiliarBonuses,
DEFAULT_FAMILIAR_BONUSES,
} from './familiar-slice';
import {
createNavigationSlice,
type NavigationActions,
} from './navigation-slice';
import {
createStudySlice,
type StudyActions,
} from './study-slice';
import { rollLootDrops, LOOT_DROPS } from './data/loot-drops';
import { CRAFTING_RECIPES, canCraftRecipe } from './data/crafting-recipes';
import { EQUIPMENT_TYPES } from './data/equipment';
import type { EquipmentInstance } from './types';
// Import computed stats and utility functions from computed-stats.ts
import {
DEFAULT_EFFECTS,
fmt,
fmtDec,
getFloorMaxHP,
getFloorElement,
getActiveEquipmentSpells,
getEffectiveSkillLevel,
computeMaxMana,
computeElementMax,
computeRegen,
computeEffectiveRegen,
computeClickMana,
calcDamage,
calcInsight,
getMeditationBonus,
getIncursionStrength,
canAffordSpellCost,
deductSpellCost,
getTotalDPS,
getDamageBreakdown,
} from './computed-stats';
// Re-export formatting functions and computed stats for backward compatibility
export {
fmt,
fmtDec,
getFloorElement,
computeMaxMana,
computeRegen,
computeClickMana,
calcDamage,
getMeditationBonus,
getIncursionStrength,
canAffordSpellCost,
getFloorMaxHP,
getActiveEquipmentSpells,
getTotalDPS,
getDamageBreakdown,
};
// ─── Local Helper Functions ────────────────────────────────────────────────────
// 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
}
// ─── 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,
skillUpgrades: overrides.skillUpgrades || {},
skillTiers: overrides.skillTiers || {}
});
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 };
}
}
return {
day: 1,
hour: 0,
loopCount: overrides.loopCount || 0,
gameOver: false,
victory: false,
paused: false,
rawMana: startRawMana,
meditateTicks: 0,
totalManaGathered: overrides.totalManaGathered || 0,
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,
// Floor Navigation
climbDirection: 'up',
clearedFloors: {},
lastClearedFloor: null,
spells: startSpells,
skills: overrides.skills || {},
skillProgress: {},
skillUpgrades: overrides.skillUpgrades || {},
skillTiers: overrides.skillTiers || {},
parallelStudyTarget: null,
studyStartedAt: null,
consecutiveStudyHours: 0,
lastStudyCost: 0,
// New equipment system
equippedInstances: startingEquipment.equippedInstances,
equipmentInstances: startingEquipment.equipmentInstances,
enchantmentDesigns: [],
designProgress: null,
preparationProgress: null,
applicationProgress: 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: {},
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,
// Combo System
combo: {
count: 0,
multiplier: 1,
lastCastTime: 0,
decayTimer: 0,
maxCombo: 0,
elementChain: [],
},
totalTicks: 0,
consecutiveHits: 0,
// Loot System
lootInventory: {
materials: {},
essence: {},
blueprints: [],
},
lootDropsToday: 0,
// Equipment Crafting Progress
equipmentCraftingProgress: null,
// Achievements
achievements: {
unlocked: [],
progress: {},
},
totalDamageDealt: 0,
totalSpellsCast: 0,
totalCraftsCompleted: 0,
// Familiars
familiars: grantStartingFamiliar(),
activeFamiliarSlots: 1,
familiarSummonProgress: 0,
totalFamiliarXpEarned: 0,
log: ['✨ The loop begins. You start with a Basic Staff (Mana Bolt) and civilian clothes. A friendly Mana Wisp floats nearby. Gather your strength, mage.'],
loopInsight: 0,
};
}
// ─── Game Store ───────────────────────────────────────────────────────────────
interface GameStore extends GameState, CraftingActions, FamiliarActions, NavigationActions, StudyActions {
// Actions
tick: () => void;
gatherMana: () => void;
setAction: (action: GameAction) => void;
setSpell: (spellId: string) => 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;
// Inventory Management
updateLootInventory: (inventory: LootInventory) => 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(),
...createFamiliarSlice(set, get),
...createNavigationSlice(set, get),
...createStudySlice(set, get),
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;
// Track hits this tick for BATTLE_FURY
let hitsThisTick = 0;
// Compute unified effects (includes skill upgrades AND equipment enchantments)
const effects = getUnifiedEffects(state);
// Compute familiar bonuses
const familiarBonuses = state.familiars.length > 0
? (() => {
const slice = createFamiliarSlice(set, get);
return slice.getActiveFamiliarBonuses();
})()
: DEFAULT_FAMILIAR_BONUSES;
const maxMana = computeMaxMana(state, effects);
const baseRegen = computeRegen(state, effects) + familiarBonuses.manaRegenBonus;
// 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 dynamic special effects
// computeDynamicRegen handles: Mana Cascade, Mana Torrent, Desperate Wells, Steady Stream
let effectiveRegen = computeDynamicRegen(
effects,
baseRegen,
maxMana,
state.rawMana,
incursionStrength
) * meditationMultiplier;
// Mana regeneration
let rawMana = Math.min(state.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
let totalManaGathered = state.totalManaGathered;
// Familiar auto-gather and auto-convert
let elements = state.elements;
if (familiarBonuses.autoGatherRate > 0 || familiarBonuses.autoConvertRate > 0) {
const familiarUpdates = processFamiliarTick(
{ rawMana, elements, totalManaGathered, familiars: state.familiars, activeFamiliarSlots: state.activeFamiliarSlots },
familiarBonuses
);
rawMana = Math.min(familiarUpdates.rawMana, maxMana);
elements = familiarUpdates.elements;
totalManaGathered = familiarUpdates.totalManaGathered;
}
// 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;
let consecutiveStudyHours = state.consecutiveStudyHours || 0;
let studyStartedAt = state.studyStartedAt;
if (state.currentAction === 'study' && currentStudyTarget) {
// Track when study started (for STUDY_RUSH)
if (studyStartedAt === null) {
studyStartedAt = newTotalTicks;
}
// Calculate study speed with all bonuses
let studySpeedMult = getStudySpeedMultiplier(skills);
// MENTAL_CLARITY: +10% study speed when mana > 75%
if (hasSpecial(effects, SPECIAL_EFFECTS.MENTAL_CLARITY) && state.rawMana >= maxMana * 0.75) {
studySpeedMult *= 1.1;
}
// STUDY_RUSH: First hour of study is 2x speed
const hoursStudied = (newTotalTicks - studyStartedAt) * HOURS_PER_TICK;
if (hasSpecial(effects, SPECIAL_EFFECTS.STUDY_RUSH) && hoursStudied < 1) {
studySpeedMult *= 2;
}
// STUDY_MOMENTUM: +5% study speed per consecutive hour (max +50%)
if (hasSpecial(effects, SPECIAL_EFFECTS.STUDY_MOMENTUM)) {
const momentumBonus = Math.min(0.5, consecutiveStudyHours * 0.05);
studySpeedMult *= 1 + momentumBonus;
}
const progressGain = HOURS_PER_TICK * studySpeedMult;
// KNOWLEDGE_ECHO: 10% instant study chance
let instantProgress = 0;
if (hasSpecial(effects, SPECIAL_EFFECTS.KNOWLEDGE_ECHO) && Math.random() < 0.1) {
instantProgress = currentStudyTarget.required - currentStudyTarget.progress;
log = [`⚡ Knowledge Echo! Instant study progress!`, ...log.slice(0, 49)];
}
currentStudyTarget = {
...currentStudyTarget,
progress: currentStudyTarget.progress + progressGain + instantProgress,
};
// Increment consecutive study hours
consecutiveStudyHours += HOURS_PER_TICK;
// Check if study is complete
if (currentStudyTarget.progress >= currentStudyTarget.required) {
// STUDY_REFUND: 25% mana back on study complete
if (hasSpecial(effects, SPECIAL_EFFECTS.STUDY_REFUND) && state.lastStudyCost > 0) {
const refund = Math.floor(state.lastStudyCost * 0.25);
rawMana = Math.min(rawMana + refund, maxMana);
log = [`💰 Study Refund! Recovered ${refund} mana!`, ...log.slice(0, 49)];
}
// Reset study tracking
studyStartedAt = null;
consecutiveStudyHours = 0;
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;
}
} else {
// Reset consecutive study hours when not studying
consecutiveStudyHours = 0;
}
// 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 - MULTI-SPELL casting from all equipped weapons
let { currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, equipmentSpellStates, combo, totalTicks, lootInventory, achievements, totalDamageDealt, totalSpellsCast } = state;
const floorElement = getFloorElement(currentFloor);
// Increment total ticks
const newTotalTicks = totalTicks + 1;
// Combo decay - decay combo when not climbing or when decay timer expires
let newCombo = { ...combo };
if (state.currentAction !== 'climb') {
// Rapidly decay combo when not climbing
newCombo.count = Math.max(0, newCombo.count - 5);
newCombo.multiplier = 1 + newCombo.count * 0.02;
} else if (newCombo.count > 0) {
// Slow decay while climbing but not casting
newCombo.decayTimer--;
if (newCombo.decayTimer <= 0) {
newCombo.count = Math.max(0, newCombo.count - 2);
newCombo.multiplier = 1 + newCombo.count * 0.02;
newCombo.decayTimer = 10;
}
}
if (state.currentAction === 'climb') {
// Get all spells from equipped caster weapons
const activeSpells = getActiveEquipmentSpells(state.equippedInstances, state.equipmentInstances);
// Initialize spell states if needed
if (!equipmentSpellStates) {
equipmentSpellStates = [];
}
// Ensure we have state for all active spells
for (const { spellId, equipmentId } of activeSpells) {
if (!equipmentSpellStates.find(s => s.spellId === spellId && s.sourceEquipment === equipmentId)) {
equipmentSpellStates.push({
spellId,
sourceEquipment: equipmentId,
castProgress: 0,
});
}
}
// Remove states for spells that are no longer equipped
equipmentSpellStates = equipmentSpellStates.filter(es =>
activeSpells.some(as => as.spellId === es.spellId && as.equipmentId === es.sourceEquipment)
);
// Compute attack speed from quickCast skill and upgrades
const baseAttackSpeed = 1 + (state.skills.quickCast || 0) * 0.05;
const totalAttackSpeed = baseAttackSpeed * effects.attackSpeedMultiplier * familiarBonuses.castSpeedMultiplier;
// Process each active spell
for (const { spellId, equipmentId } of activeSpells) {
const spellDef = SPELLS_DEF[spellId];
if (!spellDef) continue;
// Get or create spell state
let spellState = equipmentSpellStates.find(s => s.spellId === spellId && s.sourceEquipment === equipmentId);
if (!spellState) continue;
// 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
spellState = { ...spellState, castProgress: spellState.castProgress + progressPerTick };
// Process complete casts
while (spellState.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;
// Increment spell cast counter
totalSpellsCast++;
// ─── Combo System ───
// Build combo on each cast
newCombo.count = Math.min(100, newCombo.count + 1);
newCombo.lastCastTime = newTotalTicks;
newCombo.decayTimer = 10; // Reset decay timer
newCombo.maxCombo = Math.max(newCombo.maxCombo, newCombo.count);
// Track element chain
const spellElement = spellDef.elem;
newCombo.elementChain = [...newCombo.elementChain.slice(-2), spellElement];
// Calculate combo multiplier
let comboMult = 1 + newCombo.count * 0.02; // +2% per combo
// Element chain bonus: +25% if last 3 spells were different elements
const uniqueElements = new Set(newCombo.elementChain);
if (newCombo.elementChain.length === 3 && uniqueElements.size === 3) {
comboMult += 0.25;
// Log elemental chain occasionally
if (newCombo.count % 10 === 0) {
log = [`🌈 Elemental Chain! (${newCombo.elementChain.join(' → ')})`, ...log.slice(0, 49)];
}
}
newCombo.multiplier = Math.min(3.0, comboMult);
// Calculate damage
let dmg = calcDamage(state, spellId, floorElement);
// Apply combo multiplier FIRST
dmg *= newCombo.multiplier;
// Apply upgrade damage multipliers and bonuses
dmg = dmg * effects.baseDamageMultiplier + effects.baseDamageBonus;
// Overpower: +50% damage when mana above 80%
if (hasSpecial(effects, SPECIAL_EFFECTS.OVERPOWER) && rawMana >= maxMana * 0.8) {
dmg *= 1.5;
}
// Berserker: +50% damage when below 50% mana
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
dmg *= 1.5;
}
// BATTLE_FURY: +10% damage per consecutive hit (resets on floor change)
const currentConsecutiveHits = state.consecutiveHits + hitsThisTick;
if (hasSpecial(effects, SPECIAL_EFFECTS.BATTLE_FURY)) {
dmg *= 1 + (currentConsecutiveHits * 0.1);
}
// COMBO_MASTER: Every 5th attack deals 3x damage
const totalHitsThisLoop = state.totalTicks || 0;
const isFifthHit = ((totalHitsThisLoop + hitsThisTick) % 5) === 4; // 5th, 10th, 15th, etc.
if (hasSpecial(effects, SPECIAL_EFFECTS.COMBO_MASTER) && isFifthHit) {
dmg *= 3;
log = [`💥 Combo Master! Triple damage!`, ...log.slice(0, 49)];
}
// Track hits for BATTLE_FURY
hitsThisTick++;
// Familiar bonuses
dmg *= familiarBonuses.damageMultiplier;
dmg *= familiarBonuses.elementalDamageMultiplier;
// Familiar crit chance bonus
if (Math.random() < familiarBonuses.critChanceBonus / 100) {
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! ${spellDef.name} deals 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);
}
// Familiar lifesteal
if (familiarBonuses.lifeStealPercent > 0) {
const healAmount = dmg * (familiarBonuses.lifeStealPercent / 100);
rawMana = Math.min(rawMana + healAmount, maxMana);
}
// Track total damage for achievements
totalDamageDealt += dmg;
// Apply damage
floorHP = Math.max(0, floorHP - dmg);
// Reduce cast progress by 1 (one cast completed)
spellState = { ...spellState, castProgress: spellState.castProgress - 1 };
if (floorHP <= 0) {
// Floor cleared
const wasGuardian = GUARDIANS[currentFloor];
const clearedFloors = state.clearedFloors;
const climbDirection = state.climbDirection;
// Mark this floor as cleared (needs respawn if we leave and return)
clearedFloors[currentFloor] = true;
const lastClearedFloor = currentFloor;
// ─── Loot Drop System ───
const lootDrops = rollLootDrops(currentFloor, !!wasGuardian, 0);
for (const { drop, amount } of lootDrops) {
if (drop.type === 'material') {
lootInventory.materials[drop.id] = (lootInventory.materials[drop.id] || 0) + amount;
log = [`💎 Found: ${drop.name}!`, ...log.slice(0, 49)];
} else if (drop.type === 'essence' && drop.id) {
// Extract element from essence drop id (e.g., 'fireEssenceDrop' -> 'fire')
const element = drop.id.replace('EssenceDrop', '');
if (elements[element]) {
const gain = Math.min(amount, elements[element].max - elements[element].current);
elements[element] = { ...elements[element], current: elements[element].current + gain };
log = [`✨ Gained ${gain} ${element} essence!`, ...log.slice(0, 49)];
}
} else if (drop.type === 'gold') {
rawMana += amount;
log = [`💫 Gained ${amount} mana from ${drop.name}!`, ...log.slice(0, 49)];
} else if (drop.type === 'blueprint') {
if (!lootInventory.blueprints.includes(drop.id)) {
lootInventory.blueprints.push(drop.id);
log = [`📜 Discovered: ${drop.name}!`, ...log.slice(0, 49)];
}
}
}
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)];
}
}
// ADRENALINE_RUSH: Restore 5% mana on floor clear
if (hasSpecial(effects, SPECIAL_EFFECTS.ADRENALINE_RUSH)) {
const adrenalineHeal = maxMana * 0.05;
rawMana = Math.min(rawMana + adrenalineHeal, maxMana);
log = [`⚡ Adrenaline Rush! Restored ${Math.floor(adrenalineHeal)} mana!`, ...log.slice(0, 49)];
}
// Move to next floor based on direction
const nextFloor = climbDirection === 'up'
? Math.min(currentFloor + 1, 100)
: Math.max(currentFloor - 1, 1);
currentFloor = nextFloor;
floorMaxHP = getFloorMaxHP(currentFloor);
// Check if this floor was previously cleared (has enemies respawned?)
// Floors respawn when you leave them and come back
const floorWasCleared = clearedFloors[currentFloor];
if (floorWasCleared) {
// Floor has respawned - reset it but mark as uncleared
delete clearedFloors[currentFloor];
}
floorHP = floorMaxHP;
maxFloorReached = Math.max(maxFloorReached, currentFloor);
// Reset combo on floor change (partial reset - keep 50%)
newCombo.count = Math.floor(newCombo.count * 0.5);
newCombo.multiplier = 1 + newCombo.count * 0.02;
newCombo.elementChain = [];
// Reset consecutive hits on floor change
set((s) => ({ ...s, consecutiveHits: 0 }));
// Reset ALL spell progress on floor change
equipmentSpellStates = equipmentSpellStates.map(s => ({ ...s, castProgress: 0 }));
spellState = { ...spellState, castProgress: 0 };
// Update clearedFloors in the state
set((s) => ({ ...s, clearedFloors, lastClearedFloor }));
break; // Exit the while loop - new floor
}
}
// Update the spell state in the array
equipmentSpellStates = equipmentSpellStates.map(s =>
(s.spellId === spellId && s.sourceEquipment === equipmentId) ? spellState : s
);
}
}
// Update combo state
combo = newCombo;
// Process crafting actions (design, prepare, enchant)
const craftingUpdates = processCraftingTick(
{
...state,
rawMana,
log,
currentFloor,
floorHP,
floorMaxHP,
maxFloorReached,
signedPacts,
equipmentSpellStates,
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,
equipmentSpellStates,
combo,
totalTicks: newTotalTicks,
lootInventory,
achievements,
totalDamageDealt,
totalSpellsCast,
});
return;
}
// Grant XP to active familiars based on activity
let familiars = state.familiars;
if (familiars.some(f => f.active)) {
let xpGain = 0;
let xpSource: 'combat' | 'gather' | 'meditate' | 'study' | 'time' = 'time';
if (state.currentAction === 'climb') {
xpGain = 2 * HOURS_PER_TICK; // 2 XP per hour in combat
xpSource = 'combat';
} else if (state.currentAction === 'meditate') {
xpGain = 1 * HOURS_PER_TICK;
xpSource = 'meditate';
} else if (state.currentAction === 'study') {
xpGain = 1.5 * HOURS_PER_TICK;
xpSource = 'study';
} else {
xpGain = 0.5 * HOURS_PER_TICK; // Passive XP
}
// Update familiar XP and bond
familiars = familiars.map(f => {
if (!f.active) return f;
const bondMultiplier = 1 + (f.bond / 100);
const xpGained = Math.floor(xpGain * bondMultiplier);
const newXp = f.experience + xpGained;
const newBond = Math.min(100, f.bond + 0.02); // Slow bond gain
return { ...f, experience: newXp, bond: newBond };
});
}
set({
day,
hour,
rawMana,
meditateTicks,
totalManaGathered,
currentFloor,
floorHP,
floorMaxHP,
maxFloorReached,
signedPacts,
incursionStrength,
currentStudyTarget,
skills,
skillProgress,
spells,
elements,
unlockedEffects,
log,
equipmentSpellStates,
combo,
totalTicks: newTotalTicks,
lootInventory,
achievements,
totalDamageDealt,
totalSpellsCast,
familiars,
consecutiveHits: state.currentAction === 'climb' ? state.consecutiveHits + hitsThisTick : state.consecutiveHits,
consecutiveStudyHours,
studyStartedAt,
...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);
// MANA_ECHO: 10% chance to gain double mana from clicks
let echoTriggered = false;
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_ECHO) && Math.random() < 0.1) {
cm *= 2;
echoTriggered = true;
}
const max = computeMaxMana(state, effects);
const newRawMana = Math.min(state.rawMana + cm, max);
if (echoTriggered) {
set({
rawMana: newRawMana,
totalManaGathered: state.totalManaGathered + cm,
log: [`✨ Mana Echo! Gained ${cm} mana (doubled)!`, ...state.log.slice(0, 49)],
});
} else {
set({
rawMana: newRawMana,
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 });
}
},
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;
// Check for EMERGENCY_RESERVE before creating new state
const effects = getUnifiedEffects(state);
const maxMana = computeMaxMana(state, effects);
const hasEmergencyReserve = hasSpecial(effects, SPECIAL_EFFECTS.EMERGENCY_RESERVE);
// 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 };
});
}
// EMERGENCY_RESERVE: Keep 10% of max mana when starting new loop
if (hasEmergencyReserve) {
const reserveMana = Math.floor(maxMana * 0.1);
newState.rawMana = reserveMana;
newState.log = [`💫 Emergency Reserve preserved ${reserveMana} mana!`, ...newState.log.slice(0, 49)];
}
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)],
});
},
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,
};
});
},
updateLootInventory: (inventory: LootInventory) => {
set({ lootInventory: inventory });
},
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;
},
// ─── Equipment Crafting (from blueprints) ───────────────────────────────────
startCraftingEquipment: (blueprintId: string) => {
const state = get();
const recipe = CRAFTING_RECIPES[blueprintId];
if (!recipe) return false;
// Check if player has the blueprint
if (!state.lootInventory.blueprints.includes(blueprintId)) return false;
// Check materials and mana
const { canCraft } = canCraftRecipe(
recipe,
state.lootInventory.materials,
state.rawMana
);
if (!canCraft) return false;
// Deduct materials
const newMaterials = { ...state.lootInventory.materials };
for (const [matId, amount] of Object.entries(recipe.materials)) {
newMaterials[matId] = (newMaterials[matId] || 0) - amount;
if (newMaterials[matId] <= 0) {
delete newMaterials[matId];
}
}
// Start crafting progress
set((state) => ({
lootInventory: {
...state.lootInventory,
materials: newMaterials,
},
rawMana: state.rawMana - recipe.manaCost,
currentAction: 'craft',
equipmentCraftingProgress: {
blueprintId,
equipmentTypeId: recipe.equipmentTypeId,
progress: 0,
required: recipe.craftTime,
manaSpent: recipe.manaCost,
},
log: [`🔨 Started crafting ${recipe.name}...`, ...state.log.slice(0, 49)],
}));
return true;
},
cancelEquipmentCrafting: () => {
set((state) => {
const progress = state.equipmentCraftingProgress;
if (!progress) return {};
const recipe = CRAFTING_RECIPES[progress.blueprintId];
if (!recipe) return { currentAction: 'meditate', equipmentCraftingProgress: null };
// Refund 50% of mana
const manaRefund = Math.floor(progress.manaSpent * 0.5);
return {
currentAction: 'meditate',
equipmentCraftingProgress: null,
rawMana: state.rawMana + manaRefund,
log: [`🚫 Crafting cancelled. Refunded ${manaRefund} mana.`, ...state.log.slice(0, 49)],
};
});
},
deleteMaterial: (materialId: string, amount: number) => {
set((state) => {
const currentAmount = state.lootInventory.materials[materialId] || 0;
const newAmount = Math.max(0, currentAmount - amount);
const newMaterials = { ...state.lootInventory.materials };
if (newAmount <= 0) {
delete newMaterials[materialId];
} else {
newMaterials[materialId] = newAmount;
}
const dropName = LOOT_DROPS[materialId]?.name || materialId;
return {
lootInventory: {
...state.lootInventory,
materials: newMaterials,
},
log: [`🗑️ Deleted ${amount}x ${dropName}.`, ...state.log.slice(0, 49)],
};
});
},
}),
{
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,
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,
climbDirection: state.climbDirection,
clearedFloors: state.clearedFloors,
lastClearedFloor: state.lastClearedFloor,
spells: state.spells,
skills: state.skills,
skillProgress: state.skillProgress,
skillUpgrades: state.skillUpgrades,
skillTiers: state.skillTiers,
currentStudyTarget: state.currentStudyTarget,
parallelStudyTarget: state.parallelStudyTarget,
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,
// Loot system
lootInventory: state.lootInventory,
lootDropsToday: state.lootDropsToday,
// Achievements
achievements: state.achievements,
totalDamageDealt: state.totalDamageDealt,
totalSpellsCast: state.totalSpellsCast,
totalCraftsCompleted: state.totalCraftsCompleted,
// Familiars
familiars: state.familiars,
activeFamiliarSlots: state.activeFamiliarSlots,
familiarSummonProgress: state.familiarSummonProgress,
totalFamiliarXpEarned: state.totalFamiliarXpEarned,
}),
}
)
);
// ─── 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);
},
};
}