All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 3m4s
Store refactoring (2138 → 1651 lines, 23% reduction): - Extract computed-stats.ts with 18 utility functions - Extract navigation-slice.ts for floor navigation actions - Extract study-slice.ts for study-related actions - Move fmt/fmtDec to computed-stats, re-export from formatting Page refactoring (2554 → 1695 lines, 34% reduction): - Use existing SpireTab component instead of inline render - Extract ActionButtons component - Extract CalendarDisplay component - Extract CraftingProgress component - Extract StudyProgress component - Extract ManaDisplay component - Extract TimeDisplay component - Create tabs/index.ts for cleaner exports This improves code organization and makes the codebase more maintainable.
1652 lines
58 KiB
TypeScript
Executable File
1652 lines
58 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 } 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,
|
|
} 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,
|
|
};
|
|
|
|
// ─── 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,
|
|
|
|
// 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,
|
|
|
|
// 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;
|
|
|
|
// 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 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;
|
|
|
|
// 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;
|
|
|
|
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 - 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;
|
|
}
|
|
|
|
// 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)];
|
|
}
|
|
}
|
|
|
|
// 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 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,
|
|
...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 });
|
|
}
|
|
},
|
|
|
|
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)],
|
|
});
|
|
},
|
|
|
|
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);
|
|
},
|
|
};
|
|
}
|